diff --git a/.changeset/afraid-guests-jog.md b/.changeset/afraid-guests-jog.md deleted file mode 100644 index 420b9bb5d329..000000000000 --- a/.changeset/afraid-guests-jog.md +++ /dev/null @@ -1,6 +0,0 @@ ---- -"@rocket.chat/meteor": minor -"@rocket.chat/livechat": minor ---- - -Created a `transferChat` Livechat API endpoint for transferring chats programmatically, the endpoint has all the limitations & permissions required that transferring via UI has diff --git a/.changeset/bright-humans-cross.md b/.changeset/bright-humans-cross.md new file mode 100644 index 000000000000..aa0c4c658994 --- /dev/null +++ b/.changeset/bright-humans-cross.md @@ -0,0 +1,5 @@ +--- +'@rocket.chat/meteor': minor +--- + +Federation actions like sending message in a federated DM, reacting in a federated chat, etc, will no longer work if the configuration is invalid. diff --git a/.changeset/bump-patch-1722087664914.md b/.changeset/bump-patch-1722087664914.md deleted file mode 100644 index e1eaa7980afb..000000000000 --- a/.changeset/bump-patch-1722087664914.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@rocket.chat/meteor': patch ---- - -Bump @rocket.chat/meteor version. diff --git a/.changeset/bump-patch-1722559871139.md b/.changeset/bump-patch-1722559871139.md deleted file mode 100644 index e1eaa7980afb..000000000000 --- a/.changeset/bump-patch-1722559871139.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@rocket.chat/meteor': patch ---- - -Bump @rocket.chat/meteor version. diff --git a/.changeset/calm-tigers-peel.md b/.changeset/calm-tigers-peel.md new file mode 100644 index 000000000000..32feed6f7107 --- /dev/null +++ b/.changeset/calm-tigers-peel.md @@ -0,0 +1,5 @@ +--- +"@rocket.chat/ui-kit": patch +--- + +fix UiKit error message: Failed to resolve module: @rocket.chat/icons diff --git a/.changeset/chatty-hounds-hammer.md b/.changeset/chatty-hounds-hammer.md deleted file mode 100644 index 1a2d3a7de559..000000000000 --- a/.changeset/chatty-hounds-hammer.md +++ /dev/null @@ -1,6 +0,0 @@ ---- -"@rocket.chat/meteor": patch -"@rocket.chat/fuselage-ui-kit": patch ---- - -Fix validations from "UiKit" modal component diff --git a/.changeset/chilled-yaks-beg.md b/.changeset/chilled-yaks-beg.md deleted file mode 100644 index 670fa24887b7..000000000000 --- a/.changeset/chilled-yaks-beg.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@rocket.chat/meteor": patch ---- - -Fixed issue in Marketplace that caused a subscription app to show incorrect modals when subscribing diff --git a/.changeset/chilly-papayas-march.md b/.changeset/chilly-papayas-march.md deleted file mode 100644 index a7724b126695..000000000000 --- a/.changeset/chilly-papayas-march.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@rocket.chat/meteor": patch ---- - -Fixed SAML users' full names being updated on login regardless of the "Overwrite user fullname (use idp attribute)" setting diff --git a/.changeset/cool-rocks-remember.md b/.changeset/cool-rocks-remember.md new file mode 100644 index 000000000000..97af36e94320 --- /dev/null +++ b/.changeset/cool-rocks-remember.md @@ -0,0 +1,6 @@ +--- +"@rocket.chat/meteor": patch +"@rocket.chat/model-typings": patch +--- + +Fixed login with third-party apps not working without the "Manage OAuth Apps" permission diff --git a/.changeset/cuddly-brooms-approve.md b/.changeset/cuddly-brooms-approve.md deleted file mode 100644 index 24905bb91c62..000000000000 --- a/.changeset/cuddly-brooms-approve.md +++ /dev/null @@ -1,6 +0,0 @@ ---- -"@rocket.chat/meteor": minor -"@rocket.chat/i18n": minor ---- - -Allows admins to customize the `Subject` field of Omnichannel email transcripts via setting. By passing a value to the setting `Custom email subject for transcript`, system will use it as the `Subject` field, unless a custom subject is passed when requesting a transcript. If there's no custom subject and setting value is empty, the current default value will be used diff --git a/.changeset/dry-pumas-draw.md b/.changeset/dry-pumas-draw.md deleted file mode 100644 index b66ca5157cd5..000000000000 --- a/.changeset/dry-pumas-draw.md +++ /dev/null @@ -1,6 +0,0 @@ ---- -"@rocket.chat/meteor": patch -"@rocket.chat/livechat": patch ---- - -Fixed an issue that caused the widget to set the wrong department when using the setDepartment Livechat api endpoint in conjunction with a Livechat Trigger diff --git a/.changeset/empty-readers-teach.md b/.changeset/empty-readers-teach.md deleted file mode 100644 index b4bd075ef654..000000000000 --- a/.changeset/empty-readers-teach.md +++ /dev/null @@ -1,8 +0,0 @@ ---- -"@rocket.chat/meteor": patch -"@rocket.chat/tools": patch -"@rocket.chat/account-service": patch ---- - -Fixed an inconsistent evaluation of the `Accounts_LoginExpiration` setting over the codebase. In some places, it was being used as milliseconds while in others as days. Invalid values produced different results. A helper function was created to centralize the setting validation and the proper value being returned to avoid edge cases. -Negative values may be saved on the settings UI panel but the code will interpret any negative, NaN or 0 value to the default expiration which is 90 days. diff --git a/.changeset/empty-toys-smell.md b/.changeset/empty-toys-smell.md new file mode 100644 index 000000000000..043d9c19567d --- /dev/null +++ b/.changeset/empty-toys-smell.md @@ -0,0 +1,5 @@ +--- +'@rocket.chat/meteor': patch +--- + +Federated users can no longer be deleted. diff --git a/.changeset/fast-buttons-shake.md b/.changeset/fast-buttons-shake.md deleted file mode 100644 index 6281fc9941ec..000000000000 --- a/.changeset/fast-buttons-shake.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@rocket.chat/meteor': minor ---- - -Fixed an issue where FCM actions did not respect environment's proxy settings diff --git a/.changeset/fast-lobsters-turn.md b/.changeset/fast-lobsters-turn.md new file mode 100644 index 000000000000..ff1d97ea7289 --- /dev/null +++ b/.changeset/fast-lobsters-turn.md @@ -0,0 +1,5 @@ +--- +"@rocket.chat/meteor": patch +--- + +Fixed an issue due to an endpoint pagination that was causing that when an agent have assigned more than 50 departments, the departments have a blank space instead of the name. diff --git a/.changeset/funny-boats-guess.md b/.changeset/funny-boats-guess.md new file mode 100644 index 000000000000..076acff98329 --- /dev/null +++ b/.changeset/funny-boats-guess.md @@ -0,0 +1,6 @@ +--- +"@rocket.chat/meteor": minor +"@rocket.chat/i18n": minor +--- + +Added a new Audit endpoint `audit/rooms.members` that allows users with `view-members-list-all-rooms` to fetch a list of the members of any room even if the user is not part of it. diff --git a/.changeset/funny-snails-promise.md b/.changeset/funny-snails-promise.md deleted file mode 100644 index bdd74a60b1e9..000000000000 --- a/.changeset/funny-snails-promise.md +++ /dev/null @@ -1,10 +0,0 @@ ---- -"@rocket.chat/meteor": patch -"@rocket.chat/livechat": patch ---- - -livechat `setDepartment` livechat api fixes: -- Changing department didn't reflect on the registration form in real time -- Changing the department mid conversation didn't transfer the chat -- Depending on the state of the department, it couldn't be set as default - diff --git a/.changeset/funny-wolves-tie.md b/.changeset/funny-wolves-tie.md deleted file mode 100644 index e2364ccb05e5..000000000000 --- a/.changeset/funny-wolves-tie.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@rocket.chat/meteor': patch ---- - -Fixed issue where bad word filtering was not working in the UI for messages diff --git a/.changeset/gentle-bugs-think.md b/.changeset/gentle-bugs-think.md new file mode 100644 index 000000000000..fc4738f3043a --- /dev/null +++ b/.changeset/gentle-bugs-think.md @@ -0,0 +1,5 @@ +--- +"@rocket.chat/meteor": patch +--- + +Prevent `processRoomAbandonment` callback from erroring out when a room was inactive during a day Business Hours was not configured for. diff --git a/.changeset/giant-spiders-pay.md b/.changeset/giant-spiders-pay.md new file mode 100644 index 000000000000..1798cd2baaee --- /dev/null +++ b/.changeset/giant-spiders-pay.md @@ -0,0 +1,5 @@ +--- +'@rocket.chat/meteor': patch +--- + +Fixes an issue where the Announcement modal with long words was adding a horizontal scrollbar diff --git a/.changeset/gorgeous-hotels-attend.md b/.changeset/gorgeous-hotels-attend.md new file mode 100644 index 000000000000..fd858d7ace86 --- /dev/null +++ b/.changeset/gorgeous-hotels-attend.md @@ -0,0 +1,5 @@ +--- +"@rocket.chat/meteor": patch +--- + +Stopped non channel members from dragging and dropping files in a channel they do not belong diff --git a/.changeset/grumpy-worms-appear.md b/.changeset/grumpy-worms-appear.md deleted file mode 100644 index fb9fab77b24c..000000000000 --- a/.changeset/grumpy-worms-appear.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@rocket.chat/i18n": patch ---- - -Fixed wrong wording on a federation setting diff --git a/.changeset/happy-peaches-nail.md b/.changeset/happy-peaches-nail.md deleted file mode 100644 index 2dfb2151ced0..000000000000 --- a/.changeset/happy-peaches-nail.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@rocket.chat/meteor": patch ---- - -Fixed issue with livechat agents not being able to leave omnichannel rooms if joining after a room has been closed by the visitor (due to race conditions) diff --git a/.changeset/hip-queens-taste.md b/.changeset/hip-queens-taste.md deleted file mode 100644 index f1d7bb6f3f0e..000000000000 --- a/.changeset/hip-queens-taste.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@rocket.chat/meteor": minor ---- - -Added the possibility for apps to remove users from a room diff --git a/.changeset/hungry-wombats-act.md b/.changeset/hungry-wombats-act.md deleted file mode 100644 index 4e50b172e17e..000000000000 --- a/.changeset/hungry-wombats-act.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@rocket.chat/meteor": patch ---- - -Fixed an issue where non-encrypted attachments were not being downloaded diff --git a/.changeset/large-geese-ring.md b/.changeset/large-geese-ring.md new file mode 100644 index 000000000000..9b36edf1c02d --- /dev/null +++ b/.changeset/large-geese-ring.md @@ -0,0 +1,5 @@ +--- +'@rocket.chat/meteor': minor +--- + +Replaces an outdated banner with the Bubble component in order to display retention policy warning diff --git a/.changeset/large-vans-attack.md b/.changeset/large-vans-attack.md deleted file mode 100644 index c1008b2ca06f..000000000000 --- a/.changeset/large-vans-attack.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@rocket.chat/meteor": patch ---- - -fixed the contextual bar closing when editing thread messages instead of cancelling the message edit diff --git a/.changeset/lucky-beds-glow.md b/.changeset/lucky-beds-glow.md deleted file mode 100644 index 3e23797025e1..000000000000 --- a/.changeset/lucky-beds-glow.md +++ /dev/null @@ -1,7 +0,0 @@ ---- -'@rocket.chat/ui-client': minor -'@rocket.chat/i18n': minor -'@rocket.chat/meteor': minor ---- - -Feature Preview: New Navigation - `Header` and `Contextualbar` size improvements consistent with the new global `NavBar` diff --git a/.changeset/lucky-countries-look.md b/.changeset/lucky-countries-look.md deleted file mode 100644 index 79deda53edfc..000000000000 --- a/.changeset/lucky-countries-look.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@rocket.chat/meteor': patch ---- - -Fixed the disappearance of some settings after navigation under network latency. diff --git a/.changeset/many-tables-love.md b/.changeset/many-tables-love.md deleted file mode 100644 index 8f37283c6a96..000000000000 --- a/.changeset/many-tables-love.md +++ /dev/null @@ -1,6 +0,0 @@ ---- -"@rocket.chat/meteor": minor -"@rocket.chat/model-typings": minor ---- - -Fixed Livechat rooms being displayed in the Engagement Dashboard's "Channels" tab diff --git a/.changeset/mean-hairs-move.md b/.changeset/mean-hairs-move.md deleted file mode 100644 index c92293d6ae95..000000000000 --- a/.changeset/mean-hairs-move.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@rocket.chat/meteor': minor ---- - -Fixed an issue where adding `OVERWRITE_SETTING_` for any setting wasn't immediately taking effect sometimes, and needed a server restart to reflect. diff --git a/.changeset/nasty-windows-smile.md b/.changeset/nasty-windows-smile.md new file mode 100644 index 000000000000..e80ec3db27a9 --- /dev/null +++ b/.changeset/nasty-windows-smile.md @@ -0,0 +1,5 @@ +--- +"@rocket.chat/meteor": patch +--- + +Allow apps to react/unreact to messages via bridge diff --git a/.changeset/nervous-rockets-impress.md b/.changeset/nervous-rockets-impress.md deleted file mode 100644 index 26e9276193de..000000000000 --- a/.changeset/nervous-rockets-impress.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@rocket.chat/meteor": patch ---- - -Fixes Missing line breaks on Omnichannel Room Info Panel diff --git a/.changeset/new-balloons-speak.md b/.changeset/new-balloons-speak.md deleted file mode 100644 index 7d4e7cd3a57e..000000000000 --- a/.changeset/new-balloons-speak.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@rocket.chat/meteor': patch ---- - -Fixed web client crashing on Firefox private window. Firefox disables access to service workers inside private windows. Rocket.Chat needs service workers to process E2EE encrypted files on rooms. These types of files won't be available inside private windows, but the rest of E2EE encrypted features should work normally diff --git a/.changeset/new-mayflies-wait.md b/.changeset/new-mayflies-wait.md new file mode 100644 index 000000000000..832db68cecd4 --- /dev/null +++ b/.changeset/new-mayflies-wait.md @@ -0,0 +1,5 @@ +--- +'@rocket.chat/meteor': patch +--- + +Deactivating users who federated will now be permanent. diff --git a/.changeset/new-scissors-love.md b/.changeset/new-scissors-love.md deleted file mode 100644 index fb962407b353..000000000000 --- a/.changeset/new-scissors-love.md +++ /dev/null @@ -1,12 +0,0 @@ ---- -'@rocket.chat/omnichannel-services': minor -'@rocket.chat/pdf-worker': minor -'@rocket.chat/core-services': minor -'@rocket.chat/model-typings': minor -'@rocket.chat/i18n': minor -'@rocket.chat/meteor': minor ---- - -Added system messages support for Omnichannel PDF transcripts and email transcripts. Currently these transcripts don't render system messages and is shown as an empty message in PDF/email. This PR adds this support for all valid livechat system messages. - -Also added a new setting under transcripts, to toggle the inclusion of system messages in email and PDF transcripts. diff --git a/.changeset/nice-laws-eat.md b/.changeset/nice-laws-eat.md deleted file mode 100644 index e99e4f219ef9..000000000000 --- a/.changeset/nice-laws-eat.md +++ /dev/null @@ -1,15 +0,0 @@ ---- -'rocketchat-services': minor -'@rocket.chat/core-services': minor -'@rocket.chat/model-typings': minor -'@rocket.chat/ui-video-conf': minor -'@rocket.chat/core-typings': minor -'@rocket.chat/ui-contexts': minor -'@rocket.chat/models': minor -'@rocket.chat/ui-kit': minor -'@rocket.chat/i18n': minor -'@rocket.chat/meteor': minor ---- - -New Feature: Video Conference Persistent Chat. -This feature provides a discussion id for conference provider apps to store the chat messages exchanged during the conferences, so that those users may then access those messages again at any time through Rocket.Chat. \ No newline at end of file diff --git a/.changeset/ninety-hounds-exist.md b/.changeset/ninety-hounds-exist.md new file mode 100644 index 000000000000..99882de12018 --- /dev/null +++ b/.changeset/ninety-hounds-exist.md @@ -0,0 +1,7 @@ +--- +'@rocket.chat/rest-typings': patch +'@rocket.chat/meteor': patch +'@rocket.chat/i18n': patch +--- + +Fix: Show correct user info actions for non-members in channels. diff --git a/.changeset/perfect-coins-camp.md b/.changeset/perfect-coins-camp.md deleted file mode 100644 index 4dbddf965742..000000000000 --- a/.changeset/perfect-coins-camp.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@rocket.chat/meteor": patch ---- - -fixed an issue in the "Create discussion" form, that would have the "Create" action button disabled even though the form is prefilled when opening it from the message action diff --git a/.changeset/polite-foxes-repair.md b/.changeset/polite-foxes-repair.md deleted file mode 100644 index 2f524c7e5f10..000000000000 --- a/.changeset/polite-foxes-repair.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@rocket.chat/meteor': minor ---- - -Added a method to the Apps-Engine that allows apps to read multiple messages from a room diff --git a/.changeset/popular-trees-lay.md b/.changeset/popular-trees-lay.md deleted file mode 100644 index f38ef1f92367..000000000000 --- a/.changeset/popular-trees-lay.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@rocket.chat/meteor": patch ---- - -Removed 'Hide' option in the room menu for Omnichannel conversations. diff --git a/.changeset/pre.json b/.changeset/pre.json deleted file mode 100644 index 38a7fe160e48..000000000000 --- a/.changeset/pre.json +++ /dev/null @@ -1,114 +0,0 @@ -{ - "mode": "pre", - "tag": "rc", - "initialVersions": { - "@rocket.chat/meteor": "6.11.0-develop", - "rocketchat-services": "1.2.1", - "@rocket.chat/account-service": "0.4.1", - "@rocket.chat/authorization-service": "0.4.1", - "@rocket.chat/ddp-streamer": "0.3.1", - "@rocket.chat/omnichannel-transcript": "0.4.1", - "@rocket.chat/presence-service": "0.4.1", - "@rocket.chat/queue-worker": "0.4.1", - "@rocket.chat/stream-hub-service": "0.4.1", - "@rocket.chat/api-client": "0.2.1", - "@rocket.chat/ddp-client": "0.3.1", - "@rocket.chat/license": "0.2.1", - "@rocket.chat/omnichannel-services": "0.2.1", - "@rocket.chat/pdf-worker": "0.1.1", - "@rocket.chat/presence": "0.2.1", - "@rocket.chat/ui-theming": "0.2.0", - "@rocket.chat/account-utils": "0.0.2", - "@rocket.chat/agenda": "0.1.0", - "@rocket.chat/apps": "0.1.1", - "@rocket.chat/base64": "1.0.13", - "@rocket.chat/cas-validate": "0.0.2", - "@rocket.chat/core-services": "0.4.1", - "@rocket.chat/core-typings": "6.11.0-develop", - "@rocket.chat/cron": "0.1.1", - "@rocket.chat/eslint-config": "0.7.0", - "@rocket.chat/favicon": "0.0.2", - "@rocket.chat/fuselage-ui-kit": "8.0.1", - "@rocket.chat/gazzodown": "8.0.1", - "@rocket.chat/i18n": "0.5.0", - "@rocket.chat/instance-status": "0.1.1", - "@rocket.chat/jwt": "0.1.1", - "@rocket.chat/livechat": "1.18.1", - "@rocket.chat/log-format": "0.0.2", - "@rocket.chat/logger": "0.0.2", - "@rocket.chat/message-parser": "0.31.29", - "@rocket.chat/mock-providers": "0.1.0", - "@rocket.chat/model-typings": "0.5.1", - "@rocket.chat/models": "0.1.1", - "@rocket.chat/poplib": "0.0.2", - "@rocket.chat/password-policies": "0.0.2", - "@rocket.chat/patch-injection": "0.0.1", - "@rocket.chat/peggy-loader": "0.31.25", - "@rocket.chat/random": "1.2.2", - "@rocket.chat/release-action": "2.2.3", - "@rocket.chat/release-changelog": "0.1.0", - "@rocket.chat/rest-typings": "6.11.0-develop", - "@rocket.chat/server-cloud-communication": "0.0.2", - "@rocket.chat/server-fetch": "0.0.3", - "@rocket.chat/sha256": "1.0.10", - "@rocket.chat/tools": "0.2.1", - "@rocket.chat/ui-avatar": "4.0.1", - "@rocket.chat/ui-client": "8.0.1", - "@rocket.chat/ui-composer": "0.2.0", - "@rocket.chat/ui-contexts": "8.0.1", - "@rocket.chat/ui-kit": "0.35.0", - "@rocket.chat/ui-video-conf": "8.0.1", - "@rocket.chat/uikit-playground": "0.3.1", - "@rocket.chat/web-ui-registration": "8.0.1" - }, - "changesets": [ - "afraid-guests-jog", - "bump-patch-1722087664914", - "bump-patch-1722559871139", - "chatty-hounds-hammer", - "chilled-yaks-beg", - "chilly-papayas-march", - "cuddly-brooms-approve", - "dry-pumas-draw", - "empty-readers-teach", - "fast-buttons-shake", - "funny-snails-promise", - "funny-wolves-tie", - "grumpy-worms-appear", - "happy-peaches-nail", - "hip-queens-taste", - "hungry-wombats-act", - "large-vans-attack", - "lucky-beds-glow", - "lucky-countries-look", - "many-tables-love", - "mean-hairs-move", - "nervous-rockets-impress", - "new-balloons-speak", - "new-scissors-love", - "nice-laws-eat", - "perfect-coins-camp", - "polite-foxes-repair", - "popular-trees-lay", - "proud-waves-bathe", - "quick-ducks-live", - "rare-penguins-hope", - "red-numbers-happen", - "red-vans-shave", - "rich-carpets-brush", - "rotten-eggs-end", - "selfish-emus-sing", - "shaggy-hats-raise", - "sixty-nails-clean", - "smooth-lobsters-flash", - "soft-donkeys-thank", - "sour-forks-breathe", - "thin-windows-reply", - "violet-brooms-press", - "weak-insects-sort", - "weak-pets-talk", - "weak-taxis-design", - "weak-tigers-suffer", - "witty-bats-develop" - ] -} diff --git a/.changeset/proud-waves-bathe.md b/.changeset/proud-waves-bathe.md deleted file mode 100644 index 556fa3af80e1..000000000000 --- a/.changeset/proud-waves-bathe.md +++ /dev/null @@ -1,6 +0,0 @@ ---- -"@rocket.chat/meteor": minor -"@rocket.chat/model-typings": minor ---- - -Improved Engagement Dashboard's "Channels" tab performance by not returning rooms that had no activity in the analyzed period diff --git a/.changeset/proud-years-buy.md b/.changeset/proud-years-buy.md new file mode 100644 index 000000000000..94f4ab0df736 --- /dev/null +++ b/.changeset/proud-years-buy.md @@ -0,0 +1,5 @@ +--- +'@rocket.chat/i18n': patch +--- + +Fixes a typo in german translation and fixes the broken hyperlink for Resend and Change Email diff --git a/.changeset/purple-dolls-serve.md b/.changeset/purple-dolls-serve.md new file mode 100644 index 000000000000..fc44faa60a38 --- /dev/null +++ b/.changeset/purple-dolls-serve.md @@ -0,0 +1,7 @@ +--- +'@rocket.chat/web-ui-registration': patch +'@rocket.chat/i18n': patch +'@rocket.chat/meteor': patch +--- + +Fixes an issue where creating a new user with an invalid username (containing special characters) resulted in an error message, but the user was still created. The user creation process now properly aborts when an invalid username is provided. diff --git a/.changeset/quick-ducks-live.md b/.changeset/quick-ducks-live.md deleted file mode 100644 index ad628c13d087..000000000000 --- a/.changeset/quick-ducks-live.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@rocket.chat/meteor": patch ---- - -Fixed LDAP rooms, teams and roles syncs not being triggered on login even when the "Update User Data on Login" setting is enabled diff --git a/.changeset/rare-penguins-hope.md b/.changeset/rare-penguins-hope.md deleted file mode 100644 index 187bd9d09ddc..000000000000 --- a/.changeset/rare-penguins-hope.md +++ /dev/null @@ -1,6 +0,0 @@ ---- -"@rocket.chat/meteor": patch -"@rocket.chat/core-typings": patch ---- - -Allow customFields on livechat creation bridge diff --git a/.changeset/red-numbers-happen.md b/.changeset/red-numbers-happen.md deleted file mode 100644 index 61cb0d2b7586..000000000000 --- a/.changeset/red-numbers-happen.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@rocket.chat/meteor": patch ---- - -Fixed "Copy link" message action enabled in Starred and Pinned list for End to End Encrypted channels, this action is disabled now diff --git a/.changeset/red-vans-shave.md b/.changeset/red-vans-shave.md deleted file mode 100644 index ddf76535087e..000000000000 --- a/.changeset/red-vans-shave.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@rocket.chat/meteor": patch ---- - -Fixed issue that caused unintentional clicks when scrolling the channels sidebar on safari/chrome in iOS diff --git a/.changeset/rich-carpets-brush.md b/.changeset/rich-carpets-brush.md deleted file mode 100644 index 16741e31e54a..000000000000 --- a/.changeset/rich-carpets-brush.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@rocket.chat/meteor': patch ---- - -Fixed some anomalies related to disabled E2EE rooms. Earlier there are some weird issues with disabled E2EE rooms, this PR fixes these anomalies. diff --git a/.changeset/rich-pillows-hang.md b/.changeset/rich-pillows-hang.md new file mode 100644 index 000000000000..b714a5e6acd9 --- /dev/null +++ b/.changeset/rich-pillows-hang.md @@ -0,0 +1,5 @@ +--- +'@rocket.chat/meteor': patch +--- + +Fixes the `expanded` prop being accidentally forwarded to `ContextualbarHeader` diff --git a/.changeset/rooms-table-ts.md b/.changeset/rooms-table-ts.md new file mode 100644 index 000000000000..b5055ad26f69 --- /dev/null +++ b/.changeset/rooms-table-ts.md @@ -0,0 +1,5 @@ +--- +'@rocket.chat/meteor': minor +--- + +Add "Created at" column to admin rooms table diff --git a/.changeset/rotten-camels-pretend.md b/.changeset/rotten-camels-pretend.md new file mode 100644 index 000000000000..5145bbaa5050 --- /dev/null +++ b/.changeset/rotten-camels-pretend.md @@ -0,0 +1,6 @@ +--- +"@rocket.chat/meteor": patch +"@rocket.chat/core-typings": patch +--- + +Fixed issue with system messages being counted as agents' first responses in livechat rooms (which caused the "best first response time" and "average first response time" metrics to be unreliable for all agents) diff --git a/.changeset/rotten-eggs-end.md b/.changeset/rotten-eggs-end.md deleted file mode 100644 index 7d0ad6ee5047..000000000000 --- a/.changeset/rotten-eggs-end.md +++ /dev/null @@ -1,7 +0,0 @@ ---- -"@rocket.chat/meteor": minor -"@rocket.chat/i18n": patch -"@rocket.chat/ui-client": patch ---- - -Implemented a new tab to the users page called 'Active', this tab lists all users who have logged in for the first time and are active. diff --git a/.changeset/rude-dogs-burn.md b/.changeset/rude-dogs-burn.md new file mode 100644 index 000000000000..e81f00782083 --- /dev/null +++ b/.changeset/rude-dogs-burn.md @@ -0,0 +1,5 @@ +--- +"@rocket.chat/meteor": patch +--- + +Fixed a behavior when updating messages that prevented the `customFields` prop from being updated if there were no changes to the `msg` property. Now, `customFields` will be always updated on message update even if `msg` doesn't change diff --git a/.changeset/selfish-emus-sing.md b/.changeset/selfish-emus-sing.md deleted file mode 100644 index 315d674a1857..000000000000 --- a/.changeset/selfish-emus-sing.md +++ /dev/null @@ -1,6 +0,0 @@ ---- -"@rocket.chat/meteor": minor -"@rocket.chat/i18n": minor ---- - -Added account setting `Accounts_Default_User_Preferences_sidebarSectionsOrder` to allow users to reorganize sidebar sections diff --git a/.changeset/shaggy-hats-raise.md b/.changeset/shaggy-hats-raise.md deleted file mode 100644 index 40ee9f8fbb55..000000000000 --- a/.changeset/shaggy-hats-raise.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@rocket.chat/meteor": minor ---- - -Added a new setting `Livechat_transcript_send_always` that allows admins to decide if email transcript should be sent all the times when a conversation is closed. This setting bypasses agent's preferences. For this setting to work, `Livechat_enable_transcript` should be off, meaning that visitors will no longer receive the option to decide if they want a transcript or not. diff --git a/.changeset/six-beers-fry.md b/.changeset/six-beers-fry.md new file mode 100644 index 000000000000..48409c2f8de5 --- /dev/null +++ b/.changeset/six-beers-fry.md @@ -0,0 +1,5 @@ +--- +'@rocket.chat/meteor': minor +--- + +New button added to validate Matrix Federation configuration. A new field inside admin settings will reflect the configuration status being either 'Valid' or 'Invalid'. diff --git a/.changeset/sixty-nails-clean.md b/.changeset/sixty-nails-clean.md deleted file mode 100644 index 7d13e02f0bd3..000000000000 --- a/.changeset/sixty-nails-clean.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@rocket.chat/meteor': patch ---- - -Fixed an issue that prevented the option to start a discussion from being shown on the message actions diff --git a/.changeset/sixty-spoons-own.md b/.changeset/sixty-spoons-own.md new file mode 100644 index 000000000000..0b717c3965ef --- /dev/null +++ b/.changeset/sixty-spoons-own.md @@ -0,0 +1,9 @@ +--- +"@rocket.chat/meteor": minor +"@rocket.chat/core-typings": minor +"@rocket.chat/model-typings": minor +"@rocket.chat/models": minor +"@rocket.chat/rest-typings": minor +--- + +Introduced "create contacts" endpoint to omnichannel diff --git a/.changeset/smart-mice-attack.md b/.changeset/smart-mice-attack.md new file mode 100644 index 000000000000..3ca47060ce5c --- /dev/null +++ b/.changeset/smart-mice-attack.md @@ -0,0 +1,5 @@ +--- +"@rocket.chat/meteor": patch +--- + +Fixed an issue where teams were being created with no room associated with it. diff --git a/.changeset/smooth-lobsters-flash.md b/.changeset/smooth-lobsters-flash.md deleted file mode 100644 index 541d5069ee9c..000000000000 --- a/.changeset/smooth-lobsters-flash.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@rocket.chat/meteor': patch ---- - -Fix show correct user roles after updating user roles on admin edit user panel. diff --git a/.changeset/soft-donkeys-thank.md b/.changeset/soft-donkeys-thank.md deleted file mode 100644 index 7273ddcffca4..000000000000 --- a/.changeset/soft-donkeys-thank.md +++ /dev/null @@ -1,8 +0,0 @@ ---- -"@rocket.chat/meteor": patch -"@rocket.chat/mock-providers": patch -"@rocket.chat/ui-contexts": patch -"@rocket.chat/web-ui-registration": patch ---- - -Fixed an issue with blocked login when dismissed 2FA modal by clicking outside of it or pressing the escape key diff --git a/.changeset/sour-forks-breathe.md b/.changeset/sour-forks-breathe.md deleted file mode 100644 index 2d1076845fa9..000000000000 --- a/.changeset/sour-forks-breathe.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@rocket.chat/meteor": minor ---- - -Extended apps-engine events for users leaving a room to also fire when being removed by another user. Also added the triggering user's information to the event's context payload. diff --git a/.changeset/spicy-kings-think.md b/.changeset/spicy-kings-think.md new file mode 100644 index 000000000000..9e8f3648b28c --- /dev/null +++ b/.changeset/spicy-kings-think.md @@ -0,0 +1,6 @@ +--- +"@rocket.chat/meteor": patch +--- + +Fixes multiple problems with the `processRoomAbandonment` hook. This hook is in charge of calculating the time a room has been abandoned (this means, the time that elapsed since a room was last answered by an agent until it was closed). However, when business hours were enabled and the user didn't open on one day, if an abandoned room happened to be abandoned _over_ the day there was no business hour configuration, then the process will error out. +Additionally, the values the code was calculating were not right. When business hours are enabled, this code should only count the abandonment time _while a business hour was open_. When rooms were left abandoned for days or weeks, this will also throw an error or output an invalid count. diff --git a/.changeset/strong-terms-love.md b/.changeset/strong-terms-love.md new file mode 100644 index 000000000000..2535a466eb8e --- /dev/null +++ b/.changeset/strong-terms-love.md @@ -0,0 +1,6 @@ +--- +"@rocket.chat/meteor": patch +"@rocket.chat/model-typings": patch +--- + +Fixed issue with livechat analytics in a given date range considering conversation data from the following day diff --git a/.changeset/stupid-fishes-relate.md b/.changeset/stupid-fishes-relate.md new file mode 100644 index 000000000000..82bfaa1cfd28 --- /dev/null +++ b/.changeset/stupid-fishes-relate.md @@ -0,0 +1,7 @@ +--- +'@rocket.chat/core-typings': minor +'@rocket.chat/i18n': minor +'@rocket.chat/meteor': minor +--- + +Added a new setting to enable/disable file encryption in an end to end encrypted room. diff --git a/.changeset/stupid-pigs-share.md b/.changeset/stupid-pigs-share.md new file mode 100644 index 000000000000..55d68c66d587 --- /dev/null +++ b/.changeset/stupid-pigs-share.md @@ -0,0 +1,5 @@ +--- +'@rocket.chat/meteor': minor +--- + +Wraps some room settings in an accordion advanced settings section in room edit contextual bar to improve organization diff --git a/.changeset/ten-bulldogs-clap.md b/.changeset/ten-bulldogs-clap.md new file mode 100644 index 000000000000..15f88bb6bd97 --- /dev/null +++ b/.changeset/ten-bulldogs-clap.md @@ -0,0 +1,5 @@ +--- +"@rocket.chat/meteor": patch +--- + +fixed an issue with the "follow message" button not changing state after click diff --git a/.changeset/thin-windows-reply.md b/.changeset/thin-windows-reply.md deleted file mode 100644 index 1a32e1ddebfb..000000000000 --- a/.changeset/thin-windows-reply.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@rocket.chat/meteor': patch ---- - -Fixes an issue not displaying all groups in settings list diff --git a/.changeset/twelve-windows-train.md b/.changeset/twelve-windows-train.md new file mode 100644 index 000000000000..4c6ef548e650 --- /dev/null +++ b/.changeset/twelve-windows-train.md @@ -0,0 +1,5 @@ +--- +'@rocket.chat/meteor': patch +--- + +Fixed: Custom fields in extraData now correctly added to extraRoomInfo by livechat.beforeRoom callback during livechat room creation. diff --git a/.changeset/two-bikes-crash.md b/.changeset/two-bikes-crash.md new file mode 100644 index 000000000000..a120435e4a48 --- /dev/null +++ b/.changeset/two-bikes-crash.md @@ -0,0 +1,7 @@ +--- +'@rocket.chat/meteor': patch +--- + +Fixed an issue related to setting Accounts_ForgetUserSessionOnWindowClose, this setting was not working as expected. + +The new meteor 2.16 release introduced a new option to configure the Accounts package and choose between the local storage or session storage. They also changed how Meteor.\_localstorage works internally. Due to these changes in Meteor, our setting to use session storage wasn't working as expected. This PR fixes this issue and configures the Accounts package according to the workspace settings. diff --git a/.changeset/violet-brooms-press.md b/.changeset/violet-brooms-press.md deleted file mode 100644 index 632026d6fe2e..000000000000 --- a/.changeset/violet-brooms-press.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@rocket.chat/meteor': patch ---- - -Security Hotfix (https://docs.rocket.chat/guides/security/security-updates) diff --git a/.changeset/violet-radios-begin.md b/.changeset/violet-radios-begin.md new file mode 100644 index 000000000000..d11f23b47478 --- /dev/null +++ b/.changeset/violet-radios-begin.md @@ -0,0 +1,15 @@ +--- +'@rocket.chat/core-typings': minor +'@rocket.chat/i18n': minor +'@rocket.chat/meteor': minor +--- + +Fixed a bug related to uploading end to end encrypted file. + +E2EE files and uploads are uploaded as files of mime type `application/octet-stream` as we can't reveal the mime type of actual content since it is encrypted and has to be kept confidential. + +The server resolves the mime type of encrypted file as `application/octet-stream` but it wasn't playing nicely with existing settings related to whitelisted and blacklisted media types. + +E2EE files upload was getting blocked if `application/octet-stream` is not a whitelisted media type. + +Now this PR solves this issue by always accepting E2EE uploads even if `application/octet-stream` is not whitelisted but it will block the upload if `application/octet-stream` is black listed. diff --git a/.changeset/weak-insects-sort.md b/.changeset/weak-insects-sort.md deleted file mode 100644 index cbbe7c4aa08c..000000000000 --- a/.changeset/weak-insects-sort.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@rocket.chat/meteor": patch ---- - -Improving UX by change the position of room info actions buttons and menu order to avoid missclick in destructive actions. diff --git a/.changeset/weak-pets-talk.md b/.changeset/weak-pets-talk.md deleted file mode 100644 index abaa9c683d65..000000000000 --- a/.changeset/weak-pets-talk.md +++ /dev/null @@ -1,7 +0,0 @@ ---- -'@rocket.chat/omnichannel-services': patch -'@rocket.chat/core-services': patch -'@rocket.chat/meteor': patch ---- - -Reduced time on generation of PDF transcripts. Earlier Rocket.Chat was fetching the required translations everytime a PDF transcript was requested, this process was async and was being unnecessarily being performed on every pdf transcript request. This PR improves this and now the translations are loaded at the start and kept in memory to process further pdf transcripts requests. This reduces the time of asynchronously fetching translations again and again. diff --git a/.changeset/weak-taxis-design.md b/.changeset/weak-taxis-design.md deleted file mode 100644 index a2d435495cd7..000000000000 --- a/.changeset/weak-taxis-design.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@rocket.chat/meteor': minor ---- - -Added handling of attachments in Omnichannel email transcripts. Earlier attachments were being skipped and were being shown as empty space, now it should render the image attachments and should show relevant error message for unsupported attachments. diff --git a/.changeset/weak-tigers-suffer.md b/.changeset/weak-tigers-suffer.md deleted file mode 100644 index 91748a43c677..000000000000 --- a/.changeset/weak-tigers-suffer.md +++ /dev/null @@ -1,7 +0,0 @@ ---- -"@rocket.chat/meteor": minor -"@rocket.chat/model-typings": minor -"@rocket.chat/rest-typings": minor ---- - -Added the ability to filter chats by `queued` on the Current Chats Omnichannel page diff --git a/.changeset/witty-bats-develop.md b/.changeset/witty-bats-develop.md deleted file mode 100644 index 42c9409d9ef3..000000000000 --- a/.changeset/witty-bats-develop.md +++ /dev/null @@ -1,13 +0,0 @@ ---- -"@rocket.chat/meteor": patch -"@rocket.chat/apps": patch -"@rocket.chat/core-services": patch -"@rocket.chat/core-typings": patch -"@rocket.chat/fuselage-ui-kit": patch -"@rocket.chat/rest-typings": patch -"@rocket.chat/ddp-streamer": patch -"@rocket.chat/presence": patch -"rocketchat-services": patch ---- - -Added the `user` param to apps-engine update method call, allowing apps' new `onUpdate` hook to know who triggered the update. diff --git a/.github/actions/build-docker/action.yml b/.github/actions/build-docker/action.yml index dbc615da889a..5af39b924057 100644 --- a/.github/actions/build-docker/action.yml +++ b/.github/actions/build-docker/action.yml @@ -17,13 +17,28 @@ inputs: required: false description: 'Containers to build along with Rocket.Chat' type: string + turbo-cache: + required: false + description: 'Enable turbo cache' + default: 'true' + publish-image: + required: false + description: 'Publish image' + default: 'true' + setup: + required: false + description: 'Setup node.js' + default: 'true' + NPM_TOKEN: + required: false + description: 'NPM token' runs: using: composite steps: - name: Login to GitHub Container Registry - if: (github.event.pull_request.head.repo.full_name == github.repository || github.event_name == 'release' || github.ref == 'refs/heads/develop') + if: inputs.publish-image == 'true' &&(github.event.pull_request.head.repo.full_name == github.repository || github.event_name == 'release' || github.ref == 'refs/heads/develop') uses: docker/login-action@v2 with: registry: ghcr.io @@ -31,7 +46,7 @@ runs: password: ${{ inputs.CR_PAT }} - name: Restore build - uses: actions/download-artifact@v3 + uses: actions/download-artifact@v4 with: name: build path: /tmp/build @@ -42,17 +57,21 @@ runs: cd /tmp/build tar xzf Rocket.Chat.tar.gz rm Rocket.Chat.tar.gz - - uses: rharkor/caching-for-turbo@v1.5 + # if we are testing a PR from a fork, we already called the turbo cache at this point, so it should be false + if: inputs.turbo-cache == 'true' - name: Setup NodeJS uses: ./.github/actions/setup-node + if: inputs.setup == 'true' with: node-version: ${{ inputs.node-version }} cache-modules: true install: true + NPM_TOKEN: ${{ inputs.NPM_TOKEN }} - run: yarn build + if: inputs.setup == 'true' shell: bash - name: Build Docker images @@ -63,9 +82,14 @@ runs: docker compose -f docker-compose-ci.yml build "${args[@]}" - name: Publish Docker images to GitHub Container Registry - if: (github.event.pull_request.head.repo.full_name == github.repository || github.event_name == 'release' || github.ref == 'refs/heads/develop') + if: inputs.publish-image == 'true' && (github.event.pull_request.head.repo.full_name == github.repository || github.event_name == 'release' || github.ref == 'refs/heads/develop') shell: bash run: | args=(rocketchat ${{ inputs.build-containers }}) docker compose -f docker-compose-ci.yml push "${args[@]}" + + - name: Clean up temporary files + shell: bash + run: | + sudo rm -rf /tmp/bundle diff --git a/.github/actions/meteor-build/action.yml b/.github/actions/meteor-build/action.yml index c13703aeea46..525595146700 100644 --- a/.github/actions/meteor-build/action.yml +++ b/.github/actions/meteor-build/action.yml @@ -13,6 +13,9 @@ inputs: required: true description: 'Node version' type: string + NPM_TOKEN: + required: false + description: 'NPM token' runs: using: composite @@ -29,6 +32,7 @@ runs: node-version: ${{ inputs.node-version }} cache-modules: true install: true + NPM_TOKEN: ${{ inputs.NPM_TOKEN }} # - name: Free disk space # run: | @@ -124,7 +128,8 @@ runs: tar czf /tmp/Rocket.Chat.tar.gz bundle - name: Store build - uses: actions/upload-artifact@v3 + uses: actions/upload-artifact@v4 with: name: build path: /tmp/Rocket.Chat.tar.gz + overwrite: true diff --git a/.github/actions/setup-node/action.yml b/.github/actions/setup-node/action.yml index caa3c63e00f0..1035e2835792 100644 --- a/.github/actions/setup-node/action.yml +++ b/.github/actions/setup-node/action.yml @@ -1,22 +1,27 @@ name: 'Setup Node' +description: 'Setup NodeJS' inputs: node-version: required: true - type: string + description: 'Node version' cache-modules: required: false - type: boolean + description: 'Cache node_modules' install: required: false - type: boolean + description: 'Install dependencies' deno-dir: required: false - type: string + description: 'Deno directory' default: ~/.deno-cache + NPM_TOKEN: + required: false + description: 'NPM token' outputs: node-version: + description: 'Node version' value: ${{ steps.node-version.outputs.node-version }} runs: @@ -32,6 +37,7 @@ runs: uses: actions/cache@v3 with: path: | + .turbo/cache node_modules ${{ env.DENO_DIR }} apps/meteor/node_modules @@ -48,6 +54,13 @@ runs: node-version: ${{ inputs.node-version }} cache: 'yarn' + - name: yarn login + shell: bash + if: inputs.NPM_TOKEN + run: | + echo "//registry.npmjs.org/:_authToken=${{ inputs.NPM_TOKEN }}" > ~/.npmrc + - name: yarn install + if: inputs.install shell: bash run: yarn diff --git a/.github/workflows/ci-code-check.yml b/.github/workflows/ci-code-check.yml index fd214bc39488..af50b3230ba7 100644 --- a/.github/workflows/ci-code-check.yml +++ b/.github/workflows/ci-code-check.yml @@ -35,6 +35,7 @@ jobs: node-version: ${{ inputs.node-version }} cache-modules: true install: true + NPM_TOKEN: ${{ secrets.NPM_TOKEN }} # - name: Free disk space # run: | diff --git a/.github/workflows/ci-test-e2e.yml b/.github/workflows/ci-test-e2e.yml index 8bbb40a734fb..e6c02b7b6417 100644 --- a/.github/workflows/ci-test-e2e.yml +++ b/.github/workflows/ci-test-e2e.yml @@ -130,7 +130,11 @@ jobs: node-version: ${{ inputs.node-version }} cache-modules: true install: true + NPM_TOKEN: ${{ secrets.NPM_TOKEN }} + - uses: rharkor/caching-for-turbo@v1.5 + + - run: yarn build # if we are testing a PR from a fork, we need to build the docker image at this point - uses: ./.github/actions/build-docker if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name != github.repository @@ -138,8 +142,12 @@ jobs: CR_USER: ${{ secrets.CR_USER }} CR_PAT: ${{ secrets.CR_PAT }} node-version: ${{ inputs.node-version }} - - - uses: rharkor/caching-for-turbo@v1.5 + # we already called the turbo cache at this point, so it should be false + turbo-cache: false + # the same reason we need to rebuild the docker image at this point is the reason we dont want to publish it + publish-image: false + setup: false + NPM_TOKEN: ${{ secrets.NPM_TOKEN }} - name: Start httpbin container and wait for it to be ready if: inputs.type == 'api' @@ -159,8 +167,6 @@ jobs: exit 1 fi - - run: yarn build - - name: Prepare code coverage directory if: inputs.release == 'ee' run: | @@ -187,12 +193,6 @@ jobs: run: | docker compose -f docker-compose-ci.yml up -d - - name: Clean up temporary files - # remove all folders inside /tmp except /tmp/coverage - run: | - cd /tmp - sudo find . -mindepth 1 -maxdepth 1 -type d | grep -v './coverage' | sudo xargs rm -rf - - name: Cache Playwright binaries if: inputs.type == 'ui' uses: actions/cache@v3 @@ -289,9 +289,9 @@ jobs: - name: Store playwright test trace if: inputs.type == 'ui' && always() - uses: actions/upload-artifact@v3 + uses: actions/upload-artifact@v4 with: - name: playwright-test-trace${{ inputs.release }} + name: playwright-test-trace-${{ matrix.mongodb-version }}-${{ matrix.shard }} path: ./apps/meteor/tests/e2e/.playwright* - name: Show server logs if E2E test failed @@ -322,14 +322,14 @@ jobs: - name: Store e2e-api-ee-coverage if: inputs.type == 'api' && inputs.release == 'ee' - uses: actions/upload-artifact@v3 + uses: actions/upload-artifact@v4 with: - name: e2e-api-ee-coverage + name: e2e-api-ee-coverage-${{ matrix.mongodb-version }}-${{ matrix.shard }} path: /tmp/coverage - name: Store e2e-ee-coverage if: inputs.type == 'ui' && inputs.release == 'ee' - uses: actions/upload-artifact@v3 + uses: actions/upload-artifact@v4 with: - name: e2e-ee-coverage + name: e2e-ee-coverage-${{ matrix.mongodb-version }}-${{ matrix.shard }} path: ./apps/meteor/coverage* diff --git a/.github/workflows/ci-test-unit.yml b/.github/workflows/ci-test-unit.yml index a32c1e575b8f..840808ff5e31 100644 --- a/.github/workflows/ci-test-unit.yml +++ b/.github/workflows/ci-test-unit.yml @@ -39,6 +39,7 @@ jobs: node-version: ${{ inputs.node-version }} cache-modules: true install: true + NPM_TOKEN: ${{ secrets.NPM_TOKEN }} - uses: rharkor/caching-for-turbo@v1.5 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 77a8d648ae61..514dd6d1c518 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -152,6 +152,7 @@ jobs: node-version: ${{ needs.release-versions.outputs.node-version }} cache-modules: true install: true + NPM_TOKEN: ${{ secrets.NPM_TOKEN }} - name: Cache vite uses: actions/cache@v3 @@ -253,6 +254,7 @@ jobs: node-version: ${{ needs.release-versions.outputs.node-version }} platform: ${{ matrix.platform }} build-containers: ${{ matrix.platform == 'alpine' && 'authorization-service account-service ddp-streamer-service presence-service stream-hub-service queue-worker-service omnichannel-transcript-service' || '' }} + NPM_TOKEN: ${{ secrets.NPM_TOKEN }} build-gh-docker: name: đŸšĸ Build Docker Images for Production @@ -280,6 +282,7 @@ jobs: node-version: ${{ needs.release-versions.outputs.node-version }} platform: ${{ matrix.platform }} build-containers: ${{ matrix.platform == 'alpine' && 'authorization-service account-service ddp-streamer-service presence-service stream-hub-service queue-worker-service omnichannel-transcript-service' || '' }} + NPM_TOKEN: ${{ secrets.NPM_TOKEN }} - name: Rename official Docker tag to GitHub Container Registry if: matrix.platform == 'official' @@ -492,7 +495,7 @@ jobs: ref: ${{ github.ref }} - name: Restore build - uses: actions/download-artifact@v3 + uses: actions/download-artifact@v4 with: name: build path: /tmp/build @@ -540,7 +543,7 @@ jobs: - uses: actions/checkout@v4 - name: Restore build - uses: actions/download-artifact@v3 + uses: actions/download-artifact@v4 with: name: build path: /tmp/build @@ -560,6 +563,7 @@ jobs: release: preview username: ${{ secrets.CR_USER }} password: ${{ secrets.CR_PAT }} + NPM_TOKEN: ${{ secrets.NPM_TOKEN }} docker-image-publish: name: 🚀 Publish Docker Image (main) @@ -576,13 +580,13 @@ jobs: steps: - name: Login to DockerHub - uses: docker/login-action@v2 + uses: docker/login-action@v3 with: username: ${{ secrets.DOCKER_USER }} password: ${{ secrets.DOCKER_PASS }} - name: Login to GitHub Container Registry - uses: docker/login-action@v2 + uses: docker/login-action@v3 with: registry: ghcr.io username: ${{ secrets.CR_USER }} @@ -683,13 +687,13 @@ jobs: steps: - name: Login to DockerHub - uses: docker/login-action@v2 + uses: docker/login-action@v3 with: username: ${{ secrets.DOCKER_USER }} password: ${{ secrets.DOCKER_PASS }} - name: Login to GitHub Container Registry - uses: docker/login-action@v2 + uses: docker/login-action@v3 with: registry: ghcr.io username: ${{ secrets.CR_USER }} diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index 483b404a6dc8..202a02dd7785 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -26,7 +26,7 @@ jobs: # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL - uses: github/codeql-action/init@v2 + uses: github/codeql-action/init@v3 # Override language selection by uncommenting this and choosing your languages with: languages: javascript @@ -34,7 +34,7 @@ jobs: # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). # If this step fails, then you should remove it and run the build manually (see below) - name: Autobuild - uses: github/codeql-action/autobuild@v2 + uses: github/codeql-action/autobuild@v3 # ℹī¸ Command-line programs to run using the OS shell. # 📚 https://git.io/JvXDl @@ -48,4 +48,4 @@ jobs: # make release - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@v2 + uses: github/codeql-action/analyze@v3 diff --git a/.github/workflows/new-release.yml b/.github/workflows/new-release.yml index 5ef8027b1467..b2eae5d90b92 100644 --- a/.github/workflows/new-release.yml +++ b/.github/workflows/new-release.yml @@ -37,6 +37,7 @@ jobs: node-version: 14.21.3 cache-modules: true install: true + NPM_TOKEN: ${{ secrets.NPM_TOKEN }} - uses: rharkor/caching-for-turbo@v1.5 diff --git a/.github/workflows/pr-title-checker.yml b/.github/workflows/pr-title-checker.yml index bc9d1f042d58..d8f6db97c455 100644 --- a/.github/workflows/pr-title-checker.yml +++ b/.github/workflows/pr-title-checker.yml @@ -12,6 +12,6 @@ jobs: check: runs-on: ubuntu-latest steps: - - uses: thehanimo/pr-title-checker@v1.4.1 + - uses: thehanimo/pr-title-checker@v1.4.2 with: GITHUB_TOKEN: ${{ secrets.RC_TITLE_CHECKER }} diff --git a/.github/workflows/pr-update-description.yml b/.github/workflows/pr-update-description.yml index e792127eac9d..084f2a383480 100644 --- a/.github/workflows/pr-update-description.yml +++ b/.github/workflows/pr-update-description.yml @@ -24,6 +24,7 @@ jobs: node-version: 14.21.3 cache-modules: true install: true + NPM_TOKEN: ${{ secrets.NPM_TOKEN }} - uses: rharkor/caching-for-turbo@v1.5 diff --git a/.github/workflows/publish-release.yml b/.github/workflows/publish-release.yml index ccc3408e194e..3f2067ac7ec3 100644 --- a/.github/workflows/publish-release.yml +++ b/.github/workflows/publish-release.yml @@ -27,6 +27,7 @@ jobs: node-version: 14.21.3 cache-modules: true install: true + NPM_TOKEN: ${{ secrets.NPM_TOKEN }} - uses: rharkor/caching-for-turbo@v1.5 diff --git a/.github/workflows/update-version-durability.yml b/.github/workflows/update-version-durability.yml index e52b4870b369..90c835577dc1 100644 --- a/.github/workflows/update-version-durability.yml +++ b/.github/workflows/update-version-durability.yml @@ -17,7 +17,7 @@ jobs: - uses: actions/checkout@v4 - name: Use Node.js - uses: actions/setup-node@v3.7.0 + uses: actions/setup-node@v4.0.3 with: node-version: '20.15.1' diff --git a/.gitignore b/.gitignore index fcf2b8cd07c7..4e6e4bb29da9 100644 --- a/.gitignore +++ b/.gitignore @@ -50,3 +50,6 @@ yarn-error.log* *.sublime-workspace **/.vim/ + +data/ +registration.yaml diff --git a/_templates/package/new/jest.config.ts.t b/_templates/package/new/jest.config.ts.t new file mode 100644 index 000000000000..c18c8ae02465 --- /dev/null +++ b/_templates/package/new/jest.config.ts.t @@ -0,0 +1,6 @@ +import server from '@rocket.chat/jest-presets/server'; +import type { Config } from 'jest'; + +export default { + preset: server.preset, +} satisfies Config; diff --git a/_templates/package/new/package.json.ejs.t b/_templates/package/new/package.json.ejs.t index 950e5cb2bf62..6bee52f55927 100644 --- a/_templates/package/new/package.json.ejs.t +++ b/_templates/package/new/package.json.ejs.t @@ -7,11 +7,11 @@ to: packages/<%= name %>/package.json "version": "0.0.1", "private": true, "devDependencies": { - "@types/jest": "~29.5.3", + "@rocket.chat/jest-presets": "workspace:~", + "@types/jest": "~29.5.12", "eslint": "~8.45.0", - "jest": "~29.6.1", - "ts-jest": "~29.0.5", - "typescript": "~5.1.6" + "jest": "~29.7.0", + "typescript": "~5.3.3" }, "scripts": { "lint": "eslint --ext .js,.jsx,.ts,.tsx .", diff --git a/_templates/package/new/tsconfig.json.ejs.t b/_templates/package/new/tsconfig.json.ejs.t index 3e192c674d1b..399544502ed0 100644 --- a/_templates/package/new/tsconfig.json.ejs.t +++ b/_templates/package/new/tsconfig.json.ejs.t @@ -2,10 +2,10 @@ to: packages/<%= name %>/tsconfig.json --- { - "extends": "../../tsconfig.base.client.json", + "extends": "../../tsconfig.base.server.json", "compilerOptions": { "rootDir": "./src", "outDir": "./dist" }, - "include": ["./src/**/*"] + "include": ["./src/**/*"], } diff --git a/apps/meteor/.mocharc.client.js b/apps/meteor/.mocharc.client.js deleted file mode 100644 index cf339a420378..000000000000 --- a/apps/meteor/.mocharc.client.js +++ /dev/null @@ -1,42 +0,0 @@ -'use strict'; - -/** - * Mocha configuration for client-side unit and integration tests. - */ - -const base = require('./.mocharc.base.json'); - -/** - * Mocha will run `ts-node` without doing type checking to speed-up the tests. It should be fine as `npm run typecheck` - * covers test files too. - */ - -Object.assign( - process.env, - { - TS_NODE_FILES: true, - TS_NODE_TRANSPILE_ONLY: true, - }, - process.env, -); - -module.exports = { - ...base, // see https://github.com/mochajs/mocha/issues/3916 - require: [ - ...base.require, - './tests/setup/registerWebApiMocks.ts', - './tests/setup/hoistedReact.ts', - './tests/setup/cleanupTestingLibrary.ts', - ], - reporter: 'dot', - timeout: 5000, - exit: false, - slow: 200, - spec: [ - 'tests/unit/client/sidebar/**/*.spec.{ts,tsx}', - 'tests/unit/client/components/**/*.spec.{ts,tsx}', - 'tests/unit/client/lib/**/*.spec.{ts,tsx}', - 'tests/unit/lib/**/*.tests.ts', - 'tests/unit/client/**/*.test.ts', - ], -}; diff --git a/apps/meteor/CHANGELOG.md b/apps/meteor/CHANGELOG.md index c80c36bf4890..f466c34da838 100644 --- a/apps/meteor/CHANGELOG.md +++ b/apps/meteor/CHANGELOG.md @@ -1,5 +1,276 @@ # @rocket.chat/meteor +## 6.11.0 + +### Minor Changes + +- ([#32498](https://github.com/RocketChat/Rocket.Chat/pull/32498)) Created a `transferChat` Livechat API endpoint for transferring chats programmatically, the endpoint has all the limitations & permissions required that transferring via UI has + +- ([#32792](https://github.com/RocketChat/Rocket.Chat/pull/32792)) Allows admins to customize the `Subject` field of Omnichannel email transcripts via setting. By passing a value to the setting `Custom email subject for transcript`, system will use it as the `Subject` field, unless a custom subject is passed when requesting a transcript. If there's no custom subject and setting value is empty, the current default value will be used + +- ([#32739](https://github.com/RocketChat/Rocket.Chat/pull/32739)) Fixed an issue where FCM actions did not respect environment's proxy settings + +- ([#32706](https://github.com/RocketChat/Rocket.Chat/pull/32706)) Added the possibility for apps to remove users from a room + +- ([#32517](https://github.com/RocketChat/Rocket.Chat/pull/32517)) Feature Preview: New Navigation - `Header` and `Contextualbar` size improvements consistent with the new global `NavBar` + +- ([#32493](https://github.com/RocketChat/Rocket.Chat/pull/32493)) Fixed Livechat rooms being displayed in the Engagement Dashboard's "Channels" tab + +- ([#32742](https://github.com/RocketChat/Rocket.Chat/pull/32742)) Fixed an issue where adding `OVERWRITE_SETTING_` for any setting wasn't immediately taking effect sometimes, and needed a server restart to reflect. + +- ([#32752](https://github.com/RocketChat/Rocket.Chat/pull/32752)) Added system messages support for Omnichannel PDF transcripts and email transcripts. Currently these transcripts don't render system messages and is shown as an empty message in PDF/email. This PR adds this support for all valid livechat system messages. + + Also added a new setting under transcripts, to toggle the inclusion of system messages in email and PDF transcripts. + +- ([#32793](https://github.com/RocketChat/Rocket.Chat/pull/32793)) New Feature: Video Conference Persistent Chat. + This feature provides a discussion id for conference provider apps to store the chat messages exchanged during the conferences, so that those users may then access those messages again at any time through Rocket.Chat. +- ([#32176](https://github.com/RocketChat/Rocket.Chat/pull/32176)) Added a method to the Apps-Engine that allows apps to read multiple messages from a room + +- ([#32493](https://github.com/RocketChat/Rocket.Chat/pull/32493)) Improved Engagement Dashboard's "Channels" tab performance by not returning rooms that had no activity in the analyzed period + +- ([#32024](https://github.com/RocketChat/Rocket.Chat/pull/32024)) Implemented a new tab to the users page called 'Active', this tab lists all users who have logged in for the first time and are active. + +- ([#32744](https://github.com/RocketChat/Rocket.Chat/pull/32744)) Added account setting `Accounts_Default_User_Preferences_sidebarSectionsOrder` to allow users to reorganize sidebar sections + +- ([#32820](https://github.com/RocketChat/Rocket.Chat/pull/32820)) Added a new setting `Livechat_transcript_send_always` that allows admins to decide if email transcript should be sent all the times when a conversation is closed. This setting bypasses agent's preferences. For this setting to work, `Livechat_enable_transcript` should be off, meaning that visitors will no longer receive the option to decide if they want a transcript or not. + +- ([#32724](https://github.com/RocketChat/Rocket.Chat/pull/32724)) Extended apps-engine events for users leaving a room to also fire when being removed by another user. Also added the triggering user's information to the event's context payload. + +- ([#32777](https://github.com/RocketChat/Rocket.Chat/pull/32777)) Added handling of attachments in Omnichannel email transcripts. Earlier attachments were being skipped and were being shown as empty space, now it should render the image attachments and should show relevant error message for unsupported attachments. + +- ([#32800](https://github.com/RocketChat/Rocket.Chat/pull/32800)) Added the ability to filter chats by `queued` on the Current Chats Omnichannel page + +### Patch Changes + +- Bump @rocket.chat/meteor version. + +- Bump @rocket.chat/meteor version. + +- Bump @rocket.chat/meteor version. + +- Bump @rocket.chat/meteor version. + +- Bump @rocket.chat/meteor version. + +- Bump @rocket.chat/meteor version. + +- Bump @rocket.chat/meteor version. + +- ([#32679](https://github.com/RocketChat/Rocket.Chat/pull/32679)) Fix validations from "UiKit" modal component + +- ([#32730](https://github.com/RocketChat/Rocket.Chat/pull/32730)) Fixed issue in Marketplace that caused a subscription app to show incorrect modals when subscribing + +- ([#32628](https://github.com/RocketChat/Rocket.Chat/pull/32628)) Fixed SAML users' full names being updated on login regardless of the "Overwrite user fullname (use idp attribute)" setting + +- ([#32692](https://github.com/RocketChat/Rocket.Chat/pull/32692)) Fixed an issue that caused the widget to set the wrong department when using the setDepartment Livechat api endpoint in conjunction with a Livechat Trigger + +- ([#32527](https://github.com/RocketChat/Rocket.Chat/pull/32527)) Fixed an inconsistent evaluation of the `Accounts_LoginExpiration` setting over the codebase. In some places, it was being used as milliseconds while in others as days. Invalid values produced different results. A helper function was created to centralize the setting validation and the proper value being returned to avoid edge cases. + Negative values may be saved on the settings UI panel but the code will interpret any negative, NaN or 0 value to the default expiration which is 90 days. +- ([#32626](https://github.com/RocketChat/Rocket.Chat/pull/32626)) livechat `setDepartment` livechat api fixes: + - Changing department didn't reflect on the registration form in real time + - Changing the department mid conversation didn't transfer the chat + - Depending on the state of the department, it couldn't be set as default +- ([#32810](https://github.com/RocketChat/Rocket.Chat/pull/32810)) Fixed issue where bad word filtering was not working in the UI for messages + +- ([#32707](https://github.com/RocketChat/Rocket.Chat/pull/32707)) Fixed issue with livechat agents not being able to leave omnichannel rooms if joining after a room has been closed by the visitor (due to race conditions) + +- ([#32837](https://github.com/RocketChat/Rocket.Chat/pull/32837)) Fixed an issue where non-encrypted attachments were not being downloaded + +- ([#32861](https://github.com/RocketChat/Rocket.Chat/pull/32861)) fixed the contextual bar closing when editing thread messages instead of cancelling the message edit + +- ([#32713](https://github.com/RocketChat/Rocket.Chat/pull/32713)) Fixed the disappearance of some settings after navigation under network latency. + +- ([#32592](https://github.com/RocketChat/Rocket.Chat/pull/32592)) Fixes Missing line breaks on Omnichannel Room Info Panel + +- ([#32807](https://github.com/RocketChat/Rocket.Chat/pull/32807)) Fixed web client crashing on Firefox private window. Firefox disables access to service workers inside private windows. Rocket.Chat needs service workers to process E2EE encrypted files on rooms. These types of files won't be available inside private windows, but the rest of E2EE encrypted features should work normally + +- ([#32864](https://github.com/RocketChat/Rocket.Chat/pull/32864)) fixed an issue in the "Create discussion" form, that would have the "Create" action button disabled even though the form is prefilled when opening it from the message action + +- ([#32691](https://github.com/RocketChat/Rocket.Chat/pull/32691)) Removed 'Hide' option in the room menu for Omnichannel conversations. + +- ([#32445](https://github.com/RocketChat/Rocket.Chat/pull/32445)) Fixed LDAP rooms, teams and roles syncs not being triggered on login even when the "Update User Data on Login" setting is enabled + +- ([#32328](https://github.com/RocketChat/Rocket.Chat/pull/32328)) Allow customFields on livechat creation bridge + +- ([#32803](https://github.com/RocketChat/Rocket.Chat/pull/32803)) Fixed "Copy link" message action enabled in Starred and Pinned list for End to End Encrypted channels, this action is disabled now + +- ([#32769](https://github.com/RocketChat/Rocket.Chat/pull/32769)) Fixed issue that caused unintentional clicks when scrolling the channels sidebar on safari/chrome in iOS + +- ([#32857](https://github.com/RocketChat/Rocket.Chat/pull/32857)) Fixed some anomalies related to disabled E2EE rooms. Earlier there are some weird issues with disabled E2EE rooms, this PR fixes these anomalies. + +- ([#32765](https://github.com/RocketChat/Rocket.Chat/pull/32765)) Fixed an issue that prevented the option to start a discussion from being shown on the message actions + +- ([#32671](https://github.com/RocketChat/Rocket.Chat/pull/32671)) Fix show correct user roles after updating user roles on admin edit user panel. + +- ([#32482](https://github.com/RocketChat/Rocket.Chat/pull/32482)) Fixed an issue with blocked login when dismissed 2FA modal by clicking outside of it or pressing the escape key + +- ([#32804](https://github.com/RocketChat/Rocket.Chat/pull/32804)) Fixes an issue not displaying all groups in settings list + +- ([#32815](https://github.com/RocketChat/Rocket.Chat/pull/32815)) Security Hotfix (https://docs.rocket.chat/guides/security/security-updates) + +- ([#32632](https://github.com/RocketChat/Rocket.Chat/pull/32632)) Improving UX by change the position of room info actions buttons and menu order to avoid missclick in destructive actions. + +- ([#32752](https://github.com/RocketChat/Rocket.Chat/pull/32752)) Reduced time on generation of PDF transcripts. Earlier Rocket.Chat was fetching the required translations everytime a PDF transcript was requested, this process was async and was being unnecessarily being performed on every pdf transcript request. This PR improves this and now the translations are loaded at the start and kept in memory to process further pdf transcripts requests. This reduces the time of asynchronously fetching translations again and again. + +- ([#32719](https://github.com/RocketChat/Rocket.Chat/pull/32719)) Added the `user` param to apps-engine update method call, allowing apps' new `onUpdate` hook to know who triggered the update. + +-
Updated dependencies [88e5219bd2, b4bbcbfc9a, 8fc6ca8b4e, 25da5280a5, 1b7b1161cf, 439faa87d3, 03c8b066f9, 2d89a0c448, 439faa87d3, 24f7df4894, 3ffe4a2944, 3b4b19cfc5, 4e8aa575a6, 03c8b066f9, 264d7d5496, b8e5887fb9]: + + - @rocket.chat/fuselage-ui-kit@9.0.0 + - @rocket.chat/i18n@0.6.0 + - @rocket.chat/tools@0.2.2 + - @rocket.chat/ui-client@9.0.0 + - @rocket.chat/model-typings@0.6.0 + - @rocket.chat/omnichannel-services@0.3.0 + - @rocket.chat/pdf-worker@0.2.0 + - @rocket.chat/core-services@0.5.0 + - @rocket.chat/ui-video-conf@9.0.0 + - @rocket.chat/core-typings@6.11.0 + - @rocket.chat/ui-contexts@9.0.0 + - @rocket.chat/models@0.2.0 + - @rocket.chat/ui-kit@0.36.0 + - @rocket.chat/web-ui-registration@9.0.0 + - @rocket.chat/rest-typings@6.11.0 + - @rocket.chat/apps@0.1.3 + - @rocket.chat/presence@0.2.3 + - @rocket.chat/gazzodown@9.0.0 + - @rocket.chat/api-client@0.2.3 + - @rocket.chat/license@0.2.3 + - @rocket.chat/cron@0.1.3 + - @rocket.chat/ui-theming@0.2.0 + - @rocket.chat/ui-avatar@5.0.0 + - @rocket.chat/instance-status@0.1.3 + - @rocket.chat/server-cloud-communication@0.0.2 +
+ +## 6.11.0-rc.6 + +### Patch Changes + +- Bump @rocket.chat/meteor version. + +-
Updated dependencies []: + + - @rocket.chat/core-typings@6.11.0-rc.6 + - @rocket.chat/rest-typings@6.11.0-rc.6 + - @rocket.chat/api-client@0.2.3-rc.6 + - @rocket.chat/license@0.2.3-rc.6 + - @rocket.chat/omnichannel-services@0.3.0-rc.6 + - @rocket.chat/pdf-worker@0.2.0-rc.6 + - @rocket.chat/presence@0.2.3-rc.6 + - @rocket.chat/apps@0.1.3-rc.6 + - @rocket.chat/core-services@0.5.0-rc.6 + - @rocket.chat/cron@0.1.3-rc.6 + - @rocket.chat/fuselage-ui-kit@9.0.0-rc.6 + - @rocket.chat/gazzodown@9.0.0-rc.6 + - @rocket.chat/model-typings@0.6.0-rc.6 + - @rocket.chat/ui-contexts@9.0.0-rc.6 + - @rocket.chat/server-cloud-communication@0.0.2 + - @rocket.chat/models@0.2.0-rc.6 + - @rocket.chat/ui-theming@0.2.0 + - @rocket.chat/ui-avatar@5.0.0-rc.6 + - @rocket.chat/ui-client@9.0.0-rc.6 + - @rocket.chat/ui-video-conf@9.0.0-rc.6 + - @rocket.chat/web-ui-registration@9.0.0-rc.6 + - @rocket.chat/instance-status@0.1.3-rc.6 +
+ +## 6.11.0-rc.5 + +### Patch Changes + +- Bump @rocket.chat/meteor version. + +-
Updated dependencies []: + + - @rocket.chat/core-typings@6.11.0-rc.5 + - @rocket.chat/rest-typings@6.11.0-rc.5 + - @rocket.chat/api-client@0.2.3-rc.5 + - @rocket.chat/license@0.2.3-rc.5 + - @rocket.chat/omnichannel-services@0.3.0-rc.5 + - @rocket.chat/pdf-worker@0.2.0-rc.5 + - @rocket.chat/presence@0.2.3-rc.5 + - @rocket.chat/apps@0.1.3-rc.5 + - @rocket.chat/core-services@0.5.0-rc.5 + - @rocket.chat/cron@0.1.3-rc.5 + - @rocket.chat/fuselage-ui-kit@9.0.0-rc.5 + - @rocket.chat/gazzodown@9.0.0-rc.5 + - @rocket.chat/model-typings@0.6.0-rc.5 + - @rocket.chat/ui-contexts@9.0.0-rc.5 + - @rocket.chat/server-cloud-communication@0.0.2 + - @rocket.chat/models@0.2.0-rc.5 + - @rocket.chat/ui-theming@0.2.0 + - @rocket.chat/ui-avatar@5.0.0-rc.5 + - @rocket.chat/ui-client@9.0.0-rc.5 + - @rocket.chat/ui-video-conf@9.0.0-rc.5 + - @rocket.chat/web-ui-registration@9.0.0-rc.5 + - @rocket.chat/instance-status@0.1.3-rc.5 +
+ +## 6.11.0-rc.4 + +### Patch Changes + +- Bump @rocket.chat/meteor version. + +-
Updated dependencies []: + + - @rocket.chat/core-typings@6.11.0-rc.4 + - @rocket.chat/rest-typings@6.11.0-rc.4 + - @rocket.chat/api-client@0.2.3-rc.4 + - @rocket.chat/license@0.2.3-rc.4 + - @rocket.chat/omnichannel-services@0.3.0-rc.4 + - @rocket.chat/pdf-worker@0.2.0-rc.4 + - @rocket.chat/presence@0.2.3-rc.4 + - @rocket.chat/apps@0.1.3-rc.4 + - @rocket.chat/core-services@0.5.0-rc.4 + - @rocket.chat/cron@0.1.3-rc.4 + - @rocket.chat/fuselage-ui-kit@9.0.0-rc.4 + - @rocket.chat/gazzodown@9.0.0-rc.4 + - @rocket.chat/model-typings@0.6.0-rc.4 + - @rocket.chat/ui-contexts@9.0.0-rc.4 + - @rocket.chat/server-cloud-communication@0.0.2 + - @rocket.chat/models@0.2.0-rc.4 + - @rocket.chat/ui-theming@0.2.0 + - @rocket.chat/ui-avatar@5.0.0-rc.4 + - @rocket.chat/ui-client@9.0.0-rc.4 + - @rocket.chat/ui-video-conf@9.0.0-rc.4 + - @rocket.chat/web-ui-registration@9.0.0-rc.4 + - @rocket.chat/instance-status@0.1.3-rc.4 +
+ +## 6.11.0-rc.3 + +### Patch Changes + +- Bump @rocket.chat/meteor version. + +-
Updated dependencies []: + + - @rocket.chat/core-typings@6.11.0-rc.3 + - @rocket.chat/rest-typings@6.11.0-rc.3 + - @rocket.chat/api-client@0.2.3-rc.3 + - @rocket.chat/license@0.2.3-rc.3 + - @rocket.chat/omnichannel-services@0.3.0-rc.3 + - @rocket.chat/pdf-worker@0.2.0-rc.3 + - @rocket.chat/presence@0.2.3-rc.3 + - @rocket.chat/apps@0.1.3-rc.3 + - @rocket.chat/core-services@0.5.0-rc.3 + - @rocket.chat/cron@0.1.3-rc.3 + - @rocket.chat/fuselage-ui-kit@9.0.0-rc.3 + - @rocket.chat/gazzodown@9.0.0-rc.3 + - @rocket.chat/model-typings@0.6.0-rc.3 + - @rocket.chat/ui-contexts@9.0.0-rc.3 + - @rocket.chat/server-cloud-communication@0.0.2 + - @rocket.chat/models@0.2.0-rc.3 + - @rocket.chat/ui-theming@0.2.0 + - @rocket.chat/ui-avatar@5.0.0-rc.3 + - @rocket.chat/ui-client@9.0.0-rc.3 + - @rocket.chat/ui-video-conf@9.0.0-rc.3 + - @rocket.chat/web-ui-registration@9.0.0-rc.3 + - @rocket.chat/instance-status@0.1.3-rc.3 +
+ ## 6.11.0-rc.2 ### Patch Changes diff --git a/apps/meteor/app/api/server/v1/emoji-custom.ts b/apps/meteor/app/api/server/v1/emoji-custom.ts index a61149c5e66e..9cbf202896e1 100644 --- a/apps/meteor/app/api/server/v1/emoji-custom.ts +++ b/apps/meteor/app/api/server/v1/emoji-custom.ts @@ -3,6 +3,8 @@ import { EmojiCustom } from '@rocket.chat/models'; import { Meteor } from 'meteor/meteor'; import { SystemLogger } from '../../../../server/lib/logger/system'; +import { insertOrUpdateEmoji } from '../../../emoji-custom/server/lib/insertOrUpdateEmoji'; +import { uploadEmojiCustomWithBuffer } from '../../../emoji-custom/server/lib/uploadEmojiCustom'; import { settings } from '../../../settings/server'; import { API } from '../api'; import { getPaginationItems } from '../helpers/getPaginationItems'; @@ -148,9 +150,19 @@ API.v1.addRoute( fields.extension = emojiToUpdate.extension; } - await Meteor.callAsync('insertOrUpdateEmoji', { ...fields, newFile }); + const emojiData = { + name: fields.name, + _id: fields._id, + aliases: fields.aliases, + extension: fields.extension, + previousName: fields.previousName, + previousExtension: fields.previousExtension, + newFile, + }; + + await insertOrUpdateEmoji(this.userId, emojiData); if (fields.newFile) { - await Meteor.callAsync('uploadEmojiCustom', fileBuffer, mimetype, { ...fields, newFile }); + await uploadEmojiCustomWithBuffer(this.userId, fileBuffer, mimetype, emojiData); } return API.v1.success(); }, diff --git a/apps/meteor/app/api/server/v1/federation.ts b/apps/meteor/app/api/server/v1/federation.ts index 7be5b1fc13fe..5f998546cf3e 100644 --- a/apps/meteor/app/api/server/v1/federation.ts +++ b/apps/meteor/app/api/server/v1/federation.ts @@ -22,3 +22,21 @@ API.v1.addRoute( }, }, ); + +API.v1.addRoute( + 'federation/configuration.verify', + { authRequired: true, permissionsRequired: ['view-privileged-setting'] }, + { + async get() { + const service = License.hasValidLicense() ? FederationEE : Federation; + + const status = await service.configurationStatus(); + + if (!status.externalReachability.ok || !status.appservice.ok) { + return API.v1.failure(status); + } + + return API.v1.success(status); + }, + }, +); diff --git a/apps/meteor/app/api/server/v1/oauthapps.ts b/apps/meteor/app/api/server/v1/oauthapps.ts index 034a73f54104..4113b945a4db 100644 --- a/apps/meteor/app/api/server/v1/oauthapps.ts +++ b/apps/meteor/app/api/server/v1/oauthapps.ts @@ -27,11 +27,12 @@ API.v1.addRoute( { authRequired: true, validateParams: isOauthAppsGetParams }, { async get() { - if (!(await hasPermissionAsync(this.userId, 'manage-oauth-apps'))) { - return API.v1.unauthorized(); - } + const isOAuthAppsManager = await hasPermissionAsync(this.userId, 'manage-oauth-apps'); - const oauthApp = await OAuthApps.findOneAuthAppByIdOrClientId(this.queryParams); + const oauthApp = await OAuthApps.findOneAuthAppByIdOrClientId( + this.queryParams, + !isOAuthAppsManager ? { projection: { clientSecret: 0 } } : {}, + ); if (!oauthApp) { return API.v1.failure('OAuth app not found.'); diff --git a/apps/meteor/app/api/server/v1/rooms.ts b/apps/meteor/app/api/server/v1/rooms.ts index e3296b98ef17..17ef75b74574 100644 --- a/apps/meteor/app/api/server/v1/rooms.ts +++ b/apps/meteor/app/api/server/v1/rooms.ts @@ -1,8 +1,14 @@ import { Media } from '@rocket.chat/core-services'; import type { IRoom, IUpload } from '@rocket.chat/core-typings'; -import { Messages, Rooms, Users, Uploads } from '@rocket.chat/models'; +import { Messages, Rooms, Users, Uploads, Subscriptions } from '@rocket.chat/models'; import type { Notifications } from '@rocket.chat/rest-typings'; -import { isGETRoomsNameExists, isRoomsImagesProps, isRoomsMuteUnmuteUserProps, isRoomsExportProps } from '@rocket.chat/rest-typings'; +import { + isGETRoomsNameExists, + isRoomsImagesProps, + isRoomsMuteUnmuteUserProps, + isRoomsExportProps, + isRoomsIsMemberProps, +} from '@rocket.chat/rest-typings'; import { Meteor } from 'meteor/meteor'; import { isTruthy } from '../../../../lib/isTruthy'; @@ -783,6 +789,36 @@ API.v1.addRoute( }, ); +API.v1.addRoute( + 'rooms.isMember', + { + authRequired: true, + validateParams: isRoomsIsMemberProps, + }, + { + async get() { + const { roomId, userId, username } = this.queryParams; + const [room, user] = await Promise.all([ + findRoomByIdOrName({ + params: { roomId }, + }) as Promise, + Users.findOneByIdOrUsername(userId || username), + ]); + + if (!user?._id) { + return API.v1.failure('error-user-not-found'); + } + + if (await canAccessRoomAsync(room, { _id: this.user._id })) { + return API.v1.success({ + isMember: (await Subscriptions.countByRoomIdAndUserId(room._id, user._id)) > 0, + }); + } + return API.v1.unauthorized(); + }, + }, +); + API.v1.addRoute( 'rooms.muteUser', { authRequired: true, validateParams: isRoomsMuteUnmuteUserProps }, diff --git a/apps/meteor/app/api/server/v1/subscriptions.ts b/apps/meteor/app/api/server/v1/subscriptions.ts index 9d81fe6bef65..b92d9ba572fd 100644 --- a/apps/meteor/app/api/server/v1/subscriptions.ts +++ b/apps/meteor/app/api/server/v1/subscriptions.ts @@ -82,6 +82,7 @@ API.v1.addRoute( async post() { const { readThreads = false } = this.bodyParams; const roomId = 'rid' in this.bodyParams ? this.bodyParams.rid : this.bodyParams.roomId; + await readMessages(roomId, this.userId, readThreads); return API.v1.success(); diff --git a/apps/meteor/app/api/server/v1/users.ts b/apps/meteor/app/api/server/v1/users.ts index 7ae585b89dfa..9c56ecac01cb 100644 --- a/apps/meteor/app/api/server/v1/users.ts +++ b/apps/meteor/app/api/server/v1/users.ts @@ -45,6 +45,7 @@ import { setUserAvatar } from '../../../lib/server/functions/setUserAvatar'; import { setUsernameWithValidation } from '../../../lib/server/functions/setUsername'; import { validateCustomFields } from '../../../lib/server/functions/validateCustomFields'; import { validateNameChars } from '../../../lib/server/functions/validateNameChars'; +import { validateUsername } from '../../../lib/server/functions/validateUsername'; import { notifyOnUserChange, notifyOnUserChangeAsync } from '../../../lib/server/lib/notifyListener'; import { generateAccessToken } from '../../../lib/server/methods/createToken'; import { settings } from '../../../settings/server'; @@ -651,6 +652,10 @@ API.v1.addRoute( return API.v1.failure('Name contains invalid characters'); } + if (!validateUsername(this.bodyParams.username)) { + return API.v1.failure(`The username provided is not valid`); + } + if (!(await checkUsernameAvailability(this.bodyParams.username))) { return API.v1.failure('Username is already in use'); } diff --git a/apps/meteor/app/apps/server/bridges/livechat.ts b/apps/meteor/app/apps/server/bridges/livechat.ts index ec5cff29a99b..4f4794591e02 100644 --- a/apps/meteor/app/apps/server/bridges/livechat.ts +++ b/apps/meteor/app/apps/server/bridges/livechat.ts @@ -1,7 +1,7 @@ -import type { IAppServerOrchestrator, IAppsLivechatMessage } from '@rocket.chat/apps'; +import type { IAppServerOrchestrator, IAppsLivechatMessage, IAppsMessage } from '@rocket.chat/apps'; import type { IExtraRoomParams } from '@rocket.chat/apps-engine/definition/accessors/ILivechatCreator'; import type { IVisitor, ILivechatRoom, ILivechatTransferData, IDepartment } from '@rocket.chat/apps-engine/definition/livechat'; -import type { IMessage as IAppsEngineMesage } from '@rocket.chat/apps-engine/definition/messages'; +import type { IMessage as IAppsEngineMessage } from '@rocket.chat/apps-engine/definition/messages'; import type { IUser } from '@rocket.chat/apps-engine/definition/users'; import { LivechatBridge } from '@rocket.chat/apps-engine/server/bridges/LivechatBridge'; import type { ILivechatDepartment, IOmnichannelRoom, SelectedAgent, IMessage, ILivechatVisitor } from '@rocket.chat/core-typings'; @@ -13,6 +13,12 @@ import { deasyncPromise } from '../../../../server/deasync/deasync'; import { type ILivechatMessage, Livechat as LivechatTyped } from '../../../livechat/server/lib/LivechatTyped'; import { settings } from '../../../settings/server'; +declare module '@rocket.chat/apps/dist/converters/IAppMessagesConverter' { + export interface IAppMessagesConverter { + convertMessage(message: IMessage, cacheObj?: object): Promise; + } +} + declare module '@rocket.chat/apps-engine/definition/accessors/ILivechatCreator' { interface IExtraRoomParams { customFields?: Record; @@ -337,7 +343,7 @@ export class AppLivechatBridge extends LivechatBridge { return Promise.all((await LivechatDepartment.findEnabledWithAgents().toArray()).map(boundConverter)); } - protected async _fetchLivechatRoomMessages(appId: string, roomId: string): Promise> { + protected async _fetchLivechatRoomMessages(appId: string, roomId: string): Promise> { this.orch.debugLog(`The App ${appId} is getting the transcript for livechat room ${roomId}.`); const messageConverter = this.orch.getConverters()?.get('messages'); @@ -346,8 +352,7 @@ export class AppLivechatBridge extends LivechatBridge { } const livechatMessages = await LivechatTyped.getRoomMessages({ rid: roomId }); - - return Promise.all(livechatMessages.map((message) => messageConverter.convertMessage(message) as Promise)); + return Promise.all(await livechatMessages.map((message) => messageConverter.convertMessage(message, livechatMessages)).toArray()); } protected async setCustomFields( diff --git a/apps/meteor/app/apps/server/bridges/messages.ts b/apps/meteor/app/apps/server/bridges/messages.ts index 18a68220998f..824a9d5c15af 100644 --- a/apps/meteor/app/apps/server/bridges/messages.ts +++ b/apps/meteor/app/apps/server/bridges/messages.ts @@ -1,4 +1,5 @@ import type { IAppServerOrchestrator, IAppsMessage, IAppsUser } from '@rocket.chat/apps'; +import type { Reaction } from '@rocket.chat/apps-engine/definition/messages'; import type { IRoom } from '@rocket.chat/apps-engine/definition/rooms'; import type { ITypingDescriptor } from '@rocket.chat/apps-engine/server/bridges/MessageBridge'; import { MessageBridge } from '@rocket.chat/apps-engine/server/bridges/MessageBridge'; @@ -10,6 +11,7 @@ import { deleteMessage } from '../../../lib/server/functions/deleteMessage'; import { updateMessage } from '../../../lib/server/functions/updateMessage'; import { executeSendMessage } from '../../../lib/server/methods/sendMessage'; import notifications from '../../../notifications/server/lib/Notifications'; +import { executeSetReaction } from '../../../reactions/server/setReaction'; export class AppMessageBridge extends MessageBridge { constructor(private readonly orch: IAppServerOrchestrator) { @@ -118,4 +120,24 @@ export class AppMessageBridge extends MessageBridge { throw new Error('Unrecognized typing scope provided'); } } + + private isValidReaction(reaction: Reaction): boolean { + return reaction.startsWith(':') && reaction.endsWith(':'); + } + + protected async addReaction(messageId: string, userId: string, reaction: Reaction): Promise { + if (!this.isValidReaction(reaction)) { + throw new Error('Invalid reaction'); + } + + return executeSetReaction(userId, reaction, messageId, true); + } + + protected async removeReaction(messageId: string, userId: string, reaction: Reaction): Promise { + if (!this.isValidReaction(reaction)) { + throw new Error('Invalid reaction'); + } + + return executeSetReaction(userId, reaction, messageId, false); + } } diff --git a/apps/meteor/app/apps/server/converters/messages.js b/apps/meteor/app/apps/server/converters/messages.js index d7dae512e9a8..89ef2454d895 100644 --- a/apps/meteor/app/apps/server/converters/messages.js +++ b/apps/meteor/app/apps/server/converters/messages.js @@ -52,19 +52,26 @@ export class AppMessagesConverter { return transformMappedData(message, map); } - async convertMessage(msgObj) { + async convertMessage(msgObj, cacheObj = msgObj) { if (!msgObj) { return undefined; } const cache = - this.mem.get(msgObj) ?? + this.mem.get(cacheObj) ?? new Map([ ['room', cachedFunction(this.orch.getConverters().get('rooms').convertById.bind(this.orch.getConverters().get('rooms')))], - ['user', cachedFunction(this.orch.getConverters().get('users').convertById.bind(this.orch.getConverters().get('users')))], + [ + 'user.convertById', + cachedFunction(this.orch.getConverters().get('users').convertById.bind(this.orch.getConverters().get('users'))), + ], + [ + 'user.convertToApp', + cachedFunction(this.orch.getConverters().get('users').convertToApp.bind(this.orch.getConverters().get('users'))), + ], ]); - this.mem.set(msgObj, cache); + this.mem.set(cacheObj, cache); const map = { id: '_id', @@ -96,7 +103,7 @@ export class AppMessagesConverter { return undefined; } - return cache.get('user')(editedBy._id); + return cache.get('user.convertById')(editedBy._id); }, attachments: async (message) => { const result = await this._convertAttachmentsToApp(message.attachments); @@ -110,8 +117,8 @@ export class AppMessagesConverter { // When the message contains token, means the message is from the visitor(omnichannel) const user = await (isMessageFromVisitor(msgObj) - ? this.orch.getConverters().get('users').convertToApp(message.u) - : cache.get('user')(message.u._id)); + ? cache.get('user.convertToApp')(message.u) + : cache.get('user.convertById')(message.u._id)); delete message.u; @@ -120,7 +127,7 @@ export class AppMessagesConverter { * `sender` as undefined, so we need to add this fallback here. */ - return user || this.orch.getConverters().get('users').convertToApp(message.u); + return user || cache.get('user.convertToApp')(message.u); }, }; diff --git a/apps/meteor/app/apps/server/converters/rooms.js b/apps/meteor/app/apps/server/converters/rooms.js index 670c1a248a0f..a98a6701b2c2 100644 --- a/apps/meteor/app/apps/server/converters/rooms.js +++ b/apps/meteor/app/apps/server/converters/rooms.js @@ -111,8 +111,8 @@ export class AppRoomsConverter { return Object.assign(newRoom, room._unmappedProperties_); } - async convertRoom(room) { - if (!room) { + async convertRoom(originalRoom) { + if (!originalRoom) { return undefined; } @@ -134,6 +134,7 @@ export class AppRoomsConverter { _USERNAMES: '_USERNAMES', description: 'description', source: 'source', + closer: 'closer', isDefault: (room) => { const result = !!room.default; delete room.default; @@ -210,6 +211,19 @@ export class AppRoomsConverter { return this.orch.getConverters().get('departments').convertById(departmentId); }, + closedBy: async (room) => { + const { closedBy } = room; + + if (!closedBy) { + return undefined; + } + + delete room.closedBy; + if (originalRoom.closer === 'user') { + return this.orch.getConverters().get('users').convertById(closedBy._id); + } + return this.orch.getConverters().get('visitors').convertById(closedBy._id); + }, servedBy: async (room) => { const { servedBy } = room; @@ -245,7 +259,7 @@ export class AppRoomsConverter { }, }; - return transformMappedData(room, map); + return transformMappedData(originalRoom, map); } _convertTypeToApp(typeChar) { diff --git a/apps/meteor/app/authorization/server/constant/permissions.ts b/apps/meteor/app/authorization/server/constant/permissions.ts index 6efe99e14d0e..d9ae4133e49e 100644 --- a/apps/meteor/app/authorization/server/constant/permissions.ts +++ b/apps/meteor/app/authorization/server/constant/permissions.ts @@ -93,6 +93,10 @@ export const permissions = [ _id: 'view-l-room', roles: ['livechat-manager', 'livechat-monitor', 'livechat-agent', 'admin'], }, + { + _id: 'create-livechat-contact', + roles: ['livechat-manager', 'livechat-monitor', 'livechat-agent', 'admin'], + }, { _id: 'view-livechat-manager', roles: ['livechat-manager', 'livechat-monitor', 'admin'] }, { _id: 'view-omnichannel-contact-center', diff --git a/apps/meteor/app/autotranslate/server/autotranslate.ts b/apps/meteor/app/autotranslate/server/autotranslate.ts index 1e6c224c4115..f3c6d9e55fdb 100644 --- a/apps/meteor/app/autotranslate/server/autotranslate.ts +++ b/apps/meteor/app/autotranslate/server/autotranslate.ts @@ -79,7 +79,7 @@ export class TranslationProviderRegistry { return null; } - return provider.translateMessage(message, room, targetLanguage); + return provider.translateMessage(message, { room, targetLanguage }); } static getProviders(): AutoTranslate[] { @@ -290,7 +290,7 @@ export abstract class AutoTranslate { * @param {object} targetLanguage * @returns {object} unmodified message object. */ - async translateMessage(message: IMessage, room: IRoom, targetLanguage?: string): Promise { + async translateMessage(message: IMessage, { room, targetLanguage }: { room: IRoom; targetLanguage?: string }): Promise { let targetLanguages: string[]; if (targetLanguage) { targetLanguages = [targetLanguage]; diff --git a/apps/meteor/app/autotranslate/server/methods/saveSettings.ts b/apps/meteor/app/autotranslate/server/methods/saveSettings.ts index 2f119c948263..3d4d15c0316e 100644 --- a/apps/meteor/app/autotranslate/server/methods/saveSettings.ts +++ b/apps/meteor/app/autotranslate/server/methods/saveSettings.ts @@ -4,6 +4,7 @@ import { check } from 'meteor/check'; import { Meteor } from 'meteor/meteor'; import { hasPermissionAsync } from '../../../authorization/server/functions/hasPermission'; +import { notifyOnSubscriptionChangedById } from '../../../lib/server/lib/notifyListener'; declare module '@rocket.chat/ddp-client' { // eslint-disable-next-line @typescript-eslint/naming-convention @@ -44,6 +45,8 @@ Meteor.methods({ }); } + let shouldNotifySubscriptionChanged = false; + switch (field) { case 'autoTranslate': const room = await Rooms.findE2ERoomById(rid, { projection: { _id: 1 } }); @@ -53,16 +56,34 @@ Meteor.methods({ }); } - await Subscriptions.updateAutoTranslateById(subscription._id, value === '1'); + const updateAutoTranslateResponse = await Subscriptions.updateAutoTranslateById(subscription._id, value === '1'); + if (updateAutoTranslateResponse.modifiedCount) { + shouldNotifySubscriptionChanged = true; + } + if (!subscription.autoTranslateLanguage && options.defaultLanguage) { - await Subscriptions.updateAutoTranslateLanguageById(subscription._id, options.defaultLanguage); + const updateAutoTranslateLanguageResponse = await Subscriptions.updateAutoTranslateLanguageById( + subscription._id, + options.defaultLanguage, + ); + if (updateAutoTranslateLanguageResponse.modifiedCount) { + shouldNotifySubscriptionChanged = true; + } } + break; case 'autoTranslateLanguage': - await Subscriptions.updateAutoTranslateLanguageById(subscription._id, value); + const updateAutoTranslateLanguage = await Subscriptions.updateAutoTranslateLanguageById(subscription._id, value); + if (updateAutoTranslateLanguage.modifiedCount) { + shouldNotifySubscriptionChanged = true; + } break; } + if (shouldNotifySubscriptionChanged) { + void notifyOnSubscriptionChangedById(subscription._id); + } + return true; }, }); diff --git a/apps/meteor/app/channel-settings/server/functions/saveRoomCustomFields.ts b/apps/meteor/app/channel-settings/server/functions/saveRoomCustomFields.ts index 55d40cf3d7e6..ef70ff65c067 100644 --- a/apps/meteor/app/channel-settings/server/functions/saveRoomCustomFields.ts +++ b/apps/meteor/app/channel-settings/server/functions/saveRoomCustomFields.ts @@ -3,21 +3,28 @@ import { Match } from 'meteor/check'; import { Meteor } from 'meteor/meteor'; import type { UpdateResult } from 'mongodb'; +import { notifyOnSubscriptionChangedByRoomId } from '../../../lib/server/lib/notifyListener'; + export const saveRoomCustomFields = async function (rid: string, roomCustomFields: Record): Promise { if (!Match.test(rid, String)) { throw new Meteor.Error('invalid-room', 'Invalid room', { function: 'RocketChat.saveRoomCustomFields', }); } + if (!Match.test(roomCustomFields, Object)) { throw new Meteor.Error('invalid-roomCustomFields-type', 'Invalid roomCustomFields type', { function: 'RocketChat.saveRoomCustomFields', }); } + const ret = await Rooms.setCustomFieldsById(rid, roomCustomFields); // Update customFields of any user's Subscription related with this rid - await Subscriptions.updateCustomFieldsByRoomId(rid, roomCustomFields); + const { modifiedCount } = await Subscriptions.updateCustomFieldsByRoomId(rid, roomCustomFields); + if (modifiedCount) { + void notifyOnSubscriptionChangedByRoomId(rid); + } return ret; }; diff --git a/apps/meteor/app/channel-settings/server/functions/saveRoomEncrypted.ts b/apps/meteor/app/channel-settings/server/functions/saveRoomEncrypted.ts index ed07540ba2b0..c1a441463a98 100644 --- a/apps/meteor/app/channel-settings/server/functions/saveRoomEncrypted.ts +++ b/apps/meteor/app/channel-settings/server/functions/saveRoomEncrypted.ts @@ -6,6 +6,8 @@ import { Match } from 'meteor/check'; import { Meteor } from 'meteor/meteor'; import type { UpdateResult } from 'mongodb'; +import { notifyOnSubscriptionChangedByRoomId } from '../../../lib/server/lib/notifyListener'; + export const saveRoomEncrypted = async function (rid: string, encrypted: boolean, user: IUser, sendMessage = true): Promise { if (!Match.test(rid, String)) { throw new Meteor.Error('invalid-room', 'Invalid room', { @@ -27,7 +29,10 @@ export const saveRoomEncrypted = async function (rid: string, encrypted: boolean } if (encrypted) { - await Subscriptions.disableAutoTranslateByRoomId(rid); + const { modifiedCount } = await Subscriptions.disableAutoTranslateByRoomId(rid); + if (modifiedCount) { + void notifyOnSubscriptionChangedByRoomId(rid); + } } return update; }; diff --git a/apps/meteor/app/channel-settings/server/functions/saveRoomName.ts b/apps/meteor/app/channel-settings/server/functions/saveRoomName.ts index 0fc15f878bcf..f4a5afbb6380 100644 --- a/apps/meteor/app/channel-settings/server/functions/saveRoomName.ts +++ b/apps/meteor/app/channel-settings/server/functions/saveRoomName.ts @@ -1,4 +1,4 @@ -import { Message } from '@rocket.chat/core-services'; +import { Message, Room } from '@rocket.chat/core-services'; import type { IUser } from '@rocket.chat/core-typings'; import { isRoomFederated } from '@rocket.chat/core-typings'; import { Integrations, Rooms, Subscriptions } from '@rocket.chat/models'; @@ -8,11 +8,17 @@ import type { Document, UpdateResult } from 'mongodb'; import { callbacks } from '../../../../lib/callbacks'; import { roomCoordinator } from '../../../../server/lib/rooms/roomCoordinator'; import { checkUsernameAvailability } from '../../../lib/server/functions/checkUsernameAvailability'; -import { notifyOnIntegrationChangedByChannels } from '../../../lib/server/lib/notifyListener'; +import { notifyOnIntegrationChangedByChannels, notifyOnSubscriptionChangedByRoomId } from '../../../lib/server/lib/notifyListener'; import { getValidRoomName } from '../../../utils/server/lib/getValidRoomName'; const updateFName = async (rid: string, displayName: string): Promise<(UpdateResult | Document)[]> => { - return Promise.all([Rooms.setFnameById(rid, displayName), Subscriptions.updateFnameByRoomId(rid, displayName)]); + const responses = await Promise.all([Rooms.setFnameById(rid, displayName), Subscriptions.updateFnameByRoomId(rid, displayName)]); + + if (responses[1]?.modifiedCount) { + void notifyOnSubscriptionChangedByRoomId(rid); + } + + return responses; }; const updateRoomName = async (rid: string, displayName: string, slugifiedRoomName: string) => { @@ -24,10 +30,16 @@ const updateRoomName = async (rid: string, displayName: string, slugifiedRoomNam }); } - return Promise.all([ + const responses = await Promise.all([ Rooms.setNameById(rid, slugifiedRoomName, displayName), Subscriptions.updateNameAndAlertByRoomId(rid, slugifiedRoomName, displayName), ]); + + if (responses[1]?.modifiedCount) { + void notifyOnSubscriptionChangedByRoomId(rid); + } + + return responses; }; export async function saveRoomName( @@ -48,6 +60,9 @@ export async function saveRoomName( function: 'RocketChat.saveRoomdisplayName', }); } + + await Room.beforeNameChange(room); + if (displayName === room.name) { return; } diff --git a/apps/meteor/app/channel-settings/server/functions/saveRoomTopic.ts b/apps/meteor/app/channel-settings/server/functions/saveRoomTopic.ts index 11b9b5b6e565..a59f2ba82fba 100644 --- a/apps/meteor/app/channel-settings/server/functions/saveRoomTopic.ts +++ b/apps/meteor/app/channel-settings/server/functions/saveRoomTopic.ts @@ -1,4 +1,4 @@ -import { Message } from '@rocket.chat/core-services'; +import { Message, Room } from '@rocket.chat/core-services'; import { Rooms } from '@rocket.chat/models'; import { Match } from 'meteor/check'; import { Meteor } from 'meteor/meteor'; @@ -20,6 +20,10 @@ export const saveRoomTopic = async function ( }); } + const room = await Rooms.findOneById(rid); + + await Room.beforeTopicChange(room!); + const update = await Rooms.setTopicById(rid, roomTopic); if (update && sendMessage) { await Message.saveSystemMessage('room_changed_topic', rid, roomTopic || '', user); diff --git a/apps/meteor/app/channel-settings/server/functions/saveRoomType.ts b/apps/meteor/app/channel-settings/server/functions/saveRoomType.ts index e8a60d1ea0eb..4600d1d46a80 100644 --- a/apps/meteor/app/channel-settings/server/functions/saveRoomType.ts +++ b/apps/meteor/app/channel-settings/server/functions/saveRoomType.ts @@ -8,6 +8,7 @@ import type { UpdateResult, Document } from 'mongodb'; import { RoomSettingsEnum } from '../../../../definition/IRoomTypeConfig'; import { i18n } from '../../../../server/lib/i18n'; import { roomCoordinator } from '../../../../server/lib/rooms/roomCoordinator'; +import { notifyOnSubscriptionChangedByRoomId } from '../../../lib/server/lib/notifyListener'; import { settings } from '../../../settings/server'; export const saveRoomType = async function ( @@ -41,11 +42,16 @@ export const saveRoomType = async function ( }); } - const result = (await Rooms.setTypeById(rid, roomType)) && (await Subscriptions.updateTypeByRoomId(rid, roomType)); + const result = await Promise.all([Rooms.setTypeById(rid, roomType), Subscriptions.updateTypeByRoomId(rid, roomType)]); + if (!result) { return result; } + if (result[1]?.modifiedCount) { + void notifyOnSubscriptionChangedByRoomId(rid); + } + if (sendMessage) { let message; if (roomType === 'c') { @@ -59,5 +65,6 @@ export const saveRoomType = async function ( } await Message.saveSystemMessage('room_changed_privacy', rid, message, user); } + return result; }; diff --git a/apps/meteor/app/channel-settings/server/functions/saveStreamingOptions.ts b/apps/meteor/app/channel-settings/server/functions/saveStreamingOptions.ts deleted file mode 100644 index aee596402cc6..000000000000 --- a/apps/meteor/app/channel-settings/server/functions/saveStreamingOptions.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { Rooms } from '@rocket.chat/models'; -import { Match, check } from 'meteor/check'; -import { Meteor } from 'meteor/meteor'; - -export const saveStreamingOptions = async function (rid: string, options: Record): Promise { - if (!Match.test(rid, String)) { - throw new Meteor.Error('invalid-room', 'Invalid room', { - function: 'RocketChat.saveStreamingOptions', - }); - } - - check(options, { - id: Match.Optional(String), - type: Match.Optional(String), - url: Match.Optional(String), - thumbnail: Match.Optional(String), - isAudioOnly: Match.Optional(Boolean), - message: Match.Optional(String), - }); - - await Rooms.setStreamingOptionsById(rid, options); -}; diff --git a/apps/meteor/app/channel-settings/server/methods/saveRoomSettings.ts b/apps/meteor/app/channel-settings/server/methods/saveRoomSettings.ts index 44ad253d83ef..04e8fdbaf186 100644 --- a/apps/meteor/app/channel-settings/server/methods/saveRoomSettings.ts +++ b/apps/meteor/app/channel-settings/server/methods/saveRoomSettings.ts @@ -21,7 +21,6 @@ import { saveRoomReadOnly } from '../functions/saveRoomReadOnly'; import { saveRoomSystemMessages } from '../functions/saveRoomSystemMessages'; import { saveRoomTopic } from '../functions/saveRoomTopic'; import { saveRoomType } from '../functions/saveRoomType'; -import { saveStreamingOptions } from '../functions/saveStreamingOptions'; type RoomSettings = { roomAvatar: string; @@ -37,7 +36,6 @@ type RoomSettings = { systemMessages: MessageTypesValues[]; default: boolean; joinCode: string; - streamingOptions: NonNullable; retentionEnabled: boolean; retentionMaxAge: number; retentionExcludePinned: boolean; @@ -272,9 +270,6 @@ const settingSavers: RoomSettingsSavers = { void Team.update(user._id, room.teamId, { type, updateRoom: false }); } }, - async streamingOptions({ value, rid }) { - await saveStreamingOptions(rid, value); - }, async readOnly({ value, room, rid, user }) { if (value !== room.ro) { await saveRoomReadOnly(rid, value, user); @@ -354,7 +349,6 @@ const fields: (keyof RoomSettings)[] = [ 'systemMessages', 'default', 'joinCode', - 'streamingOptions', 'retentionEnabled', 'retentionMaxAge', 'retentionExcludePinned', diff --git a/apps/meteor/app/discussion/server/hooks/propagateDiscussionMetadata.ts b/apps/meteor/app/discussion/server/hooks/propagateDiscussionMetadata.ts index 05cf2326156f..1ff9ed1dc1ba 100644 --- a/apps/meteor/app/discussion/server/hooks/propagateDiscussionMetadata.ts +++ b/apps/meteor/app/discussion/server/hooks/propagateDiscussionMetadata.ts @@ -22,7 +22,7 @@ const updateAndNotifyParentRoomWithParentMessage = async (room: IRoom): Promise< */ callbacks.add( 'afterSaveMessage', - async (message, { _id, prid }) => { + async (message, { room: { _id, prid } }) => { if (!prid) { return message; } diff --git a/apps/meteor/app/discussion/server/methods/createDiscussion.ts b/apps/meteor/app/discussion/server/methods/createDiscussion.ts index 6e670d723ec9..96e0bd846390 100644 --- a/apps/meteor/app/discussion/server/methods/createDiscussion.ts +++ b/apps/meteor/app/discussion/server/methods/createDiscussion.ts @@ -5,7 +5,6 @@ import { Messages, Rooms, Users } from '@rocket.chat/models'; import { Random } from '@rocket.chat/random'; import { Meteor } from 'meteor/meteor'; -import { callbacks } from '../../../../lib/callbacks'; import { i18n } from '../../../../server/lib/i18n'; import { roomCoordinator } from '../../../../server/lib/rooms/roomCoordinator'; import { canSendMessageAsync } from '../../../authorization/server/functions/canSendMessage'; @@ -14,6 +13,7 @@ import { addUserToRoom } from '../../../lib/server/functions/addUserToRoom'; import { attachMessage } from '../../../lib/server/functions/attachMessage'; import { createRoom } from '../../../lib/server/functions/createRoom'; import { sendMessage } from '../../../lib/server/functions/sendMessage'; +import { afterSaveMessageAsync } from '../../../lib/server/lib/afterSaveMessage'; import { settings } from '../../../settings/server'; const getParentRoom = async (rid: IRoom['_id']) => { @@ -27,13 +27,11 @@ async function createDiscussionMessage( drid: IRoom['_id'], msg: IMessage['msg'], messageEmbedded?: MessageAttachmentDefault, -): Promise { - const msgId = await Message.saveSystemMessage('discussion-created', rid, msg, user, { +): Promise { + return Message.saveSystemMessage('discussion-created', rid, msg, user, { drid, ...(messageEmbedded && { attachments: [messageEmbedded] }), }); - - return Messages.findOneById(msgId); } async function mentionMessage( @@ -191,8 +189,9 @@ const create = async ({ } if (discussionMsg) { - callbacks.runAsync('afterSaveMessage', discussionMsg, parentRoom); + afterSaveMessageAsync(discussionMsg, parentRoom); } + return discussion; }; diff --git a/apps/meteor/app/e2e/server/functions/handleSuggestedGroupKey.ts b/apps/meteor/app/e2e/server/functions/handleSuggestedGroupKey.ts index 860051c04d4d..22eccf03f407 100644 --- a/apps/meteor/app/e2e/server/functions/handleSuggestedGroupKey.ts +++ b/apps/meteor/app/e2e/server/functions/handleSuggestedGroupKey.ts @@ -1,6 +1,8 @@ import { Rooms, Subscriptions } from '@rocket.chat/models'; import { Meteor } from 'meteor/meteor'; +import { notifyOnSubscriptionChangedById } from '../../../lib/server/lib/notifyListener'; + export async function handleSuggestedGroupKey( handle: 'accept' | 'reject', rid: string, @@ -30,5 +32,8 @@ export async function handleSuggestedGroupKey( await Rooms.addUserIdToE2EEQueueByRoomIds([sub.rid], userId); } - await Subscriptions.unsetGroupE2ESuggestedKey(sub._id); + const { modifiedCount } = await Subscriptions.unsetGroupE2ESuggestedKey(sub._id); + if (modifiedCount) { + void notifyOnSubscriptionChangedById(sub._id); + } } diff --git a/apps/meteor/app/e2e/server/methods/updateGroupKey.ts b/apps/meteor/app/e2e/server/methods/updateGroupKey.ts index 5764a021f54c..87182f723e7d 100644 --- a/apps/meteor/app/e2e/server/methods/updateGroupKey.ts +++ b/apps/meteor/app/e2e/server/methods/updateGroupKey.ts @@ -3,6 +3,7 @@ import { Subscriptions } from '@rocket.chat/models'; import { Meteor } from 'meteor/meteor'; import { methodDeprecationLogger } from '../../../lib/server/lib/deprecationWarningLogger'; +import { notifyOnSubscriptionChangedById, notifyOnSubscriptionChangedByRoomIdAndUserId } from '../../../lib/server/lib/notifyListener'; declare module '@rocket.chat/ddp-client' { // eslint-disable-next-line @typescript-eslint/naming-convention @@ -25,12 +26,18 @@ Meteor.methods({ if (mySub) { // Setting the key to myself, can set directly to the final field if (userId === uid) { - await Subscriptions.setGroupE2EKey(mySub._id, key); + const setGroupE2EKeyResponse = await Subscriptions.setGroupE2EKey(mySub._id, key); + if (setGroupE2EKeyResponse.modifiedCount) { + void notifyOnSubscriptionChangedById(mySub._id); + } return; } // uid also has subscription to this room - await Subscriptions.setGroupE2ESuggestedKey(uid, rid, key); + const { modifiedCount } = await Subscriptions.setGroupE2ESuggestedKey(uid, rid, key); + if (modifiedCount) { + void notifyOnSubscriptionChangedByRoomIdAndUserId(rid, uid); + } } }, }); diff --git a/apps/meteor/app/emoji-custom/server/lib/insertOrUpdateEmoji.ts b/apps/meteor/app/emoji-custom/server/lib/insertOrUpdateEmoji.ts new file mode 100644 index 000000000000..7e838baee9b0 --- /dev/null +++ b/apps/meteor/app/emoji-custom/server/lib/insertOrUpdateEmoji.ts @@ -0,0 +1,148 @@ +import { api } from '@rocket.chat/core-services'; +import { EmojiCustom } from '@rocket.chat/models'; +import limax from 'limax'; +import { Meteor } from 'meteor/meteor'; +import _ from 'underscore'; + +import { trim } from '../../../../lib/utils/stringUtils'; +import { hasPermissionAsync } from '../../../authorization/server/functions/hasPermission'; +import { RocketChatFileEmojiCustomInstance } from '../startup/emoji-custom'; + +type EmojiData = { + _id?: string; + name: string; + aliases: string; + extension: string; + previousName?: string; + previousExtension?: string; + newFile?: boolean; +}; + +type EmojiDataWithParsedAliases = Omit & { _id: string; aliases: string[] }; + +export async function insertOrUpdateEmoji(userId: string | null, emojiData: EmojiData): Promise { + if (!userId || !(await hasPermissionAsync(userId, 'manage-emoji'))) { + throw new Meteor.Error('not_authorized'); + } + + if (!trim(emojiData.name)) { + throw new Meteor.Error('error-the-field-is-required', 'The field Name is required', { + method: 'insertOrUpdateEmoji', + field: 'Name', + }); + } + + emojiData.name = limax(emojiData.name, { replacement: '_' }); + emojiData.aliases = limax(emojiData.aliases, { replacement: '_' }); + + // allow all characters except colon, whitespace, comma, >, <, &, ", ', /, \, (, ) + // more practical than allowing specific sets of characters; also allows foreign languages + const nameValidation = /[\s,:><&"'\/\\\(\)]/; + const aliasValidation = /[:><&\|"'\/\\\(\)]/; + + // silently strip colon; this allows for uploading :emojiname: as emojiname + emojiData.name = emojiData.name.replace(/:/g, ''); + emojiData.aliases = emojiData.aliases.replace(/:/g, ''); + + if (nameValidation.test(emojiData.name)) { + throw new Meteor.Error('error-input-is-not-a-valid-field', `${emojiData.name} is not a valid name`, { + method: 'insertOrUpdateEmoji', + input: emojiData.name, + field: 'Name', + }); + } + + let aliases: string[] = []; + if (emojiData.aliases) { + if (aliasValidation.test(emojiData.aliases)) { + throw new Meteor.Error('error-input-is-not-a-valid-field', `${emojiData.aliases} is not a valid alias set`, { + method: 'insertOrUpdateEmoji', + input: emojiData.aliases, + field: 'Alias_Set', + }); + } + aliases = _.without(emojiData.aliases.split(/[\s,]/).filter(Boolean), emojiData.name); + } + + emojiData.extension = emojiData.extension === 'svg+xml' ? 'png' : emojiData.extension; + + let matchingResults = []; + + if (emojiData._id) { + matchingResults = await EmojiCustom.findByNameOrAliasExceptID(emojiData.name, emojiData._id).toArray(); + for await (const alias of aliases) { + matchingResults = matchingResults.concat(await EmojiCustom.findByNameOrAliasExceptID(alias, emojiData._id).toArray()); + } + } else { + matchingResults = await EmojiCustom.findByNameOrAlias(emojiData.name).toArray(); + for await (const alias of aliases) { + matchingResults = matchingResults.concat(await EmojiCustom.findByNameOrAlias(alias).toArray()); + } + } + + if (matchingResults.length > 0) { + throw new Meteor.Error('Custom_Emoji_Error_Name_Or_Alias_Already_In_Use', 'The custom emoji or one of its aliases is already in use', { + method: 'insertOrUpdateEmoji', + }); + } + + if (typeof emojiData.extension === 'undefined') { + throw new Meteor.Error('error-the-field-is-required', 'The custom emoji file is required', { + method: 'insertOrUpdateEmoji', + }); + } + + if (!emojiData._id) { + // insert emoji + const createEmoji = { + name: emojiData.name, + aliases, + extension: emojiData.extension, + }; + + const _id = (await EmojiCustom.create(createEmoji)).insertedId; + + void api.broadcast('emoji.updateCustom', createEmoji); + + return { ...emojiData, ...createEmoji, _id }; + } + + // update emoji + if (emojiData.newFile) { + await RocketChatFileEmojiCustomInstance.deleteFile(encodeURIComponent(`${emojiData.name}.${emojiData.extension}`)); + await RocketChatFileEmojiCustomInstance.deleteFile(encodeURIComponent(`${emojiData.name}.${emojiData.previousExtension}`)); + await RocketChatFileEmojiCustomInstance.deleteFile(encodeURIComponent(`${emojiData.previousName}.${emojiData.extension}`)); + await RocketChatFileEmojiCustomInstance.deleteFile(encodeURIComponent(`${emojiData.previousName}.${emojiData.previousExtension}`)); + + await EmojiCustom.setExtension(emojiData._id, emojiData.extension); + } else if (emojiData.name !== emojiData.previousName) { + const rs = await RocketChatFileEmojiCustomInstance.getFileWithReadStream( + encodeURIComponent(`${emojiData.previousName}.${emojiData.previousExtension}`), + ); + if (rs !== null) { + await RocketChatFileEmojiCustomInstance.deleteFile(encodeURIComponent(`${emojiData.name}.${emojiData.extension}`)); + const ws = RocketChatFileEmojiCustomInstance.createWriteStream( + encodeURIComponent(`${emojiData.name}.${emojiData.previousExtension}`), + rs.contentType, + ); + ws.on('end', () => + RocketChatFileEmojiCustomInstance.deleteFile(encodeURIComponent(`${emojiData.previousName}.${emojiData.previousExtension}`)), + ); + rs.readStream.pipe(ws); + } + } + + if (emojiData.name !== emojiData.previousName) { + await EmojiCustom.setName(emojiData._id, emojiData.name); + } + + if (emojiData.aliases) { + await EmojiCustom.setAliases(emojiData._id, aliases); + } else { + await EmojiCustom.setAliases(emojiData._id, []); + } + + void api.broadcast('emoji.updateCustom', { ...emojiData, aliases }); + + return { ...emojiData, aliases } as EmojiDataWithParsedAliases; +} diff --git a/apps/meteor/app/emoji-custom/server/lib/uploadEmojiCustom.ts b/apps/meteor/app/emoji-custom/server/lib/uploadEmojiCustom.ts new file mode 100644 index 000000000000..07633eaa1a7d --- /dev/null +++ b/apps/meteor/app/emoji-custom/server/lib/uploadEmojiCustom.ts @@ -0,0 +1,77 @@ +import { api, Media } from '@rocket.chat/core-services'; +import limax from 'limax'; +import { Meteor } from 'meteor/meteor'; +import sharp from 'sharp'; + +import { hasPermissionAsync } from '../../../authorization/server/functions/hasPermission'; +import { RocketChatFile } from '../../../file/server'; +import { RocketChatFileEmojiCustomInstance } from '../startup/emoji-custom'; + +const getFile = async (file: Buffer, extension: string) => { + if (extension !== 'svg+xml') { + return file; + } + + return sharp(file).png().toBuffer(); +}; + +type EmojiData = { + _id?: string; + name: string; + aliases?: string | string[]; + extension: string; + previousName?: string; + previousExtension?: string; + newFile?: boolean; +}; + +export async function uploadEmojiCustom(userId: string | null, binaryContent: string, contentType: string, emojiData: EmojiData) { + return uploadEmojiCustomWithBuffer(userId, Buffer.from(binaryContent, 'binary'), contentType, emojiData); +} + +export async function uploadEmojiCustomWithBuffer( + userId: string | null, + buffer: Buffer, + contentType: string, + emojiData: EmojiData, +): Promise { + // technically, since this method doesnt have any datatype validations, users can + // upload videos as emojis. The FE won't play them, but they will waste space for sure. + if (!userId || !(await hasPermissionAsync(userId, 'manage-emoji'))) { + throw new Meteor.Error('not_authorized'); + } + + emojiData.name = limax(emojiData.name, { replacement: '_' }); + // delete aliases for notification purposes. here, it is a string rather than an array + delete emojiData.aliases; + + const file = await getFile(buffer, emojiData.extension); + emojiData.extension = emojiData.extension === 'svg+xml' ? 'png' : emojiData.extension; + + let fileBuffer; + // sharp doesn't support these formats without imagemagick or libvips installed + // so they will be stored as they are :( + if (['gif', 'x-icon', 'bmp', 'webm'].includes(emojiData.extension)) { + fileBuffer = file; + } else { + // This is to support the idea of having "sticker-like" emojis + const { data: resizedEmojiBuffer } = await Media.resizeFromBuffer(file, 512, 512, true, false, false, 'inside'); + fileBuffer = resizedEmojiBuffer; + } + + const rs = RocketChatFile.bufferToStream(fileBuffer); + await RocketChatFileEmojiCustomInstance.deleteFile(encodeURIComponent(`${emojiData.name}.${emojiData.extension}`)); + + return new Promise((resolve) => { + const ws = RocketChatFileEmojiCustomInstance.createWriteStream( + encodeURIComponent(`${emojiData.name}.${emojiData.extension}`), + contentType, + ); + ws.on('end', () => { + setTimeout(() => api.broadcast('emoji.updateCustom', emojiData), 500); + resolve(); + }); + + rs.pipe(ws); + }); +} diff --git a/apps/meteor/app/emoji-custom/server/methods/insertOrUpdateEmoji.ts b/apps/meteor/app/emoji-custom/server/methods/insertOrUpdateEmoji.ts index 5d4a6742314b..1891d1b3ed95 100644 --- a/apps/meteor/app/emoji-custom/server/methods/insertOrUpdateEmoji.ts +++ b/apps/meteor/app/emoji-custom/server/methods/insertOrUpdateEmoji.ts @@ -1,13 +1,7 @@ -import { api } from '@rocket.chat/core-services'; import type { ServerMethods } from '@rocket.chat/ddp-client'; -import { EmojiCustom } from '@rocket.chat/models'; -import limax from 'limax'; import { Meteor } from 'meteor/meteor'; -import _ from 'underscore'; -import { trim } from '../../../../lib/utils/stringUtils'; -import { hasPermissionAsync } from '../../../authorization/server/functions/hasPermission'; -import { RocketChatFileEmojiCustomInstance } from '../startup/emoji-custom'; +import { insertOrUpdateEmoji } from '../lib/insertOrUpdateEmoji'; declare module '@rocket.chat/ddp-client' { // eslint-disable-next-line @typescript-eslint/naming-convention @@ -26,130 +20,12 @@ declare module '@rocket.chat/ddp-client' { Meteor.methods({ async insertOrUpdateEmoji(emojiData) { - if (!this.userId || !(await hasPermissionAsync(this.userId, 'manage-emoji'))) { - throw new Meteor.Error('not_authorized'); - } - - if (!trim(emojiData.name)) { - throw new Meteor.Error('error-the-field-is-required', 'The field Name is required', { - method: 'insertOrUpdateEmoji', - field: 'Name', - }); - } - - emojiData.name = limax(emojiData.name, { replacement: '_' }); - emojiData.aliases = limax(emojiData.aliases, { replacement: '_' }); - - // allow all characters except colon, whitespace, comma, >, <, &, ", ', /, \, (, ) - // more practical than allowing specific sets of characters; also allows foreign languages - const nameValidation = /[\s,:><&"'\/\\\(\)]/; - const aliasValidation = /[:><&\|"'\/\\\(\)]/; - - // silently strip colon; this allows for uploading :emojiname: as emojiname - emojiData.name = emojiData.name.replace(/:/g, ''); - emojiData.aliases = emojiData.aliases.replace(/:/g, ''); - - if (nameValidation.test(emojiData.name)) { - throw new Meteor.Error('error-input-is-not-a-valid-field', `${emojiData.name} is not a valid name`, { - method: 'insertOrUpdateEmoji', - input: emojiData.name, - field: 'Name', - }); - } - - let aliases: string[] = []; - if (emojiData.aliases) { - if (aliasValidation.test(emojiData.aliases)) { - throw new Meteor.Error('error-input-is-not-a-valid-field', `${emojiData.aliases} is not a valid alias set`, { - method: 'insertOrUpdateEmoji', - input: emojiData.aliases, - field: 'Alias_Set', - }); - } - aliases = _.without(emojiData.aliases.split(/[\s,]/).filter(Boolean), emojiData.name); - } - - emojiData.extension = emojiData.extension === 'svg+xml' ? 'png' : emojiData.extension; - - let matchingResults = []; - - if (emojiData._id) { - matchingResults = await EmojiCustom.findByNameOrAliasExceptID(emojiData.name, emojiData._id).toArray(); - for await (const alias of aliases) { - matchingResults = matchingResults.concat(await EmojiCustom.findByNameOrAliasExceptID(alias, emojiData._id).toArray()); - } - } else { - matchingResults = await EmojiCustom.findByNameOrAlias(emojiData.name).toArray(); - for await (const alias of aliases) { - matchingResults = matchingResults.concat(await EmojiCustom.findByNameOrAlias(alias).toArray()); - } - } - - if (matchingResults.length > 0) { - throw new Meteor.Error( - 'Custom_Emoji_Error_Name_Or_Alias_Already_In_Use', - 'The custom emoji or one of its aliases is already in use', - { method: 'insertOrUpdateEmoji' }, - ); - } - - if (typeof emojiData.extension === 'undefined') { - throw new Meteor.Error('error-the-field-is-required', 'The custom emoji file is required', { - method: 'insertOrUpdateEmoji', - }); - } + const emoji = await insertOrUpdateEmoji(this.userId, emojiData); if (!emojiData._id) { - // insert emoji - const createEmoji = { - name: emojiData.name, - aliases, - extension: emojiData.extension, - }; - - const _id = (await EmojiCustom.create(createEmoji)).insertedId; - - void api.broadcast('emoji.updateCustom', createEmoji); - - return _id; + return emoji._id; } - // update emoji - if (emojiData.newFile) { - await RocketChatFileEmojiCustomInstance.deleteFile(encodeURIComponent(`${emojiData.name}.${emojiData.extension}`)); - await RocketChatFileEmojiCustomInstance.deleteFile(encodeURIComponent(`${emojiData.name}.${emojiData.previousExtension}`)); - await RocketChatFileEmojiCustomInstance.deleteFile(encodeURIComponent(`${emojiData.previousName}.${emojiData.extension}`)); - await RocketChatFileEmojiCustomInstance.deleteFile(encodeURIComponent(`${emojiData.previousName}.${emojiData.previousExtension}`)); - - await EmojiCustom.setExtension(emojiData._id, emojiData.extension); - } else if (emojiData.name !== emojiData.previousName) { - const rs = await RocketChatFileEmojiCustomInstance.getFileWithReadStream( - encodeURIComponent(`${emojiData.previousName}.${emojiData.previousExtension}`), - ); - if (rs !== null) { - await RocketChatFileEmojiCustomInstance.deleteFile(encodeURIComponent(`${emojiData.name}.${emojiData.extension}`)); - const ws = RocketChatFileEmojiCustomInstance.createWriteStream( - encodeURIComponent(`${emojiData.name}.${emojiData.previousExtension}`), - rs.contentType, - ); - ws.on('end', () => - RocketChatFileEmojiCustomInstance.deleteFile(encodeURIComponent(`${emojiData.previousName}.${emojiData.previousExtension}`)), - ); - rs.readStream.pipe(ws); - } - } - - if (emojiData.name !== emojiData.previousName) { - await EmojiCustom.setName(emojiData._id, emojiData.name); - } - - if (emojiData.aliases) { - await EmojiCustom.setAliases(emojiData._id, aliases); - } else { - await EmojiCustom.setAliases(emojiData._id, []); - } - - void api.broadcast('emoji.updateCustom', emojiData); - return true; + return !!emoji; }, }); diff --git a/apps/meteor/app/emoji-custom/server/methods/uploadEmojiCustom.ts b/apps/meteor/app/emoji-custom/server/methods/uploadEmojiCustom.ts index a46f457cd70f..e387888b1311 100644 --- a/apps/meteor/app/emoji-custom/server/methods/uploadEmojiCustom.ts +++ b/apps/meteor/app/emoji-custom/server/methods/uploadEmojiCustom.ts @@ -1,20 +1,7 @@ -import { api, Media } from '@rocket.chat/core-services'; import type { ServerMethods } from '@rocket.chat/ddp-client'; -import limax from 'limax'; import { Meteor } from 'meteor/meteor'; -import sharp from 'sharp'; -import { hasPermissionAsync } from '../../../authorization/server/functions/hasPermission'; -import { RocketChatFile } from '../../../file/server'; -import { RocketChatFileEmojiCustomInstance } from '../startup/emoji-custom'; - -const getFile = async (file: Buffer, extension: string) => { - if (extension !== 'svg+xml') { - return file; - } - - return sharp(file).png().toBuffer(); -}; +import { uploadEmojiCustom } from '../lib/uploadEmojiCustom'; declare module '@rocket.chat/ddp-client' { // eslint-disable-next-line @typescript-eslint/naming-convention @@ -33,44 +20,6 @@ declare module '@rocket.chat/ddp-client' { Meteor.methods({ async uploadEmojiCustom(binaryContent, contentType, emojiData) { - // technically, since this method doesnt have any datatype validations, users can - // upload videos as emojis. The FE won't play them, but they will waste space for sure. - if (!this.userId || !(await hasPermissionAsync(this.userId, 'manage-emoji'))) { - throw new Meteor.Error('not_authorized'); - } - - emojiData.name = limax(emojiData.name, { replacement: '_' }); - // delete aliases for notification purposes. here, it is a string rather than an array - delete emojiData.aliases; - - const file = await getFile(Buffer.from(binaryContent, 'binary'), emojiData.extension); - emojiData.extension = emojiData.extension === 'svg+xml' ? 'png' : emojiData.extension; - - let fileBuffer; - // sharp doesn't support these formats without imagemagick or libvips installed - // so they will be stored as they are :( - if (['gif', 'x-icon', 'bmp', 'webm'].includes(emojiData.extension)) { - fileBuffer = file; - } else { - // This is to support the idea of having "sticker-like" emojis - const { data: resizedEmojiBuffer } = await Media.resizeFromBuffer(file, 512, 512, true, false, false, 'inside'); - fileBuffer = resizedEmojiBuffer; - } - - const rs = RocketChatFile.bufferToStream(fileBuffer); - await RocketChatFileEmojiCustomInstance.deleteFile(encodeURIComponent(`${emojiData.name}.${emojiData.extension}`)); - - return new Promise((resolve) => { - const ws = RocketChatFileEmojiCustomInstance.createWriteStream( - encodeURIComponent(`${emojiData.name}.${emojiData.extension}`), - contentType, - ); - ws.on('end', () => { - setTimeout(() => api.broadcast('emoji.updateCustom', emojiData), 500); - resolve(); - }); - - rs.pipe(ws); - }); + await uploadEmojiCustom(this.userId, binaryContent, contentType, emojiData); }, }); diff --git a/apps/meteor/app/federation/server/endpoints/dispatch.js b/apps/meteor/app/federation/server/endpoints/dispatch.js index 6c7fa5cad0e8..4f2a197b25ee 100644 --- a/apps/meteor/app/federation/server/endpoints/dispatch.js +++ b/apps/meteor/app/federation/server/endpoints/dispatch.js @@ -6,7 +6,13 @@ import EJSON from 'ejson'; import { API } from '../../../api/server'; import { FileUpload } from '../../../file-upload/server'; import { deleteRoom } from '../../../lib/server/functions/deleteRoom'; -import { notifyOnMessageChange, notifyOnRoomChanged, notifyOnRoomChangedById } from '../../../lib/server/lib/notifyListener'; +import { + notifyOnMessageChange, + notifyOnRoomChanged, + notifyOnRoomChangedById, + notifyOnSubscriptionChanged, + notifyOnSubscriptionChangedById, +} from '../../../lib/server/lib/notifyListener'; import { notifyUsersOnMessage } from '../../../lib/server/lib/notifyUsersOnMessage'; import { sendAllNotifications } from '../../../lib/server/lib/sendNotificationsOnMessage'; import { processThreads } from '../../../threads/server/hooks/aftersavemessage'; @@ -141,7 +147,10 @@ const eventHandlers = { const denormalizedSubscription = normalizers.denormalizeSubscription(subscription); // Create the subscription - await Subscriptions.insertOne(denormalizedSubscription); + const { insertedId } = await Subscriptions.insertOne(denormalizedSubscription); + if (insertedId) { + void notifyOnSubscriptionChangedById(insertedId); + } federationAltered = true; } } catch (ex) { @@ -176,7 +185,10 @@ const eventHandlers = { } = event; // Remove the user's subscription - await Subscriptions.removeByRoomIdAndUserId(roomId, user._id); + const deletedSubscription = await Subscriptions.removeByRoomIdAndUserId(roomId, user._id); + if (deletedSubscription) { + void notifyOnSubscriptionChanged(deletedSubscription, 'removed'); + } // Refresh the servers list await FederationServers.refreshServers(); @@ -204,7 +216,10 @@ const eventHandlers = { } = event; // Remove the user's subscription - await Subscriptions.removeByRoomIdAndUserId(roomId, user._id); + const deletedSubscription = await Subscriptions.removeByRoomIdAndUserId(roomId, user._id); + if (deletedSubscription) { + void notifyOnSubscriptionChanged(deletedSubscription, 'removed'); + } // Refresh the servers list await FederationServers.refreshServers(); @@ -293,8 +308,12 @@ const eventHandlers = { await processThreads(denormalizedMessage, room); - // Notify users - await notifyUsersOnMessage(denormalizedMessage, room); + const roomUpdater = Rooms.getUpdater(); + await notifyUsersOnMessage(denormalizedMessage, room, roomUpdater); + if (roomUpdater.hasChanges()) { + await Rooms.updateFromUpdater({ _id: room._id }, roomUpdater); + } + sendAllNotifications(denormalizedMessage, room); messageForNotification = denormalizedMessage; } catch (err) { diff --git a/apps/meteor/app/federation/server/hooks/afterSaveMessage.js b/apps/meteor/app/federation/server/hooks/afterSaveMessage.js index 7f67f4770686..20c64f87dda8 100644 --- a/apps/meteor/app/federation/server/hooks/afterSaveMessage.js +++ b/apps/meteor/app/federation/server/hooks/afterSaveMessage.js @@ -6,7 +6,7 @@ import { getFederationDomain } from '../lib/getFederationDomain'; import { clientLogger } from '../lib/logger'; import { normalizers } from '../normalizers'; -async function afterSaveMessage(message, room) { +async function afterSaveMessage(message, { room }) { // If there are not federated users on this room, ignore it if (!hasExternalDomain(room)) { return message; diff --git a/apps/meteor/app/file-upload/server/lib/FileUpload.ts b/apps/meteor/app/file-upload/server/lib/FileUpload.ts index 08e2ccb0a52b..8714c71f20d6 100644 --- a/apps/meteor/app/file-upload/server/lib/FileUpload.ts +++ b/apps/meteor/app/file-upload/server/lib/FileUpload.ts @@ -10,7 +10,7 @@ import URL from 'url'; import { hashLoginToken } from '@rocket.chat/account-utils'; import { Apps, AppEvents } from '@rocket.chat/apps'; import { AppsEngineException } from '@rocket.chat/apps-engine/definition/exceptions'; -import type { IUpload } from '@rocket.chat/core-typings'; +import { isE2EEUpload, type IUpload } from '@rocket.chat/core-typings'; import { Users, Avatars, UserDataFiles, Uploads, Settings, Subscriptions, Messages, Rooms } from '@rocket.chat/models'; import type { NextFunction } from 'connect'; import filesize from 'filesize'; @@ -170,7 +170,13 @@ export const FileUpload = { throw new Meteor.Error('error-file-too-large', reason); } - if (!fileUploadIsValidContentType(file?.type)) { + if (!settings.get('E2E_Enable_Encrypt_Files') && isE2EEUpload(file)) { + const reason = i18n.t('Encrypted_file_not_allowed', { lng: language }); + throw new Meteor.Error('error-invalid-file-type', reason); + } + + // E2EE files are of type - application/octet-stream, application/octet-stream is whitelisted for E2EE files. + if (!fileUploadIsValidContentType(file?.type, isE2EEUpload(file) ? 'application/octet-stream' : undefined)) { const reason = i18n.t('File_type_is_not_accepted', { lng: language }); throw new Meteor.Error('error-invalid-file-type', reason); } diff --git a/apps/meteor/app/importer/server/classes/ImportDataConverter.ts b/apps/meteor/app/importer/server/classes/ImportDataConverter.ts index 7b1e71eaa0f0..6de47e33b2b6 100644 --- a/apps/meteor/app/importer/server/classes/ImportDataConverter.ts +++ b/apps/meteor/app/importer/server/classes/ImportDataConverter.ts @@ -28,7 +28,7 @@ import { generateUsernameSuggestion } from '../../../lib/server/functions/getUse import { insertMessage } from '../../../lib/server/functions/insertMessage'; import { saveUserIdentity } from '../../../lib/server/functions/saveUserIdentity'; import { setUserActiveStatus } from '../../../lib/server/functions/setUserActiveStatus'; -import { notifyOnUserChange } from '../../../lib/server/lib/notifyListener'; +import { notifyOnSubscriptionChangedByRoomId, notifyOnUserChange } from '../../../lib/server/lib/notifyListener'; import { createChannelMethod } from '../../../lib/server/methods/createChannel'; import { createPrivateGroupMethod } from '../../../lib/server/methods/createPrivateGroup'; import { getValidRoomName } from '../../../utils/server/lib/getValidRoomName'; @@ -1161,8 +1161,11 @@ export class ImportDataConverter { } async archiveRoomById(rid: string) { - await Rooms.archiveById(rid); - await Subscriptions.archiveByRoomId(rid); + const responses = await Promise.all([Rooms.archiveById(rid), Subscriptions.archiveByRoomId(rid)]); + + if (responses[1]?.modifiedCount) { + void notifyOnSubscriptionChangedByRoomId(rid); + } } async convertData(startedByUserId: string, callbacks: IConversionCallbacks = {}): Promise { diff --git a/apps/meteor/app/lib/server/functions/addUserToDefaultChannels.ts b/apps/meteor/app/lib/server/functions/addUserToDefaultChannels.ts index b6d977dc36e2..3fb9c419aa5f 100644 --- a/apps/meteor/app/lib/server/functions/addUserToDefaultChannels.ts +++ b/apps/meteor/app/lib/server/functions/addUserToDefaultChannels.ts @@ -5,6 +5,7 @@ import { Subscriptions } from '@rocket.chat/models'; import { callbacks } from '../../../../lib/callbacks'; import { getSubscriptionAutotranslateDefaultConfig } from '../../../../server/lib/getSubscriptionAutotranslateDefaultConfig'; import { getDefaultSubscriptionPref } from '../../../utils/lib/getDefaultSubscriptionPref'; +import { notifyOnSubscriptionChangedById } from '../lib/notifyListener'; import { getDefaultChannels } from './getDefaultChannels'; export const addUserToDefaultChannels = async function (user: IUser, silenced?: boolean): Promise { @@ -14,8 +15,9 @@ export const addUserToDefaultChannels = async function (user: IUser, silenced?: for await (const room of defaultRooms) { if (!(await Subscriptions.findOneByRoomIdAndUserId(room._id, user._id, { projection: { _id: 1 } }))) { const autoTranslateConfig = getSubscriptionAutotranslateDefaultConfig(user); + // Add a subscription to this user - await Subscriptions.createWithRoomAndUser(room, user, { + const { insertedId } = await Subscriptions.createWithRoomAndUser(room, user, { ts: new Date(), open: true, alert: true, @@ -27,6 +29,10 @@ export const addUserToDefaultChannels = async function (user: IUser, silenced?: ...getDefaultSubscriptionPref(user), }); + if (insertedId) { + void notifyOnSubscriptionChangedById(insertedId, 'inserted'); + } + // Insert user joined message if (!silenced) { await Message.saveSystemMessage('uj', room._id, user.username || '', user); diff --git a/apps/meteor/app/lib/server/functions/addUserToRoom.ts b/apps/meteor/app/lib/server/functions/addUserToRoom.ts index b6ffc0ca4629..e6ca7b2a8b4d 100644 --- a/apps/meteor/app/lib/server/functions/addUserToRoom.ts +++ b/apps/meteor/app/lib/server/functions/addUserToRoom.ts @@ -11,7 +11,7 @@ import { getSubscriptionAutotranslateDefaultConfig } from '../../../../server/li import { roomCoordinator } from '../../../../server/lib/rooms/roomCoordinator'; import { settings } from '../../../settings/server'; import { getDefaultSubscriptionPref } from '../../../utils/lib/getDefaultSubscriptionPref'; -import { notifyOnRoomChangedById } from '../lib/notifyListener'; +import { notifyOnRoomChangedById, notifyOnSubscriptionChangedById } from '../lib/notifyListener'; export const addUserToRoom = async function ( rid: string, @@ -82,7 +82,7 @@ export const addUserToRoom = async function ( const autoTranslateConfig = getSubscriptionAutotranslateDefaultConfig(userToBeAdded); - await Subscriptions.createWithRoomAndUser(room, userToBeAdded as IUser, { + const { insertedId } = await Subscriptions.createWithRoomAndUser(room, userToBeAdded as IUser, { ts: now, open: true, alert: !skipAlertSound, @@ -93,6 +93,10 @@ export const addUserToRoom = async function ( ...getDefaultSubscriptionPref(userToBeAdded as IUser), }); + if (insertedId) { + void notifyOnSubscriptionChangedById(insertedId, 'inserted'); + } + void notifyOnRoomChangedById(rid); if (!userToBeAdded.username) { diff --git a/apps/meteor/app/lib/server/functions/archiveRoom.ts b/apps/meteor/app/lib/server/functions/archiveRoom.ts index 3378d69f99ff..46fd7a1ac35b 100644 --- a/apps/meteor/app/lib/server/functions/archiveRoom.ts +++ b/apps/meteor/app/lib/server/functions/archiveRoom.ts @@ -3,11 +3,16 @@ import type { IMessage } from '@rocket.chat/core-typings'; import { Rooms, Subscriptions } from '@rocket.chat/models'; import { callbacks } from '../../../../lib/callbacks'; -import { notifyOnRoomChanged } from '../lib/notifyListener'; +import { notifyOnRoomChanged, notifyOnSubscriptionChangedByRoomId } from '../lib/notifyListener'; export const archiveRoom = async function (rid: string, user: IMessage['u']): Promise { await Rooms.archiveById(rid); - await Subscriptions.archiveByRoomId(rid); + + const archiveResponse = await Subscriptions.archiveByRoomId(rid); + if (archiveResponse.modifiedCount) { + void notifyOnSubscriptionChangedByRoomId(rid); + } + await Message.saveSystemMessage('room-archived', rid, '', user); const room = await Rooms.findOneById(rid); diff --git a/apps/meteor/app/lib/server/functions/cleanRoomHistory.ts b/apps/meteor/app/lib/server/functions/cleanRoomHistory.ts index 2bfb1086c635..765a03cad87b 100644 --- a/apps/meteor/app/lib/server/functions/cleanRoomHistory.ts +++ b/apps/meteor/app/lib/server/functions/cleanRoomHistory.ts @@ -4,7 +4,7 @@ import { Messages, Rooms, Subscriptions, ReadReceipts, Users } from '@rocket.cha import { i18n } from '../../../../server/lib/i18n'; import { FileUpload } from '../../../file-upload/server'; -import { notifyOnRoomChangedById } from '../lib/notifyListener'; +import { notifyOnRoomChangedById, notifyOnSubscriptionChangedById } from '../lib/notifyListener'; import { deleteRoom } from './deleteRoom'; export async function cleanRoomHistory({ @@ -75,6 +75,7 @@ export async function cleanRoomHistory({ if (!ignoreThreads) { const threads = new Set(); + await Messages.findThreadsByRoomIdPinnedTimestampAndUsers( { rid, pinned: excludePinned, ignoreDiscussion, ts, users: fromUsers }, { projection: { _id: 1 } }, @@ -83,7 +84,14 @@ export async function cleanRoomHistory({ }); if (threads.size > 0) { - await Subscriptions.removeUnreadThreadsByRoomId(rid, [...threads]); + const subscriptionIds: string[] = ( + await Subscriptions.findUnreadThreadsByRoomId(rid, [...threads], { projection: { _id: 1 } }).toArray() + ).map(({ _id }) => _id); + + const { modifiedCount } = await Subscriptions.removeUnreadThreadsByRoomId(rid, [...threads]); + if (modifiedCount) { + subscriptionIds.forEach((id) => notifyOnSubscriptionChangedById(id)); + } } } diff --git a/apps/meteor/app/lib/server/functions/closeLivechatRoom.ts b/apps/meteor/app/lib/server/functions/closeLivechatRoom.ts index b716be044d57..263b137ae00c 100644 --- a/apps/meteor/app/lib/server/functions/closeLivechatRoom.ts +++ b/apps/meteor/app/lib/server/functions/closeLivechatRoom.ts @@ -5,6 +5,7 @@ import { LivechatRooms, Subscriptions } from '@rocket.chat/models'; import { hasPermissionAsync } from '../../../authorization/server/functions/hasPermission'; import type { CloseRoomParams } from '../../../livechat/server/lib/LivechatTyped'; import { Livechat } from '../../../livechat/server/lib/LivechatTyped'; +import { notifyOnSubscriptionChanged } from '../lib/notifyListener'; export const closeLivechatRoom = async ( user: IUser, @@ -34,9 +35,12 @@ export const closeLivechatRoom = async ( } if (!room.open) { - const subscriptionsLeft = await Subscriptions.countByRoomId(roomId); - if (subscriptionsLeft) { - await Subscriptions.removeByRoomId(roomId); + const { deletedCount } = await Subscriptions.removeByRoomId(roomId, { + async onTrash(doc) { + void notifyOnSubscriptionChanged(doc, 'removed'); + }, + }); + if (deletedCount) { return; } throw new Error('error-room-already-closed'); diff --git a/apps/meteor/app/lib/server/functions/createDirectRoom.ts b/apps/meteor/app/lib/server/functions/createDirectRoom.ts index 67c6328f38f4..f77ee1f55901 100644 --- a/apps/meteor/app/lib/server/functions/createDirectRoom.ts +++ b/apps/meteor/app/lib/server/functions/createDirectRoom.ts @@ -11,7 +11,7 @@ import { callbacks } from '../../../../lib/callbacks'; import { isTruthy } from '../../../../lib/isTruthy'; import { settings } from '../../../settings/server'; import { getDefaultSubscriptionPref } from '../../../utils/lib/getDefaultSubscriptionPref'; -import { notifyOnRoomChangedById } from '../lib/notifyListener'; +import { notifyOnRoomChangedById, notifyOnSubscriptionChangedByRoomIdAndUserId } from '../lib/notifyListener'; const generateSubscription = ( fname: string, @@ -135,7 +135,7 @@ export async function createDirectRoom( if (roomMembers.length === 1) { // dm to yourself - await Subscriptions.updateOne( + const { modifiedCount, upsertedCount } = await Subscriptions.updateOne( { rid, 'u._id': roomMembers[0]._id }, { $set: { open: true }, @@ -146,6 +146,9 @@ export async function createDirectRoom( }, { upsert: true }, ); + if (modifiedCount || upsertedCount) { + void notifyOnSubscriptionChangedByRoomIdAndUserId(rid, roomMembers[0]._id, modifiedCount ? 'updated' : 'inserted'); + } } else { const memberIds = roomMembers.map((member) => member._id); const membersWithPreferences: IUser[] = await Users.find( @@ -155,7 +158,7 @@ export async function createDirectRoom( for await (const member of membersWithPreferences) { const otherMembers = sortedMembers.filter(({ _id }) => _id !== member._id); - await Subscriptions.updateOne( + const { modifiedCount, upsertedCount } = await Subscriptions.updateOne( { rid, 'u._id': member._id }, { ...(options?.creator === member._id && { $set: { open: true } }), @@ -166,6 +169,9 @@ export async function createDirectRoom( }, { upsert: true }, ); + if (modifiedCount || upsertedCount) { + void notifyOnSubscriptionChangedByRoomIdAndUserId(rid, member._id, modifiedCount ? 'updated' : 'inserted'); + } } } diff --git a/apps/meteor/app/lib/server/functions/createRoom.ts b/apps/meteor/app/lib/server/functions/createRoom.ts index 183cb789051f..769155b66b60 100644 --- a/apps/meteor/app/lib/server/functions/createRoom.ts +++ b/apps/meteor/app/lib/server/functions/createRoom.ts @@ -1,7 +1,7 @@ /* eslint-disable complexity */ import { AppEvents, Apps } from '@rocket.chat/apps'; import { AppsEngineException } from '@rocket.chat/apps-engine/definition/exceptions'; -import { Message, Team } from '@rocket.chat/core-services'; +import { Federation, FederationEE, License, Message, Team } from '@rocket.chat/core-services'; import type { ICreateRoomParams, ISubscriptionExtraData } from '@rocket.chat/core-services'; import type { ICreatedRoom, IUser, IRoom, RoomType } from '@rocket.chat/core-typings'; import { Rooms, Subscriptions, Users } from '@rocket.chat/models'; @@ -12,7 +12,7 @@ import { beforeCreateRoomCallback } from '../../../../lib/callbacks/beforeCreate import { getSubscriptionAutotranslateDefaultConfig } from '../../../../server/lib/getSubscriptionAutotranslateDefaultConfig'; import { getDefaultSubscriptionPref } from '../../../utils/lib/getDefaultSubscriptionPref'; import { getValidRoomName } from '../../../utils/server/lib/getValidRoomName'; -import { notifyOnRoomChanged } from '../lib/notifyListener'; +import { notifyOnRoomChanged, notifyOnSubscriptionChangedById } from '../lib/notifyListener'; import { createDirectRoom } from './createDirectRoom'; const isValidName = (name: unknown): name is string => { @@ -47,7 +47,11 @@ async function createUsersSubscriptions({ ...getDefaultSubscriptionPref(owner), }; - await Subscriptions.createWithRoomAndUser(room, owner, extra); + const { insertedId } = await Subscriptions.createWithRoomAndUser(room, owner, extra); + + if (insertedId) { + await notifyOnRoomChanged(room, 'inserted'); + } return; } @@ -98,7 +102,9 @@ async function createUsersSubscriptions({ await Users.addRoomByUserIds(memberIds, room._id); } - await Subscriptions.createWithRoomAndManyUsers(room, subs); + const { insertedIds } = await Subscriptions.createWithRoomAndManyUsers(room, subs); + + Object.values(insertedIds).forEach((subId) => notifyOnSubscriptionChangedById(subId, 'inserted')); await Rooms.incUsersCountById(room._id, subs.length); } @@ -224,6 +230,13 @@ export const createRoom = async ( Object.assign(roomProps, eventResult); } + const shouldBeHandledByFederation = roomProps.federated === true || owner.username.includes(':'); + + if (shouldBeHandledByFederation) { + const federation = (await License.hasValidLicense()) ? FederationEE : Federation; + await federation.beforeCreateRoom(roomProps); + } + if (type === 'c') { await callbacks.run('beforeCreateChannel', owner, roomProps); } @@ -232,8 +245,6 @@ export const createRoom = async ( void notifyOnRoomChanged(room, 'inserted'); - const shouldBeHandledByFederation = room.federated === true || owner.username.includes(':'); - await createUsersSubscriptions({ room, members, now, owner, options, shouldBeHandledByFederation }); if (type === 'c') { diff --git a/apps/meteor/app/lib/server/functions/deleteMessage.ts b/apps/meteor/app/lib/server/functions/deleteMessage.ts index 04542d5f1d27..a91e77858043 100644 --- a/apps/meteor/app/lib/server/functions/deleteMessage.ts +++ b/apps/meteor/app/lib/server/functions/deleteMessage.ts @@ -1,5 +1,5 @@ import { AppEvents, Apps } from '@rocket.chat/apps'; -import { api } from '@rocket.chat/core-services'; +import { api, Message } from '@rocket.chat/core-services'; import type { AtLeast, IMessage, IUser } from '@rocket.chat/core-typings'; import { Messages, Rooms, Uploads, Users, ReadReceipts } from '@rocket.chat/models'; import { Meteor } from 'meteor/meteor'; @@ -35,10 +35,18 @@ export async function deleteMessage(message: IMessage, user: IUser): Promise { await FileUpload.removeFilesByRoomId(rid); + await Messages.removeByRoomId(rid); + await callbacks.run('beforeDeleteRoom', rid); - await Subscriptions.removeByRoomId(rid); + + await Subscriptions.removeByRoomId(rid, { + async onTrash(doc) { + void notifyOnSubscriptionChanged(doc, 'removed'); + }, + }); + await FileUpload.getStore('Avatars').deleteByRoomId(rid); + await callbacks.run('afterDeleteRoom', rid); - await Rooms.removeById(rid); - void notifyOnRoomChangedById(rid, 'removed'); + const { deletedCount } = await Rooms.removeById(rid); + if (deletedCount) { + void notifyOnRoomChangedById(rid, 'removed'); + } }; diff --git a/apps/meteor/app/lib/server/functions/deleteUser.ts b/apps/meteor/app/lib/server/functions/deleteUser.ts index d6457664671a..483085d40811 100644 --- a/apps/meteor/app/lib/server/functions/deleteUser.ts +++ b/apps/meteor/app/lib/server/functions/deleteUser.ts @@ -1,5 +1,5 @@ import { api } from '@rocket.chat/core-services'; -import type { IUser } from '@rocket.chat/core-typings'; +import { isUserFederated, type IUser } from '@rocket.chat/core-typings'; import { Integrations, FederationServers, @@ -12,6 +12,7 @@ import { ReadReceipts, LivechatUnitMonitors, ModerationReports, + MatrixBridgedUser, } from '@rocket.chat/models'; import { Meteor } from 'meteor/meteor'; @@ -46,6 +47,19 @@ export async function deleteUser(userId: string, confirmRelinquish = false, dele return; } + if (isUserFederated(user)) { + throw new Meteor.Error('error-not-allowed', 'Deleting federated, external user is not allowed', { + method: 'deleteUser', + }); + } + + const remoteUser = await MatrixBridgedUser.getExternalUserIdByLocalUserId(userId); + if (remoteUser) { + throw new Meteor.Error('error-not-allowed', 'User participated in federation, this user can only be deactivated permanently', { + method: 'deleteUser', + }); + } + const subscribedRooms = await getSubscribedRoomsForUserWithDetails(userId); if (shouldRemoveOrChangeOwner(subscribedRooms) && !confirmRelinquish) { @@ -98,7 +112,7 @@ export async function deleteUser(userId: string, confirmRelinquish = false, dele const rids = subscribedRooms.map((room) => room.rid); void notifyOnRoomChangedById(rids); - await Subscriptions.removeByUserId(userId); // Remove user subscriptions + await Subscriptions.removeByUserId(userId); // Remove user as livechat agent if (user.roles.includes('livechat-agent')) { diff --git a/apps/meteor/app/lib/server/functions/relinquishRoomOwnerships.ts b/apps/meteor/app/lib/server/functions/relinquishRoomOwnerships.ts index 75b232462077..8f1981ca386d 100644 --- a/apps/meteor/app/lib/server/functions/relinquishRoomOwnerships.ts +++ b/apps/meteor/app/lib/server/functions/relinquishRoomOwnerships.ts @@ -1,6 +1,7 @@ import { Messages, Roles, Rooms, Subscriptions, ReadReceipts } from '@rocket.chat/models'; import { FileUpload } from '../../../file-upload/server'; +import { notifyOnSubscriptionChanged } from '../lib/notifyListener'; import type { SubscribedRoomsForUserWithDetails } from './getRoomsWithSingleOwner'; const bulkRoomCleanUp = async (rids: string[]): Promise => { @@ -8,7 +9,11 @@ const bulkRoomCleanUp = async (rids: string[]): Promise => { await Promise.all(rids.map((rid) => FileUpload.removeFilesByRoomId(rid))); return Promise.all([ - Subscriptions.removeByRoomIds(rids), + Subscriptions.removeByRoomIds(rids, { + async onTrash(doc) { + void notifyOnSubscriptionChanged(doc, 'removed'); + }, + }), Messages.removeByRoomIds(rids), ReadReceipts.removeByRoomIds(rids), Rooms.removeByIds(rids), diff --git a/apps/meteor/app/lib/server/functions/removeUserFromRoom.ts b/apps/meteor/app/lib/server/functions/removeUserFromRoom.ts index c55ee382f10c..5800cb68af81 100644 --- a/apps/meteor/app/lib/server/functions/removeUserFromRoom.ts +++ b/apps/meteor/app/lib/server/functions/removeUserFromRoom.ts @@ -1,6 +1,6 @@ import { Apps, AppEvents } from '@rocket.chat/apps'; import { AppsEngineException } from '@rocket.chat/apps-engine/definition/exceptions'; -import { Message, Team } from '@rocket.chat/core-services'; +import { Message, Team, Room } from '@rocket.chat/core-services'; import type { IUser } from '@rocket.chat/core-typings'; import { Subscriptions, Rooms } from '@rocket.chat/models'; import { Meteor } from 'meteor/meteor'; @@ -8,7 +8,7 @@ import { Meteor } from 'meteor/meteor'; import { afterLeaveRoomCallback } from '../../../../lib/callbacks/afterLeaveRoomCallback'; import { beforeLeaveRoomCallback } from '../../../../lib/callbacks/beforeLeaveRoomCallback'; import { settings } from '../../../settings/server'; -import { notifyOnRoomChangedById } from '../lib/notifyListener'; +import { notifyOnRoomChangedById, notifyOnSubscriptionChanged } from '../lib/notifyListener'; export const removeUserFromRoom = async function (rid: string, user: IUser, options?: { byUser: IUser }): Promise { const room = await Rooms.findOneById(rid); @@ -27,6 +27,9 @@ export const removeUserFromRoom = async function (rid: string, user: IUser, opti throw error; } + await Room.beforeLeave(room); + + // TODO: move before callbacks to service await beforeLeaveRoomCallback.run(user, room); const subscription = await Subscriptions.findOneByRoomIdAndUserId(rid, user._id, { @@ -56,7 +59,10 @@ export const removeUserFromRoom = async function (rid: string, user: IUser, opti await Message.saveSystemMessage('command', rid, 'survey', user); } - await Subscriptions.removeByRoomIdAndUserId(rid, user._id); + const deletedSubscription = await Subscriptions.removeByRoomIdAndUserId(rid, user._id); + if (deletedSubscription) { + void notifyOnSubscriptionChanged(deletedSubscription, 'removed'); + } if (room.teamId && room.teamMain) { await Team.removeMember(room.teamId, user._id); diff --git a/apps/meteor/app/lib/server/functions/saveCustomFieldsWithoutValidation.ts b/apps/meteor/app/lib/server/functions/saveCustomFieldsWithoutValidation.ts index 4a0ac005e55c..5383048f13bd 100644 --- a/apps/meteor/app/lib/server/functions/saveCustomFieldsWithoutValidation.ts +++ b/apps/meteor/app/lib/server/functions/saveCustomFieldsWithoutValidation.ts @@ -5,6 +5,7 @@ import type { UpdateFilter } from 'mongodb'; import { trim } from '../../../../lib/utils/stringUtils'; import { settings } from '../../../settings/server'; +import { notifyOnSubscriptionChangedByUserIdAndRoomType } from '../lib/notifyListener'; export const saveCustomFieldsWithoutValidation = async function (userId: string, formData: Record): Promise { if (trim(settings.get('Accounts_CustomFields')) !== '') { @@ -22,7 +23,10 @@ export const saveCustomFieldsWithoutValidation = async function (userId: string, await Users.setCustomFields(userId, customFields); // Update customFields of all Direct Messages' Rooms for userId - await Subscriptions.setCustomFieldsDirectMessagesByUserId(userId, customFields); + const setCustomFieldsResponse = await Subscriptions.setCustomFieldsDirectMessagesByUserId(userId, customFields); + if (setCustomFieldsResponse.modifiedCount) { + void notifyOnSubscriptionChangedByUserIdAndRoomType(userId, 'd'); + } for await (const fieldName of Object.keys(customFields)) { if (!customFieldsMeta[fieldName].modifyRecordField) { diff --git a/apps/meteor/app/lib/server/functions/saveUserIdentity.ts b/apps/meteor/app/lib/server/functions/saveUserIdentity.ts index 0b9ff21e53e3..1729a1ba8abd 100644 --- a/apps/meteor/app/lib/server/functions/saveUserIdentity.ts +++ b/apps/meteor/app/lib/server/functions/saveUserIdentity.ts @@ -3,7 +3,11 @@ import { Messages, VideoConference, LivechatDepartmentAgents, Rooms, Subscriptio import { SystemLogger } from '../../../../server/lib/logger/system'; import { FileUpload } from '../../../file-upload/server'; -import { notifyOnRoomChangedByUsernamesOrUids } from '../lib/notifyListener'; +import { + notifyOnRoomChangedByUsernamesOrUids, + notifyOnSubscriptionChangedByUserId, + notifyOnSubscriptionChangedByNameAndRoomType, +} from '../lib/notifyListener'; import { _setRealName } from './setRealName'; import { _setUsername } from './setUsername'; import { updateGroupDMsName } from './updateGroupDMsName'; @@ -129,20 +133,38 @@ async function updateUsernameReferences({ await Messages.updateUsernameAndMessageOfMentionByIdAndOldUsername(msg._id, previousUsername, username, updatedMsg); } - await Rooms.replaceUsername(previousUsername, username); - await Rooms.replaceMutedUsername(previousUsername, username); - await Rooms.replaceUsernameOfUserByUserId(user._id, username); - await Subscriptions.setUserUsernameByUserId(user._id, username); + const responses = await Promise.all([ + Rooms.replaceUsername(previousUsername, username), + Rooms.replaceMutedUsername(previousUsername, username), + Rooms.replaceUsernameOfUserByUserId(user._id, username), + Subscriptions.setUserUsernameByUserId(user._id, username), + LivechatDepartmentAgents.replaceUsernameOfAgentByUserId(user._id, username), + ]); - await LivechatDepartmentAgents.replaceUsernameOfAgentByUserId(user._id, username); + if (responses[3]?.modifiedCount) { + void notifyOnSubscriptionChangedByUserId(user._id); + } - void notifyOnRoomChangedByUsernamesOrUids([user._id], [previousUsername, username]); + if (responses[0]?.modifiedCount || responses[1]?.modifiedCount || responses[2]?.modifiedCount) { + void notifyOnRoomChangedByUsernamesOrUids([user._id], [previousUsername, username]); + } } // update other references if either the name or username has changed if (usernameChanged || nameChanged) { // update name and fname of 1-on-1 direct messages - await Subscriptions.updateDirectNameAndFnameByName(previousUsername, rawUsername && username, rawName && name); + const updateDirectNameResponse = await Subscriptions.updateDirectNameAndFnameByName( + previousUsername, + rawUsername && username, + rawName && name, + ); + + if (updateDirectNameResponse?.modifiedCount) { + void notifyOnSubscriptionChangedByNameAndRoomType({ + t: 'd', + name: username, + }); + } // update name and fname of group direct messages await updateGroupDMsName(user); diff --git a/apps/meteor/app/lib/server/functions/sendMessage.ts b/apps/meteor/app/lib/server/functions/sendMessage.ts index 4a5b8313ebcd..aba5ddb7264c 100644 --- a/apps/meteor/app/lib/server/functions/sendMessage.ts +++ b/apps/meteor/app/lib/server/functions/sendMessage.ts @@ -4,12 +4,12 @@ import type { IMessage, IRoom } from '@rocket.chat/core-typings'; import { Messages } from '@rocket.chat/models'; import { Match, check } from 'meteor/check'; -import { callbacks } from '../../../../lib/callbacks'; import { isRelativeURL } from '../../../../lib/utils/isRelativeURL'; import { isURL } from '../../../../lib/utils/isURL'; import { hasPermissionAsync } from '../../../authorization/server/functions/hasPermission'; import { FileUpload } from '../../../file-upload/server'; import { settings } from '../../../settings/server'; +import { afterSaveMessage } from '../lib/afterSaveMessage'; import { notifyOnRoomChangedById, notifyOnMessageChange } from '../lib/notifyListener'; import { validateCustomMessageFields } from '../lib/validateCustomMessageFields'; import { parseUrlsInMessage } from './parseUrlsInMessage'; @@ -289,11 +289,10 @@ export const sendMessage = async function (user: any, message: any, room: any, u void Apps.getBridges()?.getListenerBridge().messageEvent('IPostMessageSent', message); } - await callbacks.run('afterSaveMessage', message, room); + // TODO: is there an opportunity to send returned data to notifyOnMessageChange? + await afterSaveMessage(message, room); - void notifyOnMessageChange({ - id: message._id, - }); + void notifyOnMessageChange({ id: message._id }); void notifyOnRoomChangedById(message.rid); diff --git a/apps/meteor/app/lib/server/functions/setUserActiveStatus.ts b/apps/meteor/app/lib/server/functions/setUserActiveStatus.ts index e3104db280dd..929c24210d2d 100644 --- a/apps/meteor/app/lib/server/functions/setUserActiveStatus.ts +++ b/apps/meteor/app/lib/server/functions/setUserActiveStatus.ts @@ -1,6 +1,7 @@ +import { Federation, FederationEE, License } from '@rocket.chat/core-services'; import type { IUser, IUserEmail } from '@rocket.chat/core-typings'; import { isUserFederated, isDirectMessageRoom } from '@rocket.chat/core-typings'; -import { Rooms, Users, Subscriptions } from '@rocket.chat/models'; +import { Rooms, Users, Subscriptions, MatrixBridgedUser } from '@rocket.chat/models'; import { Accounts } from 'meteor/accounts-base'; import { check } from 'meteor/check'; import { Meteor } from 'meteor/meteor'; @@ -8,7 +9,12 @@ import { Meteor } from 'meteor/meteor'; import { callbacks } from '../../../../lib/callbacks'; import * as Mailer from '../../../mailer/server/api'; import { settings } from '../../../settings/server'; -import { notifyOnRoomChangedById, notifyOnRoomChangedByUserDM, notifyOnUserChange } from '../lib/notifyListener'; +import { + notifyOnRoomChangedById, + notifyOnRoomChangedByUserDM, + notifyOnSubscriptionChangedByNameAndRoomType, + notifyOnUserChange, +} from '../lib/notifyListener'; import { closeOmnichannelConversations } from './closeOmnichannelConversations'; import { shouldRemoveOrChangeOwner, getSubscribedRoomsForUserWithDetails } from './getRoomsWithSingleOwner'; import { getUserSingleOwnedRooms } from './getUserSingleOwnedRooms'; @@ -38,8 +44,10 @@ async function reactivateDirectConversations(userId: string) { return acc; }, []); - await Rooms.setDmReadOnlyByUserId(userId, roomsToReactivate, false, false); - void notifyOnRoomChangedById(roomsToReactivate); + const setDmReadOnlyResponse = await Rooms.setDmReadOnlyByUserId(userId, roomsToReactivate, false, false); + if (setDmReadOnlyResponse.modifiedCount) { + void notifyOnRoomChangedById(roomsToReactivate); + } } export async function setUserActiveStatus(userId: string, active: boolean, confirmRelinquish = false): Promise { @@ -58,6 +66,22 @@ export async function setUserActiveStatus(userId: string, active: boolean, confi }); } + if (user.active !== active) { + const remoteUser = await MatrixBridgedUser.getExternalUserIdByLocalUserId(userId); + + if (remoteUser) { + if (active) { + throw new Meteor.Error('error-not-allowed', 'Deactivated federated users can not be re-activated', { + method: 'setUserActiveStatus', + }); + } + + const federation = (await License.hasValidLicense()) ? FederationEE : Federation; + + await federation.deactivateRemoteUser(remoteUser); + } + } + // Users without username can't do anything, so there is no need to check for owned rooms if (user.username != null && !active) { const userAdmin = await Users.findOneAdmin(userId || ''); @@ -101,7 +125,10 @@ export async function setUserActiveStatus(userId: string, active: boolean, confi } if (user.username) { - await Subscriptions.setArchivedByUsername(user.username, !active); + const { modifiedCount } = await Subscriptions.setArchivedByUsername(user.username, !active); + if (modifiedCount) { + void notifyOnSubscriptionChangedByNameAndRoomType({ t: 'd', name: user.username }); + } } if (active === false) { diff --git a/apps/meteor/app/lib/server/functions/setUsername.ts b/apps/meteor/app/lib/server/functions/setUsername.ts index e19ef874db0f..5b2b1923da75 100644 --- a/apps/meteor/app/lib/server/functions/setUsername.ts +++ b/apps/meteor/app/lib/server/functions/setUsername.ts @@ -17,6 +17,7 @@ import { getAvatarSuggestionForUser } from './getAvatarSuggestionForUser'; import { joinDefaultChannels } from './joinDefaultChannels'; import { saveUserIdentity } from './saveUserIdentity'; import { setUserAvatar } from './setUserAvatar'; +import { validateUsername } from './validateUsername'; export const setUsernameWithValidation = async (userId: string, username: string, joinDefaultChannelsSilenced?: boolean): Promise => { if (!username) { @@ -37,14 +38,7 @@ export const setUsernameWithValidation = async (userId: string, username: string return; } - let nameValidation; - try { - nameValidation = new RegExp(`^${settings.get('UTF8_User_Names_Validation')}$`); - } catch (error) { - nameValidation = new RegExp('^[0-9a-zA-Z-_.]+$'); - } - - if (!nameValidation.test(username)) { + if (!validateUsername(username)) { throw new Meteor.Error( 'username-invalid', `${_.escape(username)} is not a valid username, use only letters, numbers, dots, hyphens and underscores`, @@ -74,18 +68,15 @@ export const setUsernameWithValidation = async (userId: string, username: string export const _setUsername = async function (userId: string, u: string, fullUser: IUser): Promise { const username = u.trim(); + if (!userId || !username) { return false; } - let nameValidation; - try { - nameValidation = new RegExp(`^${settings.get('UTF8_User_Names_Validation')}$`); - } catch (error) { - nameValidation = new RegExp('^[0-9a-zA-Z-_.]+$'); - } - if (!nameValidation.test(username)) { + + if (!validateUsername(username)) { return false; } + const user = fullUser || (await Users.findOneById(userId)); // User already has desired username, return if (user.username === username) { diff --git a/apps/meteor/app/lib/server/functions/unarchiveRoom.ts b/apps/meteor/app/lib/server/functions/unarchiveRoom.ts index 7db86ed933a3..699f9c3701b1 100644 --- a/apps/meteor/app/lib/server/functions/unarchiveRoom.ts +++ b/apps/meteor/app/lib/server/functions/unarchiveRoom.ts @@ -2,11 +2,16 @@ import { Message } from '@rocket.chat/core-services'; import type { IMessage } from '@rocket.chat/core-typings'; import { Rooms, Subscriptions } from '@rocket.chat/models'; -import { notifyOnRoomChangedById } from '../lib/notifyListener'; +import { notifyOnRoomChangedById, notifyOnSubscriptionChangedByRoomId } from '../lib/notifyListener'; export const unarchiveRoom = async function (rid: string, user: IMessage['u']): Promise { await Rooms.unarchiveById(rid); - await Subscriptions.unarchiveByRoomId(rid); + + const unarchiveResponse = await Subscriptions.unarchiveByRoomId(rid); + if (unarchiveResponse.modifiedCount) { + void notifyOnSubscriptionChangedByRoomId(rid); + } + await Message.saveSystemMessage('room-unarchived', rid, '', user); void notifyOnRoomChangedById(rid); diff --git a/apps/meteor/app/lib/server/functions/updateGroupDMsName.ts b/apps/meteor/app/lib/server/functions/updateGroupDMsName.ts index a0ad2eedcf55..feb26ce6a1b0 100644 --- a/apps/meteor/app/lib/server/functions/updateGroupDMsName.ts +++ b/apps/meteor/app/lib/server/functions/updateGroupDMsName.ts @@ -1,6 +1,8 @@ import type { IUser } from '@rocket.chat/core-typings'; import { Rooms, Subscriptions, Users } from '@rocket.chat/models'; +import { notifyOnSubscriptionChangedByRoomId } from '../lib/notifyListener'; + const getFname = (members: IUser[]): string => members.map(({ name, username }) => name || username).join(', '); const getName = (members: IUser[]): string => members.map(({ username }) => username).join(','); @@ -63,7 +65,10 @@ export const updateGroupDMsName = async (userThatChangedName: IUser): Promise _id !== sub.u._id); - await Subscriptions.updateNameAndFnameById(sub._id, getName(otherMembers), getFname(otherMembers)); + const updateNameRespose = await Subscriptions.updateNameAndFnameById(sub._id, getName(otherMembers), getFname(otherMembers)); + if (updateNameRespose.modifiedCount) { + void notifyOnSubscriptionChangedByRoomId(room._id); + } } } }; diff --git a/apps/meteor/app/lib/server/functions/updateMessage.ts b/apps/meteor/app/lib/server/functions/updateMessage.ts index f51fc83c11d7..96683d40348f 100644 --- a/apps/meteor/app/lib/server/functions/updateMessage.ts +++ b/apps/meteor/app/lib/server/functions/updateMessage.ts @@ -4,14 +4,14 @@ import type { IMessage, IUser, AtLeast } from '@rocket.chat/core-typings'; import { Messages, Rooms } from '@rocket.chat/models'; import { Meteor } from 'meteor/meteor'; -import { callbacks } from '../../../../lib/callbacks'; import { settings } from '../../../settings/server'; +import { afterSaveMessage } from '../lib/afterSaveMessage'; import { notifyOnRoomChangedById, notifyOnMessageChange } from '../lib/notifyListener'; import { validateCustomMessageFields } from '../lib/validateCustomMessageFields'; import { parseUrlsInMessage } from './parseUrlsInMessage'; export const updateMessage = async function ( - message: AtLeast, + message: AtLeast, user: IUser, originalMsg?: IMessage, previewUrls?: string[], @@ -99,11 +99,11 @@ export const updateMessage = async function ( // although this is an "afterSave" kind callback, we know they can extend message's properties // so we wait for it to run before broadcasting - const data = await callbacks.run('afterSaveMessage', msg, room, user._id); + const data = await afterSaveMessage(msg, room, user._id); void notifyOnMessageChange({ id: msg._id, - data: data as any, // TODO move "afterSaveMessage" type definition to specify a return value + data, }); if (room?.lastMessage?._id === msg._id) { diff --git a/apps/meteor/app/lib/server/functions/validateUsername.ts b/apps/meteor/app/lib/server/functions/validateUsername.ts new file mode 100644 index 000000000000..523667282d22 --- /dev/null +++ b/apps/meteor/app/lib/server/functions/validateUsername.ts @@ -0,0 +1,15 @@ +import { settings } from '../../../settings/server'; + +export const validateUsername = (username: string): boolean => { + const settingsRegExp = settings.get('UTF8_User_Names_Validation'); + const defaultPattern = /^[0-9a-zA-Z-_.]+$/; + + let usernameRegExp: RegExp; + try { + usernameRegExp = settingsRegExp ? new RegExp(`^${settingsRegExp}$`) : defaultPattern; + } catch (e) { + usernameRegExp = defaultPattern; + } + + return usernameRegExp.test(username); +}; diff --git a/apps/meteor/app/lib/server/index.ts b/apps/meteor/app/lib/server/index.ts index 80aaa2a64a9e..49fad2002c75 100644 --- a/apps/meteor/app/lib/server/index.ts +++ b/apps/meteor/app/lib/server/index.ts @@ -49,5 +49,6 @@ import './methods/unarchiveRoom'; import './methods/unblockUser'; import './methods/updateMessage'; import './methods/saveCustomFields'; +import './methods/checkFederationConfiguration'; export * from './lib'; diff --git a/apps/meteor/app/lib/server/lib/afterSaveMessage.ts b/apps/meteor/app/lib/server/lib/afterSaveMessage.ts new file mode 100644 index 000000000000..5b6e12b1e185 --- /dev/null +++ b/apps/meteor/app/lib/server/lib/afterSaveMessage.ts @@ -0,0 +1,35 @@ +import type { IMessage, IUser, IRoom } from '@rocket.chat/core-typings'; +import type { Updater } from '@rocket.chat/models'; +import { Rooms } from '@rocket.chat/models'; + +import { callbacks } from '../../../../lib/callbacks'; + +export async function afterSaveMessage( + message: IMessage, + room: IRoom, + uid?: IUser['_id'], + roomUpdater?: Updater, +): Promise { + const updater = roomUpdater ?? Rooms.getUpdater(); + const data = await callbacks.run('afterSaveMessage', message, { room, uid, roomUpdater: updater }); + + if (!roomUpdater && updater.hasChanges()) { + await Rooms.updateFromUpdater({ _id: room._id }, updater); + } + + // TODO: Fix type - callback configuration needs to be updated + return data as unknown as IMessage; +} + +export function afterSaveMessageAsync( + message: IMessage, + room: IRoom, + uid?: IUser['_id'], + roomUpdater: Updater = Rooms.getUpdater(), +): void { + callbacks.runAsync('afterSaveMessage', message, { room, uid, roomUpdater }); + + if (roomUpdater.hasChanges()) { + void Rooms.updateFromUpdater({ _id: room._id }, roomUpdater); + } +} diff --git a/apps/meteor/app/lib/server/lib/notifyListener.ts b/apps/meteor/app/lib/server/lib/notifyListener.ts index 83ab5774374a..778fe89dbbf4 100644 --- a/apps/meteor/app/lib/server/lib/notifyListener.ts +++ b/apps/meteor/app/lib/server/lib/notifyListener.ts @@ -15,6 +15,7 @@ import type { IEmailInbox, IIntegrationHistory, AtLeast, + ISubscription, ISettingColor, IUser, IMessage, @@ -30,6 +31,7 @@ import { Integrations, LoginServiceConfiguration, IntegrationHistory, + Subscriptions, LivechatInquiry, LivechatDepartmentAgents, Users, @@ -37,6 +39,7 @@ import { } from '@rocket.chat/models'; import mem from 'mem'; +import { subscriptionFields } from '../../../../lib/publishFields'; import { shouldHideSystemMessage } from '../../../../server/lib/systemMessage/hideSystemMessage'; type ClientAction = 'inserted' | 'updated' | 'removed'; @@ -461,12 +464,125 @@ export async function getMessageToBroadcast({ id, data }: { id: IMessage['_id']; } export const notifyOnMessageChange = withDbWatcherCheck(async ({ id, data }: { id: IMessage['_id']; data?: IMessage }): Promise => { - if (!dbWatchersDisabled) { - return; - } const message = await getMessageToBroadcast({ id, data }); if (!message) { return; } void api.broadcast('watch.messages', { message }); }); + +export const notifyOnSubscriptionChanged = withDbWatcherCheck( + async (subscription: ISubscription, clientAction: ClientAction = 'updated'): Promise => { + void api.broadcast('watch.subscriptions', { clientAction, subscription }); + }, +); + +export const notifyOnSubscriptionChangedByRoomIdAndUserId = withDbWatcherCheck( + async ( + rid: ISubscription['rid'], + uid: ISubscription['u']['_id'], + clientAction: Exclude = 'updated', + ): Promise => { + const cursor = Subscriptions.findByUserIdAndRoomIds(uid, [rid], { projection: subscriptionFields }); + + void cursor.forEach((subscription) => { + void api.broadcast('watch.subscriptions', { clientAction, subscription }); + }); + }, +); + +export const notifyOnSubscriptionChangedById = withDbWatcherCheck( + async (id: ISubscription['_id'], clientAction: Exclude = 'updated'): Promise => { + const subscription = await Subscriptions.findOneById(id); + if (!subscription) { + return; + } + + void api.broadcast('watch.subscriptions', { clientAction, subscription }); + }, +); + +export const notifyOnSubscriptionChangedByUserPreferences = withDbWatcherCheck( + async ( + uid: ISubscription['u']['_id'], + notificationOriginField: keyof ISubscription, + originFieldNotEqualValue: 'user' | 'subscription', + clientAction: Exclude = 'updated', + ): Promise => { + const cursor = Subscriptions.findByUserPreferences(uid, notificationOriginField, originFieldNotEqualValue, { + projection: subscriptionFields, + }); + + void cursor.forEach((subscription) => { + void api.broadcast('watch.subscriptions', { clientAction, subscription }); + }); + }, +); + +export const notifyOnSubscriptionChangedByRoomId = withDbWatcherCheck( + async (rid: ISubscription['rid'], clientAction: Exclude = 'updated'): Promise => { + const cursor = Subscriptions.findByRoomId(rid, { projection: subscriptionFields }); + + void cursor.forEach((subscription) => { + void api.broadcast('watch.subscriptions', { clientAction, subscription }); + }); + }, +); + +export const notifyOnSubscriptionChangedByAutoTranslateAndUserId = withDbWatcherCheck( + async (uid: ISubscription['u']['_id'], clientAction: Exclude = 'updated'): Promise => { + const cursor = Subscriptions.findByAutoTranslateAndUserId(uid, true, { projection: subscriptionFields }); + + void cursor.forEach((subscription) => { + void api.broadcast('watch.subscriptions', { clientAction, subscription }); + }); + }, +); + +export const notifyOnSubscriptionChangedByUserIdAndRoomType = withDbWatcherCheck( + async ( + uid: ISubscription['u']['_id'], + t: ISubscription['t'], + clientAction: Exclude = 'updated', + ): Promise => { + const cursor = Subscriptions.findByUserIdAndRoomType(uid, t, { projection: subscriptionFields }); + + void cursor.forEach((subscription) => { + void api.broadcast('watch.subscriptions', { clientAction, subscription }); + }); + }, +); + +export const notifyOnSubscriptionChangedByNameAndRoomType = withDbWatcherCheck( + async (filter: Partial>, clientAction: Exclude = 'updated'): Promise => { + const cursor = Subscriptions.findByNameAndRoomType(filter, { projection: subscriptionFields }); + + void cursor.forEach((subscription) => { + void api.broadcast('watch.subscriptions', { clientAction, subscription }); + }); + }, +); + +export const notifyOnSubscriptionChangedByUserId = withDbWatcherCheck( + async (uid: ISubscription['u']['_id'], clientAction: Exclude = 'updated'): Promise => { + const cursor = Subscriptions.findByUserId(uid, { projection: subscriptionFields }); + + void cursor.forEach((subscription) => { + void api.broadcast('watch.subscriptions', { clientAction, subscription }); + }); + }, +); + +export const notifyOnSubscriptionChangedByRoomIdAndUserIds = withDbWatcherCheck( + async ( + rid: ISubscription['rid'], + uids: ISubscription['u']['_id'][], + clientAction: Exclude = 'updated', + ): Promise => { + const cursor = Subscriptions.findByRoomIdAndUserIds(rid, uids, { projection: subscriptionFields }); + + void cursor.forEach((subscription) => { + void api.broadcast('watch.subscriptions', { clientAction, subscription }); + }); + }, +); diff --git a/apps/meteor/app/lib/server/lib/notifyUsersOnMessage.ts b/apps/meteor/app/lib/server/lib/notifyUsersOnMessage.ts index 76a18eba3362..7551cabb6e63 100644 --- a/apps/meteor/app/lib/server/lib/notifyUsersOnMessage.ts +++ b/apps/meteor/app/lib/server/lib/notifyUsersOnMessage.ts @@ -1,11 +1,17 @@ import type { IMessage, IRoom, IUser, RoomType } from '@rocket.chat/core-typings'; import { isEditedMessage } from '@rocket.chat/core-typings'; +import type { Updater } from '@rocket.chat/models'; import { Subscriptions, Rooms } from '@rocket.chat/models'; import { escapeRegExp } from '@rocket.chat/string-helpers'; import moment from 'moment'; import { callbacks } from '../../../../lib/callbacks'; import { settings } from '../../../settings/server'; +import { + notifyOnSubscriptionChanged, + notifyOnSubscriptionChangedByRoomIdAndUserId, + notifyOnSubscriptionChangedByRoomIdAndUserIds, +} from './notifyListener'; function messageContainsHighlight(message: IMessage, highlights: string[]): boolean { if (!highlights || highlights.length === 0) return false; @@ -50,26 +56,14 @@ export async function getMentions(message: IMessage): Promise<{ toAll: boolean; type UnreadCountType = 'all_messages' | 'user_mentions_only' | 'group_mentions_only' | 'user_and_group_mentions_only'; -const incGroupMentions = async ( - rid: IRoom['_id'], - roomType: RoomType, - excludeUserId: IUser['_id'], - unreadCount: Exclude, -): Promise => { +const getGroupMentions = (roomType: RoomType, unreadCount: Exclude): number => { const incUnreadByGroup = ['all_messages', 'group_mentions_only', 'user_and_group_mentions_only'].includes(unreadCount); - const incUnread = roomType === 'd' || roomType === 'l' || incUnreadByGroup ? 1 : 0; - await Subscriptions.incGroupMentionsAndUnreadForRoomIdExcludingUserId(rid, excludeUserId, 1, incUnread); + return roomType === 'd' || roomType === 'l' || incUnreadByGroup ? 1 : 0; }; -const incUserMentions = async ( - rid: IRoom['_id'], - roomType: RoomType, - uids: IUser['_id'][], - unreadCount: Exclude, -): Promise => { - const incUnreadByUser = new Set(['all_messages', 'user_mentions_only', 'user_and_group_mentions_only']).has(unreadCount); - const incUnread = roomType === 'd' || roomType === 'l' || incUnreadByUser ? 1 : 0; - await Subscriptions.incUserMentionsAndUnreadForRoomIdAndUserIds(rid, uids, 1, incUnread); +const getUserMentions = (roomType: RoomType, unreadCount: Exclude): number => { + const incUnreadByUser = ['all_messages', 'user_mentions_only', 'user_and_group_mentions_only'].includes(unreadCount); + return roomType === 'd' || roomType === 'l' || incUnreadByUser ? 1 : 0; }; export const getUserIdsFromHighlights = async (rid: IRoom['_id'], message: IMessage): Promise => { @@ -100,53 +94,87 @@ const getUnreadSettingCount = (roomType: RoomType): UnreadCountType => { }; async function updateUsersSubscriptions(message: IMessage, room: IRoom): Promise { - // Don't increase unread counter on thread messages - if (room != null && !message.tmid) { - const { toAll, toHere, mentionIds } = await getMentions(message); - - const userIds = new Set(mentionIds); - - const unreadCount = getUnreadSettingCount(room.t); + if (!room || message.tmid) { + return; + } - (await getUserIdsFromHighlights(room._id, message)).forEach((uid) => userIds.add(uid)); + const [mentions, highlightIds] = await Promise.all([getMentions(message), getUserIdsFromHighlights(room._id, message)]); + + const { toAll, toHere, mentionIds } = mentions; + const userIds = [...new Set([...mentionIds, ...highlightIds])]; + const unreadCount = getUnreadSettingCount(room.t); + + const userMentionInc = getUserMentions(room.t, unreadCount as Exclude); + const groupMentionInc = getGroupMentions(room.t, unreadCount as Exclude); + + void Subscriptions.findByRoomIdAndNotAlertOrOpenExcludingUserIds({ + roomId: room._id, + uidsExclude: [message.u._id], + uidsInclude: userIds, + onlyRead: !toAll && !toHere, + }).forEach((sub) => { + const hasUserMention = userIds.includes(sub.u._id); + const shouldIncUnread = hasUserMention || toAll || toHere || unreadCount === 'all_messages'; + void notifyOnSubscriptionChanged( + { + ...sub, + alert: true, + open: true, + ...(shouldIncUnread && { unread: sub.unread + 1 }), + ...(hasUserMention && { userMentions: sub.userMentions + 1 }), + ...((toAll || toHere) && { groupMentions: sub.groupMentions + 1 }), + }, + 'updated', + ); + }); - // Give priority to user mentions over group mentions - if (userIds.size > 0) { - await incUserMentions(room._id, room.t, [...userIds], unreadCount as Exclude); - } else if (toAll || toHere) { - await incGroupMentions(room._id, room.t, message.u._id, unreadCount as Exclude); - } + // Give priority to user mentions over group mentions + if (userIds.length) { + await Subscriptions.incUserMentionsAndUnreadForRoomIdAndUserIds(room._id, userIds, 1, userMentionInc); + } else if (toAll || toHere) { + await Subscriptions.incGroupMentionsAndUnreadForRoomIdExcludingUserId(room._id, message.u._id, 1, groupMentionInc); + } - // this shouldn't run only if has group mentions because it will already exclude mentioned users from the query - if (!toAll && !toHere && unreadCount === 'all_messages') { - await Subscriptions.incUnreadForRoomIdExcludingUserIds(room._id, [...userIds, message.u._id], 1); - } + if (!toAll && !toHere && unreadCount === 'all_messages') { + await Subscriptions.incUnreadForRoomIdExcludingUserIds(room._id, [...userIds, message.u._id], 1); } - // Update all other subscriptions to alert their owners but without incrementing - // the unread counter, as it is only for mentions and direct messages - // We now set alert and open properties in two separate update commands. This proved to be more efficient on MongoDB - because it uses a more efficient index. + // update subscriptions of other members of the room await Promise.all([ Subscriptions.setAlertForRoomIdExcludingUserId(message.rid, message.u._id), Subscriptions.setOpenForRoomIdExcludingUserId(message.rid, message.u._id), ]); + + // update subscription of the message sender + await Subscriptions.setAsReadByRoomIdAndUserId(message.rid, message.u._id); + const setAsReadResponse = await Subscriptions.setAsReadByRoomIdAndUserId(message.rid, message.u._id); + if (setAsReadResponse.modifiedCount) { + void notifyOnSubscriptionChangedByRoomIdAndUserId(message.rid, message.u._id); + } } export async function updateThreadUsersSubscriptions(message: IMessage, replies: IUser['_id'][]): Promise { // Don't increase unread counter on thread messages - - await Subscriptions.setAlertForRoomIdAndUserIds(message.rid, replies); const repliesPlusSender = [...new Set([message.u._id, ...replies])]; - await Subscriptions.setOpenForRoomIdAndUserIds(message.rid, repliesPlusSender); - await Subscriptions.setLastReplyForRoomIdAndUserIds(message.rid, repliesPlusSender, new Date()); + + const responses = await Promise.all([ + Subscriptions.setAlertForRoomIdAndUserIds(message.rid, replies), + Subscriptions.setOpenForRoomIdAndUserIds(message.rid, repliesPlusSender), + Subscriptions.setLastReplyForRoomIdAndUserIds(message.rid, repliesPlusSender, new Date()), + ]); + + responses.some((response) => response?.modifiedCount) && + void notifyOnSubscriptionChangedByRoomIdAndUserIds(message.rid, repliesPlusSender); } -export async function notifyUsersOnMessage(message: IMessage, room: IRoom): Promise { +export async function notifyUsersOnMessage(message: IMessage, room: IRoom, roomUpdater: Updater): Promise { + console.log('notifyUsersOnMessage function'); + // Skips this callback if the message was edited and increments it if the edit was way in the past (aka imported) if (isEditedMessage(message)) { if (Math.abs(moment(message.editedAt).diff(Date.now())) > 60000) { // TODO: Review as I am not sure how else to get around this as the incrementing of the msgs count shouldn't be in this callback - await Rooms.incMsgCountById(message.rid, 1); + Rooms.getIncMsgCountUpdateQuery(1, roomUpdater); return message; } @@ -156,25 +184,39 @@ export async function notifyUsersOnMessage(message: IMessage, room: IRoom): Prom (!message.tmid || message.tshow) && (!room.lastMessage || room.lastMessage._id === message._id) ) { - await Rooms.setLastMessageById(message.rid, message); + Rooms.getLastMessageUpdateQuery(message, roomUpdater); } return message; } if (message.ts && Math.abs(moment(message.ts).diff(Date.now())) > 60000) { - await Rooms.incMsgCountById(message.rid, 1); + Rooms.getIncMsgCountUpdateQuery(1, roomUpdater); return message; } // If message sent ONLY on a thread, skips the rest as it is done on a callback specific to threads if (message.tmid && !message.tshow) { - await Rooms.incMsgCountById(message.rid, 1); + Rooms.getIncMsgCountUpdateQuery(1, roomUpdater); return message; } // Update all the room activity tracker fields - await Rooms.incMsgCountAndSetLastMessageById(message.rid, 1, message.ts, settings.get('Store_Last_Message') ? message : undefined); + Rooms.setIncMsgCountAndSetLastMessageUpdateQuery(1, message, !!settings.get('Store_Last_Message'), roomUpdater); + await updateUsersSubscriptions(message, room); + + return message; +} + +export async function notifyUsersOnSystemMessage(message: IMessage, room: IRoom): Promise { + const roomUpdater = Rooms.getUpdater(); + Rooms.setIncMsgCountAndSetLastMessageUpdateQuery(1, message, !!settings.get('Store_Last_Message'), roomUpdater); + + if (roomUpdater.hasChanges()) { + await Rooms.updateFromUpdater({ _id: room._id }, roomUpdater); + } + + // TODO: Rewrite to use just needed calls from the function await updateUsersSubscriptions(message, room); return message; @@ -182,7 +224,15 @@ export async function notifyUsersOnMessage(message: IMessage, room: IRoom): Prom callbacks.add( 'afterSaveMessage', - (message, room) => notifyUsersOnMessage(message, room), + async (message, { room, roomUpdater }) => { + if (!roomUpdater) { + return message; + } + + await notifyUsersOnMessage(message, room, roomUpdater); + + return message; + }, callbacks.priority.MEDIUM, 'notifyUsersOnMessage', ); diff --git a/apps/meteor/app/lib/server/lib/sendNotificationsOnMessage.ts b/apps/meteor/app/lib/server/lib/sendNotificationsOnMessage.ts index 49fcc0ea4725..94c25f476222 100644 --- a/apps/meteor/app/lib/server/lib/sendNotificationsOnMessage.ts +++ b/apps/meteor/app/lib/server/lib/sendNotificationsOnMessage.ts @@ -406,7 +406,7 @@ settings.watch('Troubleshoot_Disable_Notifications', (value) => { callbacks.add( 'afterSaveMessage', - (message, room) => sendAllNotifications(message, room), + (message, { room }) => sendAllNotifications(message, room), callbacks.priority.LOW, 'sendNotificationsOnMessage', ); diff --git a/apps/meteor/app/lib/server/methods/blockUser.ts b/apps/meteor/app/lib/server/methods/blockUser.ts index b967e35d7bc1..7fe6ec803dd1 100644 --- a/apps/meteor/app/lib/server/methods/blockUser.ts +++ b/apps/meteor/app/lib/server/methods/blockUser.ts @@ -5,6 +5,7 @@ import { Meteor } from 'meteor/meteor'; import { RoomMemberActions } from '../../../../definition/IRoomTypeConfig'; import { roomCoordinator } from '../../../../server/lib/rooms/roomCoordinator'; +import { notifyOnSubscriptionChangedByRoomIdAndUserIds } from '../lib/notifyListener'; declare module '@rocket.chat/ddp-client' { // eslint-disable-next-line @typescript-eslint/naming-convention @@ -33,14 +34,22 @@ Meteor.methods({ throw new Meteor.Error('error-invalid-room', 'Invalid room', { method: 'blockUser' }); } - const subscription = await Subscriptions.findOneByRoomIdAndUserId(rid, userId); - const subscription2 = await Subscriptions.findOneByRoomIdAndUserId(rid, blocked); + const [blockedUser, blockerUser] = await Promise.all([ + Subscriptions.findOneByRoomIdAndUserId(rid, blocked, { projection: { _id: 1 } }), + Subscriptions.findOneByRoomIdAndUserId(rid, userId, { projection: { _id: 1 } }), + ]); - if (!subscription || !subscription2) { + if (!blockedUser || !blockerUser) { throw new Meteor.Error('error-invalid-room', 'Invalid room', { method: 'blockUser' }); } - await Subscriptions.setBlockedByRoomId(rid, blocked, userId); + const [blockedResponse, blockerResponse] = await Subscriptions.setBlockedByRoomId(rid, blocked, userId); + + const listenerUsers = [...(blockedResponse?.modifiedCount ? [blocked] : []), ...(blockerResponse?.modifiedCount ? [userId] : [])]; + + if (listenerUsers.length) { + void notifyOnSubscriptionChangedByRoomIdAndUserIds(rid, listenerUsers); + } return true; }, diff --git a/apps/meteor/app/lib/server/methods/checkFederationConfiguration.ts b/apps/meteor/app/lib/server/methods/checkFederationConfiguration.ts new file mode 100644 index 000000000000..e32f2ab5d7af --- /dev/null +++ b/apps/meteor/app/lib/server/methods/checkFederationConfiguration.ts @@ -0,0 +1,80 @@ +import { Federation, FederationEE, Authorization } from '@rocket.chat/core-services'; +import type { ServerMethods } from '@rocket.chat/ddp-client'; +import { License } from '@rocket.chat/license'; +import { Meteor } from 'meteor/meteor'; + +declare module '@rocket.chat/ddp-client' { + // eslint-disable-next-line @typescript-eslint/naming-convention + interface ServerMethods { + checkFederationConfiguration(): Promise<{ message: string }>; + } +} + +Meteor.methods({ + async checkFederationConfiguration() { + const uid = Meteor.userId(); + + if (!uid) { + throw new Meteor.Error('error-invalid-user', 'Invalid user', { + method: 'checkFederationConfiguration', + }); + } + + if (!(await Authorization.hasPermission(uid, 'view-privileged-setting'))) { + throw new Meteor.Error('error-not-allowed', 'Action not allowed', { + method: 'checkFederationConfiguration', + }); + } + + const errors: string[] = []; + + const successes: string[] = []; + + const service = License.hasValidLicense() ? FederationEE : Federation; + + const status = await service.configurationStatus(); + + if (status.externalReachability.ok) { + successes.push('homeserver configuration looks good'); + } else { + let err = 'external reachability could not be verified'; + + const { error } = status.externalReachability; + if (error) { + err += `, error: ${error}`; + } + + errors.push(err); + } + + const { + roundTrip: { durationMs: duration }, + } = status.appservice; + + if (status.appservice.ok) { + successes.push(`appservice configuration looks good, total round trip time to homeserver ${duration}ms`); + } else { + errors.push(`failed to verify appservice configuration: ${status.appservice.error}`); + } + + if (errors.length) { + void service.markConfigurationInvalid(); + + if (successes.length) { + const message = ['Configuration could only be partially verified'].concat(successes).concat(errors).join(', '); + + throw new Meteor.Error('error-invalid-configuration', message, { method: 'checkFederationConfiguration' }); + } + + throw new Meteor.Error('error-invalid-configuration', ['Invalid configuration'].concat(errors).join(', '), { + method: 'checkFederationConfiguration', + }); + } + + void service.markConfigurationValid(); + + return { + message: ['All configuration looks good'].concat(successes).join(', '), + }; + }, +}); diff --git a/apps/meteor/app/lib/server/methods/unblockUser.ts b/apps/meteor/app/lib/server/methods/unblockUser.ts index 2eec5a082109..7b4bc5660010 100644 --- a/apps/meteor/app/lib/server/methods/unblockUser.ts +++ b/apps/meteor/app/lib/server/methods/unblockUser.ts @@ -3,6 +3,8 @@ import { Subscriptions } from '@rocket.chat/models'; import { check } from 'meteor/check'; import { Meteor } from 'meteor/meteor'; +import { notifyOnSubscriptionChangedByRoomIdAndUserIds } from '../lib/notifyListener'; + declare module '@rocket.chat/ddp-client' { // eslint-disable-next-line @typescript-eslint/naming-convention interface ServerMethods { @@ -20,14 +22,22 @@ Meteor.methods({ throw new Meteor.Error('error-invalid-user', 'Invalid user', { method: 'blockUser' }); } - const subscription = await Subscriptions.findOneByRoomIdAndUserId(rid, userId); - const subscription2 = await Subscriptions.findOneByRoomIdAndUserId(rid, blocked); + const [blockedUser, blockerUser] = await Promise.all([ + Subscriptions.findOneByRoomIdAndUserId(rid, blocked, { projection: { _id: 1 } }), + Subscriptions.findOneByRoomIdAndUserId(rid, userId, { projection: { _id: 1 } }), + ]); - if (!subscription || !subscription2) { + if (!blockedUser || !blockerUser) { throw new Meteor.Error('error-invalid-room', 'Invalid room', { method: 'blockUser' }); } - await Subscriptions.unsetBlockedByRoomId(rid, blocked, userId); + const [blockedResponse, blockerResponse] = await Subscriptions.unsetBlockedByRoomId(rid, blocked, userId); + + const listenerUsers = [...(blockedResponse?.modifiedCount ? [blocked] : []), ...(blockerResponse?.modifiedCount ? [userId] : [])]; + + if (listenerUsers.length) { + void notifyOnSubscriptionChangedByRoomIdAndUserIds(rid, listenerUsers); + } return true; }, diff --git a/apps/meteor/app/lib/server/methods/updateMessage.ts b/apps/meteor/app/lib/server/methods/updateMessage.ts index aff5e563f666..8cebe563cd23 100644 --- a/apps/meteor/app/lib/server/methods/updateMessage.ts +++ b/apps/meteor/app/lib/server/methods/updateMessage.ts @@ -12,7 +12,11 @@ import { updateMessage } from '../functions/updateMessage'; const allowedEditedFields = ['tshow', 'alias', 'attachments', 'avatar', 'emoji', 'msg', 'customFields', 'content']; -export async function executeUpdateMessage(uid: IUser['_id'], message: AtLeast, previewUrls?: string[]) { +export async function executeUpdateMessage( + uid: IUser['_id'], + message: AtLeast, + previewUrls?: string[], +) { const originalMessage = await Messages.findOneById(message._id); if (!originalMessage?._id) { return; @@ -26,8 +30,11 @@ export async function executeUpdateMessage(uid: IUser['_id'], message: AtLeast { + async (message, { room }) => { // TODO: check if I need to test this 60 second rule. // If the message was edited, or is older than 60 seconds (imported) // the notifications will be skipped, so we can also skip this validation diff --git a/apps/meteor/app/livechat/client/lib/chartHandler.ts b/apps/meteor/app/livechat/client/lib/chartHandler.ts index 19c1a004ca22..da2d4be3735c 100644 --- a/apps/meteor/app/livechat/client/lib/chartHandler.ts +++ b/apps/meteor/app/livechat/client/lib/chartHandler.ts @@ -177,10 +177,9 @@ export const drawDoughnutChart = async ( chartContext: { destroy: () => void } | undefined, dataLabels: string[], dataPoints: number[], -): Promise | void> => { +): Promise => { if (!chart) { - console.error('No chart element'); - return; + throw new Error('No chart element'); } if (chartContext) { chartContext.destroy(); @@ -200,7 +199,7 @@ export const drawDoughnutChart = async ( ], }, options: doughnutChartConfiguration(title), - }); + }) as ChartType; }; /** @@ -209,12 +208,12 @@ export const drawDoughnutChart = async ( * @param {String} label [chart label] * @param {Array(Double)} data [updated data] */ -export const updateChart = async (c: ChartType, label: string, data: { [x: string]: number }): Promise => { +export const updateChart = async (c: ChartType, label: string, data: number[]): Promise => { const chart = await c; if (chart.data?.labels?.indexOf(label) === -1) { // insert data chart.data.labels.push(label); - chart.data.datasets.forEach((dataset: { data: any[] }, idx: string | number) => { + chart.data.datasets.forEach((dataset: { data: any[] }, idx: number) => { dataset.data.push(data[idx]); }); } else { @@ -224,7 +223,7 @@ export const updateChart = async (c: ChartType, label: string, data: { [x: strin return; } - chart.data.datasets.forEach((dataset: { data: { [x: string]: any } }, idx: string | number) => { + chart.data.datasets.forEach((dataset: { data: { [x: string]: any } }, idx: number) => { dataset.data[index] = data[idx]; }); } diff --git a/apps/meteor/app/livechat/server/api/lib/agents.ts b/apps/meteor/app/livechat/server/api/lib/agents.ts index 3bc5180c2f59..2dbcce8c7e2e 100644 --- a/apps/meteor/app/livechat/server/api/lib/agents.ts +++ b/apps/meteor/app/livechat/server/api/lib/agents.ts @@ -7,14 +7,8 @@ export async function findAgentDepartments({ }: { enabledDepartmentsOnly?: boolean; agentId: string; -}): Promise<{ departments: ILivechatDepartmentAgents[] }> { - if (enabledDepartmentsOnly) { - return { - departments: await LivechatDepartmentAgents.findActiveDepartmentsByAgentId(agentId).toArray(), - }; - } - +}): Promise<{ departments: (ILivechatDepartmentAgents & { departmentName: string })[] }> { return { - departments: await LivechatDepartmentAgents.find({ agentId }).toArray(), + departments: await LivechatDepartmentAgents.findDepartmentsOfAgent(agentId, enabledDepartmentsOnly).toArray(), }; } diff --git a/apps/meteor/app/livechat/server/api/lib/livechat.ts b/apps/meteor/app/livechat/server/api/lib/livechat.ts index 617d255cb6cb..a922edd40899 100644 --- a/apps/meteor/app/livechat/server/api/lib/livechat.ts +++ b/apps/meteor/app/livechat/server/api/lib/livechat.ts @@ -61,6 +61,10 @@ export function findGuest(token: string): Promise { }); } +export function findGuestWithoutActivity(token: string): Promise { + return LivechatVisitors.getVisitorByToken(token, { projection: { name: 1, username: 1, token: 1, visitorEmails: 1, department: 1 } }); +} + export async function findRoom(token: string, rid?: string): Promise { const fields = { t: 1, diff --git a/apps/meteor/app/livechat/server/api/v1/agent.ts b/apps/meteor/app/livechat/server/api/v1/agent.ts index 4c3cad33c130..abc6163fe9c9 100644 --- a/apps/meteor/app/livechat/server/api/v1/agent.ts +++ b/apps/meteor/app/livechat/server/api/v1/agent.ts @@ -6,6 +6,7 @@ import { isGETAgentNextToken, isPOSTLivechatAgentStatusProps } from '@rocket.cha import { API } from '../../../../api/server'; import { hasPermissionAsync } from '../../../../authorization/server/functions/hasPermission'; import { Livechat as LivechatTyped } from '../../lib/LivechatTyped'; +import { RoutingManager } from '../../lib/RoutingManager'; import { findRoom, findGuest, findAgent, findOpenRoom } from '../lib/livechat'; API.v1.addRoute('livechat/agent.info/:rid/:token', { @@ -48,7 +49,7 @@ API.v1.addRoute( } } - const agentData = await LivechatTyped.getNextAgent(department); + const agentData = await RoutingManager.getNextAgent(department); if (!agentData) { throw new Error('agent-not-found'); } diff --git a/apps/meteor/app/livechat/server/api/v1/config.ts b/apps/meteor/app/livechat/server/api/v1/config.ts index 79a6132136c5..17a2945e75de 100644 --- a/apps/meteor/app/livechat/server/api/v1/config.ts +++ b/apps/meteor/app/livechat/server/api/v1/config.ts @@ -2,8 +2,9 @@ import { isGETLivechatConfigParams } from '@rocket.chat/rest-typings'; import mem from 'mem'; import { API } from '../../../../api/server'; +import { settings as serverSettings } from '../../../../settings/server'; import { Livechat } from '../../lib/LivechatTyped'; -import { settings, findOpenRoom, getExtraConfigInfo, findAgent } from '../lib/livechat'; +import { settings, findOpenRoom, getExtraConfigInfo, findAgent, findGuestWithoutActivity } from '../lib/livechat'; const cachedSettings = mem(settings, { maxAge: process.env.TEST_MODE === 'true' ? 1 : 1000, cacheKey: JSON.stringify }); @@ -12,7 +13,7 @@ API.v1.addRoute( { validateParams: isGETLivechatConfigParams }, { async get() { - const enabled = Livechat.enabled(); + const enabled = serverSettings.get('Livechat_enabled'); if (!enabled) { return API.v1.success({ config: { enabled: false } }); @@ -23,7 +24,7 @@ API.v1.addRoute( const config = await cachedSettings({ businessUnit }); const status = await Livechat.online(department); - const guest = token ? await Livechat.findGuest(token) : null; + const guest = token ? await findGuestWithoutActivity(token) : null; const room = guest ? await findOpenRoom(guest.token) : undefined; const agent = guest && room && room.servedBy && (await findAgent(room.servedBy._id)); diff --git a/apps/meteor/app/livechat/server/api/v1/contact.ts b/apps/meteor/app/livechat/server/api/v1/contact.ts index 57c1d117f1b0..91b18a6b21af 100644 --- a/apps/meteor/app/livechat/server/api/v1/contact.ts +++ b/apps/meteor/app/livechat/server/api/v1/contact.ts @@ -1,14 +1,18 @@ import { LivechatCustomField, LivechatVisitors } from '@rocket.chat/models'; +import { isPOSTOmnichannelContactsProps } from '@rocket.chat/rest-typings'; import { escapeRegExp } from '@rocket.chat/string-helpers'; import { Match, check } from 'meteor/check'; import { Meteor } from 'meteor/meteor'; import { API } from '../../../../api/server'; -import { Contacts } from '../../lib/Contacts'; +import { Contacts, createContact } from '../../lib/Contacts'; API.v1.addRoute( 'omnichannel/contact', - { authRequired: true, permissionsRequired: ['view-l-room'] }, + { + authRequired: true, + permissionsRequired: ['view-l-room'], + }, { async post() { check(this.bodyParams, { @@ -82,3 +86,18 @@ API.v1.addRoute( }, }, ); + +API.v1.addRoute( + 'omnichannel/contacts', + { authRequired: true, permissionsRequired: ['create-livechat-contact'], validateParams: isPOSTOmnichannelContactsProps }, + { + async post() { + if (!process.env.TEST_MODE) { + throw new Meteor.Error('error-not-allowed', 'This endpoint is only allowed in test mode'); + } + const contactId = await createContact({ ...this.bodyParams, unknown: false }); + + return API.v1.success({ contactId }); + }, + }, +); diff --git a/apps/meteor/app/livechat/server/api/v1/pageVisited.ts b/apps/meteor/app/livechat/server/api/v1/pageVisited.ts index e89a3e17f0a1..2688ad673af0 100644 --- a/apps/meteor/app/livechat/server/api/v1/pageVisited.ts +++ b/apps/meteor/app/livechat/server/api/v1/pageVisited.ts @@ -1,5 +1,4 @@ import type { IOmnichannelSystemMessage } from '@rocket.chat/core-typings'; -import { Messages } from '@rocket.chat/models'; import { isPOSTLivechatPageVisitedParams } from '@rocket.chat/rest-typings'; import { API } from '../../../../api/server'; @@ -11,17 +10,13 @@ API.v1.addRoute( { async post() { const { token, rid, pageInfo } = this.bodyParams; - const msgId = await Livechat.savePageHistory(token, rid, pageInfo); - if (!msgId) { - return API.v1.success(); - } - const message = await Messages.findOneById(msgId); + const message = await Livechat.savePageHistory(token, rid, pageInfo); if (!message) { return API.v1.success(); } - const { msg, navigation } = message; + const { msg, navigation } = message as IOmnichannelSystemMessage; return API.v1.success({ page: { msg, navigation } }); }, }, diff --git a/apps/meteor/app/livechat/server/api/v1/transcript.ts b/apps/meteor/app/livechat/server/api/v1/transcript.ts index b36873a6ac27..e46e841628f1 100644 --- a/apps/meteor/app/livechat/server/api/v1/transcript.ts +++ b/apps/meteor/app/livechat/server/api/v1/transcript.ts @@ -6,6 +6,7 @@ import { isPOSTLivechatTranscriptParams, isPOSTLivechatTranscriptRequestParams } import { i18n } from '../../../../../server/lib/i18n'; import { API } from '../../../../api/server'; import { Livechat } from '../../lib/LivechatTyped'; +import { sendTranscript } from '../../lib/sendTranscript'; API.v1.addRoute( 'livechat/transcript', @@ -13,7 +14,7 @@ API.v1.addRoute( { async post() { const { token, rid, email } = this.bodyParams; - if (!(await Livechat.sendTranscript({ token, rid, email }))) { + if (!(await sendTranscript({ token, rid, email }))) { return API.v1.failure({ message: i18n.t('Error_sending_livechat_transcript') }); } diff --git a/apps/meteor/app/livechat/server/hooks/afterSaveOmnichannelMessage.ts b/apps/meteor/app/livechat/server/hooks/afterSaveOmnichannelMessage.ts index 372704d339bb..311343c4ad01 100644 --- a/apps/meteor/app/livechat/server/hooks/afterSaveOmnichannelMessage.ts +++ b/apps/meteor/app/livechat/server/hooks/afterSaveOmnichannelMessage.ts @@ -5,7 +5,7 @@ import { callbacks } from '../../../../lib/callbacks'; callbacks.add( 'afterSaveMessage', - async (message, room) => { + async (message, { room }) => { if (!isOmnichannelRoom(room)) { return message; } @@ -14,7 +14,7 @@ callbacks.add( const result = await callbacks.run('afterOmnichannelSaveMessage', message, { room, roomUpdater: updater }); if (updater.hasChanges()) { - await updater.persist({ _id: room._id }); + await LivechatRooms.updateFromUpdater({ _id: room._id }, updater); } return result; diff --git a/apps/meteor/app/livechat/server/hooks/markRoomNotResponded.ts b/apps/meteor/app/livechat/server/hooks/markRoomNotResponded.ts index 23131cee60a2..01d3014f1c27 100644 --- a/apps/meteor/app/livechat/server/hooks/markRoomNotResponded.ts +++ b/apps/meteor/app/livechat/server/hooks/markRoomNotResponded.ts @@ -5,7 +5,7 @@ import { callbacks } from '../../../../lib/callbacks'; callbacks.add( 'afterOmnichannelSaveMessage', - async (message, { room }) => { + (message, { room, roomUpdater }) => { // skips this callback if the message was edited if (!message || isEditedMessage(message)) { return message; @@ -21,7 +21,7 @@ callbacks.add( return message; } - await LivechatRooms.setNotResponseByRoomId(room._id); + LivechatRooms.getNotResponseByRoomIdUpdateQuery(roomUpdater); return message; }, diff --git a/apps/meteor/app/livechat/server/hooks/markRoomResponded.ts b/apps/meteor/app/livechat/server/hooks/markRoomResponded.ts index 3e9164554d47..6820bd4664bd 100644 --- a/apps/meteor/app/livechat/server/hooks/markRoomResponded.ts +++ b/apps/meteor/app/livechat/server/hooks/markRoomResponded.ts @@ -1,74 +1,72 @@ -import type { IOmnichannelRoom } from '@rocket.chat/core-typings'; -import { isEditedMessage } from '@rocket.chat/core-typings'; +import type { IOmnichannelRoom, IMessage } from '@rocket.chat/core-typings'; +import { isEditedMessage, isMessageFromVisitor, isSystemMessage } from '@rocket.chat/core-typings'; +import type { Updater } from '@rocket.chat/models'; import { LivechatRooms, LivechatVisitors, LivechatInquiry } from '@rocket.chat/models'; import moment from 'moment'; import { callbacks } from '../../../../lib/callbacks'; import { notifyOnLivechatInquiryChanged } from '../../../lib/server/lib/notifyListener'; -callbacks.add( - 'afterOmnichannelSaveMessage', - async (message, { room }) => { - // skips this callback if the message was edited - if (!message || isEditedMessage(message)) { - return message; - } +export async function markRoomResponded( + message: IMessage, + room: IOmnichannelRoom, + roomUpdater: Updater, +): Promise { + if (isSystemMessage(message) || isEditedMessage(message) || isMessageFromVisitor(message)) { + return; + } - // skips this callback if the message is a system message - if (message.t) { - return message; - } + const monthYear = moment().format('YYYY-MM'); + const isVisitorActive = await LivechatVisitors.isVisitorActiveOnPeriod(room.v._id, monthYear); - // if the message has a token, it was sent by the visitor, so ignore it - if (message.token) { - return message; - } + // Case: agent answers & visitor is not active, we mark visitor as active + if (!isVisitorActive) { + await LivechatVisitors.markVisitorActiveForPeriod(room.v._id, monthYear); + } - // Return YYYY-MM from moment - const monthYear = moment().format('YYYY-MM'); - const isVisitorActive = await LivechatVisitors.isVisitorActiveOnPeriod(room.v._id, monthYear); + if (!room.v?.activity?.includes(monthYear)) { + LivechatRooms.getVisitorActiveForPeriodUpdateQuery(monthYear, roomUpdater); + const livechatInquiry = await LivechatInquiry.markInquiryActiveForPeriod(room._id, monthYear); - // Case: agent answers & visitor is not active, we mark visitor as active - if (!isVisitorActive) { - await LivechatVisitors.markVisitorActiveForPeriod(room.v._id, monthYear); + if (livechatInquiry) { + void notifyOnLivechatInquiryChanged(livechatInquiry, 'updated', { v: livechatInquiry.v }); } + } - if (!room.v?.activity?.includes(monthYear)) { - const [, livechatInquiry] = await Promise.all([ - LivechatRooms.markVisitorActiveForPeriod(room._id, monthYear), - LivechatInquiry.markInquiryActiveForPeriod(room._id, monthYear), - ]); - if (livechatInquiry) { - void notifyOnLivechatInquiryChanged(livechatInquiry, 'updated', { v: livechatInquiry.v }); - } - } + if (room.responseBy) { + LivechatRooms.getAgentLastMessageTsUpdateQuery(roomUpdater); + } + if (!room.waitingResponse) { + // case where agent sends second message or any subsequent message in a room before visitor responds to the first message + // in this case, we just need to update the lastMessageTs of the responseBy object if (room.responseBy) { - await LivechatRooms.setAgentLastMessageTs(room._id); + LivechatRooms.getAgentLastMessageTsUpdateQuery(roomUpdater); } - // check if room is yet awaiting for response from visitor - if (!room.waitingResponse) { - // case where agent sends second message or any subsequent message in a room before visitor responds to the first message - // in this case, we just need to update the lastMessageTs of the responseBy object - if (room.responseBy) { - await LivechatRooms.setAgentLastMessageTs(room._id); - } - return message; - } + return room.responseBy; + } + + const responseBy: IOmnichannelRoom['responseBy'] = room.responseBy || { + _id: message.u._id, + username: message.u.username, + firstResponseTs: new Date(message.ts), + lastMessageTs: new Date(message.ts), + }; + + LivechatRooms.getResponseByRoomIdUpdateQuery(responseBy, roomUpdater); - // This is the first message from agent after visitor had last responded - const responseBy: IOmnichannelRoom['responseBy'] = room.responseBy || { - _id: message.u._id, - username: message.u.username, - firstResponseTs: new Date(message.ts), - lastMessageTs: new Date(message.ts), - }; + return responseBy; +} - // this unsets waitingResponse and sets responseBy object - await LivechatRooms.setResponseByRoomId(room._id, responseBy); +callbacks.add( + 'afterOmnichannelSaveMessage', + async (message, { room, roomUpdater }) => { + if (!message || isEditedMessage(message) || isMessageFromVisitor(message) || isSystemMessage(message)) { + return; + } - return message; + await markRoomResponded(message, room, roomUpdater); }, callbacks.priority.HIGH, 'markRoomResponded', diff --git a/apps/meteor/app/livechat/server/hooks/processRoomAbandonment.ts b/apps/meteor/app/livechat/server/hooks/processRoomAbandonment.ts index 8a5a4c280670..a6031bd42efa 100644 --- a/apps/meteor/app/livechat/server/hooks/processRoomAbandonment.ts +++ b/apps/meteor/app/livechat/server/hooks/processRoomAbandonment.ts @@ -6,11 +6,12 @@ import moment from 'moment'; import { callbacks } from '../../../../lib/callbacks'; import { settings } from '../../../settings/server'; import { businessHourManager } from '../business-hour'; +import type { CloseRoomParams } from '../lib/localTypes'; -const getSecondsWhenOfficeHoursIsDisabled = (room: IOmnichannelRoom, agentLastMessage: IMessage) => +export const getSecondsWhenOfficeHoursIsDisabled = (room: IOmnichannelRoom, agentLastMessage: IMessage) => moment(new Date(room.closedAt || new Date())).diff(moment(new Date(agentLastMessage.ts)), 'seconds'); -const parseDays = ( +export const parseDays = ( acc: Record, day: IBusinessHourWorkHour, ) => { @@ -22,7 +23,7 @@ const parseDays = ( return acc; }; -const getSecondsSinceLastAgentResponse = async (room: IOmnichannelRoom, agentLastMessage: IMessage) => { +export const getSecondsSinceLastAgentResponse = async (room: IOmnichannelRoom, agentLastMessage: IMessage) => { if (!settings.get('Livechat_enable_business_hours')) { return getSecondsWhenOfficeHoursIsDisabled(room, agentLastMessage); } @@ -43,65 +44,91 @@ const getSecondsSinceLastAgentResponse = async (room: IOmnichannelRoom, agentLas officeDays = (await businessHourManager.getBusinessHour())?.workHours.reduce(parseDays, {}); } - if (!officeDays) { + // Empty object we assume invalid config + if (!officeDays || !Object.keys(officeDays).length) { return getSecondsWhenOfficeHoursIsDisabled(room, agentLastMessage); } let totalSeconds = 0; - const endOfConversation = moment(new Date(room.closedAt || new Date())); - const startOfInactivity = moment(new Date(agentLastMessage.ts)); + const endOfConversation = moment.utc(new Date(room.closedAt || new Date())); + const startOfInactivity = moment.utc(new Date(agentLastMessage.ts)); const daysOfInactivity = endOfConversation.clone().startOf('day').diff(startOfInactivity.clone().startOf('day'), 'days'); - const inactivityDay = moment(new Date(agentLastMessage.ts)); + const inactivityDay = moment.utc(new Date(agentLastMessage.ts)); + for (let index = 0; index <= daysOfInactivity; index++) { const today = inactivityDay.clone().format('dddd'); const officeDay = officeDays[today]; - const startTodaysOfficeHour = moment(`${officeDay.start.day}:${officeDay.start.time}`, 'dddd:HH:mm').add(index, 'days'); - const endTodaysOfficeHour = moment(`${officeDay.finish.day}:${officeDay.finish.time}`, 'dddd:HH:mm').add(index, 'days'); - if (officeDays[today].open) { - const firstDayOfInactivity = startOfInactivity.clone().format('D') === inactivityDay.clone().format('D'); - const lastDayOfInactivity = endOfConversation.clone().format('D') === inactivityDay.clone().format('D'); - - if (!firstDayOfInactivity && !lastDayOfInactivity) { - totalSeconds += endTodaysOfficeHour.clone().diff(startTodaysOfficeHour, 'seconds'); - } else { - const end = endOfConversation.isBefore(endTodaysOfficeHour) ? endOfConversation : endTodaysOfficeHour; - const start = firstDayOfInactivity ? inactivityDay : startTodaysOfficeHour; - totalSeconds += end.clone().diff(start, 'seconds'); - } + if (!officeDay) { + inactivityDay.add(1, 'days'); + continue; + } + if (!officeDay.open) { + inactivityDay.add(1, 'days'); + continue; + } + if (!officeDay?.start?.time || !officeDay?.finish?.time) { + inactivityDay.add(1, 'days'); + continue; } - inactivityDay.add(1, 'days'); - } - return totalSeconds; -}; -callbacks.add( - 'livechat.closeRoom', - async (params) => { - const { room } = params; + const [officeStartHour, officeStartMinute] = officeDay.start.time.split(':'); + const [officeCloseHour, officeCloseMinute] = officeDay.finish.time.split(':'); + // We should only take in consideration the time where the office is open and the conversation was inactive + const todayStartOfficeHours = inactivityDay + .clone() + .set({ hour: parseInt(officeStartHour, 10), minute: parseInt(officeStartMinute, 10) }); + const todayEndOfficeHours = inactivityDay.clone().set({ hour: parseInt(officeCloseHour, 10), minute: parseInt(officeCloseMinute, 10) }); - if (!isOmnichannelRoom(room)) { - return params; + // 1: Room was inactive the whole day, we add the whole time BH is inactive + if (startOfInactivity.isBefore(todayStartOfficeHours) && endOfConversation.isAfter(todayEndOfficeHours)) { + totalSeconds += todayEndOfficeHours.diff(todayStartOfficeHours, 'seconds'); } - const closedByAgent = room.closer !== 'visitor'; - const wasTheLastMessageSentByAgent = room.lastMessage && !room.lastMessage.token; - if (!closedByAgent || !wasTheLastMessageSentByAgent) { - return params; + // 2: Room was inactive before start but was closed before end of BH, we add the inactive time + if (startOfInactivity.isBefore(todayStartOfficeHours) && endOfConversation.isBefore(todayEndOfficeHours)) { + totalSeconds += endOfConversation.diff(todayStartOfficeHours, 'seconds'); } - if (!room.v?.lastMessageTs) { - return params; + // 3: Room was inactive after start and ended after end of BH, we add the inactive time + if (startOfInactivity.isAfter(todayStartOfficeHours) && endOfConversation.isAfter(todayEndOfficeHours)) { + totalSeconds += todayEndOfficeHours.diff(startOfInactivity, 'seconds'); } - const agentLastMessage = await Messages.findAgentLastMessageByVisitorLastMessageTs(room._id, room.v.lastMessageTs); - if (!agentLastMessage) { - return params; + // 4: Room was inactive after start and before end of BH, we add the inactive time + if (startOfInactivity.isAfter(todayStartOfficeHours) && endOfConversation.isBefore(todayEndOfficeHours)) { + totalSeconds += endOfConversation.diff(startOfInactivity, 'seconds'); } - const secondsSinceLastAgentResponse = await getSecondsSinceLastAgentResponse(room, agentLastMessage); - await LivechatRooms.setVisitorInactivityInSecondsById(room._id, secondsSinceLastAgentResponse); + inactivityDay.add(1, 'days'); + } + return totalSeconds; +}; + +export const onCloseRoom = async (params: { room: IOmnichannelRoom; options: CloseRoomParams['options'] }) => { + const { room } = params; + + if (!isOmnichannelRoom(room)) { return params; - }, - callbacks.priority.HIGH, - 'process-room-abandonment', -); + } + + const closedByAgent = room.closer !== 'visitor'; + const wasTheLastMessageSentByAgent = room.lastMessage && !room.lastMessage.token; + if (!closedByAgent || !wasTheLastMessageSentByAgent) { + return params; + } + + if (!room.v?.lastMessageTs) { + return params; + } + + const agentLastMessage = await Messages.findAgentLastMessageByVisitorLastMessageTs(room._id, room.v.lastMessageTs); + if (!agentLastMessage) { + return params; + } + const secondsSinceLastAgentResponse = await getSecondsSinceLastAgentResponse(room, agentLastMessage); + await LivechatRooms.setVisitorInactivityInSecondsById(room._id, secondsSinceLastAgentResponse); + + return params; +}; + +callbacks.add('livechat.closeRoom', onCloseRoom, callbacks.priority.HIGH, 'process-room-abandonment'); diff --git a/apps/meteor/app/livechat/server/hooks/saveAnalyticsData.ts b/apps/meteor/app/livechat/server/hooks/saveAnalyticsData.ts index fef6ad0936f8..9553e9fe981b 100644 --- a/apps/meteor/app/livechat/server/hooks/saveAnalyticsData.ts +++ b/apps/meteor/app/livechat/server/hooks/saveAnalyticsData.ts @@ -1,4 +1,4 @@ -import { isEditedMessage } from '@rocket.chat/core-typings'; +import { isEditedMessage, isMessageFromVisitor, isSystemMessage } from '@rocket.chat/core-typings'; import type { IOmnichannelRoom } from '@rocket.chat/core-typings'; import { LivechatRooms } from '@rocket.chat/models'; @@ -62,7 +62,7 @@ const getAnalyticsData = (room: IOmnichannelRoom, now: Date): Record { - if (!message || isEditedMessage(message)) { + if (!message || isEditedMessage(message) || isSystemMessage(message)) { return message; } @@ -70,8 +70,12 @@ callbacks.add( message = { ...(await normalizeMessageFileUpload(message)), ...{ _updatedAt: message._updatedAt } }; } - const analyticsData = getAnalyticsData(room, new Date()); - await LivechatRooms.getAnalyticsUpdateQueryByRoomId(room, message, analyticsData, roomUpdater); + if (isMessageFromVisitor(message)) { + LivechatRooms.getAnalyticsUpdateQueryBySentByVisitor(room, message, roomUpdater); + } else { + const analyticsData = getAnalyticsData(room, new Date()); + LivechatRooms.getAnalyticsUpdateQueryBySentByAgent(room, message, analyticsData, roomUpdater); + } return message; }, diff --git a/apps/meteor/app/livechat/server/hooks/saveContactLastChat.ts b/apps/meteor/app/livechat/server/hooks/saveContactLastChat.ts index 6f42a910417d..9969f03bf8bb 100644 --- a/apps/meteor/app/livechat/server/hooks/saveContactLastChat.ts +++ b/apps/meteor/app/livechat/server/hooks/saveContactLastChat.ts @@ -1,7 +1,7 @@ import { isOmnichannelRoom } from '@rocket.chat/core-typings'; +import { LivechatVisitors } from '@rocket.chat/models'; import { callbacks } from '../../../../lib/callbacks'; -import { Livechat } from '../lib/LivechatTyped'; callbacks.add( 'livechat.newRoom', @@ -19,7 +19,7 @@ callbacks.add( _id, ts: new Date(), }; - await Livechat.updateLastChat(guestId, lastChat); + await LivechatVisitors.setLastChatById(guestId, lastChat); }, callbacks.priority.MEDIUM, 'livechat-save-last-chat', diff --git a/apps/meteor/app/livechat/server/hooks/sendEmailTranscriptOnClose.ts b/apps/meteor/app/livechat/server/hooks/sendEmailTranscriptOnClose.ts index fcc0e0228bc7..f6a35f4dd7f9 100644 --- a/apps/meteor/app/livechat/server/hooks/sendEmailTranscriptOnClose.ts +++ b/apps/meteor/app/livechat/server/hooks/sendEmailTranscriptOnClose.ts @@ -3,8 +3,8 @@ import { isOmnichannelRoom } from '@rocket.chat/core-typings'; import { LivechatRooms } from '@rocket.chat/models'; import { callbacks } from '../../../../lib/callbacks'; -import { Livechat } from '../lib/LivechatTyped'; import type { CloseRoomParams } from '../lib/LivechatTyped'; +import { sendTranscript } from '../lib/sendTranscript'; type LivechatCloseCallbackParams = { room: IOmnichannelRoom; @@ -30,10 +30,7 @@ const sendEmailTranscriptOnClose = async (params: LivechatCloseCallbackParams): const { email, subject, requestedBy: user } = transcriptData; - await Promise.all([ - Livechat.sendTranscript({ token, rid, email, subject, user }), - LivechatRooms.unsetEmailTranscriptRequestedByRoomId(rid), - ]); + await Promise.all([sendTranscript({ token, rid, email, subject, user }), LivechatRooms.unsetEmailTranscriptRequestedByRoomId(rid)]); delete room.transcriptRequest; diff --git a/apps/meteor/app/livechat/server/lib/AnalyticsTyped.ts b/apps/meteor/app/livechat/server/lib/AnalyticsTyped.ts index 3b7c6a3051bf..c0be707ba212 100644 --- a/apps/meteor/app/livechat/server/lib/AnalyticsTyped.ts +++ b/apps/meteor/app/livechat/server/lib/AnalyticsTyped.ts @@ -1,7 +1,10 @@ import { OmnichannelAnalytics } from '@rocket.chat/core-services'; import mem from 'mem'; -export const getAgentOverviewDataCached = mem(OmnichannelAnalytics.getAgentOverviewData, { maxAge: 60000, cacheKey: JSON.stringify }); +export const getAgentOverviewDataCached = mem(OmnichannelAnalytics.getAgentOverviewData, { + maxAge: process.env.TEST_MODE === 'true' ? 1 : 60000, + cacheKey: JSON.stringify, +}); // Agent overview data on realtime is cached for 5 seconds // while the data on the overview page is cached for 1 minute export const getAnalyticsOverviewDataCached = mem(OmnichannelAnalytics.getAnalyticsOverviewData, { diff --git a/apps/meteor/app/livechat/server/lib/Contacts.ts b/apps/meteor/app/livechat/server/lib/Contacts.ts index 2e648b02f5dd..4f4a33ee61b2 100644 --- a/apps/meteor/app/livechat/server/lib/Contacts.ts +++ b/apps/meteor/app/livechat/server/lib/Contacts.ts @@ -1,12 +1,25 @@ -import type { ILivechatCustomField, ILivechatVisitor, IOmnichannelRoom } from '@rocket.chat/core-typings'; -import { LivechatVisitors, Users, LivechatRooms, LivechatCustomField, LivechatInquiry, Rooms, Subscriptions } from '@rocket.chat/models'; +import type { ILivechatContactChannel, ILivechatCustomField, ILivechatVisitor, IOmnichannelRoom, IUser } from '@rocket.chat/core-typings'; +import { + LivechatVisitors, + Users, + LivechatRooms, + LivechatCustomField, + LivechatInquiry, + Rooms, + Subscriptions, + LivechatContacts, +} from '@rocket.chat/models'; import { check } from 'meteor/check'; import { Meteor } from 'meteor/meteor'; import type { MatchKeysAndValues, OnlyFieldsOfType } from 'mongodb'; import { callbacks } from '../../../../lib/callbacks'; import { trim } from '../../../../lib/utils/stringUtils'; -import { notifyOnRoomChangedById, notifyOnLivechatInquiryChangedByRoom } from '../../../lib/server/lib/notifyListener'; +import { + notifyOnRoomChangedById, + notifyOnSubscriptionChangedByRoomId, + notifyOnLivechatInquiryChangedByRoom, +} from '../../../lib/server/lib/notifyListener'; import { i18n } from '../../../utils/lib/i18n'; type RegisterContactProps = { @@ -22,6 +35,16 @@ type RegisterContactProps = { }; }; +type CreateContactParams = { + name: string; + emails: string[]; + phones: string[]; + unknown: boolean; + customFields?: Record; + contactManager?: string; + channels?: ILivechatContactChannel[]; +}; + export const Contacts = { async registerContact({ token, @@ -138,17 +161,88 @@ export const Contacts = { for await (const room of rooms) { const { _id: rid } = room; - await Promise.all([ + const responses = await Promise.all([ Rooms.setFnameById(rid, name), LivechatInquiry.setNameByRoomId(rid, name), Subscriptions.updateDisplayNameByRoomId(rid, name), ]); - void notifyOnLivechatInquiryChangedByRoom(rid, 'updated', { name }); - void notifyOnRoomChangedById(rid); + if (responses[0]?.modifiedCount) { + void notifyOnRoomChangedById(rid); + } + + if (responses[1]?.modifiedCount) { + void notifyOnLivechatInquiryChangedByRoom(rid, 'updated', { name }); + } + + if (responses[2]?.modifiedCount) { + void notifyOnSubscriptionChangedByRoomId(rid); + } } } return contactId; }, }; + +export async function createContact(params: CreateContactParams): Promise { + const { name, emails, phones, customFields = {}, contactManager, channels, unknown } = params; + + if (contactManager) { + const contactManagerUser = await Users.findOneAgentById>(contactManager, { projection: { roles: 1 } }); + if (!contactManagerUser) { + throw new Error('error-contact-manager-not-found'); + } + } + + const allowedCustomFields = await getAllowedCustomFields(); + validateCustomFields(allowedCustomFields, customFields); + + const { insertedId } = await LivechatContacts.insertOne({ + name, + emails, + phones, + contactManager, + channels, + customFields, + unknown, + }); + + return insertedId; +} + +async function getAllowedCustomFields(): Promise { + return LivechatCustomField.findByScope( + 'visitor', + { + projection: { _id: 1, label: 1, regexp: 1, required: 1 }, + }, + false, + ).toArray(); +} + +export function validateCustomFields(allowedCustomFields: ILivechatCustomField[], customFields: Record) { + for (const cf of allowedCustomFields) { + if (!customFields.hasOwnProperty(cf._id)) { + if (cf.required) { + throw new Error(i18n.t('error-invalid-custom-field-value', { field: cf.label })); + } + continue; + } + const cfValue: string = trim(customFields[cf._id]); + + if (!cfValue || typeof cfValue !== 'string') { + if (cf.required) { + throw new Error(i18n.t('error-invalid-custom-field-value', { field: cf.label })); + } + continue; + } + + if (cf.regexp) { + const regex = new RegExp(cf.regexp); + if (!regex.test(cfValue)) { + throw new Error(i18n.t('error-invalid-custom-field-value', { field: cf.label })); + } + } + } +} diff --git a/apps/meteor/app/livechat/server/lib/Helper.ts b/apps/meteor/app/livechat/server/lib/Helper.ts index c0e85a8c7c2b..17f21d8d7b04 100644 --- a/apps/meteor/app/livechat/server/lib/Helper.ts +++ b/apps/meteor/app/livechat/server/lib/Helper.ts @@ -36,10 +36,12 @@ import { validateEmail as validatorFunc } from '../../../../lib/emailValidator'; import { i18n } from '../../../../server/lib/i18n'; import { hasRoleAsync } from '../../../authorization/server/functions/hasRole'; import { sendNotification } from '../../../lib/server'; -import { sendMessage } from '../../../lib/server/functions/sendMessage'; import { notifyOnLivechatDepartmentAgentChanged, notifyOnLivechatDepartmentAgentChangedByAgentsAndDepartmentId, + notifyOnSubscriptionChangedById, + notifyOnSubscriptionChangedByRoomId, + notifyOnSubscriptionChanged, } from '../../../lib/server/lib/notifyListener'; import { settings } from '../../../settings/server'; import { Livechat as LivechatTyped } from './LivechatTyped'; @@ -141,10 +143,7 @@ export const createLivechatRoom = async < } await callbacks.run('livechat.newRoom', room); - - // TODO: replace with `Message.saveSystemMessage` - - await sendMessage(guest, { t: 'livechat-started', msg: '', groupable: false, token: guest.token }, room); + await Message.saveSystemMessageAndNotifyUser('livechat-started', rid, '', { _id, username }, { groupable: false, token: guest.token }); return result.value as IOmnichannelRoom; }; @@ -289,7 +288,13 @@ export const createLivechatSubscription = async ( ...(department && { department }), } as InsertionModel; - return Subscriptions.insertOne(subscriptionData); + const response = await Subscriptions.insertOne(subscriptionData); + + if (response?.insertedId) { + void notifyOnSubscriptionChangedById(response.insertedId, 'inserted'); + } + + return response; }; export const removeAgentFromSubscription = async (rid: string, { _id, username }: Pick) => { @@ -300,7 +305,11 @@ export const removeAgentFromSubscription = async (rid: string, { _id, username } return; } - await Subscriptions.removeByRoomIdAndUserId(rid, _id); + const deletedSubscription = await Subscriptions.removeByRoomIdAndUserId(rid, _id); + if (deletedSubscription) { + void notifyOnSubscriptionChanged(deletedSubscription, 'removed'); + } + await Message.saveSystemMessage('ul', rid, username || '', { _id: user._id, username: user.username, name: user.name }); setImmediate(() => { @@ -517,12 +526,16 @@ export const updateChatDepartment = async ({ newDepartmentId: string; oldDepartmentId?: string; }) => { - await Promise.all([ + const responses = await Promise.all([ LivechatRooms.changeDepartmentIdByRoomId(rid, newDepartmentId), LivechatInquiry.changeDepartmentIdByRoomId(rid, newDepartmentId), Subscriptions.changeDepartmentByRoomId(rid, newDepartmentId), ]); + if (responses[2].modifiedCount) { + void notifyOnSubscriptionChangedByRoomId(rid); + } + setImmediate(() => { void Apps.self?.triggerEvent(AppEvents.IPostLivechatRoomTransferred, { type: LivechatTransferEventType.DEPARTMENT, diff --git a/apps/meteor/app/livechat/server/lib/LivechatTyped.ts b/apps/meteor/app/livechat/server/lib/LivechatTyped.ts index ccca7a8eb68e..be79d565f6de 100644 --- a/apps/meteor/app/livechat/server/lib/LivechatTyped.ts +++ b/apps/meteor/app/livechat/server/lib/LivechatTyped.ts @@ -21,6 +21,7 @@ import type { ILivechatDepartmentAgents, LivechatDepartmentDTO, OmnichannelSourceType, + ILivechatInquiryRecord, } from '@rocket.chat/core-typings'; import { ILivechatAgentStatus, UserStatus, isOmnichannelRoom } from '@rocket.chat/core-typings'; import { Logger, type MainLogger } from '@rocket.chat/logger'; @@ -40,11 +41,12 @@ import { import { serverFetch as fetch } from '@rocket.chat/server-fetch'; import { Match, check } from 'meteor/check'; import { Meteor } from 'meteor/meteor'; -import type { Filter, FindCursor } from 'mongodb'; +import type { Filter, FindCursor, ClientSession, MongoError } from 'mongodb'; import UAParser from 'ua-parser-js'; import { callbacks } from '../../../../lib/callbacks'; import { trim } from '../../../../lib/utils/stringUtils'; +import { client } from '../../../../server/database/utils'; import { i18n } from '../../../../server/lib/i18n'; import { addUserRolesAsync } from '../../../../server/lib/roles/addUserRoles'; import { removeUserFromRolesAsync } from '../../../../server/lib/roles/removeUserFromRoles'; @@ -60,8 +62,10 @@ import { notifyOnLivechatInquiryChangedByRoom, notifyOnRoomChangedById, notifyOnLivechatInquiryChangedByToken, - notifyOnLivechatDepartmentAgentChangedByDepartmentId, notifyOnUserChange, + notifyOnLivechatDepartmentAgentChangedByDepartmentId, + notifyOnSubscriptionChangedByRoomId, + notifyOnSubscriptionChanged, } from '../../../lib/server/lib/notifyListener'; import * as Mailer from '../../../mailer/server/api'; import { metrics } from '../../../metrics/server'; @@ -73,7 +77,6 @@ import { RoutingManager } from './RoutingManager'; import { isDepartmentCreationAvailable } from './isDepartmentCreationAvailable'; import type { CloseRoomParams, CloseRoomParamsByUser, CloseRoomParamsByVisitor } from './localTypes'; import { parseTranscriptRequest } from './parseTranscriptRequest'; -import { sendTranscript as sendTranscriptFunc } from './sendTranscript'; type RegisterGuestType = Partial> & { id?: string; @@ -140,6 +143,13 @@ type ICRMData = { crmData?: IOmnichannelRoom['crmData']; }; +type ChatCloser = { _id: string; username: string | undefined }; + +const isRoomClosedByUserParams = (params: CloseRoomParams): params is CloseRoomParamsByUser => + (params as CloseRoomParamsByUser).user !== undefined; +const isRoomClosedByVisitorParams = (params: CloseRoomParams): params is CloseRoomParamsByVisitor => + (params as CloseRoomParamsByVisitor).visitor !== undefined; + const dnsResolveMx = util.promisify(dns.resolveMx); class LivechatClass { @@ -152,22 +162,6 @@ class LivechatClass { this.webhookLogger = this.logger.section('Webhook'); } - findGuest(token: string) { - return LivechatVisitors.getVisitorByToken(token, { - projection: { - name: 1, - username: 1, - token: 1, - visitorEmails: 1, - department: 1, - }, - }); - } - - enabled() { - return Boolean(settings.get('Livechat_enabled')); - } - async online(department?: string, skipNoAgentSetting = false, skipFallbackCheck = false): Promise { Livechat.logger.debug(`Checking online agents ${department ? `for department ${department}` : ''}`); if (!skipNoAgentSetting && settings.get('Livechat_accept_chats_with_no_agents')) { @@ -192,10 +186,6 @@ class LivechatClass { return agentsOnline; } - getNextAgent(department?: string): Promise { - return RoutingManager.getNextAgent(department); - } - async getOnlineAgents(department?: string, agent?: SelectedAgent | null): Promise | undefined> { if (agent?.agentId) { return Users.findOnlineAgents(agent.agentId); @@ -217,14 +207,115 @@ class LivechatClass { return Users.findOnlineAgents(); } - async closeRoom(params: CloseRoomParams): Promise { + async closeRoom(params: CloseRoomParams, attempts = 2): Promise { + let newRoom: IOmnichannelRoom; + let chatCloser: ChatCloser; + let removedInquiryObj: ILivechatInquiryRecord | null; + + const session = client.startSession(); + try { + session.startTransaction(); + const { room, closedBy, removedInquiry } = await this.doCloseRoom(params, session); + await session.commitTransaction(); + + newRoom = room; + chatCloser = closedBy; + removedInquiryObj = removedInquiry; + } catch (e) { + this.logger.error({ err: e, msg: 'Failed to close room', afterAttempts: attempts }); + await session.abortTransaction(); + // Dont propagate transaction errors + if ( + (e as unknown as MongoError)?.errorLabels?.includes('UnknownTransactionCommitResult') || + (e as unknown as MongoError)?.errorLabels?.includes('TransientTransactionError') + ) { + if (attempts > 0) { + this.logger.debug(`Retrying close room because of transient error. Attempts left: ${attempts}`); + return this.closeRoom(params, attempts - 1); + } + + throw new Error('error-room-cannot-be-closed-try-again'); + } + throw e; + } finally { + await session.endSession(); + } + + // Note: when reaching this point, the room has been closed + // Transaction is commited and so these messages can be sent here. + return this.afterRoomClosed(newRoom, chatCloser, removedInquiryObj, params); + } + + async afterRoomClosed( + newRoom: IOmnichannelRoom, + chatCloser: ChatCloser, + inquiry: ILivechatInquiryRecord | null, + params: CloseRoomParams, + ): Promise { + if (!chatCloser) { + // this should never happen + return; + } + // Note: we are okay with these messages being sent outside of the transaction. The process of sending a message + // is huge and involves multiple db calls. Making it transactionable this way would be really hard. + // And passing just _some_ actions to the transaction creates some deadlocks since messages are updated in the afterSaveMessages callbacks. + const transcriptRequested = + !!params.room.transcriptRequest || (!settings.get('Livechat_enable_transcript') && settings.get('Livechat_transcript_send_always')); + this.logger.debug(`Sending closing message to room ${newRoom._id}`); + await Message.saveSystemMessageAndNotifyUser('livechat-close', newRoom._id, params.comment ?? '', chatCloser, { + groupable: false, + transcriptRequested, + ...(isRoomClosedByVisitorParams(params) && { token: params.visitor.token }), + }); + + if (settings.get('Livechat_enable_transcript') && !settings.get('Livechat_transcript_send_always')) { + await Message.saveSystemMessage('command', newRoom._id, 'promptTranscript', chatCloser); + } + + this.logger.debug(`Running callbacks for room ${newRoom._id}`); + + process.nextTick(() => { + /** + * @deprecated the `AppEvents.ILivechatRoomClosedHandler` event will be removed + * in the next major version of the Apps-Engine + */ + void Apps.self?.getBridges()?.getListenerBridge().livechatEvent(AppEvents.ILivechatRoomClosedHandler, newRoom); + void Apps.self?.getBridges()?.getListenerBridge().livechatEvent(AppEvents.IPostLivechatRoomClosed, newRoom); + }); + + const visitor = isRoomClosedByVisitorParams(params) ? params.visitor : undefined; + const opts = await parseTranscriptRequest(params.room, params.options, visitor); + if (process.env.TEST_MODE) { + await callbacks.run('livechat.closeRoom', { + room: newRoom, + options: opts, + }); + } else { + callbacks.runAsync('livechat.closeRoom', { + room: newRoom, + options: opts, + }); + } + + void notifyOnRoomChangedById(newRoom._id); + if (inquiry) { + void notifyOnLivechatInquiryChanged(inquiry, 'removed'); + } + + this.logger.debug(`Room ${newRoom._id} was closed`); + } + + async doCloseRoom( + params: CloseRoomParams, + session: ClientSession, + ): Promise<{ room: IOmnichannelRoom; closedBy: ChatCloser; removedInquiry: ILivechatInquiryRecord | null }> { const { comment } = params; const { room } = params; this.logger.debug(`Attempting to close room ${room._id}`); if (!room || !isOmnichannelRoom(room) || !room.open) { this.logger.debug(`Room ${room._id} is not open`); - return; + throw new Error('error-room-closed'); } const commentRequired = settings.get('Livechat_request_comment_when_closing_conversation'); @@ -236,7 +327,7 @@ class LivechatClass { this.logger.debug(`Resolved chat tags for room ${room._id}`); const now = new Date(); - const { _id: rid, servedBy, transcriptRequest } = room; + const { _id: rid, servedBy } = room; const serviceTimeDuration = servedBy && (now.getTime() - new Date(servedBy.ts).getTime()) / 1000; const closeData: IOmnichannelRoomClosingInfo = { @@ -247,12 +338,6 @@ class LivechatClass { }; this.logger.debug(`Room ${room._id} was closed at ${closeData.closedAt} (duration ${closeData.chatDuration})`); - const isRoomClosedByUserParams = (params: CloseRoomParams): params is CloseRoomParamsByUser => - (params as CloseRoomParamsByUser).user !== undefined; - const isRoomClosedByVisitorParams = (params: CloseRoomParams): params is CloseRoomParamsByVisitor => - (params as CloseRoomParamsByVisitor).visitor !== undefined; - - let chatCloser: any; if (isRoomClosedByUserParams(params)) { const { user } = params; this.logger.debug(`Closing by user ${user?._id}`); @@ -261,7 +346,6 @@ class LivechatClass { _id: user?._id || '', username: user?.username, }; - chatCloser = user; } else if (isRoomClosedByVisitorParams(params)) { const { visitor } = params; this.logger.debug(`Closing by visitor ${params.visitor._id}`); @@ -270,87 +354,44 @@ class LivechatClass { _id: visitor._id, username: visitor.username, }; - chatCloser = visitor; } else { throw new Error('Error: Please provide details of the user or visitor who closed the room'); } this.logger.debug(`Updating DB for room ${room._id} with close data`); - const inquiry = await LivechatInquiry.findOneByRoomId(rid); - - const removedInquiry = await LivechatInquiry.removeByRoomId(rid); + const inquiry = await LivechatInquiry.findOneByRoomId(rid, { session }); + const removedInquiry = await LivechatInquiry.removeByRoomId(rid, { session }); if (removedInquiry && removedInquiry.deletedCount !== 1) { throw new Error('Error removing inquiry'); } - if (inquiry) { - void notifyOnLivechatInquiryChanged(inquiry, 'removed'); - } - const updatedRoom = await LivechatRooms.closeRoomById(rid, closeData); + const updatedRoom = await LivechatRooms.closeRoomById(rid, closeData, { session }); if (!updatedRoom || updatedRoom.modifiedCount !== 1) { throw new Error('Error closing room'); } - await Subscriptions.removeByRoomId(rid); + const subs = await Subscriptions.countByRoomId(rid, { session }); + const removedSubs = await Subscriptions.removeByRoomId(rid, { + async onTrash(doc) { + void notifyOnSubscriptionChanged(doc, 'removed'); + }, + session, + }); - this.logger.debug(`DB updated for room ${room._id}`); + if (removedSubs.deletedCount !== subs) { + throw new Error('Error removing subscriptions'); + } - const transcriptRequested = - !!transcriptRequest || (!settings.get('Livechat_enable_transcript') && settings.get('Livechat_transcript_send_always')); + this.logger.debug(`DB updated for room ${room._id}`); // Retrieve the closed room - const newRoom = await LivechatRooms.findOneById(rid); - + const newRoom = await LivechatRooms.findOneById(rid, { session }); if (!newRoom) { throw new Error('Error: Room not found'); } - this.logger.debug(`Sending closing message to room ${room._id}`); - await sendMessage( - chatCloser, - { - t: 'livechat-close', - msg: comment, - groupable: false, - transcriptRequested, - ...(isRoomClosedByVisitorParams(params) && { token: chatCloser.token }), - }, - newRoom, - ); - - if (settings.get('Livechat_enable_transcript') && !settings.get('Livechat_transcript_send_always')) { - await Message.saveSystemMessage('command', rid, 'promptTranscript', closeData.closedBy); - } - - this.logger.debug(`Running callbacks for room ${newRoom._id}`); - - process.nextTick(() => { - /** - * @deprecated the `AppEvents.ILivechatRoomClosedHandler` event will be removed - * in the next major version of the Apps-Engine - */ - void Apps.self?.getBridges()?.getListenerBridge().livechatEvent(AppEvents.ILivechatRoomClosedHandler, newRoom); - void Apps.self?.getBridges()?.getListenerBridge().livechatEvent(AppEvents.IPostLivechatRoomClosed, newRoom); - }); - - const visitor = isRoomClosedByVisitorParams(params) ? params.visitor : undefined; - const opts = await parseTranscriptRequest(params.room, options, visitor); - if (process.env.TEST_MODE) { - await callbacks.run('livechat.closeRoom', { - room: newRoom, - options: opts, - }); - } else { - callbacks.runAsync('livechat.closeRoom', { - room: newRoom, - options: opts, - }); - } - - void notifyOnRoomChangedById(newRoom._id); - - this.logger.debug(`Room ${newRoom._id} was closed`); + return { room: newRoom, closedBy: closeData.closedBy, removedInquiry: inquiry }; } async getRequiredDepartment(onlineRequired = true) { @@ -389,7 +430,7 @@ class LivechatClass { agent?: SelectedAgent; extraData?: Record; }) { - if (!this.enabled()) { + if (!settings.get('Livechat_enabled')) { throw new Meteor.Error('error-omnichannel-is-disabled'); } @@ -440,7 +481,7 @@ class LivechatClass { agent?: SelectedAgent, extraData?: E, ) { - if (!this.enabled()) { + if (!settings.get('Livechat_enabled')) { throw new Meteor.Error('error-omnichannel-is-disabled'); } Livechat.logger.debug(`Attempting to find or create a room for visitor ${guest._id}`); @@ -526,12 +567,16 @@ class LivechatClass { const result = await Promise.allSettled([ Messages.removeByRoomId(rid), ReadReceipts.removeByRoomId(rid), - Subscriptions.removeByRoomId(rid), + Subscriptions.removeByRoomId(rid, { + async onTrash(doc) { + void notifyOnSubscriptionChanged(doc, 'removed'); + }, + }), LivechatInquiry.removeByRoomId(rid), LivechatRooms.removeById(rid), ]); - if (inquiry) { + if (result[3]?.status === 'fulfilled' && result[3].value?.deletedCount && inquiry) { void notifyOnLivechatInquiryChanged(inquiry, 'removed'); } @@ -810,15 +855,6 @@ class LivechatClass { } } - async updateLastChat(contactId: string, lastChat: Required) { - const updateUser = { - $set: { - lastChat, - }, - }; - await LivechatVisitors.updateById(contactId, updateUser); - } - notifyRoomVisitorChange(roomId: string, visitor: ILivechatVisitor) { void api.broadcast('omnichannel.room', roomId, { type: 'visitorData', @@ -878,7 +914,7 @@ class LivechatClass { return Messages.findVisibleByRoomIdNotContainingTypes(rid, ignoredMessageTypes, { sort: { ts: 1 }, - }).toArray(); + }); } async archiveDepartment(_id: string) { @@ -1154,13 +1190,18 @@ class LivechatClass { const cursor = LivechatRooms.findByVisitorToken(token); for await (const room of cursor) { await Promise.all([ + Subscriptions.removeByRoomId(room._id, { + async onTrash(doc) { + void notifyOnSubscriptionChanged(doc, 'removed'); + }, + }), FileUpload.removeFilesByRoomId(room._id), Messages.removeByRoomId(room._id), ReadReceipts.removeByRoomId(room._id), ]); } - await Promise.all([Subscriptions.removeByVisitorToken(token), LivechatRooms.removeByVisitorToken(token)]); + await LivechatRooms.removeByVisitorToken(token); const livechatInquiries = await LivechatInquiry.findIdsByVisitorToken(token).toArray(); await LivechatInquiry.removeByIds(livechatInquiries.map(({ _id }) => _id)); @@ -1254,31 +1295,20 @@ class LivechatClass { const scopeData = scope || (nextDepartment ? 'department' : 'agent'); this.logger.info(`Storing new chat transfer of ${room._id} [Transfered by: ${_id} to ${scopeData}]`); - await sendMessage( - transferredBy, - { - t: 'livechat_transfer_history', - rid: room._id, + const transferMessage = { + ...(transferData.transferredBy.userType === 'visitor' && { token: room.v.token }), + transferData: { + transferredBy, ts: new Date(), - msg: '', - u: { - _id, - username, - }, - groupable: false, - ...(transferData.transferredBy.userType === 'visitor' && { token: room.v.token }), - transferData: { - transferredBy, - ts: new Date(), - scope: scopeData, - comment, - ...(previousDepartment && { previousDepartment }), - ...(nextDepartment && { nextDepartment }), - ...(transferredTo && { transferredTo }), - }, + scope: scopeData, + comment, + ...(previousDepartment && { previousDepartment }), + ...(nextDepartment && { nextDepartment }), + ...(transferredTo && { transferredTo }), }, - room, - ); + }; + + await Message.saveSystemMessageAndNotifyUser('livechat_transfer_history', room._id, '', { _id, username }, transferMessage); } async saveGuest(guestData: Pick & { email?: string; phone?: string }, userId: string) { @@ -1723,13 +1753,19 @@ class LivechatClass { const { _id: rid } = roomData; const { name } = guestData; - await Promise.all([ + const responses = await Promise.all([ Rooms.setFnameById(rid, name), LivechatInquiry.setNameByRoomId(rid, name), Subscriptions.updateDisplayNameByRoomId(rid, name), ]); - void notifyOnLivechatInquiryChangedByRoom(rid, 'updated', { name }); + if (responses[1]?.modifiedCount) { + void notifyOnLivechatInquiryChangedByRoom(rid, 'updated', { name }); + } + + if (responses[2]?.modifiedCount) { + await notifyOnSubscriptionChangedByRoomId(rid); + } } void notifyOnRoomChangedById(roomData._id); @@ -1841,22 +1877,6 @@ class LivechatClass { return departmentDB; } - - async sendTranscript({ - token, - rid, - email, - subject, - user, - }: { - token: string; - rid: string; - email: string; - subject?: string; - user?: Pick | null; - }): Promise { - return sendTranscriptFunc({ token, rid, email, subject, user }); - } } export const Livechat = new LivechatClass(); diff --git a/apps/meteor/app/livechat/server/lib/QueueManager.ts b/apps/meteor/app/livechat/server/lib/QueueManager.ts index 2075a5e9af97..e1ea79d84163 100644 --- a/apps/meteor/app/livechat/server/lib/QueueManager.ts +++ b/apps/meteor/app/livechat/server/lib/QueueManager.ts @@ -223,7 +223,7 @@ export class QueueManager { const name = (roomInfo?.fname as string) || guest.name || guest.username; - const room = await createLivechatRoom(rid, name, guest, roomInfo, { + const room = await createLivechatRoom(rid, name, { ...guest, ...(department && { department }) }, roomInfo, { ...extraData, ...(Boolean(customFields) && { customFields }), }); diff --git a/apps/meteor/app/livechat/server/methods/getNextAgent.ts b/apps/meteor/app/livechat/server/methods/getNextAgent.ts index 179f1f95dedf..f603aeef97f3 100644 --- a/apps/meteor/app/livechat/server/methods/getNextAgent.ts +++ b/apps/meteor/app/livechat/server/methods/getNextAgent.ts @@ -8,6 +8,7 @@ import { callbacks } from '../../../../lib/callbacks'; import { methodDeprecationLogger } from '../../../lib/server/lib/deprecationWarningLogger'; import { settings } from '../../../settings/server'; import { Livechat } from '../lib/LivechatTyped'; +import { RoutingManager } from '../lib/RoutingManager'; declare module '@rocket.chat/ddp-client' { // eslint-disable-next-line @typescript-eslint/naming-convention @@ -38,7 +39,7 @@ Meteor.methods({ } } - const agent = await Livechat.getNextAgent(department); + const agent = await RoutingManager.getNextAgent(department); if (!agent) { return; } diff --git a/apps/meteor/app/livechat/server/methods/sendTranscript.ts b/apps/meteor/app/livechat/server/methods/sendTranscript.ts index 4891f579926a..00287fa89327 100644 --- a/apps/meteor/app/livechat/server/methods/sendTranscript.ts +++ b/apps/meteor/app/livechat/server/methods/sendTranscript.ts @@ -6,7 +6,7 @@ import { Meteor } from 'meteor/meteor'; import { hasPermissionAsync } from '../../../authorization/server/functions/hasPermission'; import { RateLimiter } from '../../../lib/server'; -import { Livechat } from '../lib/LivechatTyped'; +import { sendTranscript } from '../lib/sendTranscript'; declare module '@rocket.chat/ddp-client' { // eslint-disable-next-line @typescript-eslint/naming-convention @@ -39,7 +39,7 @@ Meteor.methods({ throw new Meteor.Error('error-mac-limit-reached', 'MAC limit reached', { method: 'livechat:sendTranscript' }); } - return Livechat.sendTranscript({ token, rid, email, subject, user }); + return sendTranscript({ token, rid, email, subject, user }); }, }); diff --git a/apps/meteor/app/message-mark-as-unread/server/unreadMessages.ts b/apps/meteor/app/message-mark-as-unread/server/unreadMessages.ts index 6ef1f5567a20..f213ae4b7243 100644 --- a/apps/meteor/app/message-mark-as-unread/server/unreadMessages.ts +++ b/apps/meteor/app/message-mark-as-unread/server/unreadMessages.ts @@ -3,6 +3,7 @@ import type { ServerMethods } from '@rocket.chat/ddp-client'; import { Messages, Subscriptions } from '@rocket.chat/models'; import { Meteor } from 'meteor/meteor'; +import { notifyOnSubscriptionChangedByRoomIdAndUserId } from '../../lib/server/lib/notifyListener'; import logger from './logger'; declare module '@rocket.chat/ddp-client' { @@ -36,7 +37,11 @@ Meteor.methods({ }); } - await Subscriptions.setAsUnreadByRoomIdAndUserId(lastMessage.rid, userId, lastMessage.ts); + const setAsUnreadResponse = await Subscriptions.setAsUnreadByRoomIdAndUserId(lastMessage.rid, userId, lastMessage.ts); + if (setAsUnreadResponse.modifiedCount) { + void notifyOnSubscriptionChangedByRoomIdAndUserId(lastMessage.rid, userId); + } + return; } @@ -72,7 +77,11 @@ Meteor.methods({ if (firstUnreadMessage.ts >= lastSeen) { return logger.debug('Provided message is already marked as unread'); } - logger.debug(`Updating unread message of ${originalMessage.ts} as the first unread`); - await Subscriptions.setAsUnreadByRoomIdAndUserId(originalMessage.rid, userId, originalMessage.ts); + + logger.debug(`Updating unread message of ${originalMessage.ts} as the first unread`); + const setAsUnreadResponse = await Subscriptions.setAsUnreadByRoomIdAndUserId(originalMessage.rid, userId, originalMessage.ts); + if (setAsUnreadResponse.modifiedCount) { + void notifyOnSubscriptionChangedByRoomIdAndUserId(originalMessage.rid, userId); + } }, }); diff --git a/apps/meteor/app/message-pin/server/pinMessage.ts b/apps/meteor/app/message-pin/server/pinMessage.ts index f691a775cb6a..9f3dd44cc16d 100644 --- a/apps/meteor/app/message-pin/server/pinMessage.ts +++ b/apps/meteor/app/message-pin/server/pinMessage.ts @@ -134,7 +134,7 @@ Meteor.methods({ const pinMessageType = originalMessage.t === 'e2e' ? 'message_pinned_e2e' : 'message_pinned'; - const msgId = await Message.saveSystemMessage(pinMessageType, originalMessage.rid, '', me, { + return Message.saveSystemMessage(pinMessageType, originalMessage.rid, '', me, { attachments: [ { text: originalMessage.msg, @@ -145,8 +145,6 @@ Meteor.methods({ }, ], }); - - return Messages.findOneById(msgId); }, async unpinMessage(message) { check(message._id, String); diff --git a/apps/meteor/app/models/client/models/CachedChatRoom.ts b/apps/meteor/app/models/client/models/CachedChatRoom.ts index f66e5b447432..852bed5a6067 100644 --- a/apps/meteor/app/models/client/models/CachedChatRoom.ts +++ b/apps/meteor/app/models/client/models/CachedChatRoom.ts @@ -46,7 +46,6 @@ class CachedChatRoom extends CachedCollection { usernames: room.usernames, usersCount: room.usersCount, lastMessage: room.lastMessage, - streamingOptions: room.streamingOptions, teamId: room.teamId, teamMain: room.teamMain, v: (room as IOmnichannelRoom | undefined)?.v, diff --git a/apps/meteor/app/models/client/models/CachedChatSubscription.ts b/apps/meteor/app/models/client/models/CachedChatSubscription.ts index 0e325453539a..7c0e84800c77 100644 --- a/apps/meteor/app/models/client/models/CachedChatSubscription.ts +++ b/apps/meteor/app/models/client/models/CachedChatSubscription.ts @@ -35,7 +35,6 @@ class CachedChatSubscription extends CachedCollection @@ -132,7 +133,10 @@ Meteor.methods({ }); } - await notifications[field].updateMethod(subscription, value); + const updateResponse = await notifications[field].updateMethod(subscription, value); + if (updateResponse.modifiedCount) { + void notifyOnSubscriptionChangedById(subscription._id); + } return true; }, @@ -144,13 +148,19 @@ Meteor.methods({ method: 'saveAudioNotificationValue', }); } + const subscription = await Subscriptions.findOneByRoomIdAndUserId(rid, userId); if (!subscription) { throw new Meteor.Error('error-invalid-subscription', 'Invalid subscription', { method: 'saveAudioNotificationValue', }); } - await saveAudioNotificationValue(subscription._id, value); + + const saveAudioNotificationResponse = await saveAudioNotificationValue(subscription._id, value); + if (saveAudioNotificationResponse.modifiedCount) { + void notifyOnSubscriptionChangedById(subscription._id); + } + return true; }, }); diff --git a/apps/meteor/app/reactions/server/setReaction.ts b/apps/meteor/app/reactions/server/setReaction.ts index e35103e9d333..d513c8dda6a5 100644 --- a/apps/meteor/app/reactions/server/setReaction.ts +++ b/apps/meteor/app/reactions/server/setReaction.ts @@ -1,5 +1,5 @@ import { Apps, AppEvents } from '@rocket.chat/apps'; -import { api } from '@rocket.chat/core-services'; +import { api, Message } from '@rocket.chat/core-services'; import type { IMessage, IRoom, IUser } from '@rocket.chat/core-typings'; import type { ServerMethods } from '@rocket.chat/ddp-client'; import { Messages, EmojiCustom, Rooms, Users } from '@rocket.chat/models'; @@ -52,6 +52,8 @@ async function setReaction(room: IRoom, user: IUser, message: IMessage, reaction // return; // } + await Message.beforeReacted(message, room); + const userAlreadyReacted = message.reactions && Boolean(message.reactions[reaction]) && diff --git a/apps/meteor/app/retention-policy/server/cronPruneMessages.ts b/apps/meteor/app/retention-policy/server/cronPruneMessages.ts index 337691bfbe57..640aa517a679 100644 --- a/apps/meteor/app/retention-policy/server/cronPruneMessages.ts +++ b/apps/meteor/app/retention-policy/server/cronPruneMessages.ts @@ -6,13 +6,20 @@ import { getCronAdvancedTimerFromPrecisionSetting } from '../../../lib/getCronAd import { cleanRoomHistory } from '../../lib/server/functions/cleanRoomHistory'; import { settings } from '../../settings/server'; -const maxTimes = { - c: 0, - p: 0, - d: 0, +type RetentionRoomTypes = 'c' | 'p' | 'd'; + +const getMaxAgeSettingIdByRoomType = (type: RetentionRoomTypes) => { + switch (type) { + case 'c': + return settings.get('RetentionPolicy_TTL_Channels'); + case 'p': + return settings.get('RetentionPolicy_TTL_Groups'); + case 'd': + return settings.get('RetentionPolicy_TTL_DMs'); + } }; -let types: (keyof typeof maxTimes)[] = []; +let types: RetentionRoomTypes[] = []; const oldest = new Date('0001-01-01T00:00:00Z'); @@ -29,7 +36,7 @@ async function job(): Promise { // get all rooms with default values for await (const type of types) { - const maxAge = maxTimes[type] || 0; + const maxAge = getMaxAgeSettingIdByRoomType(type) || 0; const latest = new Date(now.getTime() - maxAge); const rooms = await Rooms.find( @@ -95,9 +102,6 @@ settings.watchMultiple( 'RetentionPolicy_AppliesToChannels', 'RetentionPolicy_AppliesToGroups', 'RetentionPolicy_AppliesToDMs', - 'RetentionPolicy_TTL_Channels', - 'RetentionPolicy_TTL_Groups', - 'RetentionPolicy_TTL_DMs', 'RetentionPolicy_Advanced_Precision', 'RetentionPolicy_Advanced_Precision_Cron', 'RetentionPolicy_Precision', @@ -120,10 +124,6 @@ settings.watchMultiple( types.push('d'); } - maxTimes.c = settings.get('RetentionPolicy_TTL_Channels'); - maxTimes.p = settings.get('RetentionPolicy_TTL_Groups'); - maxTimes.d = settings.get('RetentionPolicy_TTL_DMs'); - const precision = (settings.get('RetentionPolicy_Advanced_Precision') && settings.get('RetentionPolicy_Advanced_Precision_Cron')) || getCronAdvancedTimerFromPrecisionSetting(settings.get('RetentionPolicy_Precision')); diff --git a/apps/meteor/app/settings/server/SettingsRegistry.ts b/apps/meteor/app/settings/server/SettingsRegistry.ts index e86a391ad8fa..d7d2fa0a79f8 100644 --- a/apps/meteor/app/settings/server/SettingsRegistry.ts +++ b/apps/meteor/app/settings/server/SettingsRegistry.ts @@ -139,6 +139,7 @@ export class SettingsRegistry { const settingFromCodeOverwritten = overwriteSetting(settingFromCode); const settingStored = this.store.getSetting(_id); + const settingStoredOverwritten = settingStored && overwriteSetting(settingStored); try { @@ -166,7 +167,10 @@ export class SettingsRegistry { })(); await this.saveUpdatedSetting(_id, updatedProps, removedKeys); - this.store.set(settingFromCodeOverwritten); + if ('value' in updatedProps) { + this.store.set(updatedProps as ISetting); + } + return; } diff --git a/apps/meteor/app/slackbridge/client/slackbridge_import.client.js b/apps/meteor/app/slackbridge/client/slackbridge_import.client.js index 6aeffb7bef45..eebc07ddb72d 100644 --- a/apps/meteor/app/slackbridge/client/slackbridge_import.client.js +++ b/apps/meteor/app/slackbridge/client/slackbridge_import.client.js @@ -1,5 +1,5 @@ import { settings } from '../../settings/client'; -import { slashCommands } from '../../utils/lib/slashCommand'; +import { slashCommands } from '../../utils/client/slashCommand'; settings.onload('SlackBridge_Enabled', (key, value) => { if (value) { diff --git a/apps/meteor/app/slashcommand-asciiarts/client/gimme.ts b/apps/meteor/app/slashcommand-asciiarts/client/gimme.ts index 4c9d6a4e40c8..7cd5edb6bb87 100644 --- a/apps/meteor/app/slashcommand-asciiarts/client/gimme.ts +++ b/apps/meteor/app/slashcommand-asciiarts/client/gimme.ts @@ -1,7 +1,7 @@ import type { SlashCommandCallbackParams } from '@rocket.chat/core-typings'; import { sdk } from '../../utils/client/lib/SDKClient'; -import { slashCommands } from '../../utils/lib/slashCommand'; +import { slashCommands } from '../../utils/client/slashCommand'; /* * Gimme is a named function that will replace /gimme commands * @param {Object} message - The message object diff --git a/apps/meteor/app/slashcommand-asciiarts/client/lenny.ts b/apps/meteor/app/slashcommand-asciiarts/client/lenny.ts index 99eaa03b9e59..0e3cc55f6b86 100644 --- a/apps/meteor/app/slashcommand-asciiarts/client/lenny.ts +++ b/apps/meteor/app/slashcommand-asciiarts/client/lenny.ts @@ -1,7 +1,7 @@ import type { SlashCommandCallbackParams } from '@rocket.chat/core-typings'; import { sdk } from '../../utils/client/lib/SDKClient'; -import { slashCommands } from '../../utils/lib/slashCommand'; +import { slashCommands } from '../../utils/client/slashCommand'; /* * Lenny is a named function that will replace /lenny commands * @param {Object} message - The message object diff --git a/apps/meteor/app/slashcommand-asciiarts/client/shrug.ts b/apps/meteor/app/slashcommand-asciiarts/client/shrug.ts index bc0fb300789e..c4bdec8f1a8c 100644 --- a/apps/meteor/app/slashcommand-asciiarts/client/shrug.ts +++ b/apps/meteor/app/slashcommand-asciiarts/client/shrug.ts @@ -1,7 +1,7 @@ import type { SlashCommandCallbackParams } from '@rocket.chat/core-typings'; import { sdk } from '../../utils/client/lib/SDKClient'; -import { slashCommands } from '../../utils/lib/slashCommand'; +import { slashCommands } from '../../utils/client/slashCommand'; /* * Shrug is a named function that will replace /shrug commands * @param {Object} message - The message object diff --git a/apps/meteor/app/slashcommand-asciiarts/client/tableflip.ts b/apps/meteor/app/slashcommand-asciiarts/client/tableflip.ts index 0d709760fe84..8820b81f7c4a 100644 --- a/apps/meteor/app/slashcommand-asciiarts/client/tableflip.ts +++ b/apps/meteor/app/slashcommand-asciiarts/client/tableflip.ts @@ -1,7 +1,7 @@ import type { SlashCommandCallbackParams } from '@rocket.chat/core-typings'; import { sdk } from '../../utils/client/lib/SDKClient'; -import { slashCommands } from '../../utils/lib/slashCommand'; +import { slashCommands } from '../../utils/client/slashCommand'; /* * Tableflip is a named function that will replace /Tableflip commands * @param {Object} message - The message object diff --git a/apps/meteor/app/slashcommand-asciiarts/client/unflip.ts b/apps/meteor/app/slashcommand-asciiarts/client/unflip.ts index a7dc0d257e78..6c02fa196052 100644 --- a/apps/meteor/app/slashcommand-asciiarts/client/unflip.ts +++ b/apps/meteor/app/slashcommand-asciiarts/client/unflip.ts @@ -1,7 +1,7 @@ import type { SlashCommandCallbackParams } from '@rocket.chat/core-typings'; import { sdk } from '../../utils/client/lib/SDKClient'; -import { slashCommands } from '../../utils/lib/slashCommand'; +import { slashCommands } from '../../utils/client/slashCommand'; /* * Unflip is a named function that will replace /unflip commands * @param {Object} message - The message object diff --git a/apps/meteor/app/slashcommand-asciiarts/server/gimme.ts b/apps/meteor/app/slashcommand-asciiarts/server/gimme.ts index f426d6cf85c0..f902d75f33d1 100644 --- a/apps/meteor/app/slashcommand-asciiarts/server/gimme.ts +++ b/apps/meteor/app/slashcommand-asciiarts/server/gimme.ts @@ -1,7 +1,7 @@ import type { SlashCommandCallbackParams } from '@rocket.chat/core-typings'; import { executeSendMessage } from '../../lib/server/methods/sendMessage'; -import { slashCommands } from '../../utils/lib/slashCommand'; +import { slashCommands } from '../../utils/server/slashCommand'; /* * Gimme is a named function that will replace /gimme commands * @param {Object} message - The message object diff --git a/apps/meteor/app/slashcommand-asciiarts/server/lenny.ts b/apps/meteor/app/slashcommand-asciiarts/server/lenny.ts index 878a10e356d4..e760b5a1169e 100644 --- a/apps/meteor/app/slashcommand-asciiarts/server/lenny.ts +++ b/apps/meteor/app/slashcommand-asciiarts/server/lenny.ts @@ -1,7 +1,7 @@ import type { SlashCommandCallbackParams } from '@rocket.chat/core-typings'; import { executeSendMessage } from '../../lib/server/methods/sendMessage'; -import { slashCommands } from '../../utils/lib/slashCommand'; +import { slashCommands } from '../../utils/server/slashCommand'; /* * Lenny is a named function that will replace /lenny commands * @param {Object} message - The message object diff --git a/apps/meteor/app/slashcommand-asciiarts/server/shrug.ts b/apps/meteor/app/slashcommand-asciiarts/server/shrug.ts index 1240027bb38f..c2e5d166bfd8 100644 --- a/apps/meteor/app/slashcommand-asciiarts/server/shrug.ts +++ b/apps/meteor/app/slashcommand-asciiarts/server/shrug.ts @@ -1,7 +1,7 @@ import type { SlashCommandCallbackParams } from '@rocket.chat/core-typings'; import { executeSendMessage } from '../../lib/server/methods/sendMessage'; -import { slashCommands } from '../../utils/lib/slashCommand'; +import { slashCommands } from '../../utils/server/slashCommand'; /* * Shrug is a named function that will replace /shrug commands * @param {Object} message - The message object diff --git a/apps/meteor/app/slashcommand-asciiarts/server/tableflip.ts b/apps/meteor/app/slashcommand-asciiarts/server/tableflip.ts index 34acef9805e2..ac3f599dff1d 100644 --- a/apps/meteor/app/slashcommand-asciiarts/server/tableflip.ts +++ b/apps/meteor/app/slashcommand-asciiarts/server/tableflip.ts @@ -1,7 +1,7 @@ import type { SlashCommandCallbackParams } from '@rocket.chat/core-typings'; import { executeSendMessage } from '../../lib/server/methods/sendMessage'; -import { slashCommands } from '../../utils/lib/slashCommand'; +import { slashCommands } from '../../utils/server/slashCommand'; /* * Tableflip is a named function that will replace /Tableflip commands * @param {Object} message - The message object diff --git a/apps/meteor/app/slashcommand-asciiarts/server/unflip.ts b/apps/meteor/app/slashcommand-asciiarts/server/unflip.ts index 689e7262eac0..b905ed567447 100644 --- a/apps/meteor/app/slashcommand-asciiarts/server/unflip.ts +++ b/apps/meteor/app/slashcommand-asciiarts/server/unflip.ts @@ -1,7 +1,7 @@ import type { SlashCommandCallbackParams } from '@rocket.chat/core-typings'; import { executeSendMessage } from '../../lib/server/methods/sendMessage'; -import { slashCommands } from '../../utils/lib/slashCommand'; +import { slashCommands } from '../../utils/server/slashCommand'; /* * Unflip is a named function that will replace /unflip commands * @param {Object} message - The message object diff --git a/apps/meteor/app/slashcommands-archiveroom/client/client.ts b/apps/meteor/app/slashcommands-archiveroom/client/client.ts index c24763106684..f5154fb32a5b 100644 --- a/apps/meteor/app/slashcommands-archiveroom/client/client.ts +++ b/apps/meteor/app/slashcommands-archiveroom/client/client.ts @@ -1,4 +1,4 @@ -import { slashCommands } from '../../utils/lib/slashCommand'; +import { slashCommands } from '../../utils/client/slashCommand'; slashCommands.add({ command: 'archive', diff --git a/apps/meteor/app/slashcommands-archiveroom/server/server.ts b/apps/meteor/app/slashcommands-archiveroom/server/server.ts index 99bcec2cd7b3..f1b33c1022bb 100644 --- a/apps/meteor/app/slashcommands-archiveroom/server/server.ts +++ b/apps/meteor/app/slashcommands-archiveroom/server/server.ts @@ -10,7 +10,7 @@ import { roomCoordinator } from '../../../server/lib/rooms/roomCoordinator'; import { hasPermissionAsync } from '../../authorization/server/functions/hasPermission'; import { archiveRoom } from '../../lib/server/functions/archiveRoom'; import { settings } from '../../settings/server'; -import { slashCommands } from '../../utils/lib/slashCommand'; +import { slashCommands } from '../../utils/server/slashCommand'; slashCommands.add({ command: 'archive', diff --git a/apps/meteor/app/slashcommands-create/client/client.ts b/apps/meteor/app/slashcommands-create/client/client.ts index 299db606db9c..7e8ba831dbd8 100644 --- a/apps/meteor/app/slashcommands-create/client/client.ts +++ b/apps/meteor/app/slashcommands-create/client/client.ts @@ -1,4 +1,4 @@ -import { slashCommands } from '../../utils/lib/slashCommand'; +import { slashCommands } from '../../utils/client/slashCommand'; slashCommands.add({ command: 'create', diff --git a/apps/meteor/app/slashcommands-create/server/server.ts b/apps/meteor/app/slashcommands-create/server/server.ts index 104d50c56926..6abee71c56fd 100644 --- a/apps/meteor/app/slashcommands-create/server/server.ts +++ b/apps/meteor/app/slashcommands-create/server/server.ts @@ -6,7 +6,7 @@ import { i18n } from '../../../server/lib/i18n'; import { createChannelMethod } from '../../lib/server/methods/createChannel'; import { createPrivateGroupMethod } from '../../lib/server/methods/createPrivateGroup'; import { settings } from '../../settings/server'; -import { slashCommands } from '../../utils/lib/slashCommand'; +import { slashCommands } from '../../utils/server/slashCommand'; slashCommands.add({ command: 'create', diff --git a/apps/meteor/app/slashcommands-help/server/server.ts b/apps/meteor/app/slashcommands-help/server/server.ts index c24bfb22c6fe..80efaffeb852 100644 --- a/apps/meteor/app/slashcommands-help/server/server.ts +++ b/apps/meteor/app/slashcommands-help/server/server.ts @@ -4,7 +4,7 @@ import { Users } from '@rocket.chat/models'; import { i18n } from '../../../server/lib/i18n'; import { settings } from '../../settings/server'; -import { slashCommands } from '../../utils/lib/slashCommand'; +import { slashCommands } from '../../utils/server/slashCommand'; /* * Help is a named function that will replace /help commands diff --git a/apps/meteor/app/slashcommands-hide/client/hide.ts b/apps/meteor/app/slashcommands-hide/client/hide.ts index 99c1eaea7049..c6486053ecc2 100644 --- a/apps/meteor/app/slashcommands-hide/client/hide.ts +++ b/apps/meteor/app/slashcommands-hide/client/hide.ts @@ -1,4 +1,4 @@ -import { slashCommands } from '../../utils/lib/slashCommand'; +import { slashCommands } from '../../utils/client/slashCommand'; slashCommands.add({ command: 'hide', diff --git a/apps/meteor/app/slashcommands-invite/client/client.ts b/apps/meteor/app/slashcommands-invite/client/client.ts index 729073b785d8..7c8af755d64d 100644 --- a/apps/meteor/app/slashcommands-invite/client/client.ts +++ b/apps/meteor/app/slashcommands-invite/client/client.ts @@ -1,4 +1,4 @@ -import { slashCommands } from '../../utils/lib/slashCommand'; +import { slashCommands } from '../../utils/client/slashCommand'; slashCommands.add({ command: 'invite', diff --git a/apps/meteor/app/slashcommands-invite/server/server.ts b/apps/meteor/app/slashcommands-invite/server/server.ts index de525d8c6fc6..06a85301540c 100644 --- a/apps/meteor/app/slashcommands-invite/server/server.ts +++ b/apps/meteor/app/slashcommands-invite/server/server.ts @@ -6,7 +6,7 @@ import { Meteor } from 'meteor/meteor'; import { i18n } from '../../../server/lib/i18n'; import { addUsersToRoomMethod } from '../../lib/server/methods/addUsersToRoom'; import { settings } from '../../settings/server'; -import { slashCommands } from '../../utils/lib/slashCommand'; +import { slashCommands } from '../../utils/server/slashCommand'; /* * Invite is a named function that will replace /invite commands diff --git a/apps/meteor/app/slashcommands-inviteall/client/client.ts b/apps/meteor/app/slashcommands-inviteall/client/client.ts index f8ab40953d27..5083cd4a83ab 100644 --- a/apps/meteor/app/slashcommands-inviteall/client/client.ts +++ b/apps/meteor/app/slashcommands-inviteall/client/client.ts @@ -1,4 +1,4 @@ -import { slashCommands } from '../../utils/lib/slashCommand'; +import { slashCommands } from '../../utils/client/slashCommand'; slashCommands.add({ command: 'invite-all-to', diff --git a/apps/meteor/app/slashcommands-inviteall/server/server.ts b/apps/meteor/app/slashcommands-inviteall/server/server.ts index bac4349ec72c..e74bb89899c2 100644 --- a/apps/meteor/app/slashcommands-inviteall/server/server.ts +++ b/apps/meteor/app/slashcommands-inviteall/server/server.ts @@ -15,7 +15,7 @@ import { addUsersToRoomMethod } from '../../lib/server/methods/addUsersToRoom'; import { createChannelMethod } from '../../lib/server/methods/createChannel'; import { createPrivateGroupMethod } from '../../lib/server/methods/createPrivateGroup'; import { settings } from '../../settings/server'; -import { slashCommands } from '../../utils/lib/slashCommand'; +import { slashCommands } from '../../utils/server/slashCommand'; function inviteAll(type: T): SlashCommand['callback'] { return async function inviteAll({ command, params, message, userId }: SlashCommandCallbackParams): Promise { diff --git a/apps/meteor/app/slashcommands-join/client/client.ts b/apps/meteor/app/slashcommands-join/client/client.ts index 417fe1e5cd47..bc8d589f51ac 100644 --- a/apps/meteor/app/slashcommands-join/client/client.ts +++ b/apps/meteor/app/slashcommands-join/client/client.ts @@ -1,6 +1,6 @@ import type { Meteor } from 'meteor/meteor'; -import { slashCommands } from '../../utils/lib/slashCommand'; +import { slashCommands } from '../../utils/client/slashCommand'; slashCommands.add({ command: 'join', diff --git a/apps/meteor/app/slashcommands-join/server/server.ts b/apps/meteor/app/slashcommands-join/server/server.ts index 33d0278f81a3..6497324ae9e0 100644 --- a/apps/meteor/app/slashcommands-join/server/server.ts +++ b/apps/meteor/app/slashcommands-join/server/server.ts @@ -5,7 +5,7 @@ import { Meteor } from 'meteor/meteor'; import { i18n } from '../../../server/lib/i18n'; import { settings } from '../../settings/server'; -import { slashCommands } from '../../utils/lib/slashCommand'; +import { slashCommands } from '../../utils/server/slashCommand'; slashCommands.add({ command: 'join', diff --git a/apps/meteor/app/slashcommands-kick/client/client.ts b/apps/meteor/app/slashcommands-kick/client/client.ts index 475346216f1e..7fc167e17c88 100644 --- a/apps/meteor/app/slashcommands-kick/client/client.ts +++ b/apps/meteor/app/slashcommands-kick/client/client.ts @@ -1,6 +1,6 @@ import type { SlashCommandCallbackParams } from '@rocket.chat/core-typings'; -import { slashCommands } from '../../utils/lib/slashCommand'; +import { slashCommands } from '../../utils/client/slashCommand'; slashCommands.add({ command: 'kick', diff --git a/apps/meteor/app/slashcommands-kick/server/server.ts b/apps/meteor/app/slashcommands-kick/server/server.ts index 5ca6b45ec835..fdde07b897bf 100644 --- a/apps/meteor/app/slashcommands-kick/server/server.ts +++ b/apps/meteor/app/slashcommands-kick/server/server.ts @@ -6,7 +6,7 @@ import { Users } from '@rocket.chat/models'; import { i18n } from '../../../server/lib/i18n'; import { removeUserFromRoomMethod } from '../../../server/methods/removeUserFromRoom'; import { settings } from '../../settings/server'; -import { slashCommands } from '../../utils/lib/slashCommand'; +import { slashCommands } from '../../utils/server/slashCommand'; slashCommands.add({ command: 'kick', diff --git a/apps/meteor/app/slashcommands-leave/server/leave.ts b/apps/meteor/app/slashcommands-leave/server/leave.ts index 42dad0807246..fa108fe18c72 100644 --- a/apps/meteor/app/slashcommands-leave/server/leave.ts +++ b/apps/meteor/app/slashcommands-leave/server/leave.ts @@ -5,7 +5,7 @@ import { Users } from '@rocket.chat/models'; import { i18n } from '../../../server/lib/i18n'; import { leaveRoomMethod } from '../../lib/server/methods/leaveRoom'; import { settings } from '../../settings/server'; -import { slashCommands } from '../../utils/lib/slashCommand'; +import { slashCommands } from '../../utils/server/slashCommand'; /* * Leave is a named function that will replace /leave commands diff --git a/apps/meteor/app/slashcommands-me/server/me.ts b/apps/meteor/app/slashcommands-me/server/me.ts index ba6a9f8c82cc..b8b4a593cb73 100644 --- a/apps/meteor/app/slashcommands-me/server/me.ts +++ b/apps/meteor/app/slashcommands-me/server/me.ts @@ -1,7 +1,7 @@ import type { SlashCommandCallbackParams } from '@rocket.chat/core-typings'; import { executeSendMessage } from '../../lib/server/methods/sendMessage'; -import { slashCommands } from '../../utils/lib/slashCommand'; +import { slashCommands } from '../../utils/server/slashCommand'; /* * Me is a named function that will replace /me commands diff --git a/apps/meteor/app/slashcommands-msg/server/server.ts b/apps/meteor/app/slashcommands-msg/server/server.ts index c6a244b80207..e757938106eb 100644 --- a/apps/meteor/app/slashcommands-msg/server/server.ts +++ b/apps/meteor/app/slashcommands-msg/server/server.ts @@ -7,7 +7,7 @@ import { i18n } from '../../../server/lib/i18n'; import { createDirectMessage } from '../../../server/methods/createDirectMessage'; import { executeSendMessage } from '../../lib/server/methods/sendMessage'; import { settings } from '../../settings/server'; -import { slashCommands } from '../../utils/lib/slashCommand'; +import { slashCommands } from '../../utils/server/slashCommand'; /* * Msg is a named function that will replace /msg commands diff --git a/apps/meteor/app/slashcommands-mute/server/mute.ts b/apps/meteor/app/slashcommands-mute/server/mute.ts index 03ce960496da..da20ff4fed47 100644 --- a/apps/meteor/app/slashcommands-mute/server/mute.ts +++ b/apps/meteor/app/slashcommands-mute/server/mute.ts @@ -5,7 +5,7 @@ import { Users } from '@rocket.chat/models'; import { i18n } from '../../../server/lib/i18n'; import { muteUserInRoom } from '../../../server/methods/muteUserInRoom'; import { settings } from '../../settings/server'; -import { slashCommands } from '../../utils/lib/slashCommand'; +import { slashCommands } from '../../utils/server/slashCommand'; /* * Mute is a named function that will replace /mute commands diff --git a/apps/meteor/app/slashcommands-mute/server/unmute.ts b/apps/meteor/app/slashcommands-mute/server/unmute.ts index 25c0956d49e3..4dc683f4ca93 100644 --- a/apps/meteor/app/slashcommands-mute/server/unmute.ts +++ b/apps/meteor/app/slashcommands-mute/server/unmute.ts @@ -5,7 +5,7 @@ import { Users } from '@rocket.chat/models'; import { i18n } from '../../../server/lib/i18n'; import { unmuteUserInRoom } from '../../../server/methods/unmuteUserInRoom'; import { settings } from '../../settings/server'; -import { slashCommands } from '../../utils/lib/slashCommand'; +import { slashCommands } from '../../utils/server/slashCommand'; /* * Unmute is a named function that will replace /unmute commands diff --git a/apps/meteor/app/slashcommands-open/client/client.ts b/apps/meteor/app/slashcommands-open/client/client.ts index 987df9599761..99438a24eeb0 100644 --- a/apps/meteor/app/slashcommands-open/client/client.ts +++ b/apps/meteor/app/slashcommands-open/client/client.ts @@ -5,7 +5,7 @@ import { roomCoordinator } from '../../../client/lib/rooms/roomCoordinator'; import { router } from '../../../client/providers/RouterProvider'; import { Subscriptions, ChatSubscription } from '../../models/client'; import { sdk } from '../../utils/client/lib/SDKClient'; -import { slashCommands } from '../../utils/lib/slashCommand'; +import { slashCommands } from '../../utils/client/slashCommand'; slashCommands.add({ command: 'open', diff --git a/apps/meteor/app/slashcommands-status/client/status.ts b/apps/meteor/app/slashcommands-status/client/status.ts index 9136ef8f586f..3698b5fda4cb 100644 --- a/apps/meteor/app/slashcommands-status/client/status.ts +++ b/apps/meteor/app/slashcommands-status/client/status.ts @@ -2,7 +2,7 @@ import type { SlashCommandCallbackParams } from '@rocket.chat/core-typings'; import { dispatchToastMessage } from '../../../client/lib/toast'; import { sdk } from '../../utils/client/lib/SDKClient'; -import { slashCommands } from '../../utils/lib/slashCommand'; +import { slashCommands } from '../../utils/client/slashCommand'; slashCommands.add({ command: 'status', diff --git a/apps/meteor/app/slashcommands-status/server/status.ts b/apps/meteor/app/slashcommands-status/server/status.ts index 72d92afaf3f2..a2ff6483d398 100644 --- a/apps/meteor/app/slashcommands-status/server/status.ts +++ b/apps/meteor/app/slashcommands-status/server/status.ts @@ -5,7 +5,7 @@ import { Users } from '@rocket.chat/models'; import { i18n } from '../../../server/lib/i18n'; import { settings } from '../../settings/server'; import { setUserStatusMethod } from '../../user-status/server/methods/setUserStatus'; -import { slashCommands } from '../../utils/lib/slashCommand'; +import { slashCommands } from '../../utils/server/slashCommand'; slashCommands.add({ command: 'status', diff --git a/apps/meteor/app/slashcommands-topic/client/topic.ts b/apps/meteor/app/slashcommands-topic/client/topic.ts index f5f5ed58bb0f..f7e47c334b5a 100644 --- a/apps/meteor/app/slashcommands-topic/client/topic.ts +++ b/apps/meteor/app/slashcommands-topic/client/topic.ts @@ -5,7 +5,7 @@ import { callbacks } from '../../../lib/callbacks'; import { hasPermission } from '../../authorization/client'; import { ChatRoom } from '../../models/client/models/ChatRoom'; import { sdk } from '../../utils/client/lib/SDKClient'; -import { slashCommands } from '../../utils/lib/slashCommand'; +import { slashCommands } from '../../utils/client/slashCommand'; slashCommands.add({ command: 'topic', diff --git a/apps/meteor/app/slashcommands-topic/server/topic.ts b/apps/meteor/app/slashcommands-topic/server/topic.ts index 24fd51d5f509..c1fa6ea283b7 100644 --- a/apps/meteor/app/slashcommands-topic/server/topic.ts +++ b/apps/meteor/app/slashcommands-topic/server/topic.ts @@ -2,7 +2,7 @@ import type { SlashCommandCallbackParams } from '@rocket.chat/core-typings'; import { hasPermissionAsync } from '../../authorization/server/functions/hasPermission'; import { saveRoomSettings } from '../../channel-settings/server/methods/saveRoomSettings'; -import { slashCommands } from '../../utils/lib/slashCommand'; +import { slashCommands } from '../../utils/server/slashCommand'; slashCommands.add({ command: 'topic', diff --git a/apps/meteor/app/slashcommands-unarchiveroom/client/client.ts b/apps/meteor/app/slashcommands-unarchiveroom/client/client.ts index 2fed1e1c7802..7b65fc067031 100644 --- a/apps/meteor/app/slashcommands-unarchiveroom/client/client.ts +++ b/apps/meteor/app/slashcommands-unarchiveroom/client/client.ts @@ -1,4 +1,4 @@ -import { slashCommands } from '../../utils/lib/slashCommand'; +import { slashCommands } from '../../utils/client/slashCommand'; slashCommands.add({ command: 'unarchive', diff --git a/apps/meteor/app/slashcommands-unarchiveroom/server/server.ts b/apps/meteor/app/slashcommands-unarchiveroom/server/server.ts index d87981bd65a2..4c0c44269d2f 100644 --- a/apps/meteor/app/slashcommands-unarchiveroom/server/server.ts +++ b/apps/meteor/app/slashcommands-unarchiveroom/server/server.ts @@ -10,7 +10,7 @@ import { roomCoordinator } from '../../../server/lib/rooms/roomCoordinator'; import { hasPermissionAsync } from '../../authorization/server/functions/hasPermission'; import { unarchiveRoom } from '../../lib/server/functions/unarchiveRoom'; import { settings } from '../../settings/server'; -import { slashCommands } from '../../utils/lib/slashCommand'; +import { slashCommands } from '../../utils/server/slashCommand'; slashCommands.add({ command: 'unarchive', diff --git a/apps/meteor/app/statistics/server/lib/statistics.ts b/apps/meteor/app/statistics/server/lib/statistics.ts index cff2aaefcc5a..e5001b2bff87 100644 --- a/apps/meteor/app/statistics/server/lib/statistics.ts +++ b/apps/meteor/app/statistics/server/lib/statistics.ts @@ -542,7 +542,6 @@ export const statistics = { statistics.totalOTRRooms = await Rooms.findByCreatedOTR().count(); statistics.totalOTR = settings.get('OTR_Count'); statistics.totalBroadcastRooms = await Rooms.findByBroadcast().count(); - statistics.totalRoomsWithActiveLivestream = await Rooms.findByActiveLivestream().count(); statistics.totalTriggeredEmails = settings.get('Triggered_Emails_Count'); statistics.totalRoomsWithStarred = await Messages.countRoomsWithStarredMessages({ readPreference }); statistics.totalRoomsWithPinned = await Messages.countRoomsWithPinnedMessages({ readPreference }); diff --git a/apps/meteor/app/theme/client/imports/general/base_old.css b/apps/meteor/app/theme/client/imports/general/base_old.css index 20b023cc61aa..3120d9c05ff0 100644 --- a/apps/meteor/app/theme/client/imports/general/base_old.css +++ b/apps/meteor/app/theme/client/imports/general/base_old.css @@ -776,21 +776,6 @@ padding: 21px 0 10px; } - & .start { - margin-top: 44px; - - text-align: center; - - & .start__purge-warning { - margin-top: -33px; - margin-bottom: 0.5rem; - padding: 1rem; - - border-width: 1px 0 0; - background: linear-gradient(to bottom, var(--rc-color-alert-message-warning-background) 0%, rgba(255, 255, 255, 0) 100%); - } - } - & .editing .body { border-radius: var(--border-radius); } diff --git a/apps/meteor/app/threads/server/functions.ts b/apps/meteor/app/threads/server/functions.ts index 30daef8b8b93..194e482c54ae 100644 --- a/apps/meteor/app/threads/server/functions.ts +++ b/apps/meteor/app/threads/server/functions.ts @@ -2,51 +2,57 @@ import type { IMessage } from '@rocket.chat/core-typings'; import { isEditedMessage } from '@rocket.chat/core-typings'; import { Messages, Subscriptions, ReadReceipts, NotificationQueue } from '@rocket.chat/models'; +import { + notifyOnSubscriptionChangedByRoomIdAndUserIds, + notifyOnSubscriptionChangedByRoomIdAndUserId, +} from '../../lib/server/lib/notifyListener'; import { getMentions, getUserIdsFromHighlights } from '../../lib/server/lib/notifyUsersOnMessage'; export async function reply({ tmid }: { tmid?: string }, message: IMessage, parentMessage: IMessage, followers: string[]) { - const { rid, ts, u } = message; if (!tmid || isEditedMessage(message)) { return false; } - const { toAll, toHere, mentionIds } = await getMentions(message); + const { rid, ts, u } = message; + + const [highlightsUids, threadFollowers, { toAll, toHere, mentionIds }] = await Promise.all([ + getUserIdsFromHighlights(rid, message), + Messages.getThreadFollowsByThreadId(tmid), + getMentions(message), + ]); const addToReplies = [ - ...new Set([ - ...followers, - ...mentionIds, - ...(Array.isArray(parentMessage.replies) && parentMessage.replies.length ? [u._id] : [parentMessage.u._id, u._id]), - ]), + ...new Set([...followers, ...mentionIds, ...(parentMessage.replies?.length ? [u._id] : [parentMessage.u._id, u._id])]), ]; - const highlightedUserIds = new Set(); - (await getUserIdsFromHighlights(rid, message)).forEach((uid) => highlightedUserIds.add(uid)); - await Messages.updateRepliesByThreadId(tmid, addToReplies, ts); - await ReadReceipts.setAsThreadById(tmid); + const threadFollowersUids = threadFollowers?.filter((userId) => userId !== u._id && !mentionIds.includes(userId)) || []; - const replies = await Messages.getThreadFollowsByThreadId(tmid); + // Notify everyone involved in the thread + const notifyOptions = toAll || toHere ? { groupMention: true } : {}; - const repliesFiltered = (replies || []).filter((userId) => userId !== u._id).filter((userId) => !mentionIds.includes(userId)); + // Notify message mentioned users and highlights + const mentionedUsers = [...new Set([...mentionIds, ...highlightsUids])]; - if (toAll || toHere) { - await Subscriptions.addUnreadThreadByRoomIdAndUserIds(rid, repliesFiltered, tmid, { - groupMention: true, - }); - } else { - await Subscriptions.addUnreadThreadByRoomIdAndUserIds(rid, repliesFiltered, tmid, {}); - } + const promises = [ + Messages.updateRepliesByThreadId(tmid, addToReplies, ts), + ReadReceipts.setAsThreadById(tmid), + Subscriptions.addUnreadThreadByRoomIdAndUserIds(rid, threadFollowersUids, tmid, notifyOptions), + ]; - const mentionedUsers = new Set([...mentionIds, ...highlightedUserIds]); - for await (const userId of mentionedUsers) { - await Subscriptions.addUnreadThreadByRoomIdAndUserIds(rid, [userId], tmid, { userMention: true }); + if (mentionedUsers.length) { + promises.push(Subscriptions.addUnreadThreadByRoomIdAndUserIds(rid, mentionedUsers, tmid, { userMention: true })); } - const highlightIds = Array.from(highlightedUserIds); - if (highlightIds.length) { - await Subscriptions.setAlertForRoomIdAndUserIds(rid, highlightIds); - await Subscriptions.setOpenForRoomIdAndUserIds(rid, highlightIds); + if (highlightsUids.length) { + promises.push( + Subscriptions.setAlertForRoomIdAndUserIds(rid, highlightsUids), + Subscriptions.setOpenForRoomIdAndUserIds(rid, highlightsUids), + ); } + + await Promise.allSettled(promises); + + void notifyOnSubscriptionChangedByRoomIdAndUserIds(rid, [...threadFollowersUids, ...mentionedUsers, ...highlightsUids]); } export async function follow({ tmid, uid }: { tmid: string; uid: string }) { @@ -62,20 +68,27 @@ export async function unfollow({ tmid, rid, uid }: { tmid: string; rid: string; return false; } - await Subscriptions.removeUnreadThreadByRoomIdAndUserId(rid, uid, tmid); + const removeUnreadThreadResponse = await Subscriptions.removeUnreadThreadByRoomIdAndUserId(rid, uid, tmid); + if (removeUnreadThreadResponse.modifiedCount) { + void notifyOnSubscriptionChangedByRoomIdAndUserId(rid, uid); + } await Messages.removeThreadFollowerByThreadId(tmid, uid); } export const readThread = async ({ userId, rid, tmid }: { userId: string; rid: string; tmid: string }) => { - const projection = { tunread: 1 }; - const sub = await Subscriptions.findOneByRoomIdAndUserId(rid, userId, { projection }); + const sub = await Subscriptions.findOneByRoomIdAndUserId(rid, userId, { projection: { tunread: 1 } }); if (!sub) { return; } + // if the thread being marked as read is the last one unread also clear the unread subscription flag const clearAlert = sub.tunread && sub.tunread?.length <= 1 && sub.tunread.includes(tmid); - await Subscriptions.removeUnreadThreadByRoomIdAndUserId(rid, userId, tmid, clearAlert); + const removeUnreadThreadResponse = await Subscriptions.removeUnreadThreadByRoomIdAndUserId(rid, userId, tmid, clearAlert); + if (removeUnreadThreadResponse.modifiedCount) { + void notifyOnSubscriptionChangedByRoomIdAndUserId(rid, userId); + } + await NotificationQueue.clearQueueByUserId(userId); }; diff --git a/apps/meteor/app/threads/server/hooks/aftersavemessage.ts b/apps/meteor/app/threads/server/hooks/aftersavemessage.ts index 179cb5ec12b7..a938dadddb27 100644 --- a/apps/meteor/app/threads/server/hooks/aftersavemessage.ts +++ b/apps/meteor/app/threads/server/hooks/aftersavemessage.ts @@ -77,7 +77,7 @@ Meteor.startup(() => { } callbacks.add( 'afterSaveMessage', - async (message, room) => { + async (message, { room }) => { return processThreads(message, room); }, callbacks.priority.LOW, diff --git a/apps/meteor/app/threads/server/methods/followMessage.ts b/apps/meteor/app/threads/server/methods/followMessage.ts index 1790e0607a62..8ed7093e00d4 100644 --- a/apps/meteor/app/threads/server/methods/followMessage.ts +++ b/apps/meteor/app/threads/server/methods/followMessage.ts @@ -7,6 +7,7 @@ import { Meteor } from 'meteor/meteor'; import { canAccessRoomIdAsync } from '../../../authorization/server/functions/canAccessRoom'; import { RateLimiter } from '../../../lib/server'; +import { notifyOnMessageChange } from '../../../lib/server/lib/notifyListener'; import { settings } from '../../../settings/server'; import { follow } from '../functions'; @@ -41,7 +42,13 @@ Meteor.methods({ throw new Meteor.Error('error-not-allowed', 'not-allowed', { method: 'followMessage' }); } - const followResult = await follow({ tmid: message.tmid || message._id, uid }); + const id = message.tmid || message._id; + + const followResult = await follow({ tmid: id, uid }); + + void notifyOnMessageChange({ + id, + }); const isFollowed = true; await Apps.self?.triggerEvent(AppEvents.IPostMessageFollowed, message, await Meteor.userAsync(), isFollowed); diff --git a/apps/meteor/app/threads/server/methods/unfollowMessage.ts b/apps/meteor/app/threads/server/methods/unfollowMessage.ts index 6371f40af6cb..de4f2683be41 100644 --- a/apps/meteor/app/threads/server/methods/unfollowMessage.ts +++ b/apps/meteor/app/threads/server/methods/unfollowMessage.ts @@ -7,6 +7,7 @@ import { Meteor } from 'meteor/meteor'; import { canAccessRoomIdAsync } from '../../../authorization/server/functions/canAccessRoom'; import { RateLimiter } from '../../../lib/server'; +import { notifyOnMessageChange } from '../../../lib/server/lib/notifyListener'; import { settings } from '../../../settings/server'; import { unfollow } from '../functions'; @@ -41,7 +42,13 @@ Meteor.methods({ throw new Meteor.Error('error-not-allowed', 'not-allowed', { method: 'unfollowMessage' }); } - const unfollowResult = await unfollow({ rid: message.rid, tmid: message.tmid || message._id, uid }); + const id = message.tmid || message._id; + + const unfollowResult = await unfollow({ rid: message.rid, tmid: id, uid }); + + void notifyOnMessageChange({ + id, + }); const isFollowed = false; await Apps.self?.triggerEvent(AppEvents.IPostMessageFollowed, message, await Meteor.userAsync(), isFollowed); diff --git a/apps/meteor/app/utils/client/index.ts b/apps/meteor/app/utils/client/index.ts index fd03ffc3d720..561a1116141b 100644 --- a/apps/meteor/app/utils/client/index.ts +++ b/apps/meteor/app/utils/client/index.ts @@ -2,6 +2,6 @@ export { Info } from '../rocketchat.info'; export { getUserPreference } from './lib/getUserPreference'; export { fileUploadIsValidContentType } from './restrictions'; export { getUserAvatarURL } from './getUserAvatarURL'; -export { slashCommands } from '../lib/slashCommand'; +export { slashCommands } from './slashCommand'; export { getURL } from './getURL'; export { APIClient } from './lib/RestApiClient'; diff --git a/apps/meteor/app/utils/lib/slashCommand.ts b/apps/meteor/app/utils/client/slashCommand.ts similarity index 85% rename from apps/meteor/app/utils/lib/slashCommand.ts rename to apps/meteor/app/utils/client/slashCommand.ts index 47149807bbd8..66e793012fac 100644 --- a/apps/meteor/app/utils/lib/slashCommand.ts +++ b/apps/meteor/app/utils/client/slashCommand.ts @@ -6,7 +6,8 @@ import type { SlashCommandPreviewItem, SlashCommandPreviews, } from '@rocket.chat/core-typings'; -import { Meteor } from 'meteor/meteor'; + +import { InvalidCommandUsage, InvalidPreview } from '../../../client/lib/errors'; interface ISlashCommandAddParams { command: string; @@ -69,7 +70,7 @@ export const slashCommands = { } if (!message?.rid) { - throw new Meteor.Error('invalid-command-usage', 'Executing a command requires at least a message with a room id.'); + throw new InvalidCommandUsage(); } return cmd.callback({ command, params, message, triggerId, userId }); @@ -85,7 +86,7 @@ export const slashCommands = { } if (!message?.rid) { - throw new Meteor.Error('invalid-command-usage', 'Executing a command requires at least a message with a room id.'); + throw new InvalidCommandUsage(); } const previewInfo = await cmd.previewer(command, params, message); @@ -114,12 +115,12 @@ export const slashCommands = { } if (!message?.rid) { - throw new Meteor.Error('invalid-command-usage', 'Executing a command requires at least a message with a room id.'); + throw new InvalidCommandUsage(); } // { id, type, value } if (!preview.id || !preview.type || !preview.value) { - throw new Meteor.Error('error-invalid-preview', 'Preview Item must have an id, type, and value.'); + throw new InvalidPreview(); } return cmd.previewCallback(command, params, message, preview, triggerId); diff --git a/apps/meteor/app/utils/server/functions/getMongoInfo.ts b/apps/meteor/app/utils/server/functions/getMongoInfo.ts index 8460e9e4ced0..1caef4a22e32 100644 --- a/apps/meteor/app/utils/server/functions/getMongoInfo.ts +++ b/apps/meteor/app/utils/server/functions/getMongoInfo.ts @@ -7,7 +7,6 @@ function getOplogInfo(): { oplogEnabled: boolean; mongo: MongoConnection } { const oplogEnabled = isWatcherRunning(); - // @ts-expect-error - You're drunk ts return { oplogEnabled, mongo }; } diff --git a/apps/meteor/app/utils/server/slashCommand.ts b/apps/meteor/app/utils/server/slashCommand.ts index dc85fee9b671..27b3c81735f9 100644 --- a/apps/meteor/app/utils/server/slashCommand.ts +++ b/apps/meteor/app/utils/server/slashCommand.ts @@ -1,7 +1,139 @@ +import { MeteorError } from '@rocket.chat/core-services'; +import type { + IMessage, + SlashCommand, + SlashCommandOptions, + RequiredField, + SlashCommandPreviewItem, + SlashCommandPreviews, +} from '@rocket.chat/core-typings'; import type { ServerMethods } from '@rocket.chat/ddp-client'; import { Meteor } from 'meteor/meteor'; -import { slashCommands } from '../lib/slashCommand'; +interface ISlashCommandAddParams { + command: string; + callback?: SlashCommand['callback']; + options?: SlashCommandOptions; + result?: SlashCommand['result']; + providesPreview?: boolean; + previewer?: SlashCommand['previewer']; + previewCallback?: SlashCommand['previewCallback']; + appId?: string; + description?: string; +} + +export const slashCommands = { + commands: {} as Record, + add({ + command, + callback, + options = {}, + result, + providesPreview = false, + previewer, + previewCallback, + appId, + description = '', + }: ISlashCommandAddParams): void { + if (this.commands[command]) { + return; + } + this.commands[command] = { + command, + callback, + params: options.params, + description: options.description || description, + permission: options.permission, + clientOnly: options.clientOnly || false, + result, + providesPreview: Boolean(providesPreview), + previewer, + previewCallback, + appId, + } as SlashCommand; + }, + async run({ + command, + message, + params, + triggerId, + userId, + }: { + command: string; + params: string; + message: RequiredField, 'rid' | '_id'>; + userId: string; + triggerId?: string | undefined; + }): Promise { + const cmd = this.commands[command]; + if (typeof cmd?.callback !== 'function') { + return; + } + + if (!message?.rid) { + throw new MeteorError('invalid-command-usage', 'Executing a command requires at least a message with a room id.'); + } + + return cmd.callback({ command, params, message, triggerId, userId }); + }, + async getPreviews( + command: string, + params: string, + message: RequiredField, 'rid'>, + ): Promise { + const cmd = this.commands[command]; + if (typeof cmd?.previewer !== 'function') { + return; + } + + if (!message?.rid) { + throw new MeteorError('invalid-command-usage', 'Executing a command requires at least a message with a room id.'); + } + + const previewInfo = await cmd.previewer(command, params, message); + + if (!previewInfo?.items?.length) { + return; + } + + // A limit of ten results, to save time and bandwidth + if (previewInfo.items.length >= 10) { + previewInfo.items = previewInfo.items.slice(0, 10); + } + + return previewInfo; + }, + async executePreview( + command: string, + params: string, + message: Pick & Partial>, + preview: SlashCommandPreviewItem, + triggerId?: string, + ) { + const cmd = this.commands[command]; + if (typeof cmd?.previewCallback !== 'function') { + return; + } + + if (!message?.rid) { + throw new MeteorError('invalid-command-usage', 'Executing a command requires at least a message with a room id.'); + } + + // { id, type, value } + if (!preview.id || !preview.type || !preview.value) { + throw new MeteorError('error-invalid-preview', 'Preview Item must have an id, type, and value.'); + } + + return cmd.previewCallback(command, params, message, preview, triggerId); + }, +}; + +declare module '@rocket.chat/ddp-client' { + // eslint-disable-next-line @typescript-eslint/naming-convention + interface ServerMethods { + slashCommand(params: { cmd: string; params: string; msg: IMessage; triggerId: string }): unknown; + } +} Meteor.methods({ async slashCommand(command) { @@ -27,5 +159,3 @@ Meteor.methods({ }); }, }); - -export { slashCommands }; diff --git a/apps/meteor/client/NavBarV2/NavBarPagesToolbar/hooks/useAuditMenu.spec.tsx b/apps/meteor/client/NavBarV2/NavBarPagesToolbar/hooks/useAuditMenu.spec.tsx index 11eddf934055..94fdfe25a92d 100644 --- a/apps/meteor/client/NavBarV2/NavBarPagesToolbar/hooks/useAuditMenu.spec.tsx +++ b/apps/meteor/client/NavBarV2/NavBarPagesToolbar/hooks/useAuditMenu.spec.tsx @@ -1,10 +1,11 @@ import { mockAppRoot } from '@rocket.chat/mock-providers'; -import { renderHook } from '@testing-library/react-hooks'; +import { renderHook, waitFor } from '@testing-library/react'; import { useAuditMenu } from './useAuditMenu'; it('should return an empty array of items if doesn`t have license', async () => { - const { result, waitFor } = renderHook(() => useAuditMenu(), { + const { result } = renderHook(() => useAuditMenu(), { + legacyRoot: true, wrapper: mockAppRoot() .withEndpoint('GET', '/v1/licenses.info', () => ({ // @ts-expect-error: just for testing @@ -18,13 +19,12 @@ it('should return an empty array of items if doesn`t have license', async () => .build(), }); - await waitFor(() => result.all.length > 1); - - expect(result.current).toEqual([]); + await waitFor(() => expect(result.current).toEqual([])); }); it('should return an empty array of items if have license and not have permissions', async () => { - const { result, waitFor } = renderHook(() => useAuditMenu(), { + const { result } = renderHook(() => useAuditMenu(), { + legacyRoot: true, wrapper: mockAppRoot() .withEndpoint('GET', '/v1/licenses.info', () => ({ license: { @@ -41,13 +41,12 @@ it('should return an empty array of items if have license and not have permissio .build(), }); - await waitFor(() => result.all.length > 1); - - expect(result.current).toEqual([]); + await waitFor(() => expect(result.current).toEqual([])); }); it('should return auditItems if have license and permissions', async () => { - const { result, waitFor } = renderHook(() => useAuditMenu(), { + const { result } = renderHook(() => useAuditMenu(), { + legacyRoot: true, wrapper: mockAppRoot() .withEndpoint('GET', '/v1/licenses.info', () => ({ license: { @@ -65,12 +64,12 @@ it('should return auditItems if have license and permissions', async () => { .build(), }); - await waitFor(() => result.current.length > 0); - - expect(result.current[0].items[0]).toEqual( - expect.objectContaining({ - id: 'messages', - }), + await waitFor(() => + expect(result.current[0]?.items[0]).toEqual( + expect.objectContaining({ + id: 'messages', + }), + ), ); expect(result.current[0].items[1]).toEqual( @@ -81,7 +80,8 @@ it('should return auditItems if have license and permissions', async () => { }); it('should return auditMessages item if have license and can-audit permission', async () => { - const { result, waitFor } = renderHook(() => useAuditMenu(), { + const { result } = renderHook(() => useAuditMenu(), { + legacyRoot: true, wrapper: mockAppRoot() .withEndpoint('GET', '/v1/licenses.info', () => ({ license: { @@ -98,17 +98,18 @@ it('should return auditMessages item if have license and can-audit permission', .build(), }); - await waitFor(() => result.current.length > 0); - - expect(result.current[0].items[0]).toEqual( - expect.objectContaining({ - id: 'messages', - }), + await waitFor(() => + expect(result.current[0]?.items[0]).toEqual( + expect.objectContaining({ + id: 'messages', + }), + ), ); }); it('should return audiLogs item if have license and can-audit-log permission', async () => { - const { result, waitFor } = renderHook(() => useAuditMenu(), { + const { result } = renderHook(() => useAuditMenu(), { + legacyRoot: true, wrapper: mockAppRoot() .withEndpoint('GET', '/v1/licenses.info', () => ({ license: { @@ -125,11 +126,11 @@ it('should return audiLogs item if have license and can-audit-log permission', a .build(), }); - await waitFor(() => result.current.length > 0); - - expect(result.current[0].items[0]).toEqual( - expect.objectContaining({ - id: 'auditLog', - }), + await waitFor(() => + expect(result.current[0]?.items[0]).toEqual( + expect.objectContaining({ + id: 'auditLog', + }), + ), ); }); diff --git a/apps/meteor/client/NavBarV2/NavBarPagesToolbar/hooks/useMarketPlaceMenu.spec.tsx b/apps/meteor/client/NavBarV2/NavBarPagesToolbar/hooks/useMarketPlaceMenu.spec.tsx index 2a3d277e69fe..d2d1e36ca05e 100644 --- a/apps/meteor/client/NavBarV2/NavBarPagesToolbar/hooks/useMarketPlaceMenu.spec.tsx +++ b/apps/meteor/client/NavBarV2/NavBarPagesToolbar/hooks/useMarketPlaceMenu.spec.tsx @@ -1,11 +1,12 @@ import { UIActionButtonContext } from '@rocket.chat/apps-engine/definition/ui'; import { mockAppRoot } from '@rocket.chat/mock-providers'; -import { renderHook } from '@testing-library/react-hooks'; +import { renderHook, waitFor } from '@testing-library/react'; import { useMarketPlaceMenu } from './useMarketPlaceMenu'; it('should return and empty array if the user does not have `manage-apps` and `access-marketplace` permission', () => { const { result } = renderHook(() => useMarketPlaceMenu(), { + legacyRoot: true, wrapper: mockAppRoot() .withEndpoint('GET', '/apps/actionButtons', () => []) .build(), @@ -16,6 +17,7 @@ it('should return and empty array if the user does not have `manage-apps` and `a it('should return `explore` and `installed` items if the user has `access-marketplace` permission', () => { const { result } = renderHook(() => useMarketPlaceMenu(), { + legacyRoot: true, wrapper: mockAppRoot() .withEndpoint('GET', '/apps/actionButtons', () => []) .withPermission('access-marketplace') @@ -37,6 +39,7 @@ it('should return `explore` and `installed` items if the user has `access-market it('should return `explore`, `installed` and `requested` items if the user has `manage-apps` permission', () => { const { result } = renderHook(() => useMarketPlaceMenu(), { + legacyRoot: true, wrapper: mockAppRoot() .withEndpoint('GET', '/apps/actionButtons', () => []) .withEndpoint('GET', '/apps/app-request/stats', () => ({ @@ -69,7 +72,8 @@ it('should return `explore`, `installed` and `requested` items if the user has ` }); it('should return one action from the server with no conditions', async () => { - const { result, waitForValueToChange } = renderHook(() => useMarketPlaceMenu(), { + const { result } = renderHook(() => useMarketPlaceMenu(), { + legacyRoot: true, wrapper: mockAppRoot() .withEndpoint('GET', '/apps/actionButtons', () => [ { @@ -101,18 +105,19 @@ it('should return one action from the server with no conditions', async () => { }), ); - await waitForValueToChange(() => result.current[0].items[3]); - - expect(result.current[0].items[3]).toEqual( - expect.objectContaining({ - id: 'APP_ID_ACTION_ID', - }), + await waitFor(() => + expect(result.current[0]?.items[3]).toEqual( + expect.objectContaining({ + id: 'APP_ID_ACTION_ID', + }), + ), ); }); describe('Marketplace menu with role conditions', () => { it('should return the action if the user has admin role', async () => { - const { result, waitForValueToChange } = renderHook(() => useMarketPlaceMenu(), { + const { result } = renderHook(() => useMarketPlaceMenu(), { + legacyRoot: true, wrapper: mockAppRoot() .withEndpoint('GET', '/apps/actionButtons', () => [ { @@ -149,17 +154,18 @@ describe('Marketplace menu with role conditions', () => { }), ); - await waitForValueToChange(() => result.current[0].items[3]); - - expect(result.current[0].items[3]).toEqual( - expect.objectContaining({ - id: 'APP_ID_ACTION_ID', - }), + await waitFor(() => + expect(result.current[0]?.items[3]).toEqual( + expect.objectContaining({ + id: 'APP_ID_ACTION_ID', + }), + ), ); }); it('should return filter the action if the user doesn`t have admin role', async () => { const { result } = renderHook(() => useMarketPlaceMenu(), { + legacyRoot: true, wrapper: mockAppRoot() .withEndpoint('GET', '/apps/actionButtons', () => [ { @@ -206,7 +212,8 @@ describe('Marketplace menu with role conditions', () => { describe('Marketplace menu with permission conditions', () => { it('should return the action if the user has manage-apps permission', async () => { - const { result, waitForValueToChange } = renderHook(() => useMarketPlaceMenu(), { + const { result } = renderHook(() => useMarketPlaceMenu(), { + legacyRoot: true, wrapper: mockAppRoot() .withEndpoint('GET', '/apps/actionButtons', () => [ { @@ -241,17 +248,18 @@ describe('Marketplace menu with permission conditions', () => { }), ); - await waitForValueToChange(() => result.current[0].items[3]); - - expect(result.current[0].items[3]).toEqual( - expect.objectContaining({ - id: 'APP_ID_ACTION_ID', - }), + await waitFor(() => + expect(result.current[0].items[3]).toEqual( + expect.objectContaining({ + id: 'APP_ID_ACTION_ID', + }), + ), ); }); it('should return filter the action if the user doesn`t have `any` permission', async () => { const { result } = renderHook(() => useMarketPlaceMenu(), { + legacyRoot: true, wrapper: mockAppRoot() .withEndpoint('GET', '/apps/actionButtons', () => [ { diff --git a/apps/meteor/client/NavBarV2/NavBarSettingsToolbar/hooks/useAdministrationMenu.spec.tsx b/apps/meteor/client/NavBarV2/NavBarSettingsToolbar/hooks/useAdministrationMenu.spec.tsx index 1315d1053392..ba100fe79783 100644 --- a/apps/meteor/client/NavBarV2/NavBarSettingsToolbar/hooks/useAdministrationMenu.spec.tsx +++ b/apps/meteor/client/NavBarV2/NavBarSettingsToolbar/hooks/useAdministrationMenu.spec.tsx @@ -1,10 +1,11 @@ import { mockAppRoot } from '@rocket.chat/mock-providers'; -import { renderHook } from '@testing-library/react-hooks'; +import { renderHook, waitFor } from '@testing-library/react'; import { useAdministrationMenu } from './useAdministrationMenu'; it('should return omnichannel item if has `view-livechat-manager` permission ', async () => { - const { result, waitFor } = renderHook(() => useAdministrationMenu(), { + const { result } = renderHook(() => useAdministrationMenu(), { + legacyRoot: true, wrapper: mockAppRoot() .withEndpoint('GET', '/v1/licenses.info', () => ({ // @ts-expect-error this is a mock @@ -19,17 +20,18 @@ it('should return omnichannel item if has `view-livechat-manager` permission ', .build(), }); - await waitFor(() => !!result.current.length); - - expect(result.current[0].items[0]).toEqual( - expect.objectContaining({ - id: 'omnichannel', - }), + await waitFor(() => + expect(result.current[0]?.items[0]).toEqual( + expect.objectContaining({ + id: 'omnichannel', + }), + ), ); }); it('should show administration item if has at least one admin permission', async () => { - const { result, waitFor } = renderHook(() => useAdministrationMenu(), { + const { result } = renderHook(() => useAdministrationMenu(), { + legacyRoot: true, wrapper: mockAppRoot() .withEndpoint('GET', '/v1/licenses.info', () => ({ // @ts-expect-error this is a mock @@ -44,11 +46,11 @@ it('should show administration item if has at least one admin permission', async .build(), }); - await waitFor(() => !!result.current.length); - - expect(result.current[0].items[0]).toEqual( - expect.objectContaining({ - id: 'workspace', - }), + await waitFor(() => + expect(result.current[0]?.items[0]).toEqual( + expect.objectContaining({ + id: 'workspace', + }), + ), ); }); diff --git a/apps/meteor/client/components/Contextualbar/ContextualbarHeader.tsx b/apps/meteor/client/components/Contextualbar/ContextualbarHeader.tsx index 795182df8465..ebd92f0095e3 100644 --- a/apps/meteor/client/components/Contextualbar/ContextualbarHeader.tsx +++ b/apps/meteor/client/components/Contextualbar/ContextualbarHeader.tsx @@ -8,10 +8,10 @@ type ContextualbarHeaderProps = { children: ReactNode; } & ComponentPropsWithoutRef; -const ContextualbarHeader = (props: ContextualbarHeaderProps) => ( +const ContextualbarHeader = ({ expanded, ...props }: ContextualbarHeaderProps) => ( - + diff --git a/apps/meteor/client/components/GenericMenu/GenericMenu.spec.tsx b/apps/meteor/client/components/GenericMenu/GenericMenu.spec.tsx index 99e62bac1a60..530bd1404dc7 100644 --- a/apps/meteor/client/components/GenericMenu/GenericMenu.spec.tsx +++ b/apps/meteor/client/components/GenericMenu/GenericMenu.spec.tsx @@ -1,4 +1,3 @@ -import '@testing-library/jest-dom'; import { render, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import React from 'react'; @@ -32,28 +31,28 @@ const sections = [regular, danger]; describe('Room Actions Menu', () => { it('should render kebab menu with the list content', async () => { - render(); + render(, { legacyRoot: true }); - userEvent.click(screen.getByRole('button')); + await userEvent.click(screen.getByRole('button')); expect(await screen.findByText('Edit')).toBeInTheDocument(); expect(await screen.findByText('Delete')).toBeInTheDocument(); }); it('should have two different sections, regular and danger', async () => { - render(); + render(, { legacyRoot: true }); - userEvent.click(screen.getByRole('button')); + await userEvent.click(screen.getByRole('button')); expect(screen.getAllByRole('presentation')).toHaveLength(2); expect(screen.getByRole('separator')).toBeInTheDocument(); }); it('should call the action when item clicked', async () => { - render(); + render(, { legacyRoot: true }); - userEvent.click(screen.getByRole('button')); - userEvent.click(screen.getAllByRole('menuitem')[0]); + await userEvent.click(screen.getByRole('button')); + await userEvent.click(screen.getAllByRole('menuitem')[0]); expect(mockedFunction).toHaveBeenCalled(); }); diff --git a/apps/meteor/client/components/GenericModal/GenericModal.spec.tsx b/apps/meteor/client/components/GenericModal/GenericModal.spec.tsx index 0ef7235729c4..b47b6abf7b00 100644 --- a/apps/meteor/client/components/GenericModal/GenericModal.spec.tsx +++ b/apps/meteor/client/components/GenericModal/GenericModal.spec.tsx @@ -1,6 +1,5 @@ import { useSetModal } from '@rocket.chat/ui-contexts'; -import { act, screen } from '@testing-library/react'; -import { renderHook } from '@testing-library/react-hooks'; +import { act, screen, renderHook } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import type { ReactElement } from 'react'; import React, { Suspense } from 'react'; @@ -8,12 +7,11 @@ import React, { Suspense } from 'react'; import ModalProviderWithRegion from '../../providers/ModalProvider/ModalProviderWithRegion'; import GenericModal from './GenericModal'; -import '@testing-library/jest-dom'; - const renderModal = (modalElement: ReactElement) => { const { result: { current: setModal }, } = renderHook(() => useSetModal(), { + legacyRoot: true, wrapper: ({ children }) => ( {children} @@ -34,11 +32,11 @@ describe('callbacks', () => { renderModal(); - expect(await screen.findByRole('heading', { name: 'Modal', exact: true })).toBeInTheDocument(); + expect(await screen.findByRole('heading', { name: 'Modal' })).toBeInTheDocument(); - userEvent.keyboard('{Escape}'); + await userEvent.keyboard('{Escape}'); - expect(screen.queryByRole('heading', { name: 'Modal', exact: true })).not.toBeInTheDocument(); + expect(screen.queryByRole('heading', { name: 'Modal' })).not.toBeInTheDocument(); expect(handleClose).toHaveBeenCalled(); }); @@ -49,9 +47,9 @@ describe('callbacks', () => { const { setModal } = renderModal(); - expect(await screen.findByRole('heading', { name: 'Modal', exact: true })).toBeInTheDocument(); + expect(await screen.findByRole('heading', { name: 'Modal' })).toBeInTheDocument(); - userEvent.click(screen.getByRole('button', { name: 'Ok', exact: true })); + await userEvent.click(screen.getByRole('button', { name: 'Ok' })); expect(handleConfirm).toHaveBeenCalled(); @@ -59,7 +57,7 @@ describe('callbacks', () => { setModal(null); }); - expect(screen.queryByRole('heading', { name: 'Modal', exact: true })).not.toBeInTheDocument(); + expect(screen.queryByRole('heading', { name: 'Modal' })).not.toBeInTheDocument(); expect(handleClose).not.toHaveBeenCalled(); }); @@ -70,9 +68,9 @@ describe('callbacks', () => { const { setModal } = renderModal(); - expect(await screen.findByRole('heading', { name: 'Modal', exact: true })).toBeInTheDocument(); + expect(await screen.findByRole('heading', { name: 'Modal' })).toBeInTheDocument(); - userEvent.click(screen.getByRole('button', { name: 'Cancel', exact: true })); + await userEvent.click(screen.getByRole('button', { name: 'Cancel' })); expect(handleCancel).toHaveBeenCalled(); @@ -80,7 +78,7 @@ describe('callbacks', () => { setModal(null); }); - expect(screen.queryByRole('heading', { name: 'Modal', exact: true })).not.toBeInTheDocument(); + expect(screen.queryByRole('heading', { name: 'Modal' })).not.toBeInTheDocument(); expect(handleClose).not.toHaveBeenCalled(); }); diff --git a/apps/meteor/client/components/InfoPanel/RetentionPolicyCallout.spec.tsx b/apps/meteor/client/components/InfoPanel/RetentionPolicyCallout.spec.tsx index fb97b0132f85..8db42e8c649d 100644 --- a/apps/meteor/client/components/InfoPanel/RetentionPolicyCallout.spec.tsx +++ b/apps/meteor/client/components/InfoPanel/RetentionPolicyCallout.spec.tsx @@ -1,6 +1,5 @@ import { render, screen } from '@testing-library/react'; import React from 'react'; -import '@testing-library/jest-dom/extend-expect'; import { createRenteionPolicySettingsMock as createMock } from '../../../tests/mocks/client/mockRetentionPolicySettings'; import { createFakeRoom } from '../../../tests/mocks/data'; @@ -15,13 +14,17 @@ beforeEach(() => { describe('RetentionPolicyCallout', () => { it('Should render callout if settings are valid', () => { const fakeRoom = createFakeRoom({ t: 'c' }); - render(, { wrapper: createMock({ appliesToChannels: true, TTLChannels: 60000 }) }); + render(, { + legacyRoot: true, + wrapper: createMock({ appliesToChannels: true, TTLChannels: 60000 }), + }); expect(screen.getByRole('alert')).toHaveTextContent('a minute June 1, 2024, 12:30 AM'); }); it('Should not render callout if settings are invalid', () => { const fakeRoom = createFakeRoom({ t: 'c' }); render(, { + legacyRoot: true, wrapper: createMock({ appliesToChannels: true, TTLChannels: 60000, advancedPrecisionCron: '* * * 12 *', advancedPrecision: true }), }); expect(screen.queryByRole('alert')).not.toBeInTheDocument(); diff --git a/apps/meteor/client/components/Omnichannel/modals/TranscriptModal.spec.tsx b/apps/meteor/client/components/Omnichannel/modals/TranscriptModal.spec.tsx new file mode 100644 index 000000000000..87f7f70fbfbb --- /dev/null +++ b/apps/meteor/client/components/Omnichannel/modals/TranscriptModal.spec.tsx @@ -0,0 +1,48 @@ +import type { IOmnichannelRoom } from '@rocket.chat/core-typings'; +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import React from 'react'; + +import TranscriptModal from './TranscriptModal'; + +const room = { + open: true, + v: { token: '1234567890' }, + transcriptRequest: { + email: 'example@example.com', + subject: 'Transcript of livechat conversation', + }, +} as IOmnichannelRoom; + +const defaultProps = { + room, + email: 'test@example.com', + onRequest: () => null, + onSend: () => null, + onCancel: () => null, + onDiscard: () => null, +}; + +it('should show Undo request button when roomOpen is true and transcriptRequest exist', async () => { + const onDiscardMock = jest.fn(); + render(, { legacyRoot: true }); + + const undoRequestButton = await screen.findByText('Undo_request'); + await userEvent.click(undoRequestButton); + + expect(onDiscardMock).toHaveBeenCalled(); +}); + +it('should show Request button when roomOpen is true and transcriptRequest not exist', async () => { + render(, { legacyRoot: true }); + + const requestBtn = await screen.findByRole('button', { name: 'request-button' }); + expect(requestBtn).toBeInTheDocument(); +}); + +it('should show Send button when roomOpen is false', async () => { + render(, { legacyRoot: true }); + + const sendBtn = await screen.findByRole('button', { name: 'send-button' }); + expect(sendBtn).toBeInTheDocument(); +}); diff --git a/apps/meteor/client/components/Omnichannel/modals/TranscriptModal.tsx b/apps/meteor/client/components/Omnichannel/modals/TranscriptModal.tsx index 95bda1e89107..c06b6a190465 100644 --- a/apps/meteor/client/components/Omnichannel/modals/TranscriptModal.tsx +++ b/apps/meteor/client/components/Omnichannel/modals/TranscriptModal.tsx @@ -21,8 +21,7 @@ const TranscriptModal = ({ email: emailDefault = '', room, onRequest, onSend, on handleSubmit, setValue, setFocus, - watch, - formState: { errors, isValid, isSubmitting }, + formState: { errors, isSubmitting }, } = useForm({ defaultValues: { email: emailDefault || '', subject: t('Transcript_of_your_livechat_conversation') }, }); @@ -56,7 +55,7 @@ const TranscriptModal = ({ email: emailDefault = '', room, onRequest, onSend, on } }, [setValue, transcriptRequest]); - const canSubmit = isValid && Boolean(watch('subject')); + // const canSubmit = isValid && Boolean(watch('subject')); return ( } {...props}> @@ -103,12 +102,12 @@ const TranscriptModal = ({ email: emailDefault = '', room, onRequest, onSend, on )} {roomOpen && !transcriptRequest && ( - )} {!roomOpen && ( - )} diff --git a/apps/meteor/client/components/UserCard/UserCardInfo.tsx b/apps/meteor/client/components/UserCard/UserCardInfo.tsx index 8e235670a3dc..2afcf6a37f2c 100644 --- a/apps/meteor/client/components/UserCard/UserCardInfo.tsx +++ b/apps/meteor/client/components/UserCard/UserCardInfo.tsx @@ -3,7 +3,7 @@ import type { ReactElement, ComponentProps } from 'react'; import React from 'react'; const UserCardInfo = (props: ComponentProps): ReactElement => ( - + ); export default UserCardInfo; diff --git a/apps/meteor/client/components/WarningModal.spec.tsx b/apps/meteor/client/components/WarningModal.spec.tsx index 8a3ec4f47f8b..5747402d2d8b 100644 --- a/apps/meteor/client/components/WarningModal.spec.tsx +++ b/apps/meteor/client/components/WarningModal.spec.tsx @@ -4,10 +4,9 @@ import React from 'react'; import WarningModal from './WarningModal'; -import '@testing-library/jest-dom'; - it('should look good', async () => { render( undefined} close={() => undefined} />, { + legacyRoot: true, wrapper: mockAppRoot().build(), }); diff --git a/apps/meteor/client/components/message/content/reactions/useToggleReactionMutation.spec.tsx b/apps/meteor/client/components/message/content/reactions/useToggleReactionMutation.spec.tsx index 43da25f4f6b5..dfbe0fc07bed 100644 --- a/apps/meteor/client/components/message/content/reactions/useToggleReactionMutation.spec.tsx +++ b/apps/meteor/client/components/message/content/reactions/useToggleReactionMutation.spec.tsx @@ -1,20 +1,19 @@ import { mockAppRoot } from '@rocket.chat/mock-providers'; -import { renderHook, act } from '@testing-library/react-hooks'; +import { renderHook, waitFor } from '@testing-library/react'; import { useToggleReactionMutation } from './useToggleReactionMutation'; it('should be call rest `POST /v1/chat.react` method', async () => { const fn = jest.fn(); - const { result, waitFor } = renderHook(() => useToggleReactionMutation(), { + const { result } = renderHook(() => useToggleReactionMutation(), { + legacyRoot: true, wrapper: mockAppRoot().withEndpoint('POST', '/v1/chat.react', fn).withJohnDoe().build(), }); - await act(async () => { - await result.current.mutateAsync({ mid: 'MID', reaction: 'smile' }); - }); + result.current.mutate({ mid: 'MID', reaction: 'smile' }); - await waitFor(() => result.current.isLoading === false); + await waitFor(() => expect(result.current.status).toBe('success')); expect(fn).toHaveBeenCalledWith({ messageId: 'MID', @@ -26,15 +25,14 @@ it('should not work for non-logged in users', async () => { const fn = jest.fn(); const { result } = renderHook(() => useToggleReactionMutation(), { + legacyRoot: true, wrapper: mockAppRoot().withEndpoint('POST', '/v1/chat.react', fn).build(), }); - await act(async () => { - expect(result.current.mutateAsync({ mid: 'MID', reaction: 'smile' })).rejects.toThrowError(); - }); + result.current.mutate({ mid: 'MID', reaction: 'smile' }); - expect(fn).not.toHaveBeenCalled(); + await waitFor(() => expect(result.current.status).toBe('error')); - expect(result.current.status).toBe('error'); + expect(fn).not.toHaveBeenCalled(); expect(result.current.error).toEqual(new Error('Not logged in')); }); diff --git a/apps/meteor/client/components/message/content/urlPreviews/buildImageURL.spec.ts b/apps/meteor/client/components/message/content/urlPreviews/buildImageURL.spec.ts new file mode 100644 index 000000000000..678948df2b7e --- /dev/null +++ b/apps/meteor/client/components/message/content/urlPreviews/buildImageURL.spec.ts @@ -0,0 +1,20 @@ +import { buildImageURL } from './buildImageURL'; + +const testCases = [ + [ + 'https://g1.globo.com/mundo/video/misseis-atingem-ponte-de-vidro-em-kiev-11012523.ghtml', + 'https://s2.glbimg.com/fXQKM_UZjF6I_3APIbPJzJTOUvw=/1200x/smart/filters:cover():strip_icc()/s04.video.glbimg.com/x720/11012523.jpg', + 'https://s2.glbimg.com/fXQKM_UZjF6I_3APIbPJzJTOUvw=/1200x/smart/filters:cover():strip_icc()/s04.video.glbimg.com/x720/11012523.jpg', + ], + ['https://open.rocket.chat/channel/general', 'assets/favicon_512.png', 'https://open.rocket.chat/assets/favicon_512.png'], + ['https://open.rocket.chat/channel/general', '/assets/favicon_512.png', 'https://open.rocket.chat/assets/favicon_512.png'], + ['https://open.rocket.chat/channel/general/', '/assets/favicon_512.png', 'https://open.rocket.chat/assets/favicon_512.png'], +] as const; + +testCases.forEach(([linkUrl, metaImgUrl, expectedResult]) => { + it(`should return ${expectedResult} for ${metaImgUrl}`, () => { + const result = buildImageURL(linkUrl, metaImgUrl); + + expect(result).toBe(JSON.stringify(expectedResult)); + }); +}); diff --git a/apps/meteor/client/components/message/variants/RoomMessage.spec.tsx b/apps/meteor/client/components/message/variants/RoomMessage.spec.tsx new file mode 100644 index 000000000000..8d536c4b58f6 --- /dev/null +++ b/apps/meteor/client/components/message/variants/RoomMessage.spec.tsx @@ -0,0 +1,112 @@ +import type { IMessage } from '@rocket.chat/core-typings'; +import { mockAppRoot } from '@rocket.chat/mock-providers'; +import { render, screen } from '@testing-library/react'; +import React from 'react'; + +import RoomMessage from './RoomMessage'; + +const message: IMessage = { + ts: new Date('2021-10-27T00:00:00.000Z'), + u: { + _id: 'userId', + name: 'userName', + username: 'userName', + }, + msg: 'message body', + md: [ + { + type: 'PARAGRAPH', + value: [ + { + type: 'PLAIN_TEXT', + value: 'message body', + }, + ], + }, + ], + rid: 'roomId', + _id: 'messageId', + _updatedAt: new Date('2021-10-27T00:00:00.000Z'), + urls: [], +}; + +jest.mock('../header/hooks/useMessageRoles', () => ({ + useMessageRoles: () => [], +})); +jest.mock('../../../lib/utils/fireGlobalEvent', () => ({ fireGlobalEvent: () => undefined })); +jest.mock('../../../views/room/hooks/useGoToRoom', () => ({ useGoToRoom: () => undefined })); +jest.mock('../../../views/room/contextualBar/Threads/hooks/useGetMessageByID', () => undefined); +jest.mock('../../../views/room/MessageList/hooks/useAutoTranslate', () => ({ + useAutoTranslate: () => ({ + autoTranslateEnabled: false, + autoTranslateLanguage: '', + showAutoTranslate: () => false, + }), +})); +jest.mock('../../../lib/actionLinks', () => undefined); + +it('should show normal message', () => { + render( + , + { + legacyRoot: true, + wrapper: mockAppRoot().build(), + }, + ); + + expect(screen.getByRole('figure')).toBeInTheDocument(); + expect(screen.getByText('message body')).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'Message_Ignored' })).not.toBeInTheDocument(); +}); + +it('should show fallback content for ignored user', () => { + render( + , + { + legacyRoot: true, + wrapper: mockAppRoot().build(), + }, + ); + + expect(screen.getByRole('figure')).toBeInTheDocument(); + expect(screen.queryByText('message body')).not.toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Message_Ignored' })).toBeInTheDocument(); +}); + +it('should show ignored message', () => { + render( + , + { + legacyRoot: true, + wrapper: mockAppRoot().build(), + }, + ); + + expect(screen.getByRole('figure')).toBeInTheDocument(); + expect(screen.queryByText('message body')).not.toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Message_Ignored' })).toBeInTheDocument(); +}); diff --git a/apps/meteor/client/hooks/roomActions/useE2EERoomAction.spec.ts b/apps/meteor/client/hooks/roomActions/useE2EERoomAction.spec.ts index eb0cbe5b24f4..e04e25f574e6 100644 --- a/apps/meteor/client/hooks/roomActions/useE2EERoomAction.spec.ts +++ b/apps/meteor/client/hooks/roomActions/useE2EERoomAction.spec.ts @@ -1,5 +1,5 @@ import { useSetting, usePermission, useEndpoint } from '@rocket.chat/ui-contexts'; -import { act, renderHook } from '@testing-library/react-hooks'; +import { act, renderHook, waitFor } from '@testing-library/react'; import { E2EEState } from '../../../app/e2e/client/E2EEState'; import { e2e } from '../../../app/e2e/client/rocketchat.e2e'; @@ -72,7 +72,7 @@ describe('useE2EERoomAction', () => { it('should dispatch error toast message when otrState is ESTABLISHED', async () => { (useOTR as jest.Mock).mockReturnValue({ otrState: OtrRoomState.ESTABLISHED }); - const { result } = renderHook(() => useE2EERoomAction()); + const { result } = renderHook(() => useE2EERoomAction(), { legacyRoot: true }); await act(async () => { await result?.current?.action?.(); @@ -84,39 +84,41 @@ describe('useE2EERoomAction', () => { it('should dispatch error toast message when otrState is ESTABLISHING', async () => { (useOTR as jest.Mock).mockReturnValue({ otrState: OtrRoomState.ESTABLISHING }); - const { result } = renderHook(() => useE2EERoomAction()); + const { result } = renderHook(() => useE2EERoomAction(), { legacyRoot: true }); - await act(async () => { - await result?.current?.action?.(); + act(() => { + result?.current?.action?.(); }); - expect(dispatchToastMessage).toHaveBeenCalledWith({ type: 'error', message: 'E2EE_not_available_OTR' }); + await waitFor(() => expect(dispatchToastMessage).toHaveBeenCalledWith({ type: 'error', message: 'E2EE_not_available_OTR' })); }); it('should dispatch error toast message when otrState is REQUESTED', async () => { (useOTR as jest.Mock).mockReturnValue({ otrState: OtrRoomState.REQUESTED }); - const { result } = renderHook(() => useE2EERoomAction()); + const { result } = renderHook(() => useE2EERoomAction(), { legacyRoot: true }); - await act(async () => { - await result?.current?.action?.(); + act(() => { + result?.current?.action?.(); }); - expect(dispatchToastMessage).toHaveBeenCalledWith({ type: 'error', message: 'E2EE_not_available_OTR' }); + await waitFor(() => expect(dispatchToastMessage).toHaveBeenCalledWith({ type: 'error', message: 'E2EE_not_available_OTR' })); }); it('should dispatch success toast message when encryption is enabled', async () => { (useOTR as jest.Mock).mockReturnValue({ otrState: OtrRoomState.NOT_STARTED }); - const { result } = renderHook(() => useE2EERoomAction()); + const { result } = renderHook(() => useE2EERoomAction(), { legacyRoot: true }); - await act(async () => { - await result?.current?.action?.(); + act(() => { + result?.current?.action?.(); }); - expect(dispatchToastMessage).toHaveBeenCalledWith({ - type: 'success', - message: 'E2E_Encryption_enabled_for_room', - }); + await waitFor(() => + expect(dispatchToastMessage).toHaveBeenCalledWith({ + type: 'success', + message: 'E2E_Encryption_enabled_for_room', + }), + ); }); }); diff --git a/apps/meteor/client/hooks/roomActions/useStartCallRoomAction.ts b/apps/meteor/client/hooks/roomActions/useStartCallRoomAction.ts index 9d867d571cf7..8d1fa251c051 100644 --- a/apps/meteor/client/hooks/roomActions/useStartCallRoomAction.ts +++ b/apps/meteor/client/hooks/roomActions/useStartCallRoomAction.ts @@ -15,7 +15,6 @@ export const useStartCallRoomAction = () => { const federated = isRoomFederated(room); const ownUser = room.uids?.length === 1 ?? false; - const live = room?.streamingOptions?.type === 'call' ?? false; const permittedToPostReadonly = usePermission('post-readonly', room._id); const permittedToCallManagement = usePermission('call-management', room._id); @@ -81,8 +80,8 @@ export const useStartCallRoomAction = () => { disabled: true, }), full: true, - order: live ? -1 : 4, + order: 4, featured: true, }; - }, [allowed, disabled, groups, handleOpenVideoConf, live, t]); + }, [allowed, disabled, groups, handleOpenVideoConf, t]); }; diff --git a/apps/meteor/client/hooks/useAnalyticsEventTracking.ts b/apps/meteor/client/hooks/useAnalyticsEventTracking.ts index 78e078ef0070..9d1acf7b4318 100644 --- a/apps/meteor/client/hooks/useAnalyticsEventTracking.ts +++ b/apps/meteor/client/hooks/useAnalyticsEventTracking.ts @@ -55,7 +55,7 @@ export const useAnalyticsEventTracking = () => { callbacks.add( 'afterSaveMessage', - (_message, room, _uid) => { + (_message, { room }) => { trackEvent('Message', 'Send', `${room.name} (${room._id})`); }, callbacks.priority.LOW, diff --git a/apps/meteor/client/hooks/useAppSlashCommands.ts b/apps/meteor/client/hooks/useAppSlashCommands.ts index c49c629a2a06..3a925cb24690 100644 --- a/apps/meteor/client/hooks/useAppSlashCommands.ts +++ b/apps/meteor/client/hooks/useAppSlashCommands.ts @@ -3,7 +3,7 @@ import { useEndpoint, useStream, useUserId } from '@rocket.chat/ui-contexts'; import { useQuery, useQueryClient } from '@tanstack/react-query'; import { useEffect } from 'react'; -import { slashCommands } from '../../app/utils/lib/slashCommand'; +import { slashCommands } from '../../app/utils/client/slashCommand'; export const useAppSlashCommands = () => { const queryClient = useQueryClient(); diff --git a/apps/meteor/client/hooks/useOTR.spec.tsx b/apps/meteor/client/hooks/useOTR.spec.tsx index 0206d96ca176..89082c072952 100644 --- a/apps/meteor/client/hooks/useOTR.spec.tsx +++ b/apps/meteor/client/hooks/useOTR.spec.tsx @@ -1,5 +1,5 @@ import { useUserId } from '@rocket.chat/ui-contexts'; -import { renderHook } from '@testing-library/react-hooks'; +import { renderHook } from '@testing-library/react'; import OTR from '../../app/otr/client/OTR'; import { OtrRoomState } from '../../app/otr/lib/OtrRoomState'; @@ -27,7 +27,7 @@ describe('useOTR', () => { (useUserId as jest.Mock).mockReturnValue(undefined); (useRoom as jest.Mock).mockReturnValue({ _id: 'roomId' }); - const { result } = renderHook(() => useOTR()); + const { result } = renderHook(() => useOTR(), { legacyRoot: true }); expect(result.current.otr).toBeUndefined(); expect(result.current.otrState).toBe(OtrRoomState.ERROR); @@ -37,7 +37,7 @@ describe('useOTR', () => { (useUserId as jest.Mock).mockReturnValue('userId'); (useRoom as jest.Mock).mockReturnValue(undefined); - const { result } = renderHook(() => useOTR()); + const { result } = renderHook(() => useOTR(), { legacyRoot: true }); expect(result.current.otr).toBeUndefined(); expect(result.current.otrState).toBe(OtrRoomState.ERROR); @@ -48,7 +48,7 @@ describe('useOTR', () => { (useRoom as jest.Mock).mockReturnValue({ _id: 'roomId' }); (OTR.getInstanceByRoomId as jest.Mock).mockReturnValue(undefined); - const { result } = renderHook(() => useOTR()); + const { result } = renderHook(() => useOTR(), { legacyRoot: true }); expect(result.current.otr).toBeUndefined(); expect(result.current.otrState).toBe(OtrRoomState.ERROR); @@ -62,7 +62,7 @@ describe('useOTR', () => { (useRoom as jest.Mock).mockReturnValue({ _id: 'roomId' }); (OTR.getInstanceByRoomId as jest.Mock).mockReturnValue(mockOtrInstance); - const { result } = renderHook(() => useOTR()); + const { result } = renderHook(() => useOTR(), { legacyRoot: true }); expect(result.current.otr).toBe(mockOtrInstance); expect(result.current.otrState).toBe(OtrRoomState.NOT_STARTED); diff --git a/apps/meteor/client/hooks/usePruneWarningMessage.spec.ts b/apps/meteor/client/hooks/usePruneWarningMessage.spec.ts index bb602cb81a3a..4f51bd2040de 100644 --- a/apps/meteor/client/hooks/usePruneWarningMessage.spec.ts +++ b/apps/meteor/client/hooks/usePruneWarningMessage.spec.ts @@ -1,5 +1,5 @@ import type { IRoomWithRetentionPolicy } from '@rocket.chat/core-typings'; -import { renderHook } from '@testing-library/react-hooks'; +import { renderHook } from '@testing-library/react'; import { createRenteionPolicySettingsMock as createMock } from '../../tests/mocks/client/mockRetentionPolicySettings'; import { createFakeRoom } from '../../tests/mocks/data'; @@ -30,6 +30,7 @@ describe('usePruneWarningMessage hook', () => { it('Should update the message after the nextRunDate has passaed', async () => { const fakeRoom = createFakeRoom({ t: 'c' }); const { result } = renderHook(() => usePruneWarningMessage(fakeRoom), { + legacyRoot: true, wrapper: createMock({ appliesToChannels: true, TTLChannels: 60000, @@ -43,6 +44,7 @@ describe('usePruneWarningMessage hook', () => { it('Should return the default warning with precision set to every_hour', () => { const fakeRoom = createFakeRoom({ t: 'c' }); const { result } = renderHook(() => usePruneWarningMessage(fakeRoom), { + legacyRoot: true, wrapper: createMock({ appliesToChannels: true, TTLChannels: 60000, @@ -55,6 +57,7 @@ describe('usePruneWarningMessage hook', () => { it('Should return the default warning with precision set to every_six_hours', () => { const fakeRoom = createFakeRoom({ t: 'c' }); const { result } = renderHook(() => usePruneWarningMessage(fakeRoom), { + legacyRoot: true, wrapper: createMock({ appliesToChannels: true, TTLChannels: 60000, @@ -67,6 +70,7 @@ describe('usePruneWarningMessage hook', () => { it('Should return the default warning with precision set to every_day', () => { const fakeRoom = createFakeRoom({ t: 'c' }); const { result } = renderHook(() => usePruneWarningMessage(fakeRoom), { + legacyRoot: true, wrapper: createMock({ appliesToChannels: true, TTLChannels: 60000, @@ -79,6 +83,7 @@ describe('usePruneWarningMessage hook', () => { it('Should return the default warning with advanced precision', () => { const fakeRoom = createFakeRoom({ t: 'c' }); const { result } = renderHook(() => usePruneWarningMessage(fakeRoom), { + legacyRoot: true, wrapper: createMock({ appliesToChannels: true, TTLChannels: 60000, @@ -94,6 +99,7 @@ describe('usePruneWarningMessage hook', () => { it('Should return the default warning', () => { const fakeRoom = createFakeRoom({ t: 'c' }); const { result } = renderHook(() => usePruneWarningMessage(fakeRoom), { + legacyRoot: true, wrapper: createMock({ appliesToChannels: true, TTLChannels: 60000, @@ -105,6 +111,7 @@ describe('usePruneWarningMessage hook', () => { it('Should return the unpinned messages warning', () => { const fakeRoom = createFakeRoom({ t: 'c' }); const { result } = renderHook(() => usePruneWarningMessage(fakeRoom), { + legacyRoot: true, wrapper: createMock({ appliesToChannels: true, TTLChannels: 60000, @@ -118,6 +125,7 @@ describe('usePruneWarningMessage hook', () => { const fakeRoom = createFakeRoom({ t: 'c' }); const { result } = renderHook(() => usePruneWarningMessage(fakeRoom), { + legacyRoot: true, wrapper: createMock({ appliesToChannels: true, TTLChannels: 60000, @@ -131,6 +139,7 @@ describe('usePruneWarningMessage hook', () => { const fakeRoom = createFakeRoom({ t: 'c' }); const { result } = renderHook(() => usePruneWarningMessage(fakeRoom), { + legacyRoot: true, wrapper: createMock({ appliesToChannels: true, TTLChannels: 60000, @@ -146,6 +155,7 @@ describe('usePruneWarningMessage hook', () => { it('Should return the default warning', () => { const fakeRoom = createFakeRoom({ t: 'p', ...getRetentionRoomProps() }); const { result } = renderHook(() => usePruneWarningMessage(fakeRoom), { + legacyRoot: true, wrapper: createMock(), }); expect(result.current).toEqual('30 days June 1, 2024, 12:30 AM'); @@ -154,6 +164,7 @@ describe('usePruneWarningMessage hook', () => { it('Should return the unpinned messages warning', () => { const fakeRoom = createFakeRoom({ t: 'p', ...getRetentionRoomProps({ excludePinned: true }) }); const { result } = renderHook(() => usePruneWarningMessage(fakeRoom), { + legacyRoot: true, wrapper: createMock(), }); expect(result.current).toEqual('Unpinned 30 days June 1, 2024, 12:30 AM'); @@ -163,6 +174,7 @@ describe('usePruneWarningMessage hook', () => { const fakeRoom = createFakeRoom({ t: 'p', ...getRetentionRoomProps({ filesOnly: true }) }); const { result } = renderHook(() => usePruneWarningMessage(fakeRoom), { + legacyRoot: true, wrapper: createMock(), }); expect(result.current).toEqual('FilesOnly 30 days June 1, 2024, 12:30 AM'); @@ -172,6 +184,7 @@ describe('usePruneWarningMessage hook', () => { const fakeRoom = createFakeRoom({ t: 'p', ...getRetentionRoomProps({ excludePinned: true, filesOnly: true }) }); const { result } = renderHook(() => usePruneWarningMessage(fakeRoom), { + legacyRoot: true, wrapper: createMock(), }); expect(result.current).toEqual('UnpinnedFilesOnly 30 days June 1, 2024, 12:30 AM'); diff --git a/apps/meteor/client/lib/chats/flows/uploadFiles.ts b/apps/meteor/client/lib/chats/flows/uploadFiles.ts index 32e17da8ac6f..a16e368198bb 100644 --- a/apps/meteor/client/lib/chats/flows/uploadFiles.ts +++ b/apps/meteor/client/lib/chats/flows/uploadFiles.ts @@ -2,6 +2,7 @@ import type { IMessage, FileAttachmentProps, IE2EEMessage, IUpload } from '@rock import { isRoomFederated } from '@rocket.chat/core-typings'; 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'; @@ -83,6 +84,11 @@ export const uploadFiles = async (chat: ChatAPI, files: readonly File[], resetFi return; } + if (!settings.get('E2E_Enable_Encrypt_Files')) { + uploadFile(file, { description }); + return; + } + const shouldConvertSentMessages = await e2eRoom.shouldConvertSentMessages({ msg }); if (!shouldConvertSentMessages) { diff --git a/apps/meteor/tests/unit/client/lib/download.spec.ts b/apps/meteor/client/lib/download.spec.ts similarity index 71% rename from apps/meteor/tests/unit/client/lib/download.spec.ts rename to apps/meteor/client/lib/download.spec.ts index a5196264a18f..70051fd80bbe 100644 --- a/apps/meteor/tests/unit/client/lib/download.spec.ts +++ b/apps/meteor/client/lib/download.spec.ts @@ -1,47 +1,44 @@ -import { expect, spy } from 'chai'; -import { describe, it } from 'mocha'; - -import { download, downloadAs, downloadCsvAs, downloadJsonAs } from '../../../../client/lib/download'; +import { download, downloadAs, downloadCsvAs, downloadJsonAs } from './download'; describe('download', () => { it('should work', () => { - const listener = spy(); + const listener = jest.fn(); document.addEventListener('click', listener, false); download('about:blank', 'blank'); document.removeEventListener('click', listener, false); - expect(listener).to.have.been.called(); + expect(listener).toHaveBeenCalled(); }); }); describe('downloadAs', () => { it('should work', () => { - const listener = spy(); + const listener = jest.fn(); document.addEventListener('click', listener, false); downloadAs({ data: [] }, 'blank'); document.removeEventListener('click', listener, false); - expect(listener).to.have.been.called(); + expect(listener).toHaveBeenCalled(); }); }); describe('downloadJsonAs', () => { it('should work', () => { - const listener = spy(); + const listener = jest.fn(); document.addEventListener('click', listener, false); downloadJsonAs({}, 'blank'); document.removeEventListener('click', listener, false); - expect(listener).to.have.been.called(); + expect(listener).toHaveBeenCalled(); }); }); describe('downloadCsvAs', () => { it('should work', () => { - const listener = spy(); + const listener = jest.fn(); document.addEventListener('click', listener, false); downloadCsvAs( @@ -53,6 +50,6 @@ describe('downloadCsvAs', () => { ); document.removeEventListener('click', listener, false); - expect(listener).to.have.been.called(); + expect(listener).toHaveBeenCalled(); }); }); diff --git a/apps/meteor/client/lib/errors/InvalidCommandUsage.ts b/apps/meteor/client/lib/errors/InvalidCommandUsage.ts new file mode 100644 index 000000000000..66e240cf2804 --- /dev/null +++ b/apps/meteor/client/lib/errors/InvalidCommandUsage.ts @@ -0,0 +1,7 @@ +import { RocketChatError } from './RocketChatError'; + +export class InvalidCommandUsage extends RocketChatError<'invalid-command-usage'> { + constructor(message = 'Executing a command requires at least a message with a room id.', details?: string) { + super('invalid-command-usage', message, details); + } +} diff --git a/apps/meteor/client/lib/errors/InvalidPreview.ts b/apps/meteor/client/lib/errors/InvalidPreview.ts new file mode 100644 index 000000000000..2c56a74a88e4 --- /dev/null +++ b/apps/meteor/client/lib/errors/InvalidPreview.ts @@ -0,0 +1,7 @@ +import { RocketChatError } from './RocketChatError'; + +export class InvalidPreview extends RocketChatError<'error-invalid-preview'> { + constructor(message = 'Preview Item must have an id, type, and value.', details?: string) { + super('error-invalid-preview', message, details); + } +} diff --git a/apps/meteor/client/lib/errors/index.ts b/apps/meteor/client/lib/errors/index.ts new file mode 100644 index 000000000000..6c57c5f25da6 --- /dev/null +++ b/apps/meteor/client/lib/errors/index.ts @@ -0,0 +1,2 @@ +export * from './InvalidCommandUsage'; +export * from './InvalidPreview'; diff --git a/apps/meteor/client/lib/federation/Federation.spec.ts b/apps/meteor/client/lib/federation/Federation.spec.ts new file mode 100644 index 000000000000..1e367cbb9c6d --- /dev/null +++ b/apps/meteor/client/lib/federation/Federation.spec.ts @@ -0,0 +1,536 @@ +import type { IRoom, ISubscription, IUser, ValueOf } from '@rocket.chat/core-typings'; + +import { RoomRoles } from '../../../app/models/client'; +import { RoomMemberActions, RoomSettingsEnum } from '../../../definition/IRoomTypeConfig'; +import * as Federation from './Federation'; + +jest.mock('../../../app/models/client', () => ({ + RoomRoles: { + findOne: jest.fn(), + }, +})); + +afterEach(() => { + (RoomRoles.findOne as jest.Mock).mockClear(); +}); + +describe('#actionAllowed()', () => { + const me = 'user-id'; + const them = 'other-user-id'; + + it('should return false if the room is not federated', () => { + expect( + Federation.actionAllowed({ federated: false }, RoomMemberActions.REMOVE_USER, 'user-id', { roles: ['owner'] } as ISubscription), + ).toBe(false); + }); + + it('should return false if the room is a direct message', () => { + expect( + Federation.actionAllowed({ federated: true, t: 'd' }, RoomMemberActions.REMOVE_USER, 'user-id', { + roles: ['owner'], + } as ISubscription), + ).toBe(false); + }); + + it('should return false if the user is not subscribed to the room', () => { + expect(Federation.actionAllowed({ federated: true }, RoomMemberActions.REMOVE_USER, 'user-id', undefined)).toBe(false); + }); + + it('should return false if the user is trying to remove himself', () => { + expect( + Federation.actionAllowed({ federated: true }, RoomMemberActions.REMOVE_USER, 'user-id', { + u: { _id: 'user-id' }, + roles: ['owner'], + } as ISubscription), + ).toBe(false); + }); + + describe('Owners', () => { + const myRole = ['owner']; + + describe('Seeing another owners', () => { + const theirRole = ['owner']; + + it('should return true if the user want to remove himself as an owner', () => { + (RoomRoles.findOne as jest.Mock).mockReturnValue({ roles: theirRole }); + expect( + Federation.actionAllowed({ federated: true }, RoomMemberActions.SET_AS_OWNER, me, { + u: { _id: me }, + roles: myRole, + } as ISubscription), + ).toBe(true); + }); + + it('should return true if the user want to add himself as a moderator (Demoting himself to moderator)', () => { + (RoomRoles.findOne as jest.Mock).mockReturnValue({ roles: theirRole }); + expect( + Federation.actionAllowed({ federated: true }, RoomMemberActions.SET_AS_MODERATOR, me, { + u: { _id: me }, + roles: myRole, + } as ISubscription), + ).toBe(true); + }); + + it('should return false if the user want to remove another owners as an owner', () => { + (RoomRoles.findOne as jest.Mock).mockReturnValue({ roles: theirRole }); + expect( + Federation.actionAllowed({ federated: true }, RoomMemberActions.SET_AS_OWNER, me, { + u: { _id: them }, + roles: myRole, + } as ISubscription), + ).toBe(false); + }); + + it('should return false if the user want to remove another owners from the room', () => { + (RoomRoles.findOne as jest.Mock).mockReturnValue({ roles: theirRole }); + expect( + Federation.actionAllowed({ federated: true }, RoomMemberActions.REMOVE_USER, me, { + u: { _id: them }, + roles: myRole, + } as ISubscription), + ).toBe(false); + }); + }); + + describe('Seeing moderators', () => { + const theirRole = ['moderator']; + + it('should return true if the user want to add/remove moderators as an owner', () => { + (RoomRoles.findOne as jest.Mock).mockReturnValue({ roles: theirRole }); + expect( + Federation.actionAllowed({ federated: true }, RoomMemberActions.SET_AS_OWNER, me, { + u: { _id: them }, + roles: myRole, + } as ISubscription), + ).toBe(true); + }); + + it('should return true if the user want to remove moderators as a moderator', () => { + (RoomRoles.findOne as jest.Mock).mockReturnValue({ roles: theirRole }); + expect( + Federation.actionAllowed({ federated: true }, RoomMemberActions.SET_AS_MODERATOR, me, { + u: { _id: them }, + roles: myRole, + } as ISubscription), + ).toBe(true); + }); + + it('should return true if the user want to remove moderators from the room', () => { + (RoomRoles.findOne as jest.Mock).mockReturnValue({ roles: theirRole }); + expect( + Federation.actionAllowed({ federated: true }, RoomMemberActions.REMOVE_USER, me, { + u: { _id: them }, + roles: myRole, + } as ISubscription), + ).toBe(true); + }); + }); + + describe('Seeing normal users', () => { + it('should return true if the user want to add/remove normal users as an owner', () => { + (RoomRoles.findOne as jest.Mock).mockReturnValue(undefined); + expect( + Federation.actionAllowed({ federated: true }, RoomMemberActions.SET_AS_OWNER, me, { + u: { _id: them }, + roles: myRole, + } as ISubscription), + ).toBe(true); + }); + + it('should return true if the user want to add/remove normal users as a moderator', () => { + (RoomRoles.findOne as jest.Mock).mockReturnValue(undefined); + expect( + Federation.actionAllowed({ federated: true }, RoomMemberActions.SET_AS_MODERATOR, me, { + u: { _id: them }, + roles: myRole, + } as ISubscription), + ).toBe(true); + }); + + it('should return true if the user want to remove normal users from the room', () => { + (RoomRoles.findOne as jest.Mock).mockReturnValue(undefined); + expect( + Federation.actionAllowed({ federated: true }, RoomMemberActions.REMOVE_USER, me, { + u: { _id: them }, + roles: myRole, + } as ISubscription), + ).toBe(true); + }); + }); + }); + + describe('Moderators', () => { + const myRole = ['moderator']; + + describe('Seeing owners', () => { + const theirRole = ['owner']; + + it('should return false if the user want to add/remove owners as a moderator', () => { + (RoomRoles.findOne as jest.Mock).mockReturnValue({ roles: theirRole }); + expect( + Federation.actionAllowed({ federated: true }, RoomMemberActions.SET_AS_OWNER, me, { + u: { _id: me }, + roles: myRole, + } as ISubscription), + ).toBe(false); + }); + + it('should return false if the user want to add/remove owners as a moderator', () => { + (RoomRoles.findOne as jest.Mock).mockReturnValue({ roles: theirRole }); + expect( + Federation.actionAllowed({ federated: true }, RoomMemberActions.SET_AS_MODERATOR, me, { + u: { _id: me }, + roles: myRole, + } as ISubscription), + ).toBe(false); + }); + + it('should return false if the user want to add/remove owners as a moderator', () => { + (RoomRoles.findOne as jest.Mock).mockReturnValue({ roles: theirRole }); + expect( + Federation.actionAllowed({ federated: true }, RoomMemberActions.SET_AS_MODERATOR, me, { + u: { _id: me }, + roles: myRole, + } as ISubscription), + ).toBe(false); + }); + + it('should return false if the user want to remove owners from the room', () => { + (RoomRoles.findOne as jest.Mock).mockReturnValue({ roles: theirRole }); + expect( + Federation.actionAllowed({ federated: true }, RoomMemberActions.REMOVE_USER, me, { + u: { _id: me }, + roles: myRole, + } as ISubscription), + ).toBe(false); + }); + }); + + describe('Seeing another moderators', () => { + const theirRole = ['moderator']; + + it('should return false if the user want to add/remove moderator as an owner', () => { + (RoomRoles.findOne as jest.Mock).mockReturnValue({ roles: theirRole }); + expect( + Federation.actionAllowed({ federated: true }, RoomMemberActions.SET_AS_OWNER, me, { + u: { _id: them }, + roles: myRole, + } as ISubscription), + ).toBe(false); + }); + + it('should return true if the user want to remove himself as a moderator (Demoting himself)', () => { + (RoomRoles.findOne as jest.Mock).mockReturnValue({ roles: theirRole }); + expect( + Federation.actionAllowed({ federated: true }, RoomMemberActions.SET_AS_MODERATOR, me, { + u: { _id: me }, + roles: myRole, + } as ISubscription), + ).toBe(true); + }); + + it('should return false if the user want to promote himself as an owner', () => { + (RoomRoles.findOne as jest.Mock).mockReturnValue({ roles: theirRole }); + expect( + Federation.actionAllowed({ federated: true }, RoomMemberActions.SET_AS_OWNER, me, { + u: { _id: me }, + roles: myRole, + } as ISubscription), + ).toBe(false); + }); + + it('should return false if the user want to remove another moderator from their role', () => { + (RoomRoles.findOne as jest.Mock).mockReturnValue({ roles: theirRole }); + expect( + Federation.actionAllowed({ federated: true }, RoomMemberActions.SET_AS_MODERATOR, me, { + u: { _id: them }, + roles: myRole, + } as ISubscription), + ).toBe(false); + }); + + it('should return false if the user want to remove another moderator from the room', () => { + (RoomRoles.findOne as jest.Mock).mockReturnValue({ roles: theirRole }); + expect( + Federation.actionAllowed({ federated: true }, RoomMemberActions.REMOVE_USER, me, { + u: { _id: them }, + roles: myRole, + } as ISubscription), + ).toBe(false); + }); + }); + + describe('Seeing normal users', () => { + it('should return false if the user want to add/remove normal users as an owner', () => { + (RoomRoles.findOne as jest.Mock).mockReturnValue(undefined); + expect( + Federation.actionAllowed({ federated: true }, RoomMemberActions.SET_AS_OWNER, me, { + u: { _id: them }, + roles: myRole, + } as ISubscription), + ).toBe(false); + }); + + it('should return true if the user want to add/remove normal users as a moderator', () => { + (RoomRoles.findOne as jest.Mock).mockReturnValue(undefined); + expect( + Federation.actionAllowed({ federated: true }, RoomMemberActions.SET_AS_MODERATOR, me, { + u: { _id: them }, + roles: myRole, + } as ISubscription), + ).toBe(true); + }); + + it('should return true if the user want to remove normal users from the room', () => { + (RoomRoles.findOne as jest.Mock).mockReturnValue(undefined); + expect( + Federation.actionAllowed({ federated: true }, RoomMemberActions.REMOVE_USER, me, { + u: { _id: them }, + roles: myRole, + } as ISubscription), + ).toBe(true); + }); + }); + }); + + describe('Normal user', () => { + describe('Seeing owners', () => { + const theirRole = ['owner']; + + it('should return false if the user want to add/remove owners as a normal user', () => { + (RoomRoles.findOne as jest.Mock).mockReturnValue({ roles: theirRole }); + expect( + Federation.actionAllowed({ federated: true }, RoomMemberActions.SET_AS_OWNER, me, { + u: { _id: them }, + } as ISubscription), + ).toBe(false); + }); + + it('should return false if the user want to add/remove moderators as a normal user', () => { + (RoomRoles.findOne as jest.Mock).mockReturnValue({ roles: theirRole }); + expect( + Federation.actionAllowed({ federated: true }, RoomMemberActions.SET_AS_MODERATOR, me, { + u: { _id: them }, + } as ISubscription), + ).toBe(false); + }); + + it('should return false if the user want to remove owners from the room', () => { + (RoomRoles.findOne as jest.Mock).mockReturnValue({ roles: theirRole }); + expect( + Federation.actionAllowed({ federated: true }, RoomMemberActions.REMOVE_USER, me, { + u: { _id: them }, + } as ISubscription), + ).toBe(false); + }); + }); + + describe('Seeing moderators', () => { + const theirRole = ['owner']; + + it('should return false if the user want to add/remove owner as a normal user', () => { + (RoomRoles.findOne as jest.Mock).mockReturnValue({ roles: theirRole }); + expect( + Federation.actionAllowed({ federated: true }, RoomMemberActions.SET_AS_OWNER, me, { + u: { _id: them }, + } as ISubscription), + ).toBe(false); + }); + + it('should return false if the user want to remove a moderator from their role', () => { + (RoomRoles.findOne as jest.Mock).mockReturnValue({ roles: theirRole }); + expect( + Federation.actionAllowed({ federated: true }, RoomMemberActions.SET_AS_MODERATOR, me, { + u: { _id: them }, + } as ISubscription), + ).toBe(false); + }); + + it('should return false if the user want to remove a moderator from the room', () => { + (RoomRoles.findOne as jest.Mock).mockReturnValue({ roles: theirRole }); + expect( + Federation.actionAllowed({ federated: true }, RoomMemberActions.REMOVE_USER, me, { + u: { _id: them }, + } as ISubscription), + ).toBe(false); + }); + }); + + describe('Seeing another normal users', () => { + it('should return false if the user want to add/remove owner as a normal user', () => { + (RoomRoles.findOne as jest.Mock).mockReturnValue(undefined); + expect( + Federation.actionAllowed({ federated: true }, RoomMemberActions.SET_AS_OWNER, me, { + u: { _id: them }, + } as ISubscription), + ).toBe(false); + }); + + it('should return false if the user want to add/remove moderator as a normal user', () => { + (RoomRoles.findOne as jest.Mock).mockReturnValue(undefined); + expect( + Federation.actionAllowed({ federated: true }, RoomMemberActions.SET_AS_OWNER, me, { + u: { _id: them }, + } as ISubscription), + ).toBe(false); + }); + + it('should return false if the user want to remove normal users from the room', () => { + (RoomRoles.findOne as jest.Mock).mockReturnValue(undefined); + expect( + Federation.actionAllowed({ federated: true }, RoomMemberActions.REMOVE_USER, me, { + u: { _id: them }, + } as ISubscription), + ).toBe(false); + }); + + it.each([[RoomMemberActions.SET_AS_MODERATOR], [RoomMemberActions.SET_AS_OWNER], [RoomMemberActions.REMOVE_USER]])( + 'should return false if the user want to %s for himself', + (action) => { + (RoomRoles.findOne as jest.Mock).mockReturnValue(undefined); + expect( + Federation.actionAllowed({ federated: true }, action, me, { + u: { _id: me }, + } as ISubscription), + ).toBe(false); + }, + ); + }); + }); +}); + +describe('#isEditableByTheUser()', () => { + it('should return false if the user is null', () => { + expect(Federation.isEditableByTheUser(undefined, { u: { _id: 'id' } } as IRoom, {} as ISubscription)).toBe(false); + }); + + it('should return false if the room is null', () => { + expect(Federation.isEditableByTheUser({} as IUser, undefined, {} as ISubscription)).toBe(false); + }); + + it('should return false if the subscription is null', () => { + expect(Federation.isEditableByTheUser({} as IUser, {} as IRoom, undefined)).toBe(false); + }); + + it('should return false if the current room is NOT a federated one', () => { + expect(Federation.isEditableByTheUser({ _id: 'differentId' } as IUser, { u: { _id: 'id' } } as IRoom, {} as ISubscription)).toBe(false); + }); + + it('should return false if the current user is NOT the room owner nor moderator', () => { + expect( + Federation.isEditableByTheUser({ _id: 'differentId' } as IUser, { federated: true, u: { _id: 'id' } } as IRoom, {} as ISubscription), + ).toBe(false); + }); + + it('should return true if the current user is a room owner', () => { + expect( + Federation.isEditableByTheUser( + { _id: 'differentId' } as IUser, + { federated: true, u: { _id: 'id' } } as IRoom, + { roles: ['owner'] } as ISubscription, + ), + ).toBe(true); + }); + + it('should return true if the current user is a room moderator', () => { + expect( + Federation.isEditableByTheUser( + { _id: 'differentId' } as IUser, + { federated: true, u: { _id: 'id' } } as IRoom, + { roles: ['moderator'] } as ISubscription, + ), + ).toBe(true); + }); +}); + +describe('#canCreateInviteLinks()', () => { + it('should return false if the user is null', () => { + expect(Federation.canCreateInviteLinks(undefined, { u: { _id: 'id' } } as IRoom, {} as ISubscription)).toBe(false); + }); + + it('should return false if the room is null', () => { + expect(Federation.canCreateInviteLinks({} as IUser, undefined, {} as ISubscription)).toBe(false); + }); + + it('should return false if the subscription is null', () => { + expect(Federation.canCreateInviteLinks({} as IUser, {} as IRoom, undefined)).toBe(false); + }); + + it('should return false if the current room is NOT a federated one', () => { + expect(Federation.canCreateInviteLinks({ _id: 'differentId' } as IUser, { u: { _id: 'id' } } as IRoom, {} as ISubscription)).toBe( + false, + ); + }); + + it('should return false if the current room is federated one but NOT a public one', () => { + expect( + Federation.canCreateInviteLinks({ _id: 'differentId' } as IUser, { federated: true, u: { _id: 'id' } } as IRoom, {} as ISubscription), + ).toBe(false); + }); + + it('should return false if the current room is federated one, a public one but the user is NOT an owner nor moderator', () => { + expect( + Federation.canCreateInviteLinks( + { _id: 'differentId' } as IUser, + { federated: true, t: 'c', u: { _id: 'id' } } as IRoom, + {} as ISubscription, + ), + ).toBe(false); + }); + + it('should return false if the current room is federated one, a public one but the user is NOT an owner nor moderator', () => { + expect( + Federation.canCreateInviteLinks( + { _id: 'differentId' } as IUser, + { federated: true, t: 'c', u: { _id: 'id' } } as IRoom, + {} as ISubscription, + ), + ).toBe(false); + }); + + it('should return true if the current room is federated one, a public one but the user is an owner', () => { + expect( + Federation.canCreateInviteLinks( + { _id: 'differentId' } as IUser, + { federated: true, t: 'c', u: { _id: 'id' } } as IRoom, + { roles: ['owner'] } as ISubscription, + ), + ).toBe(true); + }); + + it('should return true if the current room is federated one, a public one but the user is an moderator', () => { + expect( + Federation.canCreateInviteLinks( + { _id: 'differentId' } as IUser, + { federated: true, t: 'c', u: { _id: 'id' } } as IRoom, + { roles: ['moderator'] } as ISubscription, + ), + ).toBe(true); + }); +}); + +describe('#isRoomSettingAllowed()', () => { + it('should return false if the room is NOT federated', () => { + expect(Federation.isRoomSettingAllowed({ t: 'c' }, RoomSettingsEnum.NAME)).toBe(false); + }); + + it('should return false if the room is a DM one', () => { + expect(Federation.isRoomSettingAllowed({ t: 'd', federated: true }, RoomSettingsEnum.NAME)).toBe(false); + }); + + const allowedSettingsChanges: ValueOf[] = [RoomSettingsEnum.NAME, RoomSettingsEnum.TOPIC]; + + Object.values(RoomSettingsEnum) + .filter((setting) => !allowedSettingsChanges.includes(setting)) + .forEach((setting) => { + it('should return false if the setting change is NOT allowed within the federation context for regular channels', () => { + expect(Federation.isRoomSettingAllowed({ t: 'c', federated: true }, setting)).toBe(false); + }); + }); + + allowedSettingsChanges.forEach((setting) => { + it('should return true if the setting change is allowed within the federation context for regular channels', () => { + expect(Federation.isRoomSettingAllowed({ t: 'c', federated: true }, setting)).toBe(true); + }); + }); +}); diff --git a/apps/meteor/client/lib/minimongo/bson.spec.ts b/apps/meteor/client/lib/minimongo/bson.spec.ts new file mode 100644 index 000000000000..b38b151f2c9e --- /dev/null +++ b/apps/meteor/client/lib/minimongo/bson.spec.ts @@ -0,0 +1,34 @@ +import { getBSONType, compareBSONValues } from './bson'; +import { BSONType } from './types'; + +describe('getBSONType', () => { + it('should work', () => { + expect(getBSONType(1)).toBe(BSONType.Double); + expect(getBSONType('xyz')).toBe(BSONType.String); + expect(getBSONType({})).toBe(BSONType.Object); + expect(getBSONType([])).toBe(BSONType.Array); + expect(getBSONType(new Uint8Array())).toBe(BSONType.BinData); + expect(getBSONType(undefined)).toBe(BSONType.Object); + expect(getBSONType(null)).toBe(BSONType.Null); + expect(getBSONType(false)).toBe(BSONType.Boolean); + expect(getBSONType(/.*/)).toBe(BSONType.Regex); + expect(getBSONType(() => true)).toBe(BSONType.JavaScript); + expect(getBSONType(new Date(0))).toBe(BSONType.Date); + }); +}); + +describe('compareBSONValues', () => { + it('should work for the same types', () => { + expect(compareBSONValues(2, 3)).toBe(-1); + expect(compareBSONValues('xyz', 'abc')).toBe(1); + expect(compareBSONValues({}, {})).toBe(0); + expect(compareBSONValues(true, false)).toBe(1); + expect(compareBSONValues(new Date(0), new Date(1))).toBe(-1); + }); + + it('should work for different types', () => { + expect(compareBSONValues(2, null)).toBe(1); + expect(compareBSONValues('xyz', {})).toBe(-1); + expect(compareBSONValues(false, 3)).toBe(1); + }); +}); diff --git a/apps/meteor/client/lib/minimongo/comparisons.spec.ts b/apps/meteor/client/lib/minimongo/comparisons.spec.ts new file mode 100644 index 000000000000..6209ee0c8298 --- /dev/null +++ b/apps/meteor/client/lib/minimongo/comparisons.spec.ts @@ -0,0 +1,129 @@ +import { equals, isObject, flatSome, some, isEmptyArray } from './comparisons'; + +describe('equals', () => { + it('should return true if two numbers are equal', () => { + expect(equals(1, 1)).toBe(true); + }); + + it('should return false if arguments are null or undefined', () => { + expect(equals(undefined, null)).toBe(false); + expect(equals(null, undefined)).toBe(false); + }); + + it('should return false if arguments arent objects and they are not the same', () => { + expect(equals('not', 'thesame')).toBe(false); + }); + + it('should return true if date objects provided have the same value', () => { + const currentDate = new Date(); + + expect(equals(currentDate, currentDate)).toBe(true); + }); + + it('should return true if 2 equal UInt8Array are provided', () => { + const arr1 = new Uint8Array([1, 2]); + const arr2 = new Uint8Array([1, 2]); + + expect(equals(arr1, arr2)).toBe(true); + }); + + it('should return true if 2 equal arrays are provided', () => { + const arr1 = [1, 2, 4]; + const arr2 = [1, 2, 4]; + + expect(equals(arr1, arr2)).toBe(true); + }); + + it('should return false if 2 arrays with different length are provided', () => { + const arr1 = [1, 4, 5]; + const arr2 = [1, 4, 5, 7]; + + expect(equals(arr1, arr2)).toBe(false); + }); + + it('should return true if the objects provided are "equal"', () => { + const obj = { a: 1 }; + const obj2 = obj; + + expect(equals(obj, obj2)).toBe(true); + }); + + it('should return true if both objects have the same keys', () => { + const obj = { a: 1 }; + const obj2 = { a: 1 }; + + expect(equals(obj, obj2)).toBe(true); + }); +}); + +describe('isObject', () => { + it('should return true if value is an object or function', () => { + const obj = {}; + const func = (a: any): any => a; + + expect(isObject(obj)).toBe(true); + expect(isObject(func)).toBe(true); + }); + + it('should return false for other data types', () => { + expect(isObject(1)).toBe(false); + expect(isObject(true)).toBe(false); + expect(isObject('212')).toBe(false); + }); +}); + +describe('flatSome', () => { + it('should run .some on array', () => { + const arr = [1, 2, 4, 6, 9]; + const isEven = (v: number): boolean => v % 2 === 0; + + expect(flatSome(arr, isEven)).toBe(true); + }); + + it('should run the function on the value when its not an array', () => { + const val = 1; + const isEven = (v: number): boolean => v % 2 === 0; + + expect(flatSome(val, isEven)).toBe(false); + }); +}); + +describe('some', () => { + it('should run .some on array', () => { + const arr = [1, 2, 4, 6, 9]; + const isEven = (v: number | number[]): boolean => { + if (Array.isArray(v)) { + return false; + } + return v % 2 === 0; + }; + + expect(some(arr, isEven)).toBe(true); + }); + + it('should run the function on the value when its not an array', () => { + const val = 1; + const isEven = (v: number | number[]): boolean => { + if (Array.isArray(v)) { + return false; + } + return v % 2 === 0; + }; + + expect(some(val, isEven)).toBe(false); + }); +}); + +describe('isEmptyArray', () => { + it('should return true if array is empty', () => { + expect(isEmptyArray([])).toBe(true); + }); + + it('should return false if value is not an array', () => { + expect(isEmptyArray(1)).toBe(false); + }); + + it('should return false if array is not empty', () => { + expect(isEmptyArray([1, 2])).toBe(false); + }); +}); diff --git a/apps/meteor/client/lib/minimongo/lookups.spec.ts b/apps/meteor/client/lib/minimongo/lookups.spec.ts new file mode 100644 index 000000000000..08f98cfdca4c --- /dev/null +++ b/apps/meteor/client/lib/minimongo/lookups.spec.ts @@ -0,0 +1,10 @@ +import { createLookupFunction } from './lookups'; + +describe('createLookupFunction', () => { + it('should work', () => { + expect(createLookupFunction('a.x')({ a: { x: 1 } })).toStrictEqual([1]); + expect(createLookupFunction('a.x')({ a: { x: [1] } })).toStrictEqual([[1]]); + expect(createLookupFunction('a.x')({ a: 5 })).toStrictEqual([undefined]); + expect(createLookupFunction('a.x')({ a: [{ x: 1 }, { x: [2] }, { y: 3 }] })).toStrictEqual([1, [2], undefined]); + }); +}); diff --git a/apps/meteor/client/lib/parseMessageTextToAstMarkdown.spec.ts b/apps/meteor/client/lib/parseMessageTextToAstMarkdown.spec.ts new file mode 100644 index 000000000000..e48eb15f885c --- /dev/null +++ b/apps/meteor/client/lib/parseMessageTextToAstMarkdown.spec.ts @@ -0,0 +1,550 @@ +import type { IMessage, ITranslatedMessage } from '@rocket.chat/core-typings'; +import type { Options, Root } from '@rocket.chat/message-parser'; + +import { parseMessageAttachments, parseMessageTextToAstMarkdown } from './parseMessageTextToAstMarkdown'; + +describe('parseMessageTextToAstMarkdown', () => { + const date = new Date('2021-10-27T00:00:00.000Z'); + + const parseOptions: Options = { + colors: true, + emoticons: true, + katex: { + dollarSyntax: true, + parenthesisSyntax: true, + }, + }; + + const messageParserTokenMessageWithWrongData: Root = [ + { + type: 'PARAGRAPH', + value: [ + { + type: 'PLAIN_TEXT', + value: 'message', + }, + { + type: 'BOLD', + value: [ + { + type: 'PLAIN_TEXT', + value: 'bold', + }, + ], + }, + { + type: 'PLAIN_TEXT', + value: ' ', + }, + { + type: 'ITALIC', + value: [ + { + type: 'PLAIN_TEXT', + value: 'italic', + }, + ], + }, + { + type: 'PLAIN_TEXT', + value: ' and ', + }, + { + type: 'STRIKE', + value: [ + { + type: 'PLAIN_TEXT', + value: 'strike', + }, + ], + }, + ], + }, + ]; + + const messageParserTokenMessage: Root = [ + { + type: 'PARAGRAPH', + value: [ + { + type: 'PLAIN_TEXT', + value: 'message ', + }, + { + type: 'BOLD', + value: [ + { + type: 'PLAIN_TEXT', + value: 'bold', + }, + ], + }, + { + type: 'PLAIN_TEXT', + value: ' ', + }, + { + type: 'ITALIC', + value: [ + { + type: 'PLAIN_TEXT', + value: 'italic', + }, + ], + }, + { + type: 'PLAIN_TEXT', + value: ' and ', + }, + { + type: 'STRIKE', + value: [ + { + type: 'PLAIN_TEXT', + value: 'strike', + }, + ], + }, + ], + }, + ]; + + const baseMessage: IMessage = { + ts: date, + u: { + _id: 'userId', + name: 'userName', + username: 'userName', + }, + msg: 'message **bold** _italic_ and ~strike~', + rid: 'roomId', + _id: 'messageId', + _updatedAt: date, + urls: [], + }; + + const autoTranslateOptions = { + autoTranslateEnabled: false, + showAutoTranslate: () => false, + }; + + const quoteMessage = { + author_name: 'authorName', + author_link: 'link', + author_icon: 'icon', + md: [], + }; + + it('should return md property populated if the message is parsed', () => { + expect(parseMessageTextToAstMarkdown(baseMessage, parseOptions, autoTranslateOptions).md).toStrictEqual(messageParserTokenMessage); + }); + + it('should return correct parsed md property populated and fail in comparison with different Root element', () => { + expect(parseMessageTextToAstMarkdown(baseMessage, parseOptions, autoTranslateOptions).md).not.toStrictEqual( + messageParserTokenMessageWithWrongData, + ); + }); + + describe('translated', () => { + const translatedMessage: ITranslatedMessage = { + ...baseMessage, + msg: 'message not translated', + translationProvider: 'provider', + translations: { + en: 'message translated', + }, + }; + const translatedMessageParsed: Root = [ + { + type: 'PARAGRAPH', + value: [ + { + type: 'PLAIN_TEXT', + value: 'message translated', + }, + ], + }, + ]; + + const enabledAutoTranslatedOptions = { + autoTranslateEnabled: true, + autoTranslateLanguage: 'en', + showAutoTranslate: () => true, + }; + it('should return correct translated parsed md when translate is active', () => { + expect(parseMessageTextToAstMarkdown(translatedMessage, parseOptions, enabledAutoTranslatedOptions).md).toStrictEqual( + translatedMessageParsed, + ); + }); + + it('should return correct attachment translated parsed md when translate is active', () => { + const attachmentTranslatedMessage = { + ...translatedMessage, + attachments: [ + { + description: 'description', + translations: { + en: 'description translated', + }, + }, + ], + }; + const attachmentTranslatedMessageParsed = { + ...translatedMessage, + md: translatedMessageParsed, + attachments: [ + { + description: 'description', + translations: { + en: 'description translated', + }, + md: [ + { + type: 'PARAGRAPH', + value: [ + { + type: 'PLAIN_TEXT', + value: 'description translated', + }, + ], + }, + ], + }, + ], + }; + + expect(parseMessageTextToAstMarkdown(attachmentTranslatedMessage, parseOptions, enabledAutoTranslatedOptions)).toStrictEqual( + attachmentTranslatedMessageParsed, + ); + }); + + it('should return correct attachment quote translated parsed md when translate is active', () => { + const attachmentTranslatedMessage = { + ...translatedMessage, + attachments: [ + { + text: 'text', + translations: { + en: 'text translated', + }, + }, + ], + }; + const attachmentTranslatedMessageParsed = { + ...translatedMessage, + md: translatedMessageParsed, + attachments: [ + { + text: 'text', + translations: { + en: 'text translated', + }, + md: [ + { + type: 'PARAGRAPH', + value: [ + { + type: 'PLAIN_TEXT', + value: 'text translated', + }, + ], + }, + ], + }, + ], + }; + + expect(parseMessageTextToAstMarkdown(attachmentTranslatedMessage, parseOptions, enabledAutoTranslatedOptions)).toStrictEqual( + attachmentTranslatedMessageParsed, + ); + }); + + it('should return correct multiple attachment quote translated parsed md when translate is active', () => { + const attachmentTranslatedMessage = { + ...translatedMessage, + attachments: [ + { + text: 'text', + translations: { + en: 'text translated', + }, + attachments: [{ ...quoteMessage, text: 'text level 2', translations: { en: 'text level 2 translated' } }], + }, + ], + }; + const attachmentTranslatedMessageParsed = { + ...translatedMessage, + md: translatedMessageParsed, + attachments: [ + { + text: 'text', + translations: { + en: 'text translated', + }, + md: [ + { + type: 'PARAGRAPH', + value: [ + { + type: 'PLAIN_TEXT', + value: 'text translated', + }, + ], + }, + ], + attachments: [ + { + ...quoteMessage, + text: 'text level 2', + translations: { + en: 'text level 2 translated', + }, + }, + ], + }, + ], + }; + + expect(parseMessageTextToAstMarkdown(attachmentTranslatedMessage, parseOptions, enabledAutoTranslatedOptions)).toStrictEqual( + attachmentTranslatedMessageParsed, + ); + }); + }); + + // TODO: Add more tests for each type of message and for each type of token +}); + +describe('parseMessageAttachments', () => { + const parseOptions: Options = { + colors: true, + emoticons: true, + katex: { + dollarSyntax: true, + parenthesisSyntax: true, + }, + }; + + const messageParserTokenMessage: Root = [ + { + type: 'PARAGRAPH', + value: [ + { + type: 'PLAIN_TEXT', + value: 'message ', + }, + { + type: 'BOLD', + value: [ + { + type: 'PLAIN_TEXT', + value: 'bold', + }, + ], + }, + { + type: 'PLAIN_TEXT', + value: ' ', + }, + { + type: 'ITALIC', + value: [ + { + type: 'PLAIN_TEXT', + value: 'italic', + }, + ], + }, + { + type: 'PLAIN_TEXT', + value: ' and ', + }, + { + type: 'STRIKE', + value: [ + { + type: 'PLAIN_TEXT', + value: 'strike', + }, + ], + }, + ], + }, + ]; + + const autoTranslateOptions = { + autoTranslateEnabled: false, + translated: false, + }; + + const attachmentMessage = [ + { + description: 'message **bold** _italic_ and ~strike~', + md: messageParserTokenMessage, + }, + ]; + + describe('parseMessageAttachments', () => { + it('should return md property populated if the message is parsed', () => { + expect(parseMessageAttachments(attachmentMessage, parseOptions, autoTranslateOptions)[0].md).toStrictEqual(messageParserTokenMessage); + }); + + it('should return md property populated if the attachment is not parsed', () => { + expect(parseMessageAttachments([{ ...attachmentMessage[0], md: undefined }], parseOptions, autoTranslateOptions)[0].md).toStrictEqual( + messageParserTokenMessage, + ); + }); + + describe('translated', () => { + const enabledAutoTranslatedOptions = { + translated: true, + autoTranslateLanguage: 'en', + }; + + it('should return correct attachment description translated parsed md when translate is active', () => { + const descriptionAttachment = [ + { + ...attachmentMessage[0], + description: 'attachment not translated', + translationProvider: 'provider', + translations: { + en: 'attachment translated', + }, + }, + ]; + const descriptionAttachmentParsed: Root = [ + { + type: 'PARAGRAPH', + value: [ + { + type: 'PLAIN_TEXT', + value: 'attachment translated', + }, + ], + }, + ]; + + expect(parseMessageAttachments(descriptionAttachment, parseOptions, enabledAutoTranslatedOptions)[0].md).toStrictEqual( + descriptionAttachmentParsed, + ); + }); + + it('should return correct attachment description parsed md when translate is active and auto translate language is undefined', () => { + const descriptionAttachment = [ + { + ...attachmentMessage[0], + description: 'attachment not translated', + translationProvider: 'provider', + translations: { + en: 'attachment translated', + }, + }, + ]; + const descriptionAttachmentParsed: Root = [ + { + type: 'PARAGRAPH', + value: [ + { + type: 'PLAIN_TEXT', + value: 'attachment not translated', + }, + ], + }, + ]; + + expect( + parseMessageAttachments(descriptionAttachment, parseOptions, { + ...enabledAutoTranslatedOptions, + autoTranslateLanguage: undefined, + })[0].md, + ).toStrictEqual(descriptionAttachmentParsed); + }); + + it('should return correct attachment text translated parsed md when translate is active', () => { + const textAttachment = [ + { + ...attachmentMessage[0], + text: 'attachment not translated', + translationProvider: 'provider', + translations: { + en: 'attachment translated', + }, + }, + ]; + const textAttachmentParsed: Root = [ + { + type: 'PARAGRAPH', + value: [ + { + type: 'PLAIN_TEXT', + value: 'attachment translated', + }, + ], + }, + ]; + + expect(parseMessageAttachments(textAttachment, parseOptions, enabledAutoTranslatedOptions)[0].md).toStrictEqual( + textAttachmentParsed, + ); + }); + + it('should return correct attachment text translated parsed md when translate is active and has multiple texts', () => { + const quote = { + author_name: 'authorName', + author_link: 'link', + author_icon: 'icon', + message_link: 'messageLink', + md: [], + text: 'text level 2', + translations: { en: 'text level 2 translated' }, + }; + const textAttachment = [ + { + ...quote, + text: 'attachment not translated', + translationProvider: 'provider', + translations: { + en: 'attachment translated', + }, + attachments: [quote], + }, + ]; + const textAttachmentParsed = { + ...textAttachment[0], + md: [ + { + type: 'PARAGRAPH', + value: [ + { + type: 'PLAIN_TEXT', + value: 'attachment translated', + }, + ], + }, + ], + attachments: [ + { + ...quote, + text: 'text level 2', + translations: { + en: 'text level 2 translated', + }, + md: [ + { + type: 'PARAGRAPH', + value: [ + { + type: 'PLAIN_TEXT', + value: 'text level 2 translated', + }, + ], + }, + ], + }, + ], + }; + + expect(parseMessageAttachments(textAttachment, parseOptions, enabledAutoTranslatedOptions)[0]).toStrictEqual(textAttachmentParsed); + }); + }); + }); +}); diff --git a/apps/meteor/client/lib/utils/isRTLScriptLanguage.spec.ts b/apps/meteor/client/lib/utils/isRTLScriptLanguage.spec.ts new file mode 100644 index 000000000000..127ad4cf1274 --- /dev/null +++ b/apps/meteor/client/lib/utils/isRTLScriptLanguage.spec.ts @@ -0,0 +1,26 @@ +import { isRTLScriptLanguage } from './isRTLScriptLanguage'; + +const testCases = [ + ['en', false], + ['ar', true], + ['dv', true], + ['fa', true], + ['he', true], + ['ku', true], + ['ps', true], + ['sd', true], + ['ug', true], + ['ur', true], + ['yi', true], + ['ar', true], + ['ar-LY', true], + ['dv-MV', true], + ['', false], +] as const; + +testCases.forEach(([parameter, expectedResult]) => { + it(`should return ${JSON.stringify(expectedResult)} for ${JSON.stringify(parameter)}`, () => { + const result = isRTLScriptLanguage(parameter); + expect(result).toBe(expectedResult); + }); +}); diff --git a/apps/meteor/client/lib/utils/waitForElement.spec.ts b/apps/meteor/client/lib/utils/waitForElement.spec.ts new file mode 100644 index 000000000000..ca1ebb67e4d9 --- /dev/null +++ b/apps/meteor/client/lib/utils/waitForElement.spec.ts @@ -0,0 +1,18 @@ +import { waitForElement } from './waitForElement'; + +beforeEach(() => { + document.body.innerHTML = ``; +}); + +it('should return the element when it is already in the dom', async () => { + expect(await waitForElement('.ready')).toBe(document.querySelector('.ready')); +}); + +it('should await until the element be in the dom and return it', async () => { + setTimeout(() => { + const element = document.createElement('div'); + element.setAttribute('class', 'not-ready'); + document.body.appendChild(element); + }, 5); + expect(await waitForElement('.not-ready')).toBe(document.querySelector('.not-ready')); +}); diff --git a/apps/meteor/client/omnichannel/priorities/PriorityEditForm.tsx b/apps/meteor/client/omnichannel/priorities/PriorityEditForm.tsx index d67f637a2b4e..592cd6b0f932 100644 --- a/apps/meteor/client/omnichannel/priorities/PriorityEditForm.tsx +++ b/apps/meteor/client/omnichannel/priorities/PriorityEditForm.tsx @@ -7,7 +7,7 @@ import type { ReactElement } from 'react'; import React, { useState } from 'react'; import { Controller, useForm } from 'react-hook-form'; -import StringSettingInput from '../../views/admin/settings/inputs/StringSettingInput'; +import StringSettingInput from '../../views/admin/settings/Setting/inputs/StringSettingInput'; export type PriorityFormData = { name: string; reset: boolean }; diff --git a/apps/meteor/client/providers/AvatarUrlProvider.tsx b/apps/meteor/client/providers/AvatarUrlProvider.tsx index 6cdc9012f714..b5a92c9117f2 100644 --- a/apps/meteor/client/providers/AvatarUrlProvider.tsx +++ b/apps/meteor/client/providers/AvatarUrlProvider.tsx @@ -11,13 +11,9 @@ type AvatarUrlProviderProps = { const AvatarUrlProvider = ({ children }: AvatarUrlProviderProps) => { const cdnAvatarUrl = String(useSetting('CDN_PREFIX') || ''); - const externalProviderUrl = String(useSetting('Accounts_AvatarExternalProviderUrl') || ''); const contextValue = useMemo( () => ({ getUserPathAvatar: ((): ((uid: string, etag?: string) => string) => { - if (externalProviderUrl) { - return (uid: string): string => externalProviderUrl.trim().replace(/\/+$/, '').replace('{username}', uid); - } if (cdnAvatarUrl) { return (uid: string, etag?: string): string => `${cdnAvatarUrl}/avatar/${uid}${etag ? `?etag=${etag}` : ''}`; } @@ -26,7 +22,7 @@ const AvatarUrlProvider = ({ children }: AvatarUrlProviderProps) => { getRoomPathAvatar: ({ type, ...room }: any): string => roomCoordinator.getRoomDirectives(type || room.t).getAvatarPath({ username: room._id, ...room }) || '', }), - [externalProviderUrl, cdnAvatarUrl], + [cdnAvatarUrl], ); return ; diff --git a/apps/meteor/tests/unit/client/providers/CallProvider/lib/parseStringToIceServers.spec.ts b/apps/meteor/client/providers/CallProvider/lib/parseStringToIceServers.spec.ts similarity index 93% rename from apps/meteor/tests/unit/client/providers/CallProvider/lib/parseStringToIceServers.spec.ts rename to apps/meteor/client/providers/CallProvider/lib/parseStringToIceServers.spec.ts index 1c134f71780c..b24564bfb165 100644 --- a/apps/meteor/tests/unit/client/providers/CallProvider/lib/parseStringToIceServers.spec.ts +++ b/apps/meteor/client/providers/CallProvider/lib/parseStringToIceServers.spec.ts @@ -1,7 +1,4 @@ -import { - parseStringToIceServers, - parseStringToIceServer, -} from '../../../../../../client/providers/CallProvider/lib/parseStringToIceServers'; +import { parseStringToIceServers, parseStringToIceServer } from './parseStringToIceServers'; describe('parseStringToIceServers', () => { describe('parseStringToIceServers', () => { diff --git a/apps/meteor/client/providers/ModalProvider/ModalProvider.spec.tsx b/apps/meteor/client/providers/ModalProvider/ModalProvider.spec.tsx index ea062c324807..fd82af587760 100644 --- a/apps/meteor/client/providers/ModalProvider/ModalProvider.spec.tsx +++ b/apps/meteor/client/providers/ModalProvider/ModalProvider.spec.tsx @@ -8,10 +8,10 @@ import { imperativeModal } from '../../lib/imperativeModal'; import ModalRegion from '../../views/modal/ModalRegion'; import ModalProvider from './ModalProvider'; import ModalProviderWithRegion from './ModalProviderWithRegion'; -import '@testing-library/jest-dom'; const renderWithSuspense = (ui: ReactElement) => render(ui, { + legacyRoot: true, wrapper: ({ children }) => {children}, }); diff --git a/apps/meteor/client/sidebar/header/MatrixFederationSearch/FederatedRoomList.tsx b/apps/meteor/client/sidebar/header/MatrixFederationSearch/FederatedRoomList.tsx index b4ddbf32419d..eae8a91015a8 100644 --- a/apps/meteor/client/sidebar/header/MatrixFederationSearch/FederatedRoomList.tsx +++ b/apps/meteor/client/sidebar/header/MatrixFederationSearch/FederatedRoomList.tsx @@ -28,9 +28,8 @@ const FederatedRoomList = ({ serverName, roomName, count }: FederatedRoomListPro const { mutate: onClickJoin, isLoading: isLoadingMutation } = useMutation( ['federation/joinExternalPublicRoom'], - async ({ id, pageToken }: IFederationPublicRooms) => { - return joinExternalPublicRoom({ externalRoomId: id as `!${string}:${string}`, roomName, pageToken }); - }, + async ({ id, pageToken }: IFederationPublicRooms) => + joinExternalPublicRoom({ externalRoomId: id as `!${string}:${string}`, roomName, pageToken }), { onSuccess: (_, data) => { dispatchToastMessage({ diff --git a/apps/meteor/client/sidebar/header/MatrixFederationSearch/FederatedRoomListItem.tsx b/apps/meteor/client/sidebar/header/MatrixFederationSearch/FederatedRoomListItem.tsx index dfaa79ed44de..01cb22c2e1c5 100644 --- a/apps/meteor/client/sidebar/header/MatrixFederationSearch/FederatedRoomListItem.tsx +++ b/apps/meteor/client/sidebar/header/MatrixFederationSearch/FederatedRoomListItem.tsx @@ -1,5 +1,6 @@ import { css } from '@rocket.chat/css-in-js'; import { Box, Button, Icon } from '@rocket.chat/fuselage'; +import { useUniqueId } from '@rocket.chat/fuselage-hooks'; import type { IFederationPublicRooms } from '@rocket.chat/rest-typings'; import { useTranslation } from '@rocket.chat/ui-contexts'; import React from 'react'; @@ -23,11 +24,12 @@ const FederatedRoomListItem = ({ disabled, }: FederatedRoomListItemProps) => { const t = useTranslation(); + const nameId = useUniqueId(); return ( - + - + {name} {canJoin && ( diff --git a/apps/meteor/client/sidebar/header/MatrixFederationSearch/MatrixFederationManageServerModal.tsx b/apps/meteor/client/sidebar/header/MatrixFederationSearch/MatrixFederationManageServerModal.tsx index e3c953dcb950..6909a2cacae0 100644 --- a/apps/meteor/client/sidebar/header/MatrixFederationSearch/MatrixFederationManageServerModal.tsx +++ b/apps/meteor/client/sidebar/header/MatrixFederationSearch/MatrixFederationManageServerModal.tsx @@ -1,4 +1,5 @@ -import { Divider, Modal, ButtonGroup, Button, Field, TextInput, FieldLabel, FieldRow, FieldError, FieldHint } from '@rocket.chat/fuselage'; +import { Divider, Modal, ButtonGroup, Button, Field, FieldLabel, FieldRow, FieldError, FieldHint, TextInput } from '@rocket.chat/fuselage'; +import { useUniqueId } from '@rocket.chat/fuselage-hooks'; import type { TranslationKey } from '@rocket.chat/ui-contexts'; import { useSetModal, useTranslation, useEndpoint, useToastMessageDispatch } from '@rocket.chat/ui-contexts'; import { useMutation, useQueryClient } from '@tanstack/react-query'; @@ -55,17 +56,21 @@ const MatrixFederationAddServerModal = ({ onClickClose }: MatrixFederationAddSer const { data, isLoading: isLoadingServerList } = useMatrixServerList(); + const titleId = useUniqueId(); + const serverNameId = useUniqueId(); + return ( - + - {t('Manage_servers')} + {t('Manage_servers')} - {t('Server_name')} + {t('Server_name')} ) => { @@ -76,7 +81,7 @@ const MatrixFederationAddServerModal = ({ onClickClose }: MatrixFederationAddSer }} mie={4} /> - diff --git a/apps/meteor/client/sidebar/header/MatrixFederationSearch/MatrixFederationRemoveServerList.tsx b/apps/meteor/client/sidebar/header/MatrixFederationSearch/MatrixFederationRemoveServerList.tsx index 361950cd39c9..88867313a5bc 100644 --- a/apps/meteor/client/sidebar/header/MatrixFederationSearch/MatrixFederationRemoveServerList.tsx +++ b/apps/meteor/client/sidebar/header/MatrixFederationSearch/MatrixFederationRemoveServerList.tsx @@ -1,5 +1,5 @@ import { css } from '@rocket.chat/css-in-js'; -import { Box, Option, Icon } from '@rocket.chat/fuselage'; +import { Box, Option, IconButton } from '@rocket.chat/fuselage'; import { useTranslation, useEndpoint } from '@rocket.chat/ui-contexts'; import { useMutation, useQueryClient } from '@tanstack/react-query'; import React from 'react'; @@ -44,11 +44,13 @@ const MatrixFederationRemoveServerList = ({ servers }: MatrixFederationRemoveSer {servers.map(({ name, default: isDefault }) => ( diff --git a/apps/meteor/client/sidebar/header/MatrixFederationSearch/MatrixFederationSearch.spec.tsx b/apps/meteor/client/sidebar/header/MatrixFederationSearch/MatrixFederationSearch.spec.tsx new file mode 100644 index 000000000000..5072a1310228 --- /dev/null +++ b/apps/meteor/client/sidebar/header/MatrixFederationSearch/MatrixFederationSearch.spec.tsx @@ -0,0 +1,154 @@ +import { mockAppRoot } from '@rocket.chat/mock-providers'; +import { render, screen, within } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import React from 'react'; +import { VirtuosoMockContext } from 'react-virtuoso'; + +import MatrixFederationSearch from './MatrixFederationSearch'; + +jest.mock('../../../lib/rooms/roomCoordinator', () => ({ + roomCoordinator: {}, +})); + +const renderMatrixFederationSearch = ( + serverList = [ + { name: `server-1`, default: true, local: false }, + { name: `server-2`, default: false, local: false }, + { name: `server-3`, default: false, local: false }, + ], +) => { + return render(<>, { + legacyRoot: true, + wrapper: mockAppRoot() + .withEndpoint('GET', '/v1/federation/listServersByUser', () => ({ + servers: serverList, + })) + .withEndpoint('GET', '/v1/federation/searchPublicRooms', ({ serverName, roomName, count }) => ({ + rooms: Array.from({ length: count || 100 }, (_, index) => ({ + id: `Matrix${index}`, + name: `${roomName || 'Matrix'}${index + 1}`, + canJoin: true, + canonicalAlias: `#${serverName}:matrix.org`, + joinedMembers: 44461, + topic: + 'The Official Matrix HQ - chat about Matrix here! | https://matrix.org | https://spec.matrix.org | To support Matrix.org development: https://patreon.com/matrixdotorg | Code of Conduct: https://matrix.org/legal/code-of-conduct/ | This is an English speaking room | The Official Matrix HQ - chat about Matrix here! | https://matrix.org | https://spec.matrix.org | To support Matrix.org development: https://patreon.com/matrixdotorg | Code of Conduct: https://matrix.org/legal/code-of-conduct/ | This is an English speaking room The Official Matrix HQ - chat about Matrix here! | https://matrix.org | https://spec.matrix.org | To support Matrix.org development: https://patreon.com/matrixdotorg | Code of Conduct: https://matrix.org/legal/code-of-conduct/ | This is an English speaking room | The Official Matrix HQ - chat about Matrix here! | https://matrix.org | https://spec.matrix.org | To support Matrix.org development: https://patreon.com/matrixdotorg | Code of Conduct: https://matrix.org/legal/code-of-conduct/ | This is an English speaking room', + })), + count: 1, + total: 73080, + nextPageToken: 'g6FtzZa3oXK+IUpkemFiTlVQUFh6bENKQWhFbDpmYWJyaWMucHVioWTD', + })) + .withEndpoint('POST', '/v1/federation/joinExternalPublicRoom', () => null) + .withEndpoint('POST', '/v1/federation/addServerByUser', ({ serverName }) => { + serverList.push({ name: serverName, default: false, local: false }); + return null; + }) + .withEndpoint('POST', '/v1/federation/removeServerByUser', ({ serverName }) => { + serverList = serverList.filter((server) => server.name !== serverName); + return null; + }) + .withOpenModal() + .wrap((children) => ( + {children} + )) + .build(), + }); +}; + +const openManageServers = async () => { + const manageServerLink = await screen.findByRole('button', { name: 'Manage_server_list' }); + await userEvent.click(manageServerLink); +}; + +it('should render Federated Room search modal', async () => { + renderMatrixFederationSearch(); + + expect(await screen.findByRole('dialog', { name: 'Federation_Federated_room_search' })).toBeInTheDocument(); + + expect(await screen.findByRole('listitem', { name: 'Matrix1' }, { timeout: 2000 })).toBeInTheDocument(); // TODO: remove flakyness + expect(await screen.findByRole('listitem', { name: 'Matrix2' })).toBeInTheDocument(); +}); + +it('should search for rooms', async () => { + renderMatrixFederationSearch(); + + const input = await screen.findByRole('searchbox', { name: 'Search_rooms' }); + expect(input).toBeInTheDocument(); + await userEvent.type(input, 'NotMatrix'); + + expect(await screen.findByRole('listitem', { name: 'NotMatrix1' }, { timeout: 2000 })).toBeInTheDocument(); // TODO: remove flakyness + expect(await screen.findByRole('listitem', { name: 'NotMatrix2' })).toBeInTheDocument(); +}); + +it('should close the modal when joining a room', async () => { + renderMatrixFederationSearch(); + + const firstListItem = await screen.findByRole('listitem', { name: 'Matrix1' }); + const joinButton = await within(firstListItem).findByRole('button', { name: 'Join' }); + + await userEvent.click(joinButton); + + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); +}); + +// TODO: should be a unit test for `MatrixFederationAddServerModal` +describe('server management', () => { + it('should open the manage server modal', async () => { + renderMatrixFederationSearch(); + + await openManageServers(); + + expect(await screen.findByRole('dialog', { name: 'Manage_servers' })).toBeInTheDocument(); + + expect(await screen.findByText('server-1')).toBeInTheDocument(); + expect(await screen.findByText('server-2')).toBeInTheDocument(); + expect(await screen.findByText('server-3')).toBeInTheDocument(); + }); + + it('should return to the Search modal when clicking cancel', async () => { + renderMatrixFederationSearch(); + + await openManageServers(); + + const cancelButton = await screen.findByRole('button', { name: 'Cancel' }); + await userEvent.click(cancelButton); + + expect(await screen.findByRole('dialog', { name: 'Federation_Federated_room_search' })).toBeInTheDocument(); + }); + + it('should return to the Search modal with the new server selected', async () => { + renderMatrixFederationSearch(); + + await openManageServers(); + + const input = await screen.findByRole('textbox', { name: 'Server_name' }); + await userEvent.type(input, 'server-4'); + + const addButton = await screen.findByRole('button', { name: 'Add' }); + await userEvent.click(addButton); + + expect(await screen.findByRole('dialog', { name: 'Federation_Federated_room_search' })).toBeInTheDocument(); + expect(await screen.findByRole('button', { name: 'server-4' })).toBeInTheDocument(); + }); + + it('should remove servers from the list', async () => { + renderMatrixFederationSearch([ + { name: `server-1`, default: true, local: false }, + { name: `server-2`, default: false, local: false }, + { name: `server-3`, default: false, local: false }, + { name: `server-4`, default: false, local: false }, + ]); + + await openManageServers(); + + const defaultItem = await screen.findByRole('listitem', { name: 'server-1' }); + await userEvent.hover(defaultItem); + expect(within(defaultItem).queryByRole('button', { name: 'Remove' })).not.toBeInTheDocument(); + + const lastItem = await screen.findByRole('listitem', { name: 'server-4' }); + await userEvent.hover(lastItem); + const removeButton = await within(lastItem).findByRole('button', { name: 'Remove' }); + await userEvent.click(removeButton); + + expect(screen.queryByRole('listitem', { name: 'server-4' })).not.toBeInTheDocument(); + }); +}); diff --git a/apps/meteor/client/sidebar/header/MatrixFederationSearch/MatrixFederationSearch.tsx b/apps/meteor/client/sidebar/header/MatrixFederationSearch/MatrixFederationSearch.tsx index f3dc779d28c1..741eadf7bc7e 100644 --- a/apps/meteor/client/sidebar/header/MatrixFederationSearch/MatrixFederationSearch.tsx +++ b/apps/meteor/client/sidebar/header/MatrixFederationSearch/MatrixFederationSearch.tsx @@ -1,4 +1,5 @@ import { Modal, Skeleton } from '@rocket.chat/fuselage'; +import { useUniqueId } from '@rocket.chat/fuselage-hooks'; import { useTranslation } from '@rocket.chat/ui-contexts'; import React from 'react'; @@ -13,11 +14,12 @@ type MatrixFederationSearchProps = { const MatrixFederationSearch = ({ onClose, defaultSelectedServer }: MatrixFederationSearchProps) => { const t = useTranslation(); const { data, isLoading } = useMatrixServerList(); + const titleId = useUniqueId(); return ( - + - {t('Federation_Federated_room_search')} + {t('Federation_Federated_room_search')} diff --git a/apps/meteor/client/sidebar/header/MatrixFederationSearch/MatrixFederationSearchModalContent.tsx b/apps/meteor/client/sidebar/header/MatrixFederationSearch/MatrixFederationSearchModalContent.tsx index ec6396a83440..878a019fc059 100644 --- a/apps/meteor/client/sidebar/header/MatrixFederationSearch/MatrixFederationSearchModalContent.tsx +++ b/apps/meteor/client/sidebar/header/MatrixFederationSearch/MatrixFederationSearchModalContent.tsx @@ -1,5 +1,5 @@ import type { SelectOption } from '@rocket.chat/fuselage'; -import { Box, Select, TextInput } from '@rocket.chat/fuselage'; +import { Box, SearchInput, Select } from '@rocket.chat/fuselage'; import { useDebouncedValue } from '@rocket.chat/fuselage-hooks'; import { useSetModal, useTranslation } from '@rocket.chat/ui-contexts'; import type { FormEvent } from 'react'; @@ -33,7 +33,7 @@ const MatrixFederationSearchModalContent = ({ defaultSelectedServer, servers }: const t = useTranslation(); - const serverOptions = useMemo>(() => servers.map((server): SelectOption => [server.name, server.name]), [servers]); + const serverOptions = useMemo(() => servers.map((server): SelectOption => [server.name, server.name]), [servers]); const manageServers = useCallback(() => { setModal( @@ -47,7 +47,8 @@ const MatrixFederationSearchModalContent = ({ defaultSelectedServer, servers }: