diff --git a/.changeset/afraid-rings-call.md b/.changeset/afraid-rings-call.md new file mode 100644 index 0000000000000..62de2d8263a84 --- /dev/null +++ b/.changeset/afraid-rings-call.md @@ -0,0 +1,5 @@ +--- +'@rocket.chat/meteor': minor +--- + +Overrides the scrollbars auto hide behavior from hiding while not scrolling to hiding while not moving diff --git a/.changeset/bump-patch-1755818756486.md b/.changeset/bump-patch-1755818756486.md new file mode 100644 index 0000000000000..e1eaa7980afb1 --- /dev/null +++ b/.changeset/bump-patch-1755818756486.md @@ -0,0 +1,5 @@ +--- +'@rocket.chat/meteor': patch +--- + +Bump @rocket.chat/meteor version. diff --git a/.changeset/bump-patch-1755888089463.md b/.changeset/bump-patch-1755888089463.md new file mode 100644 index 0000000000000..e1eaa7980afb1 --- /dev/null +++ b/.changeset/bump-patch-1755888089463.md @@ -0,0 +1,5 @@ +--- +'@rocket.chat/meteor': patch +--- + +Bump @rocket.chat/meteor version. diff --git a/.changeset/bump-patch-1756434427386.md b/.changeset/bump-patch-1756434427386.md new file mode 100644 index 0000000000000..e1eaa7980afb1 --- /dev/null +++ b/.changeset/bump-patch-1756434427386.md @@ -0,0 +1,5 @@ +--- +'@rocket.chat/meteor': patch +--- + +Bump @rocket.chat/meteor version. diff --git a/.changeset/bump-patch-1756442566779.md b/.changeset/bump-patch-1756442566779.md new file mode 100644 index 0000000000000..e1eaa7980afb1 --- /dev/null +++ b/.changeset/bump-patch-1756442566779.md @@ -0,0 +1,5 @@ +--- +'@rocket.chat/meteor': patch +--- + +Bump @rocket.chat/meteor version. diff --git a/.changeset/bump-patch-1756480462983.md b/.changeset/bump-patch-1756480462983.md new file mode 100644 index 0000000000000..e1eaa7980afb1 --- /dev/null +++ b/.changeset/bump-patch-1756480462983.md @@ -0,0 +1,5 @@ +--- +'@rocket.chat/meteor': patch +--- + +Bump @rocket.chat/meteor version. diff --git a/.changeset/bump-patch-1756771825666.md b/.changeset/bump-patch-1756771825666.md new file mode 100644 index 0000000000000..e1eaa7980afb1 --- /dev/null +++ b/.changeset/bump-patch-1756771825666.md @@ -0,0 +1,5 @@ +--- +'@rocket.chat/meteor': patch +--- + +Bump @rocket.chat/meteor version. diff --git a/.changeset/chatty-feet-ring.md b/.changeset/chatty-feet-ring.md new file mode 100644 index 0000000000000..b955a3d298a69 --- /dev/null +++ b/.changeset/chatty-feet-ring.md @@ -0,0 +1,5 @@ +--- +'@rocket.chat/meteor': patch +--- + +Fixes an issue where audio and video messages would stop playing if left idle past their link expiration. Now the player automatically refreshes expired links so users can continue listening or watching without reloading the chat. diff --git a/.changeset/clean-flies-glow.md b/.changeset/clean-flies-glow.md new file mode 100644 index 0000000000000..b8f3413d1530e --- /dev/null +++ b/.changeset/clean-flies-glow.md @@ -0,0 +1,6 @@ +--- +"@rocket.chat/meteor": patch +"@rocket.chat/rest-typings": patch +--- + +Add OpenAPI support for the Rocket.Chat oauth-apps.create API endpoints by migrating to a modern chained route definition syntax and utilizing shared AJV schemas for validation to enhance API documentation and ensure type safety through response validation. diff --git a/.changeset/clever-trees-occur.md b/.changeset/clever-trees-occur.md new file mode 100644 index 0000000000000..7816309c20ea0 --- /dev/null +++ b/.changeset/clever-trees-occur.md @@ -0,0 +1,6 @@ +--- +"@rocket.chat/meteor": patch +"@rocket.chat/rest-typings": patch +--- + +Add OpenAPI support for the Rocket.Chat oauth-apps.update API endpoints by migrating to a modern chained route definition syntax and utilizing shared AJV schemas for validation to enhance API documentation and ensure type safety through response validation. diff --git a/.changeset/cold-spiders-act.md b/.changeset/cold-spiders-act.md new file mode 100644 index 0000000000000..6011adc699990 --- /dev/null +++ b/.changeset/cold-spiders-act.md @@ -0,0 +1,5 @@ +--- +'@rocket.chat/meteor': patch +--- + +Fixes an issue where custom room notification sounds were not applied. diff --git a/.changeset/dull-beers-ring.md b/.changeset/dull-beers-ring.md new file mode 100644 index 0000000000000..eae5772bc12d2 --- /dev/null +++ b/.changeset/dull-beers-ring.md @@ -0,0 +1,5 @@ +--- +'@rocket.chat/meteor': patch +--- + +Addresses an issue where video conference popups don't receive proper focus because FocusScope is mispositioned diff --git a/.changeset/eleven-buses-return.md b/.changeset/eleven-buses-return.md new file mode 100644 index 0000000000000..6ecf152827ced --- /dev/null +++ b/.changeset/eleven-buses-return.md @@ -0,0 +1,5 @@ +--- +"@rocket.chat/meteor": patch +--- + +Fixes an issue where rooms transferred to a department's queue could get stuck—marked as taken but with no agent assigned. \ No newline at end of file diff --git a/.changeset/fast-walls-turn.md b/.changeset/fast-walls-turn.md new file mode 100644 index 0000000000000..20f49328ba4b7 --- /dev/null +++ b/.changeset/fast-walls-turn.md @@ -0,0 +1,6 @@ +--- +"@rocket.chat/meteor": patch +"@rocket.chat/rest-typings": patch +--- + +Add OpenAPI support for the Rocket.Chat chat.pinMessage API endpoints by migrating to a modern chained route definition syntax and utilizing shared AJV schemas for validation to enhance API documentation and ensure type safety through response validation. \ No newline at end of file diff --git a/.changeset/few-dryers-repeat.md b/.changeset/few-dryers-repeat.md new file mode 100644 index 0000000000000..d22d669fc370f --- /dev/null +++ b/.changeset/few-dryers-repeat.md @@ -0,0 +1,7 @@ +--- +"@rocket.chat/meteor": patch +"@rocket.chat/model-typings": patch +"@rocket.chat/models": patch +--- + +Allows agents to set a default agent when the chat being transferred ends up in the queue diff --git a/.changeset/fifty-tools-walk.md b/.changeset/fifty-tools-walk.md new file mode 100644 index 0000000000000..a3339bc48c78d --- /dev/null +++ b/.changeset/fifty-tools-walk.md @@ -0,0 +1,6 @@ +--- +"@rocket.chat/meteor": patch +"@rocket.chat/rest-typings": patch +--- + +Add OpenAPI support for the Rocket.Chat e2e.setRoomKeyID endpoints by migrating to a modern chained route definition syntax and utilizing shared AJV schemas for validation to enhance API documentation and ensure type safety through response validation. diff --git a/.changeset/flat-hairs-rush.md b/.changeset/flat-hairs-rush.md new file mode 100644 index 0000000000000..e13e11f86696a --- /dev/null +++ b/.changeset/flat-hairs-rush.md @@ -0,0 +1,5 @@ +--- +"@rocket.chat/meteor": minor +--- + +Adds a "Clear Filters" Button to the App Logs Filter Contextual Bar diff --git a/.changeset/giant-toes-decide.md b/.changeset/giant-toes-decide.md new file mode 100644 index 0000000000000..fef7f852afb2d --- /dev/null +++ b/.changeset/giant-toes-decide.md @@ -0,0 +1,5 @@ +--- +"@rocket.chat/meteor": minor +--- + +Adds an endpoint to fetch a Outbound Comms Provider's metadata. diff --git a/.changeset/late-toes-remain.md b/.changeset/late-toes-remain.md new file mode 100644 index 0000000000000..47e19b7a77e0b --- /dev/null +++ b/.changeset/late-toes-remain.md @@ -0,0 +1,6 @@ +--- +'@rocket.chat/meteor': patch +'@rocket.chat/apps-engine': patch +--- + +Fixes an issue where app installation would fail if the app package contained JS syntax newer than 2017 diff --git a/.changeset/loud-chefs-complain.md b/.changeset/loud-chefs-complain.md new file mode 100644 index 0000000000000..ffbb840f48af2 --- /dev/null +++ b/.changeset/loud-chefs-complain.md @@ -0,0 +1,5 @@ +--- +"@rocket.chat/meteor": patch +--- + +Fixes `create-p` and `create-c` permissions not being applyed in teams creation diff --git a/.changeset/loud-wombats-cross.md b/.changeset/loud-wombats-cross.md new file mode 100644 index 0000000000000..5b2506394df3b --- /dev/null +++ b/.changeset/loud-wombats-cross.md @@ -0,0 +1,6 @@ +--- +'@rocket.chat/meteor': patch +--- + +Fixes an issue where the Encrypted toggle in the `Create Channel Modal` would change unexpectedly or become disabled after switching the Private or Broadcast options when E2E defaults are enabled. + diff --git a/.changeset/lovely-shirts-play.md b/.changeset/lovely-shirts-play.md new file mode 100644 index 0000000000000..3760e7c5c653e --- /dev/null +++ b/.changeset/lovely-shirts-play.md @@ -0,0 +1,6 @@ +--- +'@rocket.chat/rest-typings': patch +'@rocket.chat/meteor': patch +--- + +Fix an issue where the report exported in the App logs page would not consider the instance id filter diff --git a/.changeset/nice-experts-joke.md b/.changeset/nice-experts-joke.md new file mode 100644 index 0000000000000..e237385be8e1e --- /dev/null +++ b/.changeset/nice-experts-joke.md @@ -0,0 +1,5 @@ +--- +"@rocket.chat/meteor": patch +--- + +Fixes queued conversations not being sorted in real time based on the room's SLA policy diff --git a/.changeset/pink-games-shake.md b/.changeset/pink-games-shake.md new file mode 100644 index 0000000000000..7e616b0d9a13d --- /dev/null +++ b/.changeset/pink-games-shake.md @@ -0,0 +1,6 @@ +--- +"@rocket.chat/meteor": patch +"@rocket.chat/rest-typings": patch +--- + +Add OpenAPI support for the Rocket.Chat oauth-apps.delete API endpoints by migrating to a modern chained route definition syntax and utilizing shared AJV schemas for validation to enhance API documentation and ensure type safety through response validation. diff --git a/.changeset/polite-students-lick.md b/.changeset/polite-students-lick.md new file mode 100644 index 0000000000000..85dfdcf0cb77f --- /dev/null +++ b/.changeset/polite-students-lick.md @@ -0,0 +1,6 @@ +--- +'@rocket.chat/apps-engine': patch +'@rocket.chat/meteor': patch +--- + +Fixes an error on apps loading that would cause an unhandled promise rejection crash during startup in some cases diff --git a/.changeset/popular-bugs-hide.md b/.changeset/popular-bugs-hide.md new file mode 100644 index 0000000000000..fabdae3acb3fa --- /dev/null +++ b/.changeset/popular-bugs-hide.md @@ -0,0 +1,5 @@ +--- +"@rocket.chat/meteor": patch +--- + +Fixes issue preventing the Security Logs from being accessed using the Enhanced Navbar in feature preview diff --git a/.changeset/popular-cars-float.md b/.changeset/popular-cars-float.md new file mode 100644 index 0000000000000..658260a790fba --- /dev/null +++ b/.changeset/popular-cars-float.md @@ -0,0 +1,7 @@ +--- +'@rocket.chat/model-typings': patch +'@rocket.chat/models': patch +'@rocket.chat/meteor': patch +--- + +Fixes an issue with Omnichannel inquiries where multiple instances could take the same inquiry from the queue resulting in the same room being assined to multiple agents. diff --git a/.changeset/popular-trees-hunt.md b/.changeset/popular-trees-hunt.md new file mode 100644 index 0000000000000..2adaa5ce5df00 --- /dev/null +++ b/.changeset/popular-trees-hunt.md @@ -0,0 +1,5 @@ +--- +"@rocket.chat/ui-client": patch +--- + +Adds an annotation prop to the WizardActions component, enabling the display of a contextual description alongside the action buttons. diff --git a/.changeset/pre.json b/.changeset/pre.json new file mode 100644 index 0000000000000..d726deb452c05 --- /dev/null +++ b/.changeset/pre.json @@ -0,0 +1,130 @@ +{ + "mode": "pre", + "tag": "rc", + "initialVersions": { + "@rocket.chat/meteor": "7.10.0-develop", + "rocketchat-services": "2.0.24", + "@rocket.chat/uikit-playground": "0.6.24", + "@rocket.chat/account-service": "0.4.33", + "@rocket.chat/authorization-service": "0.4.33", + "@rocket.chat/ddp-streamer": "0.3.33", + "@rocket.chat/omnichannel-transcript": "0.4.33", + "@rocket.chat/presence-service": "0.4.33", + "@rocket.chat/queue-worker": "0.4.33", + "@rocket.chat/stream-hub-service": "0.4.33", + "@rocket.chat/license": "1.0.24", + "@rocket.chat/network-broker": "0.2.12", + "@rocket.chat/omni-core-ee": "0.0.1", + "@rocket.chat/omnichannel-services": "0.3.30", + "@rocket.chat/pdf-worker": "0.3.12", + "@rocket.chat/presence": "0.2.33", + "@rocket.chat/ui-theming": "0.4.3", + "@rocket.chat/account-utils": "0.0.2", + "@rocket.chat/agenda": "0.1.0", + "@rocket.chat/api-client": "0.2.33", + "@rocket.chat/apps": "0.5.12", + "@rocket.chat/apps-engine": "1.54.0", + "@rocket.chat/base64": "1.0.13", + "@rocket.chat/cas-validate": "0.0.3", + "@rocket.chat/core-services": "0.9.12", + "@rocket.chat/core-typings": "7.10.0-develop", + "@rocket.chat/cron": "0.1.33", + "@rocket.chat/ddp-client": "0.3.33", + "@rocket.chat/eslint-config": "0.7.0", + "@rocket.chat/favicon": "0.0.2", + "@rocket.chat/freeswitch": "1.2.20", + "@rocket.chat/fuselage-ui-kit": "21.0.0", + "@rocket.chat/gazzodown": "21.0.0", + "@rocket.chat/http-router": "7.9.0", + "@rocket.chat/i18n": "1.9.0", + "@rocket.chat/instance-status": "0.1.33", + "@rocket.chat/jest-presets": "0.0.1", + "@rocket.chat/jwt": "0.1.1", + "@rocket.chat/livechat": "1.23.4", + "@rocket.chat/log-format": "0.0.2", + "@rocket.chat/logger": "0.0.2", + "@rocket.chat/message-parser": "0.31.32", + "@rocket.chat/mock-providers": "0.2.12", + "@rocket.chat/model-typings": "1.6.12", + "@rocket.chat/models": "1.5.12", + "@rocket.chat/mongo-adapter": "0.0.2", + "@rocket.chat/poplib": "0.0.2", + "@rocket.chat/omni-core": "0.0.1", + "@rocket.chat/password-policies": "0.0.2", + "@rocket.chat/patch-injection": "0.0.1", + "@rocket.chat/peggy-loader": "0.31.27", + "@rocket.chat/random": "1.2.2", + "@rocket.chat/release-action": "2.2.3", + "@rocket.chat/release-changelog": "0.1.0", + "@rocket.chat/rest-typings": "7.10.0-develop", + "@rocket.chat/server-cloud-communication": "0.0.2", + "@rocket.chat/server-fetch": "0.0.3", + "@rocket.chat/sha256": "1.0.12", + "@rocket.chat/storybook-config": "0.0.1", + "@rocket.chat/tools": "0.2.3", + "@rocket.chat/tracing": "0.0.1", + "@rocket.chat/tsconfig": "0.0.0", + "@rocket.chat/ui-avatar": "17.0.0", + "@rocket.chat/ui-client": "21.0.0", + "@rocket.chat/ui-composer": "0.5.2", + "@rocket.chat/ui-contexts": "21.0.0", + "@rocket.chat/ui-kit": "0.37.0", + "@rocket.chat/ui-video-conf": "21.0.0", + "@rocket.chat/ui-voip": "11.0.0", + "@rocket.chat/web-ui-registration": "21.0.0", + "@rocket.chat/desktop-api": "0.0.1" + }, + "changesets": [ + "afraid-rings-call", + "bump-patch-1755818756486", + "bump-patch-1755888089463", + "bump-patch-1756434427386", + "bump-patch-1756442566779", + "bump-patch-1756480462983", + "bump-patch-1756771825666", + "chatty-feet-ring", + "clean-flies-glow", + "clever-trees-occur", + "cold-spiders-act", + "dull-beers-ring", + "eleven-buses-return", + "fast-walls-turn", + "few-dryers-repeat", + "fifty-tools-walk", + "flat-hairs-rush", + "giant-toes-decide", + "late-toes-remain", + "loud-chefs-complain", + "loud-wombats-cross", + "lovely-shirts-play", + "nice-experts-joke", + "pink-games-shake", + "polite-students-lick", + "popular-bugs-hide", + "popular-cars-float", + "popular-trees-hunt", + "pretty-geckos-lick", + "proud-drinks-crash", + "rare-fans-shake", + "selfish-dancers-study", + "seven-donuts-confess", + "seven-gorillas-sell", + "shy-vans-juggle", + "silly-laws-act", + "small-mangos-hang", + "soft-fishes-leave", + "soft-suns-sip", + "strange-stingrays-live", + "strange-worms-smoke", + "tall-scissors-boil", + "tame-stingrays-hug", + "thick-hotels-occur", + "thick-ravens-flow", + "thirty-experts-thank", + "tough-ravens-shop", + "tough-students-remain", + "twelve-plums-turn", + "twenty-wasps-attend", + "wild-kiwis-cover" + ] +} diff --git a/.changeset/pretty-geckos-lick.md b/.changeset/pretty-geckos-lick.md new file mode 100644 index 0000000000000..105fa7b5d9a23 --- /dev/null +++ b/.changeset/pretty-geckos-lick.md @@ -0,0 +1,5 @@ +--- +"@rocket.chat/meteor": patch +--- + +Fixes an issue that caused some types of messages to generate an empty thread preview diff --git a/.changeset/proud-drinks-crash.md b/.changeset/proud-drinks-crash.md new file mode 100644 index 0000000000000..d1c88e46cac2b --- /dev/null +++ b/.changeset/proud-drinks-crash.md @@ -0,0 +1,5 @@ +--- +"@rocket.chat/meteor": patch +--- + +fixes an issue where some apps that don't need permission would have grantedPermissions as null making it impossible to activate the app diff --git a/.changeset/rare-fans-shake.md b/.changeset/rare-fans-shake.md new file mode 100644 index 0000000000000..c6ef66f265e69 --- /dev/null +++ b/.changeset/rare-fans-shake.md @@ -0,0 +1,5 @@ +--- +'@rocket.chat/meteor': patch +--- + +Fixes some locale loading issues for date-time formatting functionality. diff --git a/.changeset/selfish-dancers-study.md b/.changeset/selfish-dancers-study.md new file mode 100644 index 0000000000000..55f7609600fb4 --- /dev/null +++ b/.changeset/selfish-dancers-study.md @@ -0,0 +1,5 @@ +--- +"@rocket.chat/meteor": patch +--- + +Fixes the `onlyMyDepartments` flag that some endpoints accept so it works more consistent across different user roles. Now, for monitors, `onlyMyDepartments` will include departments the user is serving as an agent. For agents, it will filter the ones the user is serving. There's no change for managers and admins, which can see anything. diff --git a/.changeset/seven-donuts-confess.md b/.changeset/seven-donuts-confess.md new file mode 100644 index 0000000000000..7926c2b699c5b --- /dev/null +++ b/.changeset/seven-donuts-confess.md @@ -0,0 +1,5 @@ +--- +'@rocket.chat/meteor': patch +--- + +Fixes an issue where user custom status text is being overwritten, causing it not being updated in real time diff --git a/.changeset/seven-gorillas-sell.md b/.changeset/seven-gorillas-sell.md new file mode 100644 index 0000000000000..0827b58f1e725 --- /dev/null +++ b/.changeset/seven-gorillas-sell.md @@ -0,0 +1,5 @@ +--- +"@rocket.chat/meteor": patch +--- + +Fixes order on featured room header actions diff --git a/.changeset/shy-vans-juggle.md b/.changeset/shy-vans-juggle.md new file mode 100644 index 0000000000000..681d269c1f965 --- /dev/null +++ b/.changeset/shy-vans-juggle.md @@ -0,0 +1,6 @@ +--- +"@rocket.chat/meteor": patch +"@rocket.chat/rest-typings": patch +--- + +Add OpenAPI support for the Rocket.Chat dm.delete/im.delete API endpoints by migrating to a modern chained route definition syntax and utilizing shared AJV schemas for validation to enhance API documentation and ensure type safety through response validation. diff --git a/.changeset/silly-laws-act.md b/.changeset/silly-laws-act.md new file mode 100644 index 0000000000000..d5bfbd9a7281d --- /dev/null +++ b/.changeset/silly-laws-act.md @@ -0,0 +1,5 @@ +--- +"@rocket.chat/http-router": patch +--- + +This change fixes the HTTP route validation to return the correct error message 'invalid-params' instead of 'error-invalid-params', ensuring consistency with our API error codes. The body validation should now return 'invalid-params' instead of 'error-invalid-params'.' diff --git a/.changeset/small-mangos-hang.md b/.changeset/small-mangos-hang.md new file mode 100644 index 0000000000000..75def9c2b46d4 --- /dev/null +++ b/.changeset/small-mangos-hang.md @@ -0,0 +1,6 @@ +--- +"@rocket.chat/meteor": minor +"@rocket.chat/apps-engine": minor +--- + +Creates a new endpoint that allows agents to send an outbound message from a registered app provider diff --git a/.changeset/soft-fishes-leave.md b/.changeset/soft-fishes-leave.md new file mode 100644 index 0000000000000..a67e13da83fba --- /dev/null +++ b/.changeset/soft-fishes-leave.md @@ -0,0 +1,5 @@ +--- +'@rocket.chat/meteor': patch +--- + +Fixes "View Logs" button not filtering logs by instance id diff --git a/.changeset/soft-suns-sip.md b/.changeset/soft-suns-sip.md new file mode 100644 index 0000000000000..7e2dcd51a9d55 --- /dev/null +++ b/.changeset/soft-suns-sip.md @@ -0,0 +1,6 @@ +--- +"@rocket.chat/meteor": minor +"@rocket.chat/i18n": minor +--- + +Adds a "Collapse All" button to the Apps Logs Filter and moves existing "Expand All" button to a kebab menu diff --git a/.changeset/strange-stingrays-live.md b/.changeset/strange-stingrays-live.md new file mode 100644 index 0000000000000..2c1e59661c42a --- /dev/null +++ b/.changeset/strange-stingrays-live.md @@ -0,0 +1,5 @@ +--- +"@rocket.chat/meteor": patch +--- + +Fixes scroll issue when moving between channels or DMs diff --git a/.changeset/strange-worms-smoke.md b/.changeset/strange-worms-smoke.md new file mode 100644 index 0000000000000..a0bb1db1030a5 --- /dev/null +++ b/.changeset/strange-worms-smoke.md @@ -0,0 +1,6 @@ +--- +"@rocket.chat/meteor": patch +"@rocket.chat/rest-typings": patch +--- + +Add OpenAPI support for the Rocket.Chat oauth-apps.list API endpoints by migrating to a modern chained route definition syntax and utilizing shared AJV schemas for validation to enhance API documentation and ensure type safety through response validation. diff --git a/.changeset/tall-scissors-boil.md b/.changeset/tall-scissors-boil.md new file mode 100644 index 0000000000000..f47d786607422 --- /dev/null +++ b/.changeset/tall-scissors-boil.md @@ -0,0 +1,7 @@ +--- +"@rocket.chat/meteor": patch +"@rocket.chat/core-typings": patch +"@rocket.chat/rest-typings": patch +--- + +Add OpenAPI support for the Rocket.Chat oauth-apps.get API endpoints by migrating to a modern chained route definition syntax and utilizing shared AJV schemas for validation to enhance API documentation and ensure type safety through response validation. diff --git a/.changeset/tame-stingrays-hug.md b/.changeset/tame-stingrays-hug.md new file mode 100644 index 0000000000000..5f648cddede43 --- /dev/null +++ b/.changeset/tame-stingrays-hug.md @@ -0,0 +1,5 @@ +--- +"@rocket.chat/meteor": patch +--- + +Fixes an issue where files containing exif data would fail to upload to S3 when `Message_Attachments_Strip_Exif` is enabled. diff --git a/.changeset/thick-hotels-occur.md b/.changeset/thick-hotels-occur.md new file mode 100644 index 0000000000000..22ab272bfad4b --- /dev/null +++ b/.changeset/thick-hotels-occur.md @@ -0,0 +1,5 @@ +--- +"@rocket.chat/meteor": patch +--- + +fixes an issue where bussines hours are not working on weekends when the timezone of bh slip into another day diff --git a/.changeset/thick-ravens-flow.md b/.changeset/thick-ravens-flow.md new file mode 100644 index 0000000000000..e0c6ee94ff8c1 --- /dev/null +++ b/.changeset/thick-ravens-flow.md @@ -0,0 +1,5 @@ +--- +"@rocket.chat/meteor": patch +--- + +fixes an issue where using `/v1/users.updateOwnBasicInfo`, the user was not be able to set the password (not change), even when required diff --git a/.changeset/thirty-experts-thank.md b/.changeset/thirty-experts-thank.md new file mode 100644 index 0000000000000..e8ec62448a0e7 --- /dev/null +++ b/.changeset/thirty-experts-thank.md @@ -0,0 +1,5 @@ +--- +'@rocket.chat/ui-client': minor +--- + +Adds Wizard component to ui-client package diff --git a/.changeset/tough-ravens-shop.md b/.changeset/tough-ravens-shop.md new file mode 100644 index 0000000000000..fddfc8d053399 --- /dev/null +++ b/.changeset/tough-ravens-shop.md @@ -0,0 +1,6 @@ +--- +"@rocket.chat/meteor": patch +"@rocket.chat/rest-typings": patch +--- + +Add OpenAPI support for the Rocket.Chat Permissions API endpoints by migrating to a centralized syntax and utilizing shared AJV schemas for validation. This will enhance API documentation and ensure type safety through response validation. diff --git a/.changeset/tough-students-remain.md b/.changeset/tough-students-remain.md new file mode 100644 index 0000000000000..af9051a4a2822 --- /dev/null +++ b/.changeset/tough-students-remain.md @@ -0,0 +1,5 @@ +--- +'@rocket.chat/desktop-api': major +--- + +Adds a new package (`@rocket.chat/desktop-api`) to interface the desktop app's injected context diff --git a/.changeset/twelve-plums-turn.md b/.changeset/twelve-plums-turn.md new file mode 100644 index 0000000000000..be2a4b031e153 --- /dev/null +++ b/.changeset/twelve-plums-turn.md @@ -0,0 +1,6 @@ +--- +'@rocket.chat/meteor': patch +'@rocket.chat/apps-engine': patch +--- + +Fixes an issue that would cause the chat server to crash with an unhandled rejection in some cases diff --git a/.changeset/twenty-wasps-attend.md b/.changeset/twenty-wasps-attend.md new file mode 100644 index 0000000000000..24e5bf723d59d --- /dev/null +++ b/.changeset/twenty-wasps-attend.md @@ -0,0 +1,15 @@ +--- +'@rocket.chat/mock-providers': minor +'@rocket.chat/core-services': minor +'@rocket.chat/model-typings': minor +'@rocket.chat/core-typings': minor +'@rocket.chat/rest-typings': minor +'@rocket.chat/ui-contexts': minor +'@rocket.chat/ui-client': minor +'@rocket.chat/models': minor +'@rocket.chat/i18n': minor +'@rocket.chat/meteor': minor +--- + +Introduces the side navigation with a new filtering system. The update adds new filters for All, Mentions, Favorites, and Discussions, as well as dedicated filters for Omnichannel conversations and grouping by Teams, Channels, and DMs. +> This change is being tested under `Enhanced navigation experience` feature preview, in order to check it you need to enabled it diff --git a/.changeset/wild-kiwis-cover.md b/.changeset/wild-kiwis-cover.md new file mode 100644 index 0000000000000..8bb6afeb72917 --- /dev/null +++ b/.changeset/wild-kiwis-cover.md @@ -0,0 +1,5 @@ +--- +"@rocket.chat/meteor": patch +--- + +Fixes a bug where the `/api/v1/users.update` API call was replacing the entire `customFields` object instead of merging only the specified properties. The fix ensures that when updating custom fields, existing values are preserved while only specified fields are updated or added. \ No newline at end of file diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 1014b5e9b9efc..b98cde5f50299 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -19,6 +19,10 @@ /apps/meteor/app/livechat @RocketChat/omnichannel /apps/meteor/app/voip @RocketChat/omnichannel /apps/meteor/app/sms @RocketChat/omnichannel +/apps/meteor/app/api @RocketChat/backend +/apps/meteor/app/federation @RocketChat/backend +/apps/meteor/app/file-upload @RocketChat/backend +/apps/meteor/app/integrations @RocketChat/backend /apps/meteor/server @RocketChat/backend /packages/models @RocketChat/Architecture apps/meteor/server/startup/migrations @RocketChat/Architecture diff --git a/.github/actions/update-version-durability/package-lock.json b/.github/actions/update-version-durability/package-lock.json index 080f6c1e3bf00..8f686758cf82a 100644 --- a/.github/actions/update-version-durability/package-lock.json +++ b/.github/actions/update-version-durability/package-lock.json @@ -228,6 +228,19 @@ "resolved": "https://registry.npmjs.org/before-after-hook/-/before-after-hook-3.0.2.tgz", "integrity": "sha512-Nik3Sc0ncrMK4UUdXQmAnRtzmNQTAAXmXIopizwZ1W1t8QmfJj+zL4OA2I7XPTPW5z5TDqv4hRo/JzouDJnX3A==" }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/colors": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/colors/-/colors-1.4.0.tgz", @@ -263,6 +276,65 @@ "node": ">=0.3.1" } }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/fast-content-type-parse": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/fast-content-type-parse/-/fast-content-type-parse-2.0.1.tgz", @@ -299,18 +371,127 @@ } }, "node_modules/form-data": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.0.tgz", - "integrity": "sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww==", + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.4.tgz", + "integrity": "sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==", + "license": "MIT", "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", "mime-types": "^2.1.12" }, "engines": { "node": ">= 6" } }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, "node_modules/mime-db": { "version": "1.52.0", "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", diff --git a/.github/workflows/ci-test-e2e.yml b/.github/workflows/ci-test-e2e.yml index c471bd3e04c26..84ebeca360068 100644 --- a/.github/workflows/ci-test-e2e.yml +++ b/.github/workflows/ci-test-e2e.yml @@ -154,6 +154,10 @@ jobs: setup: false NPM_TOKEN: ${{ secrets.NPM_TOKEN }} + - name: Set DEBUG_LOG_LEVEL (debug enabled) + if: runner.debug == '1' + run: echo "DEBUG_LOG_LEVEL=2" >> $GITHUB_ENV + - name: Start httpbin container and wait for it to be ready if: inputs.type == 'api' run: | @@ -184,7 +188,7 @@ jobs: MONGO_URL: 'mongodb://host.docker.internal:27017/rocketchat?replicaSet=rs0&directConnection=true' run: | # when we are testing CE, we only need to start the rocketchat container - docker compose -f docker-compose-ci.yml up -d rocketchat + DEBUG_LOG_LEVEL=${DEBUG_LOG_LEVEL:-0} docker compose -f docker-compose-ci.yml up -d rocketchat - name: Start containers for EE if: inputs.release == 'ee' @@ -196,7 +200,7 @@ jobs: COVERAGE_REPORTER: 'lcov' DISABLE_DB_WATCHERS: ${{ inputs.db-watcher-disabled }} run: | - docker compose -f docker-compose-ci.yml up -d + DEBUG_LOG_LEVEL=${DEBUG_LOG_LEVEL:-0} docker compose -f docker-compose-ci.yml up -d - uses: ./.github/actions/setup-playwright if: inputs.type == 'ui' diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index cf141728ad8f2..569cdece7dddd 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -186,45 +186,6 @@ jobs: overwrite: true include-hidden-files: true - deploy-preview: - name: 👀 Deploy Preview - runs-on: ubuntu-24.04 - needs: [release-versions, packages-build] - steps: - - uses: actions/checkout@v4 - - - uses: rharkor/caching-for-turbo@v1.8 - if: github.event.action != 'closed' - - - name: Setup NodeJS - uses: ./.github/actions/setup-node - if: github.event.action != 'closed' - with: - node-version: 22.16.0 - deno-version: 1.43.5 - cache-modules: true - install: true - - name: Restore turbo build - uses: actions/download-artifact@v4 - with: - name: turbo-build - path: .turbo/cache - - name: Build - if: github.event.action != 'closed' - run: | - yarn turbo run build-preview - yarn turbo run .:build-preview-move - npx indexifier .preview --html --extensions .html > .preview/index.html - - - name: Create preview for PR - uses: rossjrw/pr-preview-action@v1 - if: github.event.pull_request.head.repo.full_name == github.repository - with: - source-dir: .preview - preview-branch: gh-pages - umbrella-dir: pr-preview - action: auto - build: name: 📦 Meteor Build - coverage needs: [release-versions, packages-build] diff --git a/.github/workflows/release-candidate.yml b/.github/workflows/release-candidate.yml index 969afc141d184..4b77ec775e9ba 100644 --- a/.github/workflows/release-candidate.yml +++ b/.github/workflows/release-candidate.yml @@ -1,7 +1,7 @@ name: Release candidate cut on: schedule: - - cron: '28 12 20 * *' # run at minute 28 to avoid the chance of delay due to high load on GH + - cron: '28 17 20 * *' # run at minute 28 to avoid the chance of delay due to high load on GH jobs: new-release: runs-on: ubuntu-24.04 diff --git a/apps/meteor/.meteor/packages b/apps/meteor/.meteor/packages index dffc0b6aab071..7f6f660851cf2 100644 --- a/apps/meteor/.meteor/packages +++ b/apps/meteor/.meteor/packages @@ -1,9 +1,6 @@ # Meteor packages used by this project, one per line. - # - # 'meteor add' and 'meteor remove' will edit this file for you, - # but you can also edit it by hand. rocketchat:ddp @@ -11,7 +8,6 @@ rocketchat:mongo-config rocketchat:livechat rocketchat:streamer rocketchat:version -rocketchat:user-presence accounts-base@3.1.1 accounts-facebook@1.3.4 @@ -69,4 +65,3 @@ autoupdate@2.0.1 zodern:types zodern:standard-minifier-js -ostrio:flow-router-extra diff --git a/apps/meteor/.meteor/versions b/apps/meteor/.meteor/versions index 92b51b58db387..2c3b80cb7b7c1 100644 --- a/apps/meteor/.meteor/versions +++ b/apps/meteor/.meteor/versions @@ -62,7 +62,6 @@ oauth1@1.5.2 oauth2@1.3.3 ordered-dict@1.2.0 ostrio:cookies@2.7.2 -ostrio:flow-router-extra@3.11.0 promise@1.0.0 random@1.2.2 rate-limit@1.1.2 @@ -75,7 +74,6 @@ rocketchat:ddp@0.0.1 rocketchat:livechat@0.0.1 rocketchat:mongo-config@0.0.1 rocketchat:streamer@1.1.0 -rocketchat:user-presence@2.6.3 rocketchat:version@1.0.0 routepolicy@1.1.2 service-configuration@1.3.5 diff --git a/apps/meteor/CHANGELOG.md b/apps/meteor/CHANGELOG.md index f05a131c494a5..76e8eabeec836 100644 --- a/apps/meteor/CHANGELOG.md +++ b/apps/meteor/CHANGELOG.md @@ -1,6 +1,161 @@ # @rocket.chat/meteor -## 7.9.3 +## 7.10.0-rc.6 + +### Patch Changes + +- Bump @rocket.chat/meteor version. + +- ([#36815](https://github.com/RocketChat/Rocket.Chat/pull/36815)) Fixes queued conversations not being sorted in real time based on the room's SLA policy + +-
Updated dependencies []: + + - @rocket.chat/core-typings@7.10.0-rc.6 + - @rocket.chat/rest-typings@7.10.0-rc.6 + - @rocket.chat/license@1.0.28-rc.6 + - @rocket.chat/omnichannel-services@0.3.34-rc.6 + - @rocket.chat/pdf-worker@0.3.16-rc.6 + - @rocket.chat/presence@0.2.37-rc.6 + - @rocket.chat/api-client@0.2.37-rc.6 + - @rocket.chat/apps@0.5.16-rc.6 + - @rocket.chat/core-services@0.10.0-rc.6 + - @rocket.chat/cron@0.1.37-rc.6 + - @rocket.chat/freeswitch@1.2.24-rc.6 + - @rocket.chat/fuselage-ui-kit@22.0.0-rc.6 + - @rocket.chat/gazzodown@22.0.0-rc.6 + - @rocket.chat/http-router@7.9.4-rc.6 + - @rocket.chat/model-typings@1.7.0-rc.6 + - @rocket.chat/ui-avatar@18.0.0-rc.6 + - @rocket.chat/ui-client@22.0.0-rc.6 + - @rocket.chat/ui-contexts@22.0.0-rc.6 + - @rocket.chat/web-ui-registration@22.0.0-rc.6 + - @rocket.chat/models@1.6.0-rc.6 + - @rocket.chat/server-cloud-communication@0.0.2 + - @rocket.chat/network-broker@0.2.16-rc.6 + - @rocket.chat/ui-theming@0.4.3 + - @rocket.chat/ui-video-conf@22.0.0-rc.6 + - @rocket.chat/ui-voip@12.0.0-rc.6 + - @rocket.chat/omni-core-ee@0.0.2-rc.6 + - @rocket.chat/instance-status@0.1.37-rc.6 + - @rocket.chat/omni-core@0.0.2-rc.6 +
+ +## 7.10.0-rc.5 + +### Patch Changes + +- Bump @rocket.chat/meteor version. + +-
Updated dependencies []: + - @rocket.chat/core-typings@7.10.0-rc.5 + - @rocket.chat/rest-typings@7.10.0-rc.5 + - @rocket.chat/license@1.0.25-rc.5 + - @rocket.chat/omnichannel-services@0.3.31-rc.5 + - @rocket.chat/pdf-worker@0.3.13-rc.5 + - @rocket.chat/presence@0.2.34-rc.5 + - @rocket.chat/api-client@0.2.34-rc.5 + - @rocket.chat/apps@0.5.13-rc.5 + - @rocket.chat/core-services@0.10.0-rc.5 + - @rocket.chat/cron@0.1.34-rc.5 + - @rocket.chat/freeswitch@1.2.21-rc.5 + - @rocket.chat/fuselage-ui-kit@22.0.0-rc.5 + - @rocket.chat/gazzodown@22.0.0-rc.5 + - @rocket.chat/http-router@7.9.1-rc.5 + - @rocket.chat/model-typings@1.7.0-rc.5 + - @rocket.chat/ui-avatar@18.0.0-rc.5 + - @rocket.chat/ui-client@22.0.0-rc.5 + - @rocket.chat/ui-contexts@22.0.0-rc.5 + - @rocket.chat/web-ui-registration@22.0.0-rc.5 + - @rocket.chat/models@1.6.0-rc.5 + - @rocket.chat/server-cloud-communication@0.0.2 + - @rocket.chat/network-broker@0.2.13-rc.5 + - @rocket.chat/ui-theming@0.4.3 + - @rocket.chat/ui-video-conf@22.0.0-rc.5 + - @rocket.chat/ui-voip@12.0.0-rc.5 + - @rocket.chat/omni-core-ee@0.0.2-rc.5 + - @rocket.chat/instance-status@0.1.34-rc.5 + - @rocket.chat/omni-core@0.0.2-rc.5 +
+ +## 7.10.0-rc.4 + +### Patch Changes + +- Bump @rocket.chat/meteor version. + +-
Updated dependencies []: + + - @rocket.chat/core-typings@7.10.0-rc.4 + - @rocket.chat/rest-typings@7.10.0-rc.4 + - @rocket.chat/license@1.0.25-rc.4 + - @rocket.chat/omnichannel-services@0.3.31-rc.4 + - @rocket.chat/pdf-worker@0.3.13-rc.4 + - @rocket.chat/presence@0.2.34-rc.4 + - @rocket.chat/api-client@0.2.34-rc.4 + - @rocket.chat/apps@0.5.13-rc.4 + - @rocket.chat/core-services@0.10.0-rc.4 + - @rocket.chat/cron@0.1.34-rc.4 + - @rocket.chat/freeswitch@1.2.21-rc.4 + - @rocket.chat/fuselage-ui-kit@22.0.0-rc.4 + - @rocket.chat/gazzodown@22.0.0-rc.4 + - @rocket.chat/http-router@7.9.1-rc.4 + - @rocket.chat/model-typings@1.7.0-rc.4 + - @rocket.chat/ui-avatar@18.0.0-rc.4 + - @rocket.chat/ui-client@22.0.0-rc.4 + - @rocket.chat/ui-contexts@22.0.0-rc.4 + - @rocket.chat/web-ui-registration@22.0.0-rc.4 + - @rocket.chat/models@1.6.0-rc.4 + - @rocket.chat/server-cloud-communication@0.0.2 + - @rocket.chat/network-broker@0.2.13-rc.4 + - @rocket.chat/ui-theming@0.4.3 + - @rocket.chat/ui-video-conf@22.0.0-rc.4 + - @rocket.chat/ui-voip@12.0.0-rc.4 + - @rocket.chat/omni-core-ee@0.0.2-rc.4 + - @rocket.chat/instance-status@0.1.34-rc.4 + - @rocket.chat/omni-core@0.0.2-rc.4 +
+ +## 7.10.0-rc.3 + +### Patch Changes + +- Bump @rocket.chat/meteor version. +- ([#36802](https://github.com/RocketChat/Rocket.Chat/pull/36802)) Fixes an error on apps loading that would cause an unhandled promise rejection crash during startup in some cases + +-
Updated dependencies [128b228fcb0b2fda2967c88b07340be4b34a5470]: + + - @rocket.chat/apps-engine@1.55.0-rc.1 + - @rocket.chat/presence@0.2.34-rc.3 + - @rocket.chat/apps@0.5.13-rc.3 + - @rocket.chat/core-services@0.10.0-rc.3 + - @rocket.chat/core-typings@7.10.0-rc.3 + - @rocket.chat/fuselage-ui-kit@22.0.0-rc.3 + - @rocket.chat/rest-typings@7.10.0-rc.3 + - @rocket.chat/license@1.0.25-rc.3 + - @rocket.chat/omnichannel-services@0.3.31-rc.3 + - @rocket.chat/pdf-worker@0.3.13-rc.3 + - @rocket.chat/api-client@0.2.34-rc.3 + - @rocket.chat/cron@0.1.34-rc.3 + - @rocket.chat/freeswitch@1.2.21-rc.3 + - @rocket.chat/gazzodown@22.0.0-rc.3 + - @rocket.chat/http-router@7.9.1-rc.3 + - @rocket.chat/model-typings@1.7.0-rc.3 + - @rocket.chat/ui-avatar@18.0.0-rc.3 + - @rocket.chat/ui-client@22.0.0-rc.3 + - @rocket.chat/ui-contexts@22.0.0-rc.3 + - @rocket.chat/web-ui-registration@22.0.0-rc.3 + - @rocket.chat/models@1.6.0-rc.3 + - @rocket.chat/server-cloud-communication@0.0.2 + - @rocket.chat/network-broker@0.2.13-rc.3 + - @rocket.chat/ui-theming@0.4.3 + - @rocket.chat/ui-video-conf@22.0.0-rc.3 + - @rocket.chat/ui-voip@12.0.0-rc.3 + - @rocket.chat/omni-core-ee@0.0.2-rc.3 + - @rocket.chat/instance-status@0.1.34-rc.3 + - @rocket.chat/omni-core@0.0.2-rc.3 +
+ +## 7.10.0-rc.2 ### Patch Changes @@ -8,7 +163,202 @@ -
Updated dependencies []: - - @rocket.chat/core-typings@7.9.3 + - @rocket.chat/core-typings@7.10.0-rc.2 + - @rocket.chat/rest-typings@7.10.0-rc.2 + - @rocket.chat/license@1.0.25-rc.2 + - @rocket.chat/omnichannel-services@0.3.31-rc.2 + - @rocket.chat/pdf-worker@0.3.13-rc.2 + - @rocket.chat/presence@0.2.34-rc.2 + - @rocket.chat/api-client@0.2.34-rc.2 + - @rocket.chat/apps@0.5.13-rc.2 + - @rocket.chat/core-services@0.10.0-rc.2 + - @rocket.chat/cron@0.1.34-rc.2 + - @rocket.chat/freeswitch@1.2.21-rc.2 + - @rocket.chat/fuselage-ui-kit@22.0.0-rc.2 + - @rocket.chat/gazzodown@22.0.0-rc.2 + - @rocket.chat/http-router@7.9.1-rc.2 + - @rocket.chat/model-typings@1.7.0-rc.2 + - @rocket.chat/ui-avatar@18.0.0-rc.2 + - @rocket.chat/ui-client@22.0.0-rc.2 + - @rocket.chat/ui-contexts@22.0.0-rc.2 + - @rocket.chat/web-ui-registration@22.0.0-rc.2 + - @rocket.chat/models@1.6.0-rc.2 + - @rocket.chat/server-cloud-communication@0.0.2 + - @rocket.chat/network-broker@0.2.13-rc.2 + - @rocket.chat/ui-theming@0.4.3 + - @rocket.chat/ui-video-conf@22.0.0-rc.2 + - @rocket.chat/ui-voip@12.0.0-rc.2 + - @rocket.chat/omni-core-ee@0.0.2-rc.2 + - @rocket.chat/instance-status@0.1.34-rc.2 + - @rocket.chat/omni-core@0.0.2-rc.2 +
+ +## 7.10.0-rc.1 + +### Patch Changes + +- Bump @rocket.chat/meteor version. + +-
Updated dependencies []: + + - @rocket.chat/core-typings@7.10.0-rc.1 + - @rocket.chat/rest-typings@7.10.0-rc.1 + - @rocket.chat/license@1.0.25-rc.1 + - @rocket.chat/omnichannel-services@0.3.31-rc.1 + - @rocket.chat/pdf-worker@0.3.13-rc.1 + - @rocket.chat/presence@0.2.34-rc.1 + - @rocket.chat/api-client@0.2.34-rc.1 + - @rocket.chat/apps@0.5.13-rc.1 + - @rocket.chat/core-services@0.10.0-rc.1 + - @rocket.chat/cron@0.1.34-rc.1 + - @rocket.chat/freeswitch@1.2.21-rc.1 + - @rocket.chat/fuselage-ui-kit@22.0.0-rc.1 + - @rocket.chat/gazzodown@22.0.0-rc.1 + - @rocket.chat/http-router@7.9.1-rc.1 + - @rocket.chat/model-typings@1.7.0-rc.1 + - @rocket.chat/ui-avatar@18.0.0-rc.1 + - @rocket.chat/ui-client@22.0.0-rc.1 + - @rocket.chat/ui-contexts@22.0.0-rc.1 + - @rocket.chat/web-ui-registration@22.0.0-rc.1 + - @rocket.chat/models@1.6.0-rc.1 + - @rocket.chat/server-cloud-communication@0.0.2 + - @rocket.chat/network-broker@0.2.13-rc.1 + - @rocket.chat/ui-theming@0.4.3 + - @rocket.chat/ui-video-conf@22.0.0-rc.1 + - @rocket.chat/ui-voip@12.0.0-rc.1 + - @rocket.chat/omni-core-ee@0.0.2-rc.1 + - @rocket.chat/instance-status@0.1.34-rc.1 + - @rocket.chat/omni-core@0.0.2-rc.1 +
+ +## 7.10.0-rc.0 + +### Minor Changes + +- ([#36623](https://github.com/RocketChat/Rocket.Chat/pull/36623)) Overrides the scrollbars auto hide behavior from hiding while not scrolling to hiding while not moving + +- ([#36556](https://github.com/RocketChat/Rocket.Chat/pull/36556)) Adds a "Clear Filters" Button to the App Logs Filter Contextual Bar + +- ([#36424](https://github.com/RocketChat/Rocket.Chat/pull/36424)) Adds an endpoint to fetch a Outbound Comms Provider's metadata. + +- ([#36553](https://github.com/RocketChat/Rocket.Chat/pull/36553)) Creates a new endpoint that allows agents to send an outbound message from a registered app provider + +- ([#36558](https://github.com/RocketChat/Rocket.Chat/pull/36558)) Adds a "Collapse All" button to the Apps Logs Filter and moves existing "Expand All" button to a kebab menu + +- ([#36049](https://github.com/RocketChat/Rocket.Chat/pull/36049)) Introduces the side navigation with a new filtering system. The update adds new filters for All, Mentions, Favorites, and Discussions, as well as dedicated filters for Omnichannel conversations and grouping by Teams, Channels, and DMs. + > This change is being tested under `Enhanced navigation experience` feature preview, in order to check it you need to enabled it + +### Patch Changes + +- ([#36622](https://github.com/RocketChat/Rocket.Chat/pull/36622)) Fixes an issue where audio and video messages would stop playing if left idle past their link expiration. Now the player automatically refreshes expired links so users can continue listening or watching without reloading the chat. + +- ([#36507](https://github.com/RocketChat/Rocket.Chat/pull/36507) by [@ahmed-n-abdeltwab](https://github.com/ahmed-n-abdeltwab)) Add OpenAPI support for the Rocket.Chat oauth-apps.create API endpoints by migrating to a modern chained route definition syntax and utilizing shared AJV schemas for validation to enhance API documentation and ensure type safety through response validation. + +- ([#36585](https://github.com/RocketChat/Rocket.Chat/pull/36585) by [@ahmed-n-abdeltwab](https://github.com/ahmed-n-abdeltwab)) Add OpenAPI support for the Rocket.Chat oauth-apps.update API endpoints by migrating to a modern chained route definition syntax and utilizing shared AJV schemas for validation to enhance API documentation and ensure type safety through response validation. + +- ([#36703](https://github.com/RocketChat/Rocket.Chat/pull/36703)) Fixes an issue where custom room notification sounds were not applied. + +- ([#36738](https://github.com/RocketChat/Rocket.Chat/pull/36738)) Addresses an issue where video conference popups don't receive proper focus because FocusScope is mispositioned + +- ([#36577](https://github.com/RocketChat/Rocket.Chat/pull/36577)) Fixes an issue where rooms transferred to a department's queue could get stuck—marked as taken but with no agent assigned. + +- ([#36020](https://github.com/RocketChat/Rocket.Chat/pull/36020) by [@ahmed-n-abdeltwab](https://github.com/ahmed-n-abdeltwab)) Add OpenAPI support for the Rocket.Chat chat.pinMessage API endpoints by migrating to a modern chained route definition syntax and utilizing shared AJV schemas for validation to enhance API documentation and ensure type safety through response validation. + +- ([#36617](https://github.com/RocketChat/Rocket.Chat/pull/36617)) Allows agents to set a default agent when the chat being transferred ends up in the queue + +- ([#36716](https://github.com/RocketChat/Rocket.Chat/pull/36716) by [@ahmed-n-abdeltwab](https://github.com/ahmed-n-abdeltwab)) Add OpenAPI support for the Rocket.Chat e2e.setRoomKeyID endpoints by migrating to a modern chained route definition syntax and utilizing shared AJV schemas for validation to enhance API documentation and ensure type safety through response validation. + +- ([#36625](https://github.com/RocketChat/Rocket.Chat/pull/36625)) Fixes an issue where app installation would fail if the app package contained JS syntax newer than 2017 + +- ([#36592](https://github.com/RocketChat/Rocket.Chat/pull/36592)) Fixes `create-p` and `create-c` permissions not being applyed in teams creation + +- ([#36714](https://github.com/RocketChat/Rocket.Chat/pull/36714)) Fixes an issue where the Encrypted toggle in the `Create Channel Modal` would change unexpectedly or become disabled after switching the Private or Broadcast options when E2E defaults are enabled. + +- ([#36611](https://github.com/RocketChat/Rocket.Chat/pull/36611)) Fix an issue where the report exported in the App logs page would not consider the instance id filter + +- ([#36606](https://github.com/RocketChat/Rocket.Chat/pull/36606) by [@ahmed-n-abdeltwab](https://github.com/ahmed-n-abdeltwab)) Add OpenAPI support for the Rocket.Chat oauth-apps.delete API endpoints by migrating to a modern chained route definition syntax and utilizing shared AJV schemas for validation to enhance API documentation and ensure type safety through response validation. + +- ([#36080](https://github.com/RocketChat/Rocket.Chat/pull/36080)) Fixes issue preventing the Security Logs from being accessed using the Enhanced Navbar in feature preview + +- ([#36749](https://github.com/RocketChat/Rocket.Chat/pull/36749)) Fixes an issue with Omnichannel inquiries where multiple instances could take the same inquiry from the queue resulting in the same room being assined to multiple agents. + +- ([#36527](https://github.com/RocketChat/Rocket.Chat/pull/36527)) Fixes an issue that caused some types of messages to generate an empty thread preview + +- ([#36672](https://github.com/RocketChat/Rocket.Chat/pull/36672)) fixes an issue where some apps that don't need permission would have grantedPermissions as null making it impossible to activate the app + +- ([#36651](https://github.com/RocketChat/Rocket.Chat/pull/36651)) Fixes some locale loading issues for date-time formatting functionality. + +- ([#36747](https://github.com/RocketChat/Rocket.Chat/pull/36747)) Fixes the `onlyMyDepartments` flag that some endpoints accept so it works more consistent across different user roles. Now, for monitors, `onlyMyDepartments` will include departments the user is serving as an agent. For agents, it will filter the ones the user is serving. There's no change for managers and admins, which can see anything. + +- ([#36601](https://github.com/RocketChat/Rocket.Chat/pull/36601)) Fixes an issue where user custom status text is being overwritten, causing it not being updated in real time + +- ([#36591](https://github.com/RocketChat/Rocket.Chat/pull/36591)) Fixes order on featured room header actions + +- ([#36677](https://github.com/RocketChat/Rocket.Chat/pull/36677) by [@ahmed-n-abdeltwab](https://github.com/ahmed-n-abdeltwab)) Add OpenAPI support for the Rocket.Chat dm.delete/im.delete API endpoints by migrating to a modern chained route definition syntax and utilizing shared AJV schemas for validation to enhance API documentation and ensure type safety through response validation. + +- ([#36569](https://github.com/RocketChat/Rocket.Chat/pull/36569)) Fixes "View Logs" button not filtering logs by instance id + +- ([#36662](https://github.com/RocketChat/Rocket.Chat/pull/36662)) Fixes scroll issue when moving between channels or DMs + +- ([#36586](https://github.com/RocketChat/Rocket.Chat/pull/36586) by [@ahmed-n-abdeltwab](https://github.com/ahmed-n-abdeltwab)) Add OpenAPI support for the Rocket.Chat oauth-apps.list API endpoints by migrating to a modern chained route definition syntax and utilizing shared AJV schemas for validation to enhance API documentation and ensure type safety through response validation. + +- ([#36598](https://github.com/RocketChat/Rocket.Chat/pull/36598) by [@ahmed-n-abdeltwab](https://github.com/ahmed-n-abdeltwab)) Add OpenAPI support for the Rocket.Chat oauth-apps.get API endpoints by migrating to a modern chained route definition syntax and utilizing shared AJV schemas for validation to enhance API documentation and ensure type safety through response validation. + +- ([#36676](https://github.com/RocketChat/Rocket.Chat/pull/36676)) Fixes an issue where files containing exif data would fail to upload to S3 when `Message_Attachments_Strip_Exif` is enabled. + +- ([#36544](https://github.com/RocketChat/Rocket.Chat/pull/36544)) fixes an issue where bussines hours are not working on weekends when the timezone of bh slip into another day + +- ([#36627](https://github.com/RocketChat/Rocket.Chat/pull/36627)) fixes an issue where using `/v1/users.updateOwnBasicInfo`, the user was not be able to set the password (not change), even when required + +- ([#35985](https://github.com/RocketChat/Rocket.Chat/pull/35985) by [@ahmed-n-abdeltwab](https://github.com/ahmed-n-abdeltwab)) Add OpenAPI support for the Rocket.Chat Permissions API endpoints by migrating to a centralized syntax and utilizing shared AJV schemas for validation. This will enhance API documentation and ensure type safety through response validation. + +- ([#36670](https://github.com/RocketChat/Rocket.Chat/pull/36670)) Fixes an issue that would cause the chat server to crash with an unhandled rejection in some cases + +- ([#36578](https://github.com/RocketChat/Rocket.Chat/pull/36578)) Fixes a bug where the `/api/v1/users.update` API call was replacing the entire `customFields` object instead of merging only the specified properties. The fix ensures that when updating custom fields, existing values are preserved while only specified fields are updated or added. + +-
Updated dependencies [f040b27ff67c31188026a0aed9ba1e9c4f717f08, a54f8837338246842585d037a0d0327a79245811, c5f0be15b31d1de03256f74bd277ad4ab753ada2, b25f05acd07762387fa45d67a1241b982c192f5d, c86fbce9b44942662dc25a599fc12b009fd40a74, 22498de4e9de4467642f637d00cc8344ba876987, 2fea1a79b831999f148505b9442cd584e1b06d09, 2f162a0dca79274d4458a5853afe64c506a7554f, fd32867fd4949bc2951a22075498ccb551cc6bbc, 759b178946951f10dfcf0c9daf0f45aceb422998, 1ca92c346e45486e9b6afc66566ae38fac65b48f, 580a3c945252666b3b477e1b626ea1001de6f456, c0c8919723c8d1242973625d15db74c994318460, 8942187a9b062be3aaac8fee4b576dcad467641e, 5d7dec3a68f7281b4b4531fa708d7fc7589a863c, 17bca96ecbf23ea807aba2e6e8abc95ebd66b0d0, a1c99dfe7bdee81e85164155e61b94b55dcbb752, dc6acda84bf7452d96f375be3cd97748ed016bfc, 42979690f3880d3c700582b7892020e37bc82be3, c7db598e9f3c2ad47f6a6be2a9ba7078533c245b]: + + - @rocket.chat/rest-typings@7.10.0-rc.0 + - @rocket.chat/model-typings@1.7.0-rc.0 + - @rocket.chat/models@1.6.0-rc.0 + - @rocket.chat/apps-engine@1.55.0-rc.0 + - @rocket.chat/ui-client@22.0.0-rc.0 + - @rocket.chat/http-router@7.9.1-rc.0 + - @rocket.chat/i18n@1.10.0-rc.0 + - @rocket.chat/core-typings@7.10.0-rc.0 + - @rocket.chat/core-services@0.10.0-rc.0 + - @rocket.chat/ui-contexts@22.0.0-rc.0 + - @rocket.chat/omnichannel-services@0.3.31-rc.0 + - @rocket.chat/presence@0.2.34-rc.0 + - @rocket.chat/api-client@0.2.34-rc.0 + - @rocket.chat/web-ui-registration@22.0.0-rc.0 + - @rocket.chat/apps@0.5.13-rc.0 + - @rocket.chat/omni-core-ee@0.0.2-rc.0 + - @rocket.chat/cron@0.1.34-rc.0 + - @rocket.chat/instance-status@0.1.34-rc.0 + - @rocket.chat/omni-core@0.0.2-rc.0 + - @rocket.chat/fuselage-ui-kit@22.0.0-rc.0 + - @rocket.chat/gazzodown@22.0.0-rc.0 + - @rocket.chat/ui-voip@12.0.0-rc.0 + - @rocket.chat/license@1.0.25-rc.0 + - @rocket.chat/pdf-worker@0.3.13-rc.0 + - @rocket.chat/freeswitch@1.2.21-rc.0 + - @rocket.chat/ui-avatar@18.0.0-rc.0 + - @rocket.chat/network-broker@0.2.13-rc.0 + - @rocket.chat/ui-theming@0.4.3 + - @rocket.chat/ui-video-conf@22.0.0-rc.0 + - @rocket.chat/server-cloud-communication@0.0.2 + +
+ +## 7.9.3 + +### Patch Changes + +- Bump @rocket.chat/meteor version. + +-
Updated dependencies []: +- @rocket.chat/core-typings@7.9.3 - @rocket.chat/rest-typings@7.9.3 - @rocket.chat/license@1.0.27 - @rocket.chat/omnichannel-services@0.3.33 @@ -41,7 +391,6 @@ ### Patch Changes - Bump @rocket.chat/meteor version. - - Bump @rocket.chat/meteor version. - ([#36680](https://github.com/RocketChat/Rocket.Chat/pull/36680) by [@dionisio-bot](https://github.com/dionisio-bot)) fixes an issue where some apps that don't need permission would have grantedPermissions as null making it impossible to activate the app @@ -81,7 +430,6 @@ ### Patch Changes - Bump @rocket.chat/meteor version. - - Bump @rocket.chat/meteor version. - ([#36581](https://github.com/RocketChat/Rocket.Chat/pull/36581) by [@dionisio-bot](https://github.com/dionisio-bot)) Fixes an issue where rooms transferred to a department's queue could get stuck—marked as taken but with no agent assigned. @@ -116,6 +464,7 @@ - @rocket.chat/ui-video-conf@21.0.1 - @rocket.chat/ui-voip@11.0.1 - @rocket.chat/instance-status@0.1.34 +
## 7.9.0 diff --git a/apps/meteor/app/api/server/api.helpers.ts b/apps/meteor/app/api/server/api.helpers.ts index 365e507016857..5478b1bcaeb47 100644 --- a/apps/meteor/app/api/server/api.helpers.ts +++ b/apps/meteor/app/api/server/api.helpers.ts @@ -1,6 +1,8 @@ import type { IUser } from '@rocket.chat/core-typings'; +import type { ActionThis } from './definition'; import { hasAllPermissionAsync, hasAtLeastOnePermissionAsync } from '../../authorization/server/functions/hasPermission'; +import type { DeprecationLoggerNextPlannedVersion } from '../../lib/server/lib/deprecationWarningLogger'; import { apiDeprecationLogger } from '../../lib/server/lib/deprecationWarningLogger'; type RequestMethod = 'GET' | 'POST' | 'PUT' | 'DELETE' | '*'; @@ -103,7 +105,10 @@ export function checkPermissions(options: { permissionsRequired?: PermissionsReq return false; } -export function parseDeprecation(methodThis: any, { alternatives, version }: { version: string; alternatives?: string[] }): void { +export function parseDeprecation( + methodThis: ActionThis, + { alternatives, version }: { version: DeprecationLoggerNextPlannedVersion; alternatives?: string[] }, +): void { const infoMessage = alternatives?.length ? ` Please use the alternative(s): ${alternatives.join(',')}` : ''; - apiDeprecationLogger.endpoint(methodThis.request.route, version, methodThis.response, infoMessage); + apiDeprecationLogger.endpoint(methodThis.route, version, methodThis.response, infoMessage); } diff --git a/apps/meteor/app/api/server/definition.ts b/apps/meteor/app/api/server/definition.ts index b394c5b94428c..029c3ac223391 100644 --- a/apps/meteor/app/api/server/definition.ts +++ b/apps/meteor/app/api/server/definition.ts @@ -4,6 +4,7 @@ import type { Method, MethodOf, OperationParams, OperationResult, PathPattern, U import type { ValidateFunction } from 'ajv'; import type { ITwoFactorOptions } from '../../2fa/server/code'; +import type { DeprecationLoggerNextPlannedVersion } from '../../lib/server/lib/deprecationWarningLogger'; export type SuccessStatusCodes = Exclude, Range<200>>; @@ -136,8 +137,8 @@ export type SharedOptions = ( validateParams?: ValidateFunction | { [key in TMethod]?: ValidateFunction }; authOrAnonRequired?: true; deprecation?: { - version: string; - alternatives?: string[]; + version: DeprecationLoggerNextPlannedVersion; + alternatives?: PathPattern[]; }; }; @@ -155,7 +156,7 @@ export type PartialThis = { readonly route: string; }; -type ActionThis = { +export type ActionThis = { route: string; readonly requestIp: string; urlParams: UrlParams; @@ -302,25 +303,35 @@ export type TypedThis bodyParams: TOptions['body'] extends ValidateFunction ? Body : never; requestIp?: string; + route: string; + response: Response; }; type PromiseOrValue = T | Promise; type InferResult = TResult extends ValidateFunction ? T : TResult; +type InferNon200Result = + InferResult extends { + success: false; + error?: infer TError; + } + ? TError + : never; + type Results = { [K in keyof TResponse]: K extends SuccessStatusCodes ? SuccessResult, K> : K extends RedirectStatusCodes - ? RedirectResult, K> + ? RedirectResult, K> : K extends 400 ? FailureResult> : K extends 401 - ? UnauthorizedResult> + ? UnauthorizedResult> : K extends 403 - ? ForbiddenResult> + ? ForbiddenResult> : K extends 404 - ? NotFoundResult> + ? NotFoundResult> : K extends ErrorStatusCodes ? InternalError, K> : never; diff --git a/apps/meteor/app/api/server/v1/banners.ts b/apps/meteor/app/api/server/v1/banners.ts index 598497fcb837e..cecf9d4d9bad1 100644 --- a/apps/meteor/app/api/server/v1/banners.ts +++ b/apps/meteor/app/api/server/v1/banners.ts @@ -51,7 +51,11 @@ import { API } from '../api'; */ API.v1.addRoute( 'banners.getNew', - { authRequired: true, validateParams: isBannersGetNewProps, deprecation: { version: '8.0.0', alternatives: ['banners/:id', 'banners'] } }, + { + authRequired: true, + validateParams: isBannersGetNewProps, + deprecation: { version: '8.0.0', alternatives: ['/v1/banners/:id', '/v1/banners'] }, + }, { // deprecated async get() { diff --git a/apps/meteor/app/api/server/v1/channels.ts b/apps/meteor/app/api/server/v1/channels.ts index c81a77c6ef41c..77d19384fe3fe 100644 --- a/apps/meteor/app/api/server/v1/channels.ts +++ b/apps/meteor/app/api/server/v1/channels.ts @@ -466,11 +466,11 @@ API.v1.addRoute( return API.v1.forbidden(); } - const moderators = ( - await Subscriptions.findByRoomIdAndRoles(findResult._id, ['moderator'], { - projection: { u: 1 }, - }).toArray() - ).map((sub: ISubscription) => sub.u); + const moderators = await Subscriptions.findByRoomIdAndRoles(findResult._id, ['moderator'], { + projection: { u: 1, _id: 0 }, + }) + .map((sub) => sub.u) + .toArray(); return API.v1.success({ moderators, diff --git a/apps/meteor/app/api/server/v1/chat.ts b/apps/meteor/app/api/server/v1/chat.ts index d22c9de0279e8..525f19ad0bf94 100644 --- a/apps/meteor/app/api/server/v1/chat.ts +++ b/apps/meteor/app/api/server/v1/chat.ts @@ -2,6 +2,7 @@ import { Message } from '@rocket.chat/core-services'; import type { IMessage, IThreadMainMessage } from '@rocket.chat/core-typings'; import { Messages, Users, Rooms, Subscriptions } from '@rocket.chat/models'; import { + ajv, isChatReportMessageProps, isChatGetURLPreviewProps, isChatUpdateProps, @@ -9,7 +10,6 @@ import { isChatDeleteProps, isChatSyncMessagesProps, isChatGetMessageProps, - isChatPinMessageProps, isChatPostMessageProps, isChatSearchProps, isChatSendMessageProps, @@ -29,6 +29,8 @@ import { isChatSyncThreadMessagesProps, isChatGetStarredMessagesProps, isChatGetDiscussionsProps, + validateBadRequestErrorResponse, + validateUnauthorizedErrorResponse, } from '@rocket.chat/rest-typings'; import { escapeRegExp } from '@rocket.chat/string-helpers'; import { Meteor } from 'meteor/meteor'; @@ -56,6 +58,7 @@ import { followMessage } from '../../../threads/server/methods/followMessage'; import { unfollowMessage } from '../../../threads/server/methods/unfollowMessage'; import { MessageTypes } from '../../../ui-utils/server'; import { normalizeMessagesForUser } from '../../../utils/server/lib/normalizeMessagesForUser'; +import type { ExtractRoutesFromAPI } from '../ApiClass'; import { API } from '../api'; import { getPaginationItems } from '../helpers/getPaginationItems'; import { findDiscussionsFromRoom, findMentionedMessages, findStarredMessages } from '../lib/messages'; @@ -172,25 +175,60 @@ API.v1.addRoute( }, ); -API.v1.addRoute( +type ChatPinMessage = { + messageId: IMessage['_id']; +}; + +const ChatPinMessageSchema = { + type: 'object', + properties: { + messageId: { + type: 'string', + minLength: 1, + }, + }, + required: ['messageId'], + additionalProperties: false, +}; + +const isChatPinMessageProps = ajv.compile(ChatPinMessageSchema); + +const chatPinMessageEndpoints = API.v1.post( 'chat.pinMessage', - { authRequired: true, validateParams: isChatPinMessageProps }, { - async post() { - const msg = await Messages.findOneById(this.bodyParams.messageId); + authRequired: true, + body: isChatPinMessageProps, + response: { + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + 200: ajv.compile<{ message: IMessage }>({ + type: 'object', + properties: { + message: { $ref: '#/components/schemas/IMessage' }, + success: { + type: 'boolean', + enum: [true], + }, + }, + required: ['message', 'success'], + additionalProperties: false, + }), + }, + }, + async function action() { + const msg = await Messages.findOneById(this.bodyParams.messageId); - if (!msg) { - throw new Meteor.Error('error-message-not-found', 'The provided "messageId" does not match any existing message.'); - } + if (!msg) { + throw new Meteor.Error('error-message-not-found', 'The provided "messageId" does not match any existing message.'); + } - const pinnedMessage = await pinMessage(msg, this.userId); + const pinnedMessage = await pinMessage(msg, this.userId); - const [message] = await normalizeMessagesForUser([pinnedMessage], this.userId); + const [message] = await normalizeMessagesForUser([pinnedMessage], this.userId); - return API.v1.success({ - message, - }); - }, + return API.v1.success({ + message, + }); }, ); @@ -845,3 +883,12 @@ API.v1.addRoute( }, }, ); + +type ChatPinMessageEndpoints = ExtractRoutesFromAPI; + +export type ChatEndpoints = ChatPinMessageEndpoints; + +declare module '@rocket.chat/rest-typings' { + // eslint-disable-next-line @typescript-eslint/naming-convention, @typescript-eslint/no-empty-interface + interface Endpoints extends ChatPinMessageEndpoints {} +} diff --git a/apps/meteor/app/api/server/v1/e2e.ts b/apps/meteor/app/api/server/v1/e2e.ts index a8a5e9175d876..3686f2a7b9007 100644 --- a/apps/meteor/app/api/server/v1/e2e.ts +++ b/apps/meteor/app/api/server/v1/e2e.ts @@ -1,7 +1,9 @@ import { Subscriptions, Users } from '@rocket.chat/models'; import { + ajv, + validateUnauthorizedErrorResponse, + validateBadRequestErrorResponse, ise2eGetUsersOfRoomWithoutKeyParamsGET, - ise2eSetRoomKeyIDParamsPOST, ise2eSetUserPublicAndPrivateKeysParamsPOST, ise2eUpdateGroupKeyParamsPOST, isE2EProvideUsersGroupKeyProps, @@ -20,12 +22,61 @@ import { setRoomKeyIDMethod } from '../../../e2e/server/methods/setRoomKeyID'; import { setUserPublicAndPrivateKeysMethod } from '../../../e2e/server/methods/setUserPublicAndPrivateKeys'; import { updateGroupKey } from '../../../e2e/server/methods/updateGroupKey'; import { settings } from '../../../settings/server'; +import type { ExtractRoutesFromAPI } from '../ApiClass'; import { API } from '../api'; // After 10s the room lock will expire, meaning that if for some reason the process never completed // The next reset will be available 10s after const LockMap = new ExpiryMap(10000); +type E2eSetRoomKeyIdProps = { + rid: string; + keyID: string; +}; + +const E2eSetRoomKeyIdSchema = { + type: 'object', + properties: { + rid: { + type: 'string', + }, + keyID: { + type: 'string', + }, + }, + required: ['rid', 'keyID'], + additionalProperties: false, +}; + +const isE2eSetRoomKeyIdProps = ajv.compile(E2eSetRoomKeyIdSchema); + +const e2eEndpoints = API.v1.post( + 'e2e.setRoomKeyID', + { + authRequired: true, + body: isE2eSetRoomKeyIdProps, + response: { + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + 200: ajv.compile({ + type: 'object', + properties: { + success: { type: 'boolean', enum: [true] }, + }, + required: ['success'], + }), + }, + }, + + async function action() { + const { rid, keyID } = this.bodyParams; + + await setRoomKeyIDMethod(this.userId, rid, keyID); + + return API.v1.success(); + }, +); + API.v1.addRoute( 'e2e.fetchMyKeys', { @@ -57,55 +108,6 @@ API.v1.addRoute( }, ); -/** - * @openapi - * /api/v1/e2e.setRoomKeyID: - * post: - * description: Sets the end-to-end encryption key ID for a room - * security: - * - autenticated: {} - * requestBody: - * description: A tuple containing the room ID and the key ID - * content: - * application/json: - * schema: - * type: object - * properties: - * rid: - * type: string - * keyID: - * type: string - * responses: - * 200: - * content: - * application/json: - * schema: - * $ref: '#/components/schemas/ApiSuccessV1' - * default: - * description: Unexpected error - * content: - * application/json: - * schema: - * $ref: '#/components/schemas/ApiFailureV1' - */ - -API.v1.addRoute( - 'e2e.setRoomKeyID', - { - authRequired: true, - validateParams: ise2eSetRoomKeyIDParamsPOST, - }, - { - async post() { - const { rid, keyID } = this.bodyParams; - - await setRoomKeyIDMethod(this.userId, rid, keyID); - - return API.v1.success(); - }, - }, -); - /** * @openapi * /api/v1/e2e.setUserPublicAndPrivateKeys: @@ -323,3 +325,10 @@ API.v1.addRoute( }, }, ); + +export type E2eEndpoints = ExtractRoutesFromAPI; + +declare module '@rocket.chat/rest-typings' { + // eslint-disable-next-line @typescript-eslint/naming-convention, @typescript-eslint/no-empty-interface + interface Endpoints extends E2eEndpoints {} +} diff --git a/apps/meteor/app/api/server/v1/groups.ts b/apps/meteor/app/api/server/v1/groups.ts index 8b9e0ef7b9529..20a11b5268d70 100644 --- a/apps/meteor/app/api/server/v1/groups.ts +++ b/apps/meteor/app/api/server/v1/groups.ts @@ -1215,11 +1215,11 @@ API.v1.addRoute( userId: this.userId, }); - const moderators = ( - await Subscriptions.findByRoomIdAndRoles(findResult.rid, ['moderator'], { - projection: { u: 1 }, - }).toArray() - ).map((sub: any) => sub.u); + const moderators = await Subscriptions.findByRoomIdAndRoles(findResult.rid, ['moderator'], { + projection: { u: 1, _id: 0 }, + }) + .map((sub) => sub.u) + .toArray(); return API.v1.success({ moderators, diff --git a/apps/meteor/app/api/server/v1/im.ts b/apps/meteor/app/api/server/v1/im.ts index 29f68a31bad59..4cf7c5f242b28 100644 --- a/apps/meteor/app/api/server/v1/im.ts +++ b/apps/meteor/app/api/server/v1/im.ts @@ -4,7 +4,9 @@ import type { IMessage, IRoom, ISubscription, IUser } from '@rocket.chat/core-typings'; import { Subscriptions, Uploads, Messages, Rooms, Users } from '@rocket.chat/models'; import { - isDmDeleteProps, + ajv, + validateUnauthorizedErrorResponse, + validateBadRequestErrorResponse, isDmFileProps, isDmMemberProps, isDmMessagesProps, @@ -26,7 +28,9 @@ import { getRoomByNameOrIdWithOptionToJoin } from '../../../lib/server/functions import { getChannelHistory } from '../../../lib/server/methods/getChannelHistory'; import { settings } from '../../../settings/server'; import { normalizeMessagesForUser } from '../../../utils/server/lib/normalizeMessagesForUser'; +import type { ExtractRoutesFromAPI } from '../ApiClass'; import { API } from '../api'; +import type { TypedAction } from '../definition'; import { addUserToFileObj } from '../helpers/addUserToFileObj'; import { composeRoomWithLastMessage } from '../helpers/composeRoomWithLastMessage'; import { getPaginationItems } from '../helpers/getPaginationItems'; @@ -87,28 +91,78 @@ API.v1.addRoute( }, ); -API.v1.addRoute( - ['dm.delete', 'im.delete'], - { - authRequired: true, - validateParams: isDmDeleteProps, +type DmDeleteProps = + | { + roomId: string; + } + | { + username: string; + }; + +const isDmDeleteProps = ajv.compile({ + oneOf: [ + { + type: 'object', + properties: { + roomId: { + type: 'string', + }, + }, + required: ['roomId'], + additionalProperties: false, + }, + { + type: 'object', + properties: { + username: { + type: 'string', + }, + }, + required: ['username'], + additionalProperties: false, + }, + ], +}); + +const dmDeleteEndpointsProps = { + authRequired: true, + body: isDmDeleteProps, + response: { + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + 200: ajv.compile({ + type: 'object', + properties: { + success: { + type: 'boolean', + enum: [true], + }, + }, + required: ['success'], + additionalProperties: false, + }), }, - { - async post() { - const { room } = await findDirectMessageRoom(this.bodyParams, this.userId); +} as const; - const canAccess = - (await canAccessRoomIdAsync(room._id, this.userId)) || (await hasPermissionAsync(this.userId, 'view-room-administration')); - if (!canAccess) { - throw new Meteor.Error('error-not-allowed', 'Not allowed'); - } +const dmDeleteAction = (_path: Path): TypedAction => + async function action() { + const { room } = await findDirectMessageRoom(this.bodyParams, this.userId); - await eraseRoom(room._id, this.userId); + const canAccess = + (await canAccessRoomIdAsync(room._id, this.userId)) || (await hasPermissionAsync(this.userId, 'view-room-administration')); - return API.v1.success(); - }, - }, -); + if (!canAccess) { + throw new Meteor.Error('error-not-allowed', 'Not allowed'); + } + + await eraseRoom(room._id, this.userId); + + return API.v1.success(); + }; + +const dmEndpoints = API.v1 + .post('im.delete', dmDeleteEndpointsProps, dmDeleteAction('im.delete')) + .post('dm.delete', dmDeleteEndpointsProps, dmDeleteAction('dm.delete')); API.v1.addRoute( ['dm.close', 'im.close'], @@ -589,3 +643,10 @@ API.v1.addRoute( }, }, ); + +export type DmEndpoints = ExtractRoutesFromAPI; + +declare module '@rocket.chat/rest-typings' { + // eslint-disable-next-line @typescript-eslint/naming-convention, @typescript-eslint/no-empty-interface + interface Endpoints extends DmEndpoints {} +} diff --git a/apps/meteor/app/api/server/v1/oauthapps.ts b/apps/meteor/app/api/server/v1/oauthapps.ts index 78ab03e19621c..3af8b8002b532 100644 --- a/apps/meteor/app/api/server/v1/oauthapps.ts +++ b/apps/meteor/app/api/server/v1/oauthapps.ts @@ -1,30 +1,286 @@ +import type { IOAuthApps } from '@rocket.chat/core-typings'; import { OAuthApps } from '@rocket.chat/models'; -import { isUpdateOAuthAppParams, isOauthAppsGetParams, isOauthAppsAddParams, isDeleteOAuthAppParams } from '@rocket.chat/rest-typings'; +import { + ajv, + validateUnauthorizedErrorResponse, + validateBadRequestErrorResponse, + validateForbiddenErrorResponse, +} from '@rocket.chat/rest-typings'; import { hasPermissionAsync } from '../../../authorization/server/functions/hasPermission'; import { apiDeprecationLogger } from '../../../lib/server/lib/deprecationWarningLogger'; import { addOAuthApp } from '../../../oauth2-server-config/server/admin/functions/addOAuthApp'; import { deleteOAuthApp } from '../../../oauth2-server-config/server/admin/methods/deleteOAuthApp'; import { updateOAuthApp } from '../../../oauth2-server-config/server/admin/methods/updateOAuthApp'; +import type { ExtractRoutesFromAPI } from '../ApiClass'; import { API } from '../api'; -API.v1.addRoute( - 'oauth-apps.list', - { authRequired: true, permissionsRequired: ['manage-oauth-apps'] }, - { - async get() { +type DeleteOAuthAppParams = { + appId: string; +}; + +const DeleteOAuthAppParamsSchema = { + type: 'object', + properties: { + appId: { + type: 'string', + }, + }, + required: ['appId'], + additionalProperties: false, +}; + +const isDeleteOAuthAppParams = ajv.compile(DeleteOAuthAppParamsSchema); + +export type OauthAppsAddParams = { + name: string; + active: boolean; + redirectUri: string; +}; + +const OauthAppsAddParamsSchema = { + type: 'object', + properties: { + name: { + type: 'string', + }, + active: { + type: 'boolean', + }, + redirectUri: { + type: 'string', + }, + }, + required: ['name', 'active', 'redirectUri'], + additionalProperties: false, +}; + +const isOauthAppsAddParams = ajv.compile(OauthAppsAddParamsSchema); + +type UpdateOAuthAppParams = { + appId: string; + name: string; + active: boolean; + clientId?: string | undefined; + clientSecret?: string | undefined; + redirectUri: string; +}; + +const UpdateOAuthAppParamsSchema = { + type: 'object', + properties: { + appId: { + type: 'string', + }, + name: { + type: 'string', + }, + active: { + type: 'boolean', + }, + redirectUri: { + type: 'string', + }, + }, + required: ['appId', 'name', 'active', 'redirectUri'], + additionalProperties: false, +}; + +const isUpdateOAuthAppParams = ajv.compile(UpdateOAuthAppParamsSchema); + +type OauthAppsGetParams = { clientId: string } | { appId: string } | { _id: string }; + +const oauthAppsGetParamsSchema = { + oneOf: [ + { + type: 'object', + properties: { + _id: { + type: 'string', + }, + }, + required: ['_id'], + additionalProperties: false, + }, + { + type: 'object', + properties: { + clientId: { + type: 'string', + }, + }, + required: ['clientId'], + additionalProperties: false, + }, + { + type: 'object', + properties: { + appId: { + type: 'string', + }, + }, + required: ['appId'], + additionalProperties: false, + }, + ], +}; + +const isOauthAppsGetParams = ajv.compile(oauthAppsGetParamsSchema); + +const oauthAppsEndpoints = API.v1 + .get( + 'oauth-apps.list', + { + authRequired: true, + query: ajv.compile<{ uid?: string }>({ + type: 'object', + properties: { + uid: { + type: 'string', + }, + }, + additionalProperties: false, + }), + permissionsRequired: ['manage-oauth-apps'], + response: { + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + 403: validateForbiddenErrorResponse, + 200: ajv.compile<{ oauthApps: IOAuthApps[] }>({ + type: 'object', + properties: { + oauthApps: { + type: 'array', + items: { + $ref: '#/components/schemas/IOAuthApps', + }, + }, + success: { + type: 'boolean', + enum: [true], + }, + }, + required: ['oauthApps', 'success'], + additionalProperties: false, + }), + }, + }, + + async function action() { return API.v1.success({ oauthApps: await OAuthApps.find().toArray(), }); }, - }, -); + ) + .post( + 'oauth-apps.delete', + { + authRequired: true, + body: isDeleteOAuthAppParams, + permissionsRequired: ['manage-oauth-apps'], + response: { + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + 403: validateForbiddenErrorResponse, + 200: ajv.compile({ type: 'boolean' }), + }, + }, + + async function action() { + const { appId } = this.bodyParams; + + const result = await deleteOAuthApp(this.userId, appId); + + return API.v1.success(result); + }, + ) + .post( + 'oauth-apps.create', + { + authRequired: true, + body: isOauthAppsAddParams, + permissionsRequired: ['manage-oauth-apps'], + response: { + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + 403: validateForbiddenErrorResponse, + 200: ajv.compile<{ application: IOAuthApps }>({ + type: 'object', + properties: { + application: { $ref: '#/components/schemas/IOAuthApps' }, + success: { + type: 'boolean', + enum: [true], + }, + }, + required: ['application', 'success'], + additionalProperties: false, + }), + }, + }, + + async function action() { + const application = await addOAuthApp(this.bodyParams, this.userId); + + return API.v1.success({ application }); + }, + ) + .post( + 'oauth-apps.update', + { + authRequired: true, + body: isUpdateOAuthAppParams, + permissionsRequired: ['manage-oauth-apps'], + response: { + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + 403: validateForbiddenErrorResponse, + 200: ajv.compile({ + allOf: [ + { anyOf: [{ $ref: '#/components/schemas/IOAuthApps' }, { type: 'null' }] }, + { + type: 'object', + properties: { + success: { type: 'boolean', enum: [true] }, + }, + required: ['success'], + }, + ], + }), + }, + }, + async function action() { + const { appId } = this.bodyParams; -API.v1.addRoute( - 'oauth-apps.get', - { authRequired: true, validateParams: isOauthAppsGetParams }, - { - async get() { + const result = await updateOAuthApp(this.userId, appId, this.bodyParams); + + return API.v1.success(result); + }, + ) + .get( + 'oauth-apps.get', + { + authRequired: true, + query: isOauthAppsGetParams, + response: { + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + 200: ajv.compile<{ oauthApp: IOAuthApps }>({ + type: 'object', + properties: { + oauthApp: { anyOf: [{ $ref: '#/components/schemas/IOAuthApps' }, { type: 'null' }] }, + success: { + type: 'boolean', + enum: [true], + }, + }, + required: ['oauthApp', 'success'], + additionalProperties: false, + }), + }, + }, + + async function action() { const isOAuthAppsManager = await hasPermissionAsync(this.userId, 'manage-oauth-apps'); const oauthApp = await OAuthApps.findOneAuthAppByIdOrClientId( @@ -44,57 +300,11 @@ API.v1.addRoute( oauthApp, }); }, - }, -); - -API.v1.addRoute( - 'oauth-apps.update', - { - authRequired: true, - validateParams: isUpdateOAuthAppParams, - permissionsRequired: ['manage-oauth-apps'], - }, - { - async post() { - const { appId } = this.bodyParams; - - const result = await updateOAuthApp(this.userId, appId, this.bodyParams); - - return API.v1.success(result); - }, - }, -); - -API.v1.addRoute( - 'oauth-apps.delete', - { - authRequired: true, - validateParams: isDeleteOAuthAppParams, - permissionsRequired: ['manage-oauth-apps'], - }, - { - async post() { - const { appId } = this.bodyParams; - - const result = await deleteOAuthApp(this.userId, appId); + ); - return API.v1.success(result); - }, - }, -); - -API.v1.addRoute( - 'oauth-apps.create', - { - authRequired: true, - validateParams: isOauthAppsAddParams, - permissionsRequired: ['manage-oauth-apps'], - }, - { - async post() { - const application = await addOAuthApp(this.bodyParams, this.userId); +export type OauthAppsEndpoints = ExtractRoutesFromAPI; - return API.v1.success({ application }); - }, - }, -); +declare module '@rocket.chat/rest-typings' { + // eslint-disable-next-line @typescript-eslint/naming-convention, @typescript-eslint/no-empty-interface + interface Endpoints extends OauthAppsEndpoints {} +} diff --git a/apps/meteor/app/api/server/v1/permissions.ts b/apps/meteor/app/api/server/v1/permissions.ts index 9b7cee5d3c7b5..750ebe7cb2e43 100644 --- a/apps/meteor/app/api/server/v1/permissions.ts +++ b/apps/meteor/app/api/server/v1/permissions.ts @@ -1,17 +1,101 @@ import type { IPermission } from '@rocket.chat/core-typings'; import { Permissions, Roles } from '@rocket.chat/models'; -import { isBodyParamsValidPermissionUpdate } from '@rocket.chat/rest-typings'; +import { + ajv, + validateUnauthorizedErrorResponse, + validateBadRequestErrorResponse, + validateForbiddenErrorResponse, +} from '@rocket.chat/rest-typings'; import { Meteor } from 'meteor/meteor'; import { permissionsGetMethod } from '../../../authorization/server/streamer/permissions'; import { notifyOnPermissionChangedById } from '../../../lib/server/lib/notifyListener'; +import type { ExtractRoutesFromAPI } from '../ApiClass'; import { API } from '../api'; -API.v1.addRoute( - 'permissions.listAll', - { authRequired: true }, - { - async get() { +type PermissionsListAllProps = { + updatedSince?: string; +}; + +type PermissionsUpdateProps = { + permissions: { _id: string; roles: string[] }[]; +}; + +const permissionListAllSchema = { + type: 'object', + properties: { + updatedSince: { + type: 'string', + nullable: true, + }, + }, + required: [], + additionalProperties: false, +}; + +const permissionUpdatePropsSchema = { + type: 'object', + properties: { + permissions: { + type: 'array', + items: { + type: 'object', + properties: { + _id: { type: 'string' }, + roles: { + type: 'array', + items: { type: 'string' }, + uniqueItems: true, + }, + }, + additionalProperties: false, + required: ['_id', 'roles'], + }, + }, + }, + required: ['permissions'], + additionalProperties: false, +}; + +const isPermissionsListAll = ajv.compile(permissionListAllSchema); + +const isBodyParamsValidPermissionUpdate = ajv.compile(permissionUpdatePropsSchema); + +const permissionsEndpoints = API.v1 + .get( + 'permissions.listAll', + { + authRequired: true, + query: isPermissionsListAll, + response: { + 200: ajv.compile<{ + update: IPermission[]; + remove: IPermission[]; + }>({ + type: 'object', + properties: { + update: { + type: 'array', + items: { $ref: '#/components/schemas/IPermission' }, + }, + remove: { + type: 'array', + items: { $ref: '#/components/schemas/IPermission' }, + }, + success: { + type: 'boolean', + enum: [true], + description: 'Indicates if the request was successful.', + }, + }, + required: ['update', 'remove', 'success'], + additionalProperties: false, + }), + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + }, + }, + async function action() { const { updatedSince } = this.queryParams; let updatedSinceDate: Date | undefined; @@ -36,14 +120,38 @@ API.v1.addRoute( return API.v1.success(result); }, - }, -); - -API.v1.addRoute( - 'permissions.update', - { authRequired: true, permissionsRequired: ['access-permissions'] }, - { - async post() { + ) + .post( + 'permissions.update', + { + authRequired: true, + permissionsRequired: ['access-permissions'], + body: isBodyParamsValidPermissionUpdate, + response: { + 200: ajv.compile<{ + permissions: IPermission[]; + }>({ + type: 'object', + properties: { + permissions: { + type: 'array', + items: { $ref: '#/components/schemas/IPermission' }, + }, + success: { + type: 'boolean', + enum: [true], + description: 'Indicates if the request was successful.', + }, + }, + required: ['permissions', 'success'], + additionalProperties: false, + }), + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + 403: validateForbiddenErrorResponse, + }, + }, + async function action() { const { bodyParams } = this; if (!isBodyParamsValidPermissionUpdate(bodyParams)) { @@ -76,5 +184,11 @@ API.v1.addRoute( permissions: result, }); }, - }, -); + ); + +export type PermissionsEndpoints = ExtractRoutesFromAPI; + +declare module '@rocket.chat/rest-typings' { + // eslint-disable-next-line @typescript-eslint/naming-convention, @typescript-eslint/no-empty-interface + interface Endpoints extends PermissionsEndpoints {} +} diff --git a/apps/meteor/app/api/server/v1/roles.ts b/apps/meteor/app/api/server/v1/roles.ts index fc17ed4656221..10c3a9fd78389 100644 --- a/apps/meteor/app/api/server/v1/roles.ts +++ b/apps/meteor/app/api/server/v1/roles.ts @@ -1,7 +1,7 @@ -import { api } from '@rocket.chat/core-services'; +import { api, Authorization } from '@rocket.chat/core-services'; import type { IRole } from '@rocket.chat/core-typings'; import { Roles, Users } from '@rocket.chat/models'; -import { isRoleAddUserToRoleProps, isRoleDeleteProps, isRoleRemoveUserFromRoleProps } from '@rocket.chat/rest-typings'; +import { ajv, isRoleAddUserToRoleProps, isRoleDeleteProps, isRoleRemoveUserFromRoleProps } from '@rocket.chat/rest-typings'; import { check, Match } from 'meteor/check'; import { Meteor } from 'meteor/meteor'; @@ -13,6 +13,7 @@ import { addUserToRole } from '../../../authorization/server/methods/addUserToRo import { apiDeprecationLogger } from '../../../lib/server/lib/deprecationWarningLogger'; import { notifyOnRoleChanged } from '../../../lib/server/lib/notifyListener'; import { settings } from '../../../settings/server/index'; +import type { ExtractRoutesFromAPI } from '../ApiClass'; import { API } from '../api'; import { getPaginationItems } from '../helpers/getPaginationItems'; import { getUserFromParams } from '../helpers/getUserFromParams'; @@ -243,3 +244,43 @@ API.v1.addRoute( }, }, ); + +const rolesRoutes = API.v1.get( + 'roles.getUsersInPublicRoles', + { + authRequired: true, + response: { + 200: ajv.compile<{ + users: { + _id: string; + username: string; + roles: string[]; + }[]; + }>({ + type: 'object', + properties: { + users: { + type: 'array', + items: { + type: 'object', + properties: { _id: { type: 'string' }, username: { type: 'string' }, roles: { type: 'array', items: { type: 'string' } } }, + }, + }, + }, + }), + }, + }, + + async () => { + return API.v1.success({ + users: await Authorization.getUsersFromPublicRoles(), + }); + }, +); + +type RolesEndpoints = ExtractRoutesFromAPI; + +declare module '@rocket.chat/rest-typings' { + // eslint-disable-next-line @typescript-eslint/naming-convention, @typescript-eslint/no-empty-interface + interface Endpoints extends RolesEndpoints {} +} diff --git a/apps/meteor/app/api/server/v1/rooms.ts b/apps/meteor/app/api/server/v1/rooms.ts index 446374d934684..7206cfb5ce27e 100644 --- a/apps/meteor/app/api/server/v1/rooms.ts +++ b/apps/meteor/app/api/server/v1/rooms.ts @@ -4,6 +4,7 @@ import { isPrivateRoom, isPublicRoom } from '@rocket.chat/core-typings'; import { Messages, Rooms, Users, Uploads, Subscriptions } from '@rocket.chat/models'; import type { Notifications } from '@rocket.chat/rest-typings'; import { + ajv, isGETRoomsNameExists, isRoomsImagesProps, isRoomsMuteUnmuteUserProps, @@ -23,6 +24,7 @@ import * as dataExport from '../../../../server/lib/dataExport'; import { eraseRoom } from '../../../../server/lib/eraseRoom'; import { findUsersOfRoomOrderedByRole } from '../../../../server/lib/findUsersOfRoomOrderedByRole'; import { openRoom } from '../../../../server/lib/openRoom'; +import type { RoomRoles } from '../../../../server/lib/roles/getRoomRoles'; import { hideRoomMethod } from '../../../../server/methods/hideRoom'; import { muteUserInRoom } from '../../../../server/methods/muteUserInRoom'; import { toggleFavoriteMethod } from '../../../../server/methods/toggleFavorite'; @@ -37,12 +39,14 @@ import { sendFileMessage } from '../../../file-upload/server/methods/sendFileMes import { syncRolePrioritiesForRoomIfRequired } from '../../../lib/server/functions/syncRolePrioritiesForRoomIfRequired'; import { executeArchiveRoom } from '../../../lib/server/methods/archiveRoom'; import { cleanRoomHistoryMethod } from '../../../lib/server/methods/cleanRoomHistory'; +import { executeGetRoomRoles } from '../../../lib/server/methods/getRoomRoles'; import { leaveRoomMethod } from '../../../lib/server/methods/leaveRoom'; import { executeUnarchiveRoom } from '../../../lib/server/methods/unarchiveRoom'; import { applyAirGappedRestrictionsValidation } from '../../../license/server/airGappedRestrictionsWrapper'; import type { NotificationFieldType } from '../../../push-notifications/server/methods/saveNotificationSettings'; import { saveNotificationSettingsMethod } from '../../../push-notifications/server/methods/saveNotificationSettings'; import { settings } from '../../../settings/server'; +import type { ExtractRoutesFromAPI } from '../ApiClass'; import { API } from '../api'; import { composeRoomWithLastMessage } from '../helpers/composeRoomWithLastMessage'; import { getPaginationItems } from '../helpers/getPaginationItems'; @@ -172,7 +176,7 @@ API.v1.addRoute( authRequired: true, deprecation: { version: '8.0.0', - alternatives: ['rooms.media'], + alternatives: ['/v1/rooms.media/:rid'], }, }, { @@ -207,6 +211,7 @@ API.v1.addRoute( if (stripExif) { // No need to check mime. Library will ignore any files without exif/xmp tags (like BMP, ico, PDF, etc) fileBuffer = await Media.stripExifFromBuffer(fileBuffer); + details.size = fileBuffer.length; } const fileStore = FileUpload.getStore('Uploads'); @@ -285,6 +290,7 @@ API.v1.addRoute( if (stripExif) { // No need to check mime. Library will ignore any files without exif/xmp tags (like BMP, ico, PDF, etc) fileBuffer = await Media.stripExifFromBuffer(fileBuffer); + details.size = fileBuffer.length; } const fileStore = FileUpload.getStore('Uploads'); @@ -357,13 +363,8 @@ API.v1.addRoute( } await Promise.all( - Object.keys(notifications as Notifications).map(async (notificationKey) => - saveNotificationSettingsMethod( - this.userId, - roomId, - notificationKey as NotificationFieldType, - notifications[notificationKey as keyof Notifications], - ), + Object.entries(notifications as Notifications).map(async ([notificationKey, notificationValue]) => + saveNotificationSettingsMethod(this.userId, roomId, notificationKey as NotificationFieldType, notificationValue), ), ); @@ -457,7 +458,7 @@ API.v1.addRoute( const discussionParent = room.prid && (await Rooms.findOneById>(room.prid, { - projection: { name: 1, fname: 1, t: 1, prid: 1, u: 1, sidepanel: 1 }, + projection: { name: 1, fname: 1, t: 1, prid: 1, u: 1 }, })); const { team, parentRoom } = await Team.getRoomInfo(room); const parent = discussionParent || parentRoom; @@ -1005,3 +1006,62 @@ API.v1.addRoute( }, }, ); + +const isRoomGetRolesPropsSchema = { + type: 'object', + properties: { + rid: { type: 'string' }, + }, + additionalProperties: false, + required: ['rid'], +}; +export const roomEndpoints = API.v1.get( + 'rooms.roles', + { + authRequired: true, + query: ajv.compile<{ + rid: string; + }>(isRoomGetRolesPropsSchema), + response: { + 200: ajv.compile<{ + roles: RoomRoles[]; + }>({ + type: 'object', + properties: { + roles: { + type: 'array', + items: { + type: 'object', + properties: { + rid: { type: 'string' }, + u: { + type: 'object', + properties: { _id: { type: 'string' }, username: { type: 'string' } }, + required: ['_id', 'username'], + }, + roles: { type: 'array', items: { type: 'string' } }, + }, + required: ['rid', 'u', 'roles'], + }, + }, + }, + required: ['roles'], + }), + }, + }, + async function () { + const { rid } = this.queryParams; + const roles = await executeGetRoomRoles(rid, this.userId); + + return API.v1.success({ + roles, + }); + }, +); + +type RoomEndpoints = ExtractRoutesFromAPI; + +declare module '@rocket.chat/rest-typings' { + // eslint-disable-next-line @typescript-eslint/naming-convention, @typescript-eslint/no-empty-interface + interface Endpoints extends RoomEndpoints {} +} diff --git a/apps/meteor/app/api/server/v1/teams.ts b/apps/meteor/app/api/server/v1/teams.ts index 84d1ba5db264c..ee5ec7e273f66 100644 --- a/apps/meteor/app/api/server/v1/teams.ts +++ b/apps/meteor/app/api/server/v1/teams.ts @@ -1,6 +1,6 @@ import { Team } from '@rocket.chat/core-services'; import type { ITeam, UserStatus } from '@rocket.chat/core-typings'; -import { TEAM_TYPE, isValidSidepanel } from '@rocket.chat/core-typings'; +import { TEAM_TYPE } from '@rocket.chat/core-typings'; import { Users, Rooms } from '@rocket.chat/models'; import { isTeamsConvertToChannelProps, @@ -78,11 +78,7 @@ API.v1.addRoute( }), ); - const { name, type, members, room, owner, sidepanel } = this.bodyParams; - - if (sidepanel?.items && !isValidSidepanel(sidepanel)) { - throw new Error('error-invalid-sidepanel'); - } + const { name, type, members, room, owner } = this.bodyParams; const team = await Team.create(this.userId, { team: { @@ -92,7 +88,6 @@ API.v1.addRoute( room, members, owner, - sidepanel, }); return API.v1.success({ team }); diff --git a/apps/meteor/app/api/server/v1/users.ts b/apps/meteor/app/api/server/v1/users.ts index c84c5a361d35e..8051cf8766c9f 100644 --- a/apps/meteor/app/api/server/v1/users.ts +++ b/apps/meteor/app/api/server/v1/users.ts @@ -1,5 +1,5 @@ import { MeteorError, Team, api, Calendar } from '@rocket.chat/core-services'; -import type { IExportOperation, ILoginToken, IPersonalAccessToken, IUser, UserStatus } from '@rocket.chat/core-typings'; +import { type IExportOperation, type ILoginToken, type IPersonalAccessToken, type IUser, type UserStatus } from '@rocket.chat/core-typings'; import { Users, Subscriptions } from '@rocket.chat/models'; import { isUserCreateParamsPOST, @@ -176,7 +176,13 @@ API.v1.addRoute( twoFactorMethod: 'password', }; - await executeSaveUserProfile.call(this as unknown as Meteor.MethodThisType, userData, this.bodyParams.customFields, twoFactorOptions); + await executeSaveUserProfile.call( + this as unknown as Meteor.MethodThisType, + this.user, + userData, + this.bodyParams.customFields, + twoFactorOptions, + ); return API.v1.success({ user: await Users.findOneById(this.userId, { projection: API.v1.defaultFieldsToExclude }), @@ -1317,10 +1323,14 @@ API.v1.addRoute( return API.v1.forbidden(); } + const { _id, username, roles, name } = user; + let { statusText } = user; + // TODO refactor to not update the user twice (one inside of `setStatusText` and then later just the status + statusDefault) if (this.bodyParams.message || this.bodyParams.message === '') { await setStatusText(user._id, this.bodyParams.message); + statusText = this.bodyParams.message; } if (this.bodyParams.status) { const validStatus = ['online', 'away', 'offline', 'busy']; @@ -1343,7 +1353,6 @@ API.v1.addRoute( }, ); - const { _id, username, statusText, roles, name } = user; void api.broadcast('presence.status', { user: { status, _id, username, statusText, roles, name }, previousStatus: user.status, diff --git a/apps/meteor/app/api/server/v1/webdav.ts b/apps/meteor/app/api/server/v1/webdav.ts index a3d2755b10d73..0a7fedf500bcd 100644 --- a/apps/meteor/app/api/server/v1/webdav.ts +++ b/apps/meteor/app/api/server/v1/webdav.ts @@ -1,7 +1,7 @@ import { api } from '@rocket.chat/core-services'; import type { IWebdavAccount, IWebdavAccountIntegration } from '@rocket.chat/core-typings'; import { WebdavAccounts } from '@rocket.chat/models'; -import { ajv } from '@rocket.chat/rest-typings'; +import { ajv, validateUnauthorizedErrorResponse, validateBadRequestErrorResponse } from '@rocket.chat/rest-typings'; import type { DeleteResult } from 'mongodb'; import type { ExtractRoutesFromAPI } from '../ApiClass'; @@ -48,20 +48,7 @@ const webdavGetMyAccountsEndpoints = API.v1.get( required: ['success', 'accounts'], additionalProperties: false, }), - 401: ajv.compile({ - type: 'object', - properties: { - message: { - type: 'string', - }, - success: { - type: 'boolean', - description: 'Indicates if the request was successful.', - }, - }, - required: ['success', 'message'], - additionalProperties: false, - }), + 401: validateUnauthorizedErrorResponse, }, }, async function action() { @@ -121,37 +108,8 @@ const webdavRemoveAccountEndpoints = API.v1.post( required: ['result', 'success'], additionalProperties: false, }), - 400: ajv.compile({ - type: 'object', - properties: { - errorType: { - type: 'string', - }, - error: { - type: 'string', - }, - success: { - type: 'boolean', - description: 'Indicates if the request was successful.', - }, - }, - required: ['success', 'errorType', 'error'], - additionalProperties: false, - }), - 401: ajv.compile({ - type: 'object', - properties: { - message: { - type: 'string', - }, - success: { - type: 'boolean', - description: 'Indicates if the request was successful.', - }, - }, - required: ['success', 'message'], - additionalProperties: false, - }), + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, }, }, async function action() { diff --git a/apps/meteor/app/apple/client/index.ts b/apps/meteor/app/apple/client/index.ts deleted file mode 100644 index f2579fed790d6..0000000000000 --- a/apps/meteor/app/apple/client/index.ts +++ /dev/null @@ -1,4 +0,0 @@ -import { CustomOAuth } from '../../custom-oauth/client/CustomOAuth'; -import { config } from '../lib/config'; - -CustomOAuth.configureOAuthService('apple', config); diff --git a/apps/meteor/app/apple/lib/config.ts b/apps/meteor/app/apple/lib/config.ts index 14f746b19a091..b8fde01054e63 100644 --- a/apps/meteor/app/apple/lib/config.ts +++ b/apps/meteor/app/apple/lib/config.ts @@ -1,3 +1,5 @@ +import type { OauthConfig } from '@rocket.chat/core-typings'; + export const config = { serverURL: 'https://appleid.apple.com', authorizePath: '/auth/authorize?response_mode=form_post', @@ -7,4 +9,4 @@ export const config = { mergeUsers: true, accessTokenParam: 'access_token', loginStyle: 'popup', -}; +} as const satisfies OauthConfig; diff --git a/apps/meteor/app/apps/server/bridges/rooms.ts b/apps/meteor/app/apps/server/bridges/rooms.ts index ea8a7fdf6e7e6..861f7ade14dca 100644 --- a/apps/meteor/app/apps/server/bridges/rooms.ts +++ b/apps/meteor/app/apps/server/bridges/rooms.ts @@ -237,11 +237,9 @@ export class AppRoomBridge extends RoomBridge { } private async getUsersByRoomIdAndSubscriptionRole(roomId: string, role: string): Promise { - const subs = (await Subscriptions.findByRoomIdAndRoles(roomId, [role], { + const subs = await Subscriptions.findByRoomIdAndRoles<{ uid: string }>(roomId, [role], { projection: { uid: '$u._id', _id: 0 }, - }).toArray()) as unknown as { - uid: string; - }[]; + }).toArray(); // Was this a bug? const users = await Users.findByIds(subs.map((user: { uid: string }) => user.uid)).toArray(); const userConverter = this.orch.getConverters().get('users'); diff --git a/apps/meteor/app/authorization/client/hasPermission.ts b/apps/meteor/app/authorization/client/hasPermission.ts index 165f17a273f00..ddacd972722ac 100644 --- a/apps/meteor/app/authorization/client/hasPermission.ts +++ b/apps/meteor/app/authorization/client/hasPermission.ts @@ -1,52 +1,35 @@ -import type { IUser, IRole, IPermission } from '@rocket.chat/core-typings'; +import type { IUser, IPermission } from '@rocket.chat/core-typings'; import { Meteor } from 'meteor/meteor'; -import * as Models from '../../models/client'; +import { hasRole } from './hasRole'; +import { PermissionsCachedStore } from '../../../client/cachedStores'; +import { watch } from '../../../client/lib/cachedStores'; +import { Permissions, Users } from '../../../client/stores'; import { AuthorizationUtils } from '../lib/AuthorizationUtils'; -const isValidScope = (scope: unknown): scope is keyof typeof Models => typeof scope === 'string' && scope in Models; - -const hasIsUserInRole = ( - model: unknown, -): model is { - isUserInRole: (this: any, uid: IUser['_id'], roleId: IRole['_id'], scope: string | undefined) => boolean; -} => typeof model === 'object' && model !== null && typeof (model as { isUserInRole?: unknown }).isUserInRole === 'function'; - const createPermissionValidator = (quantifier: (predicate: (permissionId: IPermission['_id']) => boolean) => boolean) => (permissionIds: IPermission['_id'][], scope: string | undefined, userId: IUser['_id'], scopedRoles?: IPermission['_id'][]): boolean => { - const user = Models.Users.findOne({ _id: userId }, { fields: { roles: 1 } }); + const userRoles = watch(Users.use, (state) => state.get(userId)?.roles); const checkEachPermission = quantifier.bind(permissionIds); return checkEachPermission((permissionId) => { - if (user?.roles) { - if (AuthorizationUtils.isPermissionRestrictedForRoleList(permissionId, user.roles)) { + if (userRoles) { + if (AuthorizationUtils.isPermissionRestrictedForRoleList(permissionId, userRoles)) { return false; } } - const permission = Models.Permissions.state.get(permissionId); + const permission = watch(Permissions.use, (state) => state.get(permissionId)); const roles = permission?.roles ?? []; return roles.some((roleId) => { - const roleScope = Models.Roles.state.get(roleId)?.scope; - - if (!isValidScope(roleScope)) { - return false; - } - - const model = Models[roleScope]; - if (scopedRoles?.includes(roleId)) { return true; } - if (hasIsUserInRole(model)) { - return model.isUserInRole(userId, roleId, scope); - } - - return undefined; + return hasRole(userId, roleId, scope); }); }); }; @@ -73,7 +56,7 @@ const validatePermissions = ( return false; } - if (!Models.AuthzCachedCollection.ready.get()) { + if (!PermissionsCachedStore.watchReady()) { return false; } diff --git a/apps/meteor/app/authorization/client/hasRole.ts b/apps/meteor/app/authorization/client/hasRole.ts index 25995aeb37730..9c8fae2f00aae 100644 --- a/apps/meteor/app/authorization/client/hasRole.ts +++ b/apps/meteor/app/authorization/client/hasRole.ts @@ -1,19 +1,21 @@ import type { IUser, IRole, IRoom } from '@rocket.chat/core-typings'; -import { Roles } from '../../models/client'; +import { watch } from '../../../client/lib/cachedStores'; +import { Roles, Subscriptions, Users } from '../../../client/stores'; -export const hasRole = (userId: IUser['_id'], roleId: IRole['_id'], scope?: IRoom['_id'], ignoreSubscriptions = false): boolean => { - if (Array.isArray(roleId)) { - throw new Error('error-invalid-arguments'); - } +export const hasRole = (userId: IUser['_id'], roleId: IRole['_id'], scope?: IRoom['_id']): boolean => { + const roleScope = watch(Roles.use, (state) => state.get(roleId)?.scope ?? 'Users'); - return Roles.isUserInRoles(userId, [roleId], scope, ignoreSubscriptions); -}; + switch (roleScope) { + case 'Subscriptions': + if (!scope) return false; -export const hasAnyRole = (userId: IUser['_id'], roleIds: IRole['_id'][], scope?: IRoom['_id'], ignoreSubscriptions = false): boolean => { - if (!Array.isArray(roleIds)) { - throw new Error('error-invalid-arguments'); - } + return watch(Subscriptions.use, (state) => state.find((record) => record.rid === scope)?.roles?.includes(roleId) ?? false); - return Roles.isUserInRoles(userId, roleIds, scope, ignoreSubscriptions); + case 'Users': + return watch(Users.use, (state) => state.get(userId)?.roles?.includes(roleId) ?? false); + + default: + return false; + } }; diff --git a/apps/meteor/app/authorization/client/index.ts b/apps/meteor/app/authorization/client/index.ts index dd335c13030ea..c85eebfc73fbd 100644 --- a/apps/meteor/app/authorization/client/index.ts +++ b/apps/meteor/app/authorization/client/index.ts @@ -1,4 +1,2 @@ -import { hasAllPermission, hasAtLeastOnePermission, hasPermission, userHasAllPermission } from './hasPermission'; -import { hasRole, hasAnyRole } from './hasRole'; - -export { hasAllPermission, hasAtLeastOnePermission, hasRole, hasAnyRole, hasPermission, userHasAllPermission }; +export { hasAllPermission, hasAtLeastOnePermission, hasPermission, userHasAllPermission } from './hasPermission'; +export { hasRole } from './hasRole'; diff --git a/apps/meteor/app/authorization/server/methods/addUserToRole.ts b/apps/meteor/app/authorization/server/methods/addUserToRole.ts index 11d28a63167ec..461c0a11418ad 100644 --- a/apps/meteor/app/authorization/server/methods/addUserToRole.ts +++ b/apps/meteor/app/authorization/server/methods/addUserToRole.ts @@ -92,6 +92,8 @@ export const addUserToRole = async (userId: string, roleId: string, username: IU Meteor.methods({ async 'authorization:addUserToRole'(roleId: IRole['_id'], username: IUser['username'], scope) { + methodDeprecationLogger.method('authorization:addUserToRole', '8.0.0', '/v1/roles.addUserToRole'); + const userId = Meteor.userId(); if (!userId) { diff --git a/apps/meteor/app/authorization/server/methods/deleteRole.ts b/apps/meteor/app/authorization/server/methods/deleteRole.ts index 512468f2d6d7f..c327d1f1a2bc3 100644 --- a/apps/meteor/app/authorization/server/methods/deleteRole.ts +++ b/apps/meteor/app/authorization/server/methods/deleteRole.ts @@ -16,6 +16,8 @@ declare module '@rocket.chat/ddp-client' { Meteor.methods({ async 'authorization:deleteRole'(roleId) { + methodDeprecationLogger.method('authorization:deleteRole', '8.0.0', '/v1/roles.delete'); + const userId = Meteor.userId(); if (!userId || !(await hasPermissionAsync(userId, 'access-permissions'))) { diff --git a/apps/meteor/app/authorization/server/methods/removeUserFromRole.ts b/apps/meteor/app/authorization/server/methods/removeUserFromRole.ts index cf255a37d5bad..5efcc71b3577b 100644 --- a/apps/meteor/app/authorization/server/methods/removeUserFromRole.ts +++ b/apps/meteor/app/authorization/server/methods/removeUserFromRole.ts @@ -97,6 +97,8 @@ export const removeUserFromRole = async (userId: string, roleId: string, usernam Meteor.methods({ async 'authorization:removeUserFromRole'(roleId, username, scope) { + methodDeprecationLogger.method('authorization:removeUserFromRole', '8.0.0', '/v1/roles.removeUserFromRole'); + const userId = Meteor.userId(); if (!userId) { diff --git a/apps/meteor/app/autotranslate/client/lib/autotranslate.ts b/apps/meteor/app/autotranslate/client/lib/autotranslate.ts index eee21af762a42..443ed67dc87d4 100644 --- a/apps/meteor/app/autotranslate/client/lib/autotranslate.ts +++ b/apps/meteor/app/autotranslate/client/lib/autotranslate.ts @@ -11,12 +11,12 @@ import mem from 'mem'; import { Meteor } from 'meteor/meteor'; import { Tracker } from 'meteor/tracker'; +import { Messages, Subscriptions } from '../../../../client/stores'; import { hasTranslationLanguageInAttachments, hasTranslationLanguageInMessage, } from '../../../../client/views/room/MessageList/lib/autoTranslate'; import { hasPermission } from '../../../authorization/client'; -import { Subscriptions, Messages } from '../../../models/client'; import { settings } from '../../../settings/client'; import { sdk } from '../../../utils/client/lib/SDKClient'; @@ -40,7 +40,7 @@ export const AutoTranslate = { messageIdsToWait: {} as { [messageId: string]: boolean }, supportedLanguages: [] as ISupportedLanguage[] | undefined, - findSubscriptionByRid: mem((rid) => Subscriptions.findOne({ rid })), + findSubscriptionByRid: mem((rid) => Subscriptions.state.find((record) => record.rid === rid)), getLanguage(rid: IRoom['_id']): string { let subscription: ISubscription | undefined; @@ -120,12 +120,8 @@ export const AutoTranslate = { } }); - Subscriptions.find().observeChanges({ - changed: (_id: string, fields: ISubscription) => { - if (fields.hasOwnProperty('autoTranslate') || fields.hasOwnProperty('autoTranslateLanguage')) { - mem.clear(this.findSubscriptionByRid); - } - }, + Subscriptions.use.subscribe(() => { + mem.clear(this.findSubscriptionByRid); }); this.initialized = true; diff --git a/apps/meteor/app/autotranslate/server/googleTranslate.ts b/apps/meteor/app/autotranslate/server/googleTranslate.ts index da5e156458dcb..5c17a38d41609 100644 --- a/apps/meteor/app/autotranslate/server/googleTranslate.ts +++ b/apps/meteor/app/autotranslate/server/googleTranslate.ts @@ -131,7 +131,7 @@ class GoogleAutoTranslate extends AutoTranslate { for await (let language of targetLanguages) { if (language.indexOf('-') !== -1 && !_.findWhere(supportedLanguages, { language })) { - language = language.substr(0, 2); + language = language.slice(0, 2); } try { @@ -178,7 +178,7 @@ class GoogleAutoTranslate extends AutoTranslate { for await (let language of targetLanguages) { if (language.indexOf('-') !== -1 && !_.findWhere(supportedLanguages, { language })) { - language = language.substr(0, 2); + language = language.slice(0, 2); } try { diff --git a/apps/meteor/app/channel-settings/server/methods/saveRoomSettings.ts b/apps/meteor/app/channel-settings/server/methods/saveRoomSettings.ts index 42adc75cb563a..7cbdca852cd6d 100644 --- a/apps/meteor/app/channel-settings/server/methods/saveRoomSettings.ts +++ b/apps/meteor/app/channel-settings/server/methods/saveRoomSettings.ts @@ -1,6 +1,6 @@ import { Team } from '@rocket.chat/core-services'; import type { IRoom, IRoomWithRetentionPolicy, IUser, MessageTypesValues } from '@rocket.chat/core-typings'; -import { TEAM_TYPE, isValidSidepanel } from '@rocket.chat/core-typings'; +import { TEAM_TYPE } from '@rocket.chat/core-typings'; import type { ServerMethods } from '@rocket.chat/ddp-client'; import { Rooms, Users } from '@rocket.chat/models'; import { Match } from 'meteor/check'; @@ -47,7 +47,6 @@ type RoomSettings = { favorite: boolean; defaultValue: boolean; }; - sidepanel?: IRoom['sidepanel']; }; type RoomSettingsValidators = { @@ -79,23 +78,6 @@ const validators: RoomSettingsValidators = { }); } }, - async sidepanel({ room, userId, value }) { - if (!room.teamMain) { - throw new Meteor.Error('error-action-not-allowed', 'Invalid room', { - method: 'saveRoomSettings', - }); - } - - if (!(await hasPermissionAsync(userId, 'edit-team', room._id))) { - throw new Meteor.Error('error-action-not-allowed', 'You do not have permission to change sidepanel items', { - method: 'saveRoomSettings', - }); - } - - if (!isValidSidepanel(value)) { - throw new Meteor.Error('error-invalid-sidepanel'); - } - }, async roomType({ userId, room, value }) { if (value === room.t) { @@ -249,11 +231,6 @@ const settingSavers: RoomSettingsSavers = { await saveRoomTopic(rid, value, user); } }, - async sidepanel({ value, rid, room }) { - if (JSON.stringify(value) !== JSON.stringify(room.sidepanel)) { - await Rooms.setSidepanelById(rid, value); - } - }, async roomAnnouncement({ value, room, rid, user }) { if (!value && !room.announcement) { return; @@ -376,7 +353,6 @@ const fields: (keyof RoomSettings)[] = [ 'retentionOverrideGlobal', 'encrypted', 'favorite', - 'sidepanel', ]; const validate = ( diff --git a/apps/meteor/app/e2e/client/events.ts b/apps/meteor/app/e2e/client/events.ts deleted file mode 100644 index 9ccef3d7b28dd..0000000000000 --- a/apps/meteor/app/e2e/client/events.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { Accounts } from 'meteor/accounts-base'; - -import { e2e } from './rocketchat.e2e'; - -Accounts.onLogout(() => { - void e2e.stopClient(); -}); diff --git a/apps/meteor/app/e2e/server/methods/updateGroupKey.ts b/apps/meteor/app/e2e/server/methods/updateGroupKey.ts index f59a3d836d3b4..dd74041a90b71 100644 --- a/apps/meteor/app/e2e/server/methods/updateGroupKey.ts +++ b/apps/meteor/app/e2e/server/methods/updateGroupKey.ts @@ -49,12 +49,13 @@ export async function updateGroupKey(rid: string, uid: string, key: string, call Meteor.methods({ async 'e2e.updateGroupKey'(rid, uid, key) { + methodDeprecationLogger.method('e2e.updateGroupKey', '8.0.0', '/v1/e2e.acceptSuggestedGroupKey'); + const userId = Meteor.userId(); if (!userId) { throw new Meteor.Error('error-invalid-user', 'Invalid user', { method: 'e2e.acceptSuggestedGroupKey' }); } - methodDeprecationLogger.method('e2e.updateGroupKey', '8.0.0'); return updateGroupKey(rid, uid, key, userId); }, }); diff --git a/apps/meteor/app/file-upload/server/lib/FileUpload.ts b/apps/meteor/app/file-upload/server/lib/FileUpload.ts index 226cbeb64a144..73f67fb473744 100644 --- a/apps/meteor/app/file-upload/server/lib/FileUpload.ts +++ b/apps/meteor/app/file-upload/server/lib/FileUpload.ts @@ -589,6 +589,27 @@ export const FileUpload = { res.end(); }, + respondWithRedirectUrlInfo( + redirectUrl: string | false, + file: IUpload, + _req: http.IncomingMessage, + res: http.ServerResponse, + expiresInSeconds?: number | null, + ) { + res.setHeader('Content-Type', 'application/json'); + res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate'); + res.writeHead(200); + res.end( + JSON.stringify({ + redirectUrl, + name: file.name, + type: file.type, + size: file.size, + ...(expiresInSeconds && { expires: new Date(Date.now() + expiresInSeconds * 1000).toISOString() }), + }), + ); + }, + proxyFile( fileName: string, fileUrl: string, diff --git a/apps/meteor/app/file-upload/server/lib/requests.ts b/apps/meteor/app/file-upload/server/lib/requests.ts index ad780116de0f8..f88b2477777c8 100644 --- a/apps/meteor/app/file-upload/server/lib/requests.ts +++ b/apps/meteor/app/file-upload/server/lib/requests.ts @@ -1,7 +1,23 @@ +import type { IncomingMessage } from 'http'; + import { Uploads } from '@rocket.chat/models'; import { WebApp } from 'meteor/webapp'; import { FileUpload } from './FileUpload'; +import { SystemLogger } from '../../../../server/lib/logger/system'; + +const hasReplyWithRedirectUrlParam = (req: IncomingMessage) => { + if (!req.url) { + return false; + } + const [, params] = req.url.split('?'); + if (!params) { + return false; + } + const searchParams = new URLSearchParams(params); + const replyWithRedirectUrl = searchParams.get('replyWithRedirectUrl'); + return replyWithRedirectUrl === 'true' || replyWithRedirectUrl === '1'; +}; WebApp.connectHandlers.use(FileUpload.getPath(), async (req, res, next) => { const match = /^\/([^\/]+)\/(.*)/.exec(req.url || ''); @@ -16,6 +32,24 @@ WebApp.connectHandlers.use(FileUpload.getPath(), async (req, res, next) => { return; } + if (hasReplyWithRedirectUrlParam(req)) { + if (!file.store) { + res.writeHead(404); + res.end(); + return; + } + const store = FileUpload.getStoreByName(file.store); + let url: string | false = false; + let expiryTimespan: number | null = null; + try { + url = await store.getStore().getRedirectURL(file, false); + expiryTimespan = await store.getStore().getUrlExpiryTimeSpan(); + } catch (e) { + SystemLogger.debug(e); + } + return FileUpload.respondWithRedirectUrlInfo(url, file, req, res, expiryTimespan); + } + res.setHeader('Content-Security-Policy', "default-src 'none'"); res.setHeader('Cache-Control', 'max-age=31536000'); await FileUpload.get(file, req, res, next); diff --git a/apps/meteor/app/file-upload/ufs/AmazonS3/server.ts b/apps/meteor/app/file-upload/ufs/AmazonS3/server.ts index d6b69faf75fa2..c1fec9a2a2075 100644 --- a/apps/meteor/app/file-upload/ufs/AmazonS3/server.ts +++ b/apps/meteor/app/file-upload/ufs/AmazonS3/server.ts @@ -190,6 +190,10 @@ class AmazonS3Store extends UploadFS.Store { return writeStream; }; + + this.getUrlExpiryTimeSpan = async () => { + return options.URLExpiryTimeSpan || null; + }; } } diff --git a/apps/meteor/app/lib/client/methods/sendMessage.ts b/apps/meteor/app/lib/client/methods/sendMessage.ts index 237dab28c7b2e..360114a3f4eb2 100644 --- a/apps/meteor/app/lib/client/methods/sendMessage.ts +++ b/apps/meteor/app/lib/client/methods/sendMessage.ts @@ -4,9 +4,9 @@ import { Meteor } from 'meteor/meteor'; import { onClientMessageReceived } from '../../../../client/lib/onClientMessageReceived'; import { dispatchToastMessage } from '../../../../client/lib/toast'; +import { Messages, Rooms } from '../../../../client/stores'; import { callbacks } from '../../../../lib/callbacks'; import { trim } from '../../../../lib/utils/stringUtils'; -import { Messages, Rooms } from '../../../models/client'; import { settings } from '../../../settings/client'; import { t } from '../../../utils/lib/i18n'; diff --git a/apps/meteor/app/lib/server/functions/createRoom.ts b/apps/meteor/app/lib/server/functions/createRoom.ts index 22a5d7c69dc7b..dd84fd450301a 100644 --- a/apps/meteor/app/lib/server/functions/createRoom.ts +++ b/apps/meteor/app/lib/server/functions/createRoom.ts @@ -126,7 +126,6 @@ export const createRoom = async ( readOnly?: boolean, roomExtraData?: Partial, options?: ICreateRoomParams['options'], - sidepanel?: ICreateRoomParams['sidepanel'], ): Promise< ICreatedRoom & { rid: string; @@ -202,7 +201,6 @@ export const createRoom = async ( }, ts: now, ro: readOnly === true, - ...(sidepanel && { sidepanel }), }; if (teamId) { diff --git a/apps/meteor/app/lib/server/functions/saveUser/validateUserEditing.ts b/apps/meteor/app/lib/server/functions/saveUser/validateUserEditing.ts index 582611399c408..c9f3ddbe296ac 100644 --- a/apps/meteor/app/lib/server/functions/saveUser/validateUserEditing.ts +++ b/apps/meteor/app/lib/server/functions/saveUser/validateUserEditing.ts @@ -45,7 +45,7 @@ export async function validateUserEditing(userId: IUser['_id'], userData: Update if ( isEditingField(user.username, userData.username) && !settings.get('Accounts_AllowUsernameChange') && - (!canEditOtherUserInfo || editingMyself) + (editingMyself ? user.username : !canEditOtherUserInfo) ) { throw new MeteorError('error-action-not-allowed', 'Edit username is not allowed', { method: 'insertOrUpdateUser', @@ -87,7 +87,11 @@ export async function validateUserEditing(userId: IUser['_id'], userData: Update }); } - if (userData.password && !settings.get('Accounts_AllowPasswordChange') && (!canEditOtherUserPassword || editingMyself)) { + if ( + userData.password && + !settings.get('Accounts_AllowPasswordChange') && + (editingMyself ? user.services?.password || !user.requirePasswordChange : !canEditOtherUserPassword) + ) { throw new MeteorError('error-action-not-allowed', 'Edit user password is not allowed', { method: 'insertOrUpdateUser', action: 'Update_user', diff --git a/apps/meteor/app/lib/server/lib/deprecationWarningLogger.ts b/apps/meteor/app/lib/server/lib/deprecationWarningLogger.ts index 5b76b007c1620..ee95c9d2fa328 100644 --- a/apps/meteor/app/lib/server/lib/deprecationWarningLogger.ts +++ b/apps/meteor/app/lib/server/lib/deprecationWarningLogger.ts @@ -1,7 +1,8 @@ import { Logger } from '@rocket.chat/logger'; +import type { PathPattern } from '@rocket.chat/rest-typings'; import semver from 'semver'; -import { metrics } from '../../../metrics/server'; +import { metrics } from '../../../metrics/server/lib/metrics'; const deprecationLogger = new Logger('DeprecationWarning'); @@ -23,9 +24,11 @@ const compareVersions = (version: string, message: string) => { } }; +export type DeprecationLoggerNextPlannedVersion = '7.0.0' | '8.0.0'; + export const apiDeprecationLogger = ((logger) => { return { - endpoint: (endpoint: string, version: string, res: Response, info = '') => { + endpoint: (endpoint: string, version: DeprecationLoggerNextPlannedVersion, res: Response, info = '') => { const message = `The endpoint "${endpoint}" is deprecated and will be removed on version ${version}${info ? ` (${info})` : ''}`; compareVersions(version, message); @@ -39,7 +42,7 @@ export const apiDeprecationLogger = ((logger) => { parameter: ( endpoint: string, parameter: string, - version: string, + version: DeprecationLoggerNextPlannedVersion, res: Response, messageGenerator?: MessageFn<{ endpoint: string }>, ) => { @@ -61,7 +64,7 @@ export const apiDeprecationLogger = ((logger) => { deprecatedParameterUsage: ( endpoint: string, parameter: string, - version: string, + version: DeprecationLoggerNextPlannedVersion, res: Response, messageGenerator?: MessageFn<{ endpoint: string; @@ -86,13 +89,18 @@ export const apiDeprecationLogger = ((logger) => { export const methodDeprecationLogger = ((logger) => { return { - method: (method: string, version: string, info = '') => { - const message = `The method "${method}" is deprecated and will be removed on version ${version}${info ? ` (${info})` : ''}`; + method: ( + method: string, + version: DeprecationLoggerNextPlannedVersion, + info: T extends `/${string}` ? (T extends PathPattern ? T : never) : string, + ) => { + const replacement = typeof info === 'string' ? info : `Use the ${info} endpoint instead`; + const message = `The method "${method}" is deprecated and will be removed on version ${version}${replacement ? ` (${replacement})` : ''}`; compareVersions(version, message); metrics.deprecations.inc({ type: 'deprecation', name: method, kind: 'method' }); logger.warn(message); }, - parameter: (method: string, parameter: string, version: string) => { + parameter: (method: string, parameter: string, version: DeprecationLoggerNextPlannedVersion) => { const message = `The parameter "${parameter}" in the method "${method}" is deprecated and will be removed on version ${version}`; metrics.deprecations.inc({ type: 'parameter-deprecation', name: method, params: parameter }); @@ -103,7 +111,7 @@ export const methodDeprecationLogger = ((logger) => { deprecatedParameterUsage: ( method: string, parameter: string, - version: string, + version: DeprecationLoggerNextPlannedVersion, messageGenerator?: MessageFn<{ method: string; }>, diff --git a/apps/meteor/app/lib/server/methods/createToken.ts b/apps/meteor/app/lib/server/methods/createToken.ts index de60a7e37fe8d..199f8cb21e155 100644 --- a/apps/meteor/app/lib/server/methods/createToken.ts +++ b/apps/meteor/app/lib/server/methods/createToken.ts @@ -34,7 +34,7 @@ export async function generateAccessToken(callee: string, userId: string) { Meteor.methods({ async createToken(userId) { - methodDeprecationLogger.method('createToken', '8.0.0'); + methodDeprecationLogger.method('createToken', '8.0.0', '/v1/users.createToken'); const callee = Meteor.userId(); if (!callee) { diff --git a/apps/meteor/app/lib/server/methods/getRoomRoles.ts b/apps/meteor/app/lib/server/methods/getRoomRoles.ts index eb8fea1e602bf..050992445cc79 100644 --- a/apps/meteor/app/lib/server/methods/getRoomRoles.ts +++ b/apps/meteor/app/lib/server/methods/getRoomRoles.ts @@ -1,17 +1,19 @@ -import type { IRoom, ISubscription } from '@rocket.chat/core-typings'; +import type { IRoom } from '@rocket.chat/core-typings'; import type { ServerMethods } from '@rocket.chat/ddp-client'; import { Rooms } from '@rocket.chat/models'; import { check } from 'meteor/check'; import { Meteor } from 'meteor/meteor'; +import type { RoomRoles } from '../../../../server/lib/roles/getRoomRoles'; import { getRoomRoles } from '../../../../server/lib/roles/getRoomRoles'; import { canAccessRoomAsync } from '../../../authorization/server'; import { settings } from '../../../settings/server'; +import { methodDeprecationLogger } from '../lib/deprecationWarningLogger'; declare module '@rocket.chat/ddp-client' { // eslint-disable-next-line @typescript-eslint/naming-convention interface ServerMethods { - getRoomRoles(rid: IRoom['_id']): ISubscription[]; + getRoomRoles(rid: IRoom['_id']): RoomRoles[]; } } @@ -36,6 +38,7 @@ export const executeGetRoomRoles = async (rid: IRoom['_id'], fromUserId?: string Meteor.methods({ async getRoomRoles(rid) { + methodDeprecationLogger.method('getRoomRoles', '8.0.0', 'Use the /v1/room.getRoles endpoint instead'); const fromUserId = Meteor.userId(); return executeGetRoomRoles(rid, fromUserId); diff --git a/apps/meteor/app/lib/server/methods/getUserRoles.ts b/apps/meteor/app/lib/server/methods/getUserRoles.ts index cbd4712930a28..d9f8939611ac3 100644 --- a/apps/meteor/app/lib/server/methods/getUserRoles.ts +++ b/apps/meteor/app/lib/server/methods/getUserRoles.ts @@ -1,17 +1,25 @@ import { Authorization } from '@rocket.chat/core-services'; -import type { IUser, IRocketChatRecord } from '@rocket.chat/core-typings'; +import type { IUser } from '@rocket.chat/core-typings'; import type { ServerMethods } from '@rocket.chat/ddp-client'; import { Meteor } from 'meteor/meteor'; +import { methodDeprecationLogger } from '../lib/deprecationWarningLogger'; + declare module '@rocket.chat/ddp-client' { // eslint-disable-next-line @typescript-eslint/naming-convention interface ServerMethods { - getUserRoles(): (IRocketChatRecord & Pick)[]; + getUserRoles(): Pick[]; } } Meteor.methods({ async getUserRoles() { + methodDeprecationLogger.method( + 'getUserRoles', + '8.0.0', + 'This method is deprecated and will be removed in the future. Use the /v1/roles.getUsersInPublicRoles endpoint instead.', + ); + if (!Meteor.userId()) { throw new Meteor.Error('error-invalid-user', 'Invalid user', { method: 'getUserRoles' }); } diff --git a/apps/meteor/app/lib/server/methods/insertOrUpdateUser.ts b/apps/meteor/app/lib/server/methods/insertOrUpdateUser.ts index 3c5f07f624b39..7907d0e185ce6 100644 --- a/apps/meteor/app/lib/server/methods/insertOrUpdateUser.ts +++ b/apps/meteor/app/lib/server/methods/insertOrUpdateUser.ts @@ -15,7 +15,7 @@ declare module '@rocket.chat/ddp-client' { Meteor.methods({ insertOrUpdateUser: twoFactorRequired(async (userData) => { - methodDeprecationLogger.method('insertOrUpdateUser', '8.0.0'); + methodDeprecationLogger.method('insertOrUpdateUser', '8.0.0', '/v1/users.create'); check(userData, Object); diff --git a/apps/meteor/app/lib/server/methods/saveCustomFields.ts b/apps/meteor/app/lib/server/methods/saveCustomFields.ts index d683e59a905cc..be4b4c065fccc 100644 --- a/apps/meteor/app/lib/server/methods/saveCustomFields.ts +++ b/apps/meteor/app/lib/server/methods/saveCustomFields.ts @@ -4,6 +4,7 @@ import { Meteor } from 'meteor/meteor'; import { saveCustomFields } from '../functions/saveCustomFields'; import { RateLimiter } from '../lib'; +import { methodDeprecationLogger } from '../lib/deprecationWarningLogger'; declare module '@rocket.chat/ddp-client' { // eslint-disable-next-line @typescript-eslint/naming-convention @@ -14,6 +15,7 @@ declare module '@rocket.chat/ddp-client' { Meteor.methods({ async saveCustomFields(fields = {}) { + methodDeprecationLogger.method('saveCustomFields', '8.0.0', 'Use the endpoint /v1/users.updateOwnBasicInfo instead'); const uid = Meteor.userId(); if (!uid) { throw new Meteor.Error('error-invalid-user', 'Invalid user', { method: 'saveCustomFields' }); diff --git a/apps/meteor/app/lib/server/methods/setUsername.ts b/apps/meteor/app/lib/server/methods/setUsername.ts index 58fac75ed3bd7..cc8411ac9a688 100644 --- a/apps/meteor/app/lib/server/methods/setUsername.ts +++ b/apps/meteor/app/lib/server/methods/setUsername.ts @@ -4,6 +4,7 @@ import { Meteor } from 'meteor/meteor'; import { setUsernameWithValidation } from '../functions/setUsername'; import { RateLimiter } from '../lib'; +import { methodDeprecationLogger } from '../lib/deprecationWarningLogger'; declare module '@rocket.chat/ddp-client' { // eslint-disable-next-line @typescript-eslint/naming-convention @@ -14,6 +15,7 @@ declare module '@rocket.chat/ddp-client' { Meteor.methods({ async setUsername(username, param = {}) { + methodDeprecationLogger.method('setUsername', '8.0.0', 'Use the endpoint /v1/users.updateOwnBasicInfo instead'); check(username, String); const userId = Meteor.userId(); diff --git a/apps/meteor/app/livechat/imports/server/rest/departments.ts b/apps/meteor/app/livechat/imports/server/rest/departments.ts index 9a744e5e00a23..6c4bada41c6e0 100644 --- a/apps/meteor/app/livechat/imports/server/rest/departments.ts +++ b/apps/meteor/app/livechat/imports/server/rest/departments.ts @@ -1,5 +1,6 @@ import type { ILivechatDepartment } from '@rocket.chat/core-typings'; import { LivechatDepartment, LivechatDepartmentAgents } from '@rocket.chat/models'; +import { isDepartmentCreationAvailable } from '@rocket.chat/omni-core'; import { isGETLivechatDepartmentProps, isPOSTLivechatDepartmentProps } from '@rocket.chat/rest-typings'; import { isLivechatDepartmentDepartmentIdAgentsGETProps, @@ -26,7 +27,6 @@ import { saveDepartmentAgents, removeDepartment, } from '../../../server/lib/departmentsLib'; -import { isDepartmentCreationAvailable } from '../../../server/lib/isDepartmentCreationAvailable'; API.v1.addRoute( 'livechat/department', diff --git a/apps/meteor/app/livechat/server/business-hour/filterBusinessHoursThatMustBeOpened.spec.ts b/apps/meteor/app/livechat/server/business-hour/filterBusinessHoursThatMustBeOpened.spec.ts index 7255b3b03792a..6fe3c1e2c9bf7 100644 --- a/apps/meteor/app/livechat/server/business-hour/filterBusinessHoursThatMustBeOpened.spec.ts +++ b/apps/meteor/app/livechat/server/business-hour/filterBusinessHoursThatMustBeOpened.spec.ts @@ -2,7 +2,7 @@ import { LivechatBusinessHourTypes } from '@rocket.chat/core-typings'; import { filterBusinessHoursThatMustBeOpened } from './filterBusinessHoursThatMustBeOpened'; -describe('different timezones between server and business hours', () => { +describe('different timezones between server and business hours saturday ', () => { beforeEach(() => jest.useFakeTimers().setSystemTime(new Date('2024-04-20T20:10:11Z'))); afterEach(() => jest.useRealTimers()); it('should return a bh when the finish time resolves to a different day on server', async () => { @@ -52,3 +52,429 @@ describe('different timezones between server and business hours', () => { expect(bh.length).toEqual(1); }); }); + +describe('different timezones between server and business hours sunday ', () => { + beforeEach(() => jest.useFakeTimers().setSystemTime(new Date('2025-07-27T11:02:11Z'))); + afterEach(() => jest.useRealTimers()); + it('should return a bh when the finish time resolves to a different day on server', async () => { + const bh = await filterBusinessHoursThatMustBeOpened([ + { + _id: '68516f256ebb4bdceda2757e', + active: true, + type: LivechatBusinessHourTypes.DEFAULT, + ts: new Date(), + name: '', + workHours: [ + { + day: 'Sunday', + start: { + time: '00:00', + utc: { + dayOfWeek: 'Saturday', + time: '18:30', + }, + cron: { + dayOfWeek: 'Saturday', + time: '15:30', + }, + }, + finish: { + time: '23:59', + utc: { + dayOfWeek: 'Sunday', + time: '18:29', + }, + cron: { + dayOfWeek: 'Sunday', + time: '15:29', + }, + }, + open: true, + code: '', + }, + { + day: 'Monday', + start: { + time: '00:00', + utc: { + dayOfWeek: 'Sunday', + time: '18:30', + }, + cron: { + dayOfWeek: 'Sunday', + time: '15:30', + }, + }, + finish: { + time: '23:59', + utc: { + dayOfWeek: 'Monday', + time: '18:29', + }, + cron: { + dayOfWeek: 'Monday', + time: '15:29', + }, + }, + open: true, + code: '', + }, + { + day: 'Tuesday', + start: { + time: '00:00', + utc: { + dayOfWeek: 'Monday', + time: '18:30', + }, + cron: { + dayOfWeek: 'Monday', + time: '15:30', + }, + }, + finish: { + time: '23:59', + utc: { + dayOfWeek: 'Tuesday', + time: '18:29', + }, + cron: { + dayOfWeek: 'Tuesday', + time: '15:29', + }, + }, + open: true, + code: '', + }, + { + day: 'Wednesday', + start: { + time: '00:00', + utc: { + dayOfWeek: 'Tuesday', + time: '18:30', + }, + cron: { + dayOfWeek: 'Tuesday', + time: '15:30', + }, + }, + finish: { + time: '23:59', + utc: { + dayOfWeek: 'Wednesday', + time: '18:29', + }, + cron: { + dayOfWeek: 'Wednesday', + time: '15:29', + }, + }, + open: true, + code: '', + }, + { + day: 'Thursday', + start: { + time: '00:00', + utc: { + dayOfWeek: 'Wednesday', + time: '18:30', + }, + cron: { + dayOfWeek: 'Wednesday', + time: '15:30', + }, + }, + finish: { + time: '23:59', + utc: { + dayOfWeek: 'Thursday', + time: '18:29', + }, + cron: { + dayOfWeek: 'Thursday', + time: '15:29', + }, + }, + open: true, + code: '', + }, + { + day: 'Friday', + start: { + time: '00:00', + utc: { + dayOfWeek: 'Thursday', + time: '18:30', + }, + cron: { + dayOfWeek: 'Thursday', + time: '15:30', + }, + }, + finish: { + time: '23:59', + utc: { + dayOfWeek: 'Friday', + time: '18:29', + }, + cron: { + dayOfWeek: 'Friday', + time: '15:29', + }, + }, + open: true, + code: '', + }, + { + day: 'Saturday', + start: { + time: '00:00', + utc: { + dayOfWeek: 'Friday', + time: '18:30', + }, + cron: { + dayOfWeek: 'Friday', + time: '15:30', + }, + }, + finish: { + time: '23:59', + utc: { + dayOfWeek: 'Saturday', + time: '18:29', + }, + cron: { + dayOfWeek: 'Saturday', + time: '15:29', + }, + }, + open: true, + code: '', + }, + ], + timezone: { + name: 'Asia/Kolkata', + utc: '+05:30', + }, + }, + ]); + + expect(bh.length).toEqual(1); + }); +}); + +describe('regular business hours', () => { + beforeEach(() => jest.useFakeTimers().setSystemTime(new Date('2025-08-07T22:02:11Z'))); + afterEach(() => jest.useRealTimers()); + it('should return a bh when the finish time resolves to a different day on server', async () => { + const bh = await filterBusinessHoursThatMustBeOpened([ + { + _id: '68516f256ebb4bdceda2757e', + active: true, + type: LivechatBusinessHourTypes.DEFAULT, + ts: new Date(), + name: '', + workHours: [ + { + day: 'Sunday', + start: { + time: '00:00', + utc: { + dayOfWeek: 'Sunday', + time: '03:00', + }, + cron: { + dayOfWeek: 'Sunday', + time: '00:00', + }, + }, + finish: { + time: '00:01', + utc: { + dayOfWeek: 'Sunday', + time: '03:01', + }, + cron: { + dayOfWeek: 'Sunday', + time: '00:01', + }, + }, + open: true, + code: '', + }, + { + day: 'Monday', + start: { + time: '00:00', + utc: { + dayOfWeek: 'Monday', + time: '03:00', + }, + cron: { + dayOfWeek: 'Monday', + time: '00:00', + }, + }, + finish: { + time: '00:01', + utc: { + dayOfWeek: 'Monday', + time: '03:01', + }, + cron: { + dayOfWeek: 'Monday', + time: '00:01', + }, + }, + open: true, + code: '', + }, + { + day: 'Tuesday', + start: { + time: '00:00', + utc: { + dayOfWeek: 'Tuesday', + time: '03:00', + }, + cron: { + dayOfWeek: 'Tuesday', + time: '00:00', + }, + }, + finish: { + time: '00:01', + utc: { + dayOfWeek: 'Tuesday', + time: '03:01', + }, + cron: { + dayOfWeek: 'Tuesday', + time: '00:01', + }, + }, + open: true, + code: '', + }, + { + day: 'Wednesday', + start: { + time: '00:00', + utc: { + dayOfWeek: 'Wednesday', + time: '03:00', + }, + cron: { + dayOfWeek: 'Wednesday', + time: '00:00', + }, + }, + finish: { + time: '00:01', + utc: { + dayOfWeek: 'Wednesday', + time: '03:01', + }, + cron: { + dayOfWeek: 'Wednesday', + time: '00:01', + }, + }, + open: true, + code: '', + }, + { + day: 'Thursday', + start: { + time: '00:00', + utc: { + dayOfWeek: 'Thursday', + time: '03:00', + }, + cron: { + dayOfWeek: 'Thursday', + time: '00:00', + }, + }, + finish: { + time: '00:01', + utc: { + dayOfWeek: 'Thursday', + time: '03:01', + }, + cron: { + dayOfWeek: 'Thursday', + time: '00:01', + }, + }, + open: true, + code: '', + }, + { + day: 'Friday', + start: { + time: '00:00', + utc: { + dayOfWeek: 'Friday', + time: '03:00', + }, + cron: { + dayOfWeek: 'Friday', + time: '00:00', + }, + }, + finish: { + time: '00:01', + utc: { + dayOfWeek: 'Friday', + time: '03:01', + }, + cron: { + dayOfWeek: 'Friday', + time: '00:01', + }, + }, + open: true, + code: '', + }, + { + day: 'Saturday', + start: { + time: '00:00', + utc: { + dayOfWeek: 'Saturday', + time: '03:00', + }, + cron: { + dayOfWeek: 'Saturday', + time: '00:00', + }, + }, + finish: { + time: '00:01', + utc: { + dayOfWeek: 'Saturday', + time: '03:01', + }, + cron: { + dayOfWeek: 'Saturday', + time: '00:01', + }, + }, + open: true, + code: '', + }, + ], + timezone: { + name: 'America/Sao_Paulo', + utc: '-3', + }, + }, + ]); + + expect(bh.length).toEqual(0); + }); +}); diff --git a/apps/meteor/app/livechat/server/business-hour/filterBusinessHoursThatMustBeOpened.ts b/apps/meteor/app/livechat/server/business-hour/filterBusinessHoursThatMustBeOpened.ts index c06dbaac4e185..fb379eb9b6693 100644 --- a/apps/meteor/app/livechat/server/business-hour/filterBusinessHoursThatMustBeOpened.ts +++ b/apps/meteor/app/livechat/server/business-hour/filterBusinessHoursThatMustBeOpened.ts @@ -16,11 +16,32 @@ export const filterBusinessHoursThatMustBeOpened = async ( const localTimeStart = moment(`${hour.start.cron.dayOfWeek}:${hour.start.cron.time}:00`, 'dddd:HH:mm:ss'); const localTimeFinish = moment(`${hour.finish.cron.dayOfWeek}:${hour.finish.cron.time}:00`, 'dddd:HH:mm:ss'); - // The way we create the instances sunday will be the first day of the current week not the next one, that way it will never met isBefore + /** because we use `dayOfWeek` moment decides if saturday/sunday belongs to the current week or the next one, this is a bit + * confusing and for that reason we need this workaround + */ + + const currentDay = currentTime.format('dddd'); + const localTimeStartDay = localTimeStart.format('dddd'); + + // This only works for sundays (where we can test if sunday is before saturday = something is wrong) + + if (localTimeStart.isAfter(localTimeFinish)) { + localTimeStart.subtract(1, 'week'); + } if (localTimeFinish.isBefore(localTimeStart)) { localTimeFinish.add(1, 'week'); } + // During Saturday, if current weekday is the same but the start time is after the current time, we need to subtract a week + if (currentDay === localTimeStartDay && localTimeStart.diff(currentTime, 'days') > 0) { + localTimeStart.subtract(1, 'week'); + } + + // During Saturday, if current weekday is the same but the finish time is before the current time, we need to add a week + if (currentDay === localTimeStartDay && localTimeFinish.diff(currentTime, 'days') < 0) { + localTimeFinish.add(1, 'week'); + } + return currentTime.isSameOrAfter(localTimeStart) && currentTime.isBefore(localTimeFinish); }), ) diff --git a/apps/meteor/app/livechat/server/lib/Helper.ts b/apps/meteor/app/livechat/server/lib/Helper.ts index 89cd6eb21058a..dd901e1db3e04 100644 --- a/apps/meteor/app/livechat/server/lib/Helper.ts +++ b/apps/meteor/app/livechat/server/lib/Helper.ts @@ -657,7 +657,7 @@ export const forwardRoomToDepartment = async (room: IOmnichannelRoom, guest: ILi isWaitingQueueEnabled, }); await saveTransferHistory(room, transferData); - return RoutingManager.unassignAgent(inquiry, departmentId, true); + return RoutingManager.unassignAgent(inquiry, departmentId, true, agent); } // Fake the department to forward the inquiry - Case the forward process does not success @@ -681,7 +681,7 @@ export const forwardRoomToDepartment = async (room: IOmnichannelRoom, guest: ILi if (!chatQueued && oldServedBy && servedBy && oldServedBy._id === servedBy._id) { if (!department?.fallbackForwardDepartment?.length) { logger.debug(`Cannot forward room ${room._id}. Chat assigned to agent ${servedBy._id} (Previous was ${oldServedBy._id})`); - throw new Error('error-no-agents-online-in-department'); + throw new Error('error-no-agents-available-for-service-on-department'); } if (!transferData.originalDepartmentName) { diff --git a/apps/meteor/app/livechat/server/lib/RoutingManager.ts b/apps/meteor/app/livechat/server/lib/RoutingManager.ts index be18f07b08f95..d54afdafbac38 100644 --- a/apps/meteor/app/livechat/server/lib/RoutingManager.ts +++ b/apps/meteor/app/livechat/server/lib/RoutingManager.ts @@ -48,7 +48,12 @@ type Routing = { options?: { clientAction?: boolean; forwardingToDepartment?: { oldDepartmentId?: string; transferData?: any } }, room?: IOmnichannelRoom, ): Promise<(IOmnichannelRoom & { chatQueued?: boolean }) | null | void>; - unassignAgent(inquiry: ILivechatInquiryRecord, departmentId?: string, shouldQueue?: boolean): Promise; + unassignAgent( + inquiry: ILivechatInquiryRecord, + departmentId?: string, + shouldQueue?: boolean, + agent?: SelectedAgent | null, + ): Promise; takeInquiry( inquiry: Omit< ILivechatInquiryRecord, @@ -157,11 +162,19 @@ export const RoutingManager: Routing = { return { inquiry, user }; }, - async unassignAgent(inquiry, departmentId, shouldQueue = false) { + async unassignAgent(inquiry, departmentId, shouldQueue = false, defaultAgent?: SelectedAgent | null) { const { rid, department } = inquiry; const room = await LivechatRooms.findOneById(rid); - logger.debug(`Removing assignations of inquiry ${inquiry._id}`); + logger.debug({ + msg: 'Removing assignations of inquiry', + inquiryId: inquiry._id, + departmentId, + room: { _id: room?._id, open: room?.open, servedBy: room?.servedBy }, + shouldQueue, + defaultAgent, + }); + if (!room?.open) { logger.debug(`Cannot unassign agent from inquiry ${inquiry._id}: Room already closed`); return false; @@ -187,7 +200,7 @@ export const RoutingManager: Routing = { } if (shouldQueue) { - const queuedInquiry = await LivechatInquiry.queueInquiry(inquiry._id, room.lastMessage); + const queuedInquiry = await LivechatInquiry.queueInquiry(inquiry._id, room.lastMessage, defaultAgent); if (queuedInquiry) { inquiry = queuedInquiry; void notifyOnLivechatInquiryChanged(inquiry, 'updated', { @@ -256,7 +269,11 @@ export const RoutingManager: Routing = { return cbRoom; } - await LivechatInquiry.takeInquiry(_id); + const result = await LivechatInquiry.takeInquiry(_id, inquiry.lockedAt); + if (result.modifiedCount === 0) { + logger.error('Failed to take inquiry, could not match lockedAt', { inquiryId: _id, lockedAt: inquiry.lockedAt }); + throw new Error('error-taking-inquiry-lockedAt-mismatch'); + } logger.info(`Inquiry ${inquiry._id} taken by agent ${agent.agentId}`); diff --git a/apps/meteor/app/livechat/server/lib/departmentsLib.ts b/apps/meteor/app/livechat/server/lib/departmentsLib.ts index 31c891c23e5aa..ea2c0ce69148b 100644 --- a/apps/meteor/app/livechat/server/lib/departmentsLib.ts +++ b/apps/meteor/app/livechat/server/lib/departmentsLib.ts @@ -1,12 +1,12 @@ import { AppEvents, Apps } from '@rocket.chat/apps'; import type { LivechatDepartmentDTO, ILivechatDepartment, ILivechatDepartmentAgents, ILivechatAgent } from '@rocket.chat/core-typings'; import { LivechatDepartment, LivechatDepartmentAgents, LivechatVisitors, LivechatRooms, Users } from '@rocket.chat/models'; +import { isDepartmentCreationAvailable } from '@rocket.chat/omni-core'; import { check } from 'meteor/check'; import { Meteor } from 'meteor/meteor'; import { updateDepartmentAgents } from './Helper'; import { afterDepartmentArchived, afterDepartmentUnarchived } from './hooks'; -import { isDepartmentCreationAvailable } from './isDepartmentCreationAvailable'; import { livechatLogger } from './logger'; import { callbacks } from '../../../../lib/callbacks'; import { diff --git a/apps/meteor/app/livechat/server/methods/changeLivechatStatus.ts b/apps/meteor/app/livechat/server/methods/changeLivechatStatus.ts index e82b2e4d24687..c52f7a8a2e0b0 100644 --- a/apps/meteor/app/livechat/server/methods/changeLivechatStatus.ts +++ b/apps/meteor/app/livechat/server/methods/changeLivechatStatus.ts @@ -16,7 +16,7 @@ declare module '@rocket.chat/ddp-client' { Meteor.methods({ async 'livechat:changeLivechatStatus'({ status, agentId = Meteor.userId() } = {}) { - methodDeprecationLogger.method('livechat:changeLivechatStatus', '7.0.0'); + methodDeprecationLogger.method('livechat:changeLivechatStatus', '7.0.0', '/v1/livechat/agent.status'); const uid = Meteor.userId(); diff --git a/apps/meteor/app/livechat/server/methods/closeRoom.ts b/apps/meteor/app/livechat/server/methods/closeRoom.ts index 4d6daa5001cd8..3af000e1762ec 100644 --- a/apps/meteor/app/livechat/server/methods/closeRoom.ts +++ b/apps/meteor/app/livechat/server/methods/closeRoom.ts @@ -44,7 +44,7 @@ declare module '@rocket.chat/ddp-client' { Meteor.methods({ async 'livechat:closeRoom'(roomId: string, comment?: string, options?: CloseRoomOptions) { - methodDeprecationLogger.method('livechat:closeRoom', '7.0.0'); + methodDeprecationLogger.method('livechat:closeRoom', '7.0.0', '/v1/livechat/room.close'); const userId = Meteor.userId(); if (!userId || !(await hasPermissionAsync(userId, 'close-livechat-room'))) { diff --git a/apps/meteor/app/livechat/server/methods/getDepartmentForwardRestrictions.ts b/apps/meteor/app/livechat/server/methods/getDepartmentForwardRestrictions.ts index 2b891514d03e3..1fb6fbaf974ca 100644 --- a/apps/meteor/app/livechat/server/methods/getDepartmentForwardRestrictions.ts +++ b/apps/meteor/app/livechat/server/methods/getDepartmentForwardRestrictions.ts @@ -13,7 +13,7 @@ declare module '@rocket.chat/ddp-client' { Meteor.methods({ async 'livechat:getDepartmentForwardRestrictions'(departmentId) { - methodDeprecationLogger.method('livechat:getDepartmentForwardRestrictions', '7.0.0'); + methodDeprecationLogger.method('livechat:getDepartmentForwardRestrictions', '7.0.0', 'This functionality is no longer supported'); if (!Meteor.userId()) { throw new Meteor.Error('error-invalid-user', 'Invalid user', { method: 'livechat:getDepartmentForwardRestrictions', diff --git a/apps/meteor/app/livechat/server/methods/getFirstRoomMessage.ts b/apps/meteor/app/livechat/server/methods/getFirstRoomMessage.ts index d8accf7f84f51..e929e3f39ec07 100644 --- a/apps/meteor/app/livechat/server/methods/getFirstRoomMessage.ts +++ b/apps/meteor/app/livechat/server/methods/getFirstRoomMessage.ts @@ -16,7 +16,7 @@ declare module '@rocket.chat/ddp-client' { Meteor.methods({ async 'livechat:getFirstRoomMessage'({ rid }) { const uid = Meteor.userId(); - methodDeprecationLogger.method('livechat:getFirsRoomMessage', '7.0.0'); + methodDeprecationLogger.method('livechat:getFirsRoomMessage', '7.0.0', 'This functionality is no longer supported'); if (!uid || !(await hasPermissionAsync(uid, 'view-l-room'))) { throw new Meteor.Error('error-not-allowed', 'Not allowed', { method: 'livechat:getFirstRoomMessage', diff --git a/apps/meteor/app/livechat/server/methods/getTagsList.ts b/apps/meteor/app/livechat/server/methods/getTagsList.ts index b3efe5d026a32..3d6cdbd159789 100644 --- a/apps/meteor/app/livechat/server/methods/getTagsList.ts +++ b/apps/meteor/app/livechat/server/methods/getTagsList.ts @@ -14,7 +14,7 @@ declare module '@rocket.chat/ddp-client' { Meteor.methods({ 'livechat:getTagsList'() { - methodDeprecationLogger.method('livechat:getTagsList', '7.0.0'); + methodDeprecationLogger.method('livechat:getTagsList', '7.0.0', 'This functionality is no longer supported'); if (!Meteor.userId()) { throw new Meteor.Error('error-invalid-user', 'Invalid user', { method: 'livechat:getTagsList', diff --git a/apps/meteor/app/livechat/server/methods/transfer.ts b/apps/meteor/app/livechat/server/methods/transfer.ts index d4ce08b0a5619..9d01a944be8ef 100644 --- a/apps/meteor/app/livechat/server/methods/transfer.ts +++ b/apps/meteor/app/livechat/server/methods/transfer.ts @@ -27,7 +27,7 @@ declare module '@rocket.chat/ddp-client' { // TODO: Deprecated: Remove in v6.0.0 Meteor.methods({ async 'livechat:transfer'(transferData) { - methodDeprecationLogger.method('livechat:transfer', '7.0.0'); + methodDeprecationLogger.method('livechat:transfer', '7.0.0', '/v1/livechat/room.forward'); const uid = Meteor.userId(); if (!uid || !(await hasPermissionAsync(uid, 'view-l-room'))) { throw new Meteor.Error('error-not-allowed', 'Not allowed', { method: 'livechat:transfer' }); diff --git a/apps/meteor/app/models/client/index.ts b/apps/meteor/app/models/client/index.ts deleted file mode 100644 index 8b4b923ceaf86..0000000000000 --- a/apps/meteor/app/models/client/index.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { CachedChatRoom } from './models/CachedChatRoom'; -import { CachedChatSubscription } from './models/CachedChatSubscription'; -import { Messages } from './models/Messages'; -import { AuthzCachedCollection, Permissions } from './models/Permissions'; -import { Roles } from './models/Roles'; -import { Rooms } from './models/Rooms'; -import { Subscriptions } from './models/Subscriptions'; -import { Users } from './models/Users'; - -export { - Roles, - CachedChatRoom, - CachedChatSubscription, - AuthzCachedCollection, - Permissions, - /** @deprecated new code refer to Minimongo collections like this one; prefer fetching data from the REST API, listening to changes via streamer events, and storing the state in a Tanstack Query */ - Users, - /** @deprecated new code refer to Minimongo collections like this one; prefer fetching data from the REST API, listening to changes via streamer events, and storing the state in a Tanstack Query */ - Rooms, - /** @deprecated new code refer to Minimongo collections like this one; prefer fetching data from the REST API, listening to changes via streamer events, and storing the state in a Tanstack Query */ - Subscriptions, - /** @deprecated prefer fetching data from the REST API, listening to changes via streamer events, and storing the state in a Tanstack Query */ - Messages, -}; diff --git a/apps/meteor/app/models/client/models/Messages.ts b/apps/meteor/app/models/client/models/Messages.ts deleted file mode 100644 index 2ada45bdd105d..0000000000000 --- a/apps/meteor/app/models/client/models/Messages.ts +++ /dev/null @@ -1,11 +0,0 @@ -import type { IMessage } from '@rocket.chat/core-typings'; - -import { createDocumentMapStore } from '../../../../client/lib/cachedCollections/DocumentMapStore'; - -/** @deprecated prefer fetching data from the REST API, listening to changes via streamer events, and storing the state in a Tanstack Query */ -export const Messages = { - use: createDocumentMapStore(), - get state() { - return Messages.use.getState(); - }, -}; diff --git a/apps/meteor/app/models/client/models/Permissions.ts b/apps/meteor/app/models/client/models/Permissions.ts deleted file mode 100644 index 387c9c9613847..0000000000000 --- a/apps/meteor/app/models/client/models/Permissions.ts +++ /dev/null @@ -1,18 +0,0 @@ -import type { IPermission } from '@rocket.chat/core-typings'; -import type { StoreApi, UseBoundStore } from 'zustand'; - -import { PrivateCachedCollection } from '../../../../client/lib/cachedCollections'; -import type { IDocumentMapStore } from '../../../../client/lib/cachedCollections/DocumentMapStore'; - -type PermissionsStore = { - use: UseBoundStore>>; - readonly state: IDocumentMapStore; -}; - -export const AuthzCachedCollection = new PrivateCachedCollection({ - name: 'permissions', - eventType: 'notify-logged', -}); - -// We are restricting the type of the collection, removing Minimongo methods to avoid further usage, until full conversion to zustand store -export const Permissions = AuthzCachedCollection.collection as PermissionsStore; diff --git a/apps/meteor/app/models/client/models/Roles.ts b/apps/meteor/app/models/client/models/Roles.ts deleted file mode 100644 index ae9f84fc9a909..0000000000000 --- a/apps/meteor/app/models/client/models/Roles.ts +++ /dev/null @@ -1,31 +0,0 @@ -import type { IRole, IUser } from '@rocket.chat/core-typings'; - -import { Subscriptions } from './Subscriptions'; -import { Users } from './Users'; -import { createDocumentMapStore } from '../../../../client/lib/cachedCollections/DocumentMapStore'; - -/** @deprecated prefer fetching data from the REST API, listening to changes via streamer events, and storing the state in a Tanstack Query */ -export const Roles = { - use: createDocumentMapStore(), - get state() { - return Roles.use.getState(); - }, - isUserInRoles: (userId: IUser['_id'], roles: IRole['_id'][] | IRole['_id'], scope?: string, ignoreSubscriptions = false) => { - roles = Array.isArray(roles) ? roles : [roles]; - return roles.some((roleId) => { - const role = Roles.state.get(roleId); - const roleScope = ignoreSubscriptions ? 'Users' : role?.scope || 'Users'; - - switch (roleScope) { - case 'Subscriptions': - return Subscriptions.isUserInRole(userId, roleId, scope); - - case 'Users': - return Users.isUserInRole(userId, roleId); - - default: - return false; - } - }); - }, -}; diff --git a/apps/meteor/app/models/client/models/Rooms.ts b/apps/meteor/app/models/client/models/Rooms.ts deleted file mode 100644 index de6065e0ea713..0000000000000 --- a/apps/meteor/app/models/client/models/Rooms.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { CachedChatRoom } from './CachedChatRoom'; - -/** @deprecated new code refer to Minimongo collections like this one; prefer fetching data from the REST API, listening to changes via streamer events, and storing the state in a Tanstack Query */ -export const Rooms = { - use: CachedChatRoom.store, - get state() { - return this.use.getState(); - }, -} as const; diff --git a/apps/meteor/app/models/client/models/Subscriptions.ts b/apps/meteor/app/models/client/models/Subscriptions.ts deleted file mode 100644 index f9bd1cf82765d..0000000000000 --- a/apps/meteor/app/models/client/models/Subscriptions.ts +++ /dev/null @@ -1,20 +0,0 @@ -import type { IRole, IRoom, IUser } from '@rocket.chat/core-typings'; -import mem from 'mem'; - -import { CachedChatSubscription } from './CachedChatSubscription'; - -/** @deprecated new code refer to Minimongo collections like this one; prefer fetching data from the REST API, listening to changes via streamer events, and storing the state in a Tanstack Query */ -export const Subscriptions = Object.assign(CachedChatSubscription.collection, { - isUserInRole: mem( - function (this: typeof CachedChatSubscription.collection, _uid: IUser['_id'], roleId: IRole['_id'], rid?: IRoom['_id']) { - if (!rid) { - return false; - } - - const subscription = this.state.find((record) => record.rid === rid); - - return subscription && Array.isArray(subscription.roles) && subscription.roles.includes(roleId); - }, - { maxAge: 1000, cacheKey: JSON.stringify }, - ), -}); diff --git a/apps/meteor/app/models/client/models/Users.ts b/apps/meteor/app/models/client/models/Users.ts deleted file mode 100644 index ee085757e4786..0000000000000 --- a/apps/meteor/app/models/client/models/Users.ts +++ /dev/null @@ -1,24 +0,0 @@ -import type { IRole, IUser } from '@rocket.chat/core-typings'; - -import { MinimongoCollection } from '../../../../client/lib/cachedCollections/MinimongoCollection'; - -class UsersCollection extends MinimongoCollection { - isUserInRole(uid: IUser['_id'], roleId: IRole['_id']) { - const user = this.findOne({ _id: uid }, { fields: { roles: 1 } }); - return user && Array.isArray(user.roles) && user.roles.includes(roleId); - } - - findUsersInRoles(roles: IRole['_id'][] | IRole['_id'], _scope: string, options: any) { - roles = Array.isArray(roles) ? roles : [roles]; - - return this.find( - { - roles: { $in: roles }, - }, - options, - ); - } -} - -/** @deprecated new code refer to Minimongo collections like this one; prefer fetching data from the REST API, listening to changes via streamer events, and storing the state in a Tanstack Query */ -export const Users = new UsersCollection(); diff --git a/apps/meteor/app/nextcloud/client/index.ts b/apps/meteor/app/nextcloud/client/index.ts deleted file mode 100644 index 1897bf0839a1b..0000000000000 --- a/apps/meteor/app/nextcloud/client/index.ts +++ /dev/null @@ -1 +0,0 @@ -import './useNextcloud'; diff --git a/apps/meteor/app/oauth2-server-config/server/admin/functions/addOAuthApp.ts b/apps/meteor/app/oauth2-server-config/server/admin/functions/addOAuthApp.ts index 43bcd0ce14bd5..cba0ef59619cc 100644 --- a/apps/meteor/app/oauth2-server-config/server/admin/functions/addOAuthApp.ts +++ b/apps/meteor/app/oauth2-server-config/server/admin/functions/addOAuthApp.ts @@ -1,10 +1,10 @@ import type { IOAuthApps, IUser } from '@rocket.chat/core-typings'; import { OAuthApps, Users } from '@rocket.chat/models'; import { Random } from '@rocket.chat/random'; -import type { OauthAppsAddParams } from '@rocket.chat/rest-typings'; import { Meteor } from 'meteor/meteor'; import { parseUriList } from './parseUriList'; +import type { OauthAppsAddParams } from '../../../../api/server/v1/oauthapps'; import { hasPermissionAsync } from '../../../../authorization/server/functions/hasPermission'; export async function addOAuthApp(applicationParams: OauthAppsAddParams, uid: IUser['_id'] | undefined): Promise { diff --git a/apps/meteor/app/otr/client/OTRRoom.ts b/apps/meteor/app/otr/client/OTRRoom.ts index c4404b26fd8ab..0e861c6f4d316 100644 --- a/apps/meteor/app/otr/client/OTRRoom.ts +++ b/apps/meteor/app/otr/client/OTRRoom.ts @@ -11,7 +11,7 @@ import { Presence } from '../../../client/lib/presence'; import { dispatchToastMessage } from '../../../client/lib/toast'; import { getUidDirectMessage } from '../../../client/lib/utils/getUidDirectMessage'; import { goToRoomById } from '../../../client/lib/utils/goToRoomById'; -import { Messages } from '../../models/client'; +import { Messages } from '../../../client/stores'; import { sdk } from '../../utils/client/lib/SDKClient'; import { t } from '../../utils/lib/i18n'; import type { IOnUserStreamData, IOTRAlgorithm, IOTRDecrypt, IOTRRoom } from '../lib/IOTR'; @@ -263,9 +263,11 @@ export class OTRRoom implements IOTRRoom { } } - async encryptText(data: string | Uint8Array): Promise { + async encryptText(data: string | Uint8Array): Promise { if (typeof data === 'string') { - data = new TextEncoder().encode(EJSON.stringify({ text: data, ack: Random.id((Random.fraction() + 1) * 20) })); + data = new TextEncoder().encode( + EJSON.stringify({ text: data, ack: Random.id((Random.fraction() + 1) * 20) }), + ) as Uint8Array; } try { if (!this._sessionKey) throw new Error('Session Key not available'); @@ -292,7 +294,7 @@ export class OTRRoom implements IOTRRoom { ack: Random.id((Random.fraction() + 1) * 20), ts: new Date(), }), - ); + ) as Uint8Array; const enc = await this.encryptText(data); return enc; } catch (e) { @@ -304,7 +306,7 @@ export class OTRRoom implements IOTRRoom { try { if (!this._sessionKey) throw new Error('Session Key not available.'); - const cipherText: Uint8Array = EJSON.parse(message); + const cipherText: Uint8Array = EJSON.parse(message); const data = await decryptAES(cipherText, this._sessionKey); const msgDecoded: IOTRDecrypt = EJSON.parse(new TextDecoder('UTF-8').decode(new Uint8Array(data))); if (msgDecoded && typeof msgDecoded === 'object') { diff --git a/apps/meteor/app/otr/lib/IOTR.ts b/apps/meteor/app/otr/lib/IOTR.ts index c7aeb0a3777ac..c015c0dd425f6 100644 --- a/apps/meteor/app/otr/lib/IOTR.ts +++ b/apps/meteor/app/otr/lib/IOTR.ts @@ -11,7 +11,7 @@ export interface IOnUserStreamData { } export interface IOTRDecrypt { - ack: string | Uint8Array; + ack: string | Uint8Array; text: string; ts: Date; userId: IUser['_id']; diff --git a/apps/meteor/app/otr/lib/functions.ts b/apps/meteor/app/otr/lib/functions.ts index 3f2b14e8fdaa2..78dfb4b6d4fef 100644 --- a/apps/meteor/app/otr/lib/functions.ts +++ b/apps/meteor/app/otr/lib/functions.ts @@ -14,9 +14,9 @@ export const encryptAES = async ({ _sessionKey, data, }: { - iv: Uint8Array; + iv: Uint8Array; _sessionKey: CryptoKey; - data: Uint8Array; + data: Uint8Array; }): Promise => subtle.encrypt( { @@ -52,7 +52,7 @@ export const importKey = async (publicKeyObject: JsonWebKey): Promise false, [], ); -export const importKeyRaw = async (sessionKeyData: Uint8Array): Promise => +export const importKeyRaw = async (sessionKeyData: Uint8Array): Promise => subtle.importKey( 'raw', sessionKeyData, @@ -72,7 +72,7 @@ export const generateKeyPair = async (): Promise => false, ['deriveKey', 'deriveBits'], ); -export const decryptAES = async (cipherText: Uint8Array, _sessionKey: CryptoKey): Promise => { +export const decryptAES = async (cipherText: Uint8Array, _sessionKey: CryptoKey): Promise => { const iv = cipherText.slice(0, 12); cipherText = cipherText.slice(12); const data = await subtle.decrypt( diff --git a/apps/meteor/app/reactions/client/methods/setReaction.ts b/apps/meteor/app/reactions/client/methods/setReaction.ts index d1cab04412310..1da1a4669ddd1 100644 --- a/apps/meteor/app/reactions/client/methods/setReaction.ts +++ b/apps/meteor/app/reactions/client/methods/setReaction.ts @@ -3,8 +3,8 @@ import type { ServerMethods } from '@rocket.chat/ddp-client'; import { Meteor } from 'meteor/meteor'; import { roomCoordinator } from '../../../../client/lib/rooms/roomCoordinator'; +import { Rooms, Subscriptions, Messages } from '../../../../client/stores'; import { emoji } from '../../../emoji/client'; -import { Messages, Rooms, Subscriptions } from '../../../models/client'; Meteor.methods({ async setReaction(reaction, messageId) { @@ -40,7 +40,7 @@ Meteor.methods({ return false; } - if (!Subscriptions.findOne({ rid: message.rid })) { + if (!Subscriptions.state.find(({ rid }) => rid === message.rid)) { return false; } diff --git a/apps/meteor/app/reactions/server/setReaction.ts b/apps/meteor/app/reactions/server/setReaction.ts index be6e5aed4a545..53358ac1c94bf 100644 --- a/apps/meteor/app/reactions/server/setReaction.ts +++ b/apps/meteor/app/reactions/server/setReaction.ts @@ -11,6 +11,7 @@ import { canAccessRoomAsync } from '../../authorization/server'; import { hasPermissionAsync } from '../../authorization/server/functions/hasPermission'; import { emoji } from '../../emoji/server'; import { isTheLastMessage } from '../../lib/server/functions/isTheLastMessage'; +import { methodDeprecationLogger } from '../../lib/server/lib/deprecationWarningLogger'; import { notifyOnMessageChange } from '../../lib/server/lib/notifyListener'; export const removeUserReaction = (message: IMessage, reaction: string, username: string) => { @@ -161,6 +162,8 @@ declare module '@rocket.chat/ddp-client' { Meteor.methods({ async setReaction(reaction, messageId, shouldReact) { + methodDeprecationLogger.method('setReaction', '8.0.0', '/v1/chat.react'); + const uid = Meteor.userId(); if (!uid) { throw new Meteor.Error('error-invalid-user', 'Invalid user', { method: 'setReaction' }); diff --git a/apps/meteor/app/settings/client/lib/settings.ts b/apps/meteor/app/settings/client/lib/settings.ts index 0bc989e6bd7ca..9f466d8ee3b27 100644 --- a/apps/meteor/app/settings/client/lib/settings.ts +++ b/apps/meteor/app/settings/client/lib/settings.ts @@ -1,43 +1,39 @@ -import type { SettingValue } from '@rocket.chat/core-typings'; import { Meteor } from 'meteor/meteor'; -import { ReactiveDict } from 'meteor/reactive-dict'; -import { PublicSettingsCachedCollection } from '../../../../client/lib/settings/PublicSettingsCachedCollection'; +import { watch } from '../../../../client/lib/cachedStores'; +import { PublicSettings } from '../../../../client/stores'; import { SettingsBase } from '../../lib/settings'; class Settings extends SettingsBase { - cachedCollection = PublicSettingsCachedCollection; + private readonly store = PublicSettings.use; - collection = PublicSettingsCachedCollection.collection; - - dict = new ReactiveDict('settings'); - - get(_id: string | RegExp, ...args: []): TValue { + override get(_id: string | RegExp, ...args: []): TValue { if (_id instanceof RegExp) { throw new Error('RegExp Settings.get(RegExp)'); } if (args.length > 0) { throw new Error('settings.get(String, callback) only works on backend'); } - return this.dict.get(_id) as TValue; - } - private _storeSettingValue(record: { _id: string; value: SettingValue }, initialLoad: boolean): void { - Meteor.settings[record._id] = record.value; - this.dict.set(record._id, record.value); - this.load(record._id, record.value, initialLoad); + return watch(this.store, (state) => state.get(_id)?.value) as TValue; } init(): void { let initialLoad = true; - this.collection.find().observe({ - added: (record: { _id: string; value: SettingValue }) => this._storeSettingValue(record, initialLoad), - changed: (record: { _id: string; value: SettingValue }) => this._storeSettingValue(record, initialLoad), - removed: (record: { _id: string }) => { - delete Meteor.settings[record._id]; - this.dict.set(record._id, null); - this.load(record._id, undefined, initialLoad); - }, + this.store.subscribe((state) => { + const removedIds = new Set(Object.keys(Meteor.settings)).difference(new Set(state.records.keys())); + + for (const _id of removedIds) { + delete Meteor.settings[_id]; + this.load(_id, undefined, initialLoad); + } + + for (const setting of state.records.values()) { + if (setting.value !== Meteor.settings[setting._id]) { + Meteor.settings[setting._id] = setting.value; + this.load(setting._id, setting.value, initialLoad); + } + } }); initialLoad = false; } diff --git a/apps/meteor/app/slashcommands-open/client/client.ts b/apps/meteor/app/slashcommands-open/client/client.ts index 44270c4cea48b..88cee2fda319a 100644 --- a/apps/meteor/app/slashcommands-open/client/client.ts +++ b/apps/meteor/app/slashcommands-open/client/client.ts @@ -1,9 +1,8 @@ import type { RoomType, ISubscription, SlashCommandCallbackParams } from '@rocket.chat/core-typings'; -import type { Filter } from 'mongodb'; import { roomCoordinator } from '../../../client/lib/rooms/roomCoordinator'; import { router } from '../../../client/providers/RouterProvider'; -import { Subscriptions } from '../../models/client'; +import { Subscriptions } from '../../../client/stores'; import { sdk } from '../../utils/client/lib/SDKClient'; import { slashCommands } from '../../utils/client/slashCommand'; @@ -18,12 +17,11 @@ slashCommands.add({ const room = params.trim().replace(/#|@/, ''); const type = dict[params.trim()[0]] || []; - const query: Filter = { - name: room, - ...(type && { t: { $in: type } }), + const predicate = ({ name, t }: ISubscription) => { + return name === room && (type.length ? type.includes(t) : true); }; - const subscription = Subscriptions.findOne(query); + const subscription = Subscriptions.state.find(predicate); if (subscription) { roomCoordinator.openRouteLink(subscription.t, subscription, router.getSearchParameters()); @@ -34,7 +32,7 @@ slashCommands.add({ } try { await sdk.call('createDirectMessage', room); - const subscription = Subscriptions.findOne(query); + const subscription = Subscriptions.state.find(predicate); if (!subscription) { return; } diff --git a/apps/meteor/app/slashcommands-topic/client/topic.ts b/apps/meteor/app/slashcommands-topic/client/topic.ts index 4f04b565658e3..93ac1b01e6719 100644 --- a/apps/meteor/app/slashcommands-topic/client/topic.ts +++ b/apps/meteor/app/slashcommands-topic/client/topic.ts @@ -1,9 +1,9 @@ import type { SlashCommandCallbackParams } from '@rocket.chat/core-typings'; import { dispatchToastMessage } from '../../../client/lib/toast'; +import { Rooms } from '../../../client/stores'; import { callbacks } from '../../../lib/callbacks'; import { hasPermission } from '../../authorization/client'; -import { Rooms } from '../../models/client/models/Rooms'; import { sdk } from '../../utils/client/lib/SDKClient'; import { slashCommands } from '../../utils/client/slashCommand'; diff --git a/apps/meteor/app/ui-message/client/findParentMessage.ts b/apps/meteor/app/ui-message/client/findParentMessage.ts index bfd9bc961d474..375d7dc591de7 100644 --- a/apps/meteor/app/ui-message/client/findParentMessage.ts +++ b/apps/meteor/app/ui-message/client/findParentMessage.ts @@ -1,8 +1,8 @@ import type { IMessage } from '@rocket.chat/core-typings'; import { callWithErrorHandling } from '../../../client/lib/utils/callWithErrorHandling'; +import { Messages } from '../../../client/stores'; import { withDebouncing } from '../../../lib/utils/highOrderFunctions'; -import { Messages } from '../../models/client'; export const findParentMessage = (() => { const waiting: string[] = []; diff --git a/apps/meteor/app/ui-utils/client/index.ts b/apps/meteor/app/ui-utils/client/index.ts index 5912c38dc7d7a..e763a63307e0d 100644 --- a/apps/meteor/app/ui-utils/client/index.ts +++ b/apps/meteor/app/ui-utils/client/index.ts @@ -1,5 +1,4 @@ export { messageBox } from './lib/messageBox'; export { LegacyRoomManager } from './lib/LegacyRoomManager'; export { upsertMessage, RoomHistoryManager } from './lib/RoomHistoryManager'; -export { mainReady } from './lib/mainReady'; export { MessageTypes, MessageType } from '../lib/MessageTypes'; diff --git a/apps/meteor/app/ui-utils/client/lib/LegacyRoomManager.ts b/apps/meteor/app/ui-utils/client/lib/LegacyRoomManager.ts index 89dc39e07f38d..c24a770c68fac 100644 --- a/apps/meteor/app/ui-utils/client/lib/LegacyRoomManager.ts +++ b/apps/meteor/app/ui-utils/client/lib/LegacyRoomManager.ts @@ -9,8 +9,8 @@ import { roomCoordinator } from '../../../../client/lib/rooms/roomCoordinator'; import { fireGlobalEvent } from '../../../../client/lib/utils/fireGlobalEvent'; import { getConfig } from '../../../../client/lib/utils/getConfig'; import { modifyMessageOnFilesDelete } from '../../../../client/lib/utils/modifyMessageOnFilesDelete'; +import { Messages, Subscriptions } from '../../../../client/stores'; import { callbacks } from '../../../../lib/callbacks'; -import { Messages, Subscriptions } from '../../../models/client'; import { sdk } from '../../../utils/client/lib/SDKClient'; const maxRoomsOpen = parseInt(getConfig('maxRoomsOpen') ?? '5') || 5; @@ -175,7 +175,7 @@ const openRoom = (typeName: string, record: OpenedRoom) => { // } // Do not load command messages into channel if (msg.t !== 'command') { - const subscription = Subscriptions.findOne({ rid: record.rid }, { reactive: false }); + const subscription = Subscriptions.state.find(({ rid }) => rid === record.rid); const isNew = !Messages.state.find((record) => record._id === msg._id && record.temp !== true); ({ _id: msg._id, temp: { $ne: true } }); await upsertMessage({ msg, subscription }); diff --git a/apps/meteor/app/ui-utils/client/lib/MessageAction.ts b/apps/meteor/app/ui-utils/client/lib/MessageAction.ts index 5a1710dc48308..9c024554c0f8f 100644 --- a/apps/meteor/app/ui-utils/client/lib/MessageAction.ts +++ b/apps/meteor/app/ui-utils/client/lib/MessageAction.ts @@ -24,8 +24,6 @@ export type MessageActionConfig = { variant?: 'danger' | 'success' | 'warning'; label: TranslationKey; order: number; - /** @deprecated */ - color?: 'alert'; group: MessageActionGroup; context?: MessageActionContext[]; action: (e: Pick | undefined) => any; diff --git a/apps/meteor/app/ui-utils/client/lib/RoomHistoryManager.ts b/apps/meteor/app/ui-utils/client/lib/RoomHistoryManager.ts index 1d890c85f8c30..b604ae69dac99 100644 --- a/apps/meteor/app/ui-utils/client/lib/RoomHistoryManager.ts +++ b/apps/meteor/app/ui-utils/client/lib/RoomHistoryManager.ts @@ -10,7 +10,7 @@ import { onClientMessageReceived } from '../../../../client/lib/onClientMessageR import { callWithErrorHandling } from '../../../../client/lib/utils/callWithErrorHandling'; import { getConfig } from '../../../../client/lib/utils/getConfig'; import { waitForElement } from '../../../../client/lib/utils/waitForElement'; -import { Messages, Subscriptions } from '../../../models/client'; +import { Messages, Subscriptions } from '../../../../client/stores'; import { getUserPreference } from '../../../utils/client'; const waitAfterFlush = () => new Promise((resolve) => Tracker.afterFlush(() => resolve(void 0))); @@ -133,7 +133,7 @@ class RoomHistoryManagerClass extends Emitter { let ls = undefined; - const subscription = Subscriptions.findOne({ rid }); + const subscription = Subscriptions.state.find((record) => record.rid === rid); if (subscription) { ({ ls } = subscription); } @@ -232,7 +232,7 @@ class RoomHistoryManagerClass extends Emitter { (a, b) => b.ts.getTime() - a.ts.getTime(), ); - const subscription = Subscriptions.findOne({ rid }); + const subscription = Subscriptions.state.find((record) => record.rid === rid); if (lastMessage?.ts) { const { ts } = lastMessage; @@ -308,8 +308,7 @@ class RoomHistoryManagerClass extends Emitter { const room = this.getRoom(message.rid); - const subscription = Subscriptions.findOne({ rid: message.rid }); - + const subscription = Subscriptions.state.find((record) => record.rid === message.rid); const result = await callWithErrorHandling('loadSurroundingMessages', message, defaultLimit); this.clear(message.rid); diff --git a/apps/meteor/app/ui-utils/client/lib/mainReady.ts b/apps/meteor/app/ui-utils/client/lib/mainReady.ts deleted file mode 100644 index ee5041f3e6bce..0000000000000 --- a/apps/meteor/app/ui-utils/client/lib/mainReady.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { ReactiveVar } from 'meteor/reactive-var'; - -export const mainReady = new ReactiveVar(false); diff --git a/apps/meteor/app/ui/client/lib/UserAction.ts b/apps/meteor/app/ui/client/lib/UserAction.ts index 8c729f94e4ccb..163f1f3ad1b7e 100644 --- a/apps/meteor/app/ui/client/lib/UserAction.ts +++ b/apps/meteor/app/ui/client/lib/UserAction.ts @@ -1,9 +1,9 @@ -import type { IExtras, IRoomActivity, IActionsObject, IUser } from '@rocket.chat/core-typings'; +import type { IExtras, IRoomActivity, IUser } from '@rocket.chat/core-typings'; +import { Emitter } from '@rocket.chat/emitter'; import { debounce } from 'lodash'; import { Meteor } from 'meteor/meteor'; -import { ReactiveDict } from 'meteor/reactive-dict'; -import { Users } from '../../../models/client'; +import { Users } from '../../../../client/stores'; import { settings } from '../../../settings/client'; import { sdk } from '../../../utils/client/lib/SDKClient'; @@ -25,7 +25,8 @@ const continuingIntervals = new Map(); const roomActivities = new Map>(); const rooms = new Map void>(); -const performingUsers = new ReactiveDict(); +const performingUsers = new Map(); +const performingUsersEmitter = new Emitter<{ changed: void }>(); const shownName = function (user: IUser | null | undefined): string | undefined { if (!user) { @@ -44,7 +45,7 @@ const emitActivities = debounce(async (rid: string, extras: IExtras): Promise void { @@ -72,9 +74,9 @@ export const UserAction = new (class { } const handler = function (username: string, activityType: string[], extras?: object): void { - const user = Users.findOne(Meteor.userId() || undefined, { - fields: { name: 1, username: 1 }, - }) as IUser; + const uid = Meteor.userId(); + const user = uid ? Users.state.get(uid) : undefined; + if (username === shownName(user)) { return; } @@ -169,4 +171,8 @@ export const UserAction = new (class { get(roomId: string): IRoomActivity | undefined { return performingUsers.get(roomId); } + + subscribe(onChanged: () => void): () => void { + return performingUsersEmitter.on('changed', onChanged); + } })(); diff --git a/apps/meteor/app/utils/client/lib/getUserPreference.ts b/apps/meteor/app/utils/client/lib/getUserPreference.ts index 95320ef180447..a704effe71156 100644 --- a/apps/meteor/app/utils/client/lib/getUserPreference.ts +++ b/apps/meteor/app/utils/client/lib/getUserPreference.ts @@ -1,6 +1,6 @@ import type { IUser } from '@rocket.chat/core-typings'; -import { Users } from '../../../models/client'; +import { Users } from '../../../../client/stores'; import { settings } from '../../../settings/client'; /** @@ -42,7 +42,6 @@ export function getUserPreference( key: string, defaultValue?: TValue, ): TValue { - const user = - typeof userIdOrUser === 'string' ? Users.findOne(userIdOrUser, { fields: { [`settings.preferences.${key}`]: 1 } }) : userIdOrUser; + const user = typeof userIdOrUser === 'string' ? Users.state.get(userIdOrUser) : userIdOrUser; return user?.settings?.preferences?.[key] ?? defaultValue ?? settings.get(`Accounts_Default_User_Preferences_${key}`); } diff --git a/apps/meteor/app/utils/rocketchat.info b/apps/meteor/app/utils/rocketchat.info index f147c1f7c470d..a471a0398799d 100644 --- a/apps/meteor/app/utils/rocketchat.info +++ b/apps/meteor/app/utils/rocketchat.info @@ -1,3 +1,3 @@ { - "version": "7.9.3" + "version": "7.10.0-rc.6" } diff --git a/apps/meteor/app/webdav/server/methods/getFileFromWebdav.ts b/apps/meteor/app/webdav/server/methods/getFileFromWebdav.ts index 38aeb0442c5c6..f8a2281586e44 100644 --- a/apps/meteor/app/webdav/server/methods/getFileFromWebdav.ts +++ b/apps/meteor/app/webdav/server/methods/getFileFromWebdav.ts @@ -10,7 +10,7 @@ import { WebdavClientAdapter } from '../lib/webdavClientAdapter'; declare module '@rocket.chat/ddp-client' { // eslint-disable-next-line @typescript-eslint/naming-convention interface ServerMethods { - getFileFromWebdav(accountId: IWebdavAccount['_id'], file: IWebdavNode): Promise<{ success: boolean; data: Uint8Array }>; + getFileFromWebdav(accountId: IWebdavAccount['_id'], file: IWebdavNode): Promise<{ success: boolean; data: Uint8Array }>; } } diff --git a/apps/meteor/app/webrtc/client/WebRTCClass.ts b/apps/meteor/app/webrtc/client/WebRTCClass.ts index 4c5cf677a0793..4b6486ce3b925 100644 --- a/apps/meteor/app/webrtc/client/WebRTCClass.ts +++ b/apps/meteor/app/webrtc/client/WebRTCClass.ts @@ -7,7 +7,7 @@ import { ReactiveVar } from 'meteor/reactive-var'; import { ChromeScreenShare } from './screenShare'; import { goToRoomById } from '../../../client/lib/utils/goToRoomById'; -import { Subscriptions, Users } from '../../models/client'; +import { Subscriptions, Users } from '../../../client/stores'; import { settings } from '../../settings/client'; import { sdk } from '../../utils/client/lib/SDKClient'; import { t } from '../../utils/lib/i18n'; @@ -820,34 +820,28 @@ class WebRTCClass { return; } - const user = Users.findOne(data.from); - let fromUsername = undefined; - if (user?.username) { - fromUsername = user.username; - } - const subscription = Subscriptions.findOne({ - rid: data.room, - })!; + const username = data.from ? Users.state.get(data.from)?.username : undefined; + const subscription = Subscriptions.state.find(({ rid }) => rid === data.room); let icon; let title; if (data.monitor === true) { icon = 'eye' as const; - title = t('WebRTC_monitor_call_from_%s', fromUsername); + title = t('WebRTC_monitor_call_from_%s', username); } else if (subscription && subscription.t === 'd') { if (data.media?.video) { icon = 'video' as const; - title = t('WebRTC_direct_video_call_from_%s', fromUsername); + title = t('WebRTC_direct_video_call_from_%s', username); } else { icon = 'phone' as const; - title = t('WebRTC_direct_audio_call_from_%s', fromUsername); + title = t('WebRTC_direct_audio_call_from_%s', username); } } else if (data.media?.video) { icon = 'video' as const; - title = t('WebRTC_group_video_call_from_%s', subscription.name); + title = t('WebRTC_group_video_call_from_%s', subscription?.name); } else { icon = 'phone' as const; - title = t('WebRTC_group_audio_call_from_%s', subscription.name); + title = t('WebRTC_group_audio_call_from_%s', subscription?.name); } imperativeModal.open({ @@ -1035,7 +1029,7 @@ const WebRTC = new (class { getInstanceByRoomId(rid: IRoom['_id'], visitorId: string | null = null) { let enabled = false; if (!visitorId) { - const subscription = Subscriptions.findOne({ rid }); + const subscription = Subscriptions.state.find((record) => record.rid === rid); if (!subscription) { return; } diff --git a/apps/meteor/app/webrtc/client/actionLink.tsx b/apps/meteor/app/webrtc/client/actionLink.tsx index 21233d0ff95cd..e3bc39743e384 100644 --- a/apps/meteor/app/webrtc/client/actionLink.tsx +++ b/apps/meteor/app/webrtc/client/actionLink.tsx @@ -2,7 +2,7 @@ import type { IMessage } from '@rocket.chat/core-typings'; import { actionLinks } from '../../../client/lib/actionLinks'; import { dispatchToastMessage } from '../../../client/lib/toast'; -import { Rooms } from '../../models/client'; +import { Rooms } from '../../../client/stores'; import { sdk } from '../../utils/client/lib/SDKClient'; import { t } from '../../utils/lib/i18n'; diff --git a/apps/meteor/client/NavBarV2/NavBarPagesGroup/NavBarPagesGroup.tsx b/apps/meteor/client/NavBarV2/NavBarPagesGroup/NavBarPagesGroup.tsx index f4d4a89068bac..946ea592502be 100644 --- a/apps/meteor/client/NavBarV2/NavBarPagesGroup/NavBarPagesGroup.tsx +++ b/apps/meteor/client/NavBarV2/NavBarPagesGroup/NavBarPagesGroup.tsx @@ -27,8 +27,8 @@ const NavBarPagesGroup = () => { )} {showMarketplace && !isMobile && } - {!isMobile && } + ); }; diff --git a/apps/meteor/client/NavBarV2/NavBarPagesGroup/actions/CreateChannelModal.spec.tsx b/apps/meteor/client/NavBarV2/NavBarPagesGroup/actions/CreateChannelModal.spec.tsx new file mode 100644 index 0000000000000..81603cdeeca54 --- /dev/null +++ b/apps/meteor/client/NavBarV2/NavBarPagesGroup/actions/CreateChannelModal.spec.tsx @@ -0,0 +1,8 @@ +import { testCreateChannelModal } from './testCreateChannelModal'; +import CreateChannelModalComponent from '../../../sidebar/header/CreateChannel'; + +jest.mock('../../../lib/utils/goToRoomById', () => ({ + goToRoomById: jest.fn(), +})); + +testCreateChannelModal(CreateChannelModalComponent); diff --git a/apps/meteor/client/NavBarV2/NavBarPagesGroup/actions/CreateChannelModal.tsx b/apps/meteor/client/NavBarV2/NavBarPagesGroup/actions/CreateChannelModal.tsx index f49c23719bb80..0cd417c2e3b0d 100644 --- a/apps/meteor/client/NavBarV2/NavBarPagesGroup/actions/CreateChannelModal.tsx +++ b/apps/meteor/client/NavBarV2/NavBarPagesGroup/actions/CreateChannelModal.tsx @@ -22,20 +22,14 @@ import { ModalFooterControllers, } from '@rocket.chat/fuselage'; import type { TranslationKey } from '@rocket.chat/ui-contexts'; -import { - useSetting, - useTranslation, - useEndpoint, - usePermission, - useToastMessageDispatch, - usePermissionWithScopedRoles, -} from '@rocket.chat/ui-contexts'; +import { useSetting, useTranslation, useEndpoint, useToastMessageDispatch, usePermissionWithScopedRoles } from '@rocket.chat/ui-contexts'; import type { ComponentProps, ReactElement } from 'react'; import { useId, useEffect, useMemo } from 'react'; import { useForm, Controller } from 'react-hook-form'; import { useEncryptedRoomDescription } from './useEncryptedRoomDescription'; import UserAutoCompleteMultipleFederated from '../../../components/UserAutoCompleteMultiple/UserAutoCompleteMultipleFederated'; +import { useCreateChannelTypePermission } from '../../../hooks/useCreateChannelTypePermission'; import { useHasLicenseModule } from '../../../hooks/useHasLicenseModule'; import { goToRoomById } from '../../../lib/utils/goToRoomById'; @@ -75,8 +69,6 @@ const CreateChannelModal = ({ teamId = '', onClose, reload }: CreateChannelModal const federationEnabled = useSetting('Federation_Matrix_enabled', false); const e2eEnabledForPrivateByDefault = useSetting('E2E_Enabled_Default_PrivateRooms') && e2eEnabled; - const canCreateChannel = usePermission('create-c'); - const canCreatePrivateChannel = usePermission('create-p'); const getEncryptedHint = useEncryptedRoomDescription('channel'); const channelNameRegex = useMemo(() => new RegExp(`^${namesValidation}$`), [namesValidation]); @@ -89,15 +81,7 @@ const CreateChannelModal = ({ teamId = '', onClose, reload }: CreateChannelModal const dispatchToastMessage = useToastMessageDispatch(); - const canOnlyCreateOneType = useMemo(() => { - if (!canCreateChannel && canCreatePrivateChannel) { - return 'p'; - } - if (canCreateChannel && !canCreatePrivateChannel) { - return 'c'; - } - return false; - }, [canCreateChannel, canCreatePrivateChannel]); + const canOnlyCreateOneType = useCreateChannelTypePermission(); const { register, @@ -123,23 +107,23 @@ const CreateChannelModal = ({ teamId = '', onClose, reload }: CreateChannelModal const { isPrivate, broadcast, readOnly, federated, encrypted } = watch(); useEffect(() => { - if (!isPrivate) { - setValue('encrypted', false); - } - - if (broadcast) { - setValue('encrypted', false); - } - if (federated) { // if room is federated, it cannot be encrypted or broadcast or readOnly setValue('encrypted', false); setValue('broadcast', false); setValue('readOnly', false); } + }, [federated, setValue]); + + useEffect(() => { + if (!isPrivate) { + setValue('encrypted', false); + } + }, [isPrivate, setValue]); + useEffect(() => { setValue('readOnly', broadcast); - }, [federated, setValue, broadcast, isPrivate]); + }, [broadcast, setValue]); const validateChannelName = async (name: string): Promise => { if (!name) { @@ -189,10 +173,7 @@ const CreateChannelModal = ({ teamId = '', onClose, reload }: CreateChannelModal } }; - const e2eDisabled = useMemo( - () => !isPrivate || broadcast || Boolean(!e2eEnabled) || Boolean(e2eEnabledForPrivateByDefault), - [e2eEnabled, e2eEnabledForPrivateByDefault, broadcast, isPrivate], - ); + const e2eDisabled = useMemo(() => !isPrivate || Boolean(!e2eEnabled) || federated, [e2eEnabled, federated, isPrivate]); const createChannelFormId = useId(); const nameId = useId(); @@ -242,7 +223,7 @@ const CreateChannelModal = ({ teamId = '', onClose, reload }: CreateChannelModal {errors.name.message} )} - {!allowSpecialNames && {t('No_spaces')}} + {!allowSpecialNames && {t('No_spaces_or_special_characters')}} {t('Topic')} @@ -272,7 +253,7 @@ const CreateChannelModal = ({ teamId = '', onClose, reload }: CreateChannelModal id={privateId} aria-describedby={`${privateId}-hint`} ref={ref} - checked={value} + checked={canOnlyCreateOneType ? canOnlyCreateOneType === 'p' : value} disabled={!!canOnlyCreateOneType} onChange={onChange} /> @@ -292,13 +273,16 @@ const CreateChannelModal = ({ teamId = '', onClose, reload }: CreateChannelModal - {t('Federation_Matrix_Federated')} + + {t('Federation_Matrix_Federated')} + ( - {t('Encrypted')} + + {t('Encrypted')} + )} /> - {getEncryptedHint({ isPrivate, broadcast, encrypted })} + {getEncryptedHint({ isPrivate, encrypted })} - {t('Read_only')} + + {t('Read_only')} + - {t('Broadcast')} + + {t('Broadcast')} + ( void }; @@ -76,6 +72,8 @@ const CreateTeamModal = ({ onClose }: CreateTeamModalProps) => { return new RegExp(`^${namesValidation}$`); }, [allowSpecialNames, namesValidation]); + const canOnlyCreateOneType = useCreateChannelTypePermission(); + const validateTeamName = async (name: string): Promise => { if (!name) { return; @@ -100,13 +98,11 @@ const CreateTeamModal = ({ onClose }: CreateTeamModalProps) => { formState: { errors, isSubmitting }, } = useForm({ defaultValues: { - isPrivate: true, + isPrivate: canOnlyCreateOneType ? canOnlyCreateOneType === 'p' : true, readOnly: false, encrypted: (e2eEnabledForPrivateByDefault as boolean) ?? false, broadcast: false, members: [], - showChannels: true, - showDiscussions: true, }, }); @@ -136,10 +132,7 @@ const CreateTeamModal = ({ onClose }: CreateTeamModalProps) => { topic, broadcast, encrypted, - showChannels, - showDiscussions, }: CreateTeamModalInputs): Promise => { - const sidepanelItem = [showChannels && 'channels', showDiscussions && 'discussions'].filter(Boolean) as [SidepanelItem, SidepanelItem?]; const params = { name, members, @@ -152,7 +145,6 @@ const CreateTeamModal = ({ onClose }: CreateTeamModalProps) => { encrypted, }, }, - ...((showChannels || showDiscussions) && { sidepanel: { items: sidepanelItem } }), }; try { @@ -174,8 +166,6 @@ const CreateTeamModal = ({ onClose }: CreateTeamModalProps) => { const encryptedId = useId(); const broadcastId = useId(); const addMembersId = useId(); - const showChannelsId = useId(); - const showDiscussionsId = useId(); return ( { {errors.name.message} )} - {!allowSpecialNames && {t('No_spaces')}} + {!allowSpecialNames && {t('No_spaces_or_special_characters')}} {t('Topic')} @@ -244,7 +234,14 @@ const CreateTeamModal = ({ onClose }: CreateTeamModalProps) => { control={control} name='isPrivate' render={({ field: { onChange, value, ref } }): ReactElement => ( - + )} /> @@ -255,56 +252,6 @@ const CreateTeamModal = ({ onClose }: CreateTeamModalProps) => { - - {null} - - - - {t('Navigation')} - - - - {t('Channels')} - ( - - )} - /> - - {t('Show_channels_description')} - - - - - {t('Discussions')} - ( - - )} - /> - - {t('Show_discussions_description')} - - - - - {t('Security_and_permissions')} diff --git a/apps/meteor/client/NavBarV2/NavBarPagesGroup/actions/testCreateChannelModal.tsx b/apps/meteor/client/NavBarV2/NavBarPagesGroup/actions/testCreateChannelModal.tsx new file mode 100644 index 0000000000000..e2971bd6fa36b --- /dev/null +++ b/apps/meteor/client/NavBarV2/NavBarPagesGroup/actions/testCreateChannelModal.tsx @@ -0,0 +1,179 @@ +import { mockAppRoot } from '@rocket.chat/mock-providers'; +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; + +import type CreateChannelModal2Component from './CreateChannelModal'; +import type CreateChannelModalComponent from '../../../sidebar/header/CreateChannel'; + +// eslint-disable-next-line @typescript-eslint/naming-convention +export function testCreateChannelModal(CreateChannelModal: typeof CreateChannelModalComponent | typeof CreateChannelModal2Component) { + describe('CreateChannelModal', () => { + it('should render with encryption option disabled and set to off when E2E_Enable=false and E2E_Enabled_Default_PrivateRooms=false', async () => { + render( null} />, { + wrapper: mockAppRoot().withSetting('E2E_Enable', false).withSetting('E2E_Enabled_Default_PrivateRooms', false).build(), + }); + + await userEvent.click(screen.getByText('Advanced_settings')); + + const encrypted = screen.getByLabelText('Encrypted') as HTMLInputElement; + expect(encrypted).toBeInTheDocument(); + expect(encrypted).not.toBeChecked(); + expect(encrypted).toBeDisabled(); + }); + + it('should render with encryption option enabled and set to off when E2E_Enable=true and E2E_Enabled_Default_PrivateRooms=false', async () => { + render( null} />, { + wrapper: mockAppRoot().withSetting('E2E_Enable', true).withSetting('E2E_Enabled_Default_PrivateRooms', false).build(), + }); + + await userEvent.click(screen.getByText('Advanced_settings')); + + const encrypted = screen.getByLabelText('Encrypted') as HTMLInputElement; + expect(encrypted).toBeInTheDocument(); + expect(encrypted).not.toBeChecked(); + expect(encrypted).toBeEnabled(); + }); + + it('should render with encryption option disabled and set to off when E2E_Enable=false and E2E_Enabled_Default_PrivateRooms=true', async () => { + render( null} />, { + wrapper: mockAppRoot().withSetting('E2E_Enable', false).withSetting('E2E_Enabled_Default_PrivateRooms', true).build(), + }); + + await userEvent.click(screen.getByText('Advanced_settings')); + + const encrypted = screen.getByLabelText('Encrypted') as HTMLInputElement; + expect(encrypted).toBeInTheDocument(); + + expect(encrypted).not.toBeChecked(); + expect(encrypted).toBeDisabled(); + }); + + it('should render with encryption option enabled and set to on when E2E_Enable=true and E2E_Enabled_Default_PrivateRooms=True', async () => { + render( null} />, { + wrapper: mockAppRoot().withSetting('E2E_Enable', true).withSetting('E2E_Enabled_Default_PrivateRooms', true).build(), + }); + + await userEvent.click(screen.getByText('Advanced_settings')); + + const encrypted = screen.getByLabelText('Encrypted') as HTMLInputElement; + expect(encrypted).toBeChecked(); + expect(encrypted).toBeEnabled(); + }); + + it('when Private goes ON → OFF: forces Encrypted OFF and disables it (E2E_Enable=true, E2E_Enabled_Default_PrivateRooms=true)', async () => { + render( null} />, { + wrapper: mockAppRoot().withSetting('E2E_Enable', true).withSetting('E2E_Enabled_Default_PrivateRooms', true).build(), + }); + + await userEvent.click(screen.getByText('Advanced_settings')); + + const encrypted = screen.getByLabelText('Encrypted') as HTMLInputElement; + const priv = screen.getByLabelText('Private') as HTMLInputElement; + + // initial: private=true, encrypted ON and enabled + expect(priv).toBeChecked(); + expect(encrypted).toBeChecked(); + expect(encrypted).toBeEnabled(); + + // Private ON -> OFF: encrypted must become OFF and disabled + await userEvent.click(priv); + expect(priv).not.toBeChecked(); + expect(encrypted).not.toBeChecked(); + expect(encrypted).toBeDisabled(); + }); + + it('when Private goes OFF → ON: keeps Encrypted OFF but re-enables it (E2E_Enable=true, E2E_Enabled_Default_PrivateRooms=true)', async () => { + render( null} />, { + wrapper: mockAppRoot().withSetting('E2E_Enable', true).withSetting('E2E_Enabled_Default_PrivateRooms', true).build(), + }); + + await userEvent.click(screen.getByText('Advanced_settings')); + + const encrypted = screen.getByLabelText('Encrypted') as HTMLInputElement; + const priv = screen.getByLabelText('Private') as HTMLInputElement; + + // turn private OFF to simulate user path from non-private + await userEvent.click(priv); + expect(priv).not.toBeChecked(); + expect(encrypted).not.toBeChecked(); + expect(encrypted).toBeDisabled(); + + // turn private back ON -> encrypted should remain OFF but become enabled + await userEvent.click(priv); + expect(priv).toBeChecked(); + expect(encrypted).not.toBeChecked(); + expect(encrypted).toBeEnabled(); + }); + + it('private room: toggling Broadcast on/off does not change or disable Encrypted', async () => { + render( null} />, { + wrapper: mockAppRoot().withSetting('E2E_Enable', true).withSetting('E2E_Enabled_Default_PrivateRooms', true).build(), + }); + + await userEvent.click(screen.getByText('Advanced_settings')); + + const encrypted = screen.getByLabelText('Encrypted') as HTMLInputElement; + const broadcast = screen.getByLabelText('Broadcast') as HTMLInputElement; + const priv = screen.getByLabelText('Private') as HTMLInputElement; + + expect(priv).toBeChecked(); + expect(encrypted).toBeChecked(); + expect(encrypted).toBeEnabled(); + expect(broadcast).not.toBeChecked(); + + // Broadcast: OFF -> ON (Encrypted unchanged + enabled) + await userEvent.click(broadcast); + expect(broadcast).toBeChecked(); + expect(encrypted).toBeChecked(); + expect(encrypted).toBeEnabled(); + + // Broadcast: ON -> OFF (Encrypted unchanged + enabled) + await userEvent.click(broadcast); + expect(broadcast).not.toBeChecked(); + expect(encrypted).toBeChecked(); + expect(encrypted).toBeEnabled(); + + // User can still toggle Encrypted freely while Broadcast is OFF + await userEvent.click(encrypted); + expect(encrypted).not.toBeChecked(); + + // User can still toggle Encrypted freely while Broadcast is ON + await userEvent.click(broadcast); + expect(broadcast).toBeChecked(); + expect(encrypted).not.toBeChecked(); + expect(encrypted).toBeEnabled(); + }); + + it('non-private room: Encrypted remains OFF and disabled regardless of Broadcast state', async () => { + render( null} />, { + wrapper: mockAppRoot().withSetting('E2E_Enable', true).withSetting('E2E_Enabled_Default_PrivateRooms', true).build(), + }); + + await userEvent.click(screen.getByText('Advanced_settings')); + + const encrypted = screen.getByLabelText('Encrypted') as HTMLInputElement; + const broadcast = screen.getByLabelText('Broadcast') as HTMLInputElement; + const priv = screen.getByLabelText('Private') as HTMLInputElement; + + // Switch to non-private + await userEvent.click(priv); + expect(priv).not.toBeChecked(); + + // Encrypted must be OFF + disabled (non-private cannot be encrypted) + expect(encrypted).not.toBeChecked(); + expect(encrypted).toBeDisabled(); + + // Broadcast: OFF -> ON (Encrypted stays OFF + disabled) + await userEvent.click(broadcast); + expect(broadcast).toBeChecked(); + expect(encrypted).not.toBeChecked(); + expect(encrypted).toBeDisabled(); + + // Broadcast: ON -> OFF (Encrypted still OFF + disabled) + await userEvent.click(broadcast); + expect(broadcast).not.toBeChecked(); + expect(encrypted).not.toBeChecked(); + expect(encrypted).toBeDisabled(); + }); + }); +} diff --git a/apps/meteor/client/NavBarV2/NavBarPagesGroup/actions/useEncryptedRoomDescription.spec.tsx b/apps/meteor/client/NavBarV2/NavBarPagesGroup/actions/useEncryptedRoomDescription.spec.tsx new file mode 100644 index 0000000000000..e775e384460f4 --- /dev/null +++ b/apps/meteor/client/NavBarV2/NavBarPagesGroup/actions/useEncryptedRoomDescription.spec.tsx @@ -0,0 +1,87 @@ +import { mockAppRoot } from '@rocket.chat/mock-providers'; +import { renderHook } from '@testing-library/react'; + +import { useEncryptedRoomDescription } from './useEncryptedRoomDescription'; +import { useEncryptedRoomDescription as useEncryptedRoomDescriptionOld } from '../../../sidebar/header/hooks/useEncryptedRoomDescription'; + +type Hook = typeof useEncryptedRoomDescription | typeof useEncryptedRoomDescriptionOld; + +const wrapper = mockAppRoot(); + +describe.each([ + ['useEncryptedRoomDescription in NavBarV2', useEncryptedRoomDescription], + ['useEncryptedRoomDescription', useEncryptedRoomDescriptionOld], +] as const)('%s', (_name, useEncryptedRoomDescriptionHook: Hook) => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe.each(['channel', 'team'] as const)('roomType=%s', (roomType) => { + it('returns "Not_available_for_this_workspace" when E2E is disabled', () => { + const { result } = renderHook(() => useEncryptedRoomDescriptionHook(roomType), { + wrapper: wrapper.withSetting('E2E_Enable', false).build(), + }); + const describe = result.current; + + expect(describe({ isPrivate: true, broadcast: false, encrypted: true })).toBe('Not_available_for_this_workspace'); + }); + + it('returns "Encrypted_not_available" when room is not private', () => { + const { result } = renderHook(() => useEncryptedRoomDescriptionHook(roomType), { + wrapper: wrapper.withSetting('E2E_Enable', true).build(), + }); + const describe = result.current; + + expect(describe({ isPrivate: false, broadcast: false, encrypted: false })).toBe(`Encrypted_not_available`); + }); + + it('returns "Not_available_for_broadcast" when broadcast=true (even if encrypted is true)', () => { + const { result } = renderHook(() => useEncryptedRoomDescriptionHook(roomType), { + wrapper: wrapper.withSetting('E2E_Enable', true).build(), + }); + const describe = result.current; + + expect(describe({ isPrivate: true, broadcast: true, encrypted: true })).toBe(`Not_available_for_broadcast`); + + expect(describe({ isPrivate: true, broadcast: true, encrypted: false })).toBe(`Not_available_for_broadcast`); + }); + + it('returns "Encrypted_messages" when private, not broadcast, and encrypted is true', () => { + const { result } = renderHook(() => useEncryptedRoomDescriptionHook(roomType), { + wrapper: wrapper.withSetting('E2E_Enable', true).build(), + }); + const describe = result.current; + + expect(describe({ isPrivate: true, broadcast: false, encrypted: true })).toBe(`Encrypted_messages`); + }); + + it('returns "Encrypted_messages_false" when private, not broadcast, and encrypted is false', () => { + const { result } = renderHook(() => useEncryptedRoomDescriptionHook(roomType), { + wrapper: wrapper.withSetting('E2E_Enable', true).build(), + }); + const describe = result.current; + + expect(describe({ isPrivate: true, broadcast: false, encrypted: false })).toBe('Encrypted_messages_false'); + }); + + describe('when broadcast is undefined', () => { + it('returns "Encrypted_messages" if private and encrypted is true and broadcast is undefined', () => { + const { result } = renderHook(() => useEncryptedRoomDescriptionHook(roomType), { + wrapper: wrapper.withSetting('E2E_Enable', true).build(), + }); + const describe = result.current; + + expect(describe({ isPrivate: true, encrypted: true })).toBe(`Encrypted_messages`); + }); + + it('returns "Encrypted_messages_false" if private and encrypted is false and broadcast is undefined', () => { + const { result } = renderHook(() => useEncryptedRoomDescriptionHook(roomType), { + wrapper: wrapper.withSetting('E2E_Enable', true).build(), + }); + const describe = result.current; + + expect(describe({ isPrivate: true, encrypted: false })).toBe('Encrypted_messages_false'); + }); + }); + }); +}); diff --git a/apps/meteor/client/NavBarV2/NavBarPagesGroup/actions/useEncryptedRoomDescription.tsx b/apps/meteor/client/NavBarV2/NavBarPagesGroup/actions/useEncryptedRoomDescription.tsx index 0cef06d391737..4a193aed413cb 100644 --- a/apps/meteor/client/NavBarV2/NavBarPagesGroup/actions/useEncryptedRoomDescription.tsx +++ b/apps/meteor/client/NavBarV2/NavBarPagesGroup/actions/useEncryptedRoomDescription.tsx @@ -4,19 +4,19 @@ import { useTranslation } from 'react-i18next'; export const useEncryptedRoomDescription = (roomType: 'channel' | 'team') => { const { t } = useTranslation(); const e2eEnabled = useSetting('E2E_Enable'); - const e2eEnabledForPrivateByDefault = useSetting('E2E_Enabled_Default_PrivateRooms'); - return ({ isPrivate, broadcast, encrypted }: { isPrivate: boolean; broadcast: boolean; encrypted: boolean }) => { + return ({ isPrivate, broadcast, encrypted }: { isPrivate: boolean; broadcast?: boolean; encrypted: boolean }) => { if (!e2eEnabled) { return t('Not_available_for_this_workspace'); } if (!isPrivate) { return t('Encrypted_not_available', { roomType }); } - if (broadcast) { + // TODO: This case will be removed once we enable E2E for broadcast teams in teams creation modal + if (broadcast !== undefined && broadcast) { return t('Not_available_for_broadcast', { roomType }); } - if (e2eEnabledForPrivateByDefault || encrypted) { + if (encrypted) { return t('Encrypted_messages', { roomType }); } return t('Encrypted_messages_false'); diff --git a/apps/meteor/client/NavBarV2/NavBarPagesGroup/hooks/useCreateNewItems.tsx b/apps/meteor/client/NavBarV2/NavBarPagesGroup/hooks/useCreateNewItems.tsx index 7ac76f3f7815e..9bbc20a0fd0f9 100644 --- a/apps/meteor/client/NavBarV2/NavBarPagesGroup/hooks/useCreateNewItems.tsx +++ b/apps/meteor/client/NavBarV2/NavBarPagesGroup/hooks/useCreateNewItems.tsx @@ -63,6 +63,6 @@ export const useCreateNewItems = (): GenericMenuItemProps[] => { ...(canCreateDirectMessages ? [createDirectMessageItem] : []), ...(canCreateDiscussion && discussionEnabled ? [createDiscussionItem] : []), ...(canCreateChannel ? [createChannelItem] : []), - ...(canCreateTeam ? [createTeamItem] : []), + ...(canCreateTeam && canCreateChannel ? [createTeamItem] : []), ]; }; diff --git a/apps/meteor/client/NavBarV2/NavBarPagesGroup/hooks/useGroupingListItems.tsx b/apps/meteor/client/NavBarV2/NavBarPagesGroup/hooks/useGroupingListItems.tsx index b2deb3c6ea452..8b1d0fe967dde 100644 --- a/apps/meteor/client/NavBarV2/NavBarPagesGroup/hooks/useGroupingListItems.tsx +++ b/apps/meteor/client/NavBarV2/NavBarPagesGroup/hooks/useGroupingListItems.tsx @@ -1,11 +1,12 @@ import { CheckBox } from '@rocket.chat/fuselage'; -import type { GenericMenuItemProps } from '@rocket.chat/ui-client'; +import { useFeaturePreview, type GenericMenuItemProps } from '@rocket.chat/ui-client'; import { useEndpoint, useUserPreference } from '@rocket.chat/ui-contexts'; import { useCallback } from 'react'; import { useTranslation } from 'react-i18next'; export const useGroupingListItems = (): GenericMenuItemProps[] => { const { t } = useTranslation(); + const secondSidebarEnabled = useFeaturePreview('secondarySidebar'); const sidebarGroupByType = useUserPreference('sidebarGroupByType'); const sidebarShowFavorites = useUserPreference('sidebarShowFavorites'); @@ -27,7 +28,7 @@ export const useGroupingListItems = (): GenericMenuItemProps[] => { icon: 'flag', addon: , }, - { + !secondSidebarEnabled && { id: 'favorites', content: t('Favorites'), icon: 'star', @@ -39,5 +40,5 @@ export const useGroupingListItems = (): GenericMenuItemProps[] => { icon: 'group-by-type', addon: , }, - ]; + ].filter(Boolean) as GenericMenuItemProps[]; }; diff --git a/apps/meteor/client/NavBarV2/NavBarPagesGroup/hooks/useGrouppingListItems.spec.tsx b/apps/meteor/client/NavBarV2/NavBarPagesGroup/hooks/useGrouppingListItems.spec.tsx new file mode 100644 index 0000000000000..f168b07975c7c --- /dev/null +++ b/apps/meteor/client/NavBarV2/NavBarPagesGroup/hooks/useGrouppingListItems.spec.tsx @@ -0,0 +1,47 @@ +import { mockAppRoot } from '@rocket.chat/mock-providers'; +import { renderHook } from '@testing-library/react'; + +import { useGroupingListItems } from './useGroupingListItems'; + +it('should render all groupingList items', async () => { + const { result } = renderHook(() => useGroupingListItems()); + + expect(result.current[0]).toEqual( + expect.objectContaining({ + id: 'unread', + }), + ); + + expect(result.current[1]).toEqual( + expect.objectContaining({ + id: 'favorites', + }), + ); + + expect(result.current[2]).toEqual( + expect.objectContaining({ + id: 'types', + }), + ); +}); + +it('should render only unread and types groupingList items if secondarySidebar is enabled', async () => { + const { result } = renderHook(() => useGroupingListItems(), { + wrapper: mockAppRoot() + .withSetting('Accounts_AllowFeaturePreview', true) + .withUserPreference('featuresPreview', [{ name: 'secondarySidebar', value: true }]) + .build(), + }); + + expect(result.current[0]).toEqual( + expect.objectContaining({ + id: 'unread', + }), + ); + + expect(result.current[1]).toEqual( + expect.objectContaining({ + id: 'types', + }), + ); +}); diff --git a/apps/meteor/client/NavBarV2/NavBarPagesGroup/hooks/useSortMenu.tsx b/apps/meteor/client/NavBarV2/NavBarPagesGroup/hooks/useSortMenu.tsx index af6a73ce6eabb..2a70c1b08ac64 100644 --- a/apps/meteor/client/NavBarV2/NavBarPagesGroup/hooks/useSortMenu.tsx +++ b/apps/meteor/client/NavBarV2/NavBarPagesGroup/hooks/useSortMenu.tsx @@ -1,3 +1,5 @@ +import type { GenericMenuItemProps } from '@rocket.chat/ui-client'; +import { useFeaturePreview } from '@rocket.chat/ui-client'; import { useTranslation } from 'react-i18next'; import { useGroupingListItems } from './useGroupingListItems'; @@ -6,16 +8,15 @@ import { useViewModeItems } from './useViewModeItems'; export const useSortMenu = () => { const { t } = useTranslation(); + const secondSidebarEnabled = useFeaturePreview('secondarySidebar'); const viewModeItems = useViewModeItems(); const sortModeItems = useSortModeItems(); const groupingListItems = useGroupingListItems(); - const sections = [ - { title: t('Display'), items: viewModeItems }, + return [ + !secondSidebarEnabled ? { title: t('Display'), items: viewModeItems } : undefined, { title: t('Sort_By'), items: sortModeItems }, { title: t('Group_by'), items: groupingListItems }, - ]; - - return sections; + ].filter(Boolean) as { title: string; items: GenericMenuItemProps[] }[]; }; diff --git a/apps/meteor/client/NavBarV2/NavBarSearch/NavBarSearchListbox.tsx b/apps/meteor/client/NavBarV2/NavBarSearch/NavBarSearchListbox.tsx index f99f1e2ab54e8..6979191f83c7b 100644 --- a/apps/meteor/client/NavBarV2/NavBarSearch/NavBarSearchListbox.tsx +++ b/apps/meteor/client/NavBarV2/NavBarSearch/NavBarSearchListbox.tsx @@ -57,7 +57,7 @@ const NavBarSearchListBox = ({ state, overlayProps }: NavBarSearchListBoxProps)
{items.length === 0 && !isLoading && } {items.length > 0 && ( - + {filterText ? t('Results') : t('Recent')} )} diff --git a/apps/meteor/client/NavBarV2/NavBarSettingsToolbar/hooks/useAuditMenu.spec.tsx b/apps/meteor/client/NavBarV2/NavBarSettingsToolbar/hooks/useAuditMenu.spec.tsx index de197ab30f09f..aa66de600a502 100644 --- a/apps/meteor/client/NavBarV2/NavBarSettingsToolbar/hooks/useAuditMenu.spec.tsx +++ b/apps/meteor/client/NavBarV2/NavBarSettingsToolbar/hooks/useAuditMenu.spec.tsx @@ -129,3 +129,30 @@ it('should return audiLogs item if have license and can-audit-log permission', a ), ); }); + +it('should return auditSecurityLog item if have license and can-audit-log permission', async () => { + const { result } = renderHook(() => useAuditMenu(), { + wrapper: mockAppRoot() + .withEndpoint('GET', '/v1/licenses.info', () => ({ + license: { + license: { + // @ts-expect-error: just for testing + grantedModules: [{ module: 'auditing' }], + }, + // @ts-expect-error: just for testing + activeModules: ['auditing'], + }, + })) + .withJohnDoe() + .withPermission('can-audit') + .build(), + }); + + await waitFor(() => + expect(result.current.items[1]).toEqual( + expect.objectContaining({ + id: 'auditSecurityLog', + }), + ), + ); +}); diff --git a/apps/meteor/client/NavBarV2/NavBarSettingsToolbar/hooks/useAuditMenu.tsx b/apps/meteor/client/NavBarV2/NavBarSettingsToolbar/hooks/useAuditMenu.tsx index be412480c3007..744513c6316e0 100644 --- a/apps/meteor/client/NavBarV2/NavBarSettingsToolbar/hooks/useAuditMenu.tsx +++ b/apps/meteor/client/NavBarV2/NavBarSettingsToolbar/hooks/useAuditMenu.tsx @@ -25,8 +25,18 @@ export const useAuditMenu = () => { onClick: () => router.navigate('/audit-log'), }; + const auditSecurityLogsItem: GenericMenuItemProps = { + id: 'auditSecurityLog', + content: t('Security_logs'), + onClick: () => router.navigate('/security-logs'), + }; + return { title: t('Audit'), - items: [hasAuditPermission && auditMessageItem, hasAuditLogPermission && auditLogItem].filter(Boolean) as GenericMenuItemProps[], + items: [ + hasAuditPermission && auditMessageItem, + hasAuditLogPermission && auditLogItem, + hasAuditPermission && auditSecurityLogsItem, + ].filter(Boolean) as GenericMenuItemProps[], }; }; diff --git a/apps/meteor/client/apps/RealAppsEngineUIHost.ts b/apps/meteor/client/apps/RealAppsEngineUIHost.ts index 85f024561de95..54bd4c8fa84da 100644 --- a/apps/meteor/client/apps/RealAppsEngineUIHost.ts +++ b/apps/meteor/client/apps/RealAppsEngineUIHost.ts @@ -2,11 +2,11 @@ import { AppsEngineUIHost } from '@rocket.chat/apps-engine/client/AppsEngineUIHo import type { IExternalComponentRoomInfo, IExternalComponentUserInfo } from '@rocket.chat/apps-engine/client/definition'; import { Meteor } from 'meteor/meteor'; -import { Rooms } from '../../app/models/client'; import { getUserAvatarURL } from '../../app/utils/client/getUserAvatarURL'; import { sdk } from '../../app/utils/client/lib/SDKClient'; import { RoomManager } from '../lib/RoomManager'; import { baseURI } from '../lib/baseURI'; +import { Rooms } from '../stores'; // FIXME: replace non-null assertions with proper error handling diff --git a/apps/meteor/client/cachedStores/PermissionsCachedStore.ts b/apps/meteor/client/cachedStores/PermissionsCachedStore.ts new file mode 100644 index 0000000000000..f52f94ab3985d --- /dev/null +++ b/apps/meteor/client/cachedStores/PermissionsCachedStore.ts @@ -0,0 +1,10 @@ +import type { IPermission } from '@rocket.chat/core-typings'; + +import { PrivateCachedStore } from '../lib/cachedStores'; +import { Permissions } from '../stores'; + +export const PermissionsCachedStore = new PrivateCachedStore({ + name: 'permissions', + eventType: 'notify-logged', + store: Permissions.use, +}); diff --git a/apps/meteor/client/cachedStores/PrivateSettingsCachedStore.ts b/apps/meteor/client/cachedStores/PrivateSettingsCachedStore.ts new file mode 100644 index 0000000000000..4469b3f1f2bcb --- /dev/null +++ b/apps/meteor/client/cachedStores/PrivateSettingsCachedStore.ts @@ -0,0 +1,31 @@ +import type { ISetting } from '@rocket.chat/core-typings'; + +import { sdk } from '../../app/utils/client/lib/SDKClient'; +import { PrivateCachedStore } from '../lib/cachedStores'; +import { PrivateSettings } from '../stores'; + +class PrivateSettingsCachedStore extends PrivateCachedStore { + constructor() { + super({ + name: 'private-settings', + eventType: 'notify-logged', + store: PrivateSettings.use, + }); + } + + override setupListener() { + return sdk.stream('notify-logged', ['private-settings-changed'], async (t, setting) => { + this.log('record received', t, setting); + const { _id, ...fields } = setting; + this.store.getState().update( + (record) => record._id === _id, + (record) => ({ ...record, ...fields }), + ); + this.sync(); + }); + } +} + +const instance = new PrivateSettingsCachedStore(); + +export { instance as PrivateSettingsCachedStore }; diff --git a/apps/meteor/client/cachedStores/PublicSettingsCachedStore.ts b/apps/meteor/client/cachedStores/PublicSettingsCachedStore.ts new file mode 100644 index 0000000000000..095b15c2fa056 --- /dev/null +++ b/apps/meteor/client/cachedStores/PublicSettingsCachedStore.ts @@ -0,0 +1,18 @@ +import type { ISetting } from '@rocket.chat/core-typings'; + +import { PublicCachedStore } from '../lib/cachedStores'; +import { PublicSettings } from '../stores'; + +class PublicSettingsCachedStore extends PublicCachedStore { + constructor() { + super({ + name: 'public-settings', + eventType: 'notify-all', + store: PublicSettings.use, + }); + } +} + +const instance = new PublicSettingsCachedStore(); + +export { instance as PublicSettingsCachedStore }; diff --git a/apps/meteor/app/models/client/models/CachedChatRoom.ts b/apps/meteor/client/cachedStores/RoomsCachedStore.ts similarity index 79% rename from apps/meteor/app/models/client/models/CachedChatRoom.ts rename to apps/meteor/client/cachedStores/RoomsCachedStore.ts index 3071781758ef3..b965a56f0b1e5 100644 --- a/apps/meteor/app/models/client/models/CachedChatRoom.ts +++ b/apps/meteor/client/cachedStores/RoomsCachedStore.ts @@ -2,16 +2,15 @@ import type { IOmnichannelRoom, IRoom, IRoomWithRetentionPolicy } from '@rocket. import { DEFAULT_SLA_CONFIG, LivechatPriorityWeight } from '@rocket.chat/core-typings'; import type { SubscriptionWithRoom } from '@rocket.chat/ui-contexts'; -import { CachedChatSubscription } from './CachedChatSubscription'; -import { PrivateCachedStore } from '../../../../client/lib/cachedCollections/CachedCollection'; -import { createDocumentMapStore } from '../../../../client/lib/cachedCollections/DocumentMapStore'; +import { PrivateCachedStore } from '../lib/cachedStores'; +import { Rooms, Subscriptions } from '../stores'; -class CachedChatRoom extends PrivateCachedStore { +class RoomsCachedStore extends PrivateCachedStore { constructor() { super({ name: 'rooms', eventType: 'notify-user', - store: createDocumentMapStore(), + store: Rooms.use, }); } @@ -67,7 +66,7 @@ class CachedChatRoom extends PrivateCachedStore { } protected override handleLoadedFromServer(rooms: IRoom[]): void { - const indexedSubscriptions = CachedChatSubscription.collection.state.indexBy('rid'); + const indexedSubscriptions = Subscriptions.use.getState().indexBy('rid'); const subscriptionsWithRoom = rooms.flatMap((room) => { const sub = indexedSubscriptions.get(room._id); @@ -77,7 +76,7 @@ class CachedChatRoom extends PrivateCachedStore { return this.merge(room, sub); }); - CachedChatSubscription.collection.state.storeMany(subscriptionsWithRoom); + Subscriptions.use.getState().storeMany(subscriptionsWithRoom); } protected override async handleRecordEvent(action: 'removed' | 'changed', room: IRoom) { @@ -85,7 +84,7 @@ class CachedChatRoom extends PrivateCachedStore { if (action === 'removed') return; - CachedChatSubscription.collection.state.update( + Subscriptions.use.getState().update( (record) => record.rid === room._id, (sub) => this.merge(room, sub), ); @@ -94,7 +93,7 @@ class CachedChatRoom extends PrivateCachedStore { protected override handleSyncEvent(action: 'removed' | 'changed', room: IRoom): void { if (action === 'removed') return; - CachedChatSubscription.collection.state.update( + Subscriptions.use.getState().update( (record) => record.rid === room._id, (sub) => this.merge(room, sub), ); @@ -111,9 +110,6 @@ class CachedChatRoom extends PrivateCachedStore { } } -const instance = new CachedChatRoom(); +const instance = new RoomsCachedStore(); -export { - /** @deprecated new code refer to Minimongo collections like this one; prefer fetching data from the REST API, listening to changes via streamer events, and storing the state in a Tanstack Query */ - instance as CachedChatRoom, -}; +export { instance as RoomsCachedStore }; diff --git a/apps/meteor/app/models/client/models/CachedChatSubscription.ts b/apps/meteor/client/cachedStores/SubscriptionsCachedStore.ts similarity index 80% rename from apps/meteor/app/models/client/models/CachedChatSubscription.ts rename to apps/meteor/client/cachedStores/SubscriptionsCachedStore.ts index 3a1440bf873cc..d3c2032201f7c 100644 --- a/apps/meteor/app/models/client/models/CachedChatSubscription.ts +++ b/apps/meteor/client/cachedStores/SubscriptionsCachedStore.ts @@ -2,26 +2,20 @@ import type { IOmnichannelRoom, IRoomWithRetentionPolicy, ISubscription } from ' import { DEFAULT_SLA_CONFIG, LivechatPriorityWeight } from '@rocket.chat/core-typings'; import type { SubscriptionWithRoom } from '@rocket.chat/ui-contexts'; -import { CachedChatRoom } from './CachedChatRoom'; -import { PrivateCachedCollection } from '../../../../client/lib/cachedCollections/CachedCollection'; +import { PrivateCachedStore } from '../lib/cachedStores'; +import { Rooms, Subscriptions } from '../stores'; -declare module '@rocket.chat/core-typings' { - interface ISubscription { - lowerCaseName: string; - lowerCaseFName: string; - } -} - -class CachedChatSubscription extends PrivateCachedCollection { +class SubscriptionsCachedStore extends PrivateCachedStore { constructor() { super({ name: 'subscriptions', eventType: 'notify-user', + store: Subscriptions.use, }); } protected override mapRecord(subscription: ISubscription): SubscriptionWithRoom { - const room = CachedChatRoom.store.getState().find((r) => r._id === subscription.rid); + const room = Rooms.use.getState().find((r) => r._id === subscription.rid); const lastRoomUpdate = room?.lm || subscription.ts || room?.ts; @@ -90,9 +84,6 @@ class CachedChatSubscription extends PrivateCachedCollection ({ - scrollbars: { autoHide: 'scroll' }, + scrollbars: { autoHide: 'move' }, overflow: { x: overflowX ? 'scroll' : 'hidden' }, }) as const; diff --git a/apps/meteor/client/components/FeaturePreviewSidePanelNavigation.tsx b/apps/meteor/client/components/FeaturePreviewSidePanelNavigation.tsx deleted file mode 100644 index adffeea33cfda..0000000000000 --- a/apps/meteor/client/components/FeaturePreviewSidePanelNavigation.tsx +++ /dev/null @@ -1,9 +0,0 @@ -import { FeaturePreview } from '@rocket.chat/ui-client'; -import type { ReactElement } from 'react'; - -import { useSidePanelNavigationScreenSize } from '../hooks/useSidePanelNavigation'; - -export const FeaturePreviewSidePanelNavigation = ({ children }: { children: ReactElement[] }) => { - const disabled = !useSidePanelNavigationScreenSize(); - return ; -}; diff --git a/apps/meteor/client/components/GazzodownText.spec.tsx b/apps/meteor/client/components/GazzodownText.spec.tsx index 3e41da52b9c44..2bf9c746b5b0b 100644 --- a/apps/meteor/client/components/GazzodownText.spec.tsx +++ b/apps/meteor/client/components/GazzodownText.spec.tsx @@ -50,7 +50,7 @@ describe('GazzodownText highlights', () => { , - { legacyRoot: true, wrapper: wrapper.build() }, + { wrapper: wrapper.build() }, ); // Expect that the highlighted element wraps exactly "тест" expect(screen.getByTitle('Highlighted_chosen_word')).toHaveTextContent(/^тест$/i); @@ -63,7 +63,7 @@ describe('GazzodownText highlights', () => { , - { legacyRoot: true, wrapper: wrapper.build() }, + { wrapper: wrapper.build() }, ); expect(screen.getByTitle('Highlighted_chosen_word')).toHaveTextContent(/^тест$/i); }); @@ -75,7 +75,7 @@ describe('GazzodownText highlights', () => { , - { legacyRoot: true, wrapper: wrapper.build() }, + { wrapper: wrapper.build() }, ); expect(screen.queryByTitle('Highlighted_chosen_word')).not.toBeInTheDocument(); }); @@ -87,7 +87,7 @@ describe('GazzodownText highlights', () => { , - { legacyRoot: true, wrapper: wrapper.build() }, + { wrapper: wrapper.build() }, ); expect(screen.getByTitle('Highlighted_chosen_word')).toHaveTextContent(/^тест$/i); }); @@ -99,7 +99,7 @@ describe('GazzodownText highlights', () => { , - { legacyRoot: true, wrapper: wrapper.build() }, + { wrapper: wrapper.build() }, ); expect(screen.getByTitle('Highlighted_chosen_word')).toHaveTextContent(/^test$/i); }); @@ -111,7 +111,7 @@ describe('GazzodownText highlights', () => { , - { legacyRoot: true, wrapper: wrapper.build() }, + { wrapper: wrapper.build() }, ); const highlightedElements = screen.getAllByTitle('Highlighted_chosen_word'); // Expect three separate highlights. @@ -133,7 +133,7 @@ in it.`; , - { legacyRoot: true, wrapper: wrapper.build() }, + { wrapper: wrapper.build() }, ); const highlightedElements = screen.getAllByTitle('Highlighted_chosen_word'); // At least two occurrences are expected: one for "Test" (capitalized) and one for "test" @@ -151,7 +151,7 @@ in it.`; , - { legacyRoot: true, wrapper: wrapper.build() }, + { wrapper: wrapper.build() }, ); // The highlighted element should contain only "test" expect(screen.getByTitle('Highlighted_chosen_word')).toHaveTextContent(/^test$/i); @@ -164,7 +164,7 @@ in it.`; , - { legacyRoot: true, wrapper: wrapper.build() }, + { wrapper: wrapper.build() }, ); // The highlighted element should contain only "test" expect(screen.getByTitle('Highlighted_chosen_word')).toHaveTextContent(/^test$/i); @@ -177,7 +177,7 @@ in it.`; , - { legacyRoot: true, wrapper: wrapper.build() }, + { wrapper: wrapper.build() }, ); // The highlighted element should contain only "test" expect(screen.getByTitle('Highlighted_chosen_word')).toHaveTextContent(/^test$/i); @@ -190,7 +190,7 @@ in it.`; , - { legacyRoot: true, wrapper: wrapper.build() }, + { wrapper: wrapper.build() }, ); const highlightedElements = screen.getAllByTitle('Highlighted_chosen_word'); @@ -209,7 +209,7 @@ in it.`; , - { legacyRoot: true, wrapper: wrapper.build() }, + { wrapper: wrapper.build() }, ); expect(screen.getByTitle('Highlighted_chosen_word')).toHaveTextContent(/^te-st_te\.st\/te=te!st:$/i); @@ -222,7 +222,7 @@ in it.`; , - { legacyRoot: true, wrapper: wrapper.build() }, + { wrapper: wrapper.build() }, ); expect(screen.getByTitle('Highlighted_chosen_word')).toHaveTextContent(/^test$/i); }); diff --git a/apps/meteor/client/components/GenericNoResults/GenericNoResults.tsx b/apps/meteor/client/components/GenericNoResults/GenericNoResults.tsx index 83e8d20a8537c..420137bcb46d0 100644 --- a/apps/meteor/client/components/GenericNoResults/GenericNoResults.tsx +++ b/apps/meteor/client/components/GenericNoResults/GenericNoResults.tsx @@ -6,10 +6,9 @@ type LinkProps = { linkText: string; linkHref: string } | { linkText?: never; li type ButtonProps = { buttonTitle: string; buttonAction: () => void } | { buttonTitle?: never; buttonAction?: never }; type GenericNoResultsProps = { - icon?: IconName; + icon?: IconName | null; title?: string; description?: string; - buttonTitle?: string; } & LinkProps & ButtonProps; @@ -27,7 +26,7 @@ const GenericNoResults = ({ return ( - + {icon && } {title || t('No_results_found')} {description && {description}} {buttonTitle && buttonAction && ( diff --git a/apps/meteor/client/components/Omnichannel/modals/CloseChatModal.tsx b/apps/meteor/client/components/Omnichannel/modals/CloseChatModal.tsx index 499a07588e743..fe476f047bae5 100644 --- a/apps/meteor/client/components/Omnichannel/modals/CloseChatModal.tsx +++ b/apps/meteor/client/components/Omnichannel/modals/CloseChatModal.tsx @@ -21,12 +21,12 @@ import { ModalContent, } from '@rocket.chat/fuselage'; import { GenericModal } from '@rocket.chat/ui-client'; -import { usePermission, useSetting, useTranslation, useUserPreference } from '@rocket.chat/ui-contexts'; +import { usePermission, useSetting, useUserPreference, useToastMessageDispatch } from '@rocket.chat/ui-contexts'; import { useCallback, useState, useEffect, useMemo } from 'react'; import { useForm } from 'react-hook-form'; +import { useTranslation } from 'react-i18next'; import { useHasLicenseModule } from '../../../hooks/useHasLicenseModule'; -import { dispatchToastMessage } from '../../../lib/toast'; import Tags from '../Tags'; type CloseChatModalFormData = { @@ -50,7 +50,8 @@ type CloseChatModalProps = { }; const CloseChatModal = ({ department, visitorEmail, onCancel, onConfirm }: CloseChatModalProps) => { - const t = useTranslation(); + const { t } = useTranslation(); + const dispatchToastMessage = useToastMessageDispatch(); const { formState: { errors }, @@ -147,11 +148,11 @@ const CloseChatModal = ({ department, visitorEmail, onCancel, onConfirm }: Close } setValue('subject', subject || customSubject || t('Transcript_of_your_livechat_conversation')); } - }, [transcriptEmail, setValue, visitorEmail, subject, t, customSubject]); + }, [transcriptEmail, setValue, visitorEmail, subject, t, customSubject, dispatchToastMessage]); if (commentRequired || tagRequired || canSendTranscript) { return ( - }> + }> {t('Wrap_up_conversation')} diff --git a/apps/meteor/client/components/Omnichannel/modals/EnterpriseDepartmentsModal.tsx b/apps/meteor/client/components/Omnichannel/modals/EnterpriseDepartmentsModal.tsx index a4f18b9f54858..f7f02f76e3428 100644 --- a/apps/meteor/client/components/Omnichannel/modals/EnterpriseDepartmentsModal.tsx +++ b/apps/meteor/client/components/Omnichannel/modals/EnterpriseDepartmentsModal.tsx @@ -61,7 +61,7 @@ const EnterpriseDepartmentsModal = ({ closeModal }: { closeModal: () => void }): - diff --git a/apps/meteor/client/components/UserAndRoomAutoCompleteMultiple/UserAndRoomAutoCompleteMultiple.spec.tsx b/apps/meteor/client/components/UserAndRoomAutoCompleteMultiple/UserAndRoomAutoCompleteMultiple.spec.tsx index f37e6a32c8c74..ff2a88d8093d6 100644 --- a/apps/meteor/client/components/UserAndRoomAutoCompleteMultiple/UserAndRoomAutoCompleteMultiple.spec.tsx +++ b/apps/meteor/client/components/UserAndRoomAutoCompleteMultiple/UserAndRoomAutoCompleteMultiple.spec.tsx @@ -1,91 +1,90 @@ -import { useUser, useUserSubscriptions, useRoomAvatarPath } from '@rocket.chat/ui-contexts'; +import { MockedAppRootBuilder } from '@rocket.chat/mock-providers/dist/MockedAppRootBuilder'; +import type { SubscriptionWithRoom } from '@rocket.chat/ui-contexts'; import { render, screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import UserAndRoomAutoCompleteMultiple from './UserAndRoomAutoCompleteMultiple'; +import { createFakeSubscription, createFakeUser } from '../../../tests/mocks/data'; import { roomCoordinator } from '../../lib/rooms/roomCoordinator'; -// Mock dependencies -jest.mock('@rocket.chat/ui-contexts', () => ({ - useUser: jest.fn(), - useUserSubscriptions: jest.fn(), - useRoomAvatarPath: jest.fn(), -})); +const user = createFakeUser({ + active: true, + roles: ['admin'], + type: 'user', +}); + +const direct = createFakeSubscription({ + t: 'd', + name: 'Direct', +}); + +const channel = createFakeSubscription({ + t: 'c', + name: 'General', +}); + +const appRoot = new MockedAppRootBuilder() + .withSubscriptions([ + { ...direct, ro: false }, + { ...channel, ro: true }, + ] as unknown as SubscriptionWithRoom[]) + .withUser(user); + jest.mock('../../lib/rooms/roomCoordinator', () => ({ - roomCoordinator: { readOnly: jest.fn() }, + roomCoordinator: { + readOnly: jest.fn(), + }, })); -const mockUser = { _id: 'user1', username: 'testuser' }; +beforeEach(() => { + (roomCoordinator.readOnly as jest.Mock).mockReturnValue(false); +}); -const mockRooms = [ - { - rid: 'room1', - fname: 'General', - name: 'general', - t: 'c', - avatarETag: 'etag1', - }, - { - rid: 'room2', - fname: 'Direct', - name: 'direct', - t: 'd', - avatarETag: 'etag2', - blocked: false, - blocker: false, - }, -]; - -describe('UserAndRoomAutoCompleteMultiple', () => { - beforeEach(() => { - (useUser as jest.Mock).mockReturnValue(mockUser); - (useUserSubscriptions as jest.Mock).mockReturnValue(mockRooms); - (useRoomAvatarPath as jest.Mock).mockReturnValue((rid: string) => `/avatar/path/${rid}`); - (roomCoordinator.readOnly as jest.Mock).mockReturnValue(false); - }); +afterEach(() => jest.clearAllMocks()); - it('should render options based on user subscriptions', async () => { - render(); +it('should render options based on user subscriptions', async () => { + render(, { wrapper: appRoot.build() }); - const input = screen.getByRole('textbox'); - await userEvent.click(input); + const input = screen.getByRole('textbox'); + await userEvent.click(input); - await waitFor(() => { - expect(screen.getByText('General')).toBeInTheDocument(); - }); + await waitFor(() => { + expect(screen.getByText('Direct')).toBeInTheDocument(); + }); - await waitFor(() => { - expect(screen.getByText('Direct')).toBeInTheDocument(); - }); + await waitFor(() => { + expect(screen.getByText('General')).toBeInTheDocument(); }); +}); - it('should filter out read-only rooms', async () => { - (roomCoordinator.readOnly as jest.Mock).mockImplementation((rid) => rid === 'room1'); - render(); +it('should filter out read-only rooms', async () => { + (roomCoordinator.readOnly as jest.Mock).mockReturnValueOnce(true); - const input = screen.getByRole('textbox'); - await userEvent.click(input); + render(, { wrapper: appRoot.build() }); - await waitFor(() => { - expect(screen.queryByText('General')).not.toBeInTheDocument(); - }); - await waitFor(() => { - expect(screen.getByText('Direct')).toBeInTheDocument(); - }); + const input = screen.getByRole('textbox'); + await userEvent.click(input); + + await waitFor(() => { + expect(screen.getByText('General')).toBeInTheDocument(); }); - it('should call onChange when selecting an option', async () => { - const handleChange = jest.fn(); - render(); + await waitFor(() => { + expect(screen.queryByText('Direct')).not.toBeInTheDocument(); + }); +}); - const input = screen.getByRole('textbox'); - await userEvent.click(input); +it('should call onChange when selecting an option', async () => { + const handleChange = jest.fn(); + render(, { wrapper: appRoot.build() }); - await waitFor(() => { - expect(screen.getByText('General')).toBeInTheDocument(); - }); + const input = screen.getByRole('textbox'); + await userEvent.click(input); - await userEvent.click(screen.getByText('General')); - expect(handleChange).toHaveBeenCalled(); + await waitFor(() => { + expect(screen.getByText('General')).toBeInTheDocument(); }); + + await userEvent.click(screen.getByText('General')); + expect(handleChange).toHaveBeenCalled(); }); diff --git a/apps/meteor/client/components/UserAndRoomAutoCompleteMultiple/UserAndRoomAutoCompleteMultiple.tsx b/apps/meteor/client/components/UserAndRoomAutoCompleteMultiple/UserAndRoomAutoCompleteMultiple.tsx index 509718bb1129d..804a0e5c72e0c 100644 --- a/apps/meteor/client/components/UserAndRoomAutoCompleteMultiple/UserAndRoomAutoCompleteMultiple.tsx +++ b/apps/meteor/client/components/UserAndRoomAutoCompleteMultiple/UserAndRoomAutoCompleteMultiple.tsx @@ -7,8 +7,8 @@ import { useUser, useUserSubscriptions } from '@rocket.chat/ui-contexts'; import type { ComponentProps } from 'react'; import { memo, useMemo, useState } from 'react'; -import { Rooms } from '../../../app/models/client'; import { roomCoordinator } from '../../lib/rooms/roomCoordinator'; +import { Rooms } from '../../stores'; type UserAndRoomAutoCompleteMultipleProps = Omit, 'filter'> & { limit?: number }; diff --git a/apps/meteor/client/components/Wizard/WizardActions.tsx b/apps/meteor/client/components/Wizard/WizardActions.tsx new file mode 100644 index 0000000000000..82c3b22fb55ce --- /dev/null +++ b/apps/meteor/client/components/Wizard/WizardActions.tsx @@ -0,0 +1,16 @@ +import { Box, ButtonGroup } from '@rocket.chat/fuselage'; +import type { ReactNode } from 'react'; + +type WizardActionsProps = { + children: ReactNode; +}; + +const WizardActions = ({ children }: WizardActionsProps) => { + return ( + + {children} + + ); +}; + +export default WizardActions; diff --git a/apps/meteor/client/components/connectionStatus/ConnectionStatusBar.stories.tsx b/apps/meteor/client/components/connectionStatus/ConnectionStatusBar.stories.tsx index 857f42760ed35..267c2df2a801d 100644 --- a/apps/meteor/client/components/connectionStatus/ConnectionStatusBar.stories.tsx +++ b/apps/meteor/client/components/connectionStatus/ConnectionStatusBar.stories.tsx @@ -1,7 +1,7 @@ -import { ConnectionStatusContext } from '@rocket.chat/ui-contexts'; +import { mockAppRoot } from '@rocket.chat/mock-providers'; +import type { ServerContextValue } from '@rocket.chat/ui-contexts'; import { action } from '@storybook/addon-actions'; import type { Meta, StoryFn } from '@storybook/react'; -import type { ContextType, ReactElement } from 'react'; import ConnectionStatusBar from './ConnectionStatusBar'; @@ -13,9 +13,14 @@ export default { }, } satisfies Meta; -const stateDecorator = (value: ContextType) => (fn: () => ReactElement) => ( - {fn()} -); +const stateDecorator = (value: Partial) => + mockAppRoot() + .withServerContext({ + ...value, + reconnect: action('reconnect'), + disconnect: action('disconnect'), + }) + .buildStoryDecorator(); const Template: StoryFn = () => ; @@ -25,8 +30,6 @@ Connected.decorators = [ connected: true, status: 'connected', retryTime: undefined, - reconnect: action('reconnect'), - isLoggingIn: false, }), ]; @@ -36,8 +39,6 @@ Connecting.decorators = [ connected: false, status: 'connecting', retryTime: undefined, - reconnect: action('reconnect'), - isLoggingIn: false, }), ]; @@ -47,8 +48,6 @@ Failed.decorators = [ connected: false, status: 'failed', retryTime: undefined, - reconnect: action('reconnect'), - isLoggingIn: false, }), ]; @@ -58,8 +57,6 @@ Waiting.decorators = [ connected: false, status: 'waiting', retryTime: Date.now() + 300000, - reconnect: action('reconnect'), - isLoggingIn: false, }), ]; @@ -69,7 +66,5 @@ Offline.decorators = [ connected: false, status: 'offline', retryTime: undefined, - reconnect: action('reconnect'), - isLoggingIn: false, }), ]; diff --git a/apps/meteor/client/components/message/content/attachments/QuoteAttachment.tsx b/apps/meteor/client/components/message/content/attachments/QuoteAttachment.tsx index 4f44f1d810c6f..2a4f975c6641a 100644 --- a/apps/meteor/client/components/message/content/attachments/QuoteAttachment.tsx +++ b/apps/meteor/client/components/message/content/attachments/QuoteAttachment.tsx @@ -18,7 +18,7 @@ import AttachmentInner from './structure/AttachmentInner'; const quoteStyles = css` .rcx-attachment__details { .rcx-message-body { - color: ${Palette.text['font-hint']}; + color: ${Palette.text['font-default']}; } } &:hover, @@ -53,14 +53,14 @@ export const QuoteAttachment = ({ attachment }: QuoteAttachmentProps): ReactElem {displayAvatarPreference && } {attachment.author_name} {attachment.ts && ( {formatTime(attachment.ts)} diff --git a/apps/meteor/client/components/message/content/attachments/file/AudioAttachment.tsx b/apps/meteor/client/components/message/content/attachments/file/AudioAttachment.tsx index d24ec6ba2ff9e..227874cfb8eaf 100644 --- a/apps/meteor/client/components/message/content/attachments/file/AudioAttachment.tsx +++ b/apps/meteor/client/components/message/content/attachments/file/AudioAttachment.tsx @@ -1,7 +1,9 @@ import type { AudioAttachmentProps } from '@rocket.chat/core-typings'; import { AudioPlayer } from '@rocket.chat/fuselage'; import { useMediaUrl } from '@rocket.chat/ui-contexts'; +import { useMemo } from 'react'; +import { useReloadOnError } from './hooks/useReloadOnError'; import MarkdownText from '../../../../MarkdownText'; import MessageCollapsible from '../../../MessageCollapsible'; import MessageContentBody from '../../../MessageContentBody'; @@ -18,11 +20,14 @@ const AudioAttachment = ({ collapsed, }: AudioAttachmentProps) => { const getURL = useMediaUrl(); + const src = useMemo(() => getURL(url), [getURL, url]); + const { mediaRef } = useReloadOnError(src, 'audio'); + return ( <> {descriptionMd ? : } - + ); diff --git a/apps/meteor/client/components/message/content/attachments/file/VideoAttachment.tsx b/apps/meteor/client/components/message/content/attachments/file/VideoAttachment.tsx index fedfa382de829..4768e01d41cda 100644 --- a/apps/meteor/client/components/message/content/attachments/file/VideoAttachment.tsx +++ b/apps/meteor/client/components/message/content/attachments/file/VideoAttachment.tsx @@ -1,7 +1,9 @@ import type { VideoAttachmentProps } from '@rocket.chat/core-typings'; import { Box, MessageGenericPreview } from '@rocket.chat/fuselage'; import { useMediaUrl } from '@rocket.chat/ui-contexts'; +import { useMemo } from 'react'; +import { useReloadOnError } from './hooks/useReloadOnError'; import { userAgentMIMETypeFallback } from '../../../../../lib/utils/userAgentMIMETypeFallback'; import MarkdownText from '../../../../MarkdownText'; import MessageCollapsible from '../../../MessageCollapsible'; @@ -19,13 +21,15 @@ const VideoAttachment = ({ collapsed, }: VideoAttachmentProps) => { const getURL = useMediaUrl(); + const src = useMemo(() => getURL(url), [getURL, url]); + const { mediaRef } = useReloadOnError(src, 'video'); return ( <> {descriptionMd ? : } - + diff --git a/apps/meteor/client/components/message/content/attachments/file/hooks/useReloadOnError.spec.tsx b/apps/meteor/client/components/message/content/attachments/file/hooks/useReloadOnError.spec.tsx new file mode 100644 index 0000000000000..2b1f7415ab452 --- /dev/null +++ b/apps/meteor/client/components/message/content/attachments/file/hooks/useReloadOnError.spec.tsx @@ -0,0 +1,235 @@ +import { renderHook, act } from '@testing-library/react'; + +import { useReloadOnError } from './useReloadOnError'; +import { FakeResponse } from '../../../../../../../tests/mocks/utils/FakeResponse'; + +interface ITestMediaElement extends HTMLAudioElement { + _emit: (type: string) => void; +} + +function makeMediaEl(): ITestMediaElement { + const el = document.createElement('audio') as ITestMediaElement; + (el as any).play = jest.fn().mockResolvedValue(undefined); + Object.defineProperty(el, 'paused', { value: false, configurable: true }); + el._emit = (type: string) => el.dispatchEvent(new Event(type)); + return el; +} + +describe('useReloadOnError', () => { + const OLD_FETCH = global.fetch; + + beforeEach(() => { + jest.useFakeTimers(); + jest.spyOn(console, 'debug').mockImplementation(() => null); + jest.spyOn(console, 'warn').mockImplementation(() => null); + jest.spyOn(console, 'error').mockImplementation(() => null); + + // default mock: fresh redirect URL + ISO expiry 60s ahead + global.fetch = jest.fn().mockResolvedValue( + new FakeResponse( + JSON.stringify({ + redirectUrl: '/sampleurl?token=xyz', + expires: new Date(Date.now() + 60_000).toISOString(), + }), + { status: 200, headers: { 'Content-Type': 'application/json' } }, + ), + ) as any; + }); + + afterEach(() => { + jest.runOnlyPendingTimers(); + jest.useRealTimers(); + global.fetch = OLD_FETCH as any; + jest.restoreAllMocks(); + jest.resetAllMocks(); + }); + + it('refreshes media src on error and preserves playback position', async () => { + const original = '/sampleurl?token=abc'; + const { result } = renderHook(() => useReloadOnError(original, 'audio')); + + const media = makeMediaEl(); + media.currentTime = 12; + + act(() => { + result.current.mediaRef(media); + }); + + const loadSpy = jest.spyOn(media, 'load'); + + await act(async () => { + media._emit('error'); + }); + + expect(global.fetch).toHaveBeenCalledTimes(1); + + expect(media.src).toContain('/sampleurl?token=xyz'); + + await act(async () => { + media._emit('loadedmetadata'); + media._emit('canplay'); + }); + + expect(loadSpy).toHaveBeenCalled(); + expect(media.currentTime).toBe(12); + expect((media as any).play).toHaveBeenCalled(); + }); + + it('refreshes media src on stalled and preserves playback position', async () => { + const original = '/sampleurl?token=abc'; + const { result } = renderHook(() => useReloadOnError(original, 'audio')); + + const media = makeMediaEl(); + media.currentTime = 12; + + act(() => { + result.current.mediaRef(media); + }); + + const loadSpy = jest.spyOn(media, 'load'); + + await act(async () => { + media._emit('stalled'); + }); + + expect(global.fetch).toHaveBeenCalledTimes(1); + + expect(media.src).toContain('/sampleurl?token=xyz'); + + await act(async () => { + media._emit('loadedmetadata'); + media._emit('canplay'); + }); + + expect(loadSpy).toHaveBeenCalled(); + expect(media.currentTime).toBe(12); + expect((media as any).play).toHaveBeenCalled(); + }); + + it('does nothing when URL is not expired (second event before expiry)', async () => { + // Pin system time so Date.now() is deterministic under fake timers + const fixed = new Date('2030-01-01T00:00:00.000Z'); + jest.setSystemTime(fixed); + + // Backend replies with expiry 60s in the future (relative to pinned time) + global.fetch = jest.fn().mockResolvedValue( + new FakeResponse( + JSON.stringify({ + redirectUrl: '/new?x=1', + expires: new Date(fixed.getTime() + 60_000).toISOString(), + }), + { status: 200, headers: { 'Content-Type': 'application/json' } }, + ), + ) as any; + + const { result } = renderHook(() => useReloadOnError('/sampleurl?token=abc', 'audio')); + const media = makeMediaEl(); + + act(() => { + result.current.mediaRef(media); + }); + + // First event → fetch + set expires + await act(async () => { + media._emit('stalled'); + }); + expect(global.fetch).toHaveBeenCalledTimes(1); + + // Second event before expiry → early return, no new fetch + await act(async () => { + media._emit('stalled'); + }); + expect(global.fetch).toHaveBeenCalledTimes(1); + }); + + it('recovers on stalled after expiry and restores seek position', async () => { + // Pin time + const fixed = new Date('2030-01-01T00:00:00.000Z'); + jest.setSystemTime(fixed); + + // 1st fetch (first recovery) -> expires in 5s + const firstReply = new FakeResponse( + JSON.stringify({ + redirectUrl: '/fresh?token=first', + expires: new Date(fixed.getTime() + 5_000).toISOString(), + }), + { status: 200, headers: { 'Content-Type': 'application/json' } }, + ); + + // 2nd fetch (after expiry) -> new url, further expiry + const secondReply = new FakeResponse( + JSON.stringify({ + redirectUrl: '/fresh?token=second', + expires: new Date(fixed.getTime() + 65_000).toISOString(), + }), + { status: 200, headers: { 'Content-Type': 'application/json' } }, + ); + + // Mock fetch to return first, then second + (global.fetch as jest.Mock) = jest.fn().mockResolvedValueOnce(firstReply).mockResolvedValueOnce(secondReply); + + const { result } = renderHook(() => useReloadOnError('/sampleurl?token=old', 'audio')); + const media = makeMediaEl(); + act(() => { + result.current.mediaRef(media); + }); + + // Initial recovery to set expiresAt (simulate an error) + await act(async () => { + media._emit('error'); + }); + expect(global.fetch).toHaveBeenCalledTimes(1); + expect(media.src).toContain('/fresh?token=first'); + + // Complete the ready cycle + await act(async () => { + media._emit('loadedmetadata'); + media._emit('canplay'); + }); + + // Fast-forward time beyond expiry + jest.setSystemTime(new Date(fixed.getTime() + 6_000)); + + // User scrubs to a new position just before stall is detected + media.currentTime = 42; + + const loadSpy = jest.spyOn(media, 'load'); + + // Now we stall after expiry -> should trigger a new fetch + await act(async () => { + media._emit('stalled'); + }); + + expect(global.fetch).toHaveBeenCalledTimes(2); + expect(media.src).toContain('/fresh?token=second'); + + // Complete the ready cycle + await act(async () => { + media._emit('loadedmetadata'); + media._emit('canplay'); + }); + + // Ensure we reloaded and restored the seek position + playback + expect(loadSpy).toHaveBeenCalled(); + expect(media.currentTime).toBe(42); + expect((media as any).play).toHaveBeenCalled(); + }); + + it('ignores initial play when expiry is unknown', async () => { + // no fetch expected on first play because expiresAt is not known yet + global.fetch = jest.fn(); + + const { result } = renderHook(() => useReloadOnError('/foo', 'audio')); + const media = makeMediaEl(); + + act(() => { + result.current.mediaRef(media); + }); + + await act(async () => { + media._emit('play'); + }); + + expect(global.fetch).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/meteor/client/components/message/content/attachments/file/hooks/useReloadOnError.tsx b/apps/meteor/client/components/message/content/attachments/file/hooks/useReloadOnError.tsx new file mode 100644 index 0000000000000..b5b73c8e2ec13 --- /dev/null +++ b/apps/meteor/client/components/message/content/attachments/file/hooks/useReloadOnError.tsx @@ -0,0 +1,157 @@ +import { useEffectEvent } from '@rocket.chat/fuselage-hooks'; +import { useSafeRefCallback } from '@rocket.chat/ui-client'; +import { useCallback, useRef, useState } from 'react'; + +const events = ['error', 'stalled', 'play']; + +function toURL(urlString: string): URL { + try { + return new URL(urlString); + } catch { + return new URL(urlString, window.location.href); + } +} + +const getRedirectURLInfo = async (url: string): Promise<{ redirectUrl: string | false; expires: number | null }> => { + const _url = toURL(url); + _url.searchParams.set('replyWithRedirectUrl', 'true'); + const response = await fetch(_url, { + credentials: 'same-origin', + }); + + if (!response.ok) { + throw new Error(`Failed to fetch URL info: ${response.statusText}`); + } + + const data = await response.json(); + + return { + redirectUrl: data.redirectUrl, + expires: data.expires ? new Date(data.expires).getTime() : null, + }; +}; + +const renderBufferingUIFallback = (vidEl: HTMLVideoElement) => { + const computed = getComputedStyle(vidEl); + + const videoTempStyles = { + width: vidEl.style.width, + height: vidEl.style.height, + }; + Object.assign(vidEl.style, { + width: computed.width, + height: computed.height, + }); + + return () => { + Object.assign(vidEl.style, videoTempStyles); + }; +}; + +export const useReloadOnError = (url: string, type: 'video' | 'audio') => { + const [expiresAt, setExpiresAt] = useState(null); + const isRecovering = useRef(false); + const firstRecoveryAttempted = useRef(false); + + const handleMediaURLRecovery = useEffectEvent(async (event: Event) => { + if (isRecovering.current) { + console.debug(`Media URL recovery already in progress, skipping ${event.type} event`); + return; + } + isRecovering.current = true; + + const node = event.target as HTMLMediaElement | null; + if (!node) { + isRecovering.current = false; + return; + } + + if (firstRecoveryAttempted.current) { + if (!expiresAt) { + console.debug('No expiration time set, skipping recovery'); + isRecovering.current = false; + return; + } + } else if (event.type === 'play') { + // The user has initiated a playback for the first time, probably we should wait for the stalled or error event + // the url may still be valid since we dont know the expiration time yet + isRecovering.current = false; + return; + } + + firstRecoveryAttempted.current = true; + + if (expiresAt && Date.now() < expiresAt) { + console.debug('Media URL is still valid, skipping recovery'); + isRecovering.current = false; + return; + } + + console.debug('Handling media URL recovery for event:', event.type); + + let cleanup: (() => void) | undefined; + if (type === 'video') { + cleanup = renderBufferingUIFallback(node as HTMLVideoElement); + } + + const wasPlaying = !node.paused; + const { currentTime } = node; + + try { + const { redirectUrl: newUrl, expires: newExpiresAt } = await getRedirectURLInfo(url); + setExpiresAt(newExpiresAt); + node.src = newUrl || url; + + const onCanPlay = async () => { + node.removeEventListener('canplay', onCanPlay); + + node.currentTime = currentTime; + if (wasPlaying) { + try { + await node.play(); + } catch (playError) { + console.warn('Failed to resume playback after URL recovery:', playError); + } finally { + isRecovering.current = false; + } + } + }; + + const onMetaDataLoaded = () => { + node.removeEventListener('loadedmetadata', onMetaDataLoaded); + isRecovering.current = false; + cleanup?.(); + }; + + node.addEventListener('canplay', onCanPlay, { once: true }); + node.addEventListener('loadedmetadata', onMetaDataLoaded, { once: true }); + node.load(); + } catch (err) { + console.error('Error during URL recovery:', err); + isRecovering.current = false; + cleanup?.(); + } + }); + + const mediaRefCallback = useSafeRefCallback( + useCallback( + (node: HTMLAudioElement | null) => { + if (!node) { + return; + } + + events.forEach((event) => { + node.addEventListener(event, handleMediaURLRecovery); + }); + return () => { + events.forEach((event) => { + node.removeEventListener(event, handleMediaURLRecovery); + }); + }; + }, + [handleMediaURLRecovery], + ), + ); + + return { mediaRef: mediaRefCallback }; +}; diff --git a/apps/meteor/client/components/message/content/attachments/structure/AttachmentDetails.tsx b/apps/meteor/client/components/message/content/attachments/structure/AttachmentDetails.tsx index 522edba439bb9..be9e4a39515c1 100644 --- a/apps/meteor/client/components/message/content/attachments/structure/AttachmentDetails.tsx +++ b/apps/meteor/client/components/message/content/attachments/structure/AttachmentDetails.tsx @@ -4,7 +4,7 @@ import type { ComponentPropsWithoutRef } from 'react'; type AttachmentDetailsProps = ComponentPropsWithoutRef; const AttachmentDetails = (props: AttachmentDetailsProps) => ( - + ); export default AttachmentDetails; diff --git a/apps/meteor/client/components/message/header/hooks/useMessageRoles.ts b/apps/meteor/client/components/message/header/hooks/useMessageRoles.ts index cf85a4011e2ae..018cedd2e18fb 100644 --- a/apps/meteor/client/components/message/header/hooks/useMessageRoles.ts +++ b/apps/meteor/client/components/message/header/hooks/useMessageRoles.ts @@ -2,11 +2,11 @@ import type { IRole, IRoom, IUser } from '@rocket.chat/core-typings'; import { useCallback } from 'react'; import { useShallow } from 'zustand/shallow'; -import { Roles } from '../../../../../app/models/client'; import type { RoomRoles } from '../../../../hooks/useRoomRolesQuery'; import { useRoomRolesQuery } from '../../../../hooks/useRoomRolesQuery'; import type { UserRoles } from '../../../../hooks/useUserRolesQuery'; import { useUserRolesQuery } from '../../../../hooks/useUserRolesQuery'; +import { Roles } from '../../../../stores'; export const useMessageRoles = (userId: IUser['_id'] | undefined, roomId: IRoom['_id'], shouldLoadRoles: boolean): Array => { const { data: userRoles } = useUserRolesQuery({ diff --git a/apps/meteor/client/components/message/toolbar/MessageToolbarActionMenu.tsx b/apps/meteor/client/components/message/toolbar/MessageToolbarActionMenu.tsx index ecea1dc5baa12..af0501ca8f288 100644 --- a/apps/meteor/client/components/message/toolbar/MessageToolbarActionMenu.tsx +++ b/apps/meteor/client/components/message/toolbar/MessageToolbarActionMenu.tsx @@ -93,7 +93,7 @@ const MessageToolbarActionMenu = ({ message, context, room, subscription, onChan const groupOptions = [...data, ...(actionButtonApps.data ?? [])] .map((option) => ({ - variant: option.color === 'alert' ? 'danger' : '', + variant: option.variant, id: option.id, icon: option.icon, content: t(option.label), @@ -140,17 +140,7 @@ const MessageToolbarActionMenu = ({ message, context, room, subscription, onChan }; }); - return ( - - ); + return ; }; export default MessageToolbarActionMenu; diff --git a/apps/meteor/client/components/message/toolbar/MessageToolbarStarsActionMenu.tsx b/apps/meteor/client/components/message/toolbar/MessageToolbarStarsActionMenu.tsx index 5a2d9b7e7dd8f..eca8b4e845a2f 100644 --- a/apps/meteor/client/components/message/toolbar/MessageToolbarStarsActionMenu.tsx +++ b/apps/meteor/client/components/message/toolbar/MessageToolbarStarsActionMenu.tsx @@ -31,7 +31,7 @@ const MessageToolbarStarsActionMenu = ({ message, context, onChangeMenuVisibilit const groupOptions = starsAction.data.reduce((acc, option) => { const transformedOption = { - variant: option.color === 'alert' ? 'danger' : '', + variant: option.variant, id: option.id, icon: option.icon, content: t(option.label), @@ -76,8 +76,6 @@ const MessageToolbarStarsActionMenu = ({ message, context, onChangeMenuVisibilit title={t('AI_Actions')} sections={groupOptions} placement='bottom-end' - data-qa-id='menu' - data-qa-type='message-action-stars-menu-options' onOpenChange={onChangeMenuVisibility} /> ); diff --git a/apps/meteor/client/components/message/toolbar/items/actions/ReactionMessageAction.tsx b/apps/meteor/client/components/message/toolbar/items/actions/ReactionMessageAction.tsx index f3e1d00062f05..f8873d35f55bf 100644 --- a/apps/meteor/client/components/message/toolbar/items/actions/ReactionMessageAction.tsx +++ b/apps/meteor/client/components/message/toolbar/items/actions/ReactionMessageAction.tsx @@ -1,6 +1,6 @@ import { isOmnichannelRoom, type IMessage, type IRoom, type ISubscription } from '@rocket.chat/core-typings'; import { useFeaturePreview } from '@rocket.chat/ui-client'; -import { useUser, useMethod } from '@rocket.chat/ui-contexts'; +import { useUser, useEndpoint } from '@rocket.chat/ui-contexts'; import { useCallback } from 'react'; import { useTranslation } from 'react-i18next'; @@ -20,7 +20,7 @@ type ReactionMessageActionProps = { const ReactionMessageAction = ({ message, room, subscription }: ReactionMessageActionProps) => { const chat = useChat(); const user = useUser(); - const setReaction = useMethod('setReaction'); + const setReaction = useEndpoint('POST', '/v1/chat.react'); const quickReactionsEnabled = useFeaturePreview('quickReactions'); const { quickReactions, addRecentEmoji } = useEmojiPickerData(); const { t } = useTranslation(); @@ -44,7 +44,10 @@ const ReactionMessageAction = ({ message, room, subscription }: ReactionMessageA } const toggleReaction = (emoji: string) => { - setReaction(`:${emoji}:`, message._id); + setReaction({ + emoji: `:${emoji}:`, + messageId: message._id, + }); addRecentEmoji(emoji); }; diff --git a/apps/meteor/client/components/message/toolbar/useDeleteMessageAction.ts b/apps/meteor/client/components/message/toolbar/useDeleteMessageAction.ts index ff8a24d12b841..fff4a8c986734 100644 --- a/apps/meteor/client/components/message/toolbar/useDeleteMessageAction.ts +++ b/apps/meteor/client/components/message/toolbar/useDeleteMessageAction.ts @@ -43,7 +43,7 @@ export const useDeleteMessageAction = ( icon: 'trash', label: 'Delete', context: ['message', 'message-mobile', 'threads', 'federated', 'videoconf', 'videoconf-threads'], - color: 'alert', + variant: 'danger', type: 'management', async action() { await chat?.flows.requestMessageDeletion(message); diff --git a/apps/meteor/client/components/message/toolbar/useFollowMessageAction.ts b/apps/meteor/client/components/message/toolbar/useFollowMessageAction.ts index 58c14756d2c02..b962b8fb75ac3 100644 --- a/apps/meteor/client/components/message/toolbar/useFollowMessageAction.ts +++ b/apps/meteor/client/components/message/toolbar/useFollowMessageAction.ts @@ -2,9 +2,9 @@ import type { IMessage, IRoom } from '@rocket.chat/core-typings'; import { isOmnichannelRoom } from '@rocket.chat/core-typings'; import { useSetting, useToastMessageDispatch, useUser } from '@rocket.chat/ui-contexts'; -import { Messages } from '../../../../app/models/client'; import type { MessageActionContext, MessageActionConfig } from '../../../../app/ui-utils/client/lib/MessageAction'; import { t } from '../../../../app/utils/lib/i18n'; +import { Messages } from '../../../stores'; import { useToggleFollowingThreadMutation } from '../../../views/room/contextualBar/Threads/hooks/useToggleFollowingThreadMutation'; export const useFollowMessageAction = ( diff --git a/apps/meteor/client/components/message/toolbar/useReplyInDMAction.ts b/apps/meteor/client/components/message/toolbar/useReplyInDMAction.ts index 7bd70c230dd64..40bbfdfbc46d9 100644 --- a/apps/meteor/client/components/message/toolbar/useReplyInDMAction.ts +++ b/apps/meteor/client/components/message/toolbar/useReplyInDMAction.ts @@ -1,13 +1,12 @@ import { type IMessage, type ISubscription, type IRoom, isE2EEMessage } from '@rocket.chat/core-typings'; -import type { SubscriptionWithRoom } from '@rocket.chat/ui-contexts'; import { usePermission, useRouter, useUser } from '@rocket.chat/ui-contexts'; import { useCallback, useMemo } from 'react'; import { useShallow } from 'zustand/shallow'; -import { Rooms, Subscriptions } from '../../../../app/models/client'; import type { MessageActionConfig } from '../../../../app/ui-utils/client/lib/MessageAction'; import { useEmbeddedLayout } from '../../../hooks/useEmbeddedLayout'; import { roomCoordinator } from '../../../lib/rooms/roomCoordinator'; +import { Rooms, Subscriptions } from '../../../stores'; export const useReplyInDMAction = ( message: IMessage, @@ -31,7 +30,7 @@ export const useReplyInDMAction = ( const dmRoom = Rooms.use(useShallow((state) => (shouldFindRoom ? state.find(roomPredicate) : undefined))); const subsPredicate = useCallback( - (record: SubscriptionWithRoom) => record.rid === dmRoom?._id || record.u._id === user?._id, + (record: ISubscription) => record.rid === dmRoom?._id || record.u._id === user?._id, [dmRoom, user?._id], ); const dmSubs = Subscriptions.use(useShallow((state) => state.find(subsPredicate))); diff --git a/apps/meteor/client/components/message/toolbar/useReportMessageAction.tsx b/apps/meteor/client/components/message/toolbar/useReportMessageAction.tsx index da0c4a47d4cce..ba281c5a1f5a2 100644 --- a/apps/meteor/client/components/message/toolbar/useReportMessageAction.tsx +++ b/apps/meteor/client/components/message/toolbar/useReportMessageAction.tsx @@ -34,7 +34,7 @@ export const useReportMessageAction = ( icon: 'report', label: 'Report', context: ['message', 'message-mobile', 'threads', 'federated', 'videoconf', 'videoconf-threads'], - color: 'alert', + variant: 'danger', type: 'management', action() { setModal( diff --git a/apps/meteor/client/components/message/toolbar/useTranslateAction.ts b/apps/meteor/client/components/message/toolbar/useTranslateAction.ts index f9448ced93f80..8b377471bf572 100644 --- a/apps/meteor/client/components/message/toolbar/useTranslateAction.ts +++ b/apps/meteor/client/components/message/toolbar/useTranslateAction.ts @@ -3,9 +3,9 @@ import { useMethod, usePermission, useSetting, useUser } from '@rocket.chat/ui-c import { useMemo } from 'react'; import { AutoTranslate } from '../../../../app/autotranslate/client'; -import { Messages } from '../../../../app/models/client'; import type { MessageActionConfig } from '../../../../app/ui-utils/client/lib/MessageAction'; import { roomCoordinator } from '../../../lib/rooms/roomCoordinator'; +import { Messages } from '../../../stores'; import { hasTranslationLanguageInAttachments, hasTranslationLanguageInMessage } from '../../../views/room/MessageList/lib/autoTranslate'; export const useTranslateAction = ( diff --git a/apps/meteor/client/components/message/toolbar/useUnFollowMessageAction.ts b/apps/meteor/client/components/message/toolbar/useUnFollowMessageAction.ts index 1e5c58a53c3c1..ad5df95d15149 100644 --- a/apps/meteor/client/components/message/toolbar/useUnFollowMessageAction.ts +++ b/apps/meteor/client/components/message/toolbar/useUnFollowMessageAction.ts @@ -2,9 +2,9 @@ import type { IMessage, IRoom } from '@rocket.chat/core-typings'; import { isOmnichannelRoom } from '@rocket.chat/core-typings'; import { useSetting, useToastMessageDispatch, useUser } from '@rocket.chat/ui-contexts'; -import { Messages } from '../../../../app/models/client'; import type { MessageActionContext, MessageActionConfig } from '../../../../app/ui-utils/client/lib/MessageAction'; import { t } from '../../../../app/utils/lib/i18n'; +import { Messages } from '../../../stores'; import { useToggleFollowingThreadMutation } from '../../../views/room/contextualBar/Threads/hooks/useToggleFollowingThreadMutation'; export const useUnFollowMessageAction = ( diff --git a/apps/meteor/client/components/message/toolbar/useViewOriginalTranslationAction.ts b/apps/meteor/client/components/message/toolbar/useViewOriginalTranslationAction.ts index 103471e1a6f6d..1ba6e7c98310a 100644 --- a/apps/meteor/client/components/message/toolbar/useViewOriginalTranslationAction.ts +++ b/apps/meteor/client/components/message/toolbar/useViewOriginalTranslationAction.ts @@ -3,9 +3,9 @@ import { useMethod, usePermission, useSetting, useUser } from '@rocket.chat/ui-c import { useMemo } from 'react'; import { AutoTranslate } from '../../../../app/autotranslate/client'; -import { Messages } from '../../../../app/models/client'; import type { MessageActionConfig } from '../../../../app/ui-utils/client/lib/MessageAction'; import { roomCoordinator } from '../../../lib/rooms/roomCoordinator'; +import { Messages } from '../../../stores'; import { hasTranslationLanguageInAttachments, hasTranslationLanguageInMessage } from '../../../views/room/MessageList/lib/autoTranslate'; export const useViewOriginalTranslationAction = ( diff --git a/apps/meteor/client/components/message/variants/SystemMessage.spec.tsx b/apps/meteor/client/components/message/variants/SystemMessage.spec.tsx index c0482e831dbf8..9df7e7669440d 100644 --- a/apps/meteor/client/components/message/variants/SystemMessage.spec.tsx +++ b/apps/meteor/client/components/message/variants/SystemMessage.spec.tsx @@ -56,7 +56,7 @@ describe('SystemMessage', () => { it('should render system message', () => { const message = createBaseMessage('& test &'); - render(, { legacyRoot: true, wrapper: wrapper.build() }); + render(, { wrapper: wrapper.build() }); expect(screen.getByText('changed room description to: & test &')).toBeInTheDocument(); }); @@ -64,7 +64,7 @@ describe('SystemMessage', () => { it('should not show escaped html while rendering system message', () => { const message = createBaseMessage('& test &'); - render(, { legacyRoot: true, wrapper: wrapper.build() }); + render(, { wrapper: wrapper.build() }); expect(screen.getByText('changed room description to: & test &')).toBeInTheDocument(); expect(screen.queryByText('changed room description to: & test &')).not.toBeInTheDocument(); @@ -73,7 +73,7 @@ describe('SystemMessage', () => { it('should not inject html', () => { const message = createBaseMessage(''); - render(, { legacyRoot: true, wrapper: wrapper.build() }); + render(, { wrapper: wrapper.build() }); expect(screen.queryByTitle('test-title')).not.toBeInTheDocument(); expect(screen.getByText('changed room description to: ')).toBeInTheDocument(); diff --git a/apps/meteor/client/components/message/variants/threadPreview/ThreadMessagePreviewBody.spec.tsx b/apps/meteor/client/components/message/variants/threadPreview/ThreadMessagePreviewBody.spec.tsx new file mode 100644 index 0000000000000..7e25162e48958 --- /dev/null +++ b/apps/meteor/client/components/message/variants/threadPreview/ThreadMessagePreviewBody.spec.tsx @@ -0,0 +1,87 @@ +import { mockAppRoot } from '@rocket.chat/mock-providers'; +import { composeStories } from '@storybook/react'; +import { render, screen } from '@testing-library/react'; +import { axe } from 'jest-axe'; + +import * as stories from './ThreadMessagePreviewBody.stories'; + +const { Default } = composeStories(stories); + +const testCases = Object.values(composeStories(stories)).map((Story) => [Story.storyName || 'Story', Story]); + +jest.mock('../../../../lib/utils/fireGlobalEvent', () => ({ + fireGlobalEvent: jest.fn(), +})); + +jest.mock('../../../../views/room/hooks/useGoToRoom', () => ({ + useGoToRoom: jest.fn(), +})); + +test.each(testCases)(`renders ThreadMessagePreviewBody without crashing`, async (_storyname, Story) => { + const view = render(, { wrapper: mockAppRoot().build() }); + + expect(view.baseElement).toMatchSnapshot(); +}); + +test.each(testCases)('ThreadMessagePreviewBody should have no a11y violations', async (_storyname, Story) => { + const { container } = render(, { wrapper: mockAppRoot().build() }); + + const results = await axe(container); + + expect(results).toHaveNoViolations(); +}); + +it('should not show an empty thread preview', async () => { + const { container } = render( + , + { wrapper: mockAppRoot().build() }, + ); + expect(container).toMatchSnapshot(); + const text = screen.getByText('http://localhost:3000/group/ds?msg=ZoX9pDowqNb4BiWxf'); + + expect(text).toBeInTheDocument; +}); diff --git a/apps/meteor/client/components/message/variants/threadPreview/ThreadMessagePreviewBody.stories.tsx b/apps/meteor/client/components/message/variants/threadPreview/ThreadMessagePreviewBody.stories.tsx new file mode 100644 index 0000000000000..24fb32c76d3e5 --- /dev/null +++ b/apps/meteor/client/components/message/variants/threadPreview/ThreadMessagePreviewBody.stories.tsx @@ -0,0 +1,29 @@ +import { mockAppRoot } from '@rocket.chat/mock-providers'; +import type { Meta, StoryFn } from '@storybook/react'; +import type { ComponentProps } from 'react'; + +import ThreadMessagePreviewBody from './ThreadMessagePreviewBody'; + +export default { + title: 'Components/ThreadMessagePreviewBody', + component: ThreadMessagePreviewBody, + parameters: { + layout: 'fullscreen', + }, + decorators: [mockAppRoot().withSetting('UI_Use_Real_Name', true).withJohnDoe().buildStoryDecorator()], + args: { + message: { + _id: 'message-id', + ts: new Date(), + msg: 'This is a message', + u: { + _id: 'user-id', + username: 'username', + }, + rid: 'room-id', + _updatedAt: new Date(), + }, + }, +} satisfies Meta; + +export const Default: StoryFn> = (args) => ; diff --git a/apps/meteor/client/components/message/variants/threadPreview/ThreadMessagePreviewBody.tsx b/apps/meteor/client/components/message/variants/threadPreview/ThreadMessagePreviewBody.tsx index d83db7abf8e3d..72c4b8cb460dd 100644 --- a/apps/meteor/client/components/message/variants/threadPreview/ThreadMessagePreviewBody.tsx +++ b/apps/meteor/client/components/message/variants/threadPreview/ThreadMessagePreviewBody.tsx @@ -30,7 +30,7 @@ const ThreadMessagePreviewBody = ({ message }: ThreadMessagePreviewBodyProps): R return <>{t('Message_with_attachment')}; } if (!isEncryptedMessage || message.e2e === 'done') { - return mdTokens ? ( + return mdTokens?.length ? ( diff --git a/apps/meteor/client/components/message/variants/threadPreview/__snapshots__/ThreadMessagePreviewBody.spec.tsx.snap b/apps/meteor/client/components/message/variants/threadPreview/__snapshots__/ThreadMessagePreviewBody.spec.tsx.snap new file mode 100644 index 0000000000000..a8388e5c6e4d1 --- /dev/null +++ b/apps/meteor/client/components/message/variants/threadPreview/__snapshots__/ThreadMessagePreviewBody.spec.tsx.snap @@ -0,0 +1,15 @@ +// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing + +exports[`renders ThreadMessagePreviewBody without crashing 1`] = ` + +
+ This is a message +
+ +`; + +exports[`should not show an empty thread preview 1`] = ` +
+ http://localhost:3000/group/ds?msg=ZoX9pDowqNb4BiWxf +
+`; diff --git a/apps/meteor/client/definitions/IRocketChatDesktop.ts b/apps/meteor/client/definitions/IRocketChatDesktop.ts deleted file mode 100644 index a0707e9ba6d75..0000000000000 --- a/apps/meteor/client/definitions/IRocketChatDesktop.ts +++ /dev/null @@ -1,10 +0,0 @@ -type OutlookEventsResponse = { status: 'success' | 'canceled' }; - -export interface IRocketChatDesktop { - openInternalVideoChatWindow?: (url: string, options: { providerName: string | undefined }) => void; - getOutlookEvents?: (date: Date) => Promise; - setOutlookExchangeUrl?: (url: string, userId: string) => Promise; - hasOutlookCredentials?: () => Promise; - clearOutlookCredentials?: () => Promise | void; - openDocumentViewer?: (url: string, format: string, options: any) => void; -} diff --git a/apps/meteor/client/definitions/global.d.ts b/apps/meteor/client/definitions/global.d.ts index 58e383ee58d8b..b19bfe2db3b06 100644 --- a/apps/meteor/client/definitions/global.d.ts +++ b/apps/meteor/client/definitions/global.d.ts @@ -1,4 +1,4 @@ -import type { IRocketChatDesktop } from './IRocketChatDesktop'; +import type { IRocketChatDesktop } from '@rocket.chat/desktop-api'; declare global { // eslint-disable-next-line @typescript-eslint/naming-convention diff --git a/apps/meteor/client/hooks/menuActions/useToggleNotificationsAction.ts b/apps/meteor/client/hooks/menuActions/useToggleNotificationsAction.ts new file mode 100644 index 0000000000000..c99e6720fa2fe --- /dev/null +++ b/apps/meteor/client/hooks/menuActions/useToggleNotificationsAction.ts @@ -0,0 +1,30 @@ +import type { IRoom } from '@rocket.chat/core-typings'; +import { useEffectEvent } from '@rocket.chat/fuselage-hooks'; +import { useEndpoint, useToastMessageDispatch } from '@rocket.chat/ui-contexts'; +import { useTranslation } from 'react-i18next'; + +type useToggleNotificationActionProps = { + rid: IRoom['_id']; + isNotificationEnabled: boolean; + roomName: string; +}; + +export const useToggleNotificationAction = ({ rid, isNotificationEnabled, roomName }: useToggleNotificationActionProps) => { + const toggleNotification = useEndpoint('POST', '/v1/rooms.saveNotification'); + const dispatchToastMessage = useToastMessageDispatch(); + const { t } = useTranslation(); + + const handleToggleNotification = useEffectEvent(async () => { + try { + await toggleNotification({ roomId: rid, notifications: { disableNotifications: isNotificationEnabled ? '1' : '0' } }); + dispatchToastMessage({ + type: 'success', + message: t(isNotificationEnabled ? 'Room_notifications_off' : 'Room_notifications_on', { roomName }), + }); + } catch (error) { + dispatchToastMessage({ type: 'error', message: error }); + } + }); + + return handleToggleNotification; +}; diff --git a/apps/meteor/client/hooks/notification/useDesktopNotification.ts b/apps/meteor/client/hooks/notification/useDesktopNotification.ts index c58cccd59f878..6d7a24317e4d0 100644 --- a/apps/meteor/client/hooks/notification/useDesktopNotification.ts +++ b/apps/meteor/client/hooks/notification/useDesktopNotification.ts @@ -3,8 +3,8 @@ import { useEffectEvent } from '@rocket.chat/fuselage-hooks'; import { useUser } from '@rocket.chat/ui-contexts'; import { useNotification } from './useNotification'; -import { e2e } from '../../../app/e2e/client'; import { RoomManager } from '../../lib/RoomManager'; +import { e2e } from '../../lib/e2ee'; import { getAvatarAsPng } from '../../lib/utils/getAvatarAsPng'; export const useDesktopNotification = () => { diff --git a/apps/meteor/client/hooks/notification/useNewMessageNotification.ts b/apps/meteor/client/hooks/notification/useNewMessageNotification.ts index 36b94568a27a5..8f1442a5424c6 100644 --- a/apps/meteor/client/hooks/notification/useNewMessageNotification.ts +++ b/apps/meteor/client/hooks/notification/useNewMessageNotification.ts @@ -2,6 +2,8 @@ import type { AtLeast, ISubscription } from '@rocket.chat/core-typings'; import { useEffectEvent } from '@rocket.chat/fuselage-hooks'; import { useCustomSound } from '@rocket.chat/ui-contexts'; +import { Subscriptions } from '../../stores'; + export const useNewMessageNotification = () => { const { notificationSounds } = useCustomSound(); @@ -9,14 +11,12 @@ export const useNewMessageNotification = () => { if (!sub || sub.audioNotificationValue === 'none') { return; } - // TODO: Fix this - Room Notifications Preferences > sound > desktop is not working. - // plays the user notificationSound preference - // if (sub.audioNotificationValue && sub.audioNotificationValue !== '0') { - // void CustomSounds.play(sub.audioNotificationValue, { - // volume: Number((notificationsSoundVolume / 100).toPrecision(2)), - // }); - // } + const subscription = Subscriptions.state.find((record) => record.rid === sub.rid); + + if (subscription?.audioNotificationValue) { + return notificationSounds.playNewMessageCustom(subscription.audioNotificationValue); + } notificationSounds.playNewMessage(); }); diff --git a/apps/meteor/client/hooks/roomActions/useAppsRoomStarActions.tsx b/apps/meteor/client/hooks/roomActions/useAppsRoomStarActions.tsx index 3b718bb4263ad..457d71254e9c0 100644 --- a/apps/meteor/client/hooks/roomActions/useAppsRoomStarActions.tsx +++ b/apps/meteor/client/hooks/roomActions/useAppsRoomStarActions.tsx @@ -24,7 +24,6 @@ export const useAppsRoomStarActions = () => { if (!result.data) { return undefined; } - const filteredActions = result.data.filter(applyButtonFilters); if (filteredActions.length === 0) { @@ -37,6 +36,7 @@ export const useAppsRoomStarActions = () => { icon: 'stars', groups: ['group', 'channel', 'live', 'team', 'direct', 'direct_multiple'], featured: true, + order: 3, renderToolboxItem: ({ id, icon, title, disabled, className }) => ( } diff --git a/apps/meteor/client/hooks/roomActions/useE2EERoomAction.spec.ts b/apps/meteor/client/hooks/roomActions/useE2EERoomAction.spec.ts index c9b891d7539c4..533d9c1112bfc 100644 --- a/apps/meteor/client/hooks/roomActions/useE2EERoomAction.spec.ts +++ b/apps/meteor/client/hooks/roomActions/useE2EERoomAction.spec.ts @@ -2,23 +2,21 @@ import { imperativeModal } from '@rocket.chat/ui-client'; import { useSetting, usePermission, useEndpoint } from '@rocket.chat/ui-contexts'; import { act, renderHook, waitFor } from '@testing-library/react'; -import { E2EEState } from '../../../app/e2e/client/E2EEState'; -import { e2e } from '../../../app/e2e/client/rocketchat.e2e'; import { OtrRoomState } from '../../../app/otr/lib/OtrRoomState'; -import { dispatchToastMessage } from '../../lib/toast'; +import { E2EEState } from '../../lib/e2ee/E2EEState'; +import { e2e } from '../../lib/e2ee/rocketchat.e2e'; import { useRoom, useRoomSubscription } from '../../views/room/contexts/RoomContext'; import { useE2EEState } from '../../views/room/hooks/useE2EEState'; import { useOTR } from '../useOTR'; import { useE2EERoomAction } from './useE2EERoomAction'; +const dispatchToastMessage = jest.fn(); + jest.mock('@rocket.chat/ui-contexts', () => ({ useSetting: jest.fn(), usePermission: jest.fn(), useEndpoint: jest.fn(), -})); - -jest.mock('../../lib/toast', () => ({ - dispatchToastMessage: jest.fn(), + useToastMessageDispatch: jest.fn(() => dispatchToastMessage), })); jest.mock('@rocket.chat/ui-client', () => ({ @@ -38,7 +36,7 @@ jest.mock('../useOTR', () => ({ useOTR: jest.fn(), })); -jest.mock('../../../app/e2e/client/rocketchat.e2e', () => ({ +jest.mock('../../lib/e2ee/rocketchat.e2e', () => ({ e2e: { isReady: jest.fn(), }, diff --git a/apps/meteor/client/hooks/roomActions/useE2EERoomAction.ts b/apps/meteor/client/hooks/roomActions/useE2EERoomAction.ts index c27ef23a5ae48..fafe63bd88c91 100644 --- a/apps/meteor/client/hooks/roomActions/useE2EERoomAction.ts +++ b/apps/meteor/client/hooks/roomActions/useE2EERoomAction.ts @@ -1,15 +1,14 @@ import { isRoomFederated } from '@rocket.chat/core-typings'; import { useEffectEvent } from '@rocket.chat/fuselage-hooks'; import { imperativeModal } from '@rocket.chat/ui-client'; -import { useSetting, usePermission, useEndpoint } from '@rocket.chat/ui-contexts'; +import { useSetting, usePermission, useEndpoint, useToastMessageDispatch } from '@rocket.chat/ui-contexts'; import { useMemo } from 'react'; import { useTranslation } from 'react-i18next'; -import { E2EEState } from '../../../app/e2e/client/E2EEState'; -import { E2ERoomState } from '../../../app/e2e/client/E2ERoomState'; import { OtrRoomState } from '../../../app/otr/lib/OtrRoomState'; +import { E2EEState } from '../../lib/e2ee/E2EEState'; +import { E2ERoomState } from '../../lib/e2ee/E2ERoomState'; import { getRoomTypeTranslation } from '../../lib/getRoomTypeTranslation'; -import { dispatchToastMessage } from '../../lib/toast'; import { useRoom, useRoomSubscription } from '../../views/room/contexts/RoomContext'; import type { RoomToolboxActionConfig } from '../../views/room/contexts/RoomToolboxContext'; import { useE2EERoomState } from '../../views/room/hooks/useE2EERoomState'; @@ -31,6 +30,7 @@ export const useE2EERoomAction = () => { const permitted = (room.t === 'd' || (permittedToEditRoom && permittedToToggleEncryption)) && readyToEncrypt; const federated = isRoomFederated(room); const { t } = useTranslation(); + const dispatchToastMessage = useToastMessageDispatch(); const { otrState } = useOTR(); const isE2EERoomNotReady = () => { diff --git a/apps/meteor/client/hooks/roomActions/useVideoCallRoomAction.tsx b/apps/meteor/client/hooks/roomActions/useVideoCallRoomAction.tsx index 9146a2fc651a0..c5501f527f594 100644 --- a/apps/meteor/client/hooks/roomActions/useVideoCallRoomAction.tsx +++ b/apps/meteor/client/hooks/roomActions/useVideoCallRoomAction.tsx @@ -77,7 +77,7 @@ export const useVideoCallRoomAction = () => { icon: 'video', featured: true, action: handleOpenVideoConf, - order: -1, + order: 1, groups, disabled, tooltip, diff --git a/apps/meteor/client/hooks/roomActions/useVoiceCallRoomAction.tsx b/apps/meteor/client/hooks/roomActions/useVoiceCallRoomAction.tsx index c858a591387a7..e9dc459951814 100644 --- a/apps/meteor/client/hooks/roomActions/useVoiceCallRoomAction.tsx +++ b/apps/meteor/client/hooks/roomActions/useVoiceCallRoomAction.tsx @@ -65,6 +65,7 @@ export const useVoiceCallRoomAction = () => { featured: true, action: handleOnClick, groups: ['direct'] as const, + order: 2, disabled, tooltip, }; diff --git a/apps/meteor/client/hooks/useCreateChannelTypePermission.ts b/apps/meteor/client/hooks/useCreateChannelTypePermission.ts new file mode 100644 index 0000000000000..9a5184b39146d --- /dev/null +++ b/apps/meteor/client/hooks/useCreateChannelTypePermission.ts @@ -0,0 +1,42 @@ +import type { IRoom } from '@rocket.chat/core-typings'; +import { usePermission } from '@rocket.chat/ui-contexts'; +import { useMemo } from 'react'; + +/** + * Determines if a user's permissions restrict them to creating only one type of channel. + * + * This hook checks a user's permissions for creating public and private channels, + * either globally or within a specific team. It returns a string indicating the + * single channel type they can create, or `false` if they can create both or neither. + * + * @param {string} [teamRoomId] The optional ID of the main team room to check for team-specific permissions. + * @returns {'c' | 'p' | false} A string ('c' or 'p') if the user can only create one channel type, otherwise `false`. + */ +export const useCreateChannelTypePermission = (teamRoomId?: IRoom['_id']) => { + const canCreateChannel = usePermission('create-c'); + const canCreatePrivateChannel = usePermission('create-p'); + + const canCreateTeamChannel = usePermission('create-team-channel', teamRoomId); + const canCreateTeamGroup = usePermission('create-team-group', teamRoomId); + + return useMemo(() => { + if (teamRoomId) { + if (!canCreateTeamChannel && canCreateTeamGroup) { + return 'p'; + } + + if (canCreateTeamChannel && !canCreateTeamGroup) { + return 'c'; + } + } + + if (!canCreateChannel && canCreatePrivateChannel) { + return 'p'; + } + + if (canCreateChannel && !canCreatePrivateChannel) { + return 'c'; + } + return false; + }, [canCreateChannel, canCreatePrivateChannel, canCreateTeamChannel, canCreateTeamGroup, teamRoomId]); +}; diff --git a/apps/meteor/client/hooks/useDecryptedMessage.spec.ts b/apps/meteor/client/hooks/useDecryptedMessage.spec.ts index cbcc577311f08..5b35e8d6e3352 100644 --- a/apps/meteor/client/hooks/useDecryptedMessage.spec.ts +++ b/apps/meteor/client/hooks/useDecryptedMessage.spec.ts @@ -2,14 +2,14 @@ import { isE2EEMessage } from '@rocket.chat/core-typings'; import { renderHook, waitFor } from '@testing-library/react'; import { useDecryptedMessage } from './useDecryptedMessage'; -import { e2e } from '../../app/e2e/client/rocketchat.e2e'; +import { e2e } from '../lib/e2ee/rocketchat.e2e'; // Mock the dependencies jest.mock('@rocket.chat/core-typings', () => ({ isE2EEMessage: jest.fn(), })); -jest.mock('../../app/e2e/client/rocketchat.e2e', () => ({ +jest.mock('../lib/e2ee/rocketchat.e2e', () => ({ e2e: { decryptMessage: jest.fn(), }, diff --git a/apps/meteor/client/hooks/useDecryptedMessage.ts b/apps/meteor/client/hooks/useDecryptedMessage.ts index f98012b11f2b3..e560aacc5b111 100644 --- a/apps/meteor/client/hooks/useDecryptedMessage.ts +++ b/apps/meteor/client/hooks/useDecryptedMessage.ts @@ -4,7 +4,7 @@ import { useSafely } from '@rocket.chat/fuselage-hooks'; import { useState, useEffect } from 'react'; import { useTranslation } from 'react-i18next'; -import { e2e } from '../../app/e2e/client/rocketchat.e2e'; +import { e2e } from '../lib/e2ee/rocketchat.e2e'; export const useDecryptedMessage = (message: IMessage): string => { const { t } = useTranslation(); diff --git a/apps/meteor/client/hooks/useDeviceLogout.tsx b/apps/meteor/client/hooks/useDeviceLogout.tsx index 3265560b6f12b..c13528d4d4c80 100644 --- a/apps/meteor/client/hooks/useDeviceLogout.tsx +++ b/apps/meteor/client/hooks/useDeviceLogout.tsx @@ -1,58 +1,55 @@ import { GenericModal } from '@rocket.chat/ui-client'; -import { useSetModal, useTranslation, useToastMessageDispatch, useRoute, useRouteParameter } from '@rocket.chat/ui-contexts'; +import { useSetModal, useToastMessageDispatch, useRoute, useRouteParameter } from '@rocket.chat/ui-contexts'; +import { useQueryClient } from '@tanstack/react-query'; import { useCallback } from 'react'; +import { useTranslation } from 'react-i18next'; -import { useEndpointAction } from './useEndpointAction'; +import { useEndpointMutation } from './useEndpointMutation'; +import { deviceManagementQueryKeys } from '../lib/queryKeys'; -export const useDeviceLogout = ( - sessionId: string, - endpoint: '/v1/sessions/logout' | '/v1/sessions/logout.me', -): ((onReload: () => void) => void) => { - const t = useTranslation(); +export const useDeviceLogout = (sessionId: string, endpoint: '/v1/sessions/logout' | '/v1/sessions/logout.me'): (() => void) => { + const { t } = useTranslation(); const setModal = useSetModal(); const dispatchToastMessage = useToastMessageDispatch(); const deviceManagementRouter = useRoute('device-management'); const routeId = useRouteParameter('id'); - const logoutDevice = useEndpointAction('POST', endpoint); + const queryClient = useQueryClient(); - const handleCloseContextualBar = useCallback((): void => deviceManagementRouter.push({}), [deviceManagementRouter]); + const { mutateAsync: logoutDevice } = useEndpointMutation('POST', endpoint, { + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: deviceManagementQueryKeys.all }); + isContextualBarOpen && handleCloseContextualBar(); + dispatchToastMessage({ type: 'success', message: t('Device_Logged_Out') }); + }, + onSettled: () => { + setModal(null); + }, + }); - const isContextualBarOpen = routeId === sessionId; + const handleCloseContextualBar = useCallback(() => deviceManagementRouter.push({}), [deviceManagementRouter]); - const handleLogoutDeviceModal = useCallback( - (onReload: () => void) => { - const closeModal = (): void => setModal(null); - - const handleLogoutDevice = async (): Promise => { - try { - await logoutDevice({ sessionId }); - onReload(); - isContextualBarOpen && handleCloseContextualBar(); - dispatchToastMessage({ type: 'success', message: t('Device_Logged_Out') }); - } catch (error) { - dispatchToastMessage({ type: 'error', message: error }); - } finally { - closeModal(); - } - }; - - setModal( - - {t('Device_Logout_Text')} - , - ); - }, - [setModal, t, logoutDevice, sessionId, isContextualBarOpen, handleCloseContextualBar, dispatchToastMessage], - ); + const isContextualBarOpen = routeId === sessionId; - return handleLogoutDeviceModal; + return useCallback(() => { + const closeModal = () => setModal(null); + + const handleLogoutDevice = async () => { + await logoutDevice({ sessionId }); + }; + + setModal( + + {t('Device_Logout_Text')} + , + ); + }, [setModal, t, logoutDevice, sessionId]); }; diff --git a/apps/meteor/client/hooks/useDialModal.tsx b/apps/meteor/client/hooks/useDialModal.tsx index 9c7ae5ad1663c..a71d31d51efd6 100644 --- a/apps/meteor/client/hooks/useDialModal.tsx +++ b/apps/meteor/client/hooks/useDialModal.tsx @@ -1,9 +1,8 @@ -import { useSetModal } from '@rocket.chat/ui-contexts'; +import { useSetModal, useToastMessageDispatch } from '@rocket.chat/ui-contexts'; import { Suspense, lazy, useCallback, useMemo } from 'react'; import { useTranslation } from 'react-i18next'; import { useIsVoipEnterprise } from '../contexts/CallContext'; -import { dispatchToastMessage } from '../lib/toast'; const DialPadModal = lazy(() => import('../voip/modal/DialPad/DialPadModal')); @@ -19,8 +18,9 @@ type DialModalControls = { export const useDialModal = (): DialModalControls => { const setModal = useSetModal(); - const isEnterprise = useIsVoipEnterprise(); const { t } = useTranslation(); + const dispatchToastMessage = useToastMessageDispatch(); + const isEnterprise = useIsVoipEnterprise(); const closeDialModal = useCallback(() => setModal(null), [setModal]); @@ -39,7 +39,7 @@ export const useDialModal = (): DialModalControls => { , ); }, - [setModal, isEnterprise, t, closeDialModal], + [isEnterprise, setModal, closeDialModal, dispatchToastMessage, t], ); return useMemo( diff --git a/apps/meteor/client/hooks/useEndpointAction.ts b/apps/meteor/client/hooks/useEndpointAction.ts deleted file mode 100644 index 96ed190f15887..0000000000000 --- a/apps/meteor/client/hooks/useEndpointAction.ts +++ /dev/null @@ -1,37 +0,0 @@ -import type { Method, PathPattern, UrlParams } from '@rocket.chat/rest-typings'; -import type { EndpointFunction } from '@rocket.chat/ui-contexts'; -import { useToastMessageDispatch, useEndpoint } from '@rocket.chat/ui-contexts'; -import { useMutation } from '@tanstack/react-query'; - -type UseEndpointActionOptions = (undefined extends UrlParams - ? { - keys?: UrlParams; - } - : { - keys: UrlParams; - }) & { - successMessage?: string; -}; -export function useEndpointAction( - method: TMethod, - pathPattern: TPathPattern, - options: NoInfer> = { keys: {} as UrlParams }, -) { - const sendData = useEndpoint(method, pathPattern, options.keys as UrlParams); - - const dispatchToastMessage = useToastMessageDispatch(); - - const mutation = useMutation({ - mutationFn: sendData, - onSuccess: () => { - if (options.successMessage) { - dispatchToastMessage({ type: 'success', message: options.successMessage }); - } - }, - onError: (error) => { - dispatchToastMessage({ type: 'error', message: error }); - }, - }); - - return mutation.mutateAsync as EndpointFunction; -} diff --git a/apps/meteor/client/hooks/useEndpointMutation.ts b/apps/meteor/client/hooks/useEndpointMutation.ts new file mode 100644 index 0000000000000..3263b10dc232a --- /dev/null +++ b/apps/meteor/client/hooks/useEndpointMutation.ts @@ -0,0 +1,43 @@ +import type { Serialized } from '@rocket.chat/core-typings'; +import type { Method, OperationParams, OperationResult, PathPattern, UrlParams } from '@rocket.chat/rest-typings'; +import { useToastMessageDispatch, useEndpoint } from '@rocket.chat/ui-contexts'; +import type { UseMutationOptions, UseMutationResult } from '@tanstack/react-query'; +import { useMutation } from '@tanstack/react-query'; + +type UseEndpointActionOptions = (undefined extends UrlParams + ? { + keys?: UrlParams; + } + : { + keys: UrlParams; + }) & + Omit< + UseMutationOptions< + Serialized>, + Error, + undefined extends OperationParams ? void : OperationParams + >, + 'mutationFn' + >; + +export function useEndpointMutation( + method: TMethod, + pathPattern: TPathPattern, + { keys, ...options }: NoInfer> = { keys: {} as UrlParams }, +) { + const sendData = useEndpoint(method, pathPattern, keys as UrlParams); + + const dispatchToastMessage = useToastMessageDispatch(); + + return useMutation({ + mutationFn: sendData, + onError: (error) => { + dispatchToastMessage({ type: 'error', message: error }); + }, + ...options, + }) as UseMutationResult< + Serialized>, + Error, + undefined extends OperationParams ? void : OperationParams + >; +} diff --git a/apps/meteor/client/hooks/useEndpointUpload.ts b/apps/meteor/client/hooks/useEndpointUpload.ts deleted file mode 100644 index aafbbc1709a62..0000000000000 --- a/apps/meteor/client/hooks/useEndpointUpload.ts +++ /dev/null @@ -1,35 +0,0 @@ -import type { UploadResult } from '@rocket.chat/ui-contexts'; -import { useToastMessageDispatch, useUpload } from '@rocket.chat/ui-contexts'; -import { useCallback } from 'react'; - -export const useEndpointUpload = ( - endpoint: Parameters[0], - successMessage: string, -): ((formData: FormData) => Promise<{ success: boolean }>) => { - const sendData = useUpload(endpoint); - const dispatchToastMessage = useToastMessageDispatch(); - - return useCallback( - async (formData: FormData) => { - try { - const data = sendData(formData); - - const promise = data instanceof Promise ? data : data.promise; - - const result = await (promise as unknown as Promise); - - if (!result.success) { - throw new Error(String(result.status)); - } - - successMessage && dispatchToastMessage({ type: 'success', message: successMessage }); - - return result as any; - } catch (error) { - dispatchToastMessage({ type: 'error', message: error }); - return { success: false }; - } - }, - [dispatchToastMessage, sendData, successMessage], - ); -}; diff --git a/apps/meteor/client/hooks/useEndpointUploadMutation.ts b/apps/meteor/client/hooks/useEndpointUploadMutation.ts new file mode 100644 index 0000000000000..82b6a5f91a5c8 --- /dev/null +++ b/apps/meteor/client/hooks/useEndpointUploadMutation.ts @@ -0,0 +1,26 @@ +import type { PathFor, PathPattern } from '@rocket.chat/rest-typings'; +import { useToastMessageDispatch, useUpload } from '@rocket.chat/ui-contexts'; +import { useMutation, type UseMutationOptions } from '@tanstack/react-query'; + +type UseEndpointUploadOptions = Omit, 'mutationFn'>; + +export const useEndpointUploadMutation = (endpoint: TPathPattern, options?: UseEndpointUploadOptions) => { + const sendData = useUpload(endpoint as PathFor<'POST'>); + const dispatchToastMessage = useToastMessageDispatch(); + + return useMutation({ + mutationFn: async (formData: FormData) => { + const data = sendData(formData); + const promise = data instanceof Promise ? data : data.promise; + const result = await promise; + + if (!result.success) { + throw new Error(String(result.status)); + } + }, + onError: (error) => { + dispatchToastMessage({ type: 'error', message: error }); + }, + ...options, + }); +}; diff --git a/apps/meteor/client/hooks/useIdleConnection.ts b/apps/meteor/client/hooks/useIdleConnection.ts index 1d22fbc9f803c..d4d5c8d9a7609 100644 --- a/apps/meteor/client/hooks/useIdleConnection.ts +++ b/apps/meteor/client/hooks/useIdleConnection.ts @@ -1,13 +1,12 @@ import { useEffectEvent } from '@rocket.chat/fuselage-hooks'; -import { ServerContext, useConnectionStatus, useSetting } from '@rocket.chat/ui-contexts'; -import { useContext } from 'react'; +import { useConnectionStatus, useSetting } from '@rocket.chat/ui-contexts'; import { useIdleActiveEvents } from './useIdleActiveEvents'; export const useIdleConnection = (uid: string | null) => { const { status } = useConnectionStatus(); const allowAnonymousRead = useSetting('Accounts_AllowAnonymousRead'); - const { disconnect: disconnectServer, reconnect: reconnectServer } = useContext(ServerContext); + const { disconnect: disconnectServer, reconnect: reconnectServer } = useConnectionStatus(); const disconnect = useEffectEvent(() => { if (status !== 'offline') { diff --git a/apps/meteor/client/hooks/useLivechatInquiryStore.ts b/apps/meteor/client/hooks/useLivechatInquiryStore.ts index ba33752c3bbbf..8641e990aac99 100644 --- a/apps/meteor/client/hooks/useLivechatInquiryStore.ts +++ b/apps/meteor/client/hooks/useLivechatInquiryStore.ts @@ -1,10 +1,12 @@ import type { ILivechatInquiryRecord, IRoom } from '@rocket.chat/core-typings'; import { create } from 'zustand'; +export type LivechatInquiryLocalRecord = ILivechatInquiryRecord & { alert?: boolean }; + export const useLivechatInquiryStore = create<{ - records: (ILivechatInquiryRecord & { alert?: boolean })[]; - add: (record: ILivechatInquiryRecord & { alert?: boolean }) => void; - merge: (record: ILivechatInquiryRecord & { alert?: boolean }) => void; + records: LivechatInquiryLocalRecord[]; + add: (record: LivechatInquiryLocalRecord) => void; + merge: (record: LivechatInquiryLocalRecord) => void; discard: (id: ILivechatInquiryRecord['_id']) => void; discardForRoom: (rid: IRoom['_id']) => void; discardAll: () => void; diff --git a/apps/meteor/client/hooks/useLoadRoomForAllowedAnonymousRead.ts b/apps/meteor/client/hooks/useLoadRoomForAllowedAnonymousRead.ts index 3e3b7e520b9c2..444721517775a 100644 --- a/apps/meteor/client/hooks/useLoadRoomForAllowedAnonymousRead.ts +++ b/apps/meteor/client/hooks/useLoadRoomForAllowedAnonymousRead.ts @@ -1,7 +1,7 @@ import { useSetting, useUserId } from '@rocket.chat/ui-contexts'; import { useEffect } from 'react'; -import { CachedChatRoom, CachedChatSubscription } from '../../app/models/client'; +import { RoomsCachedStore, SubscriptionsCachedStore } from '../cachedStores'; export const useLoadRoomForAllowedAnonymousRead = () => { const userId = useUserId(); @@ -9,11 +9,11 @@ export const useLoadRoomForAllowedAnonymousRead = () => { useEffect(() => { if (!userId && accountsAllowAnonymousRead === true) { - CachedChatRoom.init(); - CachedChatSubscription.ready.set(true); + RoomsCachedStore.init(); + SubscriptionsCachedStore.setReady(true); return () => { - CachedChatRoom.ready.set(false); - CachedChatSubscription.ready.set(false); + RoomsCachedStore.setReady(false); + SubscriptionsCachedStore.setReady(false); }; } }, [accountsAllowAnonymousRead, userId]); diff --git a/apps/meteor/client/hooks/useReactiveVar.ts b/apps/meteor/client/hooks/useReactiveVar.ts deleted file mode 100644 index d6853b779420c..0000000000000 --- a/apps/meteor/client/hooks/useReactiveVar.ts +++ /dev/null @@ -1,7 +0,0 @@ -import type { ReactiveVar } from 'meteor/reactive-var'; -import { useCallback } from 'react'; - -import { useReactiveValue } from './useReactiveValue'; - -/** @deprecated */ -export const useReactiveVar = (variable: ReactiveVar): T => useReactiveValue(useCallback(() => variable.get(), [variable])); diff --git a/apps/meteor/client/hooks/useRoomInfoEndpoint.ts b/apps/meteor/client/hooks/useRoomInfoEndpoint.ts index 6857e2590a069..294dc336fb463 100644 --- a/apps/meteor/client/hooks/useRoomInfoEndpoint.ts +++ b/apps/meteor/client/hooks/useRoomInfoEndpoint.ts @@ -9,14 +9,14 @@ import { roomsQueryKeys } from '../lib/queryKeys'; type UseRoomInfoEndpointOptions< TData = Serialized<{ room: IRoom | undefined; - parent?: Pick; + parent?: Pick; team?: Pick; }>, > = Omit< UseQueryOptions< Serialized<{ room: IRoom | undefined; - parent?: Pick; + parent?: Pick; team?: Pick; }>, { success: boolean; error: string }, @@ -29,7 +29,7 @@ type UseRoomInfoEndpointOptions< export const useRoomInfoEndpoint = < TData = Serialized<{ room: IRoom | undefined; - parent?: Pick; + parent?: Pick; team?: Pick; }>, >( diff --git a/apps/meteor/client/hooks/useRoomMenuActions.ts b/apps/meteor/client/hooks/useRoomMenuActions.ts index 46308772b4295..86c2727facea5 100644 --- a/apps/meteor/client/hooks/useRoomMenuActions.ts +++ b/apps/meteor/client/hooks/useRoomMenuActions.ts @@ -1,7 +1,6 @@ import type { RoomType } from '@rocket.chat/core-typings'; import type { GenericMenuItemProps } from '@rocket.chat/ui-client'; import { usePermission, useSetting, useUserSubscription } from '@rocket.chat/ui-contexts'; -import type { Fields } from '@rocket.chat/ui-contexts'; import { useMemo } from 'react'; import { useTranslation } from 'react-i18next'; @@ -11,12 +10,6 @@ import { useToggleReadAction } from './menuActions/useToggleReadAction'; import { useHideRoomAction } from './useHideRoomAction'; import { useOmnichannelPrioritiesMenu } from '../omnichannel/hooks/useOmnichannelPrioritiesMenu'; -const fields: Fields = { - f: true, - t: true, - name: true, -}; - type RoomMenuActionsProps = { rid: string; type: RoomType; @@ -37,7 +30,7 @@ export const useRoomMenuActions = ({ hideDefaultOptions, }: RoomMenuActionsProps): { title: string; items: GenericMenuItemProps[] }[] => { const { t } = useTranslation(); - const subscription = useUserSubscription(rid, fields); + const subscription = useUserSubscription(rid); const isFavorite = Boolean(subscription?.f); const canLeaveChannel = usePermission('leave-c'); diff --git a/apps/meteor/client/hooks/useRoomRolesQuery.ts b/apps/meteor/client/hooks/useRoomRolesQuery.ts index ff0980e71ae66..cdc012b1b7832 100644 --- a/apps/meteor/client/hooks/useRoomRolesQuery.ts +++ b/apps/meteor/client/hooks/useRoomRolesQuery.ts @@ -1,5 +1,5 @@ import type { IUser, IRole, IRoom } from '@rocket.chat/core-typings'; -import { useMethod, useStream, useUserId } from '@rocket.chat/ui-contexts'; +import { useEndpoint, useStream, useUserId } from '@rocket.chat/ui-contexts'; import { useQuery, useQueryClient, type UseQueryOptions } from '@tanstack/react-query'; import { useEffect } from 'react'; @@ -100,14 +100,16 @@ export const useRoomRolesQuery = (rid: IRoom['_id'], option }); }, [enabled, queryClient, rid, subscribeToNotifyLogged]); - const getRoomRoles = useMethod('getRoomRoles'); + const getRoomRoles = useEndpoint('GET', '/v1/rooms.roles'); return useQuery({ queryKey: roomsQueryKeys.roles(rid), queryFn: async () => { - const results = await getRoomRoles(rid); + const { roles } = await getRoomRoles({ + rid, + }); - return results.map( + return roles.map( (record): RoomRoles => ({ rid: record.rid, u: record.u, diff --git a/apps/meteor/client/hooks/useSidePanelNavigation.ts b/apps/meteor/client/hooks/useSidePanelNavigation.ts deleted file mode 100644 index f9714580cf064..0000000000000 --- a/apps/meteor/client/hooks/useSidePanelNavigation.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { useBreakpoints } from '@rocket.chat/fuselage-hooks'; -import { useFeaturePreview } from '@rocket.chat/ui-client'; - -export const useSidePanelNavigation = () => { - const isSidepanelFeatureEnabled = useFeaturePreview('sidepanelNavigation'); - // ["xs", "sm", "md", "lg", "xl", xxl"] - return useSidePanelNavigationScreenSize() && isSidepanelFeatureEnabled; -}; - -export const useSidePanelNavigationScreenSize = () => { - const breakpoints = useBreakpoints(); - // ["xs", "sm", "md", "lg", "xl", xxl"] - return breakpoints.includes('lg'); -}; diff --git a/apps/meteor/client/hooks/useSortQueryOptions.spec.tsx b/apps/meteor/client/hooks/useSortQueryOptions.spec.ts similarity index 100% rename from apps/meteor/client/hooks/useSortQueryOptions.spec.tsx rename to apps/meteor/client/hooks/useSortQueryOptions.spec.ts diff --git a/apps/meteor/client/hooks/useTeamInfoQuery.ts b/apps/meteor/client/hooks/useTeamInfoQuery.ts new file mode 100644 index 0000000000000..4044a65a60d39 --- /dev/null +++ b/apps/meteor/client/hooks/useTeamInfoQuery.ts @@ -0,0 +1,26 @@ +import type { ITeam, Serialized } from '@rocket.chat/core-typings'; +import { useEndpoint } from '@rocket.chat/ui-contexts'; +import type { UseQueryOptions } from '@tanstack/react-query'; +import { keepPreviousData, useQuery } from '@tanstack/react-query'; + +import { teamsQueryKeys } from '../lib/queryKeys'; + +type TeamInfoQueryOptions>> = Omit< + UseQueryOptions>, Error, TData, ReturnType>, + 'queryKey' | 'queryFn' +>; + +export const useTeamInfoQuery = >>(teamId: string, options: TeamInfoQueryOptions = {}) => { + const teamsInfoEndpoint = useEndpoint('GET', '/v1/teams.info'); + + return useQuery({ + queryKey: teamsQueryKeys.teamInfo(teamId), + queryFn: async () => { + const result = await teamsInfoEndpoint({ teamId }); + return result.teamInfo; + }, + placeholderData: keepPreviousData, + enabled: teamId !== '', + ...options, + }); +}; diff --git a/apps/meteor/client/hooks/useUpdateAvatar.ts b/apps/meteor/client/hooks/useUpdateAvatar.ts index ac24f6d3b57d3..d8a122683215a 100644 --- a/apps/meteor/client/hooks/useUpdateAvatar.ts +++ b/apps/meteor/client/hooks/useUpdateAvatar.ts @@ -3,8 +3,8 @@ import { useToastMessageDispatch, useMethod } from '@rocket.chat/ui-contexts'; import { useCallback } from 'react'; import { useTranslation } from 'react-i18next'; -import { useEndpointAction } from './useEndpointAction'; -import { useEndpointUpload } from './useEndpointUpload'; +import { useEndpointMutation } from './useEndpointMutation'; +import { useEndpointUploadMutation } from './useEndpointUploadMutation'; const isAvatarReset = (avatarObj: AvatarObject): avatarObj is AvatarReset => avatarObj === 'reset'; const isServiceObject = (avatarObj: AvatarObject): avatarObj is AvatarServiceObject => @@ -12,10 +12,7 @@ const isServiceObject = (avatarObj: AvatarObject): avatarObj is AvatarServiceObj const isAvatarUrl = (avatarObj: AvatarObject): avatarObj is AvatarUrlObj => !isAvatarReset(avatarObj) && typeof avatarObj === 'object' && 'service' && 'avatarUrl' in avatarObj; -export const useUpdateAvatar = ( - avatarObj: AvatarObject, - userId: IUser['_id'], -): (() => Promise<{ success: boolean } | null | undefined>) => { +export const useUpdateAvatar = (avatarObj: AvatarObject, userId: IUser['_id']) => { const { t } = useTranslation(); const avatarUrl = isAvatarUrl(avatarObj) ? avatarObj.avatarUrl : ''; @@ -24,22 +21,38 @@ export const useUpdateAvatar = ( const dispatchToastMessage = useToastMessageDispatch(); - const saveAvatarAction = useEndpointUpload('/v1/users.setAvatar', successMessage); - const saveAvatarUrlAction = useEndpointAction('POST', '/v1/users.setAvatar', { successMessage }); - const resetAvatarAction = useEndpointAction('POST', '/v1/users.resetAvatar', { successMessage }); + const { mutateAsync: saveAvatarAction } = useEndpointUploadMutation('/v1/users.setAvatar', { + onSuccess: () => { + dispatchToastMessage({ type: 'success', message: successMessage }); + }, + }); + const { mutateAsync: saveAvatarUrlAction } = useEndpointMutation('POST', '/v1/users.setAvatar', { + onSuccess: () => { + dispatchToastMessage({ type: 'success', message: successMessage }); + }, + }); + const { mutateAsync: resetAvatarAction } = useEndpointMutation('POST', '/v1/users.resetAvatar', { + onSuccess: () => { + dispatchToastMessage({ type: 'success', message: successMessage }); + }, + }); const updateAvatar = useCallback(async () => { if (isAvatarReset(avatarObj)) { - return resetAvatarAction({ + await resetAvatarAction({ userId, }); + return; } + if (isAvatarUrl(avatarObj)) { - return saveAvatarUrlAction({ + await saveAvatarUrlAction({ userId, ...(avatarUrl && { avatarUrl }), }); + return; } + if (isServiceObject(avatarObj)) { const { blob, contentType, service } = avatarObj; try { @@ -52,7 +65,7 @@ export const useUpdateAvatar = ( } if (avatarObj instanceof FormData) { avatarObj.set('userId', userId); - return saveAvatarAction(avatarObj); + await saveAvatarAction(avatarObj); } }, [ avatarObj, diff --git a/apps/meteor/client/hooks/useUserCustomFields.spec.tsx b/apps/meteor/client/hooks/useUserCustomFields.spec.tsx index 4c38adc2f3e8a..c27ec3d769dd9 100644 --- a/apps/meteor/client/hooks/useUserCustomFields.spec.tsx +++ b/apps/meteor/client/hooks/useUserCustomFields.spec.tsx @@ -10,7 +10,6 @@ it('should not break with invalid Accounts_CustomFieldsToShowInUserInfo setting' prop: 'value', }), { - legacyRoot: true, wrapper: mockAppRoot() .withSetting('Accounts_CustomFieldsToShowInUserInfo', '{"Invalid": "Object", "InvalidProperty": "Invalid" }') .build(), diff --git a/apps/meteor/client/hooks/useUserRolesQuery.ts b/apps/meteor/client/hooks/useUserRolesQuery.ts index 854d4afbc029c..d1798459441ca 100644 --- a/apps/meteor/client/hooks/useUserRolesQuery.ts +++ b/apps/meteor/client/hooks/useUserRolesQuery.ts @@ -1,5 +1,5 @@ import type { IRole, IUser } from '@rocket.chat/core-typings'; -import { useMethod, useStream, useUserId } from '@rocket.chat/ui-contexts'; +import { useStream, useUserId, useEndpoint } from '@rocket.chat/ui-contexts'; import type { UseQueryOptions } from '@tanstack/react-query'; import { useQuery, useQueryClient } from '@tanstack/react-query'; import { useEffect } from 'react'; @@ -71,14 +71,14 @@ export const useUserRolesQuery = (options?: UseUserRolesQue }); }, [enabled, queryClient, subscribeToNotifyLogged, uid]); - const getUserRoles = useMethod('getUserRoles'); + const getUserRoles = useEndpoint('GET', '/v1/roles.getUsersInPublicRoles'); return useQuery({ queryKey: rolesQueryKeys.userRoles(), queryFn: async () => { - const results = await getUserRoles(); + const { users } = await getUserRoles(); - return results.map( + return users.map( (record): UserRoles => ({ uid: record._id, roles: record.roles, diff --git a/apps/meteor/client/importPackages.ts b/apps/meteor/client/importPackages.ts index aede0a7479bd8..1677e7019bf6a 100644 --- a/apps/meteor/client/importPackages.ts +++ b/apps/meteor/client/importPackages.ts @@ -1,4 +1,3 @@ -import '../app/apple/client'; import '../app/authorization/client'; import '../app/autotranslate/client'; import '../app/emoji/client'; @@ -7,7 +6,6 @@ import '../app/gitlab/client'; import '../app/license/client'; import '../app/lib/client'; import '../app/livechat-enterprise/client'; -import '../app/nextcloud/client'; import '../app/notifications/client'; import '../app/otr/client'; import '../app/slackbridge/client'; @@ -24,10 +22,8 @@ import '../app/slashcommands-topic/client'; import '../app/slashcommands-unarchiveroom/client'; import '../app/webrtc/client'; import '../app/wordpress/client'; -import '../app/e2e/client'; import '../app/utils/client'; import '../app/settings/client'; -import '../app/models/client'; import '../app/ui-utils/client'; import '../app/reactions/client'; import '../app/livechat/client'; diff --git a/apps/meteor/client/lib/RoomManager.ts b/apps/meteor/client/lib/RoomManager.ts index a7933f50ec595..cbdeb6f0f0843 100644 --- a/apps/meteor/client/lib/RoomManager.ts +++ b/apps/meteor/client/lib/RoomManager.ts @@ -56,8 +56,6 @@ export const RoomManager = new (class RoomManager extends Emitter<{ private rooms: Map = new Map(); - private parentRid?: IRoom['_id'] | undefined; - constructor() { super(); debugRoomManager && @@ -81,13 +79,6 @@ export const RoomManager = new (class RoomManager extends Emitter<{ } get opened(): IRoom['_id'] | undefined { - return this.parentRid ?? this.rid; - } - - get openedSecondLevel(): IRoom['_id'] | undefined { - if (!this.parentRid) { - return undefined; - } return this.rid; } @@ -116,7 +107,7 @@ export const RoomManager = new (class RoomManager extends Emitter<{ this.emit('changed', this.rid); } - private _open(rid: IRoom['_id'], parent?: IRoom['_id']): void { + open(rid: IRoom['_id']): void { if (rid === this.rid) { return; } @@ -125,19 +116,10 @@ export const RoomManager = new (class RoomManager extends Emitter<{ this.rooms.set(rid, new RoomStore(rid)); } this.rid = rid; - this.parentRid = parent; this.emit('opened', this.rid); this.emit('changed', this.rid); } - open(rid: IRoom['_id']): void { - this._open(rid); - } - - openSecondLevel(parentId: IRoom['_id'], rid: IRoom['_id']): void { - this._open(rid, parentId); - } - getStore(rid: IRoom['_id']): RoomStore | undefined { return this.rooms.get(rid); } @@ -148,11 +130,6 @@ const subscribeOpenedRoom = [ (): IRoom['_id'] | undefined => RoomManager.opened, ] as const; -const subscribeOpenedSecondLevelRoom = [ - (callback: () => void): (() => void) => RoomManager.on('changed', callback), - (): IRoom['_id'] | undefined => RoomManager.openedSecondLevel, -] as const; - export const useOpenedRoom = (): IRoom['_id'] | undefined => useSyncExternalStore(...subscribeOpenedRoom); export const useOpenedRoomUnreadSince = (): Date | undefined => { @@ -170,5 +147,3 @@ export const useOpenedRoomUnreadSince = (): Date | undefined => { return useSyncExternalStore(subscribe, getSnapshotValue); }; - -export const useSecondLevelOpenedRoom = (): IRoom['_id'] | undefined => useSyncExternalStore(...subscribeOpenedSecondLevelRoom); diff --git a/apps/meteor/client/lib/cachedCollections/index.ts b/apps/meteor/client/lib/cachedCollections/index.ts deleted file mode 100644 index caafeb022b83a..0000000000000 --- a/apps/meteor/client/lib/cachedCollections/index.ts +++ /dev/null @@ -1,4 +0,0 @@ -export { PrivateCachedCollection, PublicCachedCollection } from './CachedCollection'; -export { CachedCollectionManager } from './CachedCollectionManager'; -export { pipe } from './pipe'; -export { applyQueryOptions, convertSort } from './utils'; diff --git a/apps/meteor/client/lib/cachedCollections/CachedCollection.ts b/apps/meteor/client/lib/cachedStores/CachedStore.ts similarity index 87% rename from apps/meteor/client/lib/cachedCollections/CachedCollection.ts rename to apps/meteor/client/lib/cachedStores/CachedStore.ts index 72a811d7aa60f..f57a14e58e2bd 100644 --- a/apps/meteor/client/lib/cachedCollections/CachedCollection.ts +++ b/apps/meteor/client/lib/cachedStores/CachedStore.ts @@ -3,15 +3,14 @@ import type { StreamNames } from '@rocket.chat/ddp-client'; import localforage from 'localforage'; import { Accounts } from 'meteor/accounts-base'; import { Meteor } from 'meteor/meteor'; -import { ReactiveVar } from 'meteor/reactive-var'; import { Tracker } from 'meteor/tracker'; -import type { StoreApi, UseBoundStore } from 'zustand'; +import { create, type StoreApi, type UseBoundStore } from 'zustand'; import { baseURI } from '../baseURI'; import { onLoggedIn } from '../loggedIn'; -import { CachedCollectionManager } from './CachedCollectionManager'; +import { CachedStoresManager } from './CachedStoresManager'; import type { IDocumentMapStore } from './DocumentMapStore'; -import { MinimongoCollection } from './MinimongoCollection'; +import { watch } from './watch'; import { sdk } from '../../../app/utils/client/lib/SDKClient'; import { isTruthy } from '../../../lib/isTruthy'; import { withDebouncing } from '../../../lib/utils/highOrderFunctions'; @@ -47,8 +46,6 @@ export abstract class CachedStore implements readonly store: UseBoundStore>>; - readonly ready = new ReactiveVar(false); - protected name: Name; protected eventType: StreamNames; @@ -61,6 +58,8 @@ export abstract class CachedStore implements private timer: ReturnType; + readonly useReady = create(() => false); + constructor({ name, eventType, store }: { name: Name; eventType: StreamNames; store: UseBoundStore>> }) { this.name = name; this.eventType = eventType; @@ -70,7 +69,7 @@ export abstract class CachedStore implements ? console.log.bind(console, `%cCachedCollection ${this.name}`, `color: navy; font-weight: bold;`) : () => undefined; - CachedCollectionManager.register(this); + CachedStoresManager.register(this); } protected get eventName(): `${Name}-changed` | `${string}/${Name}-changed` { @@ -311,8 +310,6 @@ export abstract class CachedStore implements await this.loadFromServerAndPopulate(); } - this.ready.set(true); - this.reconnectionComputation?.stop(); let wentOffline = Tracker.nonreactive(() => Meteor.status().status === 'offline'); this.reconnectionComputation = Tracker.autorun(() => { @@ -341,9 +338,12 @@ export abstract class CachedStore implements return this.initializationPromise; } - this.initializationPromise = this.performInitialization().finally(() => { - this.initializationPromise = undefined; - }); + this.initializationPromise = this.performInitialization() + .catch(console.error) + .finally(() => { + this.initializationPromise = undefined; + this.setReady(true); + }); return this.initializationPromise; } @@ -354,63 +354,21 @@ export abstract class CachedStore implements } this.listenerUnsubscriber?.(); - this.ready.set(false); + this.setReady(false); } private reconnectionComputation: Tracker.Computation | undefined; -} - -export class PublicCachedStore extends CachedStore { - protected override getToken() { - return undefined; - } - - override clearCacheOnLogout() { - // do nothing - } -} - -export class PrivateCachedStore extends CachedStore { - protected override getToken() { - return Accounts._storedLoginToken(); - } - - override clearCacheOnLogout() { - void this.clearCache(); - } - - listen() { - if (process.env.NODE_ENV === 'test') { - return; - } - - onLoggedIn(() => { - void this.init(); - }); - Accounts.onLogout(() => { - this.release(); - }); + watchReady() { + return watch(this.useReady, (ready) => ready); } -} - -export abstract class CachedCollection extends CachedStore { - readonly collection; - constructor({ name, eventType }: { name: Name; eventType: StreamNames }) { - const collection = new MinimongoCollection(); - - super({ - name, - eventType, - store: collection.use, - }); - - this.collection = collection; + setReady(ready: boolean) { + this.useReady.setState(ready); } } -export class PublicCachedCollection extends CachedCollection { +export class PublicCachedStore extends CachedStore { protected override getToken() { return undefined; } @@ -420,7 +378,7 @@ export class PublicCachedCollection extends } } -export class PrivateCachedCollection extends CachedCollection { +export class PrivateCachedStore extends CachedStore { protected override getToken() { return Accounts._storedLoginToken(); } diff --git a/apps/meteor/client/lib/cachedCollections/CachedCollectionManager.ts b/apps/meteor/client/lib/cachedStores/CachedStoresManager.ts similarity index 60% rename from apps/meteor/client/lib/cachedCollections/CachedCollectionManager.ts rename to apps/meteor/client/lib/cachedStores/CachedStoresManager.ts index 31ce60f58f7a0..b899dde4b33a6 100644 --- a/apps/meteor/client/lib/cachedCollections/CachedCollectionManager.ts +++ b/apps/meteor/client/lib/cachedStores/CachedStoresManager.ts @@ -1,6 +1,6 @@ -import type { IWithManageableCache } from './CachedCollection'; +import type { IWithManageableCache } from './CachedStore'; -class CachedCollectionManager { +class CachedStoresManager { private items = new Set(); register(cachedCollection: IWithManageableCache) { @@ -14,9 +14,9 @@ class CachedCollectionManager { } } -const instance = new CachedCollectionManager(); +const instance = new CachedStoresManager(); export { /** @deprecated */ - instance as CachedCollectionManager, + instance as CachedStoresManager, }; diff --git a/apps/meteor/client/lib/cachedCollections/Cursor.ts b/apps/meteor/client/lib/cachedStores/Cursor.ts similarity index 100% rename from apps/meteor/client/lib/cachedCollections/Cursor.ts rename to apps/meteor/client/lib/cachedStores/Cursor.ts diff --git a/apps/meteor/client/lib/cachedCollections/DiffSequence.ts b/apps/meteor/client/lib/cachedStores/DiffSequence.ts similarity index 98% rename from apps/meteor/client/lib/cachedCollections/DiffSequence.ts rename to apps/meteor/client/lib/cachedStores/DiffSequence.ts index ec7392c13d01d..7f20a683d9296 100644 --- a/apps/meteor/client/lib/cachedCollections/DiffSequence.ts +++ b/apps/meteor/client/lib/cachedStores/DiffSequence.ts @@ -1,5 +1,6 @@ +import { entriesOf } from '../objectUtils'; import type { IdMap } from './IdMap'; -import { clone, entriesOf, hasOwn, equals } from './common'; +import { clone, hasOwn, equals } from './common'; import type { Observer, OrderedObserver, UnorderedObserver } from './observers'; function isObjEmpty(obj: Record): boolean { diff --git a/apps/meteor/client/lib/cachedCollections/DocumentMapStore.ts b/apps/meteor/client/lib/cachedStores/DocumentMapStore.ts similarity index 100% rename from apps/meteor/client/lib/cachedCollections/DocumentMapStore.ts rename to apps/meteor/client/lib/cachedStores/DocumentMapStore.ts diff --git a/apps/meteor/client/lib/cachedCollections/IdMap.ts b/apps/meteor/client/lib/cachedStores/IdMap.ts similarity index 100% rename from apps/meteor/client/lib/cachedCollections/IdMap.ts rename to apps/meteor/client/lib/cachedStores/IdMap.ts diff --git a/apps/meteor/client/lib/cachedCollections/LocalCollection.spec.ts b/apps/meteor/client/lib/cachedStores/LocalCollection.spec.ts similarity index 100% rename from apps/meteor/client/lib/cachedCollections/LocalCollection.spec.ts rename to apps/meteor/client/lib/cachedStores/LocalCollection.spec.ts diff --git a/apps/meteor/client/lib/cachedCollections/LocalCollection.ts b/apps/meteor/client/lib/cachedStores/LocalCollection.ts similarity index 100% rename from apps/meteor/client/lib/cachedCollections/LocalCollection.ts rename to apps/meteor/client/lib/cachedStores/LocalCollection.ts diff --git a/apps/meteor/client/lib/cachedCollections/MinimongoCollection.ts b/apps/meteor/client/lib/cachedStores/MinimongoCollection.ts similarity index 100% rename from apps/meteor/client/lib/cachedCollections/MinimongoCollection.ts rename to apps/meteor/client/lib/cachedStores/MinimongoCollection.ts diff --git a/apps/meteor/client/lib/cachedCollections/MinimongoError.ts b/apps/meteor/client/lib/cachedStores/MinimongoError.ts similarity index 100% rename from apps/meteor/client/lib/cachedCollections/MinimongoError.ts rename to apps/meteor/client/lib/cachedStores/MinimongoError.ts diff --git a/apps/meteor/client/lib/cachedCollections/ObserveHandle.ts b/apps/meteor/client/lib/cachedStores/ObserveHandle.ts similarity index 100% rename from apps/meteor/client/lib/cachedCollections/ObserveHandle.ts rename to apps/meteor/client/lib/cachedStores/ObserveHandle.ts diff --git a/apps/meteor/client/lib/cachedCollections/OrderedDict.ts b/apps/meteor/client/lib/cachedStores/OrderedDict.ts similarity index 100% rename from apps/meteor/client/lib/cachedCollections/OrderedDict.ts rename to apps/meteor/client/lib/cachedStores/OrderedDict.ts diff --git a/apps/meteor/client/lib/cachedCollections/Query.ts b/apps/meteor/client/lib/cachedStores/Query.ts similarity index 100% rename from apps/meteor/client/lib/cachedCollections/Query.ts rename to apps/meteor/client/lib/cachedStores/Query.ts diff --git a/apps/meteor/client/lib/cachedCollections/SynchronousQueue.ts b/apps/meteor/client/lib/cachedStores/SynchronousQueue.ts similarity index 100% rename from apps/meteor/client/lib/cachedCollections/SynchronousQueue.ts rename to apps/meteor/client/lib/cachedStores/SynchronousQueue.ts diff --git a/apps/meteor/client/lib/cachedCollections/common.ts b/apps/meteor/client/lib/cachedStores/common.ts similarity index 94% rename from apps/meteor/client/lib/cachedCollections/common.ts rename to apps/meteor/client/lib/cachedStores/common.ts index d4b4602894058..ba9d634a178f3 100644 --- a/apps/meteor/client/lib/cachedCollections/common.ts +++ b/apps/meteor/client/lib/cachedStores/common.ts @@ -1,5 +1,7 @@ import { getBSONType } from '@rocket.chat/mongo-adapter'; +import { entriesOf } from '../objectUtils'; + export const hasOwn = Object.prototype.hasOwnProperty; const isBinary = (x: unknown): x is Uint8Array => typeof x === 'object' && x !== null && x instanceof Uint8Array; @@ -111,10 +113,6 @@ export const equals = (a: T, b: T): boolean => { export const isPlainObject = (x: any): x is Record => x && getBSONType(x) === 3; -export function entriesOf>(obj: T): [keyof T, T[keyof T]][] { - return Object.entries(obj) as [keyof T, T[keyof T]][]; -} - const invalidCharMsg = { '$': "start with '$'", '.': "contain '.'", diff --git a/apps/meteor/client/lib/cachedStores/createGlobalStore.ts b/apps/meteor/client/lib/cachedStores/createGlobalStore.ts new file mode 100644 index 0000000000000..b80aa8156f0a5 --- /dev/null +++ b/apps/meteor/client/lib/cachedStores/createGlobalStore.ts @@ -0,0 +1,14 @@ +import type { StoreApi, UseBoundStore } from 'zustand'; + +import type { IDocumentMapStore } from './DocumentMapStore'; + +export const createGlobalStore = (store: UseBoundStore>>, extension?: U) => + Object.assign( + { + use: store, + get state(): IDocumentMapStore { + return this.use.getState(); + }, + } as const, + extension, + ); diff --git a/apps/meteor/client/lib/cachedStores/index.ts b/apps/meteor/client/lib/cachedStores/index.ts new file mode 100644 index 0000000000000..b0320808dda86 --- /dev/null +++ b/apps/meteor/client/lib/cachedStores/index.ts @@ -0,0 +1,8 @@ +export { CachedStoresManager } from './CachedStoresManager'; +export { pipe } from './pipe'; +export { applyQueryOptions } from './utils'; +export { createDocumentMapStore, type IDocumentMapStore } from './DocumentMapStore'; +export { MinimongoCollection } from './MinimongoCollection'; +export { watch } from './watch'; +export { PublicCachedStore, PrivateCachedStore } from './CachedStore'; +export { createGlobalStore } from './createGlobalStore'; diff --git a/apps/meteor/client/lib/cachedCollections/observers.ts b/apps/meteor/client/lib/cachedStores/observers.ts similarity index 100% rename from apps/meteor/client/lib/cachedCollections/observers.ts rename to apps/meteor/client/lib/cachedStores/observers.ts diff --git a/apps/meteor/client/lib/cachedCollections/pipe.spec.ts b/apps/meteor/client/lib/cachedStores/pipe.spec.ts similarity index 100% rename from apps/meteor/client/lib/cachedCollections/pipe.spec.ts rename to apps/meteor/client/lib/cachedStores/pipe.spec.ts diff --git a/apps/meteor/client/lib/cachedCollections/pipe.ts b/apps/meteor/client/lib/cachedStores/pipe.ts similarity index 100% rename from apps/meteor/client/lib/cachedCollections/pipe.ts rename to apps/meteor/client/lib/cachedStores/pipe.ts diff --git a/apps/meteor/client/lib/cachedCollections/utils.ts b/apps/meteor/client/lib/cachedStores/utils.ts similarity index 95% rename from apps/meteor/client/lib/cachedCollections/utils.ts rename to apps/meteor/client/lib/cachedStores/utils.ts index a9dbfc357fcf9..52f0d72e4974a 100644 --- a/apps/meteor/client/lib/cachedCollections/utils.ts +++ b/apps/meteor/client/lib/cachedStores/utils.ts @@ -15,7 +15,7 @@ type SortObject = { /** * Converts a MongoDB-style sort structure to a sort object. */ -export const convertSort = (original: OriginalStructure): SortObject => { +const convertSort = (original: OriginalStructure): SortObject => { const convertedSort: SortObject = []; if (!original) { diff --git a/apps/meteor/client/lib/cachedStores/watch.ts b/apps/meteor/client/lib/cachedStores/watch.ts new file mode 100644 index 0000000000000..0a637fb70867e --- /dev/null +++ b/apps/meteor/client/lib/cachedStores/watch.ts @@ -0,0 +1,24 @@ +import { Tracker } from 'meteor/tracker'; +import type { StoreApi, UseBoundStore } from 'zustand'; + +/** Adds Meteor Tracker reactivity to a Zustand store lookup */ +export const watch = (store: UseBoundStore>, fn: (state: U) => T): T => { + const value = fn(store.getState()); + + const computation = Tracker.currentComputation; + + if (computation) { + const unsubscribe = store.subscribe((state) => { + const newValue = fn(state); + if (newValue !== value) { + computation.invalidate(); + } + }); + + computation.onInvalidate(() => { + unsubscribe(); + }); + } + + return value; +}; diff --git a/apps/meteor/client/lib/chats/data.ts b/apps/meteor/client/lib/chats/data.ts index 85f4366d50c91..357e6e2ca8994 100644 --- a/apps/meteor/client/lib/chats/data.ts +++ b/apps/meteor/client/lib/chats/data.ts @@ -11,10 +11,10 @@ import moment from 'moment'; import type { DataAPI } from './ChatAPI'; import { hasAtLeastOnePermission, hasPermission } from '../../../app/authorization/client'; -import { Messages, Rooms, Subscriptions } from '../../../app/models/client'; import { settings } from '../../../app/settings/client'; import { MessageTypes } from '../../../app/ui-utils/client'; import { sdk } from '../../../app/utils/client/lib/SDKClient'; +import { Messages, Rooms, Subscriptions } from '../../stores'; import { prependReplies } from '../utils/prependReplies'; export const createDataAPI = ({ rid, tmid }: { rid: IRoom['_id']; tmid: IMessage['_id'] | undefined }): DataAPI => { @@ -257,7 +257,7 @@ export const createDataAPI = ({ rid, tmid }: { rid: IRoom['_id']; tmid: IMessage return room; }; - const isSubscribedToRoom = async (): Promise => !!Subscriptions.findOne({ rid }, { reactive: false }); + const isSubscribedToRoom = async (): Promise => !!Subscriptions.state.find((record) => record.rid === rid); const joinRoom = async (): Promise => { await sdk.call('joinRoom', rid); @@ -292,13 +292,13 @@ export const createDataAPI = ({ rid, tmid }: { rid: IRoom['_id']; tmid: IMessage }; const findSubscription = async (): Promise => { - return Subscriptions.findOne({ rid }, { reactive: false }); + return Subscriptions.state.find((record) => record.rid === rid); }; const getSubscription = createStrictGetter(findSubscription, 'Subscription not found'); const findSubscriptionFromMessage = async (message: IMessage): Promise => { - return Subscriptions.findOne({ rid: message.rid }, { reactive: false }); + return Subscriptions.state.find((record) => record.rid === message.rid); }; const getSubscriptionFromMessage = createStrictGetter(findSubscriptionFromMessage, 'Subscription not found'); diff --git a/apps/meteor/client/lib/chats/flows/uploadFiles.ts b/apps/meteor/client/lib/chats/flows/uploadFiles.ts index bbb3ce17cd1bd..549d8e9c7b8a8 100644 --- a/apps/meteor/client/lib/chats/flows/uploadFiles.ts +++ b/apps/meteor/client/lib/chats/flows/uploadFiles.ts @@ -2,11 +2,11 @@ import type { IMessage, FileAttachmentProps, IE2EEMessage, IUpload } from '@rock import { isRoomFederated } from '@rocket.chat/core-typings'; import { imperativeModal } from '@rocket.chat/ui-client'; -import { e2e } from '../../../../app/e2e/client'; import { settings } from '../../../../app/settings/client'; import { fileUploadIsValidContentType } from '../../../../app/utils/client'; import { getFileExtension } from '../../../../lib/utils/getFileExtension'; import FileUploadModal from '../../../views/room/modals/FileUploadModal'; +import { e2e } from '../../e2ee'; import { prependReplies } from '../../utils/prependReplies'; import type { ChatAPI } from '../ChatAPI'; diff --git a/apps/meteor/client/lib/chats/readStateManager.ts b/apps/meteor/client/lib/chats/readStateManager.ts index f3b064e5c352a..80449dcac246f 100644 --- a/apps/meteor/client/lib/chats/readStateManager.ts +++ b/apps/meteor/client/lib/chats/readStateManager.ts @@ -2,11 +2,11 @@ import type { IMessage, IRoom, ISubscription } from '@rocket.chat/core-typings'; import { Emitter } from '@rocket.chat/emitter'; import { Meteor } from 'meteor/meteor'; -import { Messages } from '../../../app/models/client'; import { LegacyRoomManager } from '../../../app/ui-utils/client/lib/LegacyRoomManager'; import { RoomHistoryManager } from '../../../app/ui-utils/client/lib/RoomHistoryManager'; import { sdk } from '../../../app/utils/client/lib/SDKClient'; import { withDebouncing } from '../../../lib/utils/highOrderFunctions'; +import { Messages } from '../../stores'; export class ReadStateManager extends Emitter { private rid: IRoom['_id']; diff --git a/apps/meteor/client/lib/constants.ts b/apps/meteor/client/lib/constants.ts index 89052eea93acb..d144e03fec2ef 100644 --- a/apps/meteor/client/lib/constants.ts +++ b/apps/meteor/client/lib/constants.ts @@ -1,3 +1,4 @@ export const USER_STATUS_TEXT_MAX_LENGTH = 120; export const BIO_TEXT_MAX_LENGTH = 260; export const VIDEOCONF_STACK_MAX_USERS = 6; +export const NAVIGATION_REGION_ID = 'navigation-region'; diff --git a/apps/meteor/app/custom-oauth/client/CustomOAuth.ts b/apps/meteor/client/lib/customOAuth/CustomOAuth.ts similarity index 65% rename from apps/meteor/app/custom-oauth/client/CustomOAuth.ts rename to apps/meteor/client/lib/customOAuth/CustomOAuth.ts index 36b02abb5ca50..6142115facdf2 100644 --- a/apps/meteor/app/custom-oauth/client/CustomOAuth.ts +++ b/apps/meteor/client/lib/customOAuth/CustomOAuth.ts @@ -2,25 +2,19 @@ import type { OAuthConfiguration, OauthConfig } from '@rocket.chat/core-typings' import { Random } from '@rocket.chat/random'; import { capitalize } from '@rocket.chat/string-helpers'; import { Accounts } from 'meteor/accounts-base'; -import { Match } from 'meteor/check'; import { Meteor } from 'meteor/meteor'; import { OAuth } from 'meteor/oauth'; -import type { IOAuthProvider } from '../../../client/definitions/IOAuthProvider'; -import { overrideLoginMethod, type LoginCallback } from '../../../client/lib/2fa/overrideLoginMethod'; -import { loginServices } from '../../../client/lib/loginServices'; -import { createOAuthTotpLoginMethod } from '../../../client/meteorOverrides/login/oauth'; import { isURL } from '../../../lib/utils/isURL'; - -// Request custom OAuth credentials for the user -// @param options {optional} -// @param credentialRequestCompleteCallback {Function} Callback function to call on -// completion. Takes one argument, credentialToken on success, or Error on -// error. +import type { IOAuthProvider } from '../../definitions/IOAuthProvider'; +import { createOAuthTotpLoginMethod } from '../../meteorOverrides/login/oauth'; +import { overrideLoginMethod, type LoginCallback } from '../2fa/overrideLoginMethod'; +import { loginServices } from '../loginServices'; +import { CustomOAuthError } from './CustomOAuthError'; const configuredOAuthServices = new Map(); -export class CustomOAuth implements IOAuthProvider { +export class CustomOAuth implements IOAuthProvider { public serverURL: string; public authorizePath: string; @@ -30,14 +24,9 @@ export class CustomOAuth implements IOAuthProvider { public responseType: string; constructor( - public readonly name: string, - options: OauthConfig, + public readonly name: TServiceName, + options: Readonly, ) { - this.name = name; - if (!Match.test(this.name, String)) { - throw new Meteor.Error('CustomOAuth: Name is required and must be String'); - } - this.configure(options); Accounts.oauth.registerService(this.name); @@ -45,26 +34,18 @@ export class CustomOAuth implements IOAuthProvider { this.configureLogin(); } - configure(options: OauthConfig) { - if (!Match.test(options, Object)) { - throw new Meteor.Error('CustomOAuth: Options is required and must be Object'); - } - - if (!Match.test(options.serverURL, String)) { - throw new Meteor.Error('CustomOAuth: Options.serverURL is required and must be String'); - } - - if (!Match.test(options.authorizePath, String)) { - options.authorizePath = '/oauth/authorize'; + configure(options: Readonly) { + if (typeof options !== 'object' || !options) { + throw new CustomOAuthError('options is required and must be object'); } - if (!Match.test(options.scope, String)) { - options.scope = 'openid'; + if (typeof options.serverURL !== 'string') { + throw new CustomOAuthError('options.serverURL is required and must be string'); } this.serverURL = options.serverURL; - this.authorizePath = options.authorizePath; - this.scope = options.scope; + this.authorizePath = options.authorizePath ?? '/oauth/authorize'; + this.scope = options.scope ?? 'openid'; this.responseType = options.responseType || 'code'; if (!isURL(this.authorizePath)) { @@ -73,7 +54,7 @@ export class CustomOAuth implements IOAuthProvider { } configureLogin() { - const loginWithService = `loginWith${capitalize(String(this.name || ''))}` as const; + const loginWithService = `loginWith${capitalize(this.name) as Capitalize}` as const; const loginWithOAuthTokenAndTOTP = createOAuthTotpLoginMethod(this); @@ -125,17 +106,20 @@ export class CustomOAuth implements IOAuthProvider { }); } - static configureOAuthService(serviceName: string, options: OauthConfig): CustomOAuth { + static configureOAuthService( + serviceName: TServiceName, + options: Readonly, + ): CustomOAuth { const existingInstance = configuredOAuthServices.get(serviceName); if (existingInstance) { existingInstance.configure(options); - return existingInstance; + return existingInstance as CustomOAuth; } // If we don't have a reference to the instance for this service and it was already registered on meteor, // then there's nothing we can do to update it if (Accounts.oauth.serviceNames().includes(serviceName)) { - throw new Error(`CustomOAuth service [${serviceName}] already registered, skipping new configuration.`); + throw new CustomOAuthError('service already registered, skipping new configuration', { service: serviceName }); } const instance = new CustomOAuth(serviceName, options); @@ -143,7 +127,10 @@ export class CustomOAuth implements IOAuthProvider { return instance; } - static configureCustomOAuthService(serviceName: string, options: OauthConfig): CustomOAuth | undefined { + static configureCustomOAuthService( + serviceName: TServiceName, + options: Readonly, + ): CustomOAuth | undefined { // Custom OAuth services are configured based on the login service list, so if this ends up being called multiple times, simply ignore it // Non-Custom OAuth services are configured based on code, so if configureOAuthService is called multiple times for them, it's a bug and it should throw. try { diff --git a/apps/meteor/client/lib/customOAuth/CustomOAuthError.ts b/apps/meteor/client/lib/customOAuth/CustomOAuthError.ts new file mode 100644 index 0000000000000..cb8966d10b9ea --- /dev/null +++ b/apps/meteor/client/lib/customOAuth/CustomOAuthError.ts @@ -0,0 +1,11 @@ +import { RocketChatError } from '../errors/RocketChatError'; + +type CustomOAuthErrorDetails = { + service?: string; +}; + +export class CustomOAuthError extends RocketChatError<'custom-oauth-error', CustomOAuthErrorDetails> { + constructor(reason?: string, details?: CustomOAuthErrorDetails) { + super('custom-oauth-error', details?.service ? `${details.service}: ${reason}` : reason, details); + } +} diff --git a/apps/meteor/app/e2e/client/E2EEState.ts b/apps/meteor/client/lib/e2ee/E2EEState.ts similarity index 100% rename from apps/meteor/app/e2e/client/E2EEState.ts rename to apps/meteor/client/lib/e2ee/E2EEState.ts diff --git a/apps/meteor/app/e2e/client/E2ERoomState.ts b/apps/meteor/client/lib/e2ee/E2ERoomState.ts similarity index 100% rename from apps/meteor/app/e2e/client/E2ERoomState.ts rename to apps/meteor/client/lib/e2ee/E2ERoomState.ts diff --git a/apps/meteor/app/e2e/client/helper.ts b/apps/meteor/client/lib/e2ee/helper.ts similarity index 96% rename from apps/meteor/app/e2e/client/helper.ts rename to apps/meteor/client/lib/e2ee/helper.ts index 3a57cd789ca69..ff4f87e50dc80 100644 --- a/apps/meteor/app/e2e/client/helper.ts +++ b/apps/meteor/client/lib/e2ee/helper.ts @@ -45,7 +45,7 @@ export async function encryptRSA(key: any, data: any) { return crypto.subtle.encrypt({ name: 'RSA-OAEP' }, key, data); } -export async function encryptAES(vector: Uint8Array, key: CryptoKey, data: Uint8Array) { +export async function encryptAES(vector: Uint8Array, key: CryptoKey, data: Uint8Array) { return crypto.subtle.encrypt({ name: 'AES-CBC', iv: vector }, key, data); } @@ -57,7 +57,7 @@ export async function decryptRSA(key: CryptoKey, data: Uint8Array) return crypto.subtle.decrypt({ name: 'RSA-OAEP' }, key, data); } -export async function decryptAES(vector: Uint8Array, key: CryptoKey, data: Uint8Array) { +export async function decryptAES(vector: Uint8Array, key: CryptoKey, data: Uint8Array) { return crypto.subtle.decrypt({ name: 'AES-CBC', iv: vector }, key, data); } diff --git a/apps/meteor/app/e2e/client/index.ts b/apps/meteor/client/lib/e2ee/index.ts similarity index 100% rename from apps/meteor/app/e2e/client/index.ts rename to apps/meteor/client/lib/e2ee/index.ts diff --git a/apps/meteor/app/e2e/client/logger.ts b/apps/meteor/client/lib/e2ee/logger.ts similarity index 88% rename from apps/meteor/app/e2e/client/logger.ts rename to apps/meteor/client/lib/e2ee/logger.ts index 812e6867e6849..5ef4e4b02bbc7 100644 --- a/apps/meteor/app/e2e/client/logger.ts +++ b/apps/meteor/client/lib/e2ee/logger.ts @@ -1,4 +1,4 @@ -import { getConfig } from '../../../client/lib/utils/getConfig'; +import { getConfig } from '../utils/getConfig'; let debug: boolean | undefined = undefined; diff --git a/apps/meteor/app/e2e/client/rocketchat.e2e.room.ts b/apps/meteor/client/lib/e2ee/rocketchat.e2e.room.ts similarity index 95% rename from apps/meteor/app/e2e/client/rocketchat.e2e.room.ts rename to apps/meteor/client/lib/e2ee/rocketchat.e2e.room.ts index 42ea61f67bc55..69732d0cb859f 100644 --- a/apps/meteor/app/e2e/client/rocketchat.e2e.room.ts +++ b/apps/meteor/client/lib/e2ee/rocketchat.e2e.room.ts @@ -26,12 +26,12 @@ import { } from './helper'; import { log, logError } from './logger'; import { e2e } from './rocketchat.e2e'; -import { RoomManager } from '../../../client/lib/RoomManager'; -import { roomCoordinator } from '../../../client/lib/rooms/roomCoordinator'; +import { sdk } from '../../../app/utils/client/lib/SDKClient'; +import { t } from '../../../app/utils/lib/i18n'; import { RoomSettingsEnum } from '../../../definition/IRoomTypeConfig'; -import { Rooms, Subscriptions, Messages } from '../../models/client'; -import { sdk } from '../../utils/client/lib/SDKClient'; -import { t } from '../../utils/lib/i18n'; +import { Messages, Rooms, Subscriptions } from '../../stores'; +import { RoomManager } from '../RoomManager'; +import { roomCoordinator } from '../rooms/roomCoordinator'; const KEY_ID = Symbol('keyID'); const PAUSED = Symbol('PAUSED'); @@ -222,7 +222,7 @@ export class E2ERoom extends Emitter { } async decryptSubscription() { - const subscription = Subscriptions.findOne({ rid: this.roomId }); + const subscription = Subscriptions.state.find((record) => record.rid === this.roomId); if (subscription?.lastMessage?.t !== 'e2e') { this.log('decryptSubscriptions nothing to do'); @@ -231,21 +231,19 @@ export class E2ERoom extends Emitter { const message = await this.decryptMessage(subscription.lastMessage); - Subscriptions.update( - { - _id: subscription._id, - }, - { - $set: { - lastMessage: message, - }, - }, - ); + if (message !== subscription.lastMessage) { + this.log('decryptSubscriptions updating lastMessage'); + Subscriptions.state.store({ + ...subscription, + lastMessage: message, + }); + } + this.log('decryptSubscriptions Done'); } async decryptOldRoomKeys() { - const sub = Subscriptions.findOne({ rid: this.roomId }); + const sub = Subscriptions.state.find((record) => record.rid === this.roomId); if (!sub?.oldRoomKeys || sub?.oldRoomKeys.length === 0) { this.log('decryptOldRoomKeys nothing to do'); @@ -322,7 +320,7 @@ export class E2ERoom extends Emitter { this.setState(E2ERoomState.ESTABLISHING); try { - const groupKey = Subscriptions.findOne({ rid: this.roomId })?.E2EKey; + const groupKey = Subscriptions.state.find((record) => record.rid === this.roomId)?.E2EKey; if (groupKey) { await this.importGroupKey(groupKey); this.setState(E2ERoomState.READY); @@ -475,7 +473,7 @@ export class E2ERoom extends Emitter { async encryptKeyForOtherParticipants() { // Encrypt generated session key for every user in room and publish to subscription model. try { - const mySub = Subscriptions.findOne({ rid: this.roomId }); + const mySub = Subscriptions.state.find((record) => record.rid === this.roomId); const decryptedOldGroupKeys = await this.exportOldRoomKeys(mySub?.oldRoomKeys); const users = (await sdk.call('e2e.getUsersOfRoomWithoutKey', this.roomId)).users.filter((user) => user?.e2e?.public_key); @@ -605,7 +603,7 @@ export class E2ERoom extends Emitter { } // Encrypts messages - async encryptText(data: Uint8Array) { + async encryptText(data: Uint8Array) { const vector = crypto.getRandomValues(new Uint8Array(16)); try { @@ -703,7 +701,7 @@ export class E2ERoom extends Emitter { }; } - async doDecrypt(vector: Uint8Array, key: CryptoKey, cipherText: Uint8Array) { + async doDecrypt(vector: Uint8Array, key: CryptoKey, cipherText: Uint8Array) { const result = await decryptAES(vector, key, cipherText); return EJSON.parse(new TextDecoder('UTF-8').decode(new Uint8Array(result))); } @@ -769,7 +767,7 @@ export class E2ERoom extends Emitter { return; } - const mySub = Subscriptions.findOne({ rid: this.roomId }); + const mySub = Subscriptions.state.find((record) => record.rid === this.roomId); const decryptedOldGroupKeys = await this.exportOldRoomKeys(mySub?.oldRoomKeys); const usersWithKeys = await Promise.all( users.map(async (user) => { diff --git a/apps/meteor/app/e2e/client/rocketchat.e2e.ts b/apps/meteor/client/lib/e2ee/rocketchat.e2e.ts similarity index 92% rename from apps/meteor/app/e2e/client/rocketchat.e2e.ts rename to apps/meteor/client/lib/e2ee/rocketchat.e2e.ts index 102d8af58748c..ad57d69bc838b 100644 --- a/apps/meteor/app/e2e/client/rocketchat.e2e.ts +++ b/apps/meteor/client/lib/e2ee/rocketchat.e2e.ts @@ -9,7 +9,6 @@ import EJSON from 'ejson'; import _ from 'lodash'; import { Accounts } from 'meteor/accounts-base'; import { Meteor } from 'meteor/meteor'; -import { Tracker } from 'meteor/tracker'; import { E2EEState } from './E2EEState'; import { @@ -28,23 +27,21 @@ import { } from './helper'; import { log, logError } from './logger'; import { E2ERoom } from './rocketchat.e2e.room'; -import * as banners from '../../../client/lib/banners'; -import type { LegacyBannerPayload } from '../../../client/lib/banners'; -import { dispatchToastMessage } from '../../../client/lib/toast'; -import { mapMessageFromApi } from '../../../client/lib/utils/mapMessageFromApi'; -import EnterE2EPasswordModal from '../../../client/views/e2e/EnterE2EPasswordModal'; -import SaveE2EPasswordModal from '../../../client/views/e2e/SaveE2EPasswordModal'; +import { settings } from '../../../app/settings/client'; +import { limitQuoteChain } from '../../../app/ui-message/client/messageBox/limitQuoteChain'; +import { getUserAvatarURL } from '../../../app/utils/client'; +import { sdk } from '../../../app/utils/client/lib/SDKClient'; +import { t } from '../../../app/utils/lib/i18n'; import { createQuoteAttachment } from '../../../lib/createQuoteAttachment'; import { getMessageUrlRegex } from '../../../lib/getMessageUrlRegex'; import { isTruthy } from '../../../lib/isTruthy'; -import { Rooms, Subscriptions, Messages } from '../../models/client'; -import { settings } from '../../settings/client'; -import { limitQuoteChain } from '../../ui-message/client/messageBox/limitQuoteChain'; -import { getUserAvatarURL } from '../../utils/client'; -import { sdk } from '../../utils/client/lib/SDKClient'; -import { t } from '../../utils/lib/i18n'; - -import './events'; +import { Messages, Rooms, Subscriptions } from '../../stores'; +import EnterE2EPasswordModal from '../../views/e2e/EnterE2EPasswordModal'; +import SaveE2EPasswordModal from '../../views/e2e/SaveE2EPasswordModal'; +import * as banners from '../banners'; +import type { LegacyBannerPayload } from '../banners'; +import { dispatchToastMessage } from '../toast'; +import { mapMessageFromApi } from '../utils/mapMessageFromApi'; let failedToDecodeKey = false; @@ -54,7 +51,6 @@ type KeyPair = { }; const ROOM_KEY_EXCHANGE_SIZE = 10; -const E2EEStateDependency = new Tracker.Dependency(); class E2E extends Emitter { private started: boolean; @@ -73,14 +69,11 @@ class E2E extends Emitter { private state: E2EEState; - private observable: Meteor.LiveQueryHandle | undefined; - constructor() { super(); this.started = false; this.instancesByRoomId = {}; this.keyDistributionInterval = null; - this.observable = undefined; this.on('E2E_STATE_CHANGED', ({ prevState, nextState }) => { this.log(`${prevState} -> ${nextState}`); @@ -95,15 +88,15 @@ class E2E extends Emitter { }); this.on(E2EEState.DISABLED, () => { - this.observable?.stop(); + this.unsubscribeFromSubscriptions?.(); }); this.on(E2EEState.NOT_STARTED, () => { - this.observable?.stop(); + this.unsubscribeFromSubscriptions?.(); }); this.on(E2EEState.ERROR, () => { - this.observable?.stop(); + this.unsubscribeFromSubscriptions?.(); }); this.setState(E2EEState.NOT_STARTED); @@ -126,8 +119,6 @@ class E2E extends Emitter { } isReady(): boolean { - E2EEStateDependency.depend(); - // Save_Password state is also a ready state for E2EE return this.state === E2EEState.READY || this.state === E2EEState.SAVE_PASSWORD; } @@ -187,26 +178,26 @@ class E2E extends Emitter { await e2eRoom.decryptSubscription(); } + private unsubscribeFromSubscriptions: (() => void) | undefined; + observeSubscriptions() { - this.observable?.stop(); + this.unsubscribeFromSubscriptions?.(); - this.observable = Subscriptions.find().observe({ - changed: (sub: ISubscription) => { - setTimeout(() => this.onSubscriptionChanged(sub), 0); - }, - added: (sub: ISubscription) => { - setTimeout(async () => { - this.log('Subscription added', sub); - if (!sub.encrypted && !sub.E2EKey) { - return; - } - return this.getInstanceByRoomId(sub.rid); - }, 0); - }, - removed: (sub: ISubscription) => { - this.log('Subscription removed', sub); - this.removeInstanceByRoomId(sub.rid); - }, + this.unsubscribeFromSubscriptions = Subscriptions.use.subscribe((state) => { + const subscriptions = Array.from(state.records.values()).filter((sub) => sub.encrypted || sub.E2EKey); + + const subscribed = new Set(subscriptions.map((sub) => sub.rid)); + const instatiated = new Set(Object.keys(this.instancesByRoomId)); + const excess = instatiated.difference(subscribed); + + if (excess.size) { + this.log('Unsubscribing from excess instances', excess); + excess.forEach((rid) => this.removeInstanceByRoomId(rid)); + } + + for (const sub of subscriptions) { + void this.onSubscriptionChanged(sub); + } }); } @@ -220,15 +211,13 @@ class E2E extends Emitter { this.state = nextState; - E2EEStateDependency.changed(); - this.emit('E2E_STATE_CHANGED', { prevState, nextState }); this.emit(nextState); } async handleAsyncE2EESuggestedKey() { - const subs = Subscriptions.find({ E2ESuggestedKey: { $exists: true } }).fetch(); + const subs = Subscriptions.state.filter((sub) => typeof sub.E2ESuggestedKey !== 'undefined'); await Promise.all( subs .filter((sub) => sub.E2ESuggestedKey && !sub.E2EKey) @@ -745,9 +734,9 @@ class E2E extends Emitter { } async decryptSubscriptions(): Promise { - Subscriptions.find({ - encrypted: true, - }).forEach((subscription) => this.decryptSubscription(subscription._id)); + Subscriptions.state + .filter((subscription) => Boolean(subscription.encrypted)) + .forEach((subscription) => this.decryptSubscription(subscription._id)); } openAlert(config: Omit): void { @@ -904,3 +893,7 @@ class E2E extends Emitter { } export const e2e = new E2E(); + +Accounts.onLogout(() => { + void e2e.stopClient(); +}); diff --git a/apps/meteor/app/e2e/client/wordList.ts b/apps/meteor/client/lib/e2ee/wordList.ts similarity index 100% rename from apps/meteor/app/e2e/client/wordList.ts rename to apps/meteor/client/lib/e2ee/wordList.ts diff --git a/apps/meteor/client/lib/getPermaLink.ts b/apps/meteor/client/lib/getPermaLink.ts index cece6e9e549fb..9710d70cb8ba7 100644 --- a/apps/meteor/client/lib/getPermaLink.ts +++ b/apps/meteor/client/lib/getPermaLink.ts @@ -16,7 +16,7 @@ export const getPermaLink = async (msgId: string): Promise => { throw new Error('invalid-parameter'); } - const { Messages, Rooms, Subscriptions } = await import('../../app/models/client'); + const { Messages, Rooms, Subscriptions } = await import('../stores'); const msg = Messages.state.get(msgId) || (await getMessage(msgId)); if (!msg) { @@ -28,7 +28,7 @@ export const getPermaLink = async (msgId: string): Promise => { throw new Error('room-not-found'); } - const subData = Subscriptions.findOne({ 'rid': roomData._id, 'u._id': Meteor.userId() }); + const subData = Subscriptions.state.find((record) => record.rid === roomData._id && record.u._id === Meteor.userId()); const { roomCoordinator } = await import('./rooms/roomCoordinator'); diff --git a/apps/meteor/client/lib/mutationEffects/room.ts b/apps/meteor/client/lib/mutationEffects/room.ts index f1acd86bdfec1..7340a42fa55a9 100644 --- a/apps/meteor/client/lib/mutationEffects/room.ts +++ b/apps/meteor/client/lib/mutationEffects/room.ts @@ -1,19 +1,12 @@ -import { Subscriptions } from '../../../app/models/client'; +import { Subscriptions } from '../../stores'; export const toggleFavoriteRoom = (roomId: string, favorite: boolean, userId: string | null) => { if (!userId) { return; } - Subscriptions.update( - { - 'rid': roomId, - 'u._id': userId, - }, - { - $set: { - f: favorite, - }, - }, + Subscriptions.state.update( + (record) => record.rid === roomId && record.u._id === userId, + (record) => ({ ...record, f: favorite }), ); }; diff --git a/apps/meteor/client/lib/mutationEffects/starredMessage.ts b/apps/meteor/client/lib/mutationEffects/starredMessage.ts index e5657940a2e37..95cc58a7b8916 100644 --- a/apps/meteor/client/lib/mutationEffects/starredMessage.ts +++ b/apps/meteor/client/lib/mutationEffects/starredMessage.ts @@ -1,7 +1,7 @@ import type { IMessage } from '@rocket.chat/core-typings'; import { Meteor } from 'meteor/meteor'; -import { Messages } from '../../../app/models/client'; +import { Messages } from '../../stores'; export const toggleStarredMessage = (message: IMessage, starred: boolean) => { const uid = Meteor.userId()!; diff --git a/apps/meteor/client/lib/mutationEffects/updatePinMessage.ts b/apps/meteor/client/lib/mutationEffects/updatePinMessage.ts index 36215300cbe0a..055ef121433f6 100644 --- a/apps/meteor/client/lib/mutationEffects/updatePinMessage.ts +++ b/apps/meteor/client/lib/mutationEffects/updatePinMessage.ts @@ -1,6 +1,6 @@ import type { IMessage } from '@rocket.chat/core-typings'; -import { Messages } from '../../../app/models/client'; +import { Messages } from '../../stores'; import { PinMessagesNotAllowed } from '../errors/PinMessagesNotAllowed'; export const updatePinMessage = (message: IMessage, data: Partial) => { diff --git a/apps/meteor/client/lib/mutationEffects/updateSubscription.ts b/apps/meteor/client/lib/mutationEffects/updateSubscription.ts index 4decdbb4ccbbf..c49d051e7a076 100644 --- a/apps/meteor/client/lib/mutationEffects/updateSubscription.ts +++ b/apps/meteor/client/lib/mutationEffects/updateSubscription.ts @@ -1,11 +1,14 @@ import type { ISubscription } from '@rocket.chat/core-typings'; -import { Subscriptions } from '../../../app/models/client'; +import { Subscriptions } from '../../stores'; export const updateSubscription = (roomId: string, userId: string, data: Partial) => { - const oldDocument = Subscriptions.findOne({ 'rid': roomId, 'u._id': userId }); + const oldDocument = Subscriptions.state.find((record) => record.rid === roomId && record.u._id === userId); - Subscriptions.update({ 'rid': roomId, 'u._id': userId }, { $set: data }); + Subscriptions.state.update( + (record) => record.rid === roomId && record.u._id === userId, + (record) => ({ ...record, ...data }), + ); return oldDocument; }; diff --git a/apps/meteor/client/lib/normalizeThreadTitle.ts b/apps/meteor/client/lib/normalizeThreadTitle.ts index 99bd95d340217..a2e698713440f 100644 --- a/apps/meteor/client/lib/normalizeThreadTitle.ts +++ b/apps/meteor/client/lib/normalizeThreadTitle.ts @@ -5,8 +5,8 @@ import { Meteor } from 'meteor/meteor'; import { emojiParser } from '../../app/emoji/client/emojiParser'; import { filterMarkdown } from '../../app/markdown/lib/markdown'; import { MentionsParser } from '../../app/mentions/lib/MentionsParser'; -import { Users } from '../../app/models/client'; import { settings } from '../../app/settings/client'; +import { Users } from '../stores'; export function normalizeThreadTitle({ ...message }: Readonly) { if (message.msg) { @@ -15,7 +15,7 @@ export function normalizeThreadTitle({ ...message }: Readonly) { return filteredMessage; } const uid = Meteor.userId(); - const me = (uid && Users.findOne(uid, { fields: { username: 1 } })?.username) || ''; + const me = (uid && Users.state.get(uid)?.username) || ''; const pattern = settings.get('UTF8_User_Names_Validation'); const useRealName = settings.get('UI_Use_Real_Name'); diff --git a/apps/meteor/client/lib/objectUtils.ts b/apps/meteor/client/lib/objectUtils.ts new file mode 100644 index 0000000000000..156b14fd940b6 --- /dev/null +++ b/apps/meteor/client/lib/objectUtils.ts @@ -0,0 +1,7 @@ +export function objectKeys>(obj: T): (keyof T)[] { + return Object.keys(obj) as (keyof T)[]; +} + +export function entriesOf>(obj: T): [keyof T, T[keyof T]][] { + return Object.entries(obj) as [keyof T, T[keyof T]][]; +} diff --git a/apps/meteor/client/lib/queryKeys.ts b/apps/meteor/client/lib/queryKeys.ts index 9d4ac800927e3..7d7db6163f833 100644 --- a/apps/meteor/client/lib/queryKeys.ts +++ b/apps/meteor/client/lib/queryKeys.ts @@ -1,4 +1,5 @@ -import type { ILivechatDepartment, IMessage, IRoom, ITeam, IUser } from '@rocket.chat/core-typings'; +import type { ILivechatDepartment, IMessage, IRoom, ITeam, IUser, ILivechatAgent } from '@rocket.chat/core-typings'; +import type { PaginatedRequest } from '@rocket.chat/rest-typings'; export const roomsQueryKeys = { all: ['rooms'] as const, @@ -29,6 +30,12 @@ export const rolesQueryKeys = { export const omnichannelQueryKeys = { all: ['omnichannel'] as const, department: (id: string) => [...omnichannelQueryKeys.all, 'department', id] as const, + agents: (query?: PaginatedRequest) => + !query ? ([...omnichannelQueryKeys.all, 'agents'] as const) : ([...omnichannelQueryKeys.all, 'agents', query] as const), + agent: (uid: ILivechatAgent['_id']) => [...omnichannelQueryKeys.agents(), uid] as const, + agentDepartments: (uid: ILivechatAgent['_id']) => [...omnichannelQueryKeys.agent(uid), 'departments'] as const, + managers: (query?: PaginatedRequest) => + !query ? ([...omnichannelQueryKeys.all, 'managers'] as const) : ([...omnichannelQueryKeys.all, 'managers', query] as const), extensions: ( params: | { @@ -97,6 +104,8 @@ export const usersQueryKeys = { export const teamsQueryKeys = { all: ['teams'] as const, team: (teamId: ITeam['_id']) => [...teamsQueryKeys.all, teamId] as const, + teamInfo: (teamId: ITeam['_id']) => [...teamsQueryKeys.team(teamId), 'info'] as const, roomsOfUser: (teamId: ITeam['_id'], userId: IUser['_id'], options?: { canUserDelete: boolean }) => [...teamsQueryKeys.team(teamId), 'rooms-of-user', userId, options] as const, + listUserTeams: (userId: IUser['_id']) => [...teamsQueryKeys.all, 'listUserTeams', userId] as const, }; diff --git a/apps/meteor/client/lib/rooms/roomCoordinator.tsx b/apps/meteor/client/lib/rooms/roomCoordinator.tsx index a5bc4429d84d6..1e99fa0bfa7df 100644 --- a/apps/meteor/client/lib/rooms/roomCoordinator.tsx +++ b/apps/meteor/client/lib/rooms/roomCoordinator.tsx @@ -3,7 +3,6 @@ import type { RouteName } from '@rocket.chat/ui-contexts'; import { Meteor } from 'meteor/meteor'; import { hasPermission } from '../../../app/authorization/client'; -import { Subscriptions } from '../../../app/models/client'; import type { RoomSettingsEnum, RoomMemberActions, @@ -15,6 +14,7 @@ import type { } from '../../../definition/IRoomTypeConfig'; import { RoomCoordinator } from '../../../lib/rooms/coordinator'; import { router } from '../../providers/RouterProvider'; +import { Subscriptions } from '../../stores'; import RoomRoute from '../../views/room/RoomRoute'; import MainLayout from '../../views/root/MainLayout'; import { appLayout } from '../appLayout'; @@ -42,23 +42,17 @@ class RoomCoordinatorClient extends RoomCoordinator { getUiText(_context: ValueOf): string { return ''; }, - condition(): boolean { - return true; - }, getAvatarPath(_room): string { return ''; }, findRoom(_identifier: string): IRoom | undefined { return undefined; }, - showJoinLink(_roomId: string): boolean { - return false; - }, isLivechatRoom(): boolean { return false; }, canSendMessage(room: IRoom): boolean { - return Subscriptions.find({ rid: room._id }).count() > 0; + return Subscriptions.state.count((record) => record.rid === room._id) > 0; }, ...directives, config: roomConfig, diff --git a/apps/meteor/client/lib/rooms/roomTypes/conversation.ts b/apps/meteor/client/lib/rooms/roomTypes/conversation.ts index 94fa341024d51..9fc93da61cff6 100644 --- a/apps/meteor/client/lib/rooms/roomTypes/conversation.ts +++ b/apps/meteor/client/lib/rooms/roomTypes/conversation.ts @@ -1,6 +1,3 @@ -import { Meteor } from 'meteor/meteor'; - -import { getUserPreference } from '../../../../app/utils/client'; import { getConversationRoomType } from '../../../../lib/rooms/roomTypes/conversation'; import { roomCoordinator } from '../roomCoordinator'; @@ -11,10 +8,5 @@ roomCoordinator.add( ...ConversationRoomType, label: 'Conversations', }, - { - condition(): boolean { - // returns true only if sidebarGroupByType is not set - return !getUserPreference(Meteor.userId(), 'sidebarGroupByType'); - }, - }, + {}, ); diff --git a/apps/meteor/client/lib/rooms/roomTypes/direct.ts b/apps/meteor/client/lib/rooms/roomTypes/direct.ts index d6a2aa1087b1d..15f8597355250 100644 --- a/apps/meteor/client/lib/rooms/roomTypes/direct.ts +++ b/apps/meteor/client/lib/rooms/roomTypes/direct.ts @@ -1,17 +1,14 @@ -import type { AtLeast, IRoom, ISubscription, IUser } from '@rocket.chat/core-typings'; +import type { AtLeast, IRoom } from '@rocket.chat/core-typings'; import { isRoomFederated } from '@rocket.chat/core-typings'; -import { Meteor } from 'meteor/meteor'; -import type { Filter } from 'mongodb'; +import type { SubscriptionWithRoom } from '@rocket.chat/ui-contexts'; -import { hasAtLeastOnePermission } from '../../../../app/authorization/client'; -import { Subscriptions, Users, Rooms } from '../../../../app/models/client'; import { settings } from '../../../../app/settings/client'; -import { getUserPreference } from '../../../../app/utils/client'; import { getAvatarURL } from '../../../../app/utils/client/getAvatarURL'; import { getUserAvatarURL } from '../../../../app/utils/client/getUserAvatarURL'; import type { IRoomTypeClientDirectives } from '../../../../definition/IRoomTypeConfig'; import { RoomSettingsEnum, RoomMemberActions, UiTextContext } from '../../../../definition/IRoomTypeConfig'; import { getDirectMessageRoomType } from '../../../../lib/rooms/roomTypes/direct'; +import { Users, Rooms, Subscriptions } from '../../../stores'; import * as Federation from '../../federation/Federation'; import { roomCoordinator } from '../roomCoordinator'; @@ -69,7 +66,7 @@ roomCoordinator.add( return undefined; } - return Subscriptions.findOne({ rid: roomData._id }); + return Subscriptions.state.find((record) => record.rid === roomData._id); })(); if (!subscription) { @@ -98,11 +95,6 @@ roomCoordinator.add( } }, - condition() { - const groupByType = getUserPreference(Meteor.userId(), 'sidebarGroupByType'); - return groupByType && hasAtLeastOnePermission(['view-d-room', 'view-joined-room']); - }, - getAvatarPath(room) { if (!room) { return ''; @@ -120,10 +112,10 @@ roomCoordinator.add( }) as string; } - const sub = Subscriptions.findOne({ rid: room._id }, { fields: { name: 1 } }); - if (sub?.name) { - const user = Users.findOne({ username: sub.name }, { fields: { username: 1, avatarETag: 1 } }) as IUser | undefined; - return getUserAvatarURL(user?.username || sub.name, user?.avatarETag); + const subscriptionName = Subscriptions.state.find((record) => record.rid === room._id)?.name; + if (subscriptionName) { + const { username, avatarETag } = Users.state.find((record) => record.username === subscriptionName) || {}; + return getUserAvatarURL(username || subscriptionName, avatarETag); } return getUserAvatarURL(room.name || this.roomName(room) || ''); @@ -146,12 +138,9 @@ roomCoordinator.add( }, findRoom(identifier) { - const query: Filter = { - t: 'd', - $or: [{ name: identifier }, { rid: identifier }], - }; + const predicate = (record: SubscriptionWithRoom) => record.t === 'd' && (record.name === identifier || record.rid === identifier); - const subscription = Subscriptions.findOne(query); + const subscription = Subscriptions.state.find(predicate); if (subscription?.rid) { return Rooms.state.get(subscription.rid); } diff --git a/apps/meteor/client/lib/rooms/roomTypes/favorite.ts b/apps/meteor/client/lib/rooms/roomTypes/favorite.ts index 5d8d387bdbf73..7494b7a6fdc42 100644 --- a/apps/meteor/client/lib/rooms/roomTypes/favorite.ts +++ b/apps/meteor/client/lib/rooms/roomTypes/favorite.ts @@ -1,7 +1,3 @@ -import { Meteor } from 'meteor/meteor'; - -import { settings } from '../../../../app/settings/client'; -import { getUserPreference } from '../../../../app/utils/client'; import { getFavoriteRoomType } from '../../../../lib/rooms/roomTypes/favorite'; import { roomCoordinator } from '../roomCoordinator'; @@ -13,9 +9,6 @@ roomCoordinator.add( label: 'Favorites', }, { - condition(): boolean { - return settings.get('Favorite_Rooms') && getUserPreference(Meteor.userId(), 'sidebarShowFavorites'); - }, getIcon() { return 'star'; }, diff --git a/apps/meteor/client/lib/rooms/roomTypes/livechat.ts b/apps/meteor/client/lib/rooms/roomTypes/livechat.ts index 39058f48d6129..53b7bcf977fc0 100644 --- a/apps/meteor/client/lib/rooms/roomTypes/livechat.ts +++ b/apps/meteor/client/lib/rooms/roomTypes/livechat.ts @@ -1,12 +1,10 @@ import type { AtLeast, ValueOf } from '@rocket.chat/core-typings'; -import { hasPermission } from '../../../../app/authorization/client'; -import { Rooms, Subscriptions } from '../../../../app/models/client'; -import { settings } from '../../../../app/settings/client'; import { getAvatarURL } from '../../../../app/utils/client/getAvatarURL'; import type { IRoomTypeClientDirectives } from '../../../../definition/IRoomTypeConfig'; import { RoomSettingsEnum, RoomMemberActions, UiTextContext } from '../../../../definition/IRoomTypeConfig'; import { getLivechatRoomType } from '../../../../lib/rooms/roomTypes/livechat'; +import { Rooms, Subscriptions } from '../../../stores'; import { roomCoordinator } from '../roomCoordinator'; export const LivechatRoomType = getLivechatRoomType(roomCoordinator); @@ -45,10 +43,6 @@ roomCoordinator.add( } }, - condition() { - return settings.get('Livechat_enabled') && hasPermission('view-l-room'); - }, - getAvatarPath(room) { return getAvatarURL({ username: `@${this.roomName(room)}` }) || ''; }, @@ -70,7 +64,7 @@ roomCoordinator.add( return true; } - const subscription = Subscriptions.findOne({ rid: room._id }); + const subscription = Subscriptions.state.find((record) => record.rid === room._id); return !subscription; }, diff --git a/apps/meteor/client/lib/rooms/roomTypes/private.ts b/apps/meteor/client/lib/rooms/roomTypes/private.ts index 0c1eb49079df4..dc8432afb5249 100644 --- a/apps/meteor/client/lib/rooms/roomTypes/private.ts +++ b/apps/meteor/client/lib/rooms/roomTypes/private.ts @@ -1,15 +1,12 @@ import type { AtLeast, IRoom } from '@rocket.chat/core-typings'; import { isRoomFederated } from '@rocket.chat/core-typings'; -import { Meteor } from 'meteor/meteor'; -import { hasPermission } from '../../../../app/authorization/client'; -import { Rooms } from '../../../../app/models/client'; import { settings } from '../../../../app/settings/client'; -import { getUserPreference } from '../../../../app/utils/client'; import { getRoomAvatarURL } from '../../../../app/utils/client/getRoomAvatarURL'; import type { IRoomTypeClientDirectives } from '../../../../definition/IRoomTypeConfig'; import { RoomSettingsEnum, RoomMemberActions, UiTextContext } from '../../../../definition/IRoomTypeConfig'; import { getPrivateRoomType } from '../../../../lib/rooms/roomTypes/private'; +import { Rooms } from '../../../stores'; import * as Federation from '../../federation/Federation'; import { roomCoordinator } from '../roomCoordinator'; @@ -80,11 +77,6 @@ roomCoordinator.add( } }, - condition() { - const groupByType = getUserPreference(Meteor.userId(), 'sidebarGroupByType'); - return groupByType && hasPermission('view-p-room'); - }, - getAvatarPath(room) { return getRoomAvatarURL({ roomId: room._id, cache: room.avatarETag }); }, diff --git a/apps/meteor/client/lib/rooms/roomTypes/public.ts b/apps/meteor/client/lib/rooms/roomTypes/public.ts index 4b5b6d62e4c7c..13c35ec679fa8 100644 --- a/apps/meteor/client/lib/rooms/roomTypes/public.ts +++ b/apps/meteor/client/lib/rooms/roomTypes/public.ts @@ -1,15 +1,12 @@ import type { AtLeast, IRoom } from '@rocket.chat/core-typings'; import { isRoomFederated } from '@rocket.chat/core-typings'; -import { Meteor } from 'meteor/meteor'; -import { hasAtLeastOnePermission } from '../../../../app/authorization/client'; -import { Rooms } from '../../../../app/models/client'; import { settings } from '../../../../app/settings/client'; -import { getUserPreference } from '../../../../app/utils/client'; import { getRoomAvatarURL } from '../../../../app/utils/client/getRoomAvatarURL'; import type { IRoomTypeClientDirectives } from '../../../../definition/IRoomTypeConfig'; import { RoomSettingsEnum, RoomMemberActions, UiTextContext } from '../../../../definition/IRoomTypeConfig'; import { getPublicRoomType } from '../../../../lib/rooms/roomTypes/public'; +import { Rooms } from '../../../stores'; import * as Federation from '../../federation/Federation'; import { roomCoordinator } from '../roomCoordinator'; @@ -77,14 +74,6 @@ roomCoordinator.add( } }, - condition() { - const groupByType = getUserPreference(Meteor.userId(), 'sidebarGroupByType'); - return ( - groupByType && - (hasAtLeastOnePermission(['view-c-room', 'view-joined-room']) || settings.get('Accounts_AllowAnonymousRead') === true) - ); - }, - getAvatarPath(room) { return getRoomAvatarURL({ roomId: room._id, cache: room.avatarETag }); }, @@ -113,10 +102,5 @@ roomCoordinator.add( }; return Rooms.state.find(predicate); }, - - showJoinLink(roomId) { - const predicate = (record: IRoom): boolean => record.t === 'c' && record._id === roomId; - return !!Rooms.state.find(predicate); - }, } as AtLeast, ); diff --git a/apps/meteor/client/lib/rooms/roomTypes/unread.ts b/apps/meteor/client/lib/rooms/roomTypes/unread.ts index 5dcae777d438f..d7be63e684c56 100644 --- a/apps/meteor/client/lib/rooms/roomTypes/unread.ts +++ b/apps/meteor/client/lib/rooms/roomTypes/unread.ts @@ -1,6 +1,3 @@ -import { Meteor } from 'meteor/meteor'; - -import { getUserPreference } from '../../../../app/utils/client'; import { getUnreadRoomType } from '../../../../lib/rooms/roomTypes/unread'; import { roomCoordinator } from '../roomCoordinator'; @@ -11,9 +8,5 @@ roomCoordinator.add( ...UnreadRoomType, label: 'Unread', }, - { - condition(): boolean { - return getUserPreference(Meteor.userId(), 'sidebarShowUnread') as boolean; - }, - }, + {}, ); diff --git a/apps/meteor/client/lib/rooms/roomTypes/voip.ts b/apps/meteor/client/lib/rooms/roomTypes/voip.ts index b7b99b4cecba0..4ad4296acab5d 100644 --- a/apps/meteor/client/lib/rooms/roomTypes/voip.ts +++ b/apps/meteor/client/lib/rooms/roomTypes/voip.ts @@ -1,11 +1,9 @@ import type { AtLeast } from '@rocket.chat/core-typings'; -import { hasPermission } from '../../../../app/authorization/client'; -import { Rooms } from '../../../../app/models/client'; -import { settings } from '../../../../app/settings/client'; import { getAvatarURL } from '../../../../app/utils/client/getAvatarURL'; import type { IRoomTypeClientDirectives } from '../../../../definition/IRoomTypeConfig'; import { getVoipRoomType } from '../../../../lib/rooms/roomTypes/voip'; +import { Rooms } from '../../../stores'; import { roomCoordinator } from '../roomCoordinator'; export const VoipRoomType = getVoipRoomType(roomCoordinator); @@ -20,10 +18,6 @@ roomCoordinator.add( return room.name || room.fname || (room as any).label; }, - condition() { - return settings.get('Livechat_enabled') && hasPermission('view-l-room'); - }, - getAvatarPath(room) { return getAvatarURL({ username: `@${this.roomName(room)}` }) || ''; }, diff --git a/apps/meteor/client/lib/settings/PrivateSettingsCachedCollection.ts b/apps/meteor/client/lib/settings/PrivateSettingsCachedCollection.ts deleted file mode 100644 index 95486509875fa..0000000000000 --- a/apps/meteor/client/lib/settings/PrivateSettingsCachedCollection.ts +++ /dev/null @@ -1,32 +0,0 @@ -import type { ISetting } from '@rocket.chat/core-typings'; - -import { sdk } from '../../../app/utils/client/lib/SDKClient'; -import { PrivateCachedCollection } from '../cachedCollections'; - -class PrivateSettingsCachedCollection extends PrivateCachedCollection { - constructor() { - super({ - name: 'private-settings', - eventType: 'notify-logged', - }); - } - - override setupListener() { - return sdk.stream( - 'notify-logged', - [this.eventName as 'private-settings-changed'], - async (t: string, { _id, ...record }: { _id: string }) => { - this.log('record received', t, { _id, ...record }); - this.collection.update({ _id }, { $set: record }, { upsert: true }); - this.sync(); - }, - ); - } -} - -const instance = new PrivateSettingsCachedCollection(); - -export { - /** @deprecated */ - instance as PrivateSettingsCachedCollection, -}; diff --git a/apps/meteor/client/lib/settings/PublicSettingsCachedCollection.ts b/apps/meteor/client/lib/settings/PublicSettingsCachedCollection.ts deleted file mode 100644 index bc12c13997c22..0000000000000 --- a/apps/meteor/client/lib/settings/PublicSettingsCachedCollection.ts +++ /dev/null @@ -1,19 +0,0 @@ -import type { ISetting } from '@rocket.chat/core-typings'; - -import { PublicCachedCollection } from '../cachedCollections/CachedCollection'; - -class PublicSettingsCachedCollection extends PublicCachedCollection { - constructor() { - super({ - name: 'public-settings', - eventType: 'notify-all', - }); - } -} - -const instance = new PublicSettingsCachedCollection(); - -export { - /** @deprecated */ - instance as PublicSettingsCachedCollection, -}; diff --git a/apps/meteor/client/lib/userData.ts b/apps/meteor/client/lib/userData.ts index 24f37e1b72876..2b768bad7a597 100644 --- a/apps/meteor/client/lib/userData.ts +++ b/apps/meteor/client/lib/userData.ts @@ -1,10 +1,11 @@ import type { ILivechatAgent, IUser, Serialized } from '@rocket.chat/core-typings'; -import { ReactiveVar } from 'meteor/reactive-var'; +import { createTransformFromUpdateFilter } from '@rocket.chat/mongo-adapter'; +import { create } from 'zustand'; -import { Users } from '../../app/models/client'; import { sdk } from '../../app/utils/client/lib/SDKClient'; +import { Users } from '../stores'; -export const isSyncReady = new ReactiveVar(false); +export const useUserDataSyncReady = create(() => false); type RawUserData = Serialized< Pick< @@ -34,45 +35,49 @@ type RawUserData = Serialized< >; const updateUser = (userData: IUser): void => { - const user = Users.findOne({ _id: userData._id }) as IUser | undefined; + const user = Users.state.get(userData._id); if (!user?._updatedAt || user._updatedAt.getTime() < userData._updatedAt.getTime()) { - Users.upsert({ _id: userData._id }, userData); + Users.state.store(userData); return; } // delete data already on user's collection as those are newer - Object.keys(user).forEach((key) => { + for (const key of Object.keys(user)) { delete userData[key as keyof IUser]; - }); - Users.update({ _id: user._id }, { $set: { ...userData } }); + } + + Users.state.update( + ({ _id }) => _id === user._id, + (user) => ({ ...user, ...userData }), + ); }; let cancel: undefined | (() => void); export const synchronizeUserData = async (uid: IUser['_id']): Promise => { - if (!uid) { - return; - } + if (!uid) return; // Remove data from any other user that we may have retained - Users.remove({ _id: { $ne: uid } }); - + Users.state.remove((record) => record._id !== uid); cancel?.(); const result = sdk.stream('notify-user', [`${uid}/userData`], (data) => { switch (data.type) { - case 'inserted': + case 'inserted': { // eslint-disable-next-line @typescript-eslint/no-unused-vars const { type, id, ...user } = data; - Users.insert(user as unknown as IUser); + Users.state.store(user.data); break; + } - case 'updated': - Users.upsert({ _id: uid }, { $set: data.diff, $unset: data.unset as any }); + case 'updated': { + const transform = createTransformFromUpdateFilter({ $unset: data.unset as Record, $set: data.diff }); + Users.state.update(({ _id }) => _id === uid, transform); break; + } case 'removed': - Users.remove({ _id: uid }); + Users.state.delete(uid); break; } }); @@ -82,15 +87,6 @@ export const synchronizeUserData = async (uid: IUser['_id']): Promise { - const removed = Users.remove({}); +export const removeLocalUserData = () => { + Users.state.replaceAll([]); localStorage.clear(); - return removed; }; diff --git a/apps/meteor/client/lib/userPresence.ts b/apps/meteor/client/lib/userPresence.ts new file mode 100644 index 0000000000000..835c3230781e7 --- /dev/null +++ b/apps/meteor/client/lib/userPresence.ts @@ -0,0 +1,134 @@ +import type { IUser } from '@rocket.chat/core-typings'; +import { UserStatus } from '@rocket.chat/core-typings'; +import { useConnectionStatus, useIsLoggingIn, useMethod, useUser, useUserPreference } from '@rocket.chat/ui-contexts'; +import { useEffect } from 'react'; + +import { withDebouncing } from '../../lib/utils/highOrderFunctions'; +import { Users } from '../stores'; + +// TODO: merge this with the current React-based implementation of idle detection + +export class UserPresence { + private user: IUser | undefined; + + private timer: ReturnType | undefined; + + private status: UserStatus | undefined; + + private awayTime: number | undefined = 60_000; + + private connected = true; + + private goOnline: () => Promise = async () => undefined; + + private goAway: () => Promise = async () => undefined; + + private storeUser: (doc: IUser) => void = () => undefined; + + startTimer() { + this.stopTimer(); + if (!this.awayTime) return; + + this.timer = setTimeout(this.setAway, this.awayTime); + } + + private stopTimer() { + clearTimeout(this.timer); + } + + private readonly setOnline = () => this.setStatus(UserStatus.ONLINE); + + private readonly setAway = () => this.setStatus(UserStatus.AWAY); + + private readonly setStatus = withDebouncing({ wait: 1000 })(async (newStatus: UserStatus.ONLINE | UserStatus.AWAY) => { + if (!this.connected || newStatus === this.status) { + this.startTimer(); + return; + } + + if (this.user?.status !== newStatus && this.user?.statusDefault === newStatus) { + this.storeUser({ ...this.user, status: newStatus }); + } + + switch (newStatus) { + case UserStatus.ONLINE: + await this.goOnline(); + break; + + case UserStatus.AWAY: + await this.goAway(); + this.stopTimer(); + break; + } + + this.status = newStatus; + }); + + readonly use = () => { + const user = useUser() ?? undefined; + const { connected } = useConnectionStatus(); + const isLoggingIn = useIsLoggingIn(); + const enableAutoAway = useUserPreference('enableAutoAway'); + const idleTimeLimit = useUserPreference('idleTimeLimit') ?? 300; + const { RocketChatDesktop } = window; + + this.user = user; + this.connected = connected; + this.awayTime = enableAutoAway && !RocketChatDesktop ? idleTimeLimit * 1000 : undefined; + this.goOnline = useMethod('UserPresence:online'); + this.goAway = useMethod('UserPresence:away'); + this.storeUser = Users.use((state) => state.store); + + useEffect(() => { + if (!RocketChatDesktop) return; + + RocketChatDesktop.setUserPresenceDetection({ + isAutoAwayEnabled: enableAutoAway ?? false, + idleThreshold: idleTimeLimit, + setUserOnline: (online) => { + if (!online) { + this.goAway(); + return; + } + this.goOnline(); + }, + }); + + return () => { + RocketChatDesktop.setUserPresenceDetection({ + isAutoAwayEnabled: false, + idleThreshold: null, + setUserOnline: () => undefined, + }); + }; + }, [RocketChatDesktop, enableAutoAway, idleTimeLimit]); + + useEffect(() => { + if (RocketChatDesktop) return; + + const documentEvents = ['mousemove', 'mousedown', 'touchend', 'keydown'] as const; + documentEvents.forEach((key) => document.addEventListener(key, this.setOnline)); + window.addEventListener('focus', this.setOnline); + + return () => { + documentEvents.forEach((key) => document.removeEventListener(key, this.setOnline)); + window.removeEventListener('focus', this.setOnline); + }; + }, [RocketChatDesktop]); + + useEffect(() => { + if (!user || !connected || isLoggingIn) return; + this.startTimer(); + }, [connected, isLoggingIn, user]); + + useEffect(() => { + if (connected) { + this.startTimer(); + this.status = UserStatus.ONLINE; + return; + } + this.stopTimer(); + this.status = UserStatus.OFFLINE; + }, [connected]); + }; +} diff --git a/apps/meteor/client/lib/utils/getUidDirectMessage.ts b/apps/meteor/client/lib/utils/getUidDirectMessage.ts index 9b2241d66fffb..b4a8386c7259f 100644 --- a/apps/meteor/client/lib/utils/getUidDirectMessage.ts +++ b/apps/meteor/client/lib/utils/getUidDirectMessage.ts @@ -1,7 +1,7 @@ import type { IRoom, IUser } from '@rocket.chat/core-typings'; import { Meteor } from 'meteor/meteor'; -import { Rooms } from '../../../app/models/client'; +import { Rooms } from '../../stores'; export const getUidDirectMessage = (rid: IRoom['_id'], uid: IUser['_id'] | null = Meteor.userId()): string | undefined => { const room = Rooms.state.get(rid); diff --git a/apps/meteor/client/lib/utils/goToRoomById.ts b/apps/meteor/client/lib/utils/goToRoomById.ts index 12c6a2a768ee1..35a4761c59488 100644 --- a/apps/meteor/client/lib/utils/goToRoomById.ts +++ b/apps/meteor/client/lib/utils/goToRoomById.ts @@ -1,9 +1,9 @@ -import type { IRoom, ISubscription } from '@rocket.chat/core-typings'; +import type { IRoom } from '@rocket.chat/core-typings'; import { memoize } from '@rocket.chat/memo'; import { callWithErrorHandling } from './callWithErrorHandling'; -import { Subscriptions } from '../../../app/models/client'; import { router } from '../../providers/RouterProvider'; +import { Subscriptions } from '../../stores'; import { roomCoordinator } from '../rooms/roomCoordinator'; const getRoomById = memoize((rid: IRoom['_id']) => callWithErrorHandling('getRoomById', rid)); @@ -13,7 +13,7 @@ export const goToRoomById = async (rid: IRoom['_id']): Promise => { return; } - const subscription: ISubscription | undefined = Subscriptions.findOne({ rid }); + const subscription = Subscriptions.state.find((record) => record.rid === rid); if (subscription) { roomCoordinator.openRouteLink(subscription.t, subscription, router.getSearchParameters()); diff --git a/apps/meteor/client/lib/utils/legacyJumpToMessage.ts b/apps/meteor/client/lib/utils/legacyJumpToMessage.ts index 76a4d044bc8a0..fdaffcb65af30 100644 --- a/apps/meteor/client/lib/utils/legacyJumpToMessage.ts +++ b/apps/meteor/client/lib/utils/legacyJumpToMessage.ts @@ -1,11 +1,11 @@ import type { IMessage } from '@rocket.chat/core-typings'; import { isThreadMessage } from '@rocket.chat/core-typings'; -import { Rooms } from '../../../app/models/client'; +import { goToRoomById } from './goToRoomById'; import { RoomHistoryManager } from '../../../app/ui-utils/client'; import { router } from '../../providers/RouterProvider'; +import { Rooms } from '../../stores'; import { RoomManager } from '../RoomManager'; -import { goToRoomById } from './goToRoomById'; /** @deprecated */ export const legacyJumpToMessage = async (message: IMessage) => { diff --git a/apps/meteor/client/main.ts b/apps/meteor/client/main.ts index ea738dac8c6d6..2d1b6f396dd84 100644 --- a/apps/meteor/client/main.ts +++ b/apps/meteor/client/main.ts @@ -1,13 +1,6 @@ import './serviceWorker'; import './startup/accounts'; - -import { FlowRouter } from 'meteor/ostrio:flow-router-extra'; - -FlowRouter.wait(); - -FlowRouter.notFound = { - action: () => undefined, -}; +import './startup/fakeUserPresence'; import('@rocket.chat/fuselage-polyfills') .then(() => import('./meteorOverrides')) diff --git a/apps/meteor/client/meteorOverrides/unstoreLoginToken.ts b/apps/meteor/client/meteorOverrides/unstoreLoginToken.ts index d1a6ffe9c9cdb..899d89605254a 100644 --- a/apps/meteor/client/meteorOverrides/unstoreLoginToken.ts +++ b/apps/meteor/client/meteorOverrides/unstoreLoginToken.ts @@ -1,9 +1,9 @@ import { Accounts } from 'meteor/accounts-base'; -import { CachedCollectionManager } from '../lib/cachedCollections'; +import { CachedStoresManager } from '../lib/cachedStores'; const { _unstoreLoginToken } = Accounts; Accounts._unstoreLoginToken = (...args) => { _unstoreLoginToken.apply(Accounts, args); - CachedCollectionManager.clearAllCachesOnLogout(); + CachedStoresManager.clearAllCachesOnLogout(); }; diff --git a/apps/meteor/client/meteorOverrides/userAndUsers.ts b/apps/meteor/client/meteorOverrides/userAndUsers.ts index 84bd85ff38d2f..c90c98579301f 100644 --- a/apps/meteor/client/meteorOverrides/userAndUsers.ts +++ b/apps/meteor/client/meteorOverrides/userAndUsers.ts @@ -1,14 +1,40 @@ -import { Users } from '../../app/models/client/models/Users'; +import type { IUser } from '@rocket.chat/core-typings'; +import { Tracker } from 'meteor/tracker'; -Meteor.users = Users as typeof Meteor.users; +import { Users } from '../stores/Users'; -// overwrite Meteor.users collection so records on it don't get erased whenever the client reconnects to websocket -Meteor.user = function user(): Meteor.User | null { +// assertion is needed because global Mongo.Collection differs from the `meteor/mongo` package's Mongo.Collection +Meteor.users = Users.collection as typeof Meteor.users; + +const dep = new Tracker.Dependency(); +let currentUser: IUser | undefined; + +// Watch Meteor.userId() changes +Tracker.autorun(() => { const uid = Meteor.userId(); - if (!uid) { - return null; + // This will only run when the current user has changed; there is no need to validate by referential equality + currentUser = uid ? Users.state.get(uid) : undefined; + dep.changed(); +}); + +// Watch user store changes +Users.use.subscribe((state) => { + // Tracker.nonreactive is used here just to highlight that this is not a reactive computation. + // At the module level, there is almost zero chance of Tracker.active being set. + const uid = Tracker.nonreactive(() => Meteor.userId()); + + // This lookup is fast enough to be called whenever the user store changes + const user = uid ? state.get(uid) : undefined; + + if (user !== currentUser) { + currentUser = user; + dep.changed(); } +}); - return (Users.findOne({ _id: uid }) ?? null) as Meteor.User | null; +// overwrite Meteor.users collection so records on it don't get erased whenever the client reconnects to websocket +Meteor.user = function user(): Meteor.User | null { + dep.depend(); + return (currentUser ?? null) as Meteor.User | null; }; diff --git a/apps/meteor/client/omnichannel/additionalForms/SlaPoliciesSelect.tsx b/apps/meteor/client/omnichannel/additionalForms/SlaPoliciesSelect.tsx index b764b75084251..a8e6d0faacd6f 100644 --- a/apps/meteor/client/omnichannel/additionalForms/SlaPoliciesSelect.tsx +++ b/apps/meteor/client/omnichannel/additionalForms/SlaPoliciesSelect.tsx @@ -1,7 +1,7 @@ import type { IOmnichannelServiceLevelAgreements, Serialized } from '@rocket.chat/core-typings'; import type { SelectOption } from '@rocket.chat/fuselage'; import { Field, FieldLabel, FieldRow, Select } from '@rocket.chat/fuselage'; -import { useMemo } from 'react'; +import { useId, useMemo } from 'react'; import { useHasLicenseModule } from '../../hooks/useHasLicenseModule'; @@ -15,6 +15,7 @@ type SlaPoliciesSelectProps = { export const SlaPoliciesSelect = ({ value, label, options, onChange }: SlaPoliciesSelectProps) => { const hasLicense = useHasLicenseModule('livechat-enterprise'); const optionsSelect = useMemo(() => options?.map((option) => [option._id, option.name]), [options]); + const fieldId = useId(); if (!hasLicense) { return null; @@ -22,9 +23,9 @@ export const SlaPoliciesSelect = ({ value, label, options, onChange }: SlaPolici return ( - {label} + {label} - onChange(String(value))} /> ); diff --git a/apps/meteor/client/omnichannel/cannedResponses/CannedResponseEdit.tsx b/apps/meteor/client/omnichannel/cannedResponses/CannedResponseEdit.tsx index b804765482a88..550904a294eb8 100644 --- a/apps/meteor/client/omnichannel/cannedResponses/CannedResponseEdit.tsx +++ b/apps/meteor/client/omnichannel/cannedResponses/CannedResponseEdit.tsx @@ -77,7 +77,7 @@ const CannedResponseEdit = ({ cannedResponseData }: CannedResponseEditProps) => return ( router.navigate('/omnichannel/canned-responses')} > {cannedResponseData?._id && ( diff --git a/apps/meteor/client/omnichannel/cannedResponses/components/CannedResponseForm.tsx b/apps/meteor/client/omnichannel/cannedResponses/components/CannedResponseForm.tsx index 9b300be1f3738..bc2ec4f2dcdd2 100644 --- a/apps/meteor/client/omnichannel/cannedResponses/components/CannedResponseForm.tsx +++ b/apps/meteor/client/omnichannel/cannedResponses/components/CannedResponseForm.tsx @@ -121,7 +121,6 @@ const CannedResponseForm = () => { disabled={hasMonitorPermission && !hasManagerPermission} checked={value === 'global'} aria-describedby={`${publicRadioField}-hint`} - data-qa-id='canned-response-public-radio' /> )} /> diff --git a/apps/meteor/client/omnichannel/hooks/useOmnichannelPrioritiesConfig.ts b/apps/meteor/client/omnichannel/hooks/useOmnichannelPrioritiesConfig.ts new file mode 100644 index 0000000000000..dd9823613a7d8 --- /dev/null +++ b/apps/meteor/client/omnichannel/hooks/useOmnichannelPrioritiesConfig.ts @@ -0,0 +1,64 @@ +import { LivechatPriorityWeight } from '@rocket.chat/core-typings'; +import { Palette } from '@rocket.chat/fuselage'; +import type { Keys } from '@rocket.chat/icons'; +import type { TranslationKey } from '@rocket.chat/ui-contexts'; +import { useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; + +import { useOmnichannelPriorities } from './useOmnichannelPriorities'; + +type PrioritiesConfig = { iconName: Keys; color?: string; variant?: 'secondary-danger' | 'secondary-warning' | 'secondary-info' }; + +export const PRIORITIES_CONFIG: Record = { + [LivechatPriorityWeight.NOT_SPECIFIED]: { + iconName: 'circle-unfilled', + }, + [LivechatPriorityWeight.HIGHEST]: { + iconName: 'chevron-double-up', + color: Palette.badge['badge-background-level-4'].toString(), + variant: 'secondary-danger', + }, + [LivechatPriorityWeight.HIGH]: { + iconName: 'chevron-up', + color: Palette.badge['badge-background-level-4'].toString(), + variant: 'secondary-danger', + }, + [LivechatPriorityWeight.MEDIUM]: { + iconName: 'equal', + color: Palette.badge['badge-background-level-3'].toString(), + variant: 'secondary-warning', + }, + [LivechatPriorityWeight.LOW]: { + iconName: 'chevron-down', + color: Palette.badge['badge-background-level-2'].toString(), + variant: 'secondary-info', + }, + [LivechatPriorityWeight.LOWEST]: { + iconName: 'chevron-double-down', + color: Palette.badge['badge-background-level-2'].toString(), + variant: 'secondary-info', + }, +}; + +export const useOmnichannelPrioritiesConfig = (level: LivechatPriorityWeight, showUnprioritized: boolean) => { + const { t } = useTranslation(); + + const { iconName, color, variant } = PRIORITIES_CONFIG[level]; + const { data: priorities } = useOmnichannelPriorities(); + + const name = useMemo(() => { + const { _id, dirty, name, i18n } = priorities.find((p) => p.sortItem === level) || {}; + + if (!_id) { + return ''; + } + + return dirty ? name : t(i18n as TranslationKey); + }, [level, priorities, t]); + + if (!showUnprioritized && level === LivechatPriorityWeight.NOT_SPECIFIED) { + return null; + } + + return { iconName, name, color, variant }; +}; diff --git a/apps/meteor/client/omnichannel/hooks/useOmnichannelPrioritiesMenu.tsx b/apps/meteor/client/omnichannel/hooks/useOmnichannelPrioritiesMenu.tsx index 2477341565dfa..3ccfd5d12bea0 100644 --- a/apps/meteor/client/omnichannel/hooks/useOmnichannelPrioritiesMenu.tsx +++ b/apps/meteor/client/omnichannel/hooks/useOmnichannelPrioritiesMenu.tsx @@ -1,14 +1,13 @@ import type { IRoom } from '@rocket.chat/core-typings'; import { LivechatPriorityWeight } from '@rocket.chat/core-typings'; -import { useEndpoint } from '@rocket.chat/ui-contexts'; +import { useEndpoint, useToastMessageDispatch } from '@rocket.chat/ui-contexts'; import { useQueryClient } from '@tanstack/react-query'; import { useMemo } from 'react'; import { useTranslation } from 'react-i18next'; import { useOmnichannelPriorities } from './useOmnichannelPriorities'; +import { PRIORITIES_CONFIG } from './useOmnichannelPrioritiesConfig'; import { roomsQueryKeys } from '../../lib/queryKeys'; -import { dispatchToastMessage } from '../../lib/toast'; -import { PRIORITY_ICONS } from '../priorities/PriorityIcon'; export const useOmnichannelPrioritiesMenu = (rid: IRoom['_id']) => { const { t } = useTranslation(); @@ -16,6 +15,7 @@ export const useOmnichannelPrioritiesMenu = (rid: IRoom['_id']) => { const updateRoomPriority = useEndpoint('POST', '/v1/livechat/room/:rid/priority', { rid }); const removeRoomPriority = useEndpoint('DELETE', '/v1/livechat/room/:rid/priority', { rid }); const { data: priorities } = useOmnichannelPriorities(); + const dispatchToastMessage = useToastMessageDispatch(); return useMemo(() => { const handlePriorityChange = (priorityId: string) => async () => { @@ -30,8 +30,8 @@ export const useOmnichannelPrioritiesMenu = (rid: IRoom['_id']) => { const unprioritizedOption = { id: 'unprioritized', - icon: PRIORITY_ICONS[LivechatPriorityWeight.NOT_SPECIFIED].iconName, - iconColor: PRIORITY_ICONS[LivechatPriorityWeight.NOT_SPECIFIED].color, + icon: PRIORITIES_CONFIG[LivechatPriorityWeight.NOT_SPECIFIED].iconName, + iconColor: PRIORITIES_CONFIG[LivechatPriorityWeight.NOT_SPECIFIED].color, content: t('Unprioritized'), onClick: handlePriorityChange(''), }; @@ -41,8 +41,8 @@ export const useOmnichannelPrioritiesMenu = (rid: IRoom['_id']) => { return { id: priorityId, - icon: PRIORITY_ICONS[sortItem].iconName, - iconColor: PRIORITY_ICONS[sortItem].color, + icon: PRIORITIES_CONFIG[sortItem].iconName, + iconColor: PRIORITIES_CONFIG[sortItem].color, content: label, onClick: handlePriorityChange(priorityId), }; diff --git a/apps/meteor/client/omnichannel/monitors/MonitorsTable.tsx b/apps/meteor/client/omnichannel/monitors/MonitorsTable.tsx index d6de11c830805..db0588149d32e 100644 --- a/apps/meteor/client/omnichannel/monitors/MonitorsTable.tsx +++ b/apps/meteor/client/omnichannel/monitors/MonitorsTable.tsx @@ -170,7 +170,7 @@ const MonitorsTable = () => { )} {isSuccess && data.monitors.length > 0 && ( <> - + {headers} {data.monitors?.map((monitor) => ( diff --git a/apps/meteor/client/omnichannel/priorities/PriorityIcon.tsx b/apps/meteor/client/omnichannel/priorities/PriorityIcon.tsx index e41ef88a24150..ebce3561a5661 100644 --- a/apps/meteor/client/omnichannel/priorities/PriorityIcon.tsx +++ b/apps/meteor/client/omnichannel/priorities/PriorityIcon.tsx @@ -1,62 +1,20 @@ -import { LivechatPriorityWeight } from '@rocket.chat/core-typings'; -import { Icon, Palette } from '@rocket.chat/fuselage'; -import type { Keys } from '@rocket.chat/icons'; -import type { TranslationKey } from '@rocket.chat/ui-contexts'; +import type { LivechatPriorityWeight } from '@rocket.chat/core-typings'; +import { Icon } from '@rocket.chat/fuselage'; import type { ComponentProps, ReactElement } from 'react'; -import { useMemo } from 'react'; -import { useTranslation } from 'react-i18next'; -import { useOmnichannelPriorities } from '../hooks/useOmnichannelPriorities'; +import { useOmnichannelPrioritiesConfig } from '../hooks/useOmnichannelPrioritiesConfig'; type PriorityIconProps = Omit, 'name' | 'color'> & { level: LivechatPriorityWeight; showUnprioritized?: boolean; }; -export const PRIORITY_ICONS: Record = { - [LivechatPriorityWeight.NOT_SPECIFIED]: { - iconName: 'circle-unfilled', - }, - [LivechatPriorityWeight.HIGHEST]: { - iconName: 'chevron-double-up', - color: Palette.badge['badge-background-level-4'].toString(), - }, - [LivechatPriorityWeight.HIGH]: { - iconName: 'chevron-up', - color: Palette.badge['badge-background-level-4'].toString(), - }, - [LivechatPriorityWeight.MEDIUM]: { - iconName: 'equal', - color: Palette.badge['badge-background-level-3'].toString(), - }, - [LivechatPriorityWeight.LOW]: { - iconName: 'chevron-down', - color: Palette.badge['badge-background-level-2'].toString(), - }, - [LivechatPriorityWeight.LOWEST]: { - iconName: 'chevron-double-down', - color: Palette.badge['badge-background-level-2'].toString(), - }, -}; - export const PriorityIcon = ({ level, size = 20, showUnprioritized = false, ...props }: PriorityIconProps): ReactElement | null => { - const { t } = useTranslation(); - const { iconName, color } = PRIORITY_ICONS[level] || {}; - const { data: priorities } = useOmnichannelPriorities(); - - const name = useMemo(() => { - const { _id, dirty, name, i18n } = priorities.find((p) => p.sortItem === level) || {}; - - if (!_id) { - return ''; - } - - return dirty ? name : t(i18n as TranslationKey); - }, [level, priorities, t]); + const prioritiesConfig = useOmnichannelPrioritiesConfig(level, showUnprioritized); - if (!showUnprioritized && level === LivechatPriorityWeight.NOT_SPECIFIED) { + if (!prioritiesConfig) { return null; } - return iconName ? : null; + return ; }; diff --git a/apps/meteor/client/sidebar/SidebarPortal.tsx b/apps/meteor/client/portals/SidebarPortal/SidebarPortal.tsx similarity index 100% rename from apps/meteor/client/sidebar/SidebarPortal.tsx rename to apps/meteor/client/portals/SidebarPortal/SidebarPortal.tsx diff --git a/apps/meteor/client/portals/SidebarPortal/SidebarPortalV2.tsx b/apps/meteor/client/portals/SidebarPortal/SidebarPortalV2.tsx new file mode 100644 index 0000000000000..9160b205b426a --- /dev/null +++ b/apps/meteor/client/portals/SidebarPortal/SidebarPortalV2.tsx @@ -0,0 +1,39 @@ +import { Box, AnimatedVisibility } from '@rocket.chat/fuselage'; +import { useLayout } from '@rocket.chat/ui-contexts'; +import type { ReactNode } from 'react'; +import { memo, useEffect } from 'react'; +import { createPortal } from 'react-dom'; + +import { NAVIGATION_REGION_ID } from '../../lib/constants'; + +type SidebarPortalProps = { children?: ReactNode }; + +const SidebarPortal = ({ children }: SidebarPortalProps) => { + const sidebarRoot = document.getElementById(NAVIGATION_REGION_ID); + const { sidebar } = useLayout(); + + useEffect(() => { + if (sidebarRoot) { + sidebar.setOverlayed(true); + } + + return () => sidebar.setOverlayed(false); + }, [sidebar, sidebarRoot]); + + if (!sidebarRoot) { + return null; + } + + return ( + <> + {createPortal( + + {children} + , + sidebarRoot, + )} + + ); +}; + +export default memo(SidebarPortal); diff --git a/apps/meteor/client/portals/SidebarPortal/index.tsx b/apps/meteor/client/portals/SidebarPortal/index.tsx new file mode 100644 index 0000000000000..9f253b274013f --- /dev/null +++ b/apps/meteor/client/portals/SidebarPortal/index.tsx @@ -0,0 +1,20 @@ +import { FeaturePreview, FeaturePreviewOff, FeaturePreviewOn } from '@rocket.chat/ui-client'; +import type { ReactNode } from 'react'; + +import SidebarPortalV1 from './SidebarPortal'; +import SidebarPortalV2 from './SidebarPortalV2'; + +const SidebarPortal = ({ children }: { children: ReactNode }) => { + return ( + + + + + + + + + ); +}; + +export default SidebarPortal; diff --git a/apps/meteor/client/providers/AuthenticationProvider/AuthenticationProvider.tsx b/apps/meteor/client/providers/AuthenticationProvider/AuthenticationProvider.tsx index 42e55b15d3b9a..ca5edfdc599f3 100644 --- a/apps/meteor/client/providers/AuthenticationProvider/AuthenticationProvider.tsx +++ b/apps/meteor/client/providers/AuthenticationProvider/AuthenticationProvider.tsx @@ -7,6 +7,7 @@ import type { ContextType, ReactElement, ReactNode } from 'react'; import { useMemo } from 'react'; import { useLDAPAndCrowdCollisionWarning } from './hooks/useLDAPAndCrowdCollisionWarning'; +import { useReactiveValue } from '../../hooks/useReactiveValue'; import { loginServices } from '../../lib/loginServices'; export type LoginMethods = keyof typeof Meteor extends infer T ? (T extends `loginWith${string}` ? T : never) : never; @@ -25,6 +26,8 @@ const callLoginMethod = ( }); }; +const getLoggingIn = () => Accounts.loggingIn(); + const AuthenticationProvider = ({ children }: AuthenticationProviderProps): ReactElement => { const isLdapEnabled = useSetting('LDAP_Enable', false); const isCrowdEnabled = useSetting('CROWD_Enable', false); @@ -33,8 +36,11 @@ const AuthenticationProvider = ({ children }: AuthenticationProviderProps): Reac useLDAPAndCrowdCollisionWarning(); + const isLoggingIn = useReactiveValue(getLoggingIn); + const contextValue = useMemo( (): ContextType => ({ + isLoggingIn, loginWithToken: (token: string): Promise => new Promise((resolve, reject) => Meteor.loginWithToken(token, (err) => { @@ -119,7 +125,7 @@ const AuthenticationProvider = ({ children }: AuthenticationProviderProps): Reac subscribe: (onStoreChange: () => void) => loginServices.on('changed', onStoreChange), }, }), - [loginMethod], + [isLoggingIn, loginMethod], ); return ; diff --git a/apps/meteor/client/providers/AuthorizationProvider.tsx b/apps/meteor/client/providers/AuthorizationProvider.tsx index a0951c2160e65..d03b4bf8ff63f 100644 --- a/apps/meteor/client/providers/AuthorizationProvider.tsx +++ b/apps/meteor/client/providers/AuthorizationProvider.tsx @@ -4,17 +4,15 @@ import type { ReactNode } from 'react'; import { useEffect } from 'react'; import { hasPermission, hasAtLeastOnePermission, hasAllPermission, hasRole } from '../../app/authorization/client'; -import { Roles, AuthzCachedCollection } from '../../app/models/client'; +import { PermissionsCachedStore } from '../cachedStores'; import { createReactiveSubscriptionFactory } from '../lib/createReactiveSubscriptionFactory'; +import { Roles } from '../stores'; const contextValue = { queryPermission: createReactiveSubscriptionFactory((permission, scope, scopeRoles) => hasPermission(permission, scope, scopeRoles)), queryAtLeastOnePermission: createReactiveSubscriptionFactory((permissions, scope) => hasAtLeastOnePermission(permissions, scope)), queryAllPermissions: createReactiveSubscriptionFactory((permissions, scope) => hasAllPermission(permissions, scope)), - queryRole: createReactiveSubscriptionFactory( - (role, scope?, ignoreSubscriptions = false) => - !!Meteor.userId() && hasRole(Meteor.userId() as string, role, scope, ignoreSubscriptions), - ), + queryRole: createReactiveSubscriptionFactory((role, scope?) => !!Meteor.userId() && hasRole(Meteor.userId() as string, role, scope)), getRoles: () => Roles.state.records, subscribeToRoles: (callback: () => void) => Roles.use.subscribe(callback), }; @@ -25,9 +23,17 @@ type AuthorizationProviderProps = { const AuthorizationProvider = ({ children }: AuthorizationProviderProps) => { useEffect(() => { - AuthzCachedCollection.listen(); + PermissionsCachedStore.listen(); }, []); + const isLoading = !PermissionsCachedStore.useReady(); + + if (isLoading) { + throw (async () => { + await PermissionsCachedStore.init(); + })(); + } + return ; }; diff --git a/apps/meteor/client/providers/ConnectionStatusProvider.tsx b/apps/meteor/client/providers/ConnectionStatusProvider.tsx deleted file mode 100644 index f1a65088cb26a..0000000000000 --- a/apps/meteor/client/providers/ConnectionStatusProvider.tsx +++ /dev/null @@ -1,24 +0,0 @@ -import type { ConnectionStatusContextValue } from '@rocket.chat/ui-contexts'; -import { ConnectionStatusContext } from '@rocket.chat/ui-contexts'; -import { Meteor } from 'meteor/meteor'; -import type { ReactNode } from 'react'; - -import { useReactiveValue } from '../hooks/useReactiveValue'; - -const getValue = (): ConnectionStatusContextValue => ({ - ...Meteor.status(), - reconnect: Meteor.reconnect, - isLoggingIn: Meteor.loggingIn(), -}); - -type ConnectionStatusProviderProps = { - children?: ReactNode; -}; - -const ConnectionStatusProvider = ({ children }: ConnectionStatusProviderProps) => { - const status = useReactiveValue(getValue); - - return ; -}; - -export default ConnectionStatusProvider; diff --git a/apps/meteor/client/providers/CustomSoundProvider/CustomSoundProvider.tsx b/apps/meteor/client/providers/CustomSoundProvider/CustomSoundProvider.tsx index 82c12f4febb83..e06dd7a354a19 100644 --- a/apps/meteor/client/providers/CustomSoundProvider/CustomSoundProvider.tsx +++ b/apps/meteor/client/providers/CustomSoundProvider/CustomSoundProvider.tsx @@ -76,6 +76,8 @@ const CustomSoundProvider = ({ children }: CustomSoundProviderProps) => { const notificationSounds = { playNewRoom: () => play(newRoomNotification, { loop: false, volume: formatVolume(notificationsSoundVolume) }), playNewMessage: () => play(newMessageNotification, { loop: false, volume: formatVolume(notificationsSoundVolume) }), + playNewMessageCustom: (soundId: ICustomSound['_id']) => + play(soundId, { loop: false, volume: formatVolume(notificationsSoundVolume) }), playNewMessageLoop: () => play(newMessageNotification, { loop: true, volume: formatVolume(notificationsSoundVolume) }), stopNewRoom: () => stop(newRoomNotification), stopNewMessage: () => stop(newMessageNotification), diff --git a/apps/meteor/client/providers/LayoutProvider.tsx b/apps/meteor/client/providers/LayoutProvider.tsx index 92b5b5d7871ff..fbfdbbf3cf62e 100644 --- a/apps/meteor/client/providers/LayoutProvider.tsx +++ b/apps/meteor/client/providers/LayoutProvider.tsx @@ -18,10 +18,13 @@ type LayoutProviderProps = { const LayoutProvider = ({ children }: LayoutProviderProps) => { const showTopNavbarEmbeddedLayout = useSetting('UI_Show_top_navbar_embedded_layout', false); const [isCollapsed, setIsCollapsed] = useState(false); + const [displaySidePanel, setDisplaySidePanel] = useState(true); + const [overlayed, setOverlayed] = useState(false); const [navBarSearchExpanded, setNavBarSearchExpanded] = useState(false); const breakpoints = useBreakpoints(); // ["xs", "sm", "md", "lg", "xl", xxl"] const [hiddenActions, setHiddenActions] = useState(hiddenActionsDefaultValue); const enhancedNavigationEnabled = useFeaturePreview('newNavigation'); + const secondSidebarEnabled = useFeaturePreview('secondarySidebar'); const router = useRouter(); // Once the layout is embedded, it can't be changed @@ -31,6 +34,8 @@ const LayoutProvider = ({ children }: LayoutProviderProps) => { const isTablet = !breakpoints.includes('lg'); const shouldToggle = enhancedNavigationEnabled ? isTablet || isMobile : isMobile; + const shouldDisplaySidePanel = !isTablet || displaySidePanel; + const defaultSidebarWidth = secondSidebarEnabled ? '220px' : '240px'; useEffect(() => { setIsCollapsed(shouldToggle); @@ -63,14 +68,21 @@ const LayoutProvider = ({ children }: LayoutProviderProps) => { collapseSearch: isMobile ? () => setNavBarSearchExpanded(false) : undefined, }, sidebar: { + overlayed, + setOverlayed, isCollapsed, toggle: shouldToggle ? () => setIsCollapsed((isCollapsed) => !isCollapsed) : () => undefined, collapse: () => setIsCollapsed(true), expand: () => setIsCollapsed(false), close: () => (isEmbedded ? setIsCollapsed(true) : router.navigate('/home')), }, + sidePanel: { + displaySidePanel: shouldDisplaySidePanel, + closeSidePanel: () => setDisplaySidePanel(false), + openSidePanel: () => setDisplaySidePanel(true), + }, size: { - sidebar: '240px', + sidebar: isTablet ? '280px' : defaultSidebarWidth, // eslint-disable-next-line no-nested-ternary contextualBar: breakpoints.includes('sm') ? (breakpoints.includes('xl') ? '38%' : '380px') : '100%', }, @@ -83,11 +95,14 @@ const LayoutProvider = ({ children }: LayoutProviderProps) => { [ isMobile, isTablet, - navBarSearchExpanded, isEmbedded, showTopNavbarEmbeddedLayout, + navBarSearchExpanded, + overlayed, isCollapsed, shouldToggle, + shouldDisplaySidePanel, + defaultSidebarWidth, breakpoints, hiddenActions, router, diff --git a/apps/meteor/client/providers/MeteorProvider.tsx b/apps/meteor/client/providers/MeteorProvider.tsx index a8faabf0ff90f..8f06aeb393c23 100644 --- a/apps/meteor/client/providers/MeteorProvider.tsx +++ b/apps/meteor/client/providers/MeteorProvider.tsx @@ -7,7 +7,6 @@ import AuthenticationProvider from './AuthenticationProvider/AuthenticationProvi import AuthorizationProvider from './AuthorizationProvider'; import AvatarUrlProvider from './AvatarUrlProvider'; import { CallProvider as OmnichannelCallProvider } from './CallProvider'; -import ConnectionStatusProvider from './ConnectionStatusProvider'; import CustomSoundProvider from './CustomSoundProvider'; import { DeviceProvider } from './DeviceProvider/DeviceProvider'; import EmojiPickerProvider from './EmojiPickerProvider'; @@ -30,53 +29,51 @@ type MeteorProviderProps = { }; const MeteorProvider = ({ children }: MeteorProviderProps) => ( - - - - - - - - - - - - - - - - - - - - - - - - {children} - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + {children} + + + + + + + + + + + + + + + + + + + + + + ); export default MeteorProvider; diff --git a/apps/meteor/client/providers/RouterProvider.tsx b/apps/meteor/client/providers/RouterProvider.tsx index 0d6e05b52b3ec..099835e672894 100644 --- a/apps/meteor/client/providers/RouterProvider.tsx +++ b/apps/meteor/client/providers/RouterProvider.tsx @@ -1,210 +1,11 @@ -import type { RoomType, RoomRouteData } from '@rocket.chat/core-typings'; import { RouterContext } from '@rocket.chat/ui-contexts'; -import type { - RouterContextValue, - RouteName, - LocationPathname, - RouteParameters, - SearchParameters, - To, - RouteObject, - LocationSearch, -} from '@rocket.chat/ui-contexts'; -import { FlowRouter } from 'meteor/ostrio:flow-router-extra'; -import { Tracker } from 'meteor/tracker'; +import type { RouterContextValue } from '@rocket.chat/ui-contexts'; import type { ReactNode } from 'react'; -import { appLayout } from '../lib/appLayout'; -import { roomCoordinator } from '../lib/rooms/roomCoordinator'; -import { queueMicrotask } from '../lib/utils/queueMicrotask'; +import { Router } from '../router'; -const subscribers = new Set<() => void>(); - -const listenToRouteChange = () => { - FlowRouter.watchPathChange(); - subscribers.forEach((onRouteChange) => onRouteChange()); -}; - -let computation: Tracker.Computation | undefined; - -queueMicrotask(() => { - computation = Tracker.autorun(listenToRouteChange); -}); - -const subscribeToRouteChange = (onRouteChange: () => void): (() => void) => { - subscribers.add(onRouteChange); - - computation?.invalidate(); - - return () => { - subscribers.delete(onRouteChange); - - if (subscribers.size === 0) { - queueMicrotask(() => computation?.stop()); - } - }; -}; - -const getLocationPathname = () => FlowRouter.current().path.replace(/\?.*/, '') as LocationPathname; - -const getLocationSearch = () => location.search as LocationSearch; - -const getRouteParameters = () => (FlowRouter.current().params ?? {}) as RouteParameters; - -const getSearchParameters = () => (FlowRouter.current().queryParams ?? {}) as SearchParameters; - -const getRouteName = () => FlowRouter.current().route?.name as RouteName | undefined; - -const encodeSearchParameters = (searchParameters: SearchParameters) => { - const search = new URLSearchParams(); - - for (const [key, value] of Object.entries(searchParameters)) { - search.append(key, value); - } - - const searchString = search.toString(); - - return searchString ? `?${searchString}` : ''; -}; - -const buildRoutePath = (to: To): LocationPathname | `${LocationPathname}?${LocationSearch}` => { - if (typeof to === 'string') { - return to; - } - - if ('pathname' in to) { - const { pathname, search = {} } = to; - return (pathname + encodeSearchParameters(search)) as LocationPathname | `${LocationPathname}?${LocationSearch}`; - } - - if ('pattern' in to) { - const { pattern, params = {}, search = {} } = to; - return Tracker.nonreactive(() => FlowRouter.path(pattern, params, search)) as - | LocationPathname - | `${LocationPathname}?${LocationSearch}`; - } - - if ('name' in to) { - const { name, params = {}, search = {} } = to; - return Tracker.nonreactive(() => FlowRouter.path(name, params, search)) as LocationPathname | `${LocationPathname}?${LocationSearch}`; - } - - throw new Error('Invalid route'); -}; - -const navigate = ( - toOrDelta: To | number, - options?: { - replace?: boolean; - }, -) => { - if (typeof toOrDelta === 'number') { - history.go(toOrDelta); - return; - } - - const path = buildRoutePath(toOrDelta); - const state = { path }; - - if (options?.replace) { - history.replaceState(state, '', path); - } else { - history.pushState(state, '', path); - } - - dispatchEvent(new PopStateEvent('popstate', { state })); -}; - -const routes: RouteObject[] = []; -const routesSubscribers = new Set<() => void>(); - -const updateFlowRouter = () => { - if (FlowRouter._initialized) { - FlowRouter._updateCallbacks(); - FlowRouter._page.dispatch(new FlowRouter._page.Context(FlowRouter._current.path)); - return; - } - - FlowRouter.initialize({ - hashbang: false, - page: { - click: true, - }, - }); -}; - -const defineRoutes = (routes: RouteObject[]) => { - const flowRoutes = routes.map((route) => { - if (route.path === '*') { - FlowRouter.notFound = { - action: () => appLayout.render(<>{route.element}), - }; - - return FlowRouter.notFound; - } - - return FlowRouter.route(route.path, { - name: route.id, - action: () => appLayout.render(<>{route.element}), - }); - }); - - routes.push(...routes); - const index = routes.length - 1; - - updateFlowRouter(); - routesSubscribers.forEach((onRoutesChange) => onRoutesChange()); - - return () => { - flowRoutes.forEach((flowRoute) => { - FlowRouter._routes = FlowRouter._routes.filter((r) => r !== flowRoute); - if ('name' in flowRoute && flowRoute.name) { - delete FlowRouter._routesMap[flowRoute.name]; - } else { - FlowRouter.notFound = { - action: () => appLayout.render(<>), - }; - } - }); - - if (index !== -1) { - routes.splice(index, 1); - } - - updateFlowRouter(); - routesSubscribers.forEach((onRoutesChange) => onRoutesChange()); - }; -}; - -const getRoutes = () => routes; - -const subscribeToRoutesChange = (onRoutesChange: () => void): (() => void) => { - routesSubscribers.add(onRoutesChange); - - onRoutesChange(); - - return () => { - routesSubscribers.delete(onRoutesChange); - }; -}; - -/** @deprecated */ -export const router: RouterContextValue = { - subscribeToRouteChange, - getLocationPathname, - getLocationSearch, - getRouteParameters, - getSearchParameters, - getRouteName, - buildRoutePath, - navigate, - defineRoutes, - getRoutes, - subscribeToRoutesChange, - getRoomRoute(roomType: RoomType, routeData: RoomRouteData) { - return { path: roomCoordinator.getRouteLink(roomType, routeData) || '/' }; - }, -}; +/** @deprecated consume it from the `RouterContext` instead */ +export const router: RouterContextValue = new Router(); type RouterProviderProps = { children?: ReactNode; diff --git a/apps/meteor/client/providers/ServerProvider.tsx b/apps/meteor/client/providers/ServerProvider.tsx index 85df8de5f6e51..fd719cf5aaebd 100644 --- a/apps/meteor/client/providers/ServerProvider.tsx +++ b/apps/meteor/client/providers/ServerProvider.tsx @@ -8,14 +8,15 @@ import type { StreamKeys, } from '@rocket.chat/ddp-client'; import type { Method, PathFor, OperationParams, OperationResult, UrlParams, PathPattern } from '@rocket.chat/rest-typings'; -import type { UploadResult } from '@rocket.chat/ui-contexts'; +import type { UploadResult, ServerContextValue } from '@rocket.chat/ui-contexts'; import { ServerContext } from '@rocket.chat/ui-contexts'; import { Meteor } from 'meteor/meteor'; import { compile } from 'path-to-regexp'; -import type { ReactNode } from 'react'; +import { useMemo, type ReactNode } from 'react'; import { sdk } from '../../app/utils/client/lib/SDKClient'; import { Info as info } from '../../app/utils/rocketchat.info'; +import { useReactiveValue } from '../hooks/useReactiveValue'; const absoluteUrl = (path: string): string => Meteor.absoluteUrl(path); @@ -68,19 +69,36 @@ const getStream = >(eventName: K, callback: (...args: StreamerCallbackArgs) => void): (() => void) => sdk.stream(streamName, [eventName], callback).stop; -const contextValue = { - info, - absoluteUrl, - callMethod, - callEndpoint, - uploadToEndpoint, - getStream, - disconnect: () => Meteor.disconnect(), - reconnect: () => Meteor.reconnect(), -}; +const disconnect = () => Meteor.disconnect(); + +const reconnect = () => Meteor.reconnect(); + +const getStatus = () => ({ ...Meteor.status() }); type ServerProviderProps = { children?: ReactNode }; -const ServerProvider = ({ children }: ServerProviderProps) => ; +const ServerProvider = ({ children }: ServerProviderProps) => { + const { connected, status, retryCount, retryTime } = useReactiveValue(getStatus); + + const value = useMemo( + (): ServerContextValue => ({ + connected, + status, + retryCount, + retryTime, + info, + absoluteUrl, + callMethod, + callEndpoint, + uploadToEndpoint, + getStream, + disconnect, + reconnect, + }), + [connected, retryCount, retryTime, status], + ); + + return ; +}; export default ServerProvider; diff --git a/apps/meteor/client/providers/SettingsProvider.tsx b/apps/meteor/client/providers/SettingsProvider.tsx index 96f7c9c970fed..df0ba3a8e2a3d 100644 --- a/apps/meteor/client/providers/SettingsProvider.tsx +++ b/apps/meteor/client/providers/SettingsProvider.tsx @@ -1,14 +1,13 @@ import type { ISetting } from '@rocket.chat/core-typings'; -import type { SettingsContextValue } from '@rocket.chat/ui-contexts'; +import { createPredicateFromFilter } from '@rocket.chat/mongo-adapter'; +import type { SettingsContextQuery, SettingsContextValue } from '@rocket.chat/ui-contexts'; import { SettingsContext, useAtLeastOnePermission, useMethod } from '@rocket.chat/ui-contexts'; import { useQueryClient } from '@tanstack/react-query'; -import { Tracker } from 'meteor/tracker'; import type { ReactNode } from 'react'; import { useCallback, useMemo } from 'react'; -import { createReactiveSubscriptionFactory } from '../lib/createReactiveSubscriptionFactory'; -import { PrivateSettingsCachedCollection } from '../lib/settings/PrivateSettingsCachedCollection'; -import { PublicSettingsCachedCollection } from '../lib/settings/PublicSettingsCachedCollection'; +import { PublicSettingsCachedStore, PrivateSettingsCachedStore } from '../cachedStores'; +import { applyQueryOptions } from '../lib/cachedStores'; const settingsManagementPermissions = ['view-privileged-setting', 'edit-privileged-setting', 'manage-selected-settings']; @@ -19,9 +18,9 @@ type SettingsProviderProps = { const SettingsProvider = ({ children }: SettingsProviderProps) => { const canManageSettings = useAtLeastOnePermission(settingsManagementPermissions); - const cachedCollection = canManageSettings ? PrivateSettingsCachedCollection : PublicSettingsCachedCollection; + const cachedCollection = canManageSettings ? PrivateSettingsCachedStore : PublicSettingsCachedStore; - const isLoading = Tracker.nonreactive(() => !cachedCollection.ready.get()); + const isLoading = !cachedCollection.useReady(); if (isLoading) { throw (async () => { @@ -31,44 +30,65 @@ const SettingsProvider = ({ children }: SettingsProviderProps) => { const querySetting = useMemo( () => - createReactiveSubscriptionFactory((_id): ISetting | undefined => { - const subscription = cachedCollection.collection.findOne(_id); - return subscription ? { ...subscription } : undefined; - }), - [cachedCollection], - ); + (_id: ISetting['_id']): [subscribe: (onStoreChange: () => void) => () => void, getSnapshot: () => ISetting | undefined] => { + let snapshot = cachedCollection.store.getState().get(_id); - const querySettings = useMemo( - () => - createReactiveSubscriptionFactory((query = {}) => - cachedCollection.collection - .find( - { - ...('_id' in query && Array.isArray(query._id) && { _id: { $in: query._id } }), - ...('_id' in query && !Array.isArray(query._id) && { _id: query._id }), - ...('group' in query && { group: query.group }), - ...('section' in query && - (query.section - ? { section: query.section } - : { - $or: [{ section: { $exists: false } }, { section: undefined }], - })), - }, - { - sort: { - section: 1, - sorter: 1, - i18nLabel: 1, - }, - ...('skip' in query && typeof query.skip === 'number' && { skip: query.skip }), - ...('limit' in query && typeof query.limit === 'number' && { limit: query.limit }), - }, - ) - .fetch(), - ), - [cachedCollection], + const subscribe = (onStoreChange: () => void) => + cachedCollection.store.subscribe(() => { + const newSnapshot = cachedCollection.store.getState().get(_id); + if (newSnapshot === snapshot) return; + snapshot = newSnapshot; + onStoreChange(); + }); + + const getSnapshot = () => snapshot; + + return [subscribe, getSnapshot]; + }, + [cachedCollection.store], ); + const querySettings = useMemo(() => { + return (query: SettingsContextQuery): [subscribe: (onStoreChange: () => void) => () => void, getSnapshot: () => ISetting[]] => { + const effectiveQuery = { + ...('_id' in query && Array.isArray(query._id) && { _id: { $in: query._id } }), + ...('_id' in query && !Array.isArray(query._id) && { _id: query._id }), + ...('group' in query && { group: query.group }), + ...('section' in query && + (query.section + ? { section: query.section } + : { + $or: [{ section: { $exists: false } }, { section: undefined }], + })), + }; + + const options = { + sort: { + section: 1, + sorter: 1, + i18nLabel: 1, + }, + ...('skip' in query && typeof query.skip === 'number' && { skip: query.skip }), + ...('limit' in query && typeof query.limit === 'number' && { limit: query.limit }), + } as const; + + const predicate = createPredicateFromFilter(effectiveQuery); + let snapshot = applyQueryOptions(cachedCollection.store.getState().filter(predicate), options); + + const subscribe = (onStoreChange: () => void) => + cachedCollection.store.subscribe(() => { + const newSnapshot = applyQueryOptions(cachedCollection.store.getState().filter(predicate), options); + if (newSnapshot === snapshot) return; + snapshot = newSnapshot; + onStoreChange(); + }); + + const getSnapshot = () => snapshot; + + return [subscribe, getSnapshot]; + }; + }, [cachedCollection.store]); + const queryClient = useQueryClient(); const saveSettings = useMethod('saveSettings'); diff --git a/apps/meteor/client/providers/UserPresenceProvider.tsx b/apps/meteor/client/providers/UserPresenceProvider.tsx index 6936b106e38fc..8c2ca06c9913d 100644 --- a/apps/meteor/client/providers/UserPresenceProvider.tsx +++ b/apps/meteor/client/providers/UserPresenceProvider.tsx @@ -1,15 +1,20 @@ import type { UserPresenceContextValue } from '@rocket.chat/ui-contexts'; import { useSetting, UserPresenceContext } from '@rocket.chat/ui-contexts'; -import type { ReactElement, ReactNode } from 'react'; +import type { ReactNode } from 'react'; import { useMemo, useEffect } from 'react'; import { Presence } from '../lib/presence'; +import { UserPresence } from '../lib/userPresence'; + +export const userPresence = new UserPresence(); type UserPresenceProviderProps = { children?: ReactNode; }; -const UserPresenceProvider = ({ children }: UserPresenceProviderProps): ReactElement => { +const UserPresenceProvider = ({ children }: UserPresenceProviderProps) => { + userPresence.use(); + const usePresenceDisabled = useSetting('Presence_broadcast_disabled', false); useEffect(() => { diff --git a/apps/meteor/client/providers/UserProvider/UserProvider.tsx b/apps/meteor/client/providers/UserProvider/UserProvider.tsx index b887a3775442c..ba2819ae18d3a 100644 --- a/apps/meteor/client/providers/UserProvider/UserProvider.tsx +++ b/apps/meteor/client/providers/UserProvider/UserProvider.tsx @@ -1,4 +1,4 @@ -import type { IRoom, ISubscription, IUser } from '@rocket.chat/core-typings'; +import type { IRoom, IUser } from '@rocket.chat/core-typings'; import { Emitter } from '@rocket.chat/emitter'; import { useLocalStorage } from '@rocket.chat/fuselage-hooks'; import { createPredicateFromFilter } from '@rocket.chat/mongo-adapter'; @@ -17,15 +17,15 @@ import { useDeleteUser } from './hooks/useDeleteUser'; import { useEmailVerificationWarning } from './hooks/useEmailVerificationWarning'; import { useReloadAfterLogin } from './hooks/useReloadAfterLogin'; import { useUpdateAvatar } from './hooks/useUpdateAvatar'; -import { Subscriptions, Rooms } from '../../../app/models/client'; import { getUserPreference } from '../../../app/utils/client'; import { sdk } from '../../../app/utils/client/lib/SDKClient'; import { afterLogoutCleanUpCallback } from '../../../lib/callbacks/afterLogoutCleanUpCallback'; import { useIdleConnection } from '../../hooks/useIdleConnection'; import { useReactiveValue } from '../../hooks/useReactiveValue'; -import { applyQueryOptions } from '../../lib/cachedCollections'; -import type { IDocumentMapStore } from '../../lib/cachedCollections/DocumentMapStore'; +import { applyQueryOptions } from '../../lib/cachedStores'; +import type { IDocumentMapStore } from '../../lib/cachedStores/DocumentMapStore'; import { createReactiveSubscriptionFactory } from '../../lib/createReactiveSubscriptionFactory'; +import { Users, Rooms, Subscriptions } from '../../stores'; import { useSamlInviteToken } from '../../views/invite/hooks/useSamlInviteToken'; const getUser = (): IUser | null => Meteor.user() as IUser | null; @@ -69,8 +69,11 @@ const queryRoom = ( }; const UserProvider = ({ children }: UserProviderProps): ReactElement => { - const user = useReactiveValue(getUser); const userId = useReactiveValue(getUserId); + const user = Users.use((state) => { + if (!userId) return null; + return state.get(userId) ?? null; + }); const previousUserId = useRef(userId); const [userLanguage, setUserLanguage] = useLocalStorage('userLanguage', ''); const [preferedLanguage, setPreferedLanguage] = useLocalStorage('preferedLanguage', ''); @@ -116,6 +119,27 @@ const UserProvider = ({ children }: UserProviderProps): ReactElement => { return userId ? createSubscriptionFactory(Subscriptions.use) : createSubscriptionFactory(Rooms.use); }, [userId]); + const querySubscription = useMemo(() => { + return (query: object): [subscribe: (onStoreChange: () => void) => () => void, getSnapshot: () => SubscriptionWithRoom] => { + const predicate = createPredicateFromFilter(query); + let snapshot = Subscriptions.use.getState().find(predicate); + + const subscribe = (onStoreChange: () => void) => + Subscriptions.use.subscribe(() => { + const newSnapshot = Subscriptions.use.getState().find(predicate); + if (newSnapshot === snapshot) return; + snapshot = newSnapshot; + onStoreChange(); + }); + + // TODO: this type assertion is completely wrong; however, the `useUserSubscriptions` hook might be deleted in + // the future, so we can live with it for now + const getSnapshot = () => snapshot as SubscriptionWithRoom; + + return [subscribe, getSnapshot]; + }; + }, []); + const contextValue = useMemo( (): ContextType => ({ userId, @@ -123,9 +147,7 @@ const UserProvider = ({ children }: UserProviderProps): ReactElement => { queryPreference: createReactiveSubscriptionFactory( (key: string, defaultValue?: T) => getUserPreference(userId, key, defaultValue) as T, ), - querySubscription: createReactiveSubscriptionFactory((query, fields, sort) => - Subscriptions.findOne(query, { fields, sort }), - ), + querySubscription, queryRoom, querySubscriptions, logout: async () => Meteor.logout(), @@ -133,7 +155,7 @@ const UserProvider = ({ children }: UserProviderProps): ReactElement => { return ee.on('logout', cb); }, }), - [userId, user, querySubscriptions], + [userId, user, querySubscription, querySubscriptions], ); useEffect(() => { diff --git a/apps/meteor/client/providers/UserProvider/hooks/useDeleteUser.ts b/apps/meteor/client/providers/UserProvider/hooks/useDeleteUser.ts index 2a88ae4f2c5a3..554593f13778b 100644 --- a/apps/meteor/client/providers/UserProvider/hooks/useDeleteUser.ts +++ b/apps/meteor/client/providers/UserProvider/hooks/useDeleteUser.ts @@ -1,7 +1,7 @@ import { useStream, useUserId } from '@rocket.chat/ui-contexts'; import { useEffect } from 'react'; -import { Messages } from '../../../../app/models/client'; +import { Messages } from '../../../stores'; export const useDeleteUser = () => { const notify = useStream('notify-logged'); diff --git a/apps/meteor/client/providers/UserProvider/hooks/useUpdateAvatar.ts b/apps/meteor/client/providers/UserProvider/hooks/useUpdateAvatar.ts index 816ca62602bb4..6dc879fed1253 100644 --- a/apps/meteor/client/providers/UserProvider/hooks/useUpdateAvatar.ts +++ b/apps/meteor/client/providers/UserProvider/hooks/useUpdateAvatar.ts @@ -1,7 +1,7 @@ import { useUserId, useStream } from '@rocket.chat/ui-contexts'; import { useEffect } from 'react'; -import { Users } from '../../../../app/models/client'; +import { Users } from '../../../stores'; export const useUpdateAvatar = () => { const notify = useStream('notify-logged'); @@ -13,7 +13,11 @@ export const useUpdateAvatar = () => { return notify('updateAvatar', (data) => { if ('username' in data) { const { username, etag } = data; - username && Users.update({ username }, { $set: { avatarETag: etag } }); + username && + Users.state.update( + (record) => record.username === username, + (record) => ({ ...record, avatarETag: etag }), + ); } }); }, [notify, uid]); diff --git a/apps/meteor/client/router/index.tsx b/apps/meteor/client/router/index.tsx new file mode 100644 index 0000000000000..39e8f1066715a --- /dev/null +++ b/apps/meteor/client/router/index.tsx @@ -0,0 +1,337 @@ +import type { RoomType, RoomRouteData } from '@rocket.chat/core-typings'; +import { Emitter } from '@rocket.chat/emitter'; +import type { + LocationPathname, + LocationSearch, + RouteName, + RouteObject, + RouteParameters, + RouterContextValue, + SearchParameters, + To, +} from '@rocket.chat/ui-contexts'; + +import { Context, Page } from './page'; +import { appLayout } from '../lib/appLayout'; +import { roomCoordinator } from '../lib/rooms/roomCoordinator'; + +export class Router implements RouterContextValue { + private readonly pathRegExp = /(:[\w\(\)\\\+\*\.\?\[\]\-]+)+/g; + + private readonly queryRegExp = /\?([^\/\r\n].*)/; + + private current: + | Readonly<{ + path: string; + params: Map; + route: Route; + context: Context; + oldRoute: Route | undefined; + queryParams: URLSearchParams; + }> + | undefined = undefined; + + private readonly specialChars = ['/', '%', '+']; + + private initialized = false; + + private routes = new Set(); + + private pathDefById = new Map(); + + private readonly basePath = window.__meteor_runtime_config__.ROOT_URL_PATH_PREFIX || ''; + + private readonly page = new Page(); + + constructor() { + this.registerRoute('*', { id: 'not-found' }); + this.updateCallbacks(); + } + + private emitter = new Emitter<{ pathChanged: void }>(); + + private registerRoute(pathDef: string, options: RouteOptions) { + if (!/^\//.test(pathDef) && pathDef !== '*') { + throw new Error("route's path must start with '/'"); + } + + const route = new Route(pathDef, options); + + route.actionHandle = (context: Context) => { + const oldRoute = this.current?.route; + + const queryParams = new URLSearchParams(context.querystring); + this.current = Object.freeze({ + path: context.path, + params: new Map(Object.entries(context.params)), + route, + context, + oldRoute, + queryParams, + }); + + this.refresh(); + }; + + this.routes.add(route); + this.pathDefById.set(options.id, route.pathDef); + + this.updateCallbacks(); + + return route; + } + + private encodePath( + idOrPathDef: string, + params: Record = {}, + queryParams: Record = {}, + ): string { + if (this.pathDefById.has(idOrPathDef)) { + idOrPathDef = this.pathDefById.get(idOrPathDef) ?? idOrPathDef; + } + + if (this.queryRegExp.test(idOrPathDef)) { + const [pathDefPart, searchPart] = idOrPathDef.split(this.queryRegExp); + idOrPathDef = pathDefPart; + if (searchPart) { + queryParams = Object.assign(Object.fromEntries(new URLSearchParams(searchPart).entries()), queryParams); + } + } + + let path = ''; + + if (this.basePath) { + path += `/${this.basePath}/`; + } + + path += idOrPathDef.replace(this.pathRegExp, (_key) => { + const firstRegexpChar = _key.indexOf('('); + let key = _key.substring(1, firstRegexpChar > 0 ? firstRegexpChar : undefined); + key = key.replace(/[\+\*\?]+/g, ''); + + if (params[key]) { + return this.encodeParam(`${params[key]}`); + } + + return ''; + }); + + path = path.replace(/\/\/+/g, '/'); + + path = path.match(/^\/{1}$/) ? path : path.replace(/\/$/, ''); + + const strQueryParams = new URLSearchParams( + Object.entries(queryParams).filter((pair): pair is [string, string] => pair[1] !== null && pair[1] !== undefined), + ).toString(); + if (strQueryParams) { + path += `?${strQueryParams}`; + } + + path = path.replace(/\/\/+/g, '/'); + return path; + } + + private encodeParam(param: string) { + const paramArr = param.split(''); + let buffer = ''; + for (const p of paramArr) { + if (this.specialChars.includes(p)) { + buffer += encodeURIComponent(encodeURIComponent(p)); + } else { + try { + buffer += encodeURIComponent(p); + } catch (e) { + buffer += p; + } + } + } + return buffer; + } + + private initialize() { + if (this.initialized) { + throw new Error('FlowRouter is already initialized'); + } + + this.updateCallbacks(); + + this.page.setBase(this.basePath); + + this.page.start(); + this.initialized = true; + + queueMicrotask(() => { + this.emitter.emit('pathChanged'); + }); + } + + private refresh() { + if (!this.current?.route) return; + + const currentContext = this.current; + const { route } = currentContext; + + let isRouteChange = currentContext.oldRoute !== currentContext.route; + if (!currentContext.oldRoute) { + isRouteChange = false; + } + + if (!isRouteChange) { + this.emitter.emit('pathChanged'); + } + + route.action(); + + queueMicrotask(() => { + this.emitter.emit('pathChanged'); + }); + } + + private updateCallbacks() { + this.page.clearRoutes(); + + let catchAll: Route | null = null; + + for (const route of this.routes) { + if (route.pathDef === '*') { + catchAll = route; + } else { + this.page.registerRoute(route.pathDef, route.actionHandle); + } + } + + if (catchAll) { + this.page.registerRoute(catchAll.pathDef, catchAll.actionHandle); + } + } + + // router context implementation + + readonly subscribeToRouteChange = (onRouteChange: () => void): (() => void) => { + const unsubscribe = this.emitter.on('pathChanged', onRouteChange); + + // FIXME: for some reason this is (and was) necessary to invoke some route actions + this.emitter.emit('pathChanged'); + + return () => { + unsubscribe(); + }; + }; + + readonly getLocationPathname = () => this.current?.path.replace(/\?.*/, '') as LocationPathname; + + readonly getLocationSearch = () => location.search as LocationSearch; + + readonly getRouteParameters = () => (this.current?.params ? (Object.fromEntries(this.current.params.entries()) as RouteParameters) : {}); + + readonly getSearchParameters = () => + this.current?.queryParams ? (Object.fromEntries(this.current.queryParams.entries()) as SearchParameters) : {}; + + readonly getRouteName = () => this.current?.route?.id as RouteName | undefined; + + private encodeSearchParameters(searchParameters: SearchParameters) { + const search = new URLSearchParams(); + + for (const [key, value] of Object.entries(searchParameters)) { + search.append(key, value); + } + + const searchString = search.toString(); + + return searchString ? `?${searchString}` : ''; + } + + readonly buildRoutePath = (to: To): LocationPathname | `${LocationPathname}?${LocationSearch}` => { + if (typeof to === 'string') { + return to; + } + + if ('pathname' in to) { + const { pathname, search = {} } = to; + return (pathname + this.encodeSearchParameters(search)) as LocationPathname | `${LocationPathname}?${LocationSearch}`; + } + + if ('pattern' in to) { + const { pattern, params = {}, search = {} } = to; + return this.encodePath(pattern, params, search) as LocationPathname | `${LocationPathname}?${LocationSearch}`; + } + + if ('name' in to) { + const { name, params = {}, search = {} } = to; + return this.encodePath(name, params, search) as LocationPathname | `${LocationPathname}?${LocationSearch}`; + } + + throw new Error('Invalid route'); + }; + + readonly navigate = ( + toOrDelta: To | number, + options?: { + replace?: boolean; + }, + ) => { + if (typeof toOrDelta === 'number') { + history.go(toOrDelta); + return; + } + + const path = this.buildRoutePath(toOrDelta); + const state = { path }; + + if (options?.replace) { + history.replaceState(state, '', path); + } else { + history.pushState(state, '', path); + } + + dispatchEvent(new PopStateEvent('popstate', { state })); + }; + + readonly defineRoutes = (routes: readonly RouteObject[]) => { + if (!this.initialized) this.initialize(); + + const flowRoutes = routes.map((route) => + this.registerRoute(route.path, { + id: route.id, + action: () => appLayout.render(<>{route.element}), + }), + ); + + if (this.current) this.page.dispatch(new Context(this.page, this.current.path)); + + return () => { + flowRoutes.forEach((flowRoute) => { + this.routes.delete(flowRoute); + this.pathDefById.delete(flowRoute.id); + }); + + this.updateCallbacks(); + if (this.current) this.page.dispatch(new Context(this.page, this.current.path)); + }; + }; + + readonly getRoomRoute = (roomType: RoomType, routeData: RoomRouteData) => { + return { path: roomCoordinator.getRouteLink(roomType, routeData) || '/' }; + }; +} + +type RouteOptions = { + id: string; + action?: () => void; +}; + +class Route { + readonly action: () => void | Promise; + + readonly id: string; + + constructor( + public readonly pathDef: string, + public readonly options: RouteOptions, + ) { + this.action = options.action ?? (() => undefined); + this.id = options.id; + } + + actionHandle: (ctx: Context) => void; +} diff --git a/apps/meteor/client/router/page.ts b/apps/meteor/client/router/page.ts new file mode 100644 index 0000000000000..15315ca84eb9a --- /dev/null +++ b/apps/meteor/client/router/page.ts @@ -0,0 +1,247 @@ +import type { Key } from 'path-to-regexp'; +import { pathToRegexp } from 'path-to-regexp'; + +type State = { readonly path: string }; + +export class Context { + canonicalPath: string; + + path: string; + + querystring: string; + + pathname: string; + + state: State; + + title: string; + + params: Record; + + constructor(page: Page, path: string, state?: State) { + const pageBase = page.getBase(); + if (path[0] === '/' && path.indexOf(pageBase) !== 0) path = `${pageBase}${path}`; + const i = path.indexOf('?'); + + this.canonicalPath = path; + this.path = path.replace(pageBase, '') || '/'; + + this.title = document.title; + this.state = { ...state, path }; + this.querystring = ~i ? decodeURLEncodedURIComponent(path.slice(i + 1)) : ''; + this.pathname = decodeURLEncodedURIComponent(~i ? path.slice(0, i) : path); + this.params = {}; + + if (!~this.path.indexOf('#')) return; + const parts = this.path.split('#'); + this.path = parts[0]; + this.pathname = parts[0]; + this.querystring = this.querystring.split('#')[0]; + } + + pushState() { + history.pushState(this.state, this.title, this.canonicalPath); + } + + save() { + if (location.protocol === 'file:') return; + history.replaceState(this.state, this.title, this.canonicalPath); + } +} + +class Route { + private readonly regexp: RegExp; + + private readonly keys: Key[] = []; + + constructor( + path: string, + private readonly fn: (ctx: Context) => void, + ) { + this.regexp = pathToRegexp(path === '*' ? '(.*)' : path, this.keys, { strict: false }); + } + + match(path: string, params: Record): boolean { + const { keys } = this; + const qsIndex = path.indexOf('?'); + const pathname = ~qsIndex ? path.slice(0, qsIndex) : path; + const m = this.regexp.exec(decodeURIComponent(pathname)); + + if (!m) return false; + + for (let i = 1, len = m.length; i < len; ++i) { + const key = keys[i - 1]; + const val = decodeURLEncodedURIComponent(m[i]); + if (val !== undefined || !Object.prototype.hasOwnProperty.call(params, key.name)) { + params[key.name] = val; + } + } + + return true; + } + + readonly callback = (ctx: Context, next: () => void) => { + if (this.match(ctx.path, ctx.params)) { + this.fn(ctx); + return; + } + next(); + }; +} + +export class Page { + routes: Route[] = []; + + current = ''; + + private running: boolean; + + registerRoute(path: string, callback: (ctx: Context) => void): void { + const route = new Route(path, callback); + this.routes.push(route); + } + + clearRoutes() { + this.routes = []; + } + + start() { + if (this.running) return; + this.running = true; + + window.addEventListener('popstate', this.onpopstate, false); + document.addEventListener('click', this.onclick, false); + + const url = location.pathname + location.search + location.hash; + + this.replace(url, { state: undefined, dispatch: undefined }); + } + + stop() { + if (!this.running) return; + this.running = false; + this.current = ''; + document.removeEventListener('click', this.onclick, false); + window.removeEventListener('popstate', this.onpopstate, false); + } + + private readonly onclick = (e: MouseEvent) => { + if (e.button !== 0) return; + + if (e.metaKey || e.ctrlKey || e.shiftKey) return; + if (e.defaultPrevented) return; + + const el = (e.target as Element | null)?.closest('a'); + if (!el) return; + + if (el.hasAttribute('download') || el.getAttribute('rel') === 'external') return; + + const isSVGAElement = (e: HTMLAnchorElement | SVGAElement): e is SVGAElement => typeof e.href === 'object'; + + const link = isSVGAElement(el) ? el.href.baseVal : el.href; + const url = new URL(link, location.toString()); + if (url.pathname === location.pathname && url.search === location.search && (url.hash || link === '#')) return; + + if (url.protocol === 'mailto:') return; + + if (isSVGAElement(el) ? el.target.baseVal : el.target) return; + + if (!isSVGAElement(el) && (location.protocol !== url.protocol || location.hostname !== url.hostname || location.port !== url.port)) + return; + + let path = isSVGAElement(el) ? el.href.baseVal : el.pathname + el.search + (el.hash || ''); + + path = path[0] !== '/' ? `/${path}` : path; + + const orig = path; + const pageBase = this.getBase(); + + if (path.indexOf(pageBase) === 0) { + path = path.slice(this.getBase().length); + } + + if (pageBase && orig === path) return; + + e.preventDefault(); + this.show(orig); + }; + + private readonly onpopstate = (e: PopStateEvent) => { + if (e.state) { + this.replace(e.state.path, { state: e.state }); + } else { + this.show(location.pathname + location.hash, { push: false }); + } + }; + + show( + this: this, + path: string, + { state, dispatch = true, push = true, reload = false }: { state?: State; dispatch?: boolean; push?: boolean; reload?: boolean } = {}, + ) { + if (!path || (!reload && this.current === path)) return; + + const pathParts = path.split('?'); + pathParts[0] = pathParts[0].replace(/\/\/+/g, '/'); + path = pathParts.join('?'); + + const ctx = new Context(this, path, state); + this.current = ctx.path; + if (dispatch) this.dispatch(ctx); + if (push) ctx.pushState(); + } + + replace(this: this, path: string, { state, dispatch = true }: { state?: State; dispatch?: boolean } = {}) { + if (!path || this.current === path) return; + + const pathParts = path.split('?'); + pathParts[0] = pathParts[0].replace(/\/\/+/g, '/'); + path = pathParts.join('?'); + + const ctx = new Context(this, path, state); + this.current = ctx.path; + ctx.save(); // save before dispatching, which may redirect + if (dispatch) this.dispatch(ctx); + } + + dispatch(ctx: Context) { + let i = 0; + + const nextEnter = () => { + const route = this.routes[i++]; + + if (ctx.path !== this.current) return; + + if (!route) return this.unhandled(ctx); + route.callback(ctx, nextEnter); + }; + + nextEnter(); + } + + private unhandled(ctx: Context) { + const current = location.pathname + location.search; + + if (current === ctx.canonicalPath) return; + this.stop(); + location.href = ctx.canonicalPath; + } + + private base = ''; + + getBase() { + return this.base; + } + + setBase(path: string) { + this.base = path; + } + + readonly Context = Context; +} + +function decodeURLEncodedURIComponent(val: string): string { + if (typeof val !== 'string') return val; + + return decodeURIComponent(val.replace(/\+/g, ' ')); +} diff --git a/apps/meteor/client/sidebar/header/CreateChannel/CreateChannelModal.spec.tsx b/apps/meteor/client/sidebar/header/CreateChannel/CreateChannelModal.spec.tsx new file mode 100644 index 0000000000000..8c979f0a59b3e --- /dev/null +++ b/apps/meteor/client/sidebar/header/CreateChannel/CreateChannelModal.spec.tsx @@ -0,0 +1,8 @@ +import CreateChannelModal from './CreateChannelModal'; +import { testCreateChannelModal } from '../../../NavBarV2/NavBarPagesGroup/actions/testCreateChannelModal'; + +jest.mock('../../../lib/utils/goToRoomById', () => ({ + goToRoomById: jest.fn(), +})); + +testCreateChannelModal(CreateChannelModal); diff --git a/apps/meteor/client/sidebar/header/CreateChannel/CreateChannelModal.tsx b/apps/meteor/client/sidebar/header/CreateChannel/CreateChannelModal.tsx index dd2a621bd6bf6..b72a848961b3e 100644 --- a/apps/meteor/client/sidebar/header/CreateChannel/CreateChannelModal.tsx +++ b/apps/meteor/client/sidebar/header/CreateChannel/CreateChannelModal.tsx @@ -23,19 +23,13 @@ import { ModalFooterControllers, } from '@rocket.chat/fuselage'; import type { TranslationKey } from '@rocket.chat/ui-contexts'; -import { - useSetting, - useTranslation, - useEndpoint, - usePermission, - useToastMessageDispatch, - usePermissionWithScopedRoles, -} from '@rocket.chat/ui-contexts'; +import { useSetting, useTranslation, useEndpoint, useToastMessageDispatch, usePermissionWithScopedRoles } from '@rocket.chat/ui-contexts'; import type { ComponentProps, ReactElement } from 'react'; import { useId, useEffect, useMemo } from 'react'; import { useForm, Controller } from 'react-hook-form'; import UserAutoCompleteMultipleFederated from '../../../components/UserAutoCompleteMultiple/UserAutoCompleteMultipleFederated'; +import { useCreateChannelTypePermission } from '../../../hooks/useCreateChannelTypePermission'; import { useHasLicenseModule } from '../../../hooks/useHasLicenseModule'; import { goToRoomById } from '../../../lib/utils/goToRoomById'; import { useEncryptedRoomDescription } from '../hooks/useEncryptedRoomDescription'; @@ -77,8 +71,6 @@ const CreateChannelModal = ({ teamId = '', mainRoom, onClose, reload }: CreateCh const federationEnabled = useSetting('Federation_Matrix_enabled', false); const e2eEnabledForPrivateByDefault = useSetting('E2E_Enabled_Default_PrivateRooms') && e2eEnabled; - const canCreateChannel = usePermission('create-c'); - const canCreateGroup = usePermission('create-p'); const getEncryptedHint = useEncryptedRoomDescription('channel'); const channelNameRegex = useMemo(() => new RegExp(`^${namesValidation}$`), [namesValidation]); @@ -89,20 +81,9 @@ const CreateChannelModal = ({ teamId = '', mainRoom, onClose, reload }: CreateCh const createChannel = useEndpoint('POST', '/v1/channels.create'); const createPrivateChannel = useEndpoint('POST', '/v1/groups.create'); - const canCreateTeamChannel = usePermission('create-team-channel', mainRoom?._id); - const canCreateTeamGroup = usePermission('create-team-group', mainRoom?._id); - const dispatchToastMessage = useToastMessageDispatch(); - const canOnlyCreateOneType = useMemo(() => { - if ((!teamId && !canCreateChannel && canCreateGroup) || (teamId && !canCreateTeamChannel && canCreateTeamGroup)) { - return 'p'; - } - if ((!teamId && canCreateChannel && !canCreateGroup) || (teamId && canCreateTeamChannel && !canCreateTeamGroup)) { - return 'c'; - } - return false; - }, [canCreateChannel, canCreateGroup, canCreateTeamChannel, canCreateTeamGroup, teamId]); + const canOnlyCreateOneType = useCreateChannelTypePermission(mainRoom?._id); const { register, @@ -128,23 +109,23 @@ const CreateChannelModal = ({ teamId = '', mainRoom, onClose, reload }: CreateCh const { isPrivate, broadcast, readOnly, federated, encrypted } = watch(); useEffect(() => { - if (!isPrivate) { - setValue('encrypted', false); - } - - if (broadcast) { - setValue('encrypted', false); - } - if (federated) { // if room is federated, it cannot be encrypted or broadcast or readOnly setValue('encrypted', false); setValue('broadcast', false); setValue('readOnly', false); } + }, [federated, setValue]); + useEffect(() => { + if (!isPrivate) { + setValue('encrypted', false); + } + }, [isPrivate, setValue]); + + useEffect(() => { setValue('readOnly', broadcast); - }, [federated, setValue, broadcast, isPrivate]); + }, [broadcast, setValue]); const validateChannelName = async (name: string): Promise => { if (!name) { @@ -194,10 +175,7 @@ const CreateChannelModal = ({ teamId = '', mainRoom, onClose, reload }: CreateCh } }; - const e2eDisabled = useMemo( - () => !isPrivate || broadcast || Boolean(!e2eEnabled) || Boolean(e2eEnabledForPrivateByDefault), - [e2eEnabled, e2eEnabledForPrivateByDefault, broadcast, isPrivate], - ); + const e2eDisabled = useMemo(() => !isPrivate || Boolean(!e2eEnabled) || federated, [e2eEnabled, federated, isPrivate]); const createChannelFormId = useId(); const nameId = useId(); @@ -247,7 +225,7 @@ const CreateChannelModal = ({ teamId = '', mainRoom, onClose, reload }: CreateCh {errors.name.message} )} - {!allowSpecialNames && {t('No_spaces')}} + {!allowSpecialNames && {t('No_spaces_or_special_characters')}} {t('Topic')} @@ -326,14 +304,14 @@ const CreateChannelModal = ({ teamId = '', mainRoom, onClose, reload }: CreateCh id={encryptedId} ref={ref} checked={value} - disabled={e2eDisabled || federated} + disabled={e2eDisabled} onChange={onChange} aria-describedby={`${encryptedId}-hint`} /> )} /> - {getEncryptedHint({ isPrivate, broadcast, encrypted })} + {getEncryptedHint({ isPrivate, encrypted })} diff --git a/apps/meteor/client/sidebar/header/CreateTeam/CreateTeamModal.tsx b/apps/meteor/client/sidebar/header/CreateTeam/CreateTeamModal.tsx index 3d553ca3230cc..44d1a847afe4c 100644 --- a/apps/meteor/client/sidebar/header/CreateTeam/CreateTeamModal.tsx +++ b/apps/meteor/client/sidebar/header/CreateTeam/CreateTeamModal.tsx @@ -34,6 +34,7 @@ import { useId, memo, useEffect, useMemo } from 'react'; import { Controller, useForm } from 'react-hook-form'; import UserAutoCompleteMultiple from '../../../components/UserAutoCompleteMultiple'; +import { useCreateChannelTypePermission } from '../../../hooks/useCreateChannelTypePermission'; import { goToRoomById } from '../../../lib/utils/goToRoomById'; import { useEncryptedRoomDescription } from '../hooks/useEncryptedRoomDescription'; @@ -68,6 +69,8 @@ const CreateTeamModal = ({ onClose }: { onClose: () => void }): ReactElement => return new RegExp(`^${namesValidation}$`); }, [allowSpecialNames, namesValidation]); + const canOnlyCreateOneType = useCreateChannelTypePermission(); + const validateTeamName = async (name: string): Promise => { if (!name) { return; @@ -92,7 +95,7 @@ const CreateTeamModal = ({ onClose }: { onClose: () => void }): ReactElement => formState: { errors, isSubmitting }, } = useForm({ defaultValues: { - isPrivate: true, + isPrivate: canOnlyCreateOneType ? canOnlyCreateOneType === 'p' : true, readOnly: false, encrypted: (e2eEnabledForPrivateByDefault as boolean) ?? false, broadcast: false, @@ -200,7 +203,7 @@ const CreateTeamModal = ({ onClose }: { onClose: () => void }): ReactElement => {errors.name.message} )} - {!allowSpecialNames && {t('No_spaces')}} + {!allowSpecialNames && {t('No_spaces_or_special_characters')}} {t('Topic')} @@ -228,7 +231,14 @@ const CreateTeamModal = ({ onClose }: { onClose: () => void }): ReactElement => control={control} name='isPrivate' render={({ field: { onChange, value, ref } }): ReactElement => ( - + )} /> diff --git a/apps/meteor/client/sidebar/header/EditStatusModal.tsx b/apps/meteor/client/sidebar/header/EditStatusModal.tsx index 04b678e160636..d4ec342c8f58c 100644 --- a/apps/meteor/client/sidebar/header/EditStatusModal.tsx +++ b/apps/meteor/client/sidebar/header/EditStatusModal.tsx @@ -21,7 +21,7 @@ import { import { useLocalStorage, useEffectEvent } from '@rocket.chat/fuselage-hooks'; import { useToastMessageDispatch, useSetting, useTranslation, useEndpoint } from '@rocket.chat/ui-contexts'; import type { ReactElement, ChangeEvent, ComponentProps, FormEvent } from 'react'; -import { useState, useCallback } from 'react'; +import { useState, useCallback, useId } from 'react'; import UserStatusMenu from '../../components/UserStatusMenu'; import { USER_STATUS_TEXT_MAX_LENGTH } from '../../lib/constants'; @@ -39,6 +39,7 @@ const EditStatusModal = ({ onClose, userStatus, userStatusText }: EditStatusModa const initialStatusText = customStatus || userStatusText; const t = useTranslation(); + const modalId = useId(); const [statusText, setStatusText] = useState(initialStatusText); const [statusType, setStatusType] = useState(userStatus); const [statusTextError, setStatusTextError] = useState(); @@ -71,6 +72,7 @@ const EditStatusModal = ({ onClose, userStatus, userStatusText }: EditStatusModa return ( ) => ( - {t('Edit_Status')} + {t('Edit_Status')} - {t('StatusMessage')} + {t('StatusMessage')} { - + {displayName} diff --git a/apps/meteor/client/sidebar/header/actions/hooks/useCreateRoomItems.tsx b/apps/meteor/client/sidebar/header/actions/hooks/useCreateRoomItems.tsx index 1bc64d1ba8d11..8092e0639cb28 100644 --- a/apps/meteor/client/sidebar/header/actions/hooks/useCreateRoomItems.tsx +++ b/apps/meteor/client/sidebar/header/actions/hooks/useCreateRoomItems.tsx @@ -63,6 +63,6 @@ export const useCreateRoomItems = (): GenericMenuItemProps[] => { ...(canCreateDirectMessages ? [createDirectMessageItem] : []), ...(canCreateDiscussion && discussionEnabled ? [createDiscussionItem] : []), ...(canCreateChannel ? [createChannelItem] : []), - ...(canCreateTeam ? [createTeamItem] : []), + ...(canCreateTeam && canCreateChannel ? [createTeamItem] : []), ]; }; diff --git a/apps/meteor/client/sidebar/header/hooks/useEncryptedRoomDescription.tsx b/apps/meteor/client/sidebar/header/hooks/useEncryptedRoomDescription.tsx index 0cef06d391737..4a193aed413cb 100644 --- a/apps/meteor/client/sidebar/header/hooks/useEncryptedRoomDescription.tsx +++ b/apps/meteor/client/sidebar/header/hooks/useEncryptedRoomDescription.tsx @@ -4,19 +4,19 @@ import { useTranslation } from 'react-i18next'; export const useEncryptedRoomDescription = (roomType: 'channel' | 'team') => { const { t } = useTranslation(); const e2eEnabled = useSetting('E2E_Enable'); - const e2eEnabledForPrivateByDefault = useSetting('E2E_Enabled_Default_PrivateRooms'); - return ({ isPrivate, broadcast, encrypted }: { isPrivate: boolean; broadcast: boolean; encrypted: boolean }) => { + return ({ isPrivate, broadcast, encrypted }: { isPrivate: boolean; broadcast?: boolean; encrypted: boolean }) => { if (!e2eEnabled) { return t('Not_available_for_this_workspace'); } if (!isPrivate) { return t('Encrypted_not_available', { roomType }); } - if (broadcast) { + // TODO: This case will be removed once we enable E2E for broadcast teams in teams creation modal + if (broadcast !== undefined && broadcast) { return t('Not_available_for_broadcast', { roomType }); } - if (e2eEnabledForPrivateByDefault || encrypted) { + if (encrypted) { return t('Encrypted_messages', { roomType }); } return t('Encrypted_messages_false'); diff --git a/apps/meteor/client/sidebarv2/RoomList/RoomListCollapser.tsx b/apps/meteor/client/sidebarv2/RoomList/RoomListCollapser.tsx index 5f868a1e2a166..465197e41db82 100644 --- a/apps/meteor/client/sidebarv2/RoomList/RoomListCollapser.tsx +++ b/apps/meteor/client/sidebarv2/RoomList/RoomListCollapser.tsx @@ -28,6 +28,9 @@ const RoomListCollapser = ({ groupTitle, unreadCount: unreadGroupCount, collapse ) : undefined } + aria-label={ + !collapsedGroups.includes(groupTitle) ? t('Collapse_group', { group: t(groupTitle) }) : t('Expand_group', { group: t(groupTitle) }) + } {...props} /> ); diff --git a/apps/meteor/client/sidebarv2/RoomList/SidebarItem.tsx b/apps/meteor/client/sidebarv2/RoomList/SidebarItem.tsx new file mode 100644 index 0000000000000..42b06edcb22b2 --- /dev/null +++ b/apps/meteor/client/sidebarv2/RoomList/SidebarItem.tsx @@ -0,0 +1,46 @@ +import { IconButton, SidebarV2Item, SidebarV2ItemAvatarWrapper, SidebarV2ItemMenu, SidebarV2ItemTitle } from '@rocket.chat/fuselage'; +import { RoomAvatar } from '@rocket.chat/ui-avatar'; +import type { SubscriptionWithRoom } from '@rocket.chat/ui-contexts'; +import type { HTMLAttributes, ReactElement, ReactNode } from 'react'; +import { memo, useState } from 'react'; + +type SidebarItemProps = { + title: ReactNode; + titleIcon?: ReactNode; + icon?: ReactNode; + actions?: ReactNode; + href?: string; + unread?: boolean; + menu?: ReactElement; + menuOptions?: any; + selected?: boolean; + badges?: ReactNode; + clickable?: boolean; + room: SubscriptionWithRoom; +} & Omit, 'is'>; + +const SidebarItem = ({ icon, title, actions, unread, menu, badges, room, ...props }: SidebarItemProps) => { + const [menuVisibility, setMenuVisibility] = useState(!!window.DISABLE_ANIMATION); + + const handleFocus = () => setMenuVisibility(true); + const handlePointerEnter = () => setMenuVisibility(true); + + return ( + + + + + {icon} + {title} + {badges} + {actions} + {menu && ( + + {menuVisibility ? menu : } + + )} + + ); +}; + +export default memo(SidebarItem); diff --git a/apps/meteor/client/sidebarv2/RoomList/SidebarItemWithData.tsx b/apps/meteor/client/sidebarv2/RoomList/SidebarItemWithData.tsx new file mode 100644 index 0000000000000..763543cd3a491 --- /dev/null +++ b/apps/meteor/client/sidebarv2/RoomList/SidebarItemWithData.tsx @@ -0,0 +1,159 @@ +import { isDirectMessageRoom, isOmnichannelRoom, isTeamRoom } from '@rocket.chat/core-typings'; +import { SidebarV2Action, SidebarV2Actions, SidebarV2ItemBadge, SidebarV2ItemIcon } from '@rocket.chat/fuselage'; +import { useButtonPattern } from '@rocket.chat/fuselage-hooks'; +import type { SubscriptionWithRoom } from '@rocket.chat/ui-contexts'; +import type { TFunction } from 'i18next'; +import type { AllHTMLAttributes } from 'react'; +import { memo, useCallback, useMemo } from 'react'; + +import SidebarItem from './SidebarItem'; +import { RoomIcon } from '../../components/RoomIcon'; +import { roomCoordinator } from '../../lib/rooms/roomCoordinator'; +import { useSwitchSidePanelTab, useRoomsListContext, useIsRoomFilter } from '../../views/navigation/contexts/RoomsNavigationContext'; +import { useUnreadDisplay } from '../hooks/useUnreadDisplay'; + +type RoomListRowProps = { + t: TFunction; + openedRoom?: string; + isAnonymous?: boolean; + + room: SubscriptionWithRoom; + id?: string; + /* @deprecated */ + style?: AllHTMLAttributes['style']; + + videoConfActions?: { + [action: string]: () => void; + }; +}; + +const SidebarItemWithData = ({ room, id, style, t, videoConfActions }: RoomListRowProps) => { + const title = roomCoordinator.getRoomName(room.t, room) || ''; + const href = roomCoordinator.getRouteLink(room.t, room) || ''; + + const { unreadTitle, unreadVariant, showUnread, unreadCount, highlightUnread: highlighted } = useUnreadDisplay(room); + + const icon = ( + } + /> + ); + + const actions = useMemo( + () => + videoConfActions && ( + + + + + ), + [videoConfActions], + ); + + const badges = ( + <> + {showUnread && ( + + {unreadCount.total} + + )} + + ); + + const switchSidePanelTab = useSwitchSidePanelTab(); + const { parentRid } = useRoomsListContext(); + + const isRoomFilter = useIsRoomFilter(); + + const selected = isRoomFilter && room.rid === parentRid; + + const handleClick = useCallback(() => { + if (isTeamRoom(room)) { + switchSidePanelTab('teams', { parentRid: room.rid }); + return; + } + + if (isDirectMessageRoom(room)) { + switchSidePanelTab('directMessages', { parentRid: room.rid }); + return; + } + + switchSidePanelTab('channels', { parentRid: room.rid }); + }, [room, switchSidePanelTab]); + + const buttonProps = useButtonPattern(handleClick); + + return ( + + ); +}; + +function safeDateNotEqualCheck(a: Date | string | undefined, b: Date | string | undefined): boolean { + if (!a || !b) { + return a !== b; + } + return new Date(a).toISOString() !== new Date(b).toISOString(); +} + +const keys: (keyof RoomListRowProps)[] = ['id', 'style', 't', 'videoConfActions']; + +// eslint-disable-next-line react/no-multi-comp +export default memo(SidebarItemWithData, (prevProps, nextProps) => { + if (keys.some((key) => prevProps[key] !== nextProps[key])) { + return false; + } + + if (prevProps.room === nextProps.room) { + return true; + } + + if (prevProps.room._id !== nextProps.room._id) { + return false; + } + if (prevProps.room._updatedAt?.toISOString() !== nextProps.room._updatedAt?.toISOString()) { + return false; + } + if (safeDateNotEqualCheck(prevProps.room.lastMessage?._updatedAt, nextProps.room.lastMessage?._updatedAt)) { + return false; + } + if (prevProps.room.alert !== nextProps.room.alert) { + return false; + } + if (isOmnichannelRoom(prevProps.room) && isOmnichannelRoom(nextProps.room) && prevProps.room?.v?.status !== nextProps.room?.v?.status) { + return false; + } + if (prevProps.room.teamMain !== nextProps.room.teamMain) { + return false; + } + + if ( + isOmnichannelRoom(prevProps.room) && + isOmnichannelRoom(nextProps.room) && + prevProps.room.priorityWeight !== nextProps.room.priorityWeight + ) { + return false; + } + + return true; +}); diff --git a/apps/meteor/client/sidebarv2/Sidebar.tsx b/apps/meteor/client/sidebarv2/Sidebar.tsx index 278c8b4f9d589..f6dd40024684f 100644 --- a/apps/meteor/client/sidebarv2/Sidebar.tsx +++ b/apps/meteor/client/sidebarv2/Sidebar.tsx @@ -1,18 +1,20 @@ import { SidebarV2 } from '@rocket.chat/fuselage'; import { useUserPreference } from '@rocket.chat/ui-contexts'; import { memo } from 'react'; +import { useTranslation } from 'react-i18next'; import SidebarRoomList from './RoomList'; import SidebarFooter from './footer'; import BannerSection from './sections/BannerSection'; const Sidebar = () => { + const { t } = useTranslation(); const sidebarViewMode = useUserPreference('sidebarViewMode'); const sidebarHideAvatar = !useUserPreference('sidebarDisplayAvatar'); return ( { + const uid = Meteor.userId(); + const subscriptionsReady = watch(SubscriptionsCachedStore.useReady, (state) => state); + const settingsReady = watch(PublicSettingsCachedStore.useReady, (state) => state); + const userDataReady = watch(useUserDataSyncReady, (state) => state); + + return !uid || (userDataReady && subscriptionsReady && settingsReady); +}; Accounts.onEmailVerificationLink((token: string) => { Accounts.verifyEmail(token, (error) => { Tracker.autorun(() => { - if (mainReady.get()) { - if (error) { - dispatchToastMessage({ type: 'error', message: error }); - throw new Meteor.Error('verify-email', 'E-mail not verified'); - } else { - Tracker.nonreactive(() => { - void sdk.call('afterVerifyEmail'); - }); - dispatchToastMessage({ type: 'success', message: t('Email_verified') }); - } + if (!watchMainReady()) return; + + if (error) { + dispatchToastMessage({ type: 'error', message: error }); + throw new Meteor.Error('verify-email', 'E-mail not verified'); + } else { + Tracker.nonreactive(() => { + void sdk.call('afterVerifyEmail'); + }); + dispatchToastMessage({ type: 'success', message: t('Email_verified') }); } }); }); diff --git a/apps/meteor/client/startup/e2e.ts b/apps/meteor/client/startup/e2e.ts deleted file mode 100644 index 945ba71010777..0000000000000 --- a/apps/meteor/client/startup/e2e.ts +++ /dev/null @@ -1,133 +0,0 @@ -import type { IMessage, IRoom } from '@rocket.chat/core-typings'; -import { isE2EEPinnedMessage } from '@rocket.chat/core-typings'; -import { Meteor } from 'meteor/meteor'; -import { Tracker } from 'meteor/tracker'; - -import { E2EEState } from '../../app/e2e/client/E2EEState'; -import { e2e } from '../../app/e2e/client/rocketchat.e2e'; -import { MentionsParser } from '../../app/mentions/lib/MentionsParser'; -import { Rooms } from '../../app/models/client'; -import { settings } from '../../app/settings/client'; -import { onClientBeforeSendMessage } from '../lib/onClientBeforeSendMessage'; -import { onClientMessageReceived } from '../lib/onClientMessageReceived'; -import { isLayoutEmbedded } from '../lib/utils/isLayoutEmbedded'; -import { router } from '../providers/RouterProvider'; - -Meteor.startup(() => { - Tracker.autorun(() => { - if (!Meteor.userId()) { - e2e.log('Not logged in'); - return; - } - - if (!window.crypto) { - e2e.error('No crypto support'); - return; - } - - const enabled = settings.get('E2E_Enable'); - // we don't care about the reactivity of this boolean - const adminEmbedded = isLayoutEmbedded() && router.getLocationPathname().startsWith('/admin'); - - if (enabled && !adminEmbedded) { - e2e.log('E2E enabled starting client'); - e2e.startClient(); - } else { - e2e.log('E2E disabled'); - e2e.setState(E2EEState.DISABLED); - e2e.closeAlert(); - } - }); - - let offClientMessageReceived: undefined | (() => void); - let offClientBeforeSendMessage: undefined | (() => void); - let listenersAttached = false; - - Tracker.autorun(() => { - if (!e2e.isReady()) { - e2e.log('Not ready'); - offClientMessageReceived?.(); - offClientBeforeSendMessage?.(); - listenersAttached = false; - return; - } - - if (listenersAttached) { - e2e.log('Listeners already attached'); - return; - } - - offClientMessageReceived = onClientMessageReceived.use(async (msg: IMessage) => { - const e2eRoom = await e2e.getInstanceByRoomId(msg.rid); - if (!e2eRoom?.shouldConvertReceivedMessages()) { - return msg; - } - - if (isE2EEPinnedMessage(msg)) { - return e2e.decryptPinnedMessage(msg); - } - - return e2e.decryptMessage(msg); - }); - - // Encrypt messages before sending - offClientBeforeSendMessage = onClientBeforeSendMessage.use(async (message) => { - const e2eRoom = await e2e.getInstanceByRoomId(message.rid); - - if (!e2eRoom) { - return message; - } - - // e2e.getInstanceByRoomId already waits for the room to be available which means this logic needs to be - // refactored to avoid waiting for the room again - const subscription = await new Promise((resolve) => { - const room = Rooms.state.get(message.rid); - - if (room) resolve(room); - - const unsubscribe = Rooms.use.subscribe((state) => { - const room = state.get(message.rid); - if (room) { - unsubscribe(); - resolve(room); - } - }); - }); - - subscription.encrypted ? e2eRoom.resume() : e2eRoom.pause(); - - const shouldConvertSentMessages = await e2eRoom.shouldConvertSentMessages(message); - - if (!shouldConvertSentMessages) { - return message; - } - - const mentionsEnabled = settings.get('E2E_Enabled_Mentions'); - - if (mentionsEnabled) { - const me = Meteor.user()?.username || ''; - const pattern = settings.get('UTF8_User_Names_Validation'); - const useRealName = settings.get('UI_Use_Real_Name'); - - const mentions = new MentionsParser({ - pattern: () => pattern, - useRealName: () => useRealName, - me: () => me, - }); - - const e2eMentions: IMessage['e2eMentions'] = { - e2eUserMentions: mentions.getUserMentions(message.msg), - e2eChannelMentions: mentions.getChannelMentions(message.msg), - }; - - message.e2eMentions = e2eMentions; - } - - // Should encrypt this message. - return e2eRoom.encryptMessage(message); - }); - - listenersAttached = true; - e2e.log('Listeners attached', listenersAttached); - }); -}); diff --git a/apps/meteor/client/startup/fakeUserPresence.ts b/apps/meteor/client/startup/fakeUserPresence.ts new file mode 100644 index 0000000000000..3bda53c5055fd --- /dev/null +++ b/apps/meteor/client/startup/fakeUserPresence.ts @@ -0,0 +1,18 @@ +// backport of rocketchat:user-presence for the desktop app + +if (window.RocketChatDesktop) { + const fakeUserPresenceModule = { + UserPresence: { + awayTime: undefined, + start: () => undefined, + }, + }; + + window.require = ((fn) => + Object.assign((id: string) => { + if (id === 'meteor/rocketchat:user-presence') { + return fakeUserPresenceModule; + } + return fn(id); + }, fn))(window.require); +} diff --git a/apps/meteor/client/startup/incomingMessages.ts b/apps/meteor/client/startup/incomingMessages.ts index cdad11264a12b..6aaf2f6af1931 100644 --- a/apps/meteor/client/startup/incomingMessages.ts +++ b/apps/meteor/client/startup/incomingMessages.ts @@ -1,9 +1,9 @@ import type { IMessage } from '@rocket.chat/core-typings'; import { Meteor } from 'meteor/meteor'; -import { Messages } from '../../app/models/client'; import { sdk } from '../../app/utils/client/lib/SDKClient'; import { onLoggedIn } from '../lib/loggedIn'; +import { Messages } from '../stores'; Meteor.startup(() => { onLoggedIn(() => { diff --git a/apps/meteor/client/startup/index.ts b/apps/meteor/client/startup/index.ts index 8d30d3c3b1415..181a9cb2aeb3e 100644 --- a/apps/meteor/client/startup/index.ts +++ b/apps/meteor/client/startup/index.ts @@ -4,7 +4,6 @@ import './appRoot'; import './audit'; import './callbacks'; import './deviceManagement'; -import './e2e'; import './iframeCommands'; import './incomingMessages'; import './messageTypes'; diff --git a/apps/meteor/client/startup/roles.ts b/apps/meteor/client/startup/roles.ts index cd6a1e2be5e49..7491462f77b80 100644 --- a/apps/meteor/client/startup/roles.ts +++ b/apps/meteor/client/startup/roles.ts @@ -2,9 +2,9 @@ import type { IRole } from '@rocket.chat/core-typings'; import { Meteor } from 'meteor/meteor'; import { Tracker } from 'meteor/tracker'; -import { Roles } from '../../app/models/client'; import { sdk } from '../../app/utils/client/lib/SDKClient'; import { onLoggedIn } from '../lib/loggedIn'; +import { Roles } from '../stores'; Meteor.startup(() => { onLoggedIn(async () => { diff --git a/apps/meteor/client/startup/routes.tsx b/apps/meteor/client/startup/routes.tsx index a963e2995421e..e2012dfd9d56a 100644 --- a/apps/meteor/client/startup/routes.tsx +++ b/apps/meteor/client/startup/routes.tsx @@ -49,7 +49,7 @@ declare module '@rocket.chat/ui-contexts' { }; 'omnichannel-directory': { pathname: `/omnichannel-directory${`/${string}` | ''}${`/${string}` | ''}${`/${string}` | ''}`; - pattern: '/omnichannel-directory/:tab?/:context?/:id?/'; + pattern: '/omnichannel-directory/:tab?/:context?/:id?'; }; 'livechat-queue': { pathname: '/livechat-queue'; @@ -151,7 +151,7 @@ router.defineRoutes([ ), }, { - path: '/omnichannel-directory/:tab?/:context?/:id?/', + path: '/omnichannel-directory/:tab?/:context?/:id?', id: 'omnichannel-directory', element: appLayout.wrap( diff --git a/apps/meteor/client/startup/startup.ts b/apps/meteor/client/startup/startup.ts index d582ffcba1842..21747fc94e691 100644 --- a/apps/meteor/client/startup/startup.ts +++ b/apps/meteor/client/startup/startup.ts @@ -1,10 +1,8 @@ import type { UserStatus } from '@rocket.chat/core-typings'; import { Meteor } from 'meteor/meteor'; -import { UserPresence } from 'meteor/rocketchat:user-presence'; import { Tracker } from 'meteor/tracker'; import moment from 'moment'; -import { getUserPreference } from '../../app/utils/client'; import 'highlight.js/styles/github.css'; import { sdk } from '../../app/utils/client/lib/SDKClient'; import { synchronizeUserData, removeLocalUserData } from '../lib/userData'; @@ -13,9 +11,6 @@ import { fireGlobalEvent } from '../lib/utils/fireGlobalEvent'; Meteor.startup(() => { fireGlobalEvent('startup', true); - window.lastMessageWindow = {}; - window.lastMessageWindowHistory = {}; - let status: UserStatus | undefined = undefined; Tracker.autorun(async () => { const uid = Meteor.userId(); @@ -42,16 +37,6 @@ Meteor.startup(() => { sdk.call('userSetUtcOffset', utcOffset); } - if (getUserPreference(user, 'enableAutoAway')) { - const idleTimeLimit = (getUserPreference(user, 'idleTimeLimit') as number | null | undefined) || 300; - UserPresence.awayTime = idleTimeLimit * 1000; - } else { - delete UserPresence.awayTime; - UserPresence.stopTimer(); - } - - UserPresence.start(); - if (user.status !== status) { status = user.status; fireGlobalEvent('status-changed', status); diff --git a/apps/meteor/client/stores/Messages.ts b/apps/meteor/client/stores/Messages.ts new file mode 100644 index 0000000000000..187d25a771a8b --- /dev/null +++ b/apps/meteor/client/stores/Messages.ts @@ -0,0 +1,9 @@ +import type { IMessage } from '@rocket.chat/core-typings'; + +import { createDocumentMapStore, createGlobalStore } from '../lib/cachedStores'; + +/** @deprecated prefer fetching data from the REST API, listening to changes via streamer events, and storing the state in a Tanstack Query */ +export const Messages = + createGlobalStore( + createDocumentMapStore(), + ); diff --git a/apps/meteor/client/stores/Permissions.ts b/apps/meteor/client/stores/Permissions.ts new file mode 100644 index 0000000000000..0dcce02542d43 --- /dev/null +++ b/apps/meteor/client/stores/Permissions.ts @@ -0,0 +1,6 @@ +import type { IPermission } from '@rocket.chat/core-typings'; + +import { createDocumentMapStore, createGlobalStore } from '../lib/cachedStores'; + +/** @deprecated prefer fetching data from the REST API, listening to changes via streamer events, and storing the state in a Tanstack Query */ +export const Permissions = createGlobalStore(createDocumentMapStore()); diff --git a/apps/meteor/client/stores/Roles.ts b/apps/meteor/client/stores/Roles.ts new file mode 100644 index 0000000000000..1ddc4e71f3b2f --- /dev/null +++ b/apps/meteor/client/stores/Roles.ts @@ -0,0 +1,6 @@ +import type { IRole } from '@rocket.chat/core-typings'; + +import { createDocumentMapStore, createGlobalStore } from '../lib/cachedStores'; + +/** @deprecated prefer fetching data from the REST API, listening to changes via streamer events, and storing the state in a Tanstack Query */ +export const Roles = createGlobalStore(createDocumentMapStore()); diff --git a/apps/meteor/client/stores/Rooms.ts b/apps/meteor/client/stores/Rooms.ts new file mode 100644 index 0000000000000..3f2ddcbccae3d --- /dev/null +++ b/apps/meteor/client/stores/Rooms.ts @@ -0,0 +1,6 @@ +import type { IRoom } from '@rocket.chat/core-typings'; + +import { createDocumentMapStore, createGlobalStore } from '../lib/cachedStores'; + +/** @deprecated prefer fetching data from the REST API, listening to changes via streamer events, and storing the state in a Tanstack Query */ +export const Rooms = createGlobalStore(createDocumentMapStore()); diff --git a/apps/meteor/client/stores/Settings.ts b/apps/meteor/client/stores/Settings.ts new file mode 100644 index 0000000000000..dfe68c881cfa2 --- /dev/null +++ b/apps/meteor/client/stores/Settings.ts @@ -0,0 +1,7 @@ +import type { ISetting } from '@rocket.chat/core-typings'; + +import { createDocumentMapStore, createGlobalStore } from '../lib/cachedStores'; + +export const PublicSettings = createGlobalStore(createDocumentMapStore()); + +export const PrivateSettings = createGlobalStore(createDocumentMapStore()); diff --git a/apps/meteor/client/stores/Subscriptions.ts b/apps/meteor/client/stores/Subscriptions.ts new file mode 100644 index 0000000000000..ca02ed9025a12 --- /dev/null +++ b/apps/meteor/client/stores/Subscriptions.ts @@ -0,0 +1,6 @@ +import type { SubscriptionWithRoom } from '@rocket.chat/ui-contexts'; + +import { createDocumentMapStore, createGlobalStore } from '../lib/cachedStores'; + +/** @deprecated prefer fetching data from the REST API, listening to changes via streamer events, and storing the state in a Tanstack Query */ +export const Subscriptions = createGlobalStore(createDocumentMapStore()); diff --git a/apps/meteor/client/stores/Users.ts b/apps/meteor/client/stores/Users.ts new file mode 100644 index 0000000000000..63125486bbc4f --- /dev/null +++ b/apps/meteor/client/stores/Users.ts @@ -0,0 +1,12 @@ +import type { IUser } from '@rocket.chat/core-typings'; + +import { createGlobalStore, MinimongoCollection } from '../lib/cachedStores'; + +class UsersCollection extends MinimongoCollection {} + +const collection = new UsersCollection(); + +/** @deprecated prefer fetching data from the REST API, listening to changes via streamer events, and storing the state in a Tanstack Query */ +export const Users = createGlobalStore(collection.use, { + collection, +}); diff --git a/apps/meteor/client/stores/index.ts b/apps/meteor/client/stores/index.ts new file mode 100644 index 0000000000000..780283a2e367e --- /dev/null +++ b/apps/meteor/client/stores/index.ts @@ -0,0 +1,7 @@ +export { Messages } from './Messages'; +export { Permissions } from './Permissions'; +export { PrivateSettings, PublicSettings } from './Settings'; +export { Roles } from './Roles'; +export { Rooms } from './Rooms'; +export { Subscriptions } from './Subscriptions'; +export { Users } from './Users'; diff --git a/apps/meteor/client/views/account/AccountRouter.tsx b/apps/meteor/client/views/account/AccountRouter.tsx index c44b05096c27e..ee0164773361e 100644 --- a/apps/meteor/client/views/account/AccountRouter.tsx +++ b/apps/meteor/client/views/account/AccountRouter.tsx @@ -4,7 +4,7 @@ import { Suspense, useEffect } from 'react'; import AccountSidebar from './AccountSidebar'; import PageSkeleton from '../../components/PageSkeleton'; -import SidebarPortal from '../../sidebar/SidebarPortal'; +import SidebarPortal from '../../portals/SidebarPortal'; type AccountRouterProps = { children?: ReactNode; diff --git a/apps/meteor/client/views/account/deviceManagement/DeviceManagementAccountTable/DeviceManagementAccountRow.tsx b/apps/meteor/client/views/account/deviceManagement/DeviceManagementAccountTable/DeviceManagementAccountRow.tsx index b4fa59a6a2dd3..6902cbe2b3dda 100644 --- a/apps/meteor/client/views/account/deviceManagement/DeviceManagementAccountTable/DeviceManagementAccountRow.tsx +++ b/apps/meteor/client/views/account/deviceManagement/DeviceManagementAccountTable/DeviceManagementAccountRow.tsx @@ -14,17 +14,9 @@ type DevicesRowProps = { deviceType?: string; deviceOSName?: string; loginAt: string; - onReload: () => void; }; -const DeviceManagementAccountRow = ({ - _id, - deviceName, - deviceType = 'browser', - deviceOSName, - loginAt, - onReload, -}: DevicesRowProps): ReactElement => { +const DeviceManagementAccountRow = ({ _id, deviceName, deviceType = 'browser', deviceOSName, loginAt }: DevicesRowProps): ReactElement => { const { t } = useTranslation(); const formatDateAndTime = useFormatDateAndTime(); const mediaQuery = useMediaQuery('(min-width: 1024px)'); @@ -43,7 +35,7 @@ const DeviceManagementAccountRow = ({ {formatDateAndTime(loginAt)} {mediaQuery && {_id}} - + ); diff --git a/apps/meteor/client/views/account/deviceManagement/DeviceManagementAccountTable/DeviceManagementAccountTable.tsx b/apps/meteor/client/views/account/deviceManagement/DeviceManagementAccountTable/DeviceManagementAccountTable.tsx index 600335646d444..6473b0101d528 100644 --- a/apps/meteor/client/views/account/deviceManagement/DeviceManagementAccountTable/DeviceManagementAccountTable.tsx +++ b/apps/meteor/client/views/account/deviceManagement/DeviceManagementAccountTable/DeviceManagementAccountTable.tsx @@ -69,7 +69,6 @@ const DeviceManagementAccountTable = (): ReactElement => { deviceType={session.device?.type} deviceOSName={session.device?.os.name} loginAt={session.loginAt} - onReload={queryResult.refetch} /> )} current={current} diff --git a/apps/meteor/client/views/account/profile/AccountProfileForm.tsx b/apps/meteor/client/views/account/profile/AccountProfileForm.tsx index b28b28c3f1f7b..04e1865ff30f1 100644 --- a/apps/meteor/client/views/account/profile/AccountProfileForm.tsx +++ b/apps/meteor/client/views/account/profile/AccountProfileForm.tsx @@ -20,7 +20,6 @@ import { useTranslation, useEndpoint, useUser, - useMethod, useLayout, } from '@rocket.chat/ui-contexts'; import { useMutation } from '@tanstack/react-query'; @@ -105,16 +104,15 @@ const AccountProfileForm = (props: AllHTMLAttributes): ReactEle } }; - // FIXME: replace to endpoint - const updateOwnBasicInfo = useMethod('saveUserProfile'); + const updateOwnBasicInfo = useEndpoint('POST', '/v1/users.updateOwnBasicInfo'); const updateAvatar = useUpdateAvatar(avatar, user?._id || ''); const handleSave = async ({ email, name, username, statusType, statusText, nickname, bio, customFields }: AccountProfileFormValues) => { try { - await updateOwnBasicInfo( - { - realname: name, + await updateOwnBasicInfo({ + data: { + name, ...(user ? getUserEmailAddress(user) !== email && { email } : {}), username, statusText, @@ -123,7 +121,7 @@ const AccountProfileForm = (props: AllHTMLAttributes): ReactEle bio, }, customFields, - ); + }); await updateAvatar(); dispatchToastMessage({ type: 'success', message: t('Profile_saved_successfully') }); diff --git a/apps/meteor/client/views/account/security/ChangePassword.tsx b/apps/meteor/client/views/account/security/ChangePassword.tsx index 5278007a73191..b8ef68f65c34c 100644 --- a/apps/meteor/client/views/account/security/ChangePassword.tsx +++ b/apps/meteor/client/views/account/security/ChangePassword.tsx @@ -1,6 +1,6 @@ import { Box, Field, FieldError, FieldGroup, FieldHint, FieldLabel, FieldRow, PasswordInput } from '@rocket.chat/fuselage'; import { PasswordVerifier, useValidatePassword } from '@rocket.chat/ui-client'; -import { useMethod, useToastMessageDispatch } from '@rocket.chat/ui-contexts'; +import { useEndpoint, useToastMessageDispatch } from '@rocket.chat/ui-contexts'; import type { AllHTMLAttributes } from 'react'; import { useId } from 'react'; import { Controller, useFormContext } from 'react-hook-form'; @@ -40,12 +40,15 @@ const ChangePassword = (props: AllHTMLAttributes) => { const passwordIsValid = useValidatePassword(password); const { allowPasswordChange } = useAllowPasswordChange(); - // FIXME: replace to endpoint - const updatePassword = useMethod('saveUserProfile'); + const updatePassword = useEndpoint('POST', '/v1/users.updateOwnBasicInfo'); const handleSave = async ({ password }: { password?: string }) => { try { - await updatePassword({ newPassword: password }, {}); + await updatePassword({ + data: { + newPassword: password, + }, + }); dispatchToastMessage({ type: 'success', message: t('Password_changed_successfully') }); reset(); } catch (error) { diff --git a/apps/meteor/client/views/account/security/EndToEnd.tsx b/apps/meteor/client/views/account/security/EndToEnd.tsx index bf7a8a1edfae0..e36ea8076bec6 100644 --- a/apps/meteor/client/views/account/security/EndToEnd.tsx +++ b/apps/meteor/client/views/account/security/EndToEnd.tsx @@ -6,7 +6,7 @@ import type { ComponentProps, ReactElement } from 'react'; import { useId, useCallback, useEffect } from 'react'; import { Controller, useForm } from 'react-hook-form'; -import { e2e } from '../../../../app/e2e/client/rocketchat.e2e'; +import { e2e } from '../../../lib/e2ee/rocketchat.e2e'; const EndToEnd = (props: ComponentProps): ReactElement => { const t = useTranslation(); diff --git a/apps/meteor/client/views/account/security/TwoFactorEmail.tsx b/apps/meteor/client/views/account/security/TwoFactorEmail.tsx index 5ff4e6d063102..17c261e8df5a0 100644 --- a/apps/meteor/client/views/account/security/TwoFactorEmail.tsx +++ b/apps/meteor/client/views/account/security/TwoFactorEmail.tsx @@ -1,10 +1,10 @@ import { Box, Field, FieldLabel, FieldRow, Margins, ToggleSwitch } from '@rocket.chat/fuselage'; -import { useUser } from '@rocket.chat/ui-contexts'; +import { useToastMessageDispatch, useUser } from '@rocket.chat/ui-contexts'; import type { ComponentProps, FormEvent } from 'react'; import { useCallback, useId } from 'react'; import { useTranslation } from 'react-i18next'; -import { useEndpointAction } from '../../../hooks/useEndpointAction'; +import { useEndpointMutation } from '../../../hooks/useEndpointMutation'; const TwoFactorEmail = (props: ComponentProps) => { const { t } = useTranslation(); @@ -13,11 +13,17 @@ const TwoFactorEmail = (props: ComponentProps) => { const isEnabled = user?.services?.email2fa?.enabled; - const enable2faAction = useEndpointAction('POST', '/v1/users.2fa.enableEmail', { - successMessage: t('Two-factor_authentication_enabled'), + const dispatchToastMessage = useToastMessageDispatch(); + + const { mutateAsync: enable2faAction } = useEndpointMutation('POST', '/v1/users.2fa.enableEmail', { + onSuccess: () => { + dispatchToastMessage({ type: 'success', message: t('Two-factor_authentication_enabled') }); + }, }); - const disable2faAction = useEndpointAction('POST', '/v1/users.2fa.disableEmail', { - successMessage: t('Two-factor_authentication_disabled'), + const { mutateAsync: disable2faAction } = useEndpointMutation('POST', '/v1/users.2fa.disableEmail', { + onSuccess: () => { + dispatchToastMessage({ type: 'success', message: t('Two-factor_authentication_disabled') }); + }, }); const handleEnable = useCallback( diff --git a/apps/meteor/client/views/admin/AdministrationLayout.tsx b/apps/meteor/client/views/admin/AdministrationLayout.tsx index a46f2b715132a..f81aa938c290d 100644 --- a/apps/meteor/client/views/admin/AdministrationLayout.tsx +++ b/apps/meteor/client/views/admin/AdministrationLayout.tsx @@ -1,7 +1,7 @@ import type { ReactNode } from 'react'; import AdminSidebar from './sidebar/AdminSidebar'; -import SidebarPortal from '../../sidebar/SidebarPortal'; +import SidebarPortal from '../../portals/SidebarPortal'; type AdministrationLayoutProps = { children?: ReactNode; diff --git a/apps/meteor/client/views/admin/customEmoji/AddCustomEmoji.tsx b/apps/meteor/client/views/admin/customEmoji/AddCustomEmoji.tsx index bf423e53cf998..b4d2ec04caef4 100644 --- a/apps/meteor/client/views/admin/customEmoji/AddCustomEmoji.tsx +++ b/apps/meteor/client/views/admin/customEmoji/AddCustomEmoji.tsx @@ -1,10 +1,11 @@ import { Box, Button, ButtonGroup, Margins, TextInput, Field, FieldLabel, FieldRow, FieldError, IconButton } from '@rocket.chat/fuselage'; +import { useToastMessageDispatch } from '@rocket.chat/ui-contexts'; import type { ReactElement, ChangeEvent } from 'react'; import { useCallback, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { ContextualbarScrollableContent, ContextualbarFooter } from '../../../components/Contextualbar'; -import { useEndpointUpload } from '../../../hooks/useEndpointUpload'; +import { useEndpointUploadMutation } from '../../../hooks/useEndpointUploadMutation'; import { useSingleFileInput } from '../../../hooks/useSingleFileInput'; type AddCustomEmojiProps = { @@ -29,7 +30,15 @@ const AddCustomEmoji = ({ close, onChange, ...props }: AddCustomEmojiProps): Rea [setEmojiFile], ); - const saveAction = useEndpointUpload('/v1/emoji-custom.create', t('Custom_Emoji_Added_Successfully')); + const dispatchToastMessage = useToastMessageDispatch(); + + const { mutateAsync: saveAction } = useEndpointUploadMutation('/v1/emoji-custom.create', { + onSuccess: () => { + dispatchToastMessage({ type: 'success', message: t('Custom_Emoji_Added_Successfully') }); + onChange(); + close(); + }, + }); const handleSave = useCallback(async () => { if (!name) { @@ -48,13 +57,8 @@ const AddCustomEmoji = ({ close, onChange, ...props }: AddCustomEmojiProps): Rea formData.append('emoji', emojiFile); formData.append('name', name); formData.append('aliases', aliases); - const result = (await saveAction(formData)) as { success: boolean }; - - if (result.success) { - onChange(); - close(); - } - }, [emojiFile, name, aliases, saveAction, onChange, close]); + await saveAction(formData); + }, [emojiFile, name, aliases, saveAction]); const [clickUpload] = useSingleFileInput(setEmojiPreview, 'emoji'); diff --git a/apps/meteor/client/views/admin/customEmoji/EditCustomEmoji.tsx b/apps/meteor/client/views/admin/customEmoji/EditCustomEmoji.tsx index 034d29ab04968..dfc1705868087 100644 --- a/apps/meteor/client/views/admin/customEmoji/EditCustomEmoji.tsx +++ b/apps/meteor/client/views/admin/customEmoji/EditCustomEmoji.tsx @@ -12,14 +12,14 @@ import { IconButton, } from '@rocket.chat/fuselage'; import { GenericModal } from '@rocket.chat/ui-client'; -import { useSetModal, useToastMessageDispatch, useAbsoluteUrl } from '@rocket.chat/ui-contexts'; +import { useSetModal, useAbsoluteUrl, useToastMessageDispatch } from '@rocket.chat/ui-contexts'; import type { ChangeEvent } from 'react'; import { useCallback, useState, useMemo, useEffect } from 'react'; import { useTranslation } from 'react-i18next'; import { ContextualbarScrollableContent, ContextualbarFooter } from '../../../components/Contextualbar'; -import { useEndpointAction } from '../../../hooks/useEndpointAction'; -import { useEndpointUpload } from '../../../hooks/useEndpointUpload'; +import { useEndpointMutation } from '../../../hooks/useEndpointMutation'; +import { useEndpointUploadMutation } from '../../../hooks/useEndpointUploadMutation'; import { useSingleFileInput } from '../../../hooks/useSingleFileInput'; type EditCustomEmojiProps = { @@ -36,7 +36,6 @@ type EditCustomEmojiProps = { const EditCustomEmoji = ({ close, onChange, data, ...props }: EditCustomEmojiProps) => { const { t } = useTranslation(); - const dispatchToastMessage = useToastMessageDispatch(); const setModal = useSetModal(); const absoluteUrl = useAbsoluteUrl(); const [errors, setErrors] = useState({ name: false, aliases: false }); @@ -68,7 +67,13 @@ const EditCustomEmoji = ({ close, onChange, data, ...props }: EditCustomEmojiPro [previousName, name, aliases, previousAliases, emojiFile], ); - const saveAction = useEndpointUpload('/v1/emoji-custom.update', t('Custom_Emoji_Updated_Successfully')); + const { mutateAsync: saveAction } = useEndpointUploadMutation('/v1/emoji-custom.update', { + onSuccess: () => { + dispatchToastMessage({ type: 'success', message: t('Custom_Emoji_Updated_Successfully') }); + onChange(); + close(); + }, + }); const handleSave = useCallback(async () => { if (!name) { @@ -88,30 +93,28 @@ const EditCustomEmoji = ({ close, onChange, data, ...props }: EditCustomEmojiPro formData.append('_id', _id); formData.append('name', name); formData.append('aliases', aliases); - const result = (await saveAction(formData)) as { success: boolean }; - if (result.success) { + await saveAction(formData); + }, [emojiFile, _id, name, aliases, saveAction, newEmojiPreview]); + + const dispatchToastMessage = useToastMessageDispatch(); + + const { mutateAsync: deleteAction } = useEndpointMutation('POST', '/v1/emoji-custom.delete', { + onSuccess: () => { + dispatchToastMessage({ type: 'success', message: t('Custom_Emoji_Has_Been_Deleted') }); + }, + onSettled: () => { onChange(); + setModal(null); close(); - } - }, [emojiFile, _id, name, aliases, saveAction, onChange, close, newEmojiPreview]); - - const deleteAction = useEndpointAction('POST', '/v1/emoji-custom.delete'); + }, + }); const handleDeleteButtonClick = useCallback(() => { - const handleDelete = async (): Promise => { - try { - await deleteAction({ emojiId: _id }); - dispatchToastMessage({ type: 'success', message: t('Custom_Emoji_Has_Been_Deleted') }); - } catch (error) { - dispatchToastMessage({ type: 'error', message: error }); - } finally { - onChange(); - setModal(null); - close(); - } + const handleDelete = async () => { + await deleteAction({ emojiId: _id }); }; - const handleCancel = (): void => { + const handleCancel = () => { setModal(null); }; @@ -120,7 +123,7 @@ const EditCustomEmoji = ({ close, onChange, data, ...props }: EditCustomEmojiPro {t('Custom_Emoji_Delete_Warning')} , ); - }, [setModal, deleteAction, _id, dispatchToastMessage, t, onChange, close]); + }, [setModal, deleteAction, _id, t]); const handleChangeAliases = useCallback( (e: ChangeEvent) => { diff --git a/apps/meteor/client/views/admin/deviceManagement/DeviceManagementAdminPage.tsx b/apps/meteor/client/views/admin/deviceManagement/DeviceManagementAdminPage.tsx index 495fdb2654605..c6dbe9176e07d 100644 --- a/apps/meteor/client/views/admin/deviceManagement/DeviceManagementAdminPage.tsx +++ b/apps/meteor/client/views/admin/deviceManagement/DeviceManagementAdminPage.tsx @@ -1,6 +1,4 @@ import { useRouteParameter, useRouter } from '@rocket.chat/ui-contexts'; -import type { ReactElement } from 'react'; -import { useRef } from 'react'; import { useTranslation } from 'react-i18next'; import DeviceManagementAdminTable from './DeviceManagementAdminTable'; @@ -8,25 +6,23 @@ import DeviceManagementInfo from './DeviceManagementInfo'; import { ContextualbarDialog } from '../../../components/Contextualbar'; import { Page, PageHeader, PageContent } from '../../../components/Page'; -const DeviceManagementAdminPage = (): ReactElement => { +const DeviceManagementAdminPage = () => { const { t } = useTranslation(); const router = useRouter(); const context = useRouteParameter('context'); const deviceId = useRouteParameter('id'); - const reloadRef = useRef(() => null); - return ( - + {context === 'info' && deviceId && ( router.navigate('/admin/device-management')}> - + )} diff --git a/apps/meteor/client/views/admin/deviceManagement/DeviceManagementAdminTable/DeviceManagementAdminRow.tsx b/apps/meteor/client/views/admin/deviceManagement/DeviceManagementAdminTable/DeviceManagementAdminRow.tsx index d591b8dd24214..a7593cd6706e6 100644 --- a/apps/meteor/client/views/admin/deviceManagement/DeviceManagementAdminTable/DeviceManagementAdminRow.tsx +++ b/apps/meteor/client/views/admin/deviceManagement/DeviceManagementAdminTable/DeviceManagementAdminRow.tsx @@ -3,7 +3,7 @@ import { useMediaQuery, useEffectEvent } from '@rocket.chat/fuselage-hooks'; import type { GenericMenuItemProps } from '@rocket.chat/ui-client'; import { GenericMenu } from '@rocket.chat/ui-client'; import { useRoute } from '@rocket.chat/ui-contexts'; -import type { KeyboardEvent, ReactElement } from 'react'; +import type { KeyboardEvent } from 'react'; import { useCallback } from 'react'; import { useTranslation } from 'react-i18next'; @@ -21,7 +21,6 @@ type DeviceRowProps = { deviceOSName?: string; loginAt: string; rcVersion?: string; - onReload: () => void; }; const DeviceManagementAdminRow = ({ @@ -33,8 +32,7 @@ const DeviceManagementAdminRow = ({ deviceOSName = '', loginAt, rcVersion, - onReload, -}: DeviceRowProps): ReactElement => { +}: DeviceRowProps) => { const { t } = useTranslation(); const deviceManagementRouter = useRoute('device-management'); const formatDateAndTime = useFormatDateAndTime(); @@ -64,7 +62,7 @@ const DeviceManagementAdminRow = ({ id: 'logoutDevice', icon: 'sign-out', content: t('Logout_Device'), - onClick: (): void => handleDeviceLogout(onReload), + onClick: () => handleDeviceLogout(), }, ]; @@ -82,7 +80,7 @@ const DeviceManagementAdminRow = ({ {mediaQuery && {formatDateAndTime(loginAt)}} {mediaQuery && {_id}} {mediaQuery && {ip}} - e.stopPropagation()}> + e.stopPropagation()}> diff --git a/apps/meteor/client/views/admin/deviceManagement/DeviceManagementAdminTable/DeviceManagementAdminTable.tsx b/apps/meteor/client/views/admin/deviceManagement/DeviceManagementAdminTable/DeviceManagementAdminTable.tsx index 1d6be57c3da9b..6861114b2f081 100644 --- a/apps/meteor/client/views/admin/deviceManagement/DeviceManagementAdminTable/DeviceManagementAdminTable.tsx +++ b/apps/meteor/client/views/admin/deviceManagement/DeviceManagementAdminTable/DeviceManagementAdminTable.tsx @@ -2,7 +2,6 @@ import type { DeviceManagementPopulatedSession, DeviceManagementSession, Seriali import { useDebouncedValue, useMediaQuery } from '@rocket.chat/fuselage-hooks'; import { useEndpoint } from '@rocket.chat/ui-contexts'; import { useQuery } from '@tanstack/react-query'; -import type { ReactElement, MutableRefObject } from 'react'; import { useState, useMemo } from 'react'; import { useTranslation } from 'react-i18next'; @@ -25,7 +24,7 @@ const isSessionPopulatedSession = ( session: Serialized, ): session is Serialized => '_user' in session; -const DeviceManagementAdminTable = ({ reloadRef }: { reloadRef: MutableRefObject<() => void> }): ReactElement => { +const DeviceManagementAdminTable = () => { const { t } = useTranslation(); const [text, setText] = useState(''); const { current, itemsPerPage, setCurrent, setItemsPerPage, ...paginationProps } = usePagination(); @@ -50,8 +49,6 @@ const DeviceManagementAdminTable = ({ reloadRef }: { reloadRef: MutableRefObject queryFn: () => listAllSessions(query), }); - reloadRef.current = queryResult.refetch; - const mediaQuery = useMediaQuery('(min-width: 1024px)'); const headers = useMemo( @@ -84,7 +81,7 @@ const DeviceManagementAdminTable = ({ reloadRef }: { reloadRef: MutableRefObject ( + renderRow={(session) => ( )} current={current} diff --git a/apps/meteor/client/views/admin/deviceManagement/DeviceManagementInfo/DeviceManagementInfo.tsx b/apps/meteor/client/views/admin/deviceManagement/DeviceManagementInfo/DeviceManagementInfo.tsx index 45e958fa2113b..ffed8290ab354 100644 --- a/apps/meteor/client/views/admin/deviceManagement/DeviceManagementInfo/DeviceManagementInfo.tsx +++ b/apps/meteor/client/views/admin/deviceManagement/DeviceManagementInfo/DeviceManagementInfo.tsx @@ -17,11 +17,9 @@ import { InfoPanel, InfoPanelField, InfoPanelLabel, InfoPanelText } from '../../ import { useDeviceLogout } from '../../../../hooks/useDeviceLogout'; import { useFormatDateAndTime } from '../../../../hooks/useFormatDateAndTime'; -type DeviceManagementInfoProps = DeviceManagementPopulatedSession & { - onReload: () => void; -}; +type DeviceManagementInfoProps = DeviceManagementPopulatedSession; -const DeviceManagementInfo = ({ device, sessionId, loginAt, ip, userId, _user, onReload }: DeviceManagementInfoProps): ReactElement => { +const DeviceManagementInfo = ({ device, sessionId, loginAt, ip, userId, _user }: DeviceManagementInfoProps): ReactElement => { const { t } = useTranslation(); const deviceManagementRouter = useRoute('device-management'); const formatDateAndTime = useFormatDateAndTime(); @@ -91,7 +89,7 @@ const DeviceManagementInfo = ({ device, sessionId, loginAt, ip, userId, _user, o - diff --git a/apps/meteor/client/views/admin/deviceManagement/DeviceManagementInfo/DeviceManagementInfoWithData.tsx b/apps/meteor/client/views/admin/deviceManagement/DeviceManagementInfo/DeviceManagementInfoWithData.tsx index 4c0ef7ef6cb8b..639afdad59fae 100644 --- a/apps/meteor/client/views/admin/deviceManagement/DeviceManagementInfo/DeviceManagementInfoWithData.tsx +++ b/apps/meteor/client/views/admin/deviceManagement/DeviceManagementInfo/DeviceManagementInfoWithData.tsx @@ -2,7 +2,6 @@ import type { Serialized, DeviceManagementPopulatedSession } from '@rocket.chat/ import { Box, States, StatesIcon, StatesTitle, StatesSubtitle } from '@rocket.chat/fuselage'; import { useEndpoint } from '@rocket.chat/ui-contexts'; import { useQuery } from '@tanstack/react-query'; -import type { ReactElement } from 'react'; import { useTranslation } from 'react-i18next'; import DeviceManagementInfo from './DeviceManagementInfo'; @@ -25,7 +24,7 @@ const convertSessionFromAPI = ({ ...rest, }); -const DeviceInfoWithData = ({ deviceId, onReload }: { deviceId: string; onReload: () => void }): ReactElement => { +const DeviceInfoWithData = ({ deviceId }: { deviceId: string }) => { const { t } = useTranslation(); const getSessionInfo = useEndpoint('GET', '/v1/sessions/info.admin'); @@ -59,7 +58,7 @@ const DeviceInfoWithData = ({ deviceId, onReload }: { deviceId: string; onReload ); } - return ; + return ; }; export default DeviceInfoWithData; diff --git a/apps/meteor/client/views/admin/engagementDashboard/messages/useMessagesSent.spec.ts b/apps/meteor/client/views/admin/engagementDashboard/messages/useMessagesSent.spec.ts index 906885b2ebe0b..1922ed2295895 100644 --- a/apps/meteor/client/views/admin/engagementDashboard/messages/useMessagesSent.spec.ts +++ b/apps/meteor/client/views/admin/engagementDashboard/messages/useMessagesSent.spec.ts @@ -56,7 +56,6 @@ it('should return utc time', async () => { }, }; const { result } = renderHook(() => useMessagesSent({ period: 'this week', utc: true }), { - legacyRoot: true, wrapper: mockAppRoot() .withEndpoint('GET', '/v1/engagement-dashboard/messages/messages-sent', () => expectedResult) .build(), @@ -117,7 +116,6 @@ it.skip('should return local time', async () => { }, }; const { result } = renderHook(() => useMessagesSent({ period: 'this week', utc: false }), { - legacyRoot: true, wrapper: mockAppRoot() .withEndpoint('GET', '/v1/engagement-dashboard/messages/messages-sent', () => expectedResult) .build(), diff --git a/apps/meteor/client/views/admin/engagementDashboard/users/useHourlyChatActivity.spec.ts b/apps/meteor/client/views/admin/engagementDashboard/users/useHourlyChatActivity.spec.ts index e27e0fe6e2cd2..2b5183bd2897f 100644 --- a/apps/meteor/client/views/admin/engagementDashboard/users/useHourlyChatActivity.spec.ts +++ b/apps/meteor/client/views/admin/engagementDashboard/users/useHourlyChatActivity.spec.ts @@ -22,7 +22,6 @@ it('should return utc time', async () => { success: true, }; const { result } = renderHook(() => useHourlyChatActivity({ displacement: 0, utc: true }), { - legacyRoot: true, wrapper: mockAppRoot() .withEndpoint('GET', '/v1/engagement-dashboard/users/chat-busier/hourly-data', () => expectedResult) .build(), @@ -76,7 +75,6 @@ it.skip('should return local time', async () => { }; const { result } = renderHook(() => useHourlyChatActivity({ displacement: 0, utc: false }), { - legacyRoot: true, wrapper: mockAppRoot() .withEndpoint('GET', '/v1/engagement-dashboard/users/chat-busier/hourly-data', () => receivedData) .build(), diff --git a/apps/meteor/client/views/admin/engagementDashboard/users/useNewUsers.spec.ts b/apps/meteor/client/views/admin/engagementDashboard/users/useNewUsers.spec.ts index e1da141053849..6d59f392505d1 100644 --- a/apps/meteor/client/views/admin/engagementDashboard/users/useNewUsers.spec.ts +++ b/apps/meteor/client/views/admin/engagementDashboard/users/useNewUsers.spec.ts @@ -56,7 +56,6 @@ it('should return utc time', async () => { }, }; const { result } = renderHook(() => useNewUsers({ period: 'this week', utc: true }), { - legacyRoot: true, wrapper: mockAppRoot() .withEndpoint('GET', '/v1/engagement-dashboard/users/new-users', () => expectedResult) .build(), @@ -117,7 +116,6 @@ it.skip('should return local time', async () => { }, }; const { result } = renderHook(() => useNewUsers({ period: 'this week', utc: false }), { - legacyRoot: true, wrapper: mockAppRoot() .withEndpoint('GET', '/v1/engagement-dashboard/users/new-users', () => expectedResult) .build(), diff --git a/apps/meteor/client/views/admin/permissions/hooks/useFilteredPermissions.ts b/apps/meteor/client/views/admin/permissions/hooks/useFilteredPermissions.ts index 0e1506063599f..fd6c1eb6e10ee 100644 --- a/apps/meteor/client/views/admin/permissions/hooks/useFilteredPermissions.ts +++ b/apps/meteor/client/views/admin/permissions/hooks/useFilteredPermissions.ts @@ -2,7 +2,7 @@ import { useDebouncedValue } from '@rocket.chat/fuselage-hooks'; import { useMemo } from 'react'; import { useTranslation } from 'react-i18next'; -import { Permissions } from '../../../../../app/models/client'; +import { Permissions } from '../../../../stores'; import { filterPermissionKeys, mapPermissionKeys } from '../helpers/mapPermissionKeys'; export const useFilteredPermissions = ({ filter }: { filter: string }) => { diff --git a/apps/meteor/client/views/admin/permissions/hooks/usePermissionsAndRoles.ts b/apps/meteor/client/views/admin/permissions/hooks/usePermissionsAndRoles.ts index e22167b2b6e02..a7c1c33b48513 100644 --- a/apps/meteor/client/views/admin/permissions/hooks/usePermissionsAndRoles.ts +++ b/apps/meteor/client/views/admin/permissions/hooks/usePermissionsAndRoles.ts @@ -4,8 +4,8 @@ import { useShallow } from 'zustand/shallow'; import { useFilteredPermissions } from './useFilteredPermissions'; import { CONSTANTS } from '../../../../../app/authorization/lib'; -import { Permissions, Roles } from '../../../../../app/models/client'; -import { pipe } from '../../../../lib/cachedCollections'; +import { pipe } from '../../../../lib/cachedStores'; +import { Permissions, Roles } from '../../../../stores'; export const usePermissionsAndRoles = ( type = 'permissions', diff --git a/apps/meteor/client/views/admin/permissions/hooks/useRole.ts b/apps/meteor/client/views/admin/permissions/hooks/useRole.ts index f1fac8c773575..51f5f413799d5 100644 --- a/apps/meteor/client/views/admin/permissions/hooks/useRole.ts +++ b/apps/meteor/client/views/admin/permissions/hooks/useRole.ts @@ -1,5 +1,5 @@ import type { IRole } from '@rocket.chat/core-typings'; -import { Roles } from '../../../../../app/models/client'; +import { Roles } from '../../../../stores'; export const useRole = (_id?: IRole['_id']) => Roles.use((state) => (_id ? state.get(_id) : undefined)); diff --git a/apps/meteor/client/views/admin/users/AdminUserForm.tsx b/apps/meteor/client/views/admin/users/AdminUserForm.tsx index 5b0fdb6b6dc18..e84c20f54c12f 100644 --- a/apps/meteor/client/views/admin/users/AdminUserForm.tsx +++ b/apps/meteor/client/views/admin/users/AdminUserForm.tsx @@ -41,7 +41,7 @@ import { validateEmail } from '../../../../lib/emailValidator'; import { parseCSV } from '../../../../lib/utils/parseCSV'; import { ContextualbarScrollableContent, ContextualbarFooter } from '../../../components/Contextualbar'; import UserAvatarEditor from '../../../components/avatar/UserAvatarEditor'; -import { useEndpointAction } from '../../../hooks/useEndpointAction'; +import { useEndpointMutation } from '../../../hooks/useEndpointMutation'; import { useUpdateAvatar } from '../../../hooks/useUpdateAvatar'; import { USER_STATUS_TEXT_MAX_LENGTH, BIO_TEXT_MAX_LENGTH } from '../../../lib/constants'; @@ -121,7 +121,7 @@ const AdminUserForm = ({ userData, onReload, context, refetchUserFormData, roleD const { avatar, username, setRandomPassword, password, name: userFullName } = watch(); - const eventStats = useEndpointAction('POST', '/v1/statistics.telemetry'); + const { mutateAsync: eventStats } = useEndpointMutation('POST', '/v1/statistics.telemetry'); const updateUserAction = useEndpoint('POST', '/v1/users.update'); const createUserAction = useEndpoint('POST', '/v1/users.create'); diff --git a/apps/meteor/client/views/admin/users/AdminUserInfoActions.tsx b/apps/meteor/client/views/admin/users/AdminUserInfoActions.tsx index db3fe9c2f7583..18b02b255735d 100644 --- a/apps/meteor/client/views/admin/users/AdminUserInfoActions.tsx +++ b/apps/meteor/client/views/admin/users/AdminUserInfoActions.tsx @@ -55,11 +55,7 @@ const AdminUserInfoActions = ({ return [...actionsDefinition.map(mapAction), menu].filter(Boolean); }, [actionsDefinition, menu]); - return ( - - {actions} - - ); + return {actions}; }; export default AdminUserInfoActions; diff --git a/apps/meteor/client/views/admin/users/UsersTable/UsersTableRow.tsx b/apps/meteor/client/views/admin/users/UsersTable/UsersTableRow.tsx index b0eef5b28e759..a8ec72c5285e5 100644 --- a/apps/meteor/client/views/admin/users/UsersTable/UsersTableRow.tsx +++ b/apps/meteor/client/views/admin/users/UsersTable/UsersTableRow.tsx @@ -8,9 +8,9 @@ import type { KeyboardEvent, MouseEvent, ReactElement } from 'react'; import { useMemo } from 'react'; import { useTranslation } from 'react-i18next'; -import { Roles } from '../../../../../app/models/client/models/Roles'; import { GenericTableRow, GenericTableCell } from '../../../../components/GenericTable'; import { UserStatus } from '../../../../components/UserStatus'; +import { Roles } from '../../../../stores'; import type { AdminUsersTab } from '../AdminUsersPage'; import { useChangeAdminStatusAction } from '../hooks/useChangeAdminStatusAction'; import { useChangeUserStatusAction } from '../hooks/useChangeUserStatusAction'; diff --git a/apps/meteor/client/views/admin/workspace/DeploymentCard/DeploymentCard.tsx b/apps/meteor/client/views/admin/workspace/DeploymentCard/DeploymentCard.tsx index 429c0e8edba67..fe724f60064ba 100644 --- a/apps/meteor/client/views/admin/workspace/DeploymentCard/DeploymentCard.tsx +++ b/apps/meteor/client/views/admin/workspace/DeploymentCard/DeploymentCard.tsx @@ -30,7 +30,7 @@ const DeploymentCard = ({ serverInfo: { info, cloudWorkspaceId }, statistics, in }); return ( - + diff --git a/apps/meteor/client/views/e2e/EnterE2EPasswordModal.tsx b/apps/meteor/client/views/e2e/EnterE2EPasswordModal.tsx deleted file mode 100644 index d14eb3ece96ee..0000000000000 --- a/apps/meteor/client/views/e2e/EnterE2EPasswordModal.tsx +++ /dev/null @@ -1,64 +0,0 @@ -import { Box, PasswordInput, Field, FieldGroup, FieldRow, FieldError } from '@rocket.chat/fuselage'; -import { useEffectEvent } from '@rocket.chat/fuselage-hooks'; -import { GenericModal } from '@rocket.chat/ui-client'; -import DOMPurify from 'dompurify'; -import type { ChangeEvent, FormEvent, ReactElement } from 'react'; -import { useState, useCallback } from 'react'; -import { useTranslation } from 'react-i18next'; - -const EnterE2EPasswordModal = ({ - onConfirm, - onClose, - onCancel, -}: { - onConfirm: (password: string) => void; - onClose: () => void; - onCancel: () => void; -}): ReactElement => { - const { t } = useTranslation(); - const [password, setPassword] = useState(''); - const [passwordError, setPasswordError] = useState(); - - const handleChange = useCallback( - (e: ChangeEvent) => { - e.target.value !== '' && setPasswordError(undefined); - setPassword(e.currentTarget.value); - }, - [setPassword], - ); - - const handleConfirm = useEffectEvent((e: FormEvent): void => { - e.preventDefault(); - if (password === '') { - setPasswordError(t('Invalid_pass')); - return; - } - - return onConfirm(password); - }); - - return ( - } - variant='warning' - title={t('Enter_E2E_password')} - icon='warning' - cancelText={t('Do_It_Later')} - confirmText={t('Enable_encryption')} - onClose={onClose} - onCancel={onCancel} - > - - - - - - - {passwordError} - - - - ); -}; - -export default EnterE2EPasswordModal; diff --git a/apps/meteor/client/views/e2e/EnterE2EPasswordModal/EnterE2EPasswordModal.spec.tsx b/apps/meteor/client/views/e2e/EnterE2EPasswordModal/EnterE2EPasswordModal.spec.tsx new file mode 100644 index 0000000000000..0eb37d9037ad0 --- /dev/null +++ b/apps/meteor/client/views/e2e/EnterE2EPasswordModal/EnterE2EPasswordModal.spec.tsx @@ -0,0 +1,34 @@ +/* eslint-disable @typescript-eslint/naming-convention */ +import { mockAppRoot } from '@rocket.chat/mock-providers'; +import { composeStories } from '@storybook/react'; +import { render, screen } from '@testing-library/react'; +import { axe } from 'jest-axe'; + +import EnterE2EPasswordModal from '.'; +import * as stories from './EnterE2EPasswordModal.stories'; + +const testCases = Object.values(composeStories(stories)).map((Story) => [Story.storyName || 'Story', Story]); + +test.each(testCases)(`renders %s without crashing`, async (_storyname, Story) => { + const { baseElement } = render(); + expect(baseElement).toMatchSnapshot(); +}); + +test.each(testCases)('%s should have no a11y violations', async (_storyname, Story) => { + const { container } = render(); + + const results = await axe(container); + expect(results).toHaveNoViolations(); +}); + +it('should render modal and the password input should be focused', () => { + const inputPlaceholder = 'Please enter your E2E password'; + render( undefined} onClose={() => undefined} onCancel={() => undefined} />, { + wrapper: mockAppRoot() + .withTranslations('en', 'core', { + Please_enter_E2EE_password: inputPlaceholder, + }) + .build(), + }); + expect(screen.getByPlaceholderText(inputPlaceholder)).toHaveFocus(); +}); diff --git a/apps/meteor/client/views/e2e/EnterE2EPasswordModal/EnterE2EPasswordModal.stories.tsx b/apps/meteor/client/views/e2e/EnterE2EPasswordModal/EnterE2EPasswordModal.stories.tsx new file mode 100644 index 0000000000000..6262ffb3c13c5 --- /dev/null +++ b/apps/meteor/client/views/e2e/EnterE2EPasswordModal/EnterE2EPasswordModal.stories.tsx @@ -0,0 +1,15 @@ +import { mockAppRoot } from '@rocket.chat/mock-providers'; +import { action } from '@storybook/addon-actions'; +import type { Meta, StoryFn } from '@storybook/react'; + +import EnterE2EPasswordModal from '.'; + +export default { + title: 'views/EnterE2EPasswordModal', + component: EnterE2EPasswordModal, + decorators: [mockAppRoot().withTranslations('en', 'core', {}).buildStoryDecorator()], +} satisfies Meta; + +export const Default: StoryFn = () => ( + +); diff --git a/apps/meteor/client/views/e2e/EnterE2EPasswordModal/EnterE2EPasswordModal.tsx b/apps/meteor/client/views/e2e/EnterE2EPasswordModal/EnterE2EPasswordModal.tsx new file mode 100644 index 0000000000000..68199d9545824 --- /dev/null +++ b/apps/meteor/client/views/e2e/EnterE2EPasswordModal/EnterE2EPasswordModal.tsx @@ -0,0 +1,75 @@ +import { Box, PasswordInput, Field, FieldGroup, FieldRow, FieldError } from '@rocket.chat/fuselage'; +import { GenericModal } from '@rocket.chat/ui-client'; +import DOMPurify from 'dompurify'; +import { useEffect, useId } from 'react'; +import { Controller, useForm } from 'react-hook-form'; +import { useTranslation } from 'react-i18next'; + +type EnterE2EPasswordModalProps = { + onConfirm: (password: string) => void; + onClose: () => void; + onCancel: () => void; +}; + +const EnterE2EPasswordModal = ({ onConfirm, onClose, onCancel }: EnterE2EPasswordModalProps) => { + const { t } = useTranslation(); + const { + handleSubmit, + control, + setFocus, + formState: { errors }, + } = useForm({ + defaultValues: { + password: '', + }, + }); + + const passwordInputId = useId(); + + useEffect(() => { + setFocus('password'); + }, [setFocus]); + + return ( + onConfirm(password))} {...props} />} + variant='warning' + title={t('Enter_E2E_password')} + icon='warning' + cancelText={t('Do_It_Later')} + confirmText={t('Enable_encryption')} + onClose={onClose} + onCancel={onCancel} + > + + + + + ( + + )} + /> + + {errors.password && ( + + {errors.password.message} + + )} + + + + ); +}; + +export default EnterE2EPasswordModal; diff --git a/apps/meteor/client/views/e2e/EnterE2EPasswordModal/__snapshots__/EnterE2EPasswordModal.spec.tsx.snap b/apps/meteor/client/views/e2e/EnterE2EPasswordModal/__snapshots__/EnterE2EPasswordModal.spec.tsx.snap new file mode 100644 index 0000000000000..78af4fcea26c4 --- /dev/null +++ b/apps/meteor/client/views/e2e/EnterE2EPasswordModal/__snapshots__/EnterE2EPasswordModal.spec.tsx.snap @@ -0,0 +1,138 @@ +// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing + +exports[`renders Default without crashing 1`] = ` + +
+ +
+
+
+
+ +
+
+

+ Enter_E2E_password +

+
+ +
+
+
+
+
+ E2E_password_request_text +
+
+
+ + + +
+
+
+
+ +
+
+
+ +`; diff --git a/apps/meteor/client/views/e2e/EnterE2EPasswordModal/index.ts b/apps/meteor/client/views/e2e/EnterE2EPasswordModal/index.ts new file mode 100644 index 0000000000000..377666fcb93ec --- /dev/null +++ b/apps/meteor/client/views/e2e/EnterE2EPasswordModal/index.ts @@ -0,0 +1 @@ +export { default } from './EnterE2EPasswordModal'; diff --git a/apps/meteor/client/views/hooks/roomActions/useDeleteRoom.tsx b/apps/meteor/client/views/hooks/roomActions/useDeleteRoom.tsx index b7343d0acc16b..155097874efdc 100644 --- a/apps/meteor/client/views/hooks/roomActions/useDeleteRoom.tsx +++ b/apps/meteor/client/views/hooks/roomActions/useDeleteRoom.tsx @@ -3,9 +3,10 @@ import { isRoomFederated } from '@rocket.chat/core-typings'; import { useEffectEvent } from '@rocket.chat/fuselage-hooks'; import { GenericModal } from '@rocket.chat/ui-client'; import { useSetModal, useToastMessageDispatch, useRouter, usePermission, useEndpoint } from '@rocket.chat/ui-contexts'; -import { keepPreviousData, useMutation, useQuery } from '@tanstack/react-query'; +import { useMutation } from '@tanstack/react-query'; import { useTranslation } from 'react-i18next'; +import { useTeamInfoQuery } from '../../../hooks/useTeamInfoQuery'; import DeleteTeamModal from '../../teams/contextualBar/info/DeleteTeam'; export const useDeleteRoom = (room: IRoom | Pick, { reload }: { reload?: () => void } = {}) => { @@ -19,19 +20,12 @@ export const useDeleteRoom = (room: IRoom | Pick, { const deleteRoomEndpoint = useEndpoint('POST', '/v1/rooms.delete'); const deleteTeamEndpoint = useEndpoint('POST', '/v1/teams.delete'); - const teamsInfoEndpoint = useEndpoint('GET', '/v1/teams.info'); const teamId = room.teamId || ''; - const { data: teamInfoData } = useQuery({ - queryKey: ['teamId', teamId], - queryFn: async () => teamsInfoEndpoint({ teamId }), - placeholderData: keepPreviousData, - retry: false, - enabled: room.teamId !== '', - }); + const { data: teamInfo } = useTeamInfoQuery(teamId); const hasPermissionToDeleteRoom = usePermission(`delete-${room.t}`, room._id); - const hasPermissionToDeleteTeamRoom = usePermission(`delete-team-${room.t === 'c' ? 'channel' : 'group'}`, teamInfoData?.teamInfo.roomId); + const hasPermissionToDeleteTeamRoom = usePermission(`delete-team-${room.t === 'c' ? 'channel' : 'group'}`, teamInfo?.roomId); const isTeamRoom = room.teamId; const canDeleteRoom = isRoomFederated(room) ? false : hasPermissionToDeleteRoom && (!isTeamRoom || hasPermissionToDeleteTeamRoom); diff --git a/apps/meteor/client/views/hooks/useMemberList.spec.tsx b/apps/meteor/client/views/hooks/useMemberList.spec.tsx index 88aa47c076056..2d762c5268bb3 100644 --- a/apps/meteor/client/views/hooks/useMemberList.spec.tsx +++ b/apps/meteor/client/views/hooks/useMemberList.spec.tsx @@ -85,7 +85,7 @@ describe('useMembersList', () => { debouncedText: '', roomType: 'c', }), - { legacyRoot: true, wrapper: wrapper.build() }, + { wrapper: wrapper.build() }, ); await expect(result.current.isLoading).toBe(true); @@ -107,7 +107,7 @@ describe('useMembersList', () => { debouncedText: '', roomType: 'p', }), - { legacyRoot: true, wrapper: wrapper.build() }, + { wrapper: wrapper.build() }, ); expect(result.current.isLoading).toBe(true); @@ -130,7 +130,7 @@ describe('useMembersList', () => { debouncedText: '', roomType: 'd', }), - { legacyRoot: true, wrapper: wrapper.build() }, + { wrapper: wrapper.build() }, ); await waitFor(() => expect(result.current.isLoading).toBe(false)); @@ -152,7 +152,6 @@ describe('useMembersList', () => { roomType: 'c', }), { - legacyRoot: true, wrapper: wrapper .withEndpoint('GET', '/v1/rooms.membersOrderedByRole', ({ offset }) => { if (offset === 0) { @@ -199,7 +198,7 @@ describe('useMembersList', () => { debouncedText: '', roomType: 'c', }), - { legacyRoot: true, wrapper: wrapper.build() }, + { wrapper: wrapper.build() }, ); await waitFor(() => expect(subscribeMock).toHaveBeenCalledWith('roles-change', expect.any(Function))); @@ -229,7 +228,7 @@ describe('useMembersList', () => { debouncedText: '', roomType: 'c', }), - { legacyRoot: true, wrapper: wrapper.build() }, + { wrapper: wrapper.build() }, ); await waitFor(() => expect(result.current.isLoading).toBe(false)); @@ -285,7 +284,6 @@ describe('useMembersList', () => { roomType: 'c', }), { - legacyRoot: true, wrapper: wrapper.withEndpoint('GET', '/v1/rooms.membersOrderedByRole', (_params) => customPage as any).build(), }, ); @@ -362,7 +360,7 @@ describe('useMembersList', () => { debouncedText: '', roomType: 'c', }), - { legacyRoot: true, wrapper: testWrapper.build() }, + { wrapper: testWrapper.build() }, ); // Page 1 diff --git a/apps/meteor/client/views/hooks/useRequire2faSetup.ts b/apps/meteor/client/views/hooks/useRequire2faSetup.ts index 4064de351eadf..a79994bf7b46a 100644 --- a/apps/meteor/client/views/hooks/useRequire2faSetup.ts +++ b/apps/meteor/client/views/hooks/useRequire2faSetup.ts @@ -1,6 +1,6 @@ import { useSetting, useUser } from '@rocket.chat/ui-contexts'; -import { Roles } from '../../../app/models/client'; +import { Roles } from '../../stores'; export const useRequire2faSetup = () => { const user = useUser(); diff --git a/apps/meteor/client/views/marketplace/AppDetailsPage/AppDetailsPage.spec.tsx b/apps/meteor/client/views/marketplace/AppDetailsPage/AppDetailsPage.spec.tsx index d6bae87ccc599..da8f0eaad329a 100644 --- a/apps/meteor/client/views/marketplace/AppDetailsPage/AppDetailsPage.spec.tsx +++ b/apps/meteor/client/views/marketplace/AppDetailsPage/AppDetailsPage.spec.tsx @@ -62,7 +62,6 @@ describe('AppDetailsPage', () => { it('should not display the Save button initially', async () => { render(, { wrapper: wrapper.build(), - legacyRoot: true, }); await waitFor(() => { @@ -73,7 +72,6 @@ describe('AppDetailsPage', () => { it('should display the Save button when a setting is changed', async () => { render(, { wrapper: wrapper.build(), - legacyRoot: true, }); const settingInput = screen.getByLabelText('setting1'); @@ -90,7 +88,6 @@ describe('AppDetailsPage', () => { render(, { wrapper: wrapper.build(), - legacyRoot: true, }); const settingInput = screen.getByLabelText('setting1'); @@ -111,7 +108,6 @@ describe('AppDetailsPage', () => { render(, { wrapper: wrapper.build(), - legacyRoot: true, }); const settingInput = screen.getByLabelText('setting1'); @@ -131,7 +127,6 @@ describe('AppDetailsPage', () => { render(, { wrapper: wrapper.build(), - legacyRoot: true, }); const settingInput = screen.getByLabelText('setting1'); diff --git a/apps/meteor/client/views/marketplace/AppDetailsPage/tabs/AppInstances/AppInstances.tsx b/apps/meteor/client/views/marketplace/AppDetailsPage/tabs/AppInstances/AppInstances.tsx index 252349242f238..3e74a9800ae12 100644 --- a/apps/meteor/client/views/marketplace/AppDetailsPage/tabs/AppInstances/AppInstances.tsx +++ b/apps/meteor/client/views/marketplace/AppDetailsPage/tabs/AppInstances/AppInstances.tsx @@ -37,11 +37,12 @@ const AppInstances = ({ id }: AppInstanceProps): ReactElement => { const router = useRouter(); - const handleSelectLogs = () => { + const handleSelectLogs = (instanceId: string) => { router.navigate( { name: 'marketplace', params: { ...router.getRouteParameters(), tab: 'logs' }, + search: { instanceId }, }, { replace: true }, ); @@ -80,7 +81,7 @@ const AppInstances = ({ id }: AppInstanceProps): ReactElement => { items={[ { content: t('View_Logs'), - onClick: handleSelectLogs, + onClick: () => handleSelectLogs(instance.instanceId), id: 'view-logs', icon: 'desktop-text', }, diff --git a/apps/meteor/client/views/marketplace/AppDetailsPage/tabs/AppLogs/AppLogs.tsx b/apps/meteor/client/views/marketplace/AppDetailsPage/tabs/AppLogs/AppLogs.tsx index d47e7e68bd9ea..f3de52eb489ff 100644 --- a/apps/meteor/client/views/marketplace/AppDetailsPage/tabs/AppLogs/AppLogs.tsx +++ b/apps/meteor/client/views/marketplace/AppDetailsPage/tabs/AppLogs/AppLogs.tsx @@ -1,5 +1,6 @@ import type { ILogItem } from '@rocket.chat/core-typings'; import { Box, Pagination } from '@rocket.chat/fuselage'; +import { useRouter } from '@rocket.chat/ui-contexts'; import { useEffect, useMemo, useReducer, type ReactElement } from 'react'; import { useTranslation } from 'react-i18next'; @@ -16,7 +17,11 @@ import { useLogs } from '../../../hooks/useLogs'; function expandedReducer( expandedStates: { id: string; expanded: boolean }[], - action: { type: 'update'; id: string; expanded: boolean } | { type: 'expand-all' } | { type: 'reset'; logs: ILogItem[] }, + action: + | { type: 'update'; id: string; expanded: boolean } + | { type: 'expand-all' } + | { type: 'reset-all' } + | { type: 'reset'; logs: ILogItem[] }, ) { switch (action.type) { case 'update': @@ -28,6 +33,9 @@ function expandedReducer( case 'reset': return action.logs.map((log) => ({ id: log._id, expanded: false })); + case 'reset-all': + return expandedStates.map((state) => ({ ...state, expanded: false })); + default: return expandedStates; } @@ -36,7 +44,17 @@ function expandedReducer( const AppLogs = ({ id }: { id: string }): ReactElement => { const { t } = useTranslation(); - const { watch } = useAppLogsFilterFormContext(); + const router = useRouter(); + + const { instanceId: instanceLogsFilter } = router.getSearchParameters(); + + const { watch, setValue } = useAppLogsFilterFormContext(); + + useEffect(() => { + if (instanceLogsFilter) { + setValue('instance', instanceLogsFilter); + } + }, [instanceLogsFilter, setValue]); const { startTime, endTime, startDate, endDate, event, severity, instance } = watch(); @@ -48,6 +66,8 @@ const AppLogs = ({ id }: { id: string }): ReactElement => { const handleExpandAll = () => dispatch({ type: 'expand-all' }); + const handleCollapseAll = () => dispatch({ type: 'reset-all' }); + const { data, isSuccess, isError, error, refetch, isFetching } = useLogs({ appId: id, current, @@ -84,6 +104,7 @@ const AppLogs = ({ id }: { id: string }): ReactElement => { noResults={isFetching || !isSuccess || data?.logs?.length === 0} isLoading={isFetching} expandAll={() => handleExpandAll()} + collapseAll={() => handleCollapseAll()} refetchLogs={() => refetch()} />
diff --git a/apps/meteor/client/views/marketplace/AppDetailsPage/tabs/AppLogs/Filters/AppLogsFilter.stories.tsx b/apps/meteor/client/views/marketplace/AppDetailsPage/tabs/AppLogs/Filters/AppLogsFilter.stories.tsx index 0c02a79c3b384..73d2a914cc2a1 100644 --- a/apps/meteor/client/views/marketplace/AppDetailsPage/tabs/AppLogs/Filters/AppLogsFilter.stories.tsx +++ b/apps/meteor/client/views/marketplace/AppDetailsPage/tabs/AppLogs/Filters/AppLogsFilter.stories.tsx @@ -35,5 +35,11 @@ export default { } satisfies Meta; export const Default = () => ( - + ); diff --git a/apps/meteor/client/views/marketplace/AppDetailsPage/tabs/AppLogs/Filters/AppLogsFilter.tsx b/apps/meteor/client/views/marketplace/AppDetailsPage/tabs/AppLogs/Filters/AppLogsFilter.tsx index 5091fca7645bb..e9a3cb80556f6 100644 --- a/apps/meteor/client/views/marketplace/AppDetailsPage/tabs/AppLogs/Filters/AppLogsFilter.tsx +++ b/apps/meteor/client/views/marketplace/AppDetailsPage/tabs/AppLogs/Filters/AppLogsFilter.tsx @@ -3,6 +3,7 @@ import { useRouter, useSetModal } from '@rocket.chat/ui-contexts'; import { Controller } from 'react-hook-form'; import { useTranslation } from 'react-i18next'; +import AppsLogsFilterOptions from './AppLogsFilterOptions'; import CompactFilterOptions from './CompactFilterOptions'; import { EventFilterSelect } from './EventFilterSelect'; import { InstanceFilterSelect } from './InstanceFilterSelect'; @@ -15,12 +16,13 @@ import { ExportLogsModal } from './ExportLogsModal'; type AppsLogsFilterProps = { appId: string; expandAll: () => void; + collapseAll: () => void; refetchLogs: () => void; isLoading: boolean; noResults?: boolean; }; -export const AppLogsFilter = ({ appId, expandAll, refetchLogs, isLoading, noResults = false }: AppsLogsFilterProps) => { +export const AppLogsFilter = ({ appId, expandAll, collapseAll, refetchLogs, isLoading, noResults = false }: AppsLogsFilterProps) => { const { t } = useTranslation(); const { control, getValues } = useAppLogsFilterFormContext(); @@ -95,11 +97,6 @@ export const AppLogsFilter = ({ appId, expandAll, refetchLogs, isLoading, noResu } />
)} - {!compactMode && ( - - )} {!compactMode && ( )} + {!compactMode && } {compactMode && ( - + )}
); diff --git a/apps/meteor/client/views/marketplace/AppDetailsPage/tabs/AppLogs/Filters/AppLogsFilterContextualBar.spec.tsx b/apps/meteor/client/views/marketplace/AppDetailsPage/tabs/AppLogs/Filters/AppLogsFilterContextualBar.spec.tsx index 67572297984cb..b00bcfd882fe6 100644 --- a/apps/meteor/client/views/marketplace/AppDetailsPage/tabs/AppLogs/Filters/AppLogsFilterContextualBar.spec.tsx +++ b/apps/meteor/client/views/marketplace/AppDetailsPage/tabs/AppLogs/Filters/AppLogsFilterContextualBar.spec.tsx @@ -72,10 +72,10 @@ describe('Time range', () => { it(`Should update time range when ${name} is selected`, async () => { render(, { wrapper: mockAppRoot().build() }); - const startDate = screen.getByLabelText('Start Date'); - const endDate = screen.getByLabelText('End Date'); - const startTime = screen.getByLabelText('Start Time'); - const endTime = screen.getByLabelText('End Time'); + const startDate = screen.getByLabelText('Start_Date'); + const endDate = screen.getByLabelText('End_Date'); + const startTime = screen.getByLabelText('Start_Time'); + const endTime = screen.getByLabelText('End_Time'); const timeSelect = screen.getByLabelText('Time'); await userEvent.click(timeSelect); @@ -94,10 +94,10 @@ describe('Time range', () => { it(`Should manually set ${name}`, async () => { render(, { wrapper: mockAppRoot().build() }); - const startDate = screen.getByLabelText('Start Date'); - const endDate = screen.getByLabelText('End Date'); - const startTime = screen.getByLabelText('Start Time'); - const endTime = screen.getByLabelText('End Time'); + const startDate = screen.getByLabelText('Start_Date'); + const endDate = screen.getByLabelText('End_Date'); + const startTime = screen.getByLabelText('Start_Time'); + const endTime = screen.getByLabelText('End_Time'); await userEvent.type(startDate, value[0]); await userEvent.type(endDate, value[1]); @@ -111,3 +111,31 @@ describe('Time range', () => { }); }); }); + +it('Should clear time range', async () => { + render(, { wrapper: mockAppRoot().build() }); + + const startDate = screen.getByLabelText('Start_Date'); + const endDate = screen.getByLabelText('End_Date'); + const startTime = screen.getByLabelText('Start_Time'); + const endTime = screen.getByLabelText('End_Time'); + const timeSelect = screen.getByLabelText('Time'); + + await userEvent.click(timeSelect); + + expect(screen.getByRole('option', { name: 'Last_30_minutes' })).toBeVisible(); + await userEvent.click(screen.getByRole('option', { name: 'Last_30_minutes' })); + + expect(startDate).toHaveValue('2017-05-19'); + expect(endDate).toHaveValue('2017-05-19'); + expect(startTime).toHaveValue('11:50'); + expect(endTime).toHaveValue('12:20'); + + expect(screen.getByRole('button', { name: 'Clear_filters' })).toBeVisible(); + await userEvent.click(screen.getByRole('button', { name: 'Clear_filters' })); + + expect(startDate).toHaveValue(''); + expect(endDate).toHaveValue(''); + expect(startTime).toHaveValue(''); + expect(endTime).toHaveValue(''); +}); diff --git a/apps/meteor/client/views/marketplace/AppDetailsPage/tabs/AppLogs/Filters/AppLogsFilterContextualBar.stories.tsx b/apps/meteor/client/views/marketplace/AppDetailsPage/tabs/AppLogs/Filters/AppLogsFilterContextualBar.stories.tsx index e6a9bd6e27645..53cf59beb73a4 100644 --- a/apps/meteor/client/views/marketplace/AppDetailsPage/tabs/AppLogs/Filters/AppLogsFilterContextualBar.stories.tsx +++ b/apps/meteor/client/views/marketplace/AppDetailsPage/tabs/AppLogs/Filters/AppLogsFilterContextualBar.stories.tsx @@ -19,7 +19,18 @@ export default { })) .buildStoryDecorator(), (fn) => { - const methods = useForm({}); + const methods = useForm({ + defaultValues: { + instanceId: 'instance-1', + method: 'method-1', + severity: 'all', + event: 'all', + startDate: '', + endDate: '', + startTime: '', + endTime: '', + }, + }); return ( diff --git a/apps/meteor/client/views/marketplace/AppDetailsPage/tabs/AppLogs/Filters/AppLogsFilterContextualBar.tsx b/apps/meteor/client/views/marketplace/AppDetailsPage/tabs/AppLogs/Filters/AppLogsFilterContextualBar.tsx index 8f4f6222ce3a2..4e82b5520634b 100644 --- a/apps/meteor/client/views/marketplace/AppDetailsPage/tabs/AppLogs/Filters/AppLogsFilterContextualBar.tsx +++ b/apps/meteor/client/views/marketplace/AppDetailsPage/tabs/AppLogs/Filters/AppLogsFilterContextualBar.tsx @@ -1,4 +1,4 @@ -import { Box, Label } from '@rocket.chat/fuselage'; +import { Box, Button, Label } from '@rocket.chat/fuselage'; import { Controller } from 'react-hook-form'; import { useTranslation } from 'react-i18next'; @@ -13,6 +13,7 @@ import { ContextualbarClose, ContextualbarScrollableContent, ContextualbarDialog, + ContextualbarFooter, } from '../../../../../../components/Contextualbar'; import { useAppLogsFilterFormContext } from '../useAppLogsFilterForm'; @@ -24,7 +25,7 @@ type AppLogsFilterContextualBarProps = { export const AppLogsFilterContextualBar = ({ appId, onClose = () => undefined }: AppLogsFilterContextualBarProps) => { const { t } = useTranslation(); - const { control } = useAppLogsFilterFormContext(); + const { control, reset } = useAppLogsFilterFormContext(); return ( @@ -71,6 +72,11 @@ export const AppLogsFilterContextualBar = ({ appId, onClose = () => undefined }: />
+ + + ); }; diff --git a/apps/meteor/client/views/marketplace/AppDetailsPage/tabs/AppLogs/Filters/AppLogsFilterOptions.tsx b/apps/meteor/client/views/marketplace/AppDetailsPage/tabs/AppLogs/Filters/AppLogsFilterOptions.tsx new file mode 100644 index 0000000000000..08cbc95056f37 --- /dev/null +++ b/apps/meteor/client/views/marketplace/AppDetailsPage/tabs/AppLogs/Filters/AppLogsFilterOptions.tsx @@ -0,0 +1,35 @@ +import { Box, Icon, Menu } from '@rocket.chat/fuselage'; +import { useTranslation } from 'react-i18next'; + +type AppsLogsFilterOptionsProps = { + onExpandAll: () => void; + onCollapseAll: () => void; +}; + +const AppsLogsFilterOptions = ({ onExpandAll, onCollapseAll, ...props }: AppsLogsFilterOptionsProps) => { + const { t } = useTranslation(); + + const menuOptions = { + expandAll: { + label: ( + + + {t('Expand_all')} + + ), + action: onExpandAll, + }, + collapseAll: { + label: ( + + + {t('Collapse_all')} + + ), + action: onCollapseAll, + }, + }; + return ; +}; + +export default AppsLogsFilterOptions; diff --git a/apps/meteor/client/views/marketplace/AppDetailsPage/tabs/AppLogs/Filters/CompactFilterOptions.tsx b/apps/meteor/client/views/marketplace/AppDetailsPage/tabs/AppLogs/Filters/CompactFilterOptions.tsx index b0479681974c9..d2e822855e1d1 100644 --- a/apps/meteor/client/views/marketplace/AppDetailsPage/tabs/AppLogs/Filters/CompactFilterOptions.tsx +++ b/apps/meteor/client/views/marketplace/AppDetailsPage/tabs/AppLogs/Filters/CompactFilterOptions.tsx @@ -3,12 +3,20 @@ import { useTranslation } from 'react-i18next'; type CompactFilterOptionsProps = { onExpandAll: () => void; + onCollapseAll: () => void; onRefreshLogs: () => void; onExportLogs: () => void; isLoading: boolean; }; -const CompactFilterOptions = ({ onExportLogs, onExpandAll, onRefreshLogs, isLoading, ...props }: CompactFilterOptionsProps) => { +const CompactFilterOptions = ({ + onExportLogs, + onExpandAll, + onCollapseAll, + onRefreshLogs, + isLoading, + ...props +}: CompactFilterOptionsProps) => { const { t } = useTranslation(); const menuOptions = { @@ -30,6 +38,15 @@ const CompactFilterOptions = ({ onExportLogs, onExpandAll, onRefreshLogs, isLoad ), action: onExpandAll, }, + collapseAll: { + label: ( + + + {t('Collapse_all')} + + ), + action: onCollapseAll, + }, refreshLogs: { label: ( diff --git a/apps/meteor/client/views/marketplace/AppDetailsPage/tabs/AppLogs/Filters/DateTimeFilter.tsx b/apps/meteor/client/views/marketplace/AppDetailsPage/tabs/AppLogs/Filters/DateTimeFilter.tsx index 8836dd70dea4d..fa5bcee539e15 100644 --- a/apps/meteor/client/views/marketplace/AppDetailsPage/tabs/AppLogs/Filters/DateTimeFilter.tsx +++ b/apps/meteor/client/views/marketplace/AppDetailsPage/tabs/AppLogs/Filters/DateTimeFilter.tsx @@ -21,7 +21,7 @@ const DateTimeFilter = ({ type, control, id, error }: DateTimeFilterProps) => { name={type === 'start' ? 'startDate' : 'endDate'} render={({ field }) => ( { } + render={({ field }) => } /> diff --git a/apps/meteor/client/views/marketplace/AppDetailsPage/tabs/AppLogs/Filters/DateTimeModal.spec.tsx b/apps/meteor/client/views/marketplace/AppDetailsPage/tabs/AppLogs/Filters/DateTimeModal.spec.tsx index 75497569ea2ae..5879043777209 100644 --- a/apps/meteor/client/views/marketplace/AppDetailsPage/tabs/AppLogs/Filters/DateTimeModal.spec.tsx +++ b/apps/meteor/client/views/marketplace/AppDetailsPage/tabs/AppLogs/Filters/DateTimeModal.spec.tsx @@ -27,10 +27,10 @@ test.each(testCases)('AppLogsItem should have no a11y violations', async (_story it('should not enable apply button when start Date and end Date are not selected', async () => { render(, { wrapper: mockAppRoot().build() }); - const startDate = screen.getByLabelText('Start Date'); - const endDate = screen.getByLabelText('End Date'); - const startTime = screen.getByLabelText('Start Time'); - const endTime = screen.getByLabelText('End Time'); + const startDate = screen.getByLabelText('Start_Date'); + const endDate = screen.getByLabelText('End_Date'); + const startTime = screen.getByLabelText('Start_Time'); + const endTime = screen.getByLabelText('End_Time'); expect(startDate).toBeInTheDocument(); expect(endDate).toBeInTheDocument(); diff --git a/apps/meteor/client/views/marketplace/AppDetailsPage/tabs/AppLogs/Filters/ExportLogsModal.spec.tsx b/apps/meteor/client/views/marketplace/AppDetailsPage/tabs/AppLogs/Filters/ExportLogsModal.spec.tsx index f7c84846694c0..f36881642a18a 100644 --- a/apps/meteor/client/views/marketplace/AppDetailsPage/tabs/AppLogs/Filters/ExportLogsModal.spec.tsx +++ b/apps/meteor/client/views/marketplace/AppDetailsPage/tabs/AppLogs/Filters/ExportLogsModal.spec.tsx @@ -10,6 +10,10 @@ const testCases = Object.values(composeStories(stories)).map((Story) => [Story.s const onConfirm = jest.fn(); const { Default } = composeStories(stories); +afterEach(() => { + jest.clearAllMocks(); +}); + test.each(testCases)(`renders without crashing`, async (_storyname, Story) => { const view = render(, { wrapper: mockAppRoot().build(), @@ -35,3 +39,22 @@ it('should send the correct payload to the endpoint', async () => { expect(onConfirm).toHaveBeenCalledTimes(1); expect(onConfirm).toHaveBeenCalledWith('/api/apps/undefined/export-logs?count=2000&type=json'); }); + +it('should include instance filter in the payload to endpoint', async () => { + render( + , + { + wrapper: mockAppRoot().build(), + }, + ); + + expect(screen.getByRole('button', { name: 'Download' })).toBeInTheDocument(); + await userEvent.click(screen.getByRole('button', { name: 'Download' })); + expect(onConfirm).toHaveBeenCalledTimes(1); + expect(onConfirm).toHaveBeenCalledWith('/api/apps/undefined/export-logs?instanceId=123&count=2000&type=json'); +}); diff --git a/apps/meteor/client/views/marketplace/AppDetailsPage/tabs/AppLogs/Filters/ExportLogsModal.tsx b/apps/meteor/client/views/marketplace/AppDetailsPage/tabs/AppLogs/Filters/ExportLogsModal.tsx index 87b44b584af8c..0adece3e2d905 100644 --- a/apps/meteor/client/views/marketplace/AppDetailsPage/tabs/AppLogs/Filters/ExportLogsModal.tsx +++ b/apps/meteor/client/views/marketplace/AppDetailsPage/tabs/AppLogs/Filters/ExportLogsModal.tsx @@ -57,6 +57,7 @@ export const ExportLogsModal = ({ onClose, filterValues, onConfirm }: ExportLogs const getFileUrl = ({ severity, event, + instance, startDate, endDate, count, @@ -71,6 +72,9 @@ export const ExportLogsModal = ({ onClose, filterValues, onConfirm }: ExportLogs if (event && event !== 'all') { baseUrl += `method=${event}&`; } + if (instance && instance !== 'all') { + baseUrl += `instanceId=${instance}&`; + } if (startDate) { baseUrl += `startDate=${new Date(`${startDate}T${startTime}`).toISOString()}&`; } diff --git a/apps/meteor/client/views/marketplace/AppDetailsPage/tabs/AppLogs/Filters/__snapshots__/AppLogsFilterContextualBar.spec.tsx.snap b/apps/meteor/client/views/marketplace/AppDetailsPage/tabs/AppLogs/Filters/__snapshots__/AppLogsFilterContextualBar.spec.tsx.snap index 30795a832ac93..c6a657745db7b 100644 --- a/apps/meteor/client/views/marketplace/AppDetailsPage/tabs/AppLogs/Filters/__snapshots__/AppLogsFilterContextualBar.spec.tsx.snap +++ b/apps/meteor/client/views/marketplace/AppDetailsPage/tabs/AppLogs/Filters/__snapshots__/AppLogsFilterContextualBar.spec.tsx.snap @@ -169,7 +169,7 @@ exports[`renders AppLogsItem without crashing 1`] = ` class="rcx-box rcx-box--full rcx-label rcx-box rcx-box--full rcx-box--animated rcx-input-box__wrapper rcx-css-sdt442" >
+ > + All +