diff --git a/.changeset/afraid-guests-jog.md b/.changeset/afraid-guests-jog.md new file mode 100644 index 0000000000000..420b9bb5d3290 --- /dev/null +++ b/.changeset/afraid-guests-jog.md @@ -0,0 +1,6 @@ +--- +"@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/bump-patch-1722087664914.md b/.changeset/bump-patch-1722087664914.md new file mode 100644 index 0000000000000..e1eaa7980afb1 --- /dev/null +++ b/.changeset/bump-patch-1722087664914.md @@ -0,0 +1,5 @@ +--- +'@rocket.chat/meteor': patch +--- + +Bump @rocket.chat/meteor version. diff --git a/.changeset/bump-patch-1722559871139.md b/.changeset/bump-patch-1722559871139.md new file mode 100644 index 0000000000000..e1eaa7980afb1 --- /dev/null +++ b/.changeset/bump-patch-1722559871139.md @@ -0,0 +1,5 @@ +--- +'@rocket.chat/meteor': patch +--- + +Bump @rocket.chat/meteor version. diff --git a/.changeset/bump-patch-1722695753777.md b/.changeset/bump-patch-1722695753777.md new file mode 100644 index 0000000000000..e1eaa7980afb1 --- /dev/null +++ b/.changeset/bump-patch-1722695753777.md @@ -0,0 +1,5 @@ +--- +'@rocket.chat/meteor': patch +--- + +Bump @rocket.chat/meteor version. diff --git a/.changeset/bump-patch-1722930641296.md b/.changeset/bump-patch-1722930641296.md new file mode 100644 index 0000000000000..e1eaa7980afb1 --- /dev/null +++ b/.changeset/bump-patch-1722930641296.md @@ -0,0 +1,5 @@ +--- +'@rocket.chat/meteor': patch +--- + +Bump @rocket.chat/meteor version. diff --git a/.changeset/bump-patch-1723039032546.md b/.changeset/bump-patch-1723039032546.md new file mode 100644 index 0000000000000..e1eaa7980afb1 --- /dev/null +++ b/.changeset/bump-patch-1723039032546.md @@ -0,0 +1,5 @@ +--- +'@rocket.chat/meteor': patch +--- + +Bump @rocket.chat/meteor version. diff --git a/.changeset/bump-patch-1723151441289.md b/.changeset/bump-patch-1723151441289.md new file mode 100644 index 0000000000000..e1eaa7980afb1 --- /dev/null +++ b/.changeset/bump-patch-1723151441289.md @@ -0,0 +1,5 @@ +--- +'@rocket.chat/meteor': patch +--- + +Bump @rocket.chat/meteor version. diff --git a/.changeset/chatty-hounds-hammer.md b/.changeset/chatty-hounds-hammer.md new file mode 100644 index 0000000000000..1a2d3a7de559c --- /dev/null +++ b/.changeset/chatty-hounds-hammer.md @@ -0,0 +1,6 @@ +--- +"@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 new file mode 100644 index 0000000000000..670fa24887b7b --- /dev/null +++ b/.changeset/chilled-yaks-beg.md @@ -0,0 +1,5 @@ +--- +"@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 new file mode 100644 index 0000000000000..a7724b1266952 --- /dev/null +++ b/.changeset/chilly-papayas-march.md @@ -0,0 +1,5 @@ +--- +"@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/cuddly-brooms-approve.md b/.changeset/cuddly-brooms-approve.md new file mode 100644 index 0000000000000..24905bb91c625 --- /dev/null +++ b/.changeset/cuddly-brooms-approve.md @@ -0,0 +1,6 @@ +--- +"@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 new file mode 100644 index 0000000000000..b66ca5157cd58 --- /dev/null +++ b/.changeset/dry-pumas-draw.md @@ -0,0 +1,6 @@ +--- +"@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 new file mode 100644 index 0000000000000..b4bd075ef654c --- /dev/null +++ b/.changeset/empty-readers-teach.md @@ -0,0 +1,8 @@ +--- +"@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/fast-buttons-shake.md b/.changeset/fast-buttons-shake.md new file mode 100644 index 0000000000000..6281fc9941ec3 --- /dev/null +++ b/.changeset/fast-buttons-shake.md @@ -0,0 +1,5 @@ +--- +'@rocket.chat/meteor': minor +--- + +Fixed an issue where FCM actions did not respect environment's proxy settings diff --git a/.changeset/funny-snails-promise.md b/.changeset/funny-snails-promise.md new file mode 100644 index 0000000000000..bdd74a60b1e90 --- /dev/null +++ b/.changeset/funny-snails-promise.md @@ -0,0 +1,10 @@ +--- +"@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 new file mode 100644 index 0000000000000..e2364ccb05e50 --- /dev/null +++ b/.changeset/funny-wolves-tie.md @@ -0,0 +1,5 @@ +--- +'@rocket.chat/meteor': patch +--- + +Fixed issue where bad word filtering was not working in the UI for messages diff --git a/.changeset/grumpy-worms-appear.md b/.changeset/grumpy-worms-appear.md new file mode 100644 index 0000000000000..fb9fab77b24c1 --- /dev/null +++ b/.changeset/grumpy-worms-appear.md @@ -0,0 +1,5 @@ +--- +"@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 new file mode 100644 index 0000000000000..2dfb2151ced05 --- /dev/null +++ b/.changeset/happy-peaches-nail.md @@ -0,0 +1,5 @@ +--- +"@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 new file mode 100644 index 0000000000000..f1d7bb6f3f0e4 --- /dev/null +++ b/.changeset/hip-queens-taste.md @@ -0,0 +1,5 @@ +--- +"@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 new file mode 100644 index 0000000000000..4e50b172e17e7 --- /dev/null +++ b/.changeset/hungry-wombats-act.md @@ -0,0 +1,5 @@ +--- +"@rocket.chat/meteor": patch +--- + +Fixed an issue where non-encrypted attachments were not being downloaded diff --git a/.changeset/large-vans-attack.md b/.changeset/large-vans-attack.md new file mode 100644 index 0000000000000..c1008b2ca06ff --- /dev/null +++ b/.changeset/large-vans-attack.md @@ -0,0 +1,5 @@ +--- +"@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 new file mode 100644 index 0000000000000..3e23797025e1d --- /dev/null +++ b/.changeset/lucky-beds-glow.md @@ -0,0 +1,7 @@ +--- +'@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 new file mode 100644 index 0000000000000..79deda53edfcb --- /dev/null +++ b/.changeset/lucky-countries-look.md @@ -0,0 +1,5 @@ +--- +'@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 new file mode 100644 index 0000000000000..8f37283c6a967 --- /dev/null +++ b/.changeset/many-tables-love.md @@ -0,0 +1,6 @@ +--- +"@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 new file mode 100644 index 0000000000000..c92293d6ae953 --- /dev/null +++ b/.changeset/mean-hairs-move.md @@ -0,0 +1,5 @@ +--- +'@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/nervous-rockets-impress.md b/.changeset/nervous-rockets-impress.md new file mode 100644 index 0000000000000..26e9276193deb --- /dev/null +++ b/.changeset/nervous-rockets-impress.md @@ -0,0 +1,5 @@ +--- +"@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 new file mode 100644 index 0000000000000..7d4e7cd3a57e9 --- /dev/null +++ b/.changeset/new-balloons-speak.md @@ -0,0 +1,5 @@ +--- +'@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-scissors-love.md b/.changeset/new-scissors-love.md new file mode 100644 index 0000000000000..fb962407b353e --- /dev/null +++ b/.changeset/new-scissors-love.md @@ -0,0 +1,12 @@ +--- +'@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 new file mode 100644 index 0000000000000..e99e4f219ef9b --- /dev/null +++ b/.changeset/nice-laws-eat.md @@ -0,0 +1,15 @@ +--- +'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/perfect-coins-camp.md b/.changeset/perfect-coins-camp.md new file mode 100644 index 0000000000000..4dbddf965742d --- /dev/null +++ b/.changeset/perfect-coins-camp.md @@ -0,0 +1,5 @@ +--- +"@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 new file mode 100644 index 0000000000000..2f524c7e5f105 --- /dev/null +++ b/.changeset/polite-foxes-repair.md @@ -0,0 +1,5 @@ +--- +'@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 new file mode 100644 index 0000000000000..f38ef1f923670 --- /dev/null +++ b/.changeset/popular-trees-lay.md @@ -0,0 +1,5 @@ +--- +"@rocket.chat/meteor": patch +--- + +Removed 'Hide' option in the room menu for Omnichannel conversations. diff --git a/.changeset/pre.json b/.changeset/pre.json new file mode 100644 index 0000000000000..40c93f4a63bd8 --- /dev/null +++ b/.changeset/pre.json @@ -0,0 +1,118 @@ +{ + "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", + "bump-patch-1722695753777", + "bump-patch-1722930641296", + "bump-patch-1723039032546", + "bump-patch-1723151441289", + "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 new file mode 100644 index 0000000000000..556fa3af80e12 --- /dev/null +++ b/.changeset/proud-waves-bathe.md @@ -0,0 +1,6 @@ +--- +"@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/quick-ducks-live.md b/.changeset/quick-ducks-live.md new file mode 100644 index 0000000000000..ad628c13d0874 --- /dev/null +++ b/.changeset/quick-ducks-live.md @@ -0,0 +1,5 @@ +--- +"@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 new file mode 100644 index 0000000000000..187bd9d09ddcf --- /dev/null +++ b/.changeset/rare-penguins-hope.md @@ -0,0 +1,6 @@ +--- +"@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 new file mode 100644 index 0000000000000..61cb0d2b7586e --- /dev/null +++ b/.changeset/red-numbers-happen.md @@ -0,0 +1,5 @@ +--- +"@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 new file mode 100644 index 0000000000000..ddf76535087e0 --- /dev/null +++ b/.changeset/red-vans-shave.md @@ -0,0 +1,5 @@ +--- +"@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 new file mode 100644 index 0000000000000..16741e31e54ad --- /dev/null +++ b/.changeset/rich-carpets-brush.md @@ -0,0 +1,5 @@ +--- +'@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/rotten-eggs-end.md b/.changeset/rotten-eggs-end.md new file mode 100644 index 0000000000000..7d0ad6ee5047e --- /dev/null +++ b/.changeset/rotten-eggs-end.md @@ -0,0 +1,7 @@ +--- +"@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/selfish-emus-sing.md b/.changeset/selfish-emus-sing.md new file mode 100644 index 0000000000000..315d674a1857c --- /dev/null +++ b/.changeset/selfish-emus-sing.md @@ -0,0 +1,6 @@ +--- +"@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 new file mode 100644 index 0000000000000..40ee9f8fbb55a --- /dev/null +++ b/.changeset/shaggy-hats-raise.md @@ -0,0 +1,5 @@ +--- +"@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/sixty-nails-clean.md b/.changeset/sixty-nails-clean.md new file mode 100644 index 0000000000000..7d13e02f0bd3f --- /dev/null +++ b/.changeset/sixty-nails-clean.md @@ -0,0 +1,5 @@ +--- +'@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/smooth-lobsters-flash.md b/.changeset/smooth-lobsters-flash.md new file mode 100644 index 0000000000000..541d5069ee9c2 --- /dev/null +++ b/.changeset/smooth-lobsters-flash.md @@ -0,0 +1,5 @@ +--- +'@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 new file mode 100644 index 0000000000000..7273ddcffca48 --- /dev/null +++ b/.changeset/soft-donkeys-thank.md @@ -0,0 +1,8 @@ +--- +"@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 new file mode 100644 index 0000000000000..2d1076845fa90 --- /dev/null +++ b/.changeset/sour-forks-breathe.md @@ -0,0 +1,5 @@ +--- +"@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/thin-windows-reply.md b/.changeset/thin-windows-reply.md new file mode 100644 index 0000000000000..1a32e1ddebfb2 --- /dev/null +++ b/.changeset/thin-windows-reply.md @@ -0,0 +1,5 @@ +--- +'@rocket.chat/meteor': patch +--- + +Fixes an issue not displaying all groups in settings list diff --git a/.changeset/violet-brooms-press.md b/.changeset/violet-brooms-press.md new file mode 100644 index 0000000000000..632026d6fe2e1 --- /dev/null +++ b/.changeset/violet-brooms-press.md @@ -0,0 +1,5 @@ +--- +'@rocket.chat/meteor': patch +--- + +Security Hotfix (https://docs.rocket.chat/guides/security/security-updates) diff --git a/.changeset/weak-insects-sort.md b/.changeset/weak-insects-sort.md new file mode 100644 index 0000000000000..cbbe7c4aa08c6 --- /dev/null +++ b/.changeset/weak-insects-sort.md @@ -0,0 +1,5 @@ +--- +"@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 new file mode 100644 index 0000000000000..abaa9c683d65d --- /dev/null +++ b/.changeset/weak-pets-talk.md @@ -0,0 +1,7 @@ +--- +'@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 new file mode 100644 index 0000000000000..a2d435495cd71 --- /dev/null +++ b/.changeset/weak-taxis-design.md @@ -0,0 +1,5 @@ +--- +'@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 new file mode 100644 index 0000000000000..91748a43c6771 --- /dev/null +++ b/.changeset/weak-tigers-suffer.md @@ -0,0 +1,7 @@ +--- +"@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 new file mode 100644 index 0000000000000..42c9409d9ef37 --- /dev/null +++ b/.changeset/witty-bats-develop.md @@ -0,0 +1,13 @@ +--- +"@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/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md index c2a05cc611660..5a077e74a1d88 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.md +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -35,6 +35,8 @@ If you are experiencing a bug please search our issues to be sure it is not alre ### Server Setup Information: - Version of Rocket.Chat Server: +- License Type: +- Number of Users: - Operating System: - Deployment Method: - Number of Running Instances: diff --git a/.github/actions/update-version-durability/action.yml b/.github/actions/update-version-durability/action.yml new file mode 100644 index 0000000000000..803158ae1a25b --- /dev/null +++ b/.github/actions/update-version-durability/action.yml @@ -0,0 +1,24 @@ +name: Update Version Durability +description: Update Version Durability page on Document360 + +inputs: + GH_TOKEN: + required: true + description: GitHub API Token + type: string + D360_TOKEN: + required: true + description: Document360 API Token + type: string + D360_ARTICLE_ID: + required: true + description: Document360 Article ID + type: string + PUBLISH: + required: true + description: Publish Draft + type: boolean + +runs: + using: node20 + main: index.js diff --git a/.github/actions/update-version-durability/index.js b/.github/actions/update-version-durability/index.js new file mode 100644 index 0000000000000..d96fd7ee45540 --- /dev/null +++ b/.github/actions/update-version-durability/index.js @@ -0,0 +1,217 @@ +import 'colors'; +import axios from 'axios'; +import * as Diff from 'diff'; +import semver from 'semver'; +import crypto from 'crypto'; +import fs from 'fs/promises'; +import BeautyHtml from 'beauty-html'; +import { DOMParser } from 'xmldom'; +import core from '@actions/core'; +import { Octokit } from '@octokit/rest'; + +const D360_TOKEN = core.getInput('D360_TOKEN'); +const D360_ARTICLE_ID = core.getInput('D360_ARTICLE_ID'); +const PUBLISH = core.getInput('PUBLISH') === 'true'; + +const octokit = new Octokit({ + auth: core.getInput('GH_TOKEN'), +}); + + +async function requestDocument360(method = 'get', api, data = {}) { + return axios.request({ + method, + maxBodyLength: Infinity, + url: `https://apihub.us.document360.io/v1/${api}`, + headers: { + 'accept': 'application/json', + 'api_token': D360_TOKEN, + }, + data, + }); +} + +function md5(text) { + return crypto.createHash('md5').update(text).digest("hex"); +} + +async function generateTable({ owner, repo } = {}) { + const response = await requestDocument360('get', `Articles/${D360_ARTICLE_ID}/en`); + + // console.log(response.data.data); + + // const releasesResult = JSON.parse(await fs.readFile('/tmp/releasesResult')); + const releasesResult = await octokit.paginate(octokit.repos.listReleases.endpoint.merge({ owner, repo, per_page: 100 })); + // await fs.writeFile('/tmp/releasesResult', JSON.stringify(releasesResult)); + + const releases = releasesResult + .filter((release) => !release.tag_name.includes('-rc') && semver.gte(release.tag_name, '1.0.0')) + .sort((a, b) => semver.compare(b.tag_name, a.tag_name)); + + const releasesMap = {}; + + for (const release of releases) { + release.releaseDate = new Date(release.published_at); + + releasesMap[release.tag_name] = release; + } + + let index = 0; + // eslint-disable-next-line no-constant-condition + while (true) { + const release = releases[index]; + + release.minor_tag = release.tag_name.replace(/\.\d+$/, ''); + release.minorRelease = releasesMap[`${release.minor_tag}.0`]; + + if (!releases[index + 1]) { + break; + } + + const currentVersion = semver.parse(release.tag_name); + const previousVersion = semver.parse(releases[index + 1].tag_name); + + releases[index + 1].nextRelease = release; + + // Remove duplicated due to patches + if (currentVersion.major === previousVersion.major && currentVersion.minor === previousVersion.minor) { + releases.splice(index + 1, 1); + continue; + } + + index++; + } + + releases[0].last = true; + + const releaseData = []; + + for (const { tag_name, html_url, lts, last, nextRelease, minorRelease, minor_tag} of releases) { + let supportDate; + let supportDateStart; + + let releasedAt = new Date(minorRelease.releaseDate); + releasedAt.setDate(1); + + let minorDate = new Date(minorRelease.releaseDate); + minorDate.setDate(1); + supportDateStart = minorDate; + supportDate = new Date(minorDate); + supportDate.setMonth(supportDate.getMonth() + (lts ? 6 : 6)); + + releaseData.push({ + release: { + version: minor_tag, + releasedAt, + extendedSupport: { + start: supportDateStart, + end: supportDate, + }, + lts: lts === true, + }, + latestPatch: { + version: tag_name, + url: html_url, + } + }) + } + + function header({data, salt = ''}) { + return [ + '', + `

${data}

`, + '', + ].join(''); + } + + function line({data, salt = ''}) { + return [ + '', + `

${data}

`, + '', + ].join(''); + } + + const text = [ + '', + header({data: 'Rocket.Chat Release'}), + header({data: 'Released At'}), + header({data: 'End of Life'}), + '', + ]; + + releaseData.forEach(({release, latestPatch}) => { + const releasedAt = release.releasedAt.toLocaleString('en', { month: 'short', year: "numeric" }); + const endOfLife = !release.extendedSupport + ? 'TBD' + : release.extendedSupport.end.toLocaleString('en', { month: 'short', year: "numeric" }); + const link = `${release.version} (${latestPatch.version})`; + + text.push( + '', + line({data: link}), + line({data: releasedAt, salt: release.version}), + line({data: endOfLife, salt: release.version}), + '', + ); + }); + + const content = response.data.data.html_content.replace(/.+(\n.+)*<\/tbody>/m, `${text.join('').replace(/\t|\n/g, '')}`) + + // console.log(content); + + const parser = new BeautyHtml({ parser: DOMParser }); + const diff = Diff.diffLines(parser.beautify(response.data.data.html_content), parser.beautify(content), { ignoreWhitespace: true, newlineIsToken: false }); + diff.forEach((item) => { + let color = 'green'; + + if (item.removed) { + color = 'red'; + } + + if (item.removed || item.added) { + item.value.split('\n').forEach((line) => { + if (line === '') { return }; + console.log(`${item.removed ? '-' : '+'} ${line}`[color]); + }) + } + }); + + if (diff.length === 1) { + console.log('No changes found'); + return; + } + + if (response.data.data.status === 3) { + console.log('forking article', response.data.data.version_number); + + const forkResponse = await requestDocument360('put', `Articles/${D360_ARTICLE_ID}/fork`, { + lang_code: "en", + user_id: "2511fd00-9558-4826-8d8c-4cc0c110f89c", + version_number: response.data.data.version_number, + }); + + console.log(forkResponse.data); + } + + console.log('Updating article'); + const updateResponse = await requestDocument360('put', `Articles/${D360_ARTICLE_ID}/en`, { + content, + }); + + console.log(updateResponse.data); + + if (PUBLISH) { + console.log('publishing article', updateResponse.data.data.version_number); + + const forkResponse = await requestDocument360('post', `Articles/${D360_ARTICLE_ID}/en/publish`, { + user_id: "2511fd00-9558-4826-8d8c-4cc0c110f89c", + version_number: updateResponse.data.data.version_number, + publish_message: 'Update support versions table via GitHub Action', + }); + + console.log(forkResponse.data); + } +} + +generateTable({ owner: 'RocketChat', repo: 'Rocket.Chat' }); diff --git a/.github/actions/update-version-durability/package-lock.json b/.github/actions/update-version-durability/package-lock.json new file mode 100644 index 0000000000000..889d959cba6d7 --- /dev/null +++ b/.github/actions/update-version-durability/package-lock.json @@ -0,0 +1,378 @@ +{ + "name": "scripts", + "version": "1.0.0", + "lockfileVersion": 1, + "requires": true, + "dependencies": { + "@actions/core": { + "version": "1.10.1", + "resolved": "https://registry.npmjs.org/@actions/core/-/core-1.10.1.tgz", + "integrity": "sha512-3lBR9EDAY+iYIpTnTIXmWcNbX3T2kCkAEQGIQx4NVQ0575nk2k3GRZDTPQG+vVtS2izSLmINlxXf0uLtnrTP+g==", + "requires": { + "@actions/http-client": "^2.0.1", + "uuid": "^8.3.2" + } + }, + "@actions/github": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/@actions/github/-/github-6.0.0.tgz", + "integrity": "sha512-alScpSVnYmjNEXboZjarjukQEzgCRmjMv6Xj47fsdnqGS73bjJNDpiiXmp8jr0UZLdUB6d9jW63IcmddUP+l0g==", + "requires": { + "@actions/http-client": "^2.2.0", + "@octokit/core": "^5.0.1", + "@octokit/plugin-paginate-rest": "^9.0.0", + "@octokit/plugin-rest-endpoint-methods": "^10.0.0" + }, + "dependencies": { + "@octokit/openapi-types": { + "version": "20.0.0", + "resolved": "https://registry.npmjs.org/@octokit/openapi-types/-/openapi-types-20.0.0.tgz", + "integrity": "sha512-EtqRBEjp1dL/15V7WiX5LJMIxxkdiGJnabzYx5Apx4FkQIFgAfKumXeYAqqJCj1s+BMX4cPFIFC4OLCR6stlnA==" + }, + "@octokit/plugin-paginate-rest": { + "version": "9.2.1", + "resolved": "https://registry.npmjs.org/@octokit/plugin-paginate-rest/-/plugin-paginate-rest-9.2.1.tgz", + "integrity": "sha512-wfGhE/TAkXZRLjksFXuDZdmGnJQHvtU/joFQdweXUgzo1XwvBCD4o4+75NtFfjfLK5IwLf9vHTfSiU3sLRYpRw==", + "requires": { + "@octokit/types": "^12.6.0" + } + }, + "@octokit/plugin-rest-endpoint-methods": { + "version": "10.4.1", + "resolved": "https://registry.npmjs.org/@octokit/plugin-rest-endpoint-methods/-/plugin-rest-endpoint-methods-10.4.1.tgz", + "integrity": "sha512-xV1b+ceKV9KytQe3zCVqjg+8GTGfDYwaT1ATU5isiUyVtlVAO3HNdzpS4sr4GBx4hxQ46s7ITtZrAsxG22+rVg==", + "requires": { + "@octokit/types": "^12.6.0" + } + }, + "@octokit/types": { + "version": "12.6.0", + "resolved": "https://registry.npmjs.org/@octokit/types/-/types-12.6.0.tgz", + "integrity": "sha512-1rhSOfRa6H9w4YwK0yrf5faDaDTb+yLyBUKOCV4xtCDB5VmIPqd/v9yr9o6SAzOAlRxMiRiCic6JVM1/kunVkw==", + "requires": { + "@octokit/openapi-types": "^20.0.0" + } + } + } + }, + "@actions/http-client": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/@actions/http-client/-/http-client-2.2.1.tgz", + "integrity": "sha512-KhC/cZsq7f8I4LfZSJKgCvEwfkE8o1538VoBeoGzokVLLnbFDEAdFD3UhoMklxo2un9NJVBdANOresx7vTHlHw==", + "requires": { + "tunnel": "^0.0.6", + "undici": "^5.25.4" + } + }, + "@fastify/busboy": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@fastify/busboy/-/busboy-2.1.1.tgz", + "integrity": "sha512-vBZP4NlzfOlerQTnba4aqZoMhE/a9HY7HRqoOPaETQcSQuWEIyZMHGfVu6w9wGtGK5fED5qRs2DteVCjOH60sA==" + }, + "@octokit/auth-token": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@octokit/auth-token/-/auth-token-4.0.0.tgz", + "integrity": "sha512-tY/msAuJo6ARbK6SPIxZrPBms3xPbfwBrulZe0Wtr/DIY9lje2HeV1uoebShn6mx7SjCHif6EjMvoREj+gZ+SA==" + }, + "@octokit/core": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@octokit/core/-/core-5.2.0.tgz", + "integrity": "sha512-1LFfa/qnMQvEOAdzlQymH0ulepxbxnCYAKJZfMci/5XJyIHWgEYnDmgnKakbTh7CH2tFQ5O60oYDvns4i9RAIg==", + "requires": { + "@octokit/auth-token": "^4.0.0", + "@octokit/graphql": "^7.1.0", + "@octokit/request": "^8.3.1", + "@octokit/request-error": "^5.1.0", + "@octokit/types": "^13.0.0", + "before-after-hook": "^2.2.0", + "universal-user-agent": "^6.0.0" + } + }, + "@octokit/endpoint": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/@octokit/endpoint/-/endpoint-9.0.5.tgz", + "integrity": "sha512-ekqR4/+PCLkEBF6qgj8WqJfvDq65RH85OAgrtnVp1mSxaXF03u2xW/hUdweGS5654IlC0wkNYC18Z50tSYTAFw==", + "requires": { + "@octokit/types": "^13.1.0", + "universal-user-agent": "^6.0.0" + } + }, + "@octokit/graphql": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/@octokit/graphql/-/graphql-7.1.0.tgz", + "integrity": "sha512-r+oZUH7aMFui1ypZnAvZmn0KSqAUgE1/tUXIWaqUCa1758ts/Jio84GZuzsvUkme98kv0WFY8//n0J1Z+vsIsQ==", + "requires": { + "@octokit/request": "^8.3.0", + "@octokit/types": "^13.0.0", + "universal-user-agent": "^6.0.0" + } + }, + "@octokit/openapi-types": { + "version": "22.2.0", + "resolved": "https://registry.npmjs.org/@octokit/openapi-types/-/openapi-types-22.2.0.tgz", + "integrity": "sha512-QBhVjcUa9W7Wwhm6DBFu6ZZ+1/t/oYxqc2tp81Pi41YNuJinbFRx8B133qVOrAaBbF7D/m0Et6f9/pZt9Rc+tg==" + }, + "@octokit/plugin-paginate-rest": { + "version": "11.3.3", + "resolved": "https://registry.npmjs.org/@octokit/plugin-paginate-rest/-/plugin-paginate-rest-11.3.3.tgz", + "integrity": "sha512-o4WRoOJZlKqEEgj+i9CpcmnByvtzoUYC6I8PD2SA95M+BJ2x8h7oLcVOg9qcowWXBOdcTRsMZiwvM3EyLm9AfA==", + "requires": { + "@octokit/types": "^13.5.0" + } + }, + "@octokit/plugin-request-log": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/@octokit/plugin-request-log/-/plugin-request-log-5.3.0.tgz", + "integrity": "sha512-FiGcyjdtYPlr03ExBk/0ysIlEFIFGJQAVoPPMxL19B24bVSEiZQnVGBunNtaAF1YnvE/EFoDpXmITtRnyCiypQ==" + }, + "@octokit/plugin-rest-endpoint-methods": { + "version": "13.2.4", + "resolved": "https://registry.npmjs.org/@octokit/plugin-rest-endpoint-methods/-/plugin-rest-endpoint-methods-13.2.4.tgz", + "integrity": "sha512-gusyAVgTrPiuXOdfqOySMDztQHv6928PQ3E4dqVGEtOvRXAKRbJR4b1zQyniIT9waqaWk/UDaoJ2dyPr7Bk7Iw==", + "requires": { + "@octokit/types": "^13.5.0" + } + }, + "@octokit/request": { + "version": "8.4.0", + "resolved": "https://registry.npmjs.org/@octokit/request/-/request-8.4.0.tgz", + "integrity": "sha512-9Bb014e+m2TgBeEJGEbdplMVWwPmL1FPtggHQRkV+WVsMggPtEkLKPlcVYm/o8xKLkpJ7B+6N8WfQMtDLX2Dpw==", + "requires": { + "@octokit/endpoint": "^9.0.1", + "@octokit/request-error": "^5.1.0", + "@octokit/types": "^13.1.0", + "universal-user-agent": "^6.0.0" + } + }, + "@octokit/request-error": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@octokit/request-error/-/request-error-5.1.0.tgz", + "integrity": "sha512-GETXfE05J0+7H2STzekpKObFe765O5dlAKUTLNGeH+x47z7JjXHfsHKo5z21D/o/IOZTUEI6nyWyR+bZVP/n5Q==", + "requires": { + "@octokit/types": "^13.1.0", + "deprecation": "^2.0.0", + "once": "^1.4.0" + } + }, + "@octokit/rest": { + "version": "21.0.0", + "resolved": "https://registry.npmjs.org/@octokit/rest/-/rest-21.0.0.tgz", + "integrity": "sha512-XudXXOmiIjivdjNZ+fN71NLrnDM00sxSZlhqmPR3v0dVoJwyP628tSlc12xqn8nX3N0965583RBw5GPo6r8u4Q==", + "requires": { + "@octokit/core": "^6.1.2", + "@octokit/plugin-paginate-rest": "^11.0.0", + "@octokit/plugin-request-log": "^5.1.0", + "@octokit/plugin-rest-endpoint-methods": "^13.0.0" + }, + "dependencies": { + "@octokit/auth-token": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/@octokit/auth-token/-/auth-token-5.1.1.tgz", + "integrity": "sha512-rh3G3wDO8J9wSjfI436JUKzHIxq8NaiL0tVeB2aXmG6p/9859aUOAjA9pmSPNGGZxfwmaJ9ozOJImuNVJdpvbA==" + }, + "@octokit/core": { + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/@octokit/core/-/core-6.1.2.tgz", + "integrity": "sha512-hEb7Ma4cGJGEUNOAVmyfdB/3WirWMg5hDuNFVejGEDFqupeOysLc2sG6HJxY2etBp5YQu5Wtxwi020jS9xlUwg==", + "requires": { + "@octokit/auth-token": "^5.0.0", + "@octokit/graphql": "^8.0.0", + "@octokit/request": "^9.0.0", + "@octokit/request-error": "^6.0.1", + "@octokit/types": "^13.0.0", + "before-after-hook": "^3.0.2", + "universal-user-agent": "^7.0.0" + } + }, + "@octokit/endpoint": { + "version": "10.1.1", + "resolved": "https://registry.npmjs.org/@octokit/endpoint/-/endpoint-10.1.1.tgz", + "integrity": "sha512-JYjh5rMOwXMJyUpj028cu0Gbp7qe/ihxfJMLc8VZBMMqSwLgOxDI1911gV4Enl1QSavAQNJcwmwBF9M0VvLh6Q==", + "requires": { + "@octokit/types": "^13.0.0", + "universal-user-agent": "^7.0.2" + } + }, + "@octokit/graphql": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/@octokit/graphql/-/graphql-8.1.1.tgz", + "integrity": "sha512-ukiRmuHTi6ebQx/HFRCXKbDlOh/7xEV6QUXaE7MJEKGNAncGI/STSbOkl12qVXZrfZdpXctx5O9X1AIaebiDBg==", + "requires": { + "@octokit/request": "^9.0.0", + "@octokit/types": "^13.0.0", + "universal-user-agent": "^7.0.0" + } + }, + "@octokit/request": { + "version": "9.1.1", + "resolved": "https://registry.npmjs.org/@octokit/request/-/request-9.1.1.tgz", + "integrity": "sha512-pyAguc0p+f+GbQho0uNetNQMmLG1e80WjkIaqqgUkihqUp0boRU6nKItXO4VWnr+nbZiLGEyy4TeKRwqaLvYgw==", + "requires": { + "@octokit/endpoint": "^10.0.0", + "@octokit/request-error": "^6.0.1", + "@octokit/types": "^13.1.0", + "universal-user-agent": "^7.0.2" + } + }, + "@octokit/request-error": { + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/@octokit/request-error/-/request-error-6.1.1.tgz", + "integrity": "sha512-1mw1gqT3fR/WFvnoVpY/zUM2o/XkMs/2AszUUG9I69xn0JFLv6PGkPhNk5lbfvROs79wiS0bqiJNxfCZcRJJdg==", + "requires": { + "@octokit/types": "^13.0.0" + } + }, + "before-after-hook": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/before-after-hook/-/before-after-hook-3.0.2.tgz", + "integrity": "sha512-Nik3Sc0ncrMK4UUdXQmAnRtzmNQTAAXmXIopizwZ1W1t8QmfJj+zL4OA2I7XPTPW5z5TDqv4hRo/JzouDJnX3A==" + }, + "universal-user-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/universal-user-agent/-/universal-user-agent-7.0.2.tgz", + "integrity": "sha512-0JCqzSKnStlRRQfCdowvqy3cy0Dvtlb8xecj/H8JFZuCze4rwjPZQOgvFvn0Ws/usCHQFGpyr+pB9adaGwXn4Q==" + } + } + }, + "@octokit/types": { + "version": "13.5.0", + "resolved": "https://registry.npmjs.org/@octokit/types/-/types-13.5.0.tgz", + "integrity": "sha512-HdqWTf5Z3qwDVlzCrP8UJquMwunpDiMPt5er+QjGzL4hqr/vBVY/MauQgS1xWxCDT1oMx1EULyqxncdCY/NVSQ==", + "requires": { + "@octokit/openapi-types": "^22.2.0" + } + }, + "asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==" + }, + "axios": { + "version": "1.7.2", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.7.2.tgz", + "integrity": "sha512-2A8QhOMrbomlDuiLeK9XibIBzuHeRcqqNOHp0Cyp5EoJ1IFDh+XZH3A6BkXtv0K4gFGCI0Y4BM7B1wOEi0Rmgw==", + "requires": { + "follow-redirects": "^1.15.6", + "form-data": "^4.0.0", + "proxy-from-env": "^1.1.0" + } + }, + "beauty-html": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/beauty-html/-/beauty-html-1.3.1.tgz", + "integrity": "sha512-c0iKWc527T2MQcYhIMMw9OHN8kcXSf/ijadWzURhZWi6e6cnBXxAQ5IlXbYd0YZJE9lFtXRB1fJVQrvJf5DmPQ==" + }, + "before-after-hook": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/before-after-hook/-/before-after-hook-2.2.3.tgz", + "integrity": "sha512-NzUnlZexiaH/46WDhANlyR2bXRopNg4F/zuSA3OpZnllCUgRaOF2znDioDWrmbNVsuZk6l9pMquQB38cfBZwkQ==" + }, + "colors": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/colors/-/colors-1.4.0.tgz", + "integrity": "sha512-a+UqTh4kgZg/SlGvfbzDHpgRu7AAQOmmqRHJnxhRZICKFUT91brVhNNt58CMWU9PsBbv3PDCZUHbVxuDiH2mtA==" + }, + "combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "requires": { + "delayed-stream": "~1.0.0" + } + }, + "delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==" + }, + "deprecation": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/deprecation/-/deprecation-2.3.1.tgz", + "integrity": "sha512-xmHIy4F3scKVwMsQ4WnVaS8bHOx0DmVwRywosKhaILI0ywMDWPtBSku2HNxRvF7jtwDRsoEwYQSfbxj8b7RlJQ==" + }, + "diff": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/diff/-/diff-5.2.0.tgz", + "integrity": "sha512-uIFDxqpRZGZ6ThOk84hEfqWoHx2devRFvpTZcTHur85vImfaxUbTW9Ryh4CpCuDnToOP1CEtXKIgytHBPVff5A==" + }, + "follow-redirects": { + "version": "1.15.6", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.6.tgz", + "integrity": "sha512-wWN62YITEaOpSK584EZXJafH1AGpO8RVgElfkuXbTOrPX4fIfOyEpW/CsiNd8JdYrAoOvafRTOEnvsO++qCqFA==" + }, + "form-data": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.0.tgz", + "integrity": "sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww==", + "requires": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "mime-types": "^2.1.12" + } + }, + "mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==" + }, + "mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "requires": { + "mime-db": "1.52.0" + } + }, + "once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "requires": { + "wrappy": "1" + } + }, + "proxy-from-env": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", + "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==" + }, + "semver": { + "version": "7.6.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.2.tgz", + "integrity": "sha512-FNAIBWCx9qcRhoHcgcJ0gvU7SN1lYU2ZXuSfl04bSC5OpvDHFyJCjdNHomPXxjQlCBU67YW64PzY7/VIEH7F2w==" + }, + "tunnel": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/tunnel/-/tunnel-0.0.6.tgz", + "integrity": "sha512-1h/Lnq9yajKY2PEbBadPXj3VxsDDu844OnaAo52UVmIzIvwwtBPIuNvkjuzBlTWpfJyUbG3ez0KSBibQkj4ojg==" + }, + "undici": { + "version": "5.28.4", + "resolved": "https://registry.npmjs.org/undici/-/undici-5.28.4.tgz", + "integrity": "sha512-72RFADWFqKmUb2hmmvNODKL3p9hcB6Gt2DOQMis1SEBaV6a4MH8soBvzg+95CYhCKPFedut2JY9bMfrDl9D23g==", + "requires": { + "@fastify/busboy": "^2.0.0" + } + }, + "universal-user-agent": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/universal-user-agent/-/universal-user-agent-6.0.1.tgz", + "integrity": "sha512-yCzhz6FN2wU1NiiQRogkTQszlQSlpWaw8SvVegAc+bDxbzHgh1vX8uIe8OYyMH6DwH+sdTJsgMl36+mSMdRJIQ==" + }, + "uuid": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", + "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==" + }, + "wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==" + }, + "xmldom": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/xmldom/-/xmldom-0.6.0.tgz", + "integrity": "sha512-iAcin401y58LckRZ0TkI4k0VSM1Qg0KGSc3i8rU+xrxe19A/BN1zHyVSJY7uoutVlaTSzYyk/v5AmkewAP7jtg==" + } + } +} diff --git a/.github/actions/update-version-durability/package.json b/.github/actions/update-version-durability/package.json new file mode 100644 index 0000000000000..2a66585815404 --- /dev/null +++ b/.github/actions/update-version-durability/package.json @@ -0,0 +1,21 @@ +{ + "name": "scripts", + "version": "1.0.0", + "type": "module", + "description": "", + "scripts": { + "test": "echo \"Error: no test specified\" && exit 1" + }, + "author": "", + "license": "ISC", + "dependencies": { + "@actions/core": "^1.10.1", + "@octokit/rest": "^21.0.0", + "axios": "^1.7.2", + "beauty-html": "^1.3.1", + "colors": "^1.4.0", + "diff": "^5.1.0", + "semver": "^7.5.4", + "xmldom": "^0.6.0" + } +} diff --git a/.github/workflows/ci-test-e2e.yml b/.github/workflows/ci-test-e2e.yml index b46c124d149be..378769883f19a 100644 --- a/.github/workflows/ci-test-e2e.yml +++ b/.github/workflows/ci-test-e2e.yml @@ -67,6 +67,8 @@ on: required: false CODECOV_TOKEN: required: false + REPORTER_JIRA_ROCKETCHAT_API_KEY: + required: false env: MONGO_URL: mongodb://localhost:27017/rocketchat?replicaSet=rs0&directConnection=true @@ -122,7 +124,7 @@ jobs: # 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.pull_request.head.repo.full_name != github.repository + if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name != github.repository with: CR_USER: ${{ secrets.CR_USER }} CR_PAT: ${{ secrets.CR_PAT }} @@ -176,6 +178,12 @@ 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 @@ -210,6 +218,8 @@ jobs: sleep 10 done; + - name: Remove unused Docker images + run: docker system prune -af - name: E2E Test API if: inputs.type == 'api' working-directory: ./apps/meteor @@ -250,10 +260,15 @@ jobs: IS_EE: ${{ inputs.release == 'ee' && 'true' || '' }} REPORTER_ROCKETCHAT_API_KEY: ${{ secrets.REPORTER_ROCKETCHAT_API_KEY }} REPORTER_ROCKETCHAT_URL: ${{ secrets.REPORTER_ROCKETCHAT_URL }} + REPORTER_JIRA_ROCKETCHAT_API_KEY: ${{ secrets.REPORTER_JIRA_ROCKETCHAT_API_KEY }} REPORTER_ROCKETCHAT_REPORT: ${{ github.event.pull_request.draft != 'true' && 'true' || '' }} REPORTER_ROCKETCHAT_RUN: ${{ github.run_number }} REPORTER_ROCKETCHAT_BRANCH: ${{ github.ref }} REPORTER_ROCKETCHAT_DRAFT: ${{ github.event.pull_request.draft }} + REPORTER_ROCKETCHAT_HEAD_SHA: ${{ github.event.pull_request.head.sha }} + REPORTER_ROCKETCHAT_AUTHOR: ${{ github.event.pull_request.user.login }} + REPORTER_ROCKETCHAT_RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + REPORTER_ROCKETCHAT_PR: ${{ github.event.pull_request.number }} QASE_API_TOKEN: ${{ secrets.QASE_API_TOKEN }} QASE_REPORT: ${{ github.ref == 'refs/heads/develop' && 'true' || '' }} CI: true diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b542cfbf65232..411aa2cc5b1ad 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -35,10 +35,12 @@ jobs: # to avoid this, we are using a dummy license, expiring at 2025-06-31 enterprise-license: X/XumwIkgwQuld0alWKt37lVA90XjKOrfiMvMZ0/RtqsMtrdL9GoAk+4jXnaY1b2ePoG7XSzGhuxEDxFKIWJK3hIKGNTvrd980LgH5sM5+1T4P42ivSpd8UZi0bwjJkCFLIu9RozzYwslGG0IehMxe0S6VjcO0UYlUJtbMCBHuR2WmTAmO6YVU3ln+pZCbrPFaTPSS1RovhKaNCNkZwIx/CLWW8UTXUuFV/ML4PbKKVoa5nvvJwPeatgL7UCnlSD90lfCiiuikpzj/Y/JLkIL6velFbwNxsrxg9iRJ2k0sKheMMSmlTiGzSvZUm+na5WQq91aKGncih+DmaEZA7QGrjp4eoA0dqTk6OmItsy0fHmQhvZIOKNMeO7vNQiLbaSV6rqibrzu7WPpeIvsvL57T1h37USoCSB6+jDqkzdfoqIpz8BxTiJDj1d8xGPJFVrgxoqQqkj9qIP/gCaEz5DF39QFv5sovk4yK2O8fEQYod2d14V9yECYl4szZPMk1IBfCAC2w7czWGHHFonhL+CQGT403y5wmDmnsnjlCqMKF72odqfTPTI8XnCvJDriPMWohnQEAGtTTyciAhNokx/mjAVJ4NeZPcsbm4BjhvJvnjxx/BhYhBBTNWPaCSZzocfrGUj9Z+ZA7BEz+xAFQyGDx3xRzqIXfT0G7w8fvgYJMU= steps: - - uses: Bhacaz/checkout-files@v2 + - uses: actions/checkout@v4 with: - files: package.json - branch: ${{ github.ref }} + sparse-checkout: | + package.json + sparse-checkout-cone-mode: false + ref: ${{ github.ref }} - id: var run: | @@ -85,10 +87,12 @@ jobs: runs-on: ubuntu-20.04 needs: [release-versions] steps: - - uses: Bhacaz/checkout-files@v2 + - uses: actions/checkout@v4 with: - files: package.json - branch: ${{ github.ref }} + sparse-checkout: | + package.json + sparse-checkout-cone-mode: false + ref: ${{ github.ref }} - name: Register release on cloud as Draft if: github.event_name == 'release' @@ -350,6 +354,7 @@ jobs: QASE_API_TOKEN: ${{ secrets.QASE_API_TOKEN }} REPORTER_ROCKETCHAT_API_KEY: ${{ secrets.REPORTER_ROCKETCHAT_API_KEY }} REPORTER_ROCKETCHAT_URL: ${{ secrets.REPORTER_ROCKETCHAT_URL }} + REPORTER_JIRA_ROCKETCHAT_API_KEY: ${{ secrets.REPORTER_JIRA_ROCKETCHAT_API_KEY }} test-api-ee: name: 🔨 Test API (EE) @@ -401,6 +406,7 @@ jobs: REPORTER_ROCKETCHAT_API_KEY: ${{ secrets.REPORTER_ROCKETCHAT_API_KEY }} REPORTER_ROCKETCHAT_URL: ${{ secrets.REPORTER_ROCKETCHAT_URL }} CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} + REPORTER_JIRA_ROCKETCHAT_API_KEY: ${{ secrets.REPORTER_JIRA_ROCKETCHAT_API_KEY }} test-ui-ee-no-watcher: name: 🔨 Test UI (EE) @@ -431,15 +437,44 @@ jobs: REPORTER_ROCKETCHAT_API_KEY: ${{ secrets.REPORTER_ROCKETCHAT_API_KEY }} REPORTER_ROCKETCHAT_URL: ${{ secrets.REPORTER_ROCKETCHAT_URL }} CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} + REPORTER_JIRA_ROCKETCHAT_API_KEY: ${{ secrets.REPORTER_JIRA_ROCKETCHAT_API_KEY }} tests-done: name: ✅ Tests Done runs-on: ubuntu-20.04 needs: [checks, test-unit, test-api, test-ui, test-api-ee, test-ui-ee, test-ui-ee-no-watcher] - + if: always() steps: - name: Test finish aggregation run: | + if [[ '${{ needs.checks.result }}' != 'success' ]]; then + exit 1 + fi + + if [[ '${{ needs.test-unit.result }}' != 'success' ]]; then + exit 1 + fi + + if [[ '${{ needs.test-api.result }}' != 'success' ]]; then + exit 1 + fi + + if [[ '${{ needs.test-ui.result }}' != 'success' ]]; then + exit 1 + fi + + if [[ '${{ needs.test-api-ee.result }}' != 'success' ]]; then + exit 1 + fi + + if [[ '${{ needs.test-ui-ee.result }}' != 'success' ]]; then + exit 1 + fi + + if [[ '${{ needs.test-ui-ee-no-watcher.result }}' != 'success' ]]; then + exit 1 + fi + echo finished deploy: @@ -449,10 +484,12 @@ jobs: needs: [build-gh-docker, release-versions] steps: - - uses: Bhacaz/checkout-files@v2 + - uses: actions/checkout@v4 with: - files: package.json - branch: ${{ github.ref }} + sparse-checkout: | + package.json + sparse-checkout-cone-mode: false + ref: ${{ github.ref }} - name: Restore build uses: actions/download-artifact@v3 @@ -732,10 +769,12 @@ jobs: - docker-image-publish - release-versions steps: - - uses: Bhacaz/checkout-files@v2 + - uses: actions/checkout@v4 with: - files: package.json - branch: ${{ github.ref }} + sparse-checkout: | + package.json + sparse-checkout-cone-mode: false + ref: ${{ github.ref }} - name: Releases service env: @@ -784,10 +823,15 @@ jobs: repository: RocketChat/Release.Distributions client-payload: '{"tag": "${{ github.ref_name }}"}' - - name: Update docs - uses: peter-evans/repository-dispatch@v2 - with: - token: ${{ secrets.DOCS_PAT }} - event-type: new_release - repository: RocketChat/docs - client-payload: '{"tag": "${{ github.ref_name }}"}' + docs-update: + name: Update Version Durability + + if: github.event_name == 'release' + needs: + - services-docker-image-publish + - docker-image-publish + + uses: ./.github/workflows/update-version-durability.yml + secrets: + CI_PAT: ${{ secrets.CI_PAT }} + D360_TOKEN: ${{ secrets.D360_TOKEN }} diff --git a/.github/workflows/update-version-durability.yml b/.github/workflows/update-version-durability.yml new file mode 100644 index 0000000000000..e52b4870b369d --- /dev/null +++ b/.github/workflows/update-version-durability.yml @@ -0,0 +1,35 @@ +name: Update Version Durability + +on: + workflow_dispatch: + workflow_call: + secrets: + CI_PAT: + required: true + D360_TOKEN: + required: true + +jobs: + update-versions: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - name: Use Node.js + uses: actions/setup-node@v3.7.0 + with: + node-version: '20.15.1' + + - name: Install dependencies + run: | + cd ./.github/actions/update-version-durability + npm install + + - name: Update Version Durability + uses: ./.github/actions/update-version-durability + with: + GH_TOKEN: ${{ secrets.CI_PAT }} + D360_TOKEN: ${{ secrets.D360_TOKEN }} + D360_ARTICLE_ID: 800f8d52-409d-478d-b560-f82a2c0eb7fb + PUBLISH: true diff --git a/README.md b/README.md index 64dec811e1ca7..56e38c111e97e 100644 --- a/README.md +++ b/README.md @@ -67,6 +67,10 @@ yarn dsv # run only meteor (front and back) with pre-built packages After initialized, you can access the server at http://localhost:3000 +More details at: [Developer Docs](https://developer.rocket.chat/v1/docs/server-environment-setup) +PS: For Windows you MUST use WSL2 and have +12Gb RAM + + # Gitpod Setup 1. Click the button below to open this project in Gitpod. diff --git a/apps/meteor/.eslintignore b/apps/meteor/.eslintignore index 2bbdbae00b89a..2701a871d9814 100644 --- a/apps/meteor/.eslintignore +++ b/apps/meteor/.eslintignore @@ -1,6 +1,5 @@ /node_modules/ #/tests/e2e/ -/tests/data/ /packages/ /app/emoji-emojione/generateEmojiIndex.js /public/ diff --git a/apps/meteor/.mocharc.api.js b/apps/meteor/.mocharc.api.js index eca1284e62e5d..b73a24a275e43 100644 --- a/apps/meteor/.mocharc.api.js +++ b/apps/meteor/.mocharc.api.js @@ -1,13 +1,14 @@ 'use strict'; -/** +/* * Mocha configuration for REST API integration tests. */ -module.exports = { +module.exports = /** @satisfies {import('mocha').MochaOptions} */ ({ ...require('./.mocharc.base.json'), // see https://github.com/mochajs/mocha/issues/3916 timeout: 10000, bail: true, - file: 'tests/end-to-end/teardown.js', + retries: 0, + file: 'tests/end-to-end/teardown.ts', spec: ['tests/end-to-end/api/**/*', 'tests/end-to-end/apps/*'], -}; +}); diff --git a/apps/meteor/CHANGELOG.md b/apps/meteor/CHANGELOG.md index f17422bbb318e..4f6de9c113b79 100644 --- a/apps/meteor/CHANGELOG.md +++ b/apps/meteor/CHANGELOG.md @@ -1,11 +1,333 @@ # @rocket.chat/meteor -## 6.10.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 - Bump @rocket.chat/meteor version. +-
Updated dependencies []: + + - @rocket.chat/core-typings@6.11.0-rc.2 + - @rocket.chat/rest-typings@6.11.0-rc.2 + - @rocket.chat/api-client@0.2.3-rc.2 + - @rocket.chat/license@0.2.3-rc.2 + - @rocket.chat/omnichannel-services@0.3.0-rc.2 + - @rocket.chat/pdf-worker@0.2.0-rc.2 + - @rocket.chat/presence@0.2.3-rc.2 + - @rocket.chat/apps@0.1.3-rc.2 + - @rocket.chat/core-services@0.5.0-rc.2 + - @rocket.chat/cron@0.1.3-rc.2 + - @rocket.chat/fuselage-ui-kit@9.0.0-rc.2 + - @rocket.chat/gazzodown@9.0.0-rc.2 + - @rocket.chat/model-typings@0.6.0-rc.2 + - @rocket.chat/ui-contexts@9.0.0-rc.2 + - @rocket.chat/server-cloud-communication@0.0.2 + - @rocket.chat/models@0.2.0-rc.2 + - @rocket.chat/ui-theming@0.2.0 + - @rocket.chat/ui-avatar@5.0.0-rc.2 + - @rocket.chat/ui-client@9.0.0-rc.2 + - @rocket.chat/ui-video-conf@9.0.0-rc.2 + - @rocket.chat/web-ui-registration@9.0.0-rc.2 + - @rocket.chat/instance-status@0.1.3-rc.2 +
+ +## 6.11.0-rc.1 + +### Patch Changes + +- Bump @rocket.chat/meteor version. + +-
Updated dependencies []: + + - @rocket.chat/core-typings@6.11.0-rc.1 + - @rocket.chat/rest-typings@6.11.0-rc.1 + - @rocket.chat/api-client@0.2.2-rc.1 + - @rocket.chat/license@0.2.2-rc.1 + - @rocket.chat/omnichannel-services@0.3.0-rc.1 + - @rocket.chat/pdf-worker@0.2.0-rc.1 + - @rocket.chat/presence@0.2.2-rc.1 + - @rocket.chat/apps@0.1.2-rc.1 + - @rocket.chat/core-services@0.5.0-rc.1 + - @rocket.chat/cron@0.1.2-rc.1 + - @rocket.chat/fuselage-ui-kit@9.0.0-rc.1 + - @rocket.chat/gazzodown@9.0.0-rc.1 + - @rocket.chat/model-typings@0.6.0-rc.1 + - @rocket.chat/ui-contexts@9.0.0-rc.1 + - @rocket.chat/server-cloud-communication@0.0.2 + - @rocket.chat/models@0.2.0-rc.1 + - @rocket.chat/ui-theming@0.2.0 + - @rocket.chat/ui-avatar@5.0.0-rc.1 + - @rocket.chat/ui-client@9.0.0-rc.1 + - @rocket.chat/ui-video-conf@9.0.0-rc.1 + - @rocket.chat/web-ui-registration@9.0.0-rc.1 + - @rocket.chat/instance-status@0.1.2-rc.1 +
+ +## 6.11.0-rc.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 + +- ([#32570](https://github.com/RocketChat/Rocket.Chat/pull/32570)) Login services button was not respecting the button color and text color settings. Implemented a fix to respect these settings and change the button colors accordingly. + + Added a warning on all settings which allow admins to change OAuth button colors, so that they can be alerted about WCAG (Web Content Accessibility Guidelines) compliance. + +- ([#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 + +- ([#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, 15664127be, 25da5280a5, 1b7b1161cf, 439faa87d3, 03c8b066f9, 2d89a0c448, 439faa87d3, 24f7df4894, 3ffe4a2944, 3b4b19cfc5, 4e8aa575a6, 03c8b066f9, 264d7d5496, b8e5887fb9]: + + - @rocket.chat/fuselage-ui-kit@9.0.0-rc.0 + - @rocket.chat/i18n@0.6.0-rc.0 + - @rocket.chat/tools@0.2.2-rc.0 + - @rocket.chat/web-ui-registration@9.0.0-rc.0 + - @rocket.chat/ui-client@9.0.0-rc.0 + - @rocket.chat/model-typings@0.6.0-rc.0 + - @rocket.chat/omnichannel-services@0.3.0-rc.0 + - @rocket.chat/pdf-worker@0.2.0-rc.0 + - @rocket.chat/core-services@0.5.0-rc.0 + - @rocket.chat/ui-video-conf@9.0.0-rc.0 + - @rocket.chat/core-typings@6.11.0-rc.0 + - @rocket.chat/ui-contexts@9.0.0-rc.0 + - @rocket.chat/models@0.2.0-rc.0 + - @rocket.chat/ui-kit@0.36.0-rc.0 + - @rocket.chat/rest-typings@6.11.0-rc.0 + - @rocket.chat/apps@0.1.2-rc.0 + - @rocket.chat/presence@0.2.2-rc.0 + - @rocket.chat/gazzodown@9.0.0-rc.0 + - @rocket.chat/api-client@0.2.2-rc.0 + - @rocket.chat/license@0.2.2-rc.0 + - @rocket.chat/cron@0.1.2-rc.0 + - @rocket.chat/ui-theming@0.2.0 + - @rocket.chat/ui-avatar@5.0.0-rc.0 + - @rocket.chat/instance-status@0.1.2-rc.0 + - @rocket.chat/server-cloud-communication@0.0.2 + +## 6.10.2 + +### Patch Changes + - Bump @rocket.chat/meteor version. - ([#32935](https://github.com/RocketChat/Rocket.Chat/pull/32935)) Fixed an issue that prevented apps from being updated or uninstalled in some cases diff --git a/apps/meteor/app/api/server/lib/getUploadFormData.ts b/apps/meteor/app/api/server/lib/getUploadFormData.ts index 85fc0658542d4..3136a6c16e130 100644 --- a/apps/meteor/app/api/server/lib/getUploadFormData.ts +++ b/apps/meteor/app/api/server/lib/getUploadFormData.ts @@ -63,7 +63,7 @@ export async function getUploadFormData< function onFile( fieldname: string, file: Readable & { truncated: boolean }, - { filename, encoding }: { filename: string; encoding: string }, + { filename, encoding, mimeType: mimetype }: { filename: string; encoding: string; mimeType: string }, ) { if (options.field && fieldname !== options.field) { file.resume(); @@ -85,7 +85,7 @@ export async function getUploadFormData< file, filename, encoding, - mimetype: getMimeType(filename), + mimetype: getMimeType(mimetype, filename), fieldname, fields, fileBuffer: Buffer.concat(fileChunks), diff --git a/apps/meteor/app/api/server/v1/users.ts b/apps/meteor/app/api/server/v1/users.ts index 410a65fe7edab..7ae585b89dfa7 100644 --- a/apps/meteor/app/api/server/v1/users.ts +++ b/apps/meteor/app/api/server/v1/users.ts @@ -19,6 +19,7 @@ import { isUsersCheckUsernameAvailabilityParamsGET, isUsersSendConfirmationEmailParamsPOST, } from '@rocket.chat/rest-typings'; +import { getLoginExpirationInMs } from '@rocket.chat/tools'; import { Accounts } from 'meteor/accounts-base'; import { Match, check } from 'meteor/check'; import { Meteor } from 'meteor/meteor'; @@ -1065,8 +1066,9 @@ API.v1.addRoute( const token = me.services?.resume?.loginTokens?.find((token) => token.hashedToken === hashedToken); - const tokenExpires = - (token && 'when' in token && new Date(token.when.getTime() + settings.get('Accounts_LoginExpiration') * 1000)) || undefined; + const loginExp = settings.get('Accounts_LoginExpiration'); + + const tokenExpires = (token && 'when' in token && new Date(token.when.getTime() + getLoginExpirationInMs(loginExp))) || undefined; return API.v1.success({ token: xAuthToken, @@ -1214,7 +1216,7 @@ API.v1.addRoute( throw new Meteor.Error('error-invalid-user-id', 'Invalid user id'); } - void notifyOnUserChange({ clientAction: 'updated', id: this.userId, diff: { 'services.resume.loginTokens': [] } }); + void notifyOnUserChange({ clientAction: 'updated', id: userId, diff: { 'services.resume.loginTokens': [] } }); return API.v1.success({ message: `User ${userId} has been logged out!`, diff --git a/apps/meteor/app/apps/server/bridges/listeners.js b/apps/meteor/app/apps/server/bridges/listeners.js index ab2632c912b0d..13db1179310c5 100644 --- a/apps/meteor/app/apps/server/bridges/listeners.js +++ b/apps/meteor/app/apps/server/bridges/listeners.js @@ -143,10 +143,11 @@ export class AppListenerBridge { }; case AppInterface.IPreRoomUserLeave: case AppInterface.IPostRoomUserLeave: - const [leavingUser] = payload; + const [leavingUser, removedBy] = payload; return { room: rm, leavingUser: this.orch.getConverters().get('users').convertToApp(leavingUser), + removedBy: this.orch.getConverters().get('users').convertToApp(removedBy), }; default: return rm; diff --git a/apps/meteor/app/apps/server/bridges/livechat.ts b/apps/meteor/app/apps/server/bridges/livechat.ts index 7067ab8e6a52f..ec5cff29a99be 100644 --- a/apps/meteor/app/apps/server/bridges/livechat.ts +++ b/apps/meteor/app/apps/server/bridges/livechat.ts @@ -7,14 +7,18 @@ import { LivechatBridge } from '@rocket.chat/apps-engine/server/bridges/Livechat import type { ILivechatDepartment, IOmnichannelRoom, SelectedAgent, IMessage, ILivechatVisitor } from '@rocket.chat/core-typings'; import { OmnichannelSourceType } from '@rocket.chat/core-typings'; import { LivechatVisitors, LivechatRooms, LivechatDepartment, Users } from '@rocket.chat/models'; -import { Random } from '@rocket.chat/random'; import { callbacks } from '../../../../lib/callbacks'; import { deasyncPromise } from '../../../../server/deasync/deasync'; -import { getRoom } from '../../../livechat/server/api/lib/livechat'; import { type ILivechatMessage, Livechat as LivechatTyped } from '../../../livechat/server/lib/LivechatTyped'; import { settings } from '../../../settings/server'; +declare module '@rocket.chat/apps-engine/definition/accessors/ILivechatCreator' { + interface IExtraRoomParams { + customFields?: Record; + } +} + export class AppLivechatBridge extends LivechatBridge { constructor(private readonly orch: IAppServerOrchestrator) { super(); @@ -79,17 +83,14 @@ export class AppLivechatBridge extends LivechatBridge { await LivechatTyped.updateMessage(data); } - protected async createRoom(visitor: IVisitor, agent: IUser, appId: string, extraParams?: IExtraRoomParams): Promise { + protected async createRoom( + visitor: IVisitor, + agent: IUser, + appId: string, + { source, customFields }: IExtraRoomParams = {}, + ): Promise { this.orch.debugLog(`The App ${appId} is creating a livechat room.`); - const { source } = extraParams || {}; - // `source` will likely have the properties below, so we tell TS it's alright - const { sidebarIcon, defaultIcon, label } = (source || {}) as { - sidebarIcon?: string; - defaultIcon?: string; - label?: string; - }; - let agentRoom: SelectedAgent | undefined; if (agent?.id) { const user = await Users.getAgentInfo(agent.id, settings.get('Livechat_show_agent_email')); @@ -99,25 +100,27 @@ export class AppLivechatBridge extends LivechatBridge { agentRoom = { agentId: user._id, username: user.username }; } - const result = await getRoom({ - guest: this.orch.getConverters()?.get('visitors').convertAppVisitor(visitor), - agent: agentRoom, - rid: Random.id(), + const room = await LivechatTyped.createRoom({ + visitor: this.orch.getConverters()?.get('visitors').convertAppVisitor(visitor), roomInfo: { source: { type: OmnichannelSourceType.APP, id: appId, alias: this.orch.getManager()?.getOneById(appId)?.getName(), - label, - sidebarIcon, - defaultIcon, + ...(source && + source.type === 'app' && { + sidebarIcon: source.sidebarIcon, + defaultIcon: source.defaultIcon, + label: source.label, + }), }, }, - extraParams: undefined, + agent: agentRoom, + extraData: customFields && { customFields }, }); // #TODO: #AppsEngineTypes - Remove explicit types and typecasts once the apps-engine definition/implementation mismatch is fixed. - return this.orch.getConverters()?.get('rooms').convertRoom(result.room) as Promise; + return this.orch.getConverters()?.get('rooms').convertRoom(room) as Promise; } protected async closeRoom(room: ILivechatRoom, comment: string, closer: IUser | undefined, appId: string): Promise { @@ -195,7 +198,33 @@ export class AppLivechatBridge extends LivechatBridge { ...(visitor.visitorEmails?.length && { email: visitor.visitorEmails[0].address }), }; - return LivechatTyped.registerGuest(registerData); + const livechatVisitor = await LivechatTyped.registerGuest(registerData); + + if (!livechatVisitor) { + throw new Error('Invalid visitor, cannot create'); + } + + return livechatVisitor._id; + } + + protected async createAndReturnVisitor(visitor: IVisitor, appId: string): Promise { + this.orch.debugLog(`The App ${appId} is creating a livechat visitor.`); + + const registerData = { + department: visitor.department, + username: visitor.username, + name: visitor.name, + token: visitor.token, + email: '', + connectionData: undefined, + id: visitor.id, + ...(visitor.phone?.length && { phone: { number: visitor.phone[0].phoneNumber } }), + ...(visitor.visitorEmails?.length && { email: visitor.visitorEmails[0].address }), + }; + + const livechatVisitor = await LivechatTyped.registerGuest(registerData); + + return this.orch.getConverters()?.get('visitors').convertVisitor(livechatVisitor); } protected async transferVisitor(visitor: IVisitor, transferData: ILivechatTransferData, appId: string): Promise { @@ -217,7 +246,8 @@ export class AppLivechatBridge extends LivechatBridge { username, name, type, - }; + userType: 'user', + } as const; let userId; let transferredTo; diff --git a/apps/meteor/app/apps/server/bridges/rooms.ts b/apps/meteor/app/apps/server/bridges/rooms.ts index bbd24152716f0..344acc74bda43 100644 --- a/apps/meteor/app/apps/server/bridges/rooms.ts +++ b/apps/meteor/app/apps/server/bridges/rooms.ts @@ -1,16 +1,19 @@ import type { IAppServerOrchestrator } from '@rocket.chat/apps'; -import type { IMessage } from '@rocket.chat/apps-engine/definition/messages'; +import type { IMessage, IMessageRaw } from '@rocket.chat/apps-engine/definition/messages'; import type { IRoom } from '@rocket.chat/apps-engine/definition/rooms'; import { RoomType } from '@rocket.chat/apps-engine/definition/rooms'; import type { IUser } from '@rocket.chat/apps-engine/definition/users'; +import type { GetMessagesOptions } from '@rocket.chat/apps-engine/server/bridges/RoomBridge'; import { RoomBridge } from '@rocket.chat/apps-engine/server/bridges/RoomBridge'; -import type { ISubscription, IUser as ICoreUser, IRoom as ICoreRoom } from '@rocket.chat/core-typings'; -import { Subscriptions, Users, Rooms } from '@rocket.chat/models'; +import type { ISubscription, IUser as ICoreUser, IRoom as ICoreRoom, IMessage as ICoreMessage } from '@rocket.chat/core-typings'; +import { Subscriptions, Users, Rooms, Messages } from '@rocket.chat/models'; +import type { FindOptions, Sort } from 'mongodb'; import { createDirectMessage } from '../../../../server/methods/createDirectMessage'; import { createDiscussion } from '../../../discussion/server/methods/createDiscussion'; import { addUserToRoom } from '../../../lib/server/functions/addUserToRoom'; import { deleteRoom } from '../../../lib/server/functions/deleteRoom'; +import { removeUserFromRoom } from '../../../lib/server/functions/removeUserFromRoom'; import { createChannelMethod } from '../../../lib/server/methods/createChannel'; import { createPrivateGroupMethod } from '../../../lib/server/methods/createPrivateGroup'; @@ -102,6 +105,38 @@ export class AppRoomBridge extends RoomBridge { return this.orch.getConverters()?.get('users').convertById(room.u._id); } + protected async getMessages(roomId: string, options: GetMessagesOptions, appId: string): Promise { + this.orch.debugLog(`The App ${appId} is getting the messages of the room: "${roomId}" with options:`, options); + + const { limit, skip = 0, sort: _sort } = options; + + const messageConverter = this.orch.getConverters()?.get('messages'); + if (!messageConverter) { + throw new Error('Message converter not found'); + } + + // We support only one field for now + const sort: Sort | undefined = _sort?.createdAt ? { ts: _sort.createdAt } : undefined; + + const messageQueryOptions: FindOptions = { + limit, + skip, + sort, + }; + + const query = { + rid: roomId, + _hidden: { $ne: true }, + t: { $exists: false }, + }; + + const cursor = Messages.find(query, messageQueryOptions); + + const messagePromises: Promise[] = await cursor.map((message) => messageConverter.convertMessageRaw(message)).toArray(); + + return Promise.all(messagePromises); + } + protected async getMembers(roomId: string, appId: string): Promise> { this.orch.debugLog(`The App ${appId} is getting the room's members by room id: "${roomId}"`); const subscriptions = await Subscriptions.findByRoomId(roomId, {}); @@ -209,4 +244,14 @@ export class AppRoomBridge extends RoomBridge { const userConverter = this.orch.getConverters().get('users'); return users.map((user: ICoreUser) => userConverter.convertToApp(user)); } + + protected async removeUsers(roomId: string, usernames: Array, appId: string): Promise { + this.orch.debugLog(`The App ${appId} is removing users ${usernames} from room id: ${roomId}`); + if (!roomId) { + throw new Error('roomId was not provided.'); + } + + const members = await Users.findUsersByUsernames(usernames, { limit: 50 }).toArray(); + await Promise.all(members.map((user) => removeUserFromRoom(roomId, user))); + } } diff --git a/apps/meteor/app/apps/server/bridges/videoConferences.ts b/apps/meteor/app/apps/server/bridges/videoConferences.ts index bebcb25a6f516..efab0f201f873 100644 --- a/apps/meteor/app/apps/server/bridges/videoConferences.ts +++ b/apps/meteor/app/apps/server/bridges/videoConferences.ts @@ -59,6 +59,10 @@ export class AppVideoConferenceBridge extends VideoConferenceBridge { if (data.status > oldData.status) { await VideoConf.setStatus(call._id, data.status); } + + if (data.discussionRid !== oldData.discussionRid) { + await VideoConf.assignDiscussionToConference(call._id, data.discussionRid); + } } protected async registerProvider(info: IVideoConfProvider, appId: string): Promise { diff --git a/apps/meteor/app/apps/server/converters/cachedFunction.ts b/apps/meteor/app/apps/server/converters/cachedFunction.ts new file mode 100644 index 0000000000000..3310574f01608 --- /dev/null +++ b/apps/meteor/app/apps/server/converters/cachedFunction.ts @@ -0,0 +1,17 @@ +export const cachedFunction = any>(fn: F) => { + const cache = new Map(); + + return ((...args) => { + const cacheKey = JSON.stringify(args); + + if (cache.has(cacheKey)) { + return cache.get(cacheKey) as ReturnType; + } + + const result = fn(...args); + + cache.set(cacheKey, result); + + return result; + }) as F; +}; diff --git a/apps/meteor/app/apps/server/converters/messages.js b/apps/meteor/app/apps/server/converters/messages.js index 187a6519339a4..d7dae512e9a8f 100644 --- a/apps/meteor/app/apps/server/converters/messages.js +++ b/apps/meteor/app/apps/server/converters/messages.js @@ -1,9 +1,13 @@ +import { isMessageFromVisitor } from '@rocket.chat/core-typings'; import { Messages, Rooms, Users } from '@rocket.chat/models'; import { Random } from '@rocket.chat/random'; +import { cachedFunction } from './cachedFunction'; import { transformMappedData } from './transformMappedData'; export class AppMessagesConverter { + mem = new WeakMap(); + constructor(orch) { this.orch = orch; } @@ -14,11 +18,54 @@ export class AppMessagesConverter { return this.convertMessage(msg); } + async convertMessageRaw(msgObj) { + if (!msgObj) { + return undefined; + } + + const { attachments, ...message } = msgObj; + const getAttachments = async () => this._convertAttachmentsToApp(attachments); + + const map = { + id: '_id', + threadId: 'tmid', + reactions: 'reactions', + parseUrls: 'parseUrls', + text: 'msg', + createdAt: 'ts', + updatedAt: '_updatedAt', + editedAt: 'editedAt', + emoji: 'emoji', + avatarUrl: 'avatar', + alias: 'alias', + file: 'file', + customFields: 'customFields', + groupable: 'groupable', + token: 'token', + blocks: 'blocks', + roomId: 'rid', + editor: 'editedBy', + attachments: getAttachments, + sender: 'u', + }; + + return transformMappedData(message, map); + } + async convertMessage(msgObj) { if (!msgObj) { return undefined; } + const cache = + this.mem.get(msgObj) ?? + 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')))], + ]); + + this.mem.set(msgObj, cache); + const map = { id: '_id', threadId: 'tmid', @@ -37,7 +84,7 @@ export class AppMessagesConverter { token: 'token', blocks: 'blocks', room: async (message) => { - const result = await this.orch.getConverters().get('rooms').convertById(message.rid); + const result = await cache.get('room')(message.rid); delete message.rid; return result; }, @@ -49,7 +96,7 @@ export class AppMessagesConverter { return undefined; } - return this.orch.getConverters().get('users').convertById(editedBy._id); + return cache.get('user')(editedBy._id); }, attachments: async (message) => { const result = await this._convertAttachmentsToApp(message.attachments); @@ -61,16 +108,19 @@ export class AppMessagesConverter { return undefined; } - let user = await this.orch.getConverters().get('users').convertById(message.u._id); - - // When the sender of the message is a Guest (livechat) and not a user - if (!user) { - user = this.orch.getConverters().get('users').convertToApp(message.u); - } + // 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)); delete message.u; - return user; + /** + * Old System Messages from visitor doesn't have the `token` field, to not return + * `sender` as undefined, so we need to add this fallback here. + */ + + return user || this.orch.getConverters().get('users').convertToApp(message.u); }, }; diff --git a/apps/meteor/app/apps/server/converters/threads.ts b/apps/meteor/app/apps/server/converters/threads.ts index 840f4f1613ebb..e31ee094b4d73 100644 --- a/apps/meteor/app/apps/server/converters/threads.ts +++ b/apps/meteor/app/apps/server/converters/threads.ts @@ -5,6 +5,7 @@ import type { IUser } from '@rocket.chat/core-typings'; import { isEditedMessage, type IMessage } from '@rocket.chat/core-typings'; import { Messages } from '@rocket.chat/models'; +import { cachedFunction } from './cachedFunction'; import { transformMappedData } from './transformMappedData'; // eslint-disable-next-line @typescript-eslint/naming-convention @@ -18,24 +19,6 @@ interface Orchestrator { }; } -const cachedFunction = any>(fn: F) => { - const cache = new Map(); - - return ((...args) => { - const cacheKey = JSON.stringify(args); - - if (cache.has(cacheKey)) { - return cache.get(cacheKey) as ReturnType; - } - - const result = fn(...args); - - cache.set(cacheKey, result); - - return result; - }) as F; -}; - export class AppThreadsConverter implements IAppThreadsConverter { constructor( private readonly orch: { diff --git a/apps/meteor/app/authentication/server/startup/index.js b/apps/meteor/app/authentication/server/startup/index.js index bffbe1f9876dd..2e4c599ce558c 100644 --- a/apps/meteor/app/authentication/server/startup/index.js +++ b/apps/meteor/app/authentication/server/startup/index.js @@ -2,6 +2,7 @@ import { Apps, AppEvents } from '@rocket.chat/apps'; import { User } from '@rocket.chat/core-services'; import { Roles, Settings, Users } from '@rocket.chat/models'; import { escapeRegExp, escapeHTML } from '@rocket.chat/string-helpers'; +import { getLoginExpirationInDays } from '@rocket.chat/tools'; import { Accounts } from 'meteor/accounts-base'; import { Match } from 'meteor/check'; import { Meteor } from 'meteor/meteor'; @@ -31,7 +32,7 @@ Accounts.config({ Meteor.startup(() => { settings.watchMultiple(['Accounts_LoginExpiration', 'Site_Name', 'From_Email'], () => { - Accounts._options.loginExpirationInDays = settings.get('Accounts_LoginExpiration'); + Accounts._options.loginExpirationInDays = getLoginExpirationInDays(settings.get('Accounts_LoginExpiration')); Accounts.emailTemplates.siteName = settings.get('Site_Name'); diff --git a/apps/meteor/app/discussion/client/createDiscussionMessageAction.ts b/apps/meteor/app/discussion/client/createDiscussionMessageAction.ts index 3ad61c4c42f06..ecf0142488308 100644 --- a/apps/meteor/app/discussion/client/createDiscussionMessageAction.ts +++ b/apps/meteor/app/discussion/client/createDiscussionMessageAction.ts @@ -44,7 +44,7 @@ Meteor.startup(() => { subscription, user, }) { - if (drid || !Number.isNaN(dcount)) { + if (drid || !Number.isNaN(Number(dcount))) { return false; } if (!subscription) { diff --git a/apps/meteor/app/discussion/server/hooks/propagateDiscussionMetadata.ts b/apps/meteor/app/discussion/server/hooks/propagateDiscussionMetadata.ts index 0f42f495e9626..d8e3637575ab5 100644 --- a/apps/meteor/app/discussion/server/hooks/propagateDiscussionMetadata.ts +++ b/apps/meteor/app/discussion/server/hooks/propagateDiscussionMetadata.ts @@ -1,5 +1,5 @@ import type { IRoom } from '@rocket.chat/core-typings'; -import { Messages, Rooms } from '@rocket.chat/models'; +import { Messages, Rooms, VideoConference } from '@rocket.chat/models'; import { callbacks } from '../../../../lib/callbacks'; import { broadcastMessageFromData } from '../../../../server/modules/watchers/lib/messages'; @@ -108,6 +108,8 @@ callbacks.add( }, }, ); + + await VideoConference.unsetDiscussionRid(drid); return drid; }, callbacks.priority.LOW, diff --git a/apps/meteor/app/e2e/client/rocketchat.e2e.ts b/apps/meteor/app/e2e/client/rocketchat.e2e.ts index 0cc344ff51527..bbd6f208f35a5 100644 --- a/apps/meteor/app/e2e/client/rocketchat.e2e.ts +++ b/apps/meteor/app/e2e/client/rocketchat.e2e.ts @@ -257,7 +257,7 @@ class E2E extends Emitter { return null; } - if (room.encrypted !== true && !room.e2eKeyId) { + if (!room.encrypted) { return null; } @@ -272,7 +272,7 @@ class E2E extends Emitter { delete this.instancesByRoomId[rid]; } - async persistKeys( + private async persistKeys( { public_key, private_key }: KeyPair, password: string, { force }: { force: boolean } = { force: false }, diff --git a/apps/meteor/app/emoji/client/emojiParser.js b/apps/meteor/app/emoji/client/emojiParser.js index 7b887bb0575fe..0b3b722aaebdf 100644 --- a/apps/meteor/app/emoji/client/emojiParser.js +++ b/apps/meteor/app/emoji/client/emojiParser.js @@ -1,17 +1,13 @@ import { isIE11 } from '../../../client/lib/utils/isIE11'; import { emoji } from './lib'; -/* +/** * emojiParser is a function that will replace emojis - * @param {Object} message - The message object + * @param {{ html: string }} message - The message object + * @return {{ html: string }} */ - -const emojiParser = (message) => { - if (!message.html?.trim()) { - return message; - } - - let html = message.html.trim(); +export const emojiParser = ({ html }) => { + html = html.trim(); // ' to apostrophe (') for emojis such as :') html = html.replace(/'/g, "'"); @@ -64,7 +60,5 @@ const emojiParser = (message) => { // line breaks '
' back to '
' html = html.replace(/
/g, '
'); - return { ...message, html }; + return { html }; }; - -export { emojiParser }; diff --git a/apps/meteor/app/lib/server/functions/addUserToRoom.ts b/apps/meteor/app/lib/server/functions/addUserToRoom.ts index 57ea20f00cb1e..b6ffc0ca4629a 100644 --- a/apps/meteor/app/lib/server/functions/addUserToRoom.ts +++ b/apps/meteor/app/lib/server/functions/addUserToRoom.ts @@ -15,9 +15,15 @@ import { notifyOnRoomChangedById } from '../lib/notifyListener'; export const addUserToRoom = async function ( rid: string, - user: Pick | string, + user: Pick | string, inviter?: Pick, - silenced?: boolean, + { + skipSystemMessage, + skipAlertSound, + }: { + skipSystemMessage?: boolean; + skipAlertSound?: boolean; + } = {}, ): Promise { const now = new Date(); const room = await Rooms.findOneById(rid); @@ -43,12 +49,12 @@ export const addUserToRoom = async function ( } try { - await callbacks.run('federation.beforeAddUserToARoom', { user, inviter }, room); + await callbacks.run('federation.beforeAddUserToARoom', { user: userToBeAdded, inviter }, room); } catch (error) { throw new Meteor.Error((error as any)?.message); } - await callbacks.run('beforeAddedToRoom', { user: userToBeAdded, inviter: userToBeAdded }); + await callbacks.run('beforeAddedToRoom', { user: userToBeAdded, inviter }); // Check if user is already in room const subscription = await Subscriptions.findOneByRoomIdAndUserId(rid, userToBeAdded._id); @@ -79,7 +85,7 @@ export const addUserToRoom = async function ( await Subscriptions.createWithRoomAndUser(room, userToBeAdded as IUser, { ts: now, open: true, - alert: true, + alert: !skipAlertSound, unread: 1, userMentions: 1, groupMentions: 0, @@ -93,7 +99,7 @@ export const addUserToRoom = async function ( throw new Meteor.Error('error-invalid-user', 'Cannot add an user to a room without a username'); } - if (!silenced) { + if (!skipSystemMessage) { if (inviter) { const extraData = { ts: now, diff --git a/apps/meteor/app/lib/server/functions/removeUserFromRoom.ts b/apps/meteor/app/lib/server/functions/removeUserFromRoom.ts index 3b065c68f15c4..c55ee382f10cf 100644 --- a/apps/meteor/app/lib/server/functions/removeUserFromRoom.ts +++ b/apps/meteor/app/lib/server/functions/removeUserFromRoom.ts @@ -10,11 +10,7 @@ import { beforeLeaveRoomCallback } from '../../../../lib/callbacks/beforeLeaveRo import { settings } from '../../../settings/server'; import { notifyOnRoomChangedById } from '../lib/notifyListener'; -export const removeUserFromRoom = async function ( - rid: string, - user: IUser, - options?: { byUser: Pick }, -): Promise { +export const removeUserFromRoom = async function (rid: string, user: IUser, options?: { byUser: IUser }): Promise { const room = await Rooms.findOneById(rid); if (!room) { @@ -22,7 +18,7 @@ export const removeUserFromRoom = async function ( } try { - await Apps.self?.triggerEvent(AppEvents.IPreRoomUserLeave, room, user); + await Apps.self?.triggerEvent(AppEvents.IPreRoomUserLeave, room, user, options?.byUser); } catch (error: any) { if (error.name === AppsEngineException.name) { throw new Meteor.Error('error-app-prevented', error.message); @@ -75,5 +71,5 @@ export const removeUserFromRoom = async function ( void notifyOnRoomChangedById(rid); - await Apps.self?.triggerEvent(AppEvents.IPostRoomUserLeave, room, user); + await Apps.self?.triggerEvent(AppEvents.IPostRoomUserLeave, room, user, options?.byUser); }; diff --git a/apps/meteor/app/lib/server/lib/notifyListener.ts b/apps/meteor/app/lib/server/lib/notifyListener.ts index f4e948390c99b..635c236fda27a 100644 --- a/apps/meteor/app/lib/server/lib/notifyListener.ts +++ b/apps/meteor/app/lib/server/lib/notifyListener.ts @@ -34,415 +34,322 @@ import { type ClientAction = 'inserted' | 'updated' | 'removed'; -export async function notifyOnLivechatPriorityChanged( - data: Pick, - clientAction: ClientAction = 'updated', -): Promise { - if (!dbWatchersDisabled) { - return; - } - - const { _id, ...rest } = data; - - void api.broadcast('watch.priorities', { clientAction, id: _id, diff: { ...rest } }); -} - -export async function notifyOnRoomChanged( - data: T | T[], - clientAction: ClientAction = 'updated', -): Promise { - if (!dbWatchersDisabled) { - return; - } - - const items = Array.isArray(data) ? data : [data]; - - for (const item of items) { - void api.broadcast('watch.rooms', { clientAction, room: item }); - } +function withDbWatcherCheck Promise>(fn: T): T { + return dbWatchersDisabled ? fn : ((() => Promise.resolve()) as T); } -export async function notifyOnRoomChangedById( - ids: T['_id'] | T['_id'][], - clientAction: ClientAction = 'updated', -): Promise { - if (!dbWatchersDisabled) { - return; - } - - const eligibleIds = Array.isArray(ids) ? ids : [ids]; - - const items = Rooms.findByIds(eligibleIds); - - for await (const item of items) { - void api.broadcast('watch.rooms', { clientAction, room: item }); - } -} - -export async function notifyOnRoomChangedByUsernamesOrUids( - uids: T['u']['_id'][], - usernames: T['u']['username'][], - clientAction: ClientAction = 'updated', -): Promise { - if (!dbWatchersDisabled) { - return; - } - - const items = Rooms.findByUsernamesOrUids(uids, usernames); - - for await (const item of items) { - void api.broadcast('watch.rooms', { clientAction, room: item }); - } -} - -export async function notifyOnRoomChangedByUserDM( - userId: T['u']['_id'], - clientAction: ClientAction = 'updated', -): Promise { - if (!dbWatchersDisabled) { - return; - } - - const items = Rooms.findDMsByUids([userId]); - - for await (const item of items) { - void api.broadcast('watch.rooms', { clientAction, room: item }); - } -} - -export async function notifyOnPermissionChanged(permission: IPermission, clientAction: ClientAction = 'updated'): Promise { - if (!dbWatchersDisabled) { - return; - } - - void api.broadcast('permission.changed', { clientAction, data: permission }); +export const notifyOnLivechatPriorityChanged = withDbWatcherCheck( + async (data: Pick, clientAction: ClientAction = 'updated'): Promise => { + const { _id, ...rest } = data; + void api.broadcast('watch.priorities', { clientAction, id: _id, diff: { ...rest } }); + }, +); + +export const notifyOnRoomChanged = withDbWatcherCheck( + async (data: T | T[], clientAction: ClientAction = 'updated'): Promise => { + const items = Array.isArray(data) ? data : [data]; + for (const item of items) { + void api.broadcast('watch.rooms', { clientAction, room: item }); + } + }, +); + +export const notifyOnRoomChangedById = withDbWatcherCheck( + async (ids: T['_id'] | T['_id'][], clientAction: ClientAction = 'updated'): Promise => { + const eligibleIds = Array.isArray(ids) ? ids : [ids]; + const items = Rooms.findByIds(eligibleIds); + for await (const item of items) { + void api.broadcast('watch.rooms', { clientAction, room: item }); + } + }, +); + +export const notifyOnRoomChangedByUsernamesOrUids = withDbWatcherCheck( + async ( + uids: T['u']['_id'][], + usernames: T['u']['username'][], + clientAction: ClientAction = 'updated', + ): Promise => { + const items = Rooms.findByUsernamesOrUids(uids, usernames); + for await (const item of items) { + void api.broadcast('watch.rooms', { clientAction, room: item }); + } + }, +); + +export const notifyOnRoomChangedByUserDM = withDbWatcherCheck( + async (userId: T['u']['_id'], clientAction: ClientAction = 'updated'): Promise => { + const items = Rooms.findDMsByUids([userId]); + for await (const item of items) { + void api.broadcast('watch.rooms', { clientAction, room: item }); + } + }, +); + +export const notifyOnPermissionChanged = withDbWatcherCheck( + async (permission: IPermission, clientAction: ClientAction = 'updated'): Promise => { + void api.broadcast('permission.changed', { clientAction, data: permission }); + + if (permission.level === 'settings' && permission.settingId) { + const setting = await Settings.findOneNotHiddenById(permission.settingId); + if (!setting) { + return; + } + void notifyOnSettingChanged(setting, 'updated'); + } + }, +); - if (permission.level === 'settings' && permission.settingId) { - const setting = await Settings.findOneNotHiddenById(permission.settingId); - if (!setting) { +export const notifyOnPermissionChangedById = withDbWatcherCheck( + async (pid: IPermission['_id'], clientAction: ClientAction = 'updated'): Promise => { + const permission = await Permissions.findOneById(pid); + if (!permission) { return; } - void notifyOnSettingChanged(setting, 'updated'); - } -} -export async function notifyOnPermissionChangedById(pid: IPermission['_id'], clientAction: ClientAction = 'updated'): Promise { - if (!dbWatchersDisabled) { - return; - } - - const permission = await Permissions.findOneById(pid); - if (!permission) { - return; - } - - return notifyOnPermissionChanged(permission, clientAction); -} - -export async function notifyOnPbxEventChangedById( - id: T['_id'], - clientAction: ClientAction = 'updated', -): Promise { - if (!dbWatchersDisabled) { - return; - } + return notifyOnPermissionChanged(permission, clientAction); + }, +); - const item = await PbxEvents.findOneById(id); - if (!item) { - return; - } - - void api.broadcast('watch.pbxevents', { clientAction, id, data: item }); -} - -export async function notifyOnRoleChanged(role: T, clientAction: 'removed' | 'changed' = 'changed'): Promise { - if (!dbWatchersDisabled) { - return; - } - - void api.broadcast('watch.roles', { clientAction, role }); -} - -export async function notifyOnRoleChangedById( - id: T['_id'], - clientAction: 'removed' | 'changed' = 'changed', -): Promise { - if (!dbWatchersDisabled) { - return; - } - - const role = await Roles.findOneById(id); - if (!role) { - return; - } - - void notifyOnRoleChanged(role, clientAction); -} - -export async function notifyOnLoginServiceConfigurationChanged( - service: Partial & Pick, - clientAction: ClientAction = 'updated', -): Promise { - if (!dbWatchersDisabled) { - return; - } - - void api.broadcast('watch.loginServiceConfiguration', { - clientAction, - id: service._id, - data: service, - }); -} - -export async function notifyOnLoginServiceConfigurationChangedByService( - service: T['service'], - clientAction: ClientAction = 'updated', -): Promise { - if (!dbWatchersDisabled) { - return; - } - - const item = await LoginServiceConfiguration.findOneByService>(service, { - projection: { secret: 0 }, - }); - if (!item) { - return; - } - - void notifyOnLoginServiceConfigurationChanged(item, clientAction); -} - -export async function notifyOnIntegrationChanged(data: T, clientAction: ClientAction = 'updated'): Promise { - if (!dbWatchersDisabled) { - return; - } - - void api.broadcast('watch.integrations', { clientAction, id: data._id, data }); -} +export const notifyOnPbxEventChangedById = withDbWatcherCheck( + async (id: T['_id'], clientAction: ClientAction = 'updated'): Promise => { + const item = await PbxEvents.findOneById(id); + if (!item) { + return; + } -export async function notifyOnIntegrationChangedById( - id: T['_id'], - clientAction: ClientAction = 'updated', -): Promise { - if (!dbWatchersDisabled) { - return; - } + void api.broadcast('watch.pbxevents', { clientAction, id, data: item }); + }, +); - const item = await Integrations.findOneById(id); - if (!item) { - return; - } +export const notifyOnRoleChanged = withDbWatcherCheck( + async (role: T, clientAction: 'removed' | 'changed' = 'changed'): Promise => { + void api.broadcast('watch.roles', { clientAction, role }); + }, +); - void api.broadcast('watch.integrations', { clientAction, id: item._id, data: item }); -} +export const notifyOnRoleChangedById = withDbWatcherCheck( + async (id: T['_id'], clientAction: 'removed' | 'changed' = 'changed'): Promise => { + const role = await Roles.findOneById(id); + if (!role) { + return; + } -export async function notifyOnIntegrationChangedByUserId( - id: T['userId'], - clientAction: ClientAction = 'updated', -): Promise { - if (!dbWatchersDisabled) { - return; - } + void notifyOnRoleChanged(role, clientAction); + }, +); + +export const notifyOnLoginServiceConfigurationChanged = withDbWatcherCheck( + async ( + service: Partial & Pick, + clientAction: ClientAction = 'updated', + ): Promise => { + void api.broadcast('watch.loginServiceConfiguration', { + clientAction, + id: service._id, + data: service, + }); + }, +); + +export const notifyOnLoginServiceConfigurationChangedByService = withDbWatcherCheck( + async (service: T['service'], clientAction: ClientAction = 'updated'): Promise => { + const item = await LoginServiceConfiguration.findOneByService>(service, { + projection: { secret: 0 }, + }); + if (!item) { + return; + } - const items = Integrations.findByUserId(id); + void notifyOnLoginServiceConfigurationChanged(item, clientAction); + }, +); - for await (const item of items) { - void api.broadcast('watch.integrations', { clientAction, id: item._id, data: item }); - } -} +export const notifyOnIntegrationChanged = withDbWatcherCheck( + async (data: T, clientAction: ClientAction = 'updated'): Promise => { + void api.broadcast('watch.integrations', { clientAction, id: data._id, data }); + }, +); -export async function notifyOnIntegrationChangedByChannels( - channels: T['channel'], - clientAction: ClientAction = 'updated', -): Promise { - if (!dbWatchersDisabled) { - return; - } - - const items = Integrations.findByChannels(channels); +export const notifyOnIntegrationChangedById = withDbWatcherCheck( + async (id: T['_id'], clientAction: ClientAction = 'updated'): Promise => { + const item = await Integrations.findOneById(id); + if (!item) { + return; + } - for await (const item of items) { void api.broadcast('watch.integrations', { clientAction, id: item._id, data: item }); - } -} - -export async function notifyOnEmailInboxChanged( - data: Pick | T, // TODO: improve typing - clientAction: ClientAction = 'updated', -): Promise { - if (!dbWatchersDisabled) { - return; - } - - void api.broadcast('watch.emailInbox', { clientAction, id: data._id, data }); -} - -export async function notifyOnLivechatInquiryChanged( - data: ILivechatInquiryRecord | ILivechatInquiryRecord[], - clientAction: ClientAction = 'updated', - diff?: Partial & { queuedAt: unknown; takenAt: unknown }>, -): Promise { - if (!dbWatchersDisabled) { - return; - } - - const items = Array.isArray(data) ? data : [data]; - - for (const item of items) { - void api.broadcast('watch.inquiries', { clientAction, inquiry: item, diff }); - } -} - -export async function notifyOnLivechatInquiryChangedById( - id: ILivechatInquiryRecord['_id'], - clientAction: ClientAction = 'updated', - diff?: Partial & { queuedAt: unknown; takenAt: unknown }>, -): Promise { - if (!dbWatchersDisabled) { - return; - } - - const inquiry = clientAction === 'removed' ? await LivechatInquiry.trashFindOneById(id) : await LivechatInquiry.findOneById(id); - - if (!inquiry) { - return; - } - - void api.broadcast('watch.inquiries', { clientAction, inquiry, diff }); -} - -export async function notifyOnLivechatInquiryChangedByRoom( - rid: ILivechatInquiryRecord['rid'], - clientAction: ClientAction = 'updated', - diff?: Partial & { queuedAt: unknown; takenAt: unknown }>, -): Promise { - if (!dbWatchersDisabled) { - return; - } - - const inquiry = await LivechatInquiry.findOneByRoomId(rid, {}); - - if (!inquiry) { - return; - } - - void api.broadcast('watch.inquiries', { clientAction, inquiry, diff }); -} - -export async function notifyOnLivechatInquiryChangedByToken( - token: ILivechatInquiryRecord['v']['token'], - clientAction: ClientAction = 'updated', - diff?: Partial & { queuedAt: unknown; takenAt: unknown }>, -): Promise { - if (!dbWatchersDisabled) { - return; - } + }, +); - const inquiry = await LivechatInquiry.findOneByToken(token); - - if (!inquiry) { - return; - } - - void api.broadcast('watch.inquiries', { clientAction, inquiry, diff }); -} - -export async function notifyOnIntegrationHistoryChanged( - data: AtLeast, - clientAction: ClientAction = 'updated', - diff: Partial = {}, -): Promise { - if (!dbWatchersDisabled) { - return; - } - - void api.broadcast('watch.integrationHistory', { clientAction, id: data._id, data, diff }); -} - -export async function notifyOnIntegrationHistoryChangedById( - id: T['_id'], - clientAction: ClientAction = 'updated', - diff: Partial = {}, -): Promise { - if (!dbWatchersDisabled) { - return; - } +export const notifyOnIntegrationChangedByUserId = withDbWatcherCheck( + async (id: T['userId'], clientAction: ClientAction = 'updated'): Promise => { + const items = Integrations.findByUserId(id); - const item = await IntegrationHistory.findOneById(id); + for await (const item of items) { + void api.broadcast('watch.integrations', { clientAction, id: item._id, data: item }); + } + }, +); - if (!item) { - return; - } +export const notifyOnIntegrationChangedByChannels = withDbWatcherCheck( + async (channels: T['channel'], clientAction: ClientAction = 'updated'): Promise => { + const items = Integrations.findByChannels(channels); - void api.broadcast('watch.integrationHistory', { clientAction, id: item._id, data: item, diff }); -} + for await (const item of items) { + void api.broadcast('watch.integrations', { clientAction, id: item._id, data: item }); + } + }, +); + +export const notifyOnEmailInboxChanged = withDbWatcherCheck( + async ( + data: Pick | T, // TODO: improve typing + clientAction: ClientAction = 'updated', + ): Promise => { + void api.broadcast('watch.emailInbox', { clientAction, id: data._id, data }); + }, +); + +export const notifyOnLivechatInquiryChanged = withDbWatcherCheck( + async ( + data: ILivechatInquiryRecord | ILivechatInquiryRecord[], + clientAction: ClientAction = 'updated', + diff?: Partial & { queuedAt: unknown; takenAt: unknown }>, + ): Promise => { + const items = Array.isArray(data) ? data : [data]; + + for (const item of items) { + void api.broadcast('watch.inquiries', { clientAction, inquiry: item, diff }); + } + }, +); + +export const notifyOnLivechatInquiryChangedById = withDbWatcherCheck( + async ( + id: ILivechatInquiryRecord['_id'], + clientAction: ClientAction = 'updated', + diff?: Partial & { queuedAt: unknown; takenAt: unknown }>, + ): Promise => { + const inquiry = clientAction === 'removed' ? await LivechatInquiry.trashFindOneById(id) : await LivechatInquiry.findOneById(id); + + if (!inquiry) { + return; + } -export async function notifyOnLivechatDepartmentAgentChanged( - data: Partial & Pick, - clientAction: ClientAction = 'updated', -): Promise { - if (!dbWatchersDisabled) { - return; - } + void api.broadcast('watch.inquiries', { clientAction, inquiry, diff }); + }, +); - void api.broadcast('watch.livechatDepartmentAgents', { clientAction, id: data._id, data }); -} +export const notifyOnLivechatInquiryChangedByRoom = withDbWatcherCheck( + async ( + rid: ILivechatInquiryRecord['rid'], + clientAction: ClientAction = 'updated', + diff?: Partial & { queuedAt: unknown; takenAt: unknown }>, + ): Promise => { + const inquiry = await LivechatInquiry.findOneByRoomId(rid, {}); -export async function notifyOnLivechatDepartmentAgentChangedByDepartmentId( - departmentId: T['departmentId'], - clientAction: 'inserted' | 'updated' = 'updated', -): Promise { - if (!dbWatchersDisabled) { - return; - } + if (!inquiry) { + return; + } - const items = LivechatDepartmentAgents.findByDepartmentId(departmentId, { projection: { _id: 1, agentId: 1, departmentId: 1 } }); + void api.broadcast('watch.inquiries', { clientAction, inquiry, diff }); + }, +); - for await (const item of items) { - void api.broadcast('watch.livechatDepartmentAgents', { clientAction, id: item._id, data: item }); - } -} +export const notifyOnLivechatInquiryChangedByToken = withDbWatcherCheck( + async ( + token: ILivechatInquiryRecord['v']['token'], + clientAction: ClientAction = 'updated', + diff?: Partial & { queuedAt: unknown; takenAt: unknown }>, + ): Promise => { + const inquiry = await LivechatInquiry.findOneByToken(token); -export async function notifyOnLivechatDepartmentAgentChangedByAgentsAndDepartmentId( - agentsIds: T['agentId'][], - departmentId: T['departmentId'], - clientAction: 'inserted' | 'updated' = 'updated', -): Promise { - if (!dbWatchersDisabled) { - return; - } + if (!inquiry) { + return; + } - const items = LivechatDepartmentAgents.findByAgentsAndDepartmentId(agentsIds, departmentId, { - projection: { _id: 1, agentId: 1, departmentId: 1 }, - }); + void api.broadcast('watch.inquiries', { clientAction, inquiry, diff }); + }, +); + +export const notifyOnIntegrationHistoryChanged = withDbWatcherCheck( + async ( + data: AtLeast, + clientAction: ClientAction = 'updated', + diff: Partial = {}, + ): Promise => { + void api.broadcast('watch.integrationHistory', { clientAction, id: data._id, data, diff }); + }, +); + +export const notifyOnIntegrationHistoryChangedById = withDbWatcherCheck( + async (id: T['_id'], clientAction: ClientAction = 'updated', diff: Partial = {}): Promise => { + const item = await IntegrationHistory.findOneById(id); + + if (!item) { + return; + } - for await (const item of items) { - void api.broadcast('watch.livechatDepartmentAgents', { clientAction, id: item._id, data: item }); - } -} + void api.broadcast('watch.integrationHistory', { clientAction, id: item._id, data: item, diff }); + }, +); + +export const notifyOnLivechatDepartmentAgentChanged = withDbWatcherCheck( + async ( + data: Partial & Pick, + clientAction: ClientAction = 'updated', + ): Promise => { + void api.broadcast('watch.livechatDepartmentAgents', { clientAction, id: data._id, data }); + }, +); + +export const notifyOnLivechatDepartmentAgentChangedByDepartmentId = withDbWatcherCheck( + async ( + departmentId: T['departmentId'], + clientAction: 'inserted' | 'updated' = 'updated', + ): Promise => { + const items = LivechatDepartmentAgents.findByDepartmentId(departmentId, { projection: { _id: 1, agentId: 1, departmentId: 1 } }); + + for await (const item of items) { + void api.broadcast('watch.livechatDepartmentAgents', { clientAction, id: item._id, data: item }); + } + }, +); + +export const notifyOnLivechatDepartmentAgentChangedByAgentsAndDepartmentId = withDbWatcherCheck( + async ( + agentsIds: T['agentId'][], + departmentId: T['departmentId'], + clientAction: 'inserted' | 'updated' = 'updated', + ): Promise => { + const items = LivechatDepartmentAgents.findByAgentsAndDepartmentId(agentsIds, departmentId, { + projection: { _id: 1, agentId: 1, departmentId: 1 }, + }); + + for await (const item of items) { + void api.broadcast('watch.livechatDepartmentAgents', { clientAction, id: item._id, data: item }); + } + }, +); -export async function notifyOnSettingChanged( - setting: ISetting & { editor?: ISettingColor['editor'] }, - clientAction: ClientAction = 'updated', -): Promise { - if (!dbWatchersDisabled) { - return; - } - void api.broadcast('watch.settings', { clientAction, setting }); -} +export const notifyOnSettingChanged = withDbWatcherCheck( + async (setting: ISetting & { editor?: ISettingColor['editor'] }, clientAction: ClientAction = 'updated'): Promise => { + void api.broadcast('watch.settings', { clientAction, setting }); + }, +); -export async function notifyOnSettingChangedById(id: ISetting['_id'], clientAction: ClientAction = 'updated'): Promise { - if (!dbWatchersDisabled) { - return; - } - const item = clientAction === 'removed' ? await Settings.trashFindOneById(id) : await Settings.findOneById(id); +export const notifyOnSettingChangedById = withDbWatcherCheck( + async (id: ISetting['_id'], clientAction: ClientAction = 'updated'): Promise => { + const item = clientAction === 'removed' ? await Settings.trashFindOneById(id) : await Settings.findOneById(id); - if (!item) { - return; - } + if (!item) { + return; + } - void api.broadcast('watch.settings', { clientAction, setting: item }); -} + void api.broadcast('watch.settings', { clientAction, setting: item }); + }, +); type NotifyUserChange = { id: IUser['_id']; @@ -452,30 +359,24 @@ type NotifyUserChange = { unset?: Record; }; -export async function notifyOnUserChange({ clientAction, id, data, diff, unset }: NotifyUserChange) { - if (!dbWatchersDisabled) { - return; - } +export const notifyOnUserChange = withDbWatcherCheck(async ({ clientAction, id, data, diff, unset }: NotifyUserChange) => { if (clientAction === 'removed') { void api.broadcast('watch.users', { clientAction, id }); return; } + if (clientAction === 'inserted') { void api.broadcast('watch.users', { clientAction, id, data: data! }); return; } void api.broadcast('watch.users', { clientAction, diff: diff!, unset: unset || {}, id }); -} +}); /** * Calls the callback only if DB Watchers are disabled */ -export async function notifyOnUserChangeAsync(cb: () => Promise) { - if (!dbWatchersDisabled) { - return; - } - +export const notifyOnUserChangeAsync = withDbWatcherCheck(async (cb: () => Promise) => { const result = await cb(); if (!result) { return; @@ -487,17 +388,16 @@ export async function notifyOnUserChangeAsync(cb: () => Promise { + const user = await Users.findOneById(id); + if (!user) { + return; + } - void notifyOnUserChange({ id, clientAction, data: user }); -} + void notifyOnUserChange({ id, clientAction, data: user }); + }, +); diff --git a/apps/meteor/app/lib/server/lib/sendNotificationsOnMessage.ts b/apps/meteor/app/lib/server/lib/sendNotificationsOnMessage.ts index 44654428ae8fb..49fcc0ea4725c 100644 --- a/apps/meteor/app/lib/server/lib/sendNotificationsOnMessage.ts +++ b/apps/meteor/app/lib/server/lib/sendNotificationsOnMessage.ts @@ -266,7 +266,7 @@ export async function sendMessageNotifications(message: IMessage, room: IRoom, u return; } - const sender = await roomCoordinator.getRoomDirectives(room.t).getMsgSender(message.u._id); + const sender = await roomCoordinator.getRoomDirectives(room.t).getMsgSender(message); if (!sender) { return message; } diff --git a/apps/meteor/app/livechat-enterprise/client/components/modals/PlaceChatOnHoldModal.tsx b/apps/meteor/app/livechat-enterprise/client/components/modals/PlaceChatOnHoldModal.tsx index c70f32c11c0d6..41af8a22b6bbc 100644 --- a/apps/meteor/app/livechat-enterprise/client/components/modals/PlaceChatOnHoldModal.tsx +++ b/apps/meteor/app/livechat-enterprise/client/components/modals/PlaceChatOnHoldModal.tsx @@ -1,6 +1,5 @@ import { Button, Modal } from '@rocket.chat/fuselage'; import { useTranslation } from '@rocket.chat/ui-contexts'; -import type { FC } from 'react'; import React from 'react'; type PlaceChatOnHoldModalProps = { @@ -9,7 +8,7 @@ type PlaceChatOnHoldModalProps = { onCancel: () => void; }; -const PlaceChatOnHoldModal: FC = ({ onCancel, onOnHoldChat, confirm = onOnHoldChat, ...props }) => { +const PlaceChatOnHoldModal = ({ onCancel, onOnHoldChat, confirm = onOnHoldChat, ...props }: PlaceChatOnHoldModalProps) => { const t = useTranslation(); return ( diff --git a/apps/meteor/app/livechat/imports/server/rest/rooms.ts b/apps/meteor/app/livechat/imports/server/rest/rooms.ts index f7d5ddb314c92..f80ed61a131e8 100644 --- a/apps/meteor/app/livechat/imports/server/rest/rooms.ts +++ b/apps/meteor/app/livechat/imports/server/rest/rooms.ts @@ -30,7 +30,7 @@ API.v1.addRoute( async get() { const { offset, count } = await getPaginationItems(this.queryParams); const { sort, fields } = await this.parseJsonQuery(); - const { agents, departmentId, open, tags, roomName, onhold } = this.queryParams; + const { agents, departmentId, open, tags, roomName, onhold, queued } = this.queryParams; const { createdAt, customFields, closedAt } = this.queryParams; const createdAtParam = validateDateParams('createdAt', createdAt); @@ -69,6 +69,7 @@ API.v1.addRoute( tags, customFields: parsedCf, onhold, + queued, options: { offset, count, sort, fields }, }), ); diff --git a/apps/meteor/app/livechat/imports/server/rest/sms.ts b/apps/meteor/app/livechat/imports/server/rest/sms.ts index f6502b70f68a9..6f8ce64bc6352 100644 --- a/apps/meteor/app/livechat/imports/server/rest/sms.ts +++ b/apps/meteor/app/livechat/imports/server/rest/sms.ts @@ -73,8 +73,13 @@ const defineVisitor = async (smsNumber: string, targetDepartment?: string) => { data.department = targetDepartment; } - const id = await LivechatTyped.registerGuest(data); - return LivechatVisitors.findOneEnabledById(id); + const livechatVisitor = await LivechatTyped.registerGuest(data); + + if (!livechatVisitor) { + throw new Meteor.Error('error-invalid-visitor', 'Invalid visitor'); + } + + return livechatVisitor; }; const normalizeLocationSharing = (payload: ServiceData) => { @@ -110,12 +115,6 @@ API.v1.addRoute('livechat/sms-incoming/:service', { return API.v1.success(SMSService.error(new Error('Invalid visitor'))); } - const { token } = visitor; - const room = await LivechatRooms.findOneOpenByVisitorTokenAndDepartmentIdAndSource(token, targetDepartment, OmnichannelSourceType.SMS); - const roomExists = !!room; - const location = normalizeLocationSharing(sms); - const rid = room?._id || Random.id(); - const roomInfo = { sms: { from: sms.to, @@ -126,10 +125,15 @@ API.v1.addRoute('livechat/sms-incoming/:service', { }, }; - // create an empty room first place, so attachments have a place to live - if (!roomExists) { - await LivechatTyped.getRoom(visitor, { rid, token, msg: '' }, roomInfo, undefined); - } + const { token } = visitor; + const room = + (await LivechatRooms.findOneOpenByVisitorTokenAndDepartmentIdAndSource(token, targetDepartment, OmnichannelSourceType.SMS)) ?? + (await LivechatTyped.createRoom({ + visitor, + roomInfo, + })); + const location = normalizeLocationSharing(sms); + const rid = room?._id; let file: ILivechatMessage['file']; const attachments: (MessageAttachment | undefined)[] = []; diff --git a/apps/meteor/app/livechat/server/api/lib/livechat.ts b/apps/meteor/app/livechat/server/api/lib/livechat.ts index 00229dae2de51..617d255cb6cb2 100644 --- a/apps/meteor/app/livechat/server/api/lib/livechat.ts +++ b/apps/meteor/app/livechat/server/api/lib/livechat.ts @@ -1,14 +1,6 @@ -import type { - ILivechatAgent, - ILivechatDepartment, - ILivechatTrigger, - ILivechatVisitor, - IOmnichannelRoom, - SelectedAgent, -} from '@rocket.chat/core-typings'; +import type { ILivechatAgent, ILivechatDepartment, ILivechatTrigger, ILivechatVisitor, IOmnichannelRoom } from '@rocket.chat/core-typings'; import { License } from '@rocket.chat/license'; import { EmojiCustom, LivechatTrigger, LivechatVisitors, LivechatRooms, LivechatDepartment } from '@rocket.chat/models'; -import { Random } from '@rocket.chat/random'; import { Meteor } from 'meteor/meteor'; import { callbacks } from '../../../../../lib/callbacks'; @@ -104,33 +96,6 @@ export async function findOpenRoom(token: string, departmentId?: string): Promis return rooms[0]; } } -export function getRoom({ - guest, - rid, - roomInfo, - agent, - extraParams, -}: { - guest: ILivechatVisitor; - rid: string; - roomInfo: { - source?: IOmnichannelRoom['source']; - }; - agent?: SelectedAgent; - extraParams?: Record; -}): Promise<{ room: IOmnichannelRoom; newRoom: boolean }> { - const token = guest?.token; - - const message = { - _id: Random.id(), - rid, - msg: '', - token, - ts: new Date(), - }; - - return LivechatTyped.getRoom(guest, message, roomInfo, agent, extraParams); -} export async function findAgent(agentId?: string): Promise { return normalizeAgent(agentId); diff --git a/apps/meteor/app/livechat/server/api/lib/rooms.ts b/apps/meteor/app/livechat/server/api/lib/rooms.ts index b130e5c2c73a4..26449dce39631 100644 --- a/apps/meteor/app/livechat/server/api/lib/rooms.ts +++ b/apps/meteor/app/livechat/server/api/lib/rooms.ts @@ -14,6 +14,7 @@ export async function findRooms({ tags, customFields, onhold, + queued, options: { offset, count, fields, sort }, }: { agents?: Array; @@ -31,6 +32,7 @@ export async function findRooms({ tags?: Array; customFields?: Record; onhold?: string | boolean; + queued?: string | boolean; options: { offset: number; count: number; fields: Record; sort: Record }; }): Promise }>> { const extraQuery = await callbacks.run('livechat.applyRoomRestrictions', {}); @@ -44,6 +46,7 @@ export async function findRooms({ tags, customFields, onhold: ['t', 'true', '1'].includes(`${onhold}`), + queued: ['t', 'true', '1'].includes(`${queued}`), options: { sort: sort || { ts: -1 }, offset, diff --git a/apps/meteor/app/livechat/server/api/v1/message.ts b/apps/meteor/app/livechat/server/api/v1/message.ts index 97c92eeb530fb..b7eb6e1f684a0 100644 --- a/apps/meteor/app/livechat/server/api/v1/message.ts +++ b/apps/meteor/app/livechat/server/api/v1/message.ts @@ -251,7 +251,7 @@ API.v1.addRoute( async post() { const visitorToken = this.bodyParams.visitor.token; - let visitor = await LivechatVisitors.getVisitorByToken(visitorToken, {}); + const visitor = await LivechatVisitors.getVisitorByToken(visitorToken, {}); let rid: string; if (visitor) { const extraQuery = await callbacks.run('livechat.applyRoomRestrictions', {}); @@ -267,8 +267,10 @@ API.v1.addRoute( const guest: typeof this.bodyParams.visitor & { connectionData?: unknown } = this.bodyParams.visitor; guest.connectionData = normalizeHttpHeaderData(this.request.headers); - const visitorId = await LivechatTyped.registerGuest(guest); - visitor = await LivechatVisitors.findOneEnabledById(visitorId); + const visitor = await LivechatTyped.registerGuest(guest); + if (!visitor) { + throw new Error('error-livechat-visitor-registration'); + } } const guest = visitor; diff --git a/apps/meteor/app/livechat/server/api/v1/room.ts b/apps/meteor/app/livechat/server/api/v1/room.ts index d2a76e53926f7..b0f45a63ff87d 100644 --- a/apps/meteor/app/livechat/server/api/v1/room.ts +++ b/apps/meteor/app/livechat/server/api/v1/room.ts @@ -1,8 +1,7 @@ import { Omnichannel } from '@rocket.chat/core-services'; -import type { ILivechatAgent, IOmnichannelRoom, IUser, SelectedAgent, TransferByData } from '@rocket.chat/core-typings'; +import type { ILivechatAgent, IUser, SelectedAgent, TransferByData } from '@rocket.chat/core-typings'; import { isOmnichannelRoom, OmnichannelSourceType } from '@rocket.chat/core-typings'; import { LivechatVisitors, Users, LivechatRooms, Messages } from '@rocket.chat/models'; -import { Random } from '@rocket.chat/random'; import { isLiveChatRoomForwardProps, isPOSTLivechatRoomCloseParams, @@ -27,7 +26,7 @@ import { settings as rcSettings } from '../../../../settings/server'; import { normalizeTransferredByData } from '../../lib/Helper'; import type { CloseRoomParams } from '../../lib/LivechatTyped'; import { Livechat as LivechatTyped } from '../../lib/LivechatTyped'; -import { findGuest, findRoom, getRoom, settings, findAgent, onCheckRoomParams } from '../lib/livechat'; +import { findGuest, findRoom, settings, findAgent, onCheckRoomParams } from '../lib/livechat'; import { findVisitorInfo } from '../lib/visitors'; const isAgentWithInfo = (agentObj: ILivechatAgent | { hiddenInfo: boolean }): agentObj is ILivechatAgent => !('hiddenInfo' in agentObj); @@ -43,16 +42,15 @@ API.v1.addRoute('livechat/room', { check(this.queryParams, extraCheckParams as any); - const { token, rid: roomId, agentId, ...extraParams } = this.queryParams; + const { token, rid, agentId, ...extraParams } = this.queryParams; const guest = token && (await findGuest(token)); if (!guest) { throw new Error('invalid-token'); } - let room: IOmnichannelRoom | null; - if (!roomId) { - room = await LivechatRooms.findOneOpenByVisitorToken(token, {}); + if (!rid) { + const room = await LivechatRooms.findOneOpenByVisitorToken(token, {}); if (room) { return API.v1.success({ room, newRoom: false }); } @@ -68,18 +66,21 @@ API.v1.addRoute('livechat/room', { } } - const rid = Random.id(); const roomInfo = { source: { type: isWidget(this.request.headers) ? OmnichannelSourceType.WIDGET : OmnichannelSourceType.API, }, }; - const newRoom = await getRoom({ guest, rid, agent, roomInfo, extraParams }); - return API.v1.success(newRoom); + const newRoom = await LivechatTyped.createRoom({ visitor: guest, roomInfo, agent, extraData: extraParams }); + + return API.v1.success({ + room: newRoom, + newRoom: true, + }); } - const froom = await LivechatRooms.findOneOpenByRoomIdAndVisitorToken(roomId, token, {}); + const froom = await LivechatRooms.findOneOpenByRoomIdAndVisitorToken(rid, token, {}); if (!froom) { throw new Error('invalid-room'); } @@ -292,8 +293,7 @@ API.v1.addRoute( throw new Error('error-invalid-visitor'); } - const transferedBy = this.user satisfies TransferByData; - transferData.transferredBy = normalizeTransferredByData(transferedBy, room); + transferData.transferredBy = normalizeTransferredByData(this.user, room); if (transferData.userId) { const userToTransfer = await Users.findOneById(transferData.userId); if (userToTransfer) { diff --git a/apps/meteor/app/livechat/server/api/v1/visitor.ts b/apps/meteor/app/livechat/server/api/v1/visitor.ts index 9c19f5bbdec82..a5b3f2de35b10 100644 --- a/apps/meteor/app/livechat/server/api/v1/visitor.ts +++ b/apps/meteor/app/livechat/server/api/v1/visitor.ts @@ -1,4 +1,4 @@ -import type { ILivechatCustomField, ILivechatVisitor, IRoom } from '@rocket.chat/core-typings'; +import type { ILivechatCustomField, IRoom } from '@rocket.chat/core-typings'; import { LivechatVisitors as VisitorsRaw, LivechatCustomField, LivechatRooms } from '@rocket.chat/models'; import { Match, check } from 'meteor/check'; import { Meteor } from 'meteor/meteor'; @@ -47,27 +47,29 @@ API.v1.addRoute('livechat/visitor', { connectionData: normalizeHttpHeaderData(this.request.headers), }; - const visitorId = await LivechatTyped.registerGuest(guest); - - let visitor: ILivechatVisitor | null = await VisitorsRaw.findOneEnabledById(visitorId, {}); - if (visitor) { - const extraQuery = await callbacks.run('livechat.applyRoomRestrictions', {}); - // If it's updating an existing visitor, it must also update the roomInfo - const rooms = await LivechatRooms.findOpenByVisitorToken(visitor?.token, {}, extraQuery).toArray(); - await Promise.all( - rooms.map( - (room: IRoom) => - visitor && - LivechatTyped.saveRoomInfo(room, { - _id: visitor._id, - name: visitor.name, - phone: visitor.phone?.[0]?.phoneNumber, - livechatData: visitor.livechatData as { [k: string]: string }, - }), - ), - ); + const visitor = await LivechatTyped.registerGuest(guest); + if (!visitor) { + throw new Meteor.Error('error-livechat-visitor-registration', 'Error registering visitor', { + method: 'livechat/visitor', + }); } + const extraQuery = await callbacks.run('livechat.applyRoomRestrictions', {}); + // If it's updating an existing visitor, it must also update the roomInfo + const rooms = await LivechatRooms.findOpenByVisitorToken(visitor?.token, {}, extraQuery).toArray(); + await Promise.all( + rooms.map( + (room: IRoom) => + visitor && + LivechatTyped.saveRoomInfo(room, { + _id: visitor._id, + name: visitor.name, + phone: visitor.phone?.[0]?.phoneNumber, + livechatData: visitor.livechatData as { [k: string]: string }, + }), + ), + ); + if (customFields && Array.isArray(customFields) && customFields.length > 0) { const keys = customFields.map((field) => field.key); const errors: string[] = []; @@ -96,7 +98,7 @@ API.v1.addRoute('livechat/visitor', { if (processedKeys.length !== keys.length) { LivechatTyped.logger.warn({ msg: 'Some custom fields were not processed', - visitorId, + visitorId: visitor._id, missingKeys: keys.filter((key) => !processedKeys.includes(key)), }); } @@ -104,13 +106,13 @@ API.v1.addRoute('livechat/visitor', { if (errors.length > 0) { LivechatTyped.logger.error({ msg: 'Error updating custom fields', - visitorId, + visitorId: visitor._id, errors, }); throw new Error('error-updating-custom-fields'); } - visitor = await VisitorsRaw.findOneEnabledById(visitorId, {}); + return API.v1.success({ visitor: await VisitorsRaw.findOneEnabledById(visitor._id) }); } if (!visitor) { diff --git a/apps/meteor/app/livechat/server/hooks/sendToCRM.ts b/apps/meteor/app/livechat/server/hooks/sendToCRM.ts index 5c3a2c0b54ab2..24e1d685a0e6b 100644 --- a/apps/meteor/app/livechat/server/hooks/sendToCRM.ts +++ b/apps/meteor/app/livechat/server/hooks/sendToCRM.ts @@ -180,18 +180,11 @@ callbacks.add( callbacks.add( 'livechat.afterTakeInquiry', - async (inquiry) => { + async ({ inquiry, room }) => { if (!settings.get('Livechat_webhook_on_chat_taken')) { return inquiry; } - const { rid } = inquiry; - const room = await LivechatRooms.findOneById(rid); - - if (!room) { - return inquiry; - } - return sendToCRM('LivechatSessionTaken', room); }, callbacks.priority.MEDIUM, diff --git a/apps/meteor/app/livechat/server/lib/Helper.ts b/apps/meteor/app/livechat/server/lib/Helper.ts index dacd99be00f92..c0e85a8c7c2b2 100644 --- a/apps/meteor/app/livechat/server/lib/Helper.ts +++ b/apps/meteor/app/livechat/server/lib/Helper.ts @@ -4,7 +4,6 @@ import { api, Message, Omnichannel } from '@rocket.chat/core-services'; import type { ILivechatVisitor, IOmnichannelRoom, - IMessage, SelectedAgent, ISubscription, ILivechatInquiryRecord, @@ -30,6 +29,7 @@ import { } from '@rocket.chat/models'; import { Match, check } from 'meteor/check'; import { Meteor } from 'meteor/meteor'; +import { ObjectId } from 'mongodb'; import { callbacks } from '../../../../lib/callbacks'; import { validateEmail as validatorFunc } from '../../../../lib/emailValidator'; @@ -57,12 +57,18 @@ export const allowAgentSkipQueue = (agent: SelectedAgent) => { return hasRoleAsync(agent.agentId, 'bot'); }; -export const createLivechatRoom = async ( +export const createLivechatRoom = async < + E extends Record & { + sla?: string; + customFields?: Record; + source?: OmnichannelSourceType; + }, +>( rid: string, name: string, guest: ILivechatVisitor, roomInfo: Partial = {}, - extraData = {}, + extraData?: E, ) => { check(rid, String); check(name, String); @@ -86,47 +92,61 @@ export const createLivechatRoom = async ( visitor: { _id, username, departmentId, status, activity }, }); - const room: InsertionModel = Object.assign( - { - _id: rid, - msgs: 0, - usersCount: 1, - lm: newRoomAt, - fname: name, - t: 'l' as const, - ts: newRoomAt, - departmentId, - v: { - _id, - username, - token, - status, - ...(activity?.length && { activity }), - }, - cl: false, - open: true, - waitingResponse: true, - // this should be overriden by extraRoomInfo when provided - // in case it's not provided, we'll use this "default" type - source: { - type: OmnichannelSourceType.OTHER, - alias: 'unknown', - }, - queuedAt: newRoomAt, + // TODO: Solve `u` missing issue + const room: InsertionModel = { + _id: rid, + msgs: 0, + usersCount: 1, + lm: newRoomAt, + fname: name, + t: 'l' as const, + ts: newRoomAt, + departmentId, + v: { + _id, + username, + token, + status, + ...(activity?.length && { activity }), + }, + cl: false, + open: true, + waitingResponse: true, + // this should be overridden by extraRoomInfo when provided + // in case it's not provided, we'll use this "default" type + source: { + type: OmnichannelSourceType.OTHER, + alias: 'unknown', + }, + queuedAt: newRoomAt, + livechatData: undefined, + priorityWeight: LivechatPriorityWeight.NOT_SPECIFIED, + estimatedWaitingTimeQueue: DEFAULT_SLA_CONFIG.ESTIMATED_WAITING_TIME_QUEUE, + ...extraRoomInfo, + } as InsertionModel; - priorityWeight: LivechatPriorityWeight.NOT_SPECIFIED, - estimatedWaitingTimeQueue: DEFAULT_SLA_CONFIG.ESTIMATED_WAITING_TIME_QUEUE, + const result = await Rooms.findOneAndUpdate( + room, + { + $set: {}, + }, + { + upsert: true, + returnDocument: 'after', }, - extraRoomInfo, ); - const roomId = (await Rooms.insertOne(room)).insertedId; + if (!result.value) { + throw new Error('Room not created'); + } await callbacks.run('livechat.newRoom', room); - await sendMessage(guest, { t: 'livechat-started', msg: '', groupable: false }, room); + // TODO: replace with `Message.saveSystemMessage` + + await sendMessage(guest, { t: 'livechat-started', msg: '', groupable: false, token: guest.token }, room); - return roomId; + return result.value as IOmnichannelRoom; }; export const createLivechatInquiry = async ({ @@ -140,7 +160,7 @@ export const createLivechatInquiry = async ({ rid: string; name?: string; guest?: Pick; - message?: Pick; + message?: string; initialStatus?: LivechatInquiryStatus; extraData?: Pick; }) => { @@ -156,17 +176,11 @@ export const createLivechatInquiry = async ({ activity: Match.Maybe([String]), }), ); - check( - message, - Match.ObjectIncluding({ - msg: String, - }), - ); const extraInquiryInfo = await callbacks.run('livechat.beforeInquiry', extraData); const { _id, username, token, department, status = UserStatus.ONLINE, activity } = guest; - const { msg } = message; + const ts = new Date(); logger.debug({ @@ -174,31 +188,44 @@ export const createLivechatInquiry = async ({ visitor: { _id, username, department, status, activity }, }); - const inquiry: InsertionModel = { - rid, - name, - ts, - department, - message: msg, - status: initialStatus || LivechatInquiryStatus.READY, - v: { - _id, - username, - token, - status, - ...(activity?.length && { activity }), - }, - t: 'l', - priorityWeight: LivechatPriorityWeight.NOT_SPECIFIED, - estimatedWaitingTimeQueue: DEFAULT_SLA_CONFIG.ESTIMATED_WAITING_TIME_QUEUE, - - ...extraInquiryInfo, - }; + const result = await LivechatInquiry.findOneAndUpdate( + { + rid, + name, + ts, + department, + message: message ?? '', + status: initialStatus || LivechatInquiryStatus.READY, + v: { + _id, + username, + token, + status, + ...(activity?.length && { activity }), + }, + t: 'l', + priorityWeight: LivechatPriorityWeight.NOT_SPECIFIED, + estimatedWaitingTimeQueue: DEFAULT_SLA_CONFIG.ESTIMATED_WAITING_TIME_QUEUE, - const result = (await LivechatInquiry.insertOne(inquiry)).insertedId; + ...extraInquiryInfo, + }, + { + $set: { + _id: new ObjectId().toHexString(), + }, + }, + { + upsert: true, + returnDocument: 'after', + }, + ); logger.debug(`Inquiry ${result} created for visitor ${_id}`); - return result; + if (!result.value) { + throw new Error('Inquiry not created'); + } + + return result.value as ILivechatInquiryRecord; }; export const createLivechatSubscription = async ( @@ -337,6 +364,10 @@ export const dispatchAgentDelegated = async (rid: string, agentId?: string) => { }); }; +/** + * @deprecated + */ + export const dispatchInquiryQueued = async (inquiry: ILivechatInquiryRecord, agent?: SelectedAgent | null) => { if (!inquiry?._id) { return; @@ -355,10 +386,12 @@ export const dispatchInquiryQueued = async (inquiry: ILivechatInquiryRecord, age return; } - if (!agent || !(await allowAgentSkipQueue(agent))) { - await saveQueueInquiry(inquiry); + if (agent && (await allowAgentSkipQueue(agent))) { + return; } + await saveQueueInquiry(inquiry); + // Alert only the online agents of the queued request const onlineAgents = await LivechatTyped.getOnlineAgents(department, agent); if (!onlineAgents) { @@ -439,9 +472,14 @@ export const forwardRoomToAgent = async (room: IOmnichannelRoom, transferData: T // There are some Enterprise features that may interrupt the forwarding process // Due to that we need to check whether the agent has been changed or not logger.debug(`Forwarding inquiry ${inquiry._id} to agent ${agent.agentId}`); - const roomTaken = await RoutingManager.takeInquiry(inquiry, agent, { - ...(clientAction && { clientAction }), - }); + const roomTaken = await RoutingManager.takeInquiry( + inquiry, + agent, + { + ...(clientAction && { clientAction }), + }, + room, + ); if (!roomTaken) { logger.debug(`Cannot forward inquiry ${inquiry._id}`); return false; @@ -566,10 +604,15 @@ export const forwardRoomToDepartment = async (room: IOmnichannelRoom, guest: ILi // Fake the department to forward the inquiry - Case the forward process does not success // the inquiry will stay in the same original department inquiry.department = departmentId; - const roomTaken = await RoutingManager.delegateInquiry(inquiry, agent, { - forwardingToDepartment: { oldDepartmentId }, - ...(clientAction && { clientAction }), - }); + const roomTaken = await RoutingManager.delegateInquiry( + inquiry, + agent, + { + forwardingToDepartment: { oldDepartmentId }, + ...(clientAction && { clientAction }), + }, + room, + ); if (!roomTaken) { logger.debug(`Cannot forward room ${room._id}. Unable to delegate inquiry`); return false; @@ -605,6 +648,7 @@ export const forwardRoomToDepartment = async (room: IOmnichannelRoom, guest: ILi '', { _id, username }, { + ...(transferData.transferredBy.userType === 'visitor' && { token: room.v.token }), transferData: { ...transferData, prevDepartment: transferData.originalDepartmentName, @@ -640,31 +684,26 @@ export const forwardRoomToDepartment = async (room: IOmnichannelRoom, guest: ILi return true; }; -export const normalizeTransferredByData = (transferredBy: TransferByData, room: IOmnichannelRoom) => { +type MakePropertyOptional = Omit & { [P in K]?: T[P] }; + +export const normalizeTransferredByData = ( + transferredBy: MakePropertyOptional, + room: IOmnichannelRoom, +): TransferByData => { if (!transferredBy || !room) { throw new Error('You must provide "transferredBy" and "room" params to "getTransferredByData"'); } const { servedBy: { _id: agentId } = {} } = room; const { _id, username, name, userType: transferType } = transferredBy; - const type = transferType || (_id === agentId ? 'agent' : 'user'); + const userType = transferType || (_id === agentId ? 'agent' : 'user'); return { _id, username, ...(name && { name }), - type, + userType, }; }; -export const checkServiceStatus = async ({ guest, agent }: { guest: Pick; agent?: SelectedAgent }) => { - if (!agent) { - return LivechatTyped.online(guest.department); - } - - const { agentId } = agent; - const users = await Users.countOnlineAgents(agentId); - return users > 0; -}; - const parseFromIntOrStr = (value: string | number) => { if (typeof value === 'number') { return value; diff --git a/apps/meteor/app/livechat/server/lib/LivechatTyped.ts b/apps/meteor/app/livechat/server/lib/LivechatTyped.ts index bf5014b984f18..ccca7a8eb68e6 100644 --- a/apps/meteor/app/livechat/server/lib/LivechatTyped.ts +++ b/apps/meteor/app/livechat/server/lib/LivechatTyped.ts @@ -9,7 +9,6 @@ import type { IUser, MessageTypesValues, ILivechatVisitor, - IOmnichannelSystemMessage, SelectedAgent, ILivechatAgent, IMessage, @@ -21,6 +20,7 @@ import type { IOmnichannelAgent, ILivechatDepartmentAgents, LivechatDepartmentDTO, + OmnichannelSourceType, } from '@rocket.chat/core-typings'; import { ILivechatAgentStatus, UserStatus, isOmnichannelRoom } from '@rocket.chat/core-typings'; import { Logger, type MainLogger } from '@rocket.chat/logger'; @@ -37,12 +37,10 @@ import { Rooms, LivechatCustomField, } from '@rocket.chat/models'; -import { Random } from '@rocket.chat/random'; import { serverFetch as fetch } from '@rocket.chat/server-fetch'; import { Match, check } from 'meteor/check'; import { Meteor } from 'meteor/meteor'; -import moment from 'moment-timezone'; -import type { Filter, FindCursor, UpdateFilter } from 'mongodb'; +import type { Filter, FindCursor } from 'mongodb'; import UAParser from 'ua-parser-js'; import { callbacks } from '../../../../lib/callbacks'; @@ -68,43 +66,22 @@ import { import * as Mailer from '../../../mailer/server/api'; import { metrics } from '../../../metrics/server'; import { settings } from '../../../settings/server'; -import { getTimezone } from '../../../utils/server/lib/getTimezone'; import { businessHourManager } from '../business-hour'; import { parseAgentCustomFields, updateDepartmentAgents, validateEmail, normalizeTransferredByData } from './Helper'; import { QueueManager } from './QueueManager'; import { RoutingManager } from './RoutingManager'; import { isDepartmentCreationAvailable } from './isDepartmentCreationAvailable'; - -type GenericCloseRoomParams = { - room: IOmnichannelRoom; - comment?: string; - options?: { - clientAction?: boolean; - tags?: string[]; - emailTranscript?: - | { - sendToVisitor: false; - } - | { - sendToVisitor: true; - requestData: NonNullable; - }; - pdfTranscript?: { - requestedBy: string; - }; - }; +import type { CloseRoomParams, CloseRoomParamsByUser, CloseRoomParamsByVisitor } from './localTypes'; +import { parseTranscriptRequest } from './parseTranscriptRequest'; +import { sendTranscript as sendTranscriptFunc } from './sendTranscript'; + +type RegisterGuestType = Partial> & { + id?: string; + connectionData?: any; + email?: string; + phone?: { number: string }; }; -export type CloseRoomParamsByUser = { - user: IUser | null; -} & GenericCloseRoomParams; - -export type CloseRoomParamsByVisitor = { - visitor: ILivechatVisitor; -} & GenericCloseRoomParams; - -export type CloseRoomParams = CloseRoomParamsByUser | CloseRoomParamsByVisitor; - type OfflineMessageData = { message: string; name: string; @@ -235,7 +212,7 @@ class LivechatClass { return; } - return Users.findByIds(agentIds); + return Users.findByIds([...new Set(agentIds)]); } return Users.findOnlineAgents(); } @@ -319,12 +296,8 @@ class LivechatClass { this.logger.debug(`DB updated for room ${room._id}`); - const message = { - t: 'livechat-close', - msg: comment, - groupable: false, - transcriptRequested: !!transcriptRequest, - }; + const transcriptRequested = + !!transcriptRequest || (!settings.get('Livechat_enable_transcript') && settings.get('Livechat_transcript_send_always')); // Retrieve the closed room const newRoom = await LivechatRooms.findOneById(rid); @@ -334,9 +307,21 @@ class LivechatClass { } this.logger.debug(`Sending closing message to room ${room._id}`); - await sendMessage(chatCloser, message, newRoom); + await sendMessage( + chatCloser, + { + t: 'livechat-close', + msg: comment, + groupable: false, + transcriptRequested, + ...(isRoomClosedByVisitorParams(params) && { token: chatCloser.token }), + }, + newRoom, + ); - await Message.saveSystemMessage('command', rid, 'promptTranscript', closeData.closedBy); + 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}`); @@ -348,15 +333,18 @@ class LivechatClass { 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, + options: opts, }); } else { callbacks.runAsync('livechat.closeRoom', { room: newRoom, - options, + options: opts, }); } @@ -383,7 +371,66 @@ class LivechatClass { } } - async getRoom( + async createRoom({ + visitor, + message, + rid, + roomInfo, + agent, + extraData, + }: { + visitor: ILivechatVisitor; + message?: string; + rid?: string; + roomInfo: { + source?: IOmnichannelRoom['source']; + [key: string]: unknown; + }; + agent?: SelectedAgent; + extraData?: Record; + }) { + if (!this.enabled()) { + throw new Meteor.Error('error-omnichannel-is-disabled'); + } + + const defaultAgent = await callbacks.run('livechat.checkDefaultAgentOnNewRoom', agent, visitor); + // if no department selected verify if there is at least one active and pick the first + if (!defaultAgent && !visitor.department) { + const department = await this.getRequiredDepartment(); + Livechat.logger.debug(`No department or default agent selected for ${visitor._id}`); + + if (department) { + Livechat.logger.debug(`Assigning ${visitor._id} to department ${department._id}`); + visitor.department = department._id; + } + } + + // delegate room creation to QueueManager + Livechat.logger.debug(`Calling QueueManager to request a room for visitor ${visitor._id}`); + + const room = await QueueManager.requestRoom({ + guest: visitor, + message, + rid, + roomInfo, + agent: defaultAgent, + extraData, + }); + + Livechat.logger.debug(`Room obtained for visitor ${visitor._id} -> ${room._id}`); + + await Messages.setRoomIdByToken(visitor.token, room._id); + + return room; + } + + async getRoom< + E extends Record & { + sla?: string; + customFields?: Record; + source?: OmnichannelSourceType; + }, + >( guest: ILivechatVisitor, message: Pick, roomInfo: { @@ -391,69 +438,31 @@ class LivechatClass { [key: string]: unknown; }, agent?: SelectedAgent, - extraData?: Record, + extraData?: E, ) { if (!this.enabled()) { throw new Meteor.Error('error-omnichannel-is-disabled'); } Livechat.logger.debug(`Attempting to find or create a room for visitor ${guest._id}`); - let room = await LivechatRooms.findOneById(message.rid); - let newRoom = false; + const room = await LivechatRooms.findOneById(message.rid); if (room && !room.open) { Livechat.logger.debug(`Last room for visitor ${guest._id} closed. Creating new one`); - message.rid = Random.id(); - room = null; - } - - if ( - guest.department && - !(await LivechatDepartment.findOneById>(guest.department, { projection: { _id: 1 } })) - ) { - await LivechatVisitors.removeDepartmentById(guest._id); - const tmpGuest = await LivechatVisitors.findOneEnabledById(guest._id); - if (tmpGuest) { - guest = tmpGuest; - } } - if (room == null) { - const defaultAgent = await callbacks.run('livechat.checkDefaultAgentOnNewRoom', agent, guest); - // if no department selected verify if there is at least one active and pick the first - if (!defaultAgent && !guest.department) { - const department = await this.getRequiredDepartment(); - Livechat.logger.debug(`No department or default agent selected for ${guest._id}`); - - if (department) { - Livechat.logger.debug(`Assigning ${guest._id} to department ${department._id}`); - guest.department = department._id; - } - } - - // delegate room creation to QueueManager - Livechat.logger.debug(`Calling QueueManager to request a room for visitor ${guest._id}`); - room = await QueueManager.requestRoom({ - guest, - message, - roomInfo, - agent: defaultAgent, - extraData, - }); - newRoom = true; - - Livechat.logger.debug(`Room obtained for visitor ${guest._id} -> ${room._id}`); + if (!room?.open) { + return { + room: await this.createRoom({ visitor: guest, message: message.msg, roomInfo, agent, extraData }), + newRoom: true, + }; } - if (!room || room.v.token !== guest.token) { + if (room.v.token !== guest.token) { Livechat.logger.debug(`Visitor ${guest._id} trying to access another visitor's room`); throw new Meteor.Error('cannot-access-room'); } - if (newRoom) { - await Messages.setRoomIdByToken(guest.token, room._id); - } - - return { room, newRoom }; + return { room, newRoom: false }; } async checkOnlineAgents(department?: string, agent?: { agentId: string }, skipFallbackCheck = false): Promise { @@ -534,230 +543,93 @@ class LivechatClass { } } - async sendTranscript({ - token, - rid, - email, - subject, - user, - }: { - token: string; - rid: string; - email: string; - subject?: string; - user?: Pick | null; - }): Promise { - check(rid, String); - check(email, String); - this.logger.debug(`Sending conversation transcript of room ${rid} to user with token ${token}`); - - const room = await LivechatRooms.findOneById(rid); - - const visitor = await LivechatVisitors.getVisitorByToken(token, { - projection: { _id: 1, token: 1, language: 1, username: 1, name: 1 }, - }); - - if (!visitor) { - throw new Error('error-invalid-token'); - } - - // @ts-expect-error - Visitor typings should include language? - const userLanguage = visitor?.language || settings.get('Language') || 'en'; - const timezone = getTimezone(user); - this.logger.debug(`Transcript will be sent using ${timezone} as timezone`); - - if (!room) { - throw new Error('error-invalid-room'); - } - - // allow to only user to send transcripts from their own chats - if (room.t !== 'l' || !room.v || room.v.token !== token) { - throw new Error('error-invalid-room'); - } - - const showAgentInfo = settings.get('Livechat_show_agent_info'); - const closingMessage = await Messages.findLivechatClosingMessage(rid, { projection: { ts: 1 } }); - const ignoredMessageTypes: MessageTypesValues[] = [ - 'livechat_navigation_history', - 'livechat_transcript_history', - 'command', - 'livechat-close', - 'livechat-started', - 'livechat_video_call', - ]; - const messages = await Messages.findVisibleByRoomIdNotContainingTypesBeforeTs( - rid, - ignoredMessageTypes, - closingMessage?.ts ? new Date(closingMessage.ts) : new Date(), - { - sort: { ts: 1 }, - }, - ); - - let html = '

'; - await messages.forEach((message) => { - let author; - if (message.u._id === visitor._id) { - author = i18n.t('You', { lng: userLanguage }); - } else { - author = showAgentInfo ? message.u.name || message.u.username : i18n.t('Agent', { lng: userLanguage }); - } - - const datetime = moment.tz(message.ts, timezone).locale(userLanguage).format('LLL'); - const singleMessage = ` -

${author} ${datetime}

-

${message.msg}

- `; - html += singleMessage; - }); - - html = `${html}
`; - - const fromEmail = settings.get('From_Email').match(/\b[A-Z0-9._%+-]+@(?:[A-Z0-9-]+\.)+[A-Z]{2,4}\b/i); - let emailFromRegexp = ''; - if (fromEmail) { - emailFromRegexp = fromEmail[0]; - } else { - emailFromRegexp = settings.get('From_Email'); - } - - const mailSubject = subject || i18n.t('Transcript_of_your_livechat_conversation', { lng: userLanguage }); - - await this.sendEmail(emailFromRegexp, email, emailFromRegexp, mailSubject, html); - - setImmediate(() => { - void callbacks.run('livechat.sendTranscript', messages, email); - }); - - const requestData: IOmnichannelSystemMessage['requestData'] = { - type: 'user', - visitor, - user, - }; - - if (!user?.username) { - const cat = await Users.findOneById('rocket.cat', { projection: { _id: 1, username: 1, name: 1 } }); - if (cat) { - requestData.user = cat; - requestData.type = 'visitor'; - } - } - - if (!requestData.user) { - this.logger.error('rocket.cat user not found'); - throw new Error('No user provided and rocket.cat not found'); - } - - await Message.saveSystemMessage('livechat_transcript_history', room._id, '', requestData.user, { - requestData, - }); - - return true; - } - async registerGuest({ id, token, name, + phone, email, department, - phone, username, connectionData, status = UserStatus.ONLINE, - }: { - id?: string; - token: string; - name?: string; - email?: string; - department?: string; - phone?: { number: string }; - username?: string; - connectionData?: any; - status?: ILivechatVisitor['status']; - }) { + }: RegisterGuestType): Promise { check(token, String); check(id, Match.Maybe(String)); Livechat.logger.debug(`New incoming conversation: id: ${id} | token: ${token}`); - let userId; - type Mutable = { - -readonly [Key in keyof Type]: Type[Key]; - }; - - type UpdateUserType = Required, '$set'>>; - const updateUser: Required, '$set'>> = { - $set: { - token, - status, - ...(phone?.number ? { phone: [{ phoneNumber: phone.number }] } : {}), - ...(name ? { name } : {}), - }, + const visitorDataToUpdate: Partial & { userAgent?: string; ip?: string; host?: string } = { + token, + status, + ...(phone?.number ? { phone: [{ phoneNumber: phone.number }] } : {}), + ...(name ? { name } : {}), }; if (email) { - email = email.trim().toLowerCase(); - validateEmail(email); - (updateUser.$set as Mutable).visitorEmails = [{ address: email }]; + const visitorEmail = email.trim().toLowerCase(); + validateEmail(visitorEmail); + visitorDataToUpdate.visitorEmails = [{ address: visitorEmail }]; } - if (department) { + const livechatVisitor = await LivechatVisitors.getVisitorByToken(token, { projection: { _id: 1 } }); + + if (livechatVisitor?.department !== department && department) { Livechat.logger.debug(`Attempt to find a department with id/name ${department}`); const dep = await LivechatDepartment.findOneByIdOrName(department, { projection: { _id: 1 } }); if (!dep) { - Livechat.logger.debug('Invalid department provided'); + Livechat.logger.debug(`Invalid department provided: ${department}`); throw new Meteor.Error('error-invalid-department', 'The provided department is invalid'); } Livechat.logger.debug(`Assigning visitor ${token} to department ${dep._id}`); - (updateUser.$set as Mutable).department = dep._id; + visitorDataToUpdate.department = dep._id; } - const user = await LivechatVisitors.getVisitorByToken(token, { projection: { _id: 1 } }); + visitorDataToUpdate.token = livechatVisitor?.token || token; + let existingUser = null; - if (user) { + if (livechatVisitor) { Livechat.logger.debug('Found matching user by token'); - userId = user._id; + visitorDataToUpdate._id = livechatVisitor._id; } else if (phone?.number && (existingUser = await LivechatVisitors.findOneVisitorByPhone(phone.number))) { Livechat.logger.debug('Found matching user by phone number'); - userId = existingUser._id; + visitorDataToUpdate._id = existingUser._id; // Don't change token when matching by phone number, use current visitor token - (updateUser.$set as Mutable).token = existingUser.token; + visitorDataToUpdate.token = existingUser.token; } else if (email && (existingUser = await LivechatVisitors.findOneGuestByEmailAddress(email))) { Livechat.logger.debug('Found matching user by email'); - userId = existingUser._id; - } else { + visitorDataToUpdate._id = existingUser._id; + } else if (!livechatVisitor) { Livechat.logger.debug(`No matches found. Attempting to create new user with token ${token}`); - if (!username) { - username = await LivechatVisitors.getNextVisitorUsername(); - } - const userData = { - username, - status, - ts: new Date(), - token, - ...(id && { _id: id }), - }; + visitorDataToUpdate._id = id || undefined; + visitorDataToUpdate.username = username || (await LivechatVisitors.getNextVisitorUsername()); + visitorDataToUpdate.status = status; + visitorDataToUpdate.ts = new Date(); if (settings.get('Livechat_Allow_collect_and_store_HTTP_header_informations')) { Livechat.logger.debug(`Saving connection data for visitor ${token}`); - const connection = connectionData; - if (connection?.httpHeaders) { - (updateUser.$set as Mutable).userAgent = connection.httpHeaders['user-agent']; - (updateUser.$set as Mutable).ip = - connection.httpHeaders['x-real-ip'] || connection.httpHeaders['x-forwarded-for'] || connection.clientAddress; - (updateUser.$set as Mutable).host = connection.httpHeaders.host; + const { httpHeaders, clientAddress } = connectionData; + if (httpHeaders) { + visitorDataToUpdate.userAgent = httpHeaders['user-agent']; + visitorDataToUpdate.ip = httpHeaders['x-real-ip'] || httpHeaders['x-forwarded-for'] || clientAddress; + visitorDataToUpdate.host = httpHeaders?.host; } } - - userId = (await LivechatVisitors.insertOne(userData)).insertedId; } - await LivechatVisitors.updateById(userId, updateUser); + const upsertedLivechatVisitor = await LivechatVisitors.updateOneByIdOrToken(visitorDataToUpdate, { + upsert: true, + returnDocument: 'after', + }); + + if (!upsertedLivechatVisitor.value) { + Livechat.logger.debug(`No visitor found after upsert`); + return null; + } - return userId; + return upsertedLivechatVisitor.value; } private async getBotAgents(department?: string) { @@ -1255,7 +1127,7 @@ class LivechatClass { if (guest.name) { message.alias = guest.name; } - return Object.assign(await sendMessage(guest, message, room), { + return Object.assign(await sendMessage(guest, { ...message, token: guest.token }, room), { newRoom, showConnecting: this.showConnecting(), }); @@ -1374,7 +1246,7 @@ class LivechatClass { _id: String, username: String, name: Match.Maybe(String), - type: String, + userType: String, }), ); @@ -1382,34 +1254,31 @@ class LivechatClass { const scopeData = scope || (nextDepartment ? 'department' : 'agent'); this.logger.info(`Storing new chat transfer of ${room._id} [Transfered by: ${_id} to ${scopeData}]`); - const transfer = { - transferData: { - transferredBy, + await sendMessage( + transferredBy, + { + t: 'livechat_transfer_history', + rid: room._id, ts: new Date(), - scope: scopeData, - comment, - ...(previousDepartment && { previousDepartment }), - ...(nextDepartment && { nextDepartment }), - ...(transferredTo && { transferredTo }), - }, - }; - - const type = 'livechat_transfer_history'; - const transferMessage = { - t: type, - rid: room._id, - ts: new Date(), - msg: '', - u: { - _id, - username, + 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 }), + }, }, - groupable: false, - }; - - Object.assign(transferMessage, transfer); - - await sendMessage(transferredBy, transferMessage, room); + room, + ); } async saveGuest(guestData: Pick & { email?: string; phone?: string }, userId: string) { @@ -1972,6 +1841,23 @@ 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(); +export * from './localTypes'; diff --git a/apps/meteor/app/livechat/server/lib/QueueManager.ts b/apps/meteor/app/livechat/server/lib/QueueManager.ts index 576b29990b33a..e1ea79d841633 100644 --- a/apps/meteor/app/livechat/server/lib/QueueManager.ts +++ b/apps/meteor/app/livechat/server/lib/QueueManager.ts @@ -1,26 +1,34 @@ import { Apps, AppEvents } from '@rocket.chat/apps'; import { Omnichannel } from '@rocket.chat/core-services'; +import type { ILivechatDepartment } from '@rocket.chat/core-typings'; import { LivechatInquiryStatus, type ILivechatInquiryRecord, type ILivechatVisitor, - type IMessage, type IOmnichannelRoom, type SelectedAgent, + type OmnichannelSourceType, } from '@rocket.chat/core-typings'; import { Logger } from '@rocket.chat/logger'; -import { LivechatInquiry, LivechatRooms, Users } from '@rocket.chat/models'; +import { LivechatDepartment, LivechatDepartmentAgents, LivechatInquiry, LivechatRooms, Users } from '@rocket.chat/models'; +import { Random } from '@rocket.chat/random'; import { Match, check } from 'meteor/check'; import { Meteor } from 'meteor/meteor'; +import { dispatchInquiryPosition } from '../../../../ee/app/livechat-enterprise/server/lib/Helper'; import { callbacks } from '../../../../lib/callbacks'; +import { sendNotification } from '../../../lib/server'; import { notifyOnLivechatInquiryChangedById, notifyOnLivechatInquiryChanged, notifyOnSettingChanged, } from '../../../lib/server/lib/notifyListener'; -import { checkServiceStatus, createLivechatRoom, createLivechatInquiry } from './Helper'; +import { settings } from '../../../settings/server'; +import { i18n } from '../../../utils/lib/i18n'; +import { createLivechatRoom, createLivechatInquiry, allowAgentSkipQueue } from './Helper'; +import { Livechat } from './LivechatTyped'; import { RoutingManager } from './RoutingManager'; +import { getInquirySortMechanismSetting } from './settings'; const logger = new Logger('QueueManager'); @@ -39,54 +47,129 @@ export const saveQueueInquiry = async (inquiry: ILivechatInquiryRecord) => { }); }; +/** + * @deprecated + */ export const queueInquiry = async (inquiry: ILivechatInquiryRecord, defaultAgent?: SelectedAgent) => { - const inquiryAgent = await RoutingManager.delegateAgent(defaultAgent, inquiry); - logger.debug(`Delegating inquiry with id ${inquiry._id} to agent ${defaultAgent?.username}`); - - await callbacks.run('livechat.beforeRouteChat', inquiry, inquiryAgent); const room = await LivechatRooms.findOneById(inquiry.rid, { projection: { v: 1 } }); - if (!room || !(await Omnichannel.isWithinMACLimit(room))) { - logger.error({ msg: 'MAC limit reached, not routing inquiry', inquiry }); - // We'll queue these inquiries so when new license is applied, they just start rolling again - // Minimizing disruption + + if (!room) { await saveQueueInquiry(inquiry); return; } - const dbInquiry = await LivechatInquiry.findOneById(inquiry._id); - if (!dbInquiry) { - throw new Error('inquiry-not-found'); + return QueueManager.requeueInquiry(inquiry, room, defaultAgent); +}; + +const getDepartment = async (department: string): Promise => { + if (!department) { + return; } - if (dbInquiry.status === 'ready') { - logger.debug(`Inquiry with id ${inquiry._id} is ready. Delegating to agent ${inquiryAgent?.username}`); - return RoutingManager.delegateInquiry(dbInquiry, inquiryAgent); + if (await LivechatDepartmentAgents.checkOnlineForDepartment(department)) { + return department; + } + + const departmentDocument = await LivechatDepartment.findOneById>( + department, + { + projection: { fallbackForwardDepartment: 1 }, + }, + ); + + if (departmentDocument?.fallbackForwardDepartment) { + return getDepartment(departmentDocument.fallbackForwardDepartment); } }; -type queueManager = { - requestRoom: (params: { +export class QueueManager { + static async requeueInquiry(inquiry: ILivechatInquiryRecord, room: IOmnichannelRoom, defaultAgent?: SelectedAgent) { + if (!(await Omnichannel.isWithinMACLimit(room))) { + logger.error({ msg: 'MAC limit reached, not routing inquiry', inquiry }); + // We'll queue these inquiries so when new license is applied, they just start rolling again + // Minimizing disruption + await saveQueueInquiry(inquiry); + return; + } + + const inquiryAgent = await RoutingManager.delegateAgent(defaultAgent, inquiry); + logger.debug(`Delegating inquiry with id ${inquiry._id} to agent ${defaultAgent?.username}`); + await callbacks.run('livechat.beforeRouteChat', inquiry, inquiryAgent); + const dbInquiry = await LivechatInquiry.findOneById(inquiry._id); + + if (!dbInquiry) { + throw new Error('inquiry-not-found'); + } + + if (dbInquiry.status === 'ready') { + logger.debug(`Inquiry with id ${inquiry._id} is ready. Delegating to agent ${inquiryAgent?.username}`); + return RoutingManager.delegateInquiry(dbInquiry, inquiryAgent, undefined, room); + } + } + + private static fnQueueInquiryStatus: (typeof QueueManager)['getInquiryStatus'] | undefined; + + public static patchInquiryStatus(fn: (typeof QueueManager)['getInquiryStatus']) { + this.fnQueueInquiryStatus = fn; + } + + static async getInquiryStatus({ room, agent }: { room: IOmnichannelRoom; agent?: SelectedAgent }): Promise { + if (this.fnQueueInquiryStatus) { + return this.fnQueueInquiryStatus({ room, agent }); + } + + if (!(await Omnichannel.isWithinMACLimit(room))) { + return LivechatInquiryStatus.QUEUED; + } + + if (RoutingManager.getConfig()?.autoAssignAgent) { + return LivechatInquiryStatus.READY; + } + + if (!agent || !(await allowAgentSkipQueue(agent))) { + return LivechatInquiryStatus.QUEUED; + } + + return LivechatInquiryStatus.READY; + } + + static async queueInquiry(inquiry: ILivechatInquiryRecord, room: IOmnichannelRoom, defaultAgent?: SelectedAgent | null) { + if (inquiry.status === 'ready') { + return RoutingManager.delegateInquiry(inquiry, defaultAgent, undefined, room); + } + + await callbacks.run('livechat.afterInquiryQueued', inquiry); + + void callbacks.run('livechat.chatQueued', room); + + await this.dispatchInquiryQueued(inquiry, room, defaultAgent); + } + + static async requestRoom< + E extends Record & { + sla?: string; + customFields?: Record; + source?: OmnichannelSourceType; + }, + >({ + guest, + rid = Random.id(), + message, + roomInfo, + agent, + extraData: { customFields, ...extraData } = {} as E, + }: { guest: ILivechatVisitor; - message: Pick; + rid?: string; + message?: string; roomInfo: { source?: IOmnichannelRoom['source']; [key: string]: unknown; }; agent?: SelectedAgent; - extraData?: Record; - }) => Promise; - unarchiveRoom: (archivedRoom?: IOmnichannelRoom) => Promise; -}; - -export const QueueManager: queueManager = { - async requestRoom({ guest, message, roomInfo, agent, extraData }) { + extraData?: E; + }) { logger.debug(`Requesting a room for guest ${guest._id}`); - check( - message, - Match.ObjectIncluding({ - rid: String, - }), - ); check( guest, Match.ObjectIncluding({ @@ -99,29 +182,67 @@ export const QueueManager: queueManager = { }), ); - if (!(await checkServiceStatus({ guest, agent }))) { - throw new Meteor.Error('no-agent-online', 'Sorry, no online agents'); + const defaultAgent = + (await callbacks.run('livechat.beforeDelegateAgent', agent, { + department: guest.department, + })) || undefined; + + const department = guest.department && (await getDepartment(guest.department)); + + /** + * we have 4 cases here + * 1. agent and no department + * 2. no agent and no department + * 3. no agent and department + * 4. agent and department informed + * + * in case 1, we check if the agent is online + * in case 2, we check if there is at least one online agent in the whole service + * in case 3, we check if there is at least one online agent in the department + * + * the case 4 is weird, but we are not throwing an error, just because the application works in some mysterious way + * we don't have explicitly defined what to do in this case so we just kept the old behavior + * it seems that agent has priority over department + * but some cases department is handled before agent + * + */ + + if (!settings.get('Livechat_accept_chats_with_no_agents')) { + if (agent && !defaultAgent) { + throw new Meteor.Error('no-agent-online', 'Sorry, no online agents'); + } + + if (!defaultAgent && guest.department && !department) { + throw new Meteor.Error('no-agent-online', 'Sorry, no online agents'); + } + + if (!agent && !guest.department && !(await Livechat.checkOnlineAgents())) { + throw new Meteor.Error('no-agent-online', 'Sorry, no online agents'); + } } - const { rid } = message; const name = (roomInfo?.fname as string) || guest.name || guest.username; - const room = await LivechatRooms.findOneById(await createLivechatRoom(rid, name, guest, roomInfo, extraData)); + const room = await createLivechatRoom(rid, name, { ...guest, ...(department && { department }) }, roomInfo, { + ...extraData, + ...(Boolean(customFields) && { customFields }), + }); + if (!room) { logger.error(`Room for visitor ${guest._id} not found`); throw new Error('room-not-found'); } logger.debug(`Room for visitor ${guest._id} created with id ${room._id}`); - const inquiry = await LivechatInquiry.findOneById( - await createLivechatInquiry({ - rid, - name, - guest, - message, - extraData: { ...extraData, source: roomInfo.source }, - }), - ); + const inquiry = await createLivechatInquiry({ + rid, + name, + initialStatus: await this.getInquiryStatus({ room, agent: defaultAgent }), + guest, + message, + extraData: { ...extraData, source: roomInfo.source }, + }); + if (!inquiry) { logger.error(`Inquiry for visitor ${guest._id} not found`); throw new Error('inquiry-not-found'); @@ -134,19 +255,28 @@ export const QueueManager: queueManager = { void notifyOnSettingChanged(livechatSetting); } - await queueInquiry(inquiry, agent); - logger.debug(`Inquiry ${inquiry._id} queued`); - - const newRoom = await LivechatRooms.findOneById(rid); + const newRoom = (await this.queueInquiry(inquiry, room, defaultAgent)) ?? (await LivechatRooms.findOneById(rid)); if (!newRoom) { logger.error(`Room with id ${rid} not found`); throw new Error('room-not-found'); } + if (!newRoom.servedBy && settings.get('Omnichannel_calculate_dispatch_service_queue_statistics')) { + const [inq] = await LivechatInquiry.getCurrentSortedQueueAsync({ + inquiryId: inquiry._id, + department, + queueSortBy: getInquirySortMechanismSetting(), + }); + + if (inq) { + void dispatchInquiryPosition(inq); + } + } + return newRoom; - }, + } - async unarchiveRoom(archivedRoom) { + static async unarchiveRoom(archivedRoom: IOmnichannelRoom) { if (!archivedRoom) { throw new Error('no-room-to-unarchive'); } @@ -181,14 +311,70 @@ export const QueueManager: queueManager = { if (!room) { throw new Error('room-not-found'); } - const inquiry = await LivechatInquiry.findOneById(await createLivechatInquiry({ rid, name, guest, message, extraData: { source } })); + const inquiry = await createLivechatInquiry({ + rid, + name, + guest, + message: message?.msg, + extraData: { source }, + }); if (!inquiry) { throw new Error('inquiry-not-found'); } - await queueInquiry(inquiry, defaultAgent); + await this.requeueInquiry(inquiry, room, defaultAgent); logger.debug(`Inquiry ${inquiry._id} queued`); return room; - }, -}; + } + + private static dispatchInquiryQueued = async (inquiry: ILivechatInquiryRecord, room: IOmnichannelRoom, agent?: SelectedAgent | null) => { + logger.debug(`Notifying agents of new inquiry ${inquiry._id} queued`); + + const { department, rid, v } = inquiry; + // Alert only the online agents of the queued request + const onlineAgents = await Livechat.getOnlineAgents(department, agent); + + if (!onlineAgents) { + logger.debug('Cannot notify agents of queued inquiry. No online agents found'); + return; + } + + logger.debug(`Notifying ${await onlineAgents.count()} agents of new inquiry`); + const notificationUserName = v && (v.name || v.username); + + for await (const agent of onlineAgents) { + const { _id, active, emails, language, status, statusConnection, username } = agent; + await sendNotification({ + // fake a subscription in order to make use of the function defined above + subscription: { + rid, + u: { + _id, + }, + receiver: [ + { + active, + emails, + language, + status, + statusConnection, + username, + }, + ], + name: '', + }, + sender: v, + hasMentionToAll: true, // consider all agents to be in the room + hasReplyToThread: false, + disableAllMessageNotifications: false, + hasMentionToHere: false, + message: { _id: '', u: v, msg: '' }, + // we should use server's language for this type of messages instead of user's + notificationMessage: i18n.t('User_started_a_new_conversation', { username: notificationUserName }, language), + room: { ...room, name: i18n.t('New_chat_in_queue', {}, language) }, + mentionIds: [], + }); + } + }; +} diff --git a/apps/meteor/app/livechat/server/lib/RoutingManager.ts b/apps/meteor/app/livechat/server/lib/RoutingManager.ts index 5782d01e318fb..f4a2288305e54 100644 --- a/apps/meteor/app/livechat/server/lib/RoutingManager.ts +++ b/apps/meteor/app/livechat/server/lib/RoutingManager.ts @@ -46,8 +46,8 @@ type Routing = { inquiry: InquiryWithAgentInfo, agent?: SelectedAgent | null, options?: { clientAction?: boolean; forwardingToDepartment?: { oldDepartmentId?: string; transferData?: any } }, + room?: IOmnichannelRoom, ): Promise<(IOmnichannelRoom & { chatQueued?: boolean }) | null | void>; - assignAgent(inquiry: InquiryWithAgentInfo, agent: SelectedAgent): Promise; unassignAgent(inquiry: ILivechatInquiryRecord, departmentId?: string, shouldQueue?: boolean): Promise; takeInquiry( inquiry: Omit< @@ -55,11 +55,14 @@ type Routing = { 'estimatedInactivityCloseTimeAt' | 'message' | 't' | 'source' | 'estimatedWaitingTimeQueue' | 'priorityWeight' | '_updatedAt' >, agent: SelectedAgent | null, - options?: { clientAction?: boolean; forwardingToDepartment?: { oldDepartmentId?: string; transferData?: any } }, + options: { clientAction?: boolean; forwardingToDepartment?: { oldDepartmentId?: string; transferData?: any } }, + room: IOmnichannelRoom, ): Promise; transferRoom(room: IOmnichannelRoom, guest: ILivechatVisitor, transferData: TransferData): Promise; delegateAgent(agent: SelectedAgent | undefined, inquiry: ILivechatInquiryRecord): Promise; removeAllRoomSubscriptions(room: Pick, ignoreUser?: { _id: string }): Promise; + + assignAgent(inquiry: InquiryWithAgentInfo, room: IOmnichannelRoom, agent: SelectedAgent): Promise; }; export const RoutingManager: Routing = { @@ -101,7 +104,7 @@ export const RoutingManager: Routing = { return this.getMethod().getNextAgent(department, ignoreAgentId); }, - async delegateInquiry(inquiry, agent, options = {}) { + async delegateInquiry(inquiry, agent, options = {}, room) { const { department, rid } = inquiry; logger.debug(`Attempting to delegate inquiry ${inquiry._id}`); if (!agent || (agent.username && !(await Users.findOneOnlineAgentByUserList(agent.username)) && !(await allowAgentSkipQueue(agent)))) { @@ -117,11 +120,15 @@ export const RoutingManager: Routing = { return LivechatRooms.findOneById(rid); } + if (!room) { + throw new Meteor.Error('error-invalid-room'); + } + logger.debug(`Inquiry ${inquiry._id} will be taken by agent ${agent.agentId}`); - return this.takeInquiry(inquiry, agent, options); + return this.takeInquiry(inquiry, agent, options, room); }, - async assignAgent(inquiry, agent) { + async assignAgent(inquiry: InquiryWithAgentInfo, room: IOmnichannelRoom, agent: SelectedAgent): Promise { check( agent, Match.ObjectIncluding({ @@ -142,19 +149,14 @@ export const RoutingManager: Routing = { await Rooms.incUsersCountById(rid, 1); const user = await Users.findOneById(agent.agentId); - const room = await LivechatRooms.findOneById(rid); if (user) { await Promise.all([Message.saveSystemMessage('command', rid, 'connected', user), Message.saveSystemMessage('uj', rid, '', user)]); } - if (!room) { - logger.debug(`Cannot assign agent to inquiry ${inquiry._id}: Room not found`); - throw new Meteor.Error('error-room-not-found', 'Room not found'); - } - await dispatchAgentDelegated(rid, agent.agentId); - logger.debug(`Agent ${agent.agentId} assigned to inquriy ${inquiry._id}. Instances notified`); + + logger.debug(`Agent ${agent.agentId} assigned to inquiry ${inquiry._id}. Instances notified`); void Apps.self?.getBridges()?.getListenerBridge().livechatEvent(AppEvents.IPostLivechatAgentAssigned, { room, user }); return inquiry; @@ -206,7 +208,7 @@ export const RoutingManager: Routing = { return true; }, - async takeInquiry(inquiry, agent, options = { clientAction: false }) { + async takeInquiry(inquiry, agent, options = { clientAction: false }, room) { check( agent, Match.ObjectIncluding({ @@ -227,7 +229,6 @@ export const RoutingManager: Routing = { logger.debug(`Attempting to take Inquiry ${inquiry._id} [Agent ${agent.agentId}] `); const { _id, rid } = inquiry; - const room = await LivechatRooms.findOneById(rid); if (!room?.open) { logger.debug(`Cannot take Inquiry ${inquiry._id}: Room is closed`); return room; @@ -262,10 +263,16 @@ export const RoutingManager: Routing = { await LivechatInquiry.takeInquiry(_id); - const inq = await this.assignAgent(inquiry as InquiryWithAgentInfo, agent); logger.info(`Inquiry ${inquiry._id} taken by agent ${agent.agentId}`); - callbacks.runAsync('livechat.afterTakeInquiry', inq, agent); + callbacks.runAsync( + 'livechat.afterTakeInquiry', + { + inquiry: await this.assignAgent(inquiry as InquiryWithAgentInfo, room, agent), + room, + }, + agent, + ); void notifyOnLivechatInquiryChangedById(inquiry._id, 'updated', { status: LivechatInquiryStatus.TAKEN, diff --git a/apps/meteor/app/livechat/server/lib/localTypes.ts b/apps/meteor/app/livechat/server/lib/localTypes.ts new file mode 100644 index 0000000000000..c6acbbc5bcbd6 --- /dev/null +++ b/apps/meteor/app/livechat/server/lib/localTypes.ts @@ -0,0 +1,31 @@ +import type { IOmnichannelRoom, IUser, ILivechatVisitor } from '@rocket.chat/core-typings'; + +export type GenericCloseRoomParams = { + room: IOmnichannelRoom; + comment?: string; + options?: { + clientAction?: boolean; + tags?: string[]; + emailTranscript?: + | { + sendToVisitor: false; + } + | { + sendToVisitor: true; + requestData: NonNullable; + }; + pdfTranscript?: { + requestedBy: string; + }; + }; +}; + +export type CloseRoomParamsByUser = { + user: IUser | null; +} & GenericCloseRoomParams; + +export type CloseRoomParamsByVisitor = { + visitor: ILivechatVisitor; +} & GenericCloseRoomParams; + +export type CloseRoomParams = CloseRoomParamsByUser | CloseRoomParamsByVisitor; diff --git a/apps/meteor/app/livechat/server/lib/parseTranscriptRequest.ts b/apps/meteor/app/livechat/server/lib/parseTranscriptRequest.ts new file mode 100644 index 0000000000000..76595a7ff6402 --- /dev/null +++ b/apps/meteor/app/livechat/server/lib/parseTranscriptRequest.ts @@ -0,0 +1,61 @@ +import type { ILivechatVisitor, IOmnichannelRoom, IUser } from '@rocket.chat/core-typings'; +import { LivechatVisitors, Users } from '@rocket.chat/models'; + +import { settings } from '../../../settings/server'; +import type { CloseRoomParams } from './localTypes'; + +export const parseTranscriptRequest = async ( + room: IOmnichannelRoom, + options: CloseRoomParams['options'], + visitor?: ILivechatVisitor, + user?: IUser, +): Promise => { + const visitorDecideTranscript = settings.get('Livechat_enable_transcript'); + // visitor decides, no changes + if (visitorDecideTranscript) { + return options; + } + + // send always is disabled, no changes + const sendAlways = settings.get('Livechat_transcript_send_always'); + if (!sendAlways) { + return options; + } + + const visitorData = + visitor || + (await LivechatVisitors.findOneById>(room.v._id, { projection: { visitorEmails: 1 } })); + // no visitor, no changes + if (!visitorData) { + return options; + } + const visitorEmail = visitorData?.visitorEmails?.[0]?.address; + // visitor doesnt have email, no changes + if (!visitorEmail) { + return options; + } + + const defOptions = { projection: { _id: 1, username: 1, name: 1 } }; + const requestedBy = + user || + (room.servedBy && (await Users.findOneById(room.servedBy._id, defOptions))) || + (await Users.findOneById('rocket.cat', defOptions)); + + // no user available for backing request, no changes + if (!requestedBy) { + return options; + } + + return { + ...options, + emailTranscript: { + sendToVisitor: true, + requestData: { + email: visitorEmail, + requestedAt: new Date(), + subject: '', + requestedBy, + }, + }, + }; +}; diff --git a/apps/meteor/app/livechat/server/lib/sendTranscript.ts b/apps/meteor/app/livechat/server/lib/sendTranscript.ts new file mode 100644 index 0000000000000..74032121ee509 --- /dev/null +++ b/apps/meteor/app/livechat/server/lib/sendTranscript.ts @@ -0,0 +1,227 @@ +import { Message } from '@rocket.chat/core-services'; +import { + type IUser, + type MessageTypesValues, + type IOmnichannelSystemMessage, + isFileAttachment, + isFileImageAttachment, +} from '@rocket.chat/core-typings'; +import colors from '@rocket.chat/fuselage-tokens/colors'; +import { Logger } from '@rocket.chat/logger'; +import { LivechatRooms, LivechatVisitors, Messages, Uploads, Users } from '@rocket.chat/models'; +import { check } from 'meteor/check'; +import moment from 'moment-timezone'; + +import { callbacks } from '../../../../lib/callbacks'; +import { i18n } from '../../../../server/lib/i18n'; +import { FileUpload } from '../../../file-upload/server'; +import * as Mailer from '../../../mailer/server/api'; +import { settings } from '../../../settings/server'; +import { MessageTypes } from '../../../ui-utils/lib/MessageTypes'; +import { getTimezone } from '../../../utils/server/lib/getTimezone'; + +const logger = new Logger('Livechat-SendTranscript'); + +export async function sendTranscript({ + token, + rid, + email, + subject, + user, +}: { + token: string; + rid: string; + email: string; + subject?: string; + user?: Pick | null; +}): Promise { + check(rid, String); + check(email, String); + logger.debug(`Sending conversation transcript of room ${rid} to user with token ${token}`); + + const room = await LivechatRooms.findOneById(rid); + + const visitor = await LivechatVisitors.getVisitorByToken(token, { + projection: { _id: 1, token: 1, language: 1, username: 1, name: 1 }, + }); + + if (!visitor) { + throw new Error('error-invalid-token'); + } + + // @ts-expect-error - Visitor typings should include language? + const userLanguage = visitor?.language || settings.get('Language') || 'en'; + const timezone = getTimezone(user); + logger.debug(`Transcript will be sent using ${timezone} as timezone`); + + if (!room) { + throw new Error('error-invalid-room'); + } + + // allow to only user to send transcripts from their own chats + if (room.t !== 'l' || !room.v || room.v.token !== token) { + throw new Error('error-invalid-room'); + } + + const showAgentInfo = settings.get('Livechat_show_agent_info'); + const showSystemMessages = settings.get('Livechat_transcript_show_system_messages'); + const closingMessage = await Messages.findLivechatClosingMessage(rid, { projection: { ts: 1 } }); + const ignoredMessageTypes: MessageTypesValues[] = [ + 'livechat_navigation_history', + 'livechat_transcript_history', + 'command', + 'livechat-close', + 'livechat-started', + 'livechat_video_call', + 'omnichannel_priority_change_history', + ]; + const acceptableImageMimeTypes = ['image/jpeg', 'image/png', 'image/jpg']; + const messages = await Messages.findVisibleByRoomIdNotContainingTypesBeforeTs( + rid, + ignoredMessageTypes, + closingMessage?.ts ? new Date(closingMessage.ts) : new Date(), + showSystemMessages, + { + sort: { ts: 1 }, + }, + ); + + let html = '

'; + const InvalidFileMessage = `
${i18n.t( + 'This_attachment_is_not_supported', + { lng: userLanguage }, + )}
`; + + for await (const message of messages) { + let author; + if (message.u._id === visitor._id) { + author = i18n.t('You', { lng: userLanguage }); + } else { + author = showAgentInfo ? message.u.name || message.u.username : i18n.t('Agent', { lng: userLanguage }); + } + + const isSystemMessage = MessageTypes.isSystemMessage(message); + const messageType = isSystemMessage && MessageTypes.getType(message); + + let messageContent = messageType + ? `${i18n.t( + messageType.message, + messageType.data + ? { ...messageType.data(message), interpolation: { escapeValue: false } } + : { interpolation: { escapeValue: false } }, + )}` + : message.msg; + + let filesHTML = ''; + + if (message.attachments && message.attachments?.length > 0) { + messageContent = message.attachments[0].description || ''; + + for await (const attachment of message.attachments) { + if (!isFileAttachment(attachment)) { + // ignore other types of attachments + continue; + } + + if (!isFileImageAttachment(attachment)) { + filesHTML += `
${attachment.title || ''}${InvalidFileMessage}
`; + continue; + } + + if (!attachment.image_type || !acceptableImageMimeTypes.includes(attachment.image_type)) { + filesHTML += `
${attachment.title || ''}${InvalidFileMessage}
`; + continue; + } + + // Image attachment can be rendered in email body + const file = message.files?.find((file) => file.name === attachment.title); + + if (!file) { + filesHTML += `
${attachment.title || ''}${InvalidFileMessage}
`; + continue; + } + + const uploadedFile = await Uploads.findOneById(file._id); + + if (!uploadedFile) { + filesHTML += `
${file.name}${InvalidFileMessage}
`; + continue; + } + + const uploadedFileBuffer = await FileUpload.getBuffer(uploadedFile); + filesHTML += `

${file.name}

`; + } + } + + const datetime = moment.tz(message.ts, timezone).locale(userLanguage).format('LLL'); + const singleMessage = ` +

${author} ${datetime}

+

${messageContent}

+

${filesHTML}

+ `; + html += singleMessage; + } + + html = `${html}
`; + + const fromEmail = settings.get('From_Email').match(/\b[A-Z0-9._%+-]+@(?:[A-Z0-9-]+\.)+[A-Z]{2,4}\b/i); + let emailFromRegexp = ''; + if (fromEmail) { + emailFromRegexp = fromEmail[0]; + } else { + emailFromRegexp = settings.get('From_Email'); + } + + // Some endpoints allow the caller to pass a different `subject` via parameter. + // IF subject is passed, we'll use that one and treat it as an override + // IF no subject is passed, we fallback to the setting `Livechat_transcript_email_subject` + // IF that is not configured, we fallback to 'Transcript of your livechat conversation', which is the default value + // As subject and setting value are user input, we don't translate them + const mailSubject = + subject || + settings.get('Livechat_transcript_email_subject') || + i18n.t('Transcript_of_your_livechat_conversation', { lng: userLanguage }); + + await Mailer.send({ + to: email, + from: emailFromRegexp, + replyTo: emailFromRegexp, + subject: mailSubject, + html, + }); + + setImmediate(() => { + void callbacks.run('livechat.sendTranscript', messages, email); + }); + + const requestData: IOmnichannelSystemMessage['requestData'] = { + type: 'user', + visitor, + user, + }; + + if (!user?.username) { + const cat = await Users.findOneById('rocket.cat', { projection: { _id: 1, username: 1, name: 1 } }); + if (cat) { + requestData.user = cat; + requestData.type = 'visitor'; + } + } + + if (!requestData.user) { + logger.error('rocket.cat user not found'); + throw new Error('No user provided and rocket.cat not found'); + } + + await Message.saveSystemMessage('livechat_transcript_history', room._id, '', requestData.user, { + requestData, + }); + + return true; +} diff --git a/apps/meteor/app/livechat/server/methods/registerGuest.ts b/apps/meteor/app/livechat/server/methods/registerGuest.ts index 01f720b85a4d2..4a531d0c89e52 100644 --- a/apps/meteor/app/livechat/server/methods/registerGuest.ts +++ b/apps/meteor/app/livechat/server/methods/registerGuest.ts @@ -23,21 +23,24 @@ declare module '@rocket.chat/ui-contexts' { department?: string; customFields?: Array<{ key: string; value: string; overwrite: boolean; scope?: unknown }>; }): { - userId: string; - visitor: ILivechatVisitor | null; + userId: ILivechatVisitor['_id']; + visitor: Pick; }; } } Meteor.methods({ - async 'livechat:registerGuest'({ token, name, email, department, customFields } = {}) { + async 'livechat:registerGuest'({ token, name, email, department, customFields } = {}): Promise<{ + userId: ILivechatVisitor['_id']; + visitor: Pick; + }> { methodDeprecationLogger.method('livechat:registerGuest', '7.0.0'); if (!token) { throw new Meteor.Error('error-invalid-token', 'Invalid token', { method: 'livechat:registerGuest' }); } - const userId = await LivechatTyped.registerGuest.call(this, { + const visitor = await LivechatTyped.registerGuest.call(this, { token, name, email, @@ -47,16 +50,6 @@ Meteor.methods({ // update visited page history to not expire await Messages.keepHistoryForToken(token); - const visitor = await LivechatVisitors.getVisitorByToken(token, { - projection: { - token: 1, - name: 1, - username: 1, - visitorEmails: 1, - department: 1, - }, - }); - if (!visitor) { throw new Meteor.Error('error-invalid-visitor', 'Invalid visitor', { method: 'livechat:registerGuest' }); } @@ -89,8 +82,15 @@ Meteor.methods({ } return { - userId, - visitor, + userId: visitor._id, + visitor: { + _id: visitor._id, + token: visitor.token, + name: visitor.name, + username: visitor.username, + visitorEmails: visitor.visitorEmails, + department: visitor.department, + }, }; }, }); diff --git a/apps/meteor/app/livechat/server/methods/takeInquiry.ts b/apps/meteor/app/livechat/server/methods/takeInquiry.ts index 3433b4a33ae86..30a5dabb57176 100644 --- a/apps/meteor/app/livechat/server/methods/takeInquiry.ts +++ b/apps/meteor/app/livechat/server/methods/takeInquiry.ts @@ -60,7 +60,7 @@ export const takeInquiry = async ( }; try { - await RoutingManager.takeInquiry(inquiry, agent, options); + await RoutingManager.takeInquiry(inquiry, agent, options ?? {}, room); } catch (e: any) { throw new Meteor.Error(e.message); } diff --git a/apps/meteor/app/markdown/lib/markdown.js b/apps/meteor/app/markdown/lib/markdown.js index 3c3acdb178938..c7fe452e08291 100644 --- a/apps/meteor/app/markdown/lib/markdown.js +++ b/apps/meteor/app/markdown/lib/markdown.js @@ -69,6 +69,7 @@ class MarkdownClass { return code(...args); } + /** @param {string} message */ filterMarkdownFromMessage(message) { return parsers.filtered(message); } @@ -76,6 +77,7 @@ class MarkdownClass { export const Markdown = new MarkdownClass(); +/** @param {string} message */ export const filterMarkdown = (message) => Markdown.filterMarkdownFromMessage(message); export const createMarkdownMessageRenderer = ({ ...options }) => { diff --git a/apps/meteor/app/markdown/lib/parser/filtered/filtered.js b/apps/meteor/app/markdown/lib/parser/filtered/filtered.js index ac53144d6d1b1..260fc835d8a0a 100644 --- a/apps/meteor/app/markdown/lib/parser/filtered/filtered.js +++ b/apps/meteor/app/markdown/lib/parser/filtered/filtered.js @@ -1,6 +1,7 @@ -/* +/** * Filter markdown tags in message - * Use case: notifications + * Use case: notifications + * @param {string} message */ export const filtered = ( message, diff --git a/apps/meteor/app/meteor-accounts-saml/server/lib/SAML.ts b/apps/meteor/app/meteor-accounts-saml/server/lib/SAML.ts index 76747b5991047..6e68518ef31c4 100644 --- a/apps/meteor/app/meteor-accounts-saml/server/lib/SAML.ts +++ b/apps/meteor/app/meteor-accounts-saml/server/lib/SAML.ts @@ -198,11 +198,6 @@ export class SAML { updateData.emails = emails; } - // Overwrite fullname if needed - if (nameOverwrite === true) { - updateData.name = fullName; - } - // When updating an user, we only update the roles if we received them from the mapping if (userObject.roles?.length) { updateData.roles = userObject.roles; @@ -221,8 +216,8 @@ export class SAML { }, ); - if ((username && username !== user.username) || (fullName && fullName !== user.name)) { - await saveUserIdentity({ _id: user._id, name: fullName || undefined, username }); + if ((username && username !== user.username) || (nameOverwrite && fullName && fullName !== user.name)) { + await saveUserIdentity({ _id: user._id, name: nameOverwrite ? fullName || undefined : user.name, username }); } // sending token along with the userId diff --git a/apps/meteor/app/push/server/fcm.ts b/apps/meteor/app/push/server/fcm.ts index 87ced6e130df0..6015780f118fa 100644 --- a/apps/meteor/app/push/server/fcm.ts +++ b/apps/meteor/app/push/server/fcm.ts @@ -1,6 +1,6 @@ +import { serverFetch as fetch, type ExtendedFetchOptions } from '@rocket.chat/server-fetch'; import EJSON from 'ejson'; -import fetch from 'node-fetch'; -import type { RequestInit, Response } from 'node-fetch'; +import type { Response } from 'node-fetch'; import type { PendingPushNotification } from './definition'; import { logger } from './logger'; @@ -65,7 +65,7 @@ type FCMError = { * - For 429 errors: retry after waiting for the duration set in the retry-after header. If no retry-after header is set, default to 60 seconds. * - For 500 errors: retry with exponential backoff. */ -async function fetchWithRetry(url: string, _removeToken: () => void, options: RequestInit, retries = 0): Promise { +async function fetchWithRetry(url: string, _removeToken: () => void, options: ExtendedFetchOptions, retries = 0): Promise { const MAX_RETRIES = 5; const response = await fetch(url, options); diff --git a/apps/meteor/app/settings/server/CachedSettings.ts b/apps/meteor/app/settings/server/CachedSettings.ts index 06cfad4a91a6c..9a42569b4cf64 100644 --- a/apps/meteor/app/settings/server/CachedSettings.ts +++ b/apps/meteor/app/settings/server/CachedSettings.ts @@ -333,7 +333,7 @@ export class CachedSettings } public getConfig = (config?: OverCustomSettingsConfig): SettingsConfig => ({ - debounce: 500, + debounce: process.env.TEST_MODE ? 0 : 500, ...config, }); diff --git a/apps/meteor/app/settings/server/SettingsRegistry.ts b/apps/meteor/app/settings/server/SettingsRegistry.ts index 5783e2946dc1f..d7d2fa0a79f80 100644 --- a/apps/meteor/app/settings/server/SettingsRegistry.ts +++ b/apps/meteor/app/settings/server/SettingsRegistry.ts @@ -73,7 +73,7 @@ const compareSettingsIgnoringKeys = .filter((key) => !keys.includes(key as keyof ISetting)) .every((key) => isEqual(a[key as keyof ISetting], b[key as keyof ISetting])); -const compareSettings = compareSettingsIgnoringKeys([ +export const compareSettings = compareSettingsIgnoringKeys([ 'value', 'ts', 'createdAt', @@ -139,6 +139,7 @@ export class SettingsRegistry { const settingFromCodeOverwritten = overwriteSetting(settingFromCode); const settingStored = this.store.getSetting(_id); + const settingStoredOverwritten = settingStored && overwriteSetting(settingStored); try { @@ -166,6 +167,10 @@ export class SettingsRegistry { })(); await this.saveUpdatedSetting(_id, updatedProps, removedKeys); + if ('value' in updatedProps) { + this.store.set(updatedProps as ISetting); + } + return; } @@ -175,6 +180,7 @@ export class SettingsRegistry { const removedKeys = Object.keys(settingStored).filter((key) => !['_updatedAt'].includes(key) && !overwrittenKeys.includes(key)); await this.saveUpdatedSetting(_id, settingProps, removedKeys); + this.store.set(settingFromCodeOverwritten); } return; } diff --git a/apps/meteor/app/settings/server/functions/settings.mocks.ts b/apps/meteor/app/settings/server/functions/settings.mocks.ts index 9cd409ba0b837..fb31c3021b1b3 100644 --- a/apps/meteor/app/settings/server/functions/settings.mocks.ts +++ b/apps/meteor/app/settings/server/functions/settings.mocks.ts @@ -9,6 +9,12 @@ type Dictionary = { class SettingsClass { settings: ICachedSettings; + private delay = 0; + + setDelay(delay: number): void { + this.delay = delay; + } + find(): any[] { return []; } @@ -65,22 +71,41 @@ class SettingsClass { throw new Error('Invalid upsert'); } - // console.log(query, data); - this.data.set(query._id, data); - - // Can't import before the mock command on end of this file! - // eslint-disable-next-line @typescript-eslint/no-var-requires - this.settings.set(data); + if (this.delay) { + setTimeout(() => { + // console.log(query, data); + this.data.set(query._id, data); + + // Can't import before the mock command on end of this file! + // eslint-disable-next-line @typescript-eslint/no-var-requires + this.settings.set(data); + }, this.delay); + } else { + this.data.set(query._id, data); + // Can't import before the mock command on end of this file! + // eslint-disable-next-line @typescript-eslint/no-var-requires + this.settings.set(data); + } this.upsertCalls++; } + findOneAndUpdate({ _id }: { _id: string }, value: any, options?: any) { + this.updateOne({ _id }, value, options); + return { value: this.findOne({ _id }) }; + } + updateValueById(id: string, value: any): void { this.data.set(id, { ...this.data.get(id), value }); - // Can't import before the mock command on end of this file! // eslint-disable-next-line @typescript-eslint/no-var-requires - this.settings.set(this.data.get(id) as ISetting); + if (this.delay) { + setTimeout(() => { + this.settings.set(this.data.get(id) as ISetting); + }, this.delay); + } else { + this.settings.set(this.data.get(id) as ISetting); + } } } diff --git a/apps/meteor/app/slackbridge/server/SlackAdapter.js b/apps/meteor/app/slackbridge/server/SlackAdapter.js index 78d48deb49935..0263d5369a4c5 100644 --- a/apps/meteor/app/slackbridge/server/SlackAdapter.js +++ b/apps/meteor/app/slackbridge/server/SlackAdapter.js @@ -1341,7 +1341,7 @@ export default class SlackAdapter { const user = (await this.rocket.findUser(member)) || (await this.rocket.addUser(member)); if (user) { slackLogger.debug('Adding user to room', user.username, rid); - await addUserToRoom(rid, user, null, true); + await addUserToRoom(rid, user, null, { skipSystemMessage: true }); } } } 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 cead4a2cb584e..20b023cc61aa5 100644 --- a/apps/meteor/app/theme/client/imports/general/base_old.css +++ b/apps/meteor/app/theme/client/imports/general/base_old.css @@ -777,7 +777,7 @@ } & .start { - margin-top: 12px; + margin-top: 44px; text-align: center; @@ -794,12 +794,6 @@ & .editing .body { border-radius: var(--border-radius); } - - &.has-leader { - & .wrapper { - padding-top: 57px; - } - } } .rcx-message { diff --git a/apps/meteor/app/utils/lib/mimeTypes.spec.ts b/apps/meteor/app/utils/lib/mimeTypes.spec.ts new file mode 100644 index 0000000000000..d0fbd4360e24a --- /dev/null +++ b/apps/meteor/app/utils/lib/mimeTypes.spec.ts @@ -0,0 +1,89 @@ +import { expect } from 'chai'; + +import { getExtension, getMimeType } from './mimeTypes'; + +const mimeTypeToExtension = { + 'text/plain': 'txt', + 'image/x-icon': 'ico', + 'image/vnd.microsoft.icon': 'ico', + 'image/png': 'png', + 'image/jpeg': 'jpeg', + 'image/gif': 'gif', + 'image/webp': 'webp', + 'image/svg+xml': 'svg', + 'image/bmp': 'bmp', + 'image/tiff': 'tif', + 'audio/wav': 'wav', + 'audio/wave': 'wav', + 'audio/aac': 'aac', + 'audio/x-aac': 'aac', + 'audio/mp4': 'm4a', + 'audio/mpeg': 'mpga', + 'audio/ogg': 'oga', + 'application/octet-stream': 'bin', +}; + +const extensionToMimeType = { + lst: 'text/plain', + txt: 'text/plain', + ico: 'image/x-icon', + png: 'image/png', + jpeg: 'image/jpeg', + gif: 'image/gif', + webp: 'image/webp', + svg: 'image/svg+xml', + bmp: 'image/bmp', + tiff: 'image/tiff', + tif: 'image/tiff', + wav: 'audio/wav', + aac: 'audio/aac', + mp3: 'audio/mpeg', + ogg: 'audio/ogg', + oga: 'audio/ogg', + m4a: 'audio/mp4', + mpga: 'audio/mpeg', + mp4: 'video/mp4', + bin: 'application/octet-stream', +}; + +describe('mimeTypes', () => { + describe('getExtension', () => { + for (const [mimeType, extension] of Object.entries(mimeTypeToExtension)) { + it(`should return the correct extension ${extension} for the given mimeType ${mimeType}`, async () => { + expect(getExtension(mimeType)).to.be.eql(extension); + }); + } + + it('should return an empty string if the mimeType is not found', async () => { + expect(getExtension('application/unknown')).to.be.eql(''); + }); + }); + + describe('getMimeType', () => { + for (const [extension, mimeType] of Object.entries(extensionToMimeType)) { + it(`should return the correct mimeType ${mimeType} for the given fileName file.${extension} passing the correct mimeType`, async () => { + expect(getMimeType(mimeType, `file.${extension}`)).to.be.eql(mimeType); + }); + } + + it('should return the correct mimeType for the given fileName', async () => { + for (const [extension, mimeType] of Object.entries(extensionToMimeType)) { + expect(getMimeType('application/unknown', `file.${extension}`)).to.be.eql(mimeType); + } + }); + + it('should return the correct mimeType for the given fileName when informed mimeType is application/octet-stream', async () => { + for (const [extension, mimeType] of Object.entries(extensionToMimeType)) { + expect(getMimeType('application/octet-stream', `file.${extension}`)).to.be.eql(mimeType); + } + }); + + it('should return the mimeType if it is not application/octet-stream', async () => { + expect(getMimeType('audio/wav', 'file.wav')).to.be.eql('audio/wav'); + }); + + it('should return application/octet-stream if the mimeType is not found', async () => { + expect(getMimeType('application/octet-stream', 'file.unknown')).to.be.eql('application/octet-stream'); + }); + }); +}); diff --git a/apps/meteor/app/utils/lib/mimeTypes.ts b/apps/meteor/app/utils/lib/mimeTypes.ts index 909a955d6724d..df670145b494f 100644 --- a/apps/meteor/app/utils/lib/mimeTypes.ts +++ b/apps/meteor/app/utils/lib/mimeTypes.ts @@ -3,8 +3,8 @@ import mime from 'mime-type/with-db'; mime.types.wav = 'audio/wav'; mime.types.lst = 'text/plain'; mime.define('image/vnd.microsoft.icon', { source: '', extensions: ['ico'] }, mime.dupAppend); -mime.define('image/x-icon', { source: '', extensions: ['ico'] }, mime.dupAppend); -mime.types.ico = 'image/x-icon'; +mime.define('image/x-icon', { source: '', extensions: ['ico'] }, mime.dupOverwrite); +mime.define('audio/aac', { source: '', extensions: ['aac'] }, mime.dupOverwrite); const getExtension = (param: string): string => { const extension = mime.extension(param); @@ -12,7 +12,14 @@ const getExtension = (param: string): string => { return !extension || typeof extension === 'boolean' ? '' : extension; }; -const getMimeType = (fileName: string): string => { +const getMimeType = (mimetype: string, fileName: string): string => { + // If the extension from the mimetype is different from the file extension, the file + // extension may be wrong so use the informed mimetype + const extension = mime.extension(mimetype); + if (mimetype !== 'application/octet-stream' && extension && extension !== fileName.split('.').pop()) { + return mimetype; + } + const fileMimeType = mime.lookup(fileName); return typeof fileMimeType === 'string' ? fileMimeType : 'application/octet-stream'; }; diff --git a/apps/meteor/app/utils/rocketchat.info b/apps/meteor/app/utils/rocketchat.info index 3cb06b1e99abe..7cad52f21bcf5 100644 --- a/apps/meteor/app/utils/rocketchat.info +++ b/apps/meteor/app/utils/rocketchat.info @@ -1,3 +1,3 @@ { - "version": "6.10.2" + "version": "6.11.0-rc.6" } diff --git a/apps/meteor/client/NavBarV2/NavBar.tsx b/apps/meteor/client/NavBarV2/NavBar.tsx new file mode 100644 index 0000000000000..908e729c956e0 --- /dev/null +++ b/apps/meteor/client/NavBarV2/NavBar.tsx @@ -0,0 +1,73 @@ +import { useToolbar } from '@react-aria/toolbar'; +import { NavBar as NavBarComponent, NavBarSection, NavBarGroup, NavBarDivider } from '@rocket.chat/fuselage'; +import { usePermission, useTranslation, useUser } from '@rocket.chat/ui-contexts'; +import React, { useRef } from 'react'; + +import { useIsCallEnabled, useIsCallReady } from '../contexts/CallContext'; +import { useOmnichannelEnabled } from '../hooks/omnichannel/useOmnichannelEnabled'; +import { useOmnichannelShowQueueLink } from '../hooks/omnichannel/useOmnichannelShowQueueLink'; +import { useHasLicenseModule } from '../hooks/useHasLicenseModule'; +import { + NavBarItemOmniChannelCallDialPad, + NavBarItemOmnichannelContact, + NavBarItemOmnichannelLivechatToggle, + NavBarItemOmnichannelQueue, + NavBarItemOmnichannelCallToggle, +} from './NavBarOmnichannelToolbar'; +import { NavBarItemMarketPlaceMenu, NavBarItemAuditMenu, NavBarItemDirectoryPage, NavBarItemHomePage } from './NavBarPagesToolbar'; +import { NavBarItemLoginPage, NavBarItemAdministrationMenu, UserMenu } from './NavBarSettingsToolbar'; + +const NavBar = () => { + const t = useTranslation(); + const user = useUser(); + + const hasAuditLicense = useHasLicenseModule('auditing') === true; + + const showOmnichannel = useOmnichannelEnabled(); + const hasManageAppsPermission = usePermission('manage-apps'); + const hasAccessMarketplacePermission = usePermission('access-marketplace'); + const showMarketplace = hasAccessMarketplacePermission || hasManageAppsPermission; + + const showOmnichannelQueueLink = useOmnichannelShowQueueLink(); + const isCallEnabled = useIsCallEnabled(); + const isCallReady = useIsCallReady(); + + const pagesToolbarRef = useRef(null); + const { toolbarProps: pagesToolbarProps } = useToolbar({ 'aria-label': t('Pages') }, pagesToolbarRef); + + const omnichannelToolbarRef = useRef(null); + const { toolbarProps: omnichannelToolbarProps } = useToolbar({ 'aria-label': t('Omnichannel') }, omnichannelToolbarRef); + + return ( + + + + + + {showMarketplace && } + {hasAuditLicense && } + + {showOmnichannel && ( + <> + + + {showOmnichannelQueueLink && } + {isCallReady && } + + {isCallEnabled && } + + + + )} + + + + + {user ? : } + + + + ); +}; + +export default NavBar; diff --git a/apps/meteor/client/NavBarV2/NavBarOmnichannelToolbar/NavBarItemOmniChannelCallDialPad.tsx b/apps/meteor/client/NavBarV2/NavBarOmnichannelToolbar/NavBarItemOmniChannelCallDialPad.tsx new file mode 100644 index 0000000000000..af9b907df12e6 --- /dev/null +++ b/apps/meteor/client/NavBarV2/NavBarOmnichannelToolbar/NavBarItemOmniChannelCallDialPad.tsx @@ -0,0 +1,30 @@ +import { NavBarItem } from '@rocket.chat/fuselage'; +import { useTranslation } from '@rocket.chat/ui-contexts'; +import type { ComponentPropsWithoutRef } from 'react'; +import React from 'react'; + +import { useVoipOutboundStates } from '../../contexts/CallContext'; +import { useDialModal } from '../../hooks/useDialModal'; + +type NavBarItemOmniChannelCallDialPadProps = ComponentPropsWithoutRef; + +const NavBarItemOmniChannelCallDialPad = (props: NavBarItemOmniChannelCallDialPadProps) => { + const t = useTranslation(); + + const { openDialModal } = useDialModal(); + + const { outBoundCallsAllowed, outBoundCallsEnabledForUser } = useVoipOutboundStates(); + + return ( + openDialModal()} + disabled={!outBoundCallsEnabledForUser} + aria-label={t('Open_Dialpad')} + data-tooltip={outBoundCallsAllowed ? t('New_Call') : t('New_Call_Premium_Only')} + {...props} + /> + ); +}; + +export default NavBarItemOmniChannelCallDialPad; diff --git a/apps/meteor/client/NavBarV2/NavBarOmnichannelToolbar/NavBarItemOmnichannelCallToggle.tsx b/apps/meteor/client/NavBarV2/NavBarOmnichannelToolbar/NavBarItemOmnichannelCallToggle.tsx new file mode 100644 index 0000000000000..ce62cb51864b7 --- /dev/null +++ b/apps/meteor/client/NavBarV2/NavBarOmnichannelToolbar/NavBarItemOmnichannelCallToggle.tsx @@ -0,0 +1,27 @@ +import type { ComponentPropsWithoutRef } from 'react'; +import React from 'react'; + +import { useIsCallReady, useIsCallError } from '../../contexts/CallContext'; +import NavBarItemOmnichannelCallToggleError from './NavBarItemOmnichannelCallToggleError'; +import NavBarItemOmnichannelCallToggleLoading from './NavBarItemOmnichannelCallToggleLoading'; +import NavBarItemOmnichannelCallToggleReady from './NavBarItemOmnichannelCallToggleReady'; + +type NavBarItemOmnichannelCallToggleProps = ComponentPropsWithoutRef< + typeof NavBarItemOmnichannelCallToggleError | typeof NavBarItemOmnichannelCallToggleLoading | typeof NavBarItemOmnichannelCallToggleReady +>; + +const NavBarItemOmnichannelCallToggle = (props: NavBarItemOmnichannelCallToggleProps) => { + const isCallReady = useIsCallReady(); + const isCallError = useIsCallError(); + if (isCallError) { + return ; + } + + if (!isCallReady) { + return ; + } + + return ; +}; + +export default NavBarItemOmnichannelCallToggle; diff --git a/apps/meteor/client/NavBarV2/NavBarOmnichannelToolbar/NavBarItemOmnichannelCallToggleError.tsx b/apps/meteor/client/NavBarV2/NavBarOmnichannelToolbar/NavBarItemOmnichannelCallToggleError.tsx new file mode 100644 index 0000000000000..cf4e7ec240b41 --- /dev/null +++ b/apps/meteor/client/NavBarV2/NavBarOmnichannelToolbar/NavBarItemOmnichannelCallToggleError.tsx @@ -0,0 +1,13 @@ +import { NavBarItem } from '@rocket.chat/fuselage'; +import { useTranslation } from '@rocket.chat/ui-contexts'; +import type { ComponentPropsWithoutRef } from 'react'; +import React from 'react'; + +type NavBarItemOmnichannelCallToggleErrorProps = ComponentPropsWithoutRef; + +const NavBarItemOmnichannelCallToggleError = (props: NavBarItemOmnichannelCallToggleErrorProps) => { + const t = useTranslation(); + return ; +}; + +export default NavBarItemOmnichannelCallToggleError; diff --git a/apps/meteor/client/NavBarV2/NavBarOmnichannelToolbar/NavBarItemOmnichannelCallToggleLoading.tsx b/apps/meteor/client/NavBarV2/NavBarOmnichannelToolbar/NavBarItemOmnichannelCallToggleLoading.tsx new file mode 100644 index 0000000000000..c4b53acefabbc --- /dev/null +++ b/apps/meteor/client/NavBarV2/NavBarOmnichannelToolbar/NavBarItemOmnichannelCallToggleLoading.tsx @@ -0,0 +1,13 @@ +import { NavBarItem } from '@rocket.chat/fuselage'; +import { useTranslation } from '@rocket.chat/ui-contexts'; +import type { ComponentPropsWithoutRef } from 'react'; +import React from 'react'; + +type NavBarItemOmnichannelCallToggleLoadingProps = ComponentPropsWithoutRef; + +const NavBarItemOmnichannelCallToggleLoading = (props: NavBarItemOmnichannelCallToggleLoadingProps) => { + const t = useTranslation(); + return ; +}; + +export default NavBarItemOmnichannelCallToggleLoading; diff --git a/apps/meteor/client/NavBarV2/NavBarOmnichannelToolbar/NavBarItemOmnichannelCallToggleReady.tsx b/apps/meteor/client/NavBarV2/NavBarOmnichannelToolbar/NavBarItemOmnichannelCallToggleReady.tsx new file mode 100644 index 0000000000000..8b51fc6c5b579 --- /dev/null +++ b/apps/meteor/client/NavBarV2/NavBarOmnichannelToolbar/NavBarItemOmnichannelCallToggleReady.tsx @@ -0,0 +1,67 @@ +import { NavBarItem } from '@rocket.chat/fuselage'; +import { useTranslation } from '@rocket.chat/ui-contexts'; +import type { ComponentPropsWithoutRef } from 'react'; +import React, { useCallback } from 'react'; + +import { useCallerInfo, useCallRegisterClient, useCallUnregisterClient, useVoipNetworkStatus } from '../../contexts/CallContext'; + +type NavBarItemOmnichannelCallToggleReadyProps = ComponentPropsWithoutRef; + +const NavBarItemOmnichannelCallToggleReady = (props: NavBarItemOmnichannelCallToggleReadyProps) => { + const t = useTranslation(); + + const caller = useCallerInfo(); + const unregister = useCallUnregisterClient(); + const register = useCallRegisterClient(); + + const networkStatus = useVoipNetworkStatus(); + const registered = !['ERROR', 'INITIAL', 'UNREGISTERED'].includes(caller.state); + const inCall = ['IN_CALL'].includes(caller.state); + + const onClickVoipButton = useCallback((): void => { + if (registered) { + unregister(); + return; + } + register(); + }, [registered, register, unregister]); + + const getTitle = (): string => { + if (networkStatus === 'offline') { + return t('Waiting_for_server_connection'); + } + + if (inCall) { + return t('Cannot_disable_while_on_call'); + } + + if (registered) { + return t('Turn_off_answer_calls'); + } + + return t('Turn_on_answer_calls'); + }; + + const getIcon = (): 'phone-issue' | 'phone' | 'phone-disabled' => { + if (networkStatus === 'offline') { + return 'phone-issue'; + } + return registered ? 'phone' : 'phone-disabled'; + }; + + return ( + + ); +}; + +export default NavBarItemOmnichannelCallToggleReady; diff --git a/apps/meteor/client/NavBarV2/NavBarOmnichannelToolbar/NavBarItemOmnichannelContact.tsx b/apps/meteor/client/NavBarV2/NavBarOmnichannelToolbar/NavBarItemOmnichannelContact.tsx new file mode 100644 index 0000000000000..99cdbd9b4a160 --- /dev/null +++ b/apps/meteor/client/NavBarV2/NavBarOmnichannelToolbar/NavBarItemOmnichannelContact.tsx @@ -0,0 +1,22 @@ +import { NavBarItem } from '@rocket.chat/fuselage'; +import { useRouter, useCurrentRoutePath } from '@rocket.chat/ui-contexts'; +import type { HTMLAttributes } from 'react'; +import React from 'react'; + +type NavBarItemOmnichannelContactProps = Omit, 'is'>; + +const NavBarItemOmnichannelContact = (props: NavBarItemOmnichannelContactProps) => { + const router = useRouter(); + const currentRoute = useCurrentRoutePath(); + + return ( + router.navigate('/omnichannel-directory')} + pressed={currentRoute?.includes('/omnichannel-directory')} + /> + ); +}; + +export default NavBarItemOmnichannelContact; diff --git a/apps/meteor/client/NavBarV2/NavBarOmnichannelToolbar/NavBarItemOmnichannelLivechatToggle.tsx b/apps/meteor/client/NavBarV2/NavBarOmnichannelToolbar/NavBarItemOmnichannelLivechatToggle.tsx new file mode 100644 index 0000000000000..5bf174362e194 --- /dev/null +++ b/apps/meteor/client/NavBarV2/NavBarOmnichannelToolbar/NavBarItemOmnichannelLivechatToggle.tsx @@ -0,0 +1,37 @@ +import { Sidebar } from '@rocket.chat/fuselage'; +import { useEffectEvent } from '@rocket.chat/fuselage-hooks'; +import { useEndpoint, useToastMessageDispatch, useTranslation } from '@rocket.chat/ui-contexts'; +import type { ReactElement, ComponentProps } from 'react'; +import React from 'react'; + +import { useOmnichannelAgentAvailable } from '../../hooks/omnichannel/useOmnichannelAgentAvailable'; + +type NavBarItemOmnichannelLivechatToggleProps = Omit, 'icon'>; + +const NavBarItemOmnichannelLivechatToggle = (props: NavBarItemOmnichannelLivechatToggleProps): ReactElement => { + const t = useTranslation(); + const agentAvailable = useOmnichannelAgentAvailable(); + const changeAgentStatus = useEndpoint('POST', '/v1/livechat/agent.status'); + const dispatchToastMessage = useToastMessageDispatch(); + + const handleAvailableStatusChange = useEffectEvent(async () => { + try { + await changeAgentStatus({}); + } catch (error: unknown) { + dispatchToastMessage({ type: 'error', message: error }); + } + }); + + return ( + + ); +}; + +export default NavBarItemOmnichannelLivechatToggle; diff --git a/apps/meteor/client/NavBarV2/NavBarOmnichannelToolbar/NavBarItemOmnichannelQueue.tsx b/apps/meteor/client/NavBarV2/NavBarOmnichannelToolbar/NavBarItemOmnichannelQueue.tsx new file mode 100644 index 0000000000000..8b1c00a2a17ce --- /dev/null +++ b/apps/meteor/client/NavBarV2/NavBarOmnichannelToolbar/NavBarItemOmnichannelQueue.tsx @@ -0,0 +1,22 @@ +import { NavBarItem } from '@rocket.chat/fuselage'; +import { useRouter, useCurrentRoutePath } from '@rocket.chat/ui-contexts'; +import type { HTMLAttributes } from 'react'; +import React from 'react'; + +type NavBarItemOmnichannelQueueProps = Omit, 'is'>; + +const NavBarItemOmnichannelQueue = (props: NavBarItemOmnichannelQueueProps) => { + const router = useRouter(); + const currentRoute = useCurrentRoutePath(); + + return ( + router.navigate('/livechat-queue')} + pressed={currentRoute?.includes('/livechat-queue')} + /> + ); +}; + +export default NavBarItemOmnichannelQueue; diff --git a/apps/meteor/client/NavBarV2/NavBarOmnichannelToolbar/index.ts b/apps/meteor/client/NavBarV2/NavBarOmnichannelToolbar/index.ts new file mode 100644 index 0000000000000..8dacb885deb3b --- /dev/null +++ b/apps/meteor/client/NavBarV2/NavBarOmnichannelToolbar/index.ts @@ -0,0 +1,5 @@ +export { default as NavBarItemOmniChannelCallDialPad } from './NavBarItemOmniChannelCallDialPad'; +export { default as NavBarItemOmnichannelCallToggle } from './NavBarItemOmnichannelCallToggle'; +export { default as NavBarItemOmnichannelContact } from './NavBarItemOmnichannelContact'; +export { default as NavBarItemOmnichannelLivechatToggle } from './NavBarItemOmnichannelLivechatToggle'; +export { default as NavBarItemOmnichannelQueue } from './NavBarItemOmnichannelQueue'; diff --git a/apps/meteor/client/NavBarV2/NavBarPagesToolbar/NavBarItemAuditMenu.tsx b/apps/meteor/client/NavBarV2/NavBarPagesToolbar/NavBarItemAuditMenu.tsx new file mode 100644 index 0000000000000..07936f6f42764 --- /dev/null +++ b/apps/meteor/client/NavBarV2/NavBarPagesToolbar/NavBarItemAuditMenu.tsx @@ -0,0 +1,29 @@ +import { NavBarItem } from '@rocket.chat/fuselage'; +import { useCurrentRoutePath, useTranslation } from '@rocket.chat/ui-contexts'; +import type { HTMLAttributes } from 'react'; +import React from 'react'; + +import GenericMenu from '../../components/GenericMenu/GenericMenu'; +import { useAuditMenu } from './hooks/useAuditMenu'; + +type NavBarItemAuditMenuProps = Omit, 'is'>; + +const NavBarItemAuditMenu = (props: NavBarItemAuditMenuProps) => { + const t = useTranslation(); + const sections = useAuditMenu(); + const currentRoute = useCurrentRoutePath(); + + return ( + + ); +}; + +export default NavBarItemAuditMenu; diff --git a/apps/meteor/client/NavBarV2/NavBarPagesToolbar/NavBarItemDirectoryPage.tsx b/apps/meteor/client/NavBarV2/NavBarPagesToolbar/NavBarItemDirectoryPage.tsx new file mode 100644 index 0000000000000..0cc26c6c1356d --- /dev/null +++ b/apps/meteor/client/NavBarV2/NavBarPagesToolbar/NavBarItemDirectoryPage.tsx @@ -0,0 +1,19 @@ +import { NavBarItem } from '@rocket.chat/fuselage'; +import { useEffectEvent } from '@rocket.chat/fuselage-hooks'; +import { useRouter, useCurrentRoutePath } from '@rocket.chat/ui-contexts'; +import type { HTMLAttributes } from 'react'; +import React from 'react'; + +type NavBarItemDirectoryPageProps = Omit, 'is'>; + +const NavBarItemDirectoryPage = (props: NavBarItemDirectoryPageProps) => { + const router = useRouter(); + const handleDirectory = useEffectEvent(() => { + router.navigate('/directory'); + }); + const currentRoute = useCurrentRoutePath(); + + return ; +}; + +export default NavBarItemDirectoryPage; diff --git a/apps/meteor/client/NavBarV2/NavBarPagesToolbar/NavBarItemHomePage.tsx b/apps/meteor/client/NavBarV2/NavBarPagesToolbar/NavBarItemHomePage.tsx new file mode 100644 index 0000000000000..128a41ea97ae6 --- /dev/null +++ b/apps/meteor/client/NavBarV2/NavBarPagesToolbar/NavBarItemHomePage.tsx @@ -0,0 +1,22 @@ +import { NavBarItem } from '@rocket.chat/fuselage'; +import { useEffectEvent } from '@rocket.chat/fuselage-hooks'; +import { useRouter, useLayout, useSetting, useCurrentRoutePath } from '@rocket.chat/ui-contexts'; +import type { HTMLAttributes } from 'react'; +import React from 'react'; + +type NavBarItemHomePageProps = Omit, 'is'>; + +const NavBarItemHomePage = (props: NavBarItemHomePageProps) => { + const router = useRouter(); + const { sidebar } = useLayout(); + const showHome = useSetting('Layout_Show_Home_Button'); + const handleHome = useEffectEvent(() => { + sidebar.toggle(); + router.navigate('/home'); + }); + const currentRoute = useCurrentRoutePath(); + + return showHome ? : null; +}; + +export default NavBarItemHomePage; diff --git a/apps/meteor/client/NavBarV2/NavBarPagesToolbar/NavBarItemMarketPlaceMenu.tsx b/apps/meteor/client/NavBarV2/NavBarPagesToolbar/NavBarItemMarketPlaceMenu.tsx new file mode 100644 index 0000000000000..4a2bbc916b578 --- /dev/null +++ b/apps/meteor/client/NavBarV2/NavBarPagesToolbar/NavBarItemMarketPlaceMenu.tsx @@ -0,0 +1,30 @@ +import { NavBarItem } from '@rocket.chat/fuselage'; +import { useCurrentRoutePath, useTranslation } from '@rocket.chat/ui-contexts'; +import type { HTMLAttributes } from 'react'; +import React from 'react'; + +import GenericMenu from '../../components/GenericMenu/GenericMenu'; +import { useMarketPlaceMenu } from './hooks/useMarketPlaceMenu'; + +type NavBarItemMarketPlaceMenuProps = Omit, 'is'>; + +const NavBarItemMarketPlaceMenu = (props: NavBarItemMarketPlaceMenuProps) => { + const t = useTranslation(); + const sections = useMarketPlaceMenu(); + + const currentRoute = useCurrentRoutePath(); + + return ( + + ); +}; + +export default NavBarItemMarketPlaceMenu; diff --git a/apps/meteor/client/NavBarV2/NavBarPagesToolbar/hooks/useAuditMenu.spec.tsx b/apps/meteor/client/NavBarV2/NavBarPagesToolbar/hooks/useAuditMenu.spec.tsx new file mode 100644 index 0000000000000..11eddf9340558 --- /dev/null +++ b/apps/meteor/client/NavBarV2/NavBarPagesToolbar/hooks/useAuditMenu.spec.tsx @@ -0,0 +1,135 @@ +import { mockAppRoot } from '@rocket.chat/mock-providers'; +import { renderHook } from '@testing-library/react-hooks'; + +import { useAuditMenu } from './useAuditMenu'; + +it('should return an empty array of items if doesn`t have license', async () => { + const { result, waitFor } = renderHook(() => useAuditMenu(), { + wrapper: mockAppRoot() + .withEndpoint('GET', '/v1/licenses.info', () => ({ + // @ts-expect-error: just for testing + license: { + activeModules: [], + }, + })) + .withJohnDoe() + .withPermission('can-audit') + .withPermission('can-audit-log') + .build(), + }); + + await waitFor(() => result.all.length > 1); + + 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(), { + wrapper: mockAppRoot() + .withEndpoint('GET', '/v1/licenses.info', () => ({ + license: { + license: { + // @ts-expect-error: just for testing + grantedModules: [{ module: 'auditing' }], + }, + // @ts-expect-error: just for testing + activeModules: ['auditing'], + }, + })) + .withMethod('license:getModules', () => ['auditing']) + .withJohnDoe() + .build(), + }); + + await waitFor(() => result.all.length > 1); + + expect(result.current).toEqual([]); +}); + +it('should return auditItems if have license and permissions', async () => { + const { result, waitFor } = renderHook(() => useAuditMenu(), { + wrapper: mockAppRoot() + .withEndpoint('GET', '/v1/licenses.info', () => ({ + license: { + license: { + // @ts-expect-error: just for testing + grantedModules: [{ module: 'auditing' }], + }, + // @ts-expect-error: just for testing + activeModules: ['auditing'], + }, + })) + .withJohnDoe() + .withPermission('can-audit') + .withPermission('can-audit-log') + .build(), + }); + + await waitFor(() => result.current.length > 0); + + expect(result.current[0].items[0]).toEqual( + expect.objectContaining({ + id: 'messages', + }), + ); + + expect(result.current[0].items[1]).toEqual( + expect.objectContaining({ + id: 'auditLog', + }), + ); +}); + +it('should return auditMessages item if have license and can-audit permission', async () => { + const { result, waitFor } = renderHook(() => useAuditMenu(), { + wrapper: mockAppRoot() + .withEndpoint('GET', '/v1/licenses.info', () => ({ + license: { + license: { + // @ts-expect-error: just for testing + grantedModules: [{ module: 'auditing' }], + }, + // @ts-expect-error: just for testing + activeModules: ['auditing'], + }, + })) + .withJohnDoe() + .withPermission('can-audit') + .build(), + }); + + await waitFor(() => result.current.length > 0); + + 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(), { + wrapper: mockAppRoot() + .withEndpoint('GET', '/v1/licenses.info', () => ({ + license: { + license: { + // @ts-expect-error: just for testing + grantedModules: [{ module: 'auditing' }], + }, + // @ts-expect-error: just for testing + activeModules: ['auditing'], + }, + })) + .withJohnDoe() + .withPermission('can-audit-log') + .build(), + }); + + await waitFor(() => result.current.length > 0); + + expect(result.current[0].items[0]).toEqual( + expect.objectContaining({ + id: 'auditLog', + }), + ); +}); diff --git a/apps/meteor/client/NavBarV2/NavBarPagesToolbar/hooks/useAuditMenu.tsx b/apps/meteor/client/NavBarV2/NavBarPagesToolbar/hooks/useAuditMenu.tsx new file mode 100644 index 0000000000000..88a2a5de31aac --- /dev/null +++ b/apps/meteor/client/NavBarV2/NavBarPagesToolbar/hooks/useAuditMenu.tsx @@ -0,0 +1,38 @@ +import { usePermission, useRouter, useTranslation } from '@rocket.chat/ui-contexts'; + +import type { GenericMenuItemProps } from '../../../components/GenericMenu/GenericMenuItem'; +import { useHasLicenseModule } from '../../../hooks/useHasLicenseModule'; + +export const useAuditMenu = () => { + const router = useRouter(); + const t = useTranslation(); + + const hasAuditLicense = useHasLicenseModule('auditing') === true; + + const hasAuditPermission = usePermission('can-audit') && hasAuditLicense; + const hasAuditLogPermission = usePermission('can-audit-log') && hasAuditLicense; + + if (!hasAuditPermission && !hasAuditLogPermission) { + return []; + } + + const auditMessageItem: GenericMenuItemProps = { + id: 'messages', + icon: 'document-eye', + content: t('Messages'), + onClick: () => router.navigate('/audit'), + }; + const auditLogItem: GenericMenuItemProps = { + id: 'auditLog', + icon: 'document-eye', + content: t('Logs'), + onClick: () => router.navigate('/audit-log'), + }; + + return [ + { + title: t('Audit'), + items: [hasAuditPermission && auditMessageItem, hasAuditLogPermission && auditLogItem].filter(Boolean) as GenericMenuItemProps[], + }, + ]; +}; diff --git a/apps/meteor/client/NavBarV2/NavBarPagesToolbar/hooks/useMarketPlaceMenu.spec.tsx b/apps/meteor/client/NavBarV2/NavBarPagesToolbar/hooks/useMarketPlaceMenu.spec.tsx new file mode 100644 index 0000000000000..2a3d277e69fe0 --- /dev/null +++ b/apps/meteor/client/NavBarV2/NavBarPagesToolbar/hooks/useMarketPlaceMenu.spec.tsx @@ -0,0 +1,279 @@ +import { UIActionButtonContext } from '@rocket.chat/apps-engine/definition/ui'; +import { mockAppRoot } from '@rocket.chat/mock-providers'; +import { renderHook } from '@testing-library/react-hooks'; + +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(), { + wrapper: mockAppRoot() + .withEndpoint('GET', '/apps/actionButtons', () => []) + .build(), + }); + + expect(result.current[0].items).toEqual([]); +}); + +it('should return `explore` and `installed` items if the user has `access-marketplace` permission', () => { + const { result } = renderHook(() => useMarketPlaceMenu(), { + wrapper: mockAppRoot() + .withEndpoint('GET', '/apps/actionButtons', () => []) + .withPermission('access-marketplace') + .build(), + }); + + expect(result.current[0].items[0]).toEqual( + expect.objectContaining({ + id: 'explore', + }), + ); + + expect(result.current[0].items[1]).toEqual( + expect.objectContaining({ + id: 'installed', + }), + ); +}); + +it('should return `explore`, `installed` and `requested` items if the user has `manage-apps` permission', () => { + const { result } = renderHook(() => useMarketPlaceMenu(), { + wrapper: mockAppRoot() + .withEndpoint('GET', '/apps/actionButtons', () => []) + .withEndpoint('GET', '/apps/app-request/stats', () => ({ + data: { + totalSeen: 0, + totalUnseen: 1, + }, + })) + .withPermission('manage-apps') + .build(), + }); + + expect(result.current[0].items[0]).toEqual( + expect.objectContaining({ + id: 'explore', + }), + ); + + expect(result.current[0].items[1]).toEqual( + expect.objectContaining({ + id: 'installed', + }), + ); + + expect(result.current[0].items[2]).toEqual( + expect.objectContaining({ + id: 'requested-apps', + }), + ); +}); + +it('should return one action from the server with no conditions', async () => { + const { result, waitForValueToChange } = renderHook(() => useMarketPlaceMenu(), { + wrapper: mockAppRoot() + .withEndpoint('GET', '/apps/actionButtons', () => [ + { + appId: 'APP_ID', + actionId: 'ACTION_ID', + labelI18n: 'LABEL_I18N', + context: UIActionButtonContext.USER_DROPDOWN_ACTION, + }, + ]) + .withEndpoint('GET', '/apps/app-request/stats', () => ({ + data: { + totalSeen: 0, + totalUnseen: 1, + }, + })) + .withPermission('manage-apps') + .build(), + }); + + expect(result.current[0].items[0]).toEqual( + expect.objectContaining({ + id: 'explore', + }), + ); + + expect(result.current[0].items[1]).toEqual( + expect.objectContaining({ + id: 'installed', + }), + ); + + await waitForValueToChange(() => result.current[0].items[3]); + + 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(), { + wrapper: mockAppRoot() + .withEndpoint('GET', '/apps/actionButtons', () => [ + { + appId: 'APP_ID', + actionId: 'ACTION_ID', + labelI18n: 'LABEL_I18N', + context: UIActionButtonContext.USER_DROPDOWN_ACTION, + when: { + hasOneRole: ['admin'], + }, + }, + ]) + .withEndpoint('GET', '/apps/app-request/stats', () => ({ + data: { + totalSeen: 0, + totalUnseen: 1, + }, + })) + .withPermission('manage-apps') + .withJohnDoe() + .withRole('admin') + .build(), + }); + + expect(result.current[0].items[0]).toEqual( + expect.objectContaining({ + id: 'explore', + }), + ); + + expect(result.current[0].items[1]).toEqual( + expect.objectContaining({ + id: 'installed', + }), + ); + + await waitForValueToChange(() => result.current[0].items[3]); + + 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(), { + wrapper: mockAppRoot() + .withEndpoint('GET', '/apps/actionButtons', () => [ + { + appId: 'APP_ID', + actionId: 'ACTION_ID', + labelI18n: 'LABEL_I18N', + context: UIActionButtonContext.USER_DROPDOWN_ACTION, + when: { + hasOneRole: ['admin'], + }, + }, + ]) + .withEndpoint('GET', '/apps/app-request/stats', () => ({ + data: { + totalSeen: 0, + totalUnseen: 1, + }, + })) + .withPermission('manage-apps') + .build(), + }); + + expect(result.current[0].items[0]).toEqual( + expect.objectContaining({ + id: 'explore', + }), + ); + + expect(result.current[0].items[1]).toEqual( + expect.objectContaining({ + id: 'installed', + }), + ); + + expect(result.current[0].items[2]).toEqual( + expect.objectContaining({ + id: 'requested-apps', + }), + ); + + expect(result.current[0].items[3]).toEqual(undefined); + }); +}); + +describe('Marketplace menu with permission conditions', () => { + it('should return the action if the user has manage-apps permission', async () => { + const { result, waitForValueToChange } = renderHook(() => useMarketPlaceMenu(), { + wrapper: mockAppRoot() + .withEndpoint('GET', '/apps/actionButtons', () => [ + { + appId: 'APP_ID', + actionId: 'ACTION_ID', + labelI18n: 'LABEL_I18N', + context: UIActionButtonContext.USER_DROPDOWN_ACTION, + when: { + hasOnePermission: ['manage-apps'], + }, + }, + ]) + .withEndpoint('GET', '/apps/app-request/stats', () => ({ + data: { + totalSeen: 0, + totalUnseen: 1, + }, + })) + .withPermission('manage-apps') + .build(), + }); + + expect(result.current[0].items[0]).toEqual( + expect.objectContaining({ + id: 'explore', + }), + ); + + expect(result.current[0].items[1]).toEqual( + expect.objectContaining({ + id: 'installed', + }), + ); + + await waitForValueToChange(() => result.current[0].items[3]); + + 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(), { + wrapper: mockAppRoot() + .withEndpoint('GET', '/apps/actionButtons', () => [ + { + appId: 'APP_ID', + actionId: 'ACTION_ID', + labelI18n: 'LABEL_I18N', + context: UIActionButtonContext.USER_DROPDOWN_ACTION, + when: { + hasOnePermission: ['any'], + }, + }, + ]) + .withEndpoint('GET', '/apps/app-request/stats', () => ({ + data: { + totalSeen: 0, + totalUnseen: 1, + }, + })) + .withPermission('manage-apps') + .build(), + }); + + expect(result.current[0].items[3]).toEqual(undefined); + }); +}); diff --git a/apps/meteor/client/NavBarV2/NavBarPagesToolbar/hooks/useMarketPlaceMenu.tsx b/apps/meteor/client/NavBarV2/NavBarPagesToolbar/hooks/useMarketPlaceMenu.tsx new file mode 100644 index 0000000000000..fd704ffafe1f8 --- /dev/null +++ b/apps/meteor/client/NavBarV2/NavBarPagesToolbar/hooks/useMarketPlaceMenu.tsx @@ -0,0 +1,65 @@ +import { Badge, Skeleton } from '@rocket.chat/fuselage'; +import { useTranslation, usePermission, useRouter } from '@rocket.chat/ui-contexts'; +import React from 'react'; + +import type { GenericMenuItemProps } from '../../../components/GenericMenu/GenericMenuItem'; +import { useUserDropdownAppsActionButtons } from '../../../hooks/useAppActionButtons'; +import { useAppRequestStats } from '../../../views/marketplace/hooks/useAppRequestStats'; + +export const useMarketPlaceMenu = () => { + const t = useTranslation(); + + const appBoxItems = useUserDropdownAppsActionButtons(); + + const hasManageAppsPermission = usePermission('manage-apps'); + const hasAccessMarketplacePermission = usePermission('access-marketplace'); + + const showMarketplace = hasAccessMarketplacePermission || hasManageAppsPermission; + + const router = useRouter(); + + const appRequestStats = useAppRequestStats(); + + const marketPlaceItems: GenericMenuItemProps[] = [ + { + id: 'explore', + icon: 'compass', + content: t('Explore'), + onClick: () => router.navigate('/marketplace/explore/list'), + }, + { + id: 'installed', + icon: 'circle-arrow-down', + content: t('Installed'), + onClick: () => router.navigate('/marketplace/installed/list'), + }, + ]; + + const appsManagementItem: GenericMenuItemProps = { + id: 'requested-apps', + icon: 'cube', + content: t('Requested'), + onClick: () => { + router.navigate('/marketplace/requested/list'); + }, + addon: ( + <> + {appRequestStats.isLoading && } + {appRequestStats.isSuccess && appRequestStats.data.totalUnseen > 0 && ( + {appRequestStats.data.totalUnseen} + )} + + ), + }; + + return [ + { + title: t('Marketplace'), + items: [ + ...(showMarketplace ? marketPlaceItems : []), + ...(hasManageAppsPermission ? [appsManagementItem] : []), + ...(appBoxItems.isSuccess ? appBoxItems.data : []), + ], + }, + ]; +}; diff --git a/apps/meteor/client/NavBarV2/NavBarPagesToolbar/index.ts b/apps/meteor/client/NavBarV2/NavBarPagesToolbar/index.ts new file mode 100644 index 0000000000000..2b334cab4b2d2 --- /dev/null +++ b/apps/meteor/client/NavBarV2/NavBarPagesToolbar/index.ts @@ -0,0 +1,4 @@ +export { default as NavBarItemAuditMenu } from './NavBarItemAuditMenu'; +export { default as NavBarItemHomePage } from './NavBarItemHomePage'; +export { default as NavBarItemMarketPlaceMenu } from './NavBarItemMarketPlaceMenu'; +export { default as NavBarItemDirectoryPage } from './NavBarItemDirectoryPage'; diff --git a/apps/meteor/client/NavBarV2/NavBarSettingsToolbar/NavBarItemAdministrationMenu.tsx b/apps/meteor/client/NavBarV2/NavBarSettingsToolbar/NavBarItemAdministrationMenu.tsx new file mode 100644 index 0000000000000..045b36425512e --- /dev/null +++ b/apps/meteor/client/NavBarV2/NavBarSettingsToolbar/NavBarItemAdministrationMenu.tsx @@ -0,0 +1,33 @@ +import { NavBarItem } from '@rocket.chat/fuselage'; +import { useCurrentRoutePath, useTranslation } from '@rocket.chat/ui-contexts'; +import type { HTMLAttributes } from 'react'; +import React from 'react'; + +import GenericMenu from '../../components/GenericMenu/GenericMenu'; +import { useAdministrationMenu } from './hooks/useAdministrationMenu'; + +type NavBarItemAdministrationMenuProps = Omit, 'is'>; + +const NavBarItemAdministrationMenu = (props: NavBarItemAdministrationMenuProps) => { + const t = useTranslation(); + const currentRoute = useCurrentRoutePath(); + + const sections = useAdministrationMenu(); + + if (!sections[0].items.length) { + return null; + } + return ( + + ); +}; + +export default NavBarItemAdministrationMenu; diff --git a/apps/meteor/client/NavBarV2/NavBarSettingsToolbar/NavBarItemLoginPage.tsx b/apps/meteor/client/NavBarV2/NavBarSettingsToolbar/NavBarItemLoginPage.tsx new file mode 100644 index 0000000000000..a02c17db0b9be --- /dev/null +++ b/apps/meteor/client/NavBarV2/NavBarSettingsToolbar/NavBarItemLoginPage.tsx @@ -0,0 +1,19 @@ +import { Button } from '@rocket.chat/fuselage'; +import { useSessionDispatch, useTranslation } from '@rocket.chat/ui-contexts'; +import type { HTMLAttributes } from 'react'; +import React from 'react'; + +type NavBarItemLoginPageProps = Omit, 'is'>; + +const NavBarItemLoginPage = (props: NavBarItemLoginPageProps) => { + const setForceLogin = useSessionDispatch('forceLogin'); + const t = useTranslation(); + + return ( + + ); +}; + +export default NavBarItemLoginPage; diff --git a/apps/meteor/client/NavBarV2/NavBarSettingsToolbar/UserMenu/EditStatusModal.tsx b/apps/meteor/client/NavBarV2/NavBarSettingsToolbar/UserMenu/EditStatusModal.tsx new file mode 100644 index 0000000000000..f4dce69af876d --- /dev/null +++ b/apps/meteor/client/NavBarV2/NavBarSettingsToolbar/UserMenu/EditStatusModal.tsx @@ -0,0 +1,106 @@ +import type { IUser } from '@rocket.chat/core-typings'; +import { Field, TextInput, FieldGroup, Modal, Button, Box, FieldLabel, FieldRow, FieldError, FieldHint } from '@rocket.chat/fuselage'; +import { useEffectEvent, useLocalStorage } from '@rocket.chat/fuselage-hooks'; +import { useToastMessageDispatch, useSetting, useTranslation, useEndpoint } from '@rocket.chat/ui-contexts'; +import type { ReactElement, ChangeEvent, ComponentProps, FormEvent } from 'react'; +import React, { useState, useCallback } from 'react'; + +import UserStatusMenu from '../../../components/UserStatusMenu'; +import { USER_STATUS_TEXT_MAX_LENGTH } from '../../../lib/constants'; + +type EditStatusModalProps = { + onClose: () => void; + userStatus: IUser['status']; + userStatusText: IUser['statusText']; +}; + +const EditStatusModal = ({ onClose, userStatus, userStatusText }: EditStatusModalProps): ReactElement => { + const allowUserStatusMessageChange = useSetting('Accounts_AllowUserStatusMessageChange'); + const dispatchToastMessage = useToastMessageDispatch(); + const [customStatus, setCustomStatus] = useLocalStorage('Local_Custom_Status', ''); + const initialStatusText = customStatus || userStatusText; + + const t = useTranslation(); + const [statusText, setStatusText] = useState(initialStatusText); + const [statusType, setStatusType] = useState(userStatus); + const [statusTextError, setStatusTextError] = useState(); + + const setUserStatus = useEndpoint('POST', '/v1/users.setStatus'); + + const handleStatusText = useEffectEvent((e: ChangeEvent): void => { + setStatusText(e.currentTarget.value); + + if (statusText && statusText.length > USER_STATUS_TEXT_MAX_LENGTH) { + return setStatusTextError(t('Max_length_is', USER_STATUS_TEXT_MAX_LENGTH)); + } + + return setStatusTextError(undefined); + }); + + const handleStatusType = (type: IUser['status']): void => setStatusType(type); + + const handleSaveStatus = useCallback(async () => { + try { + await setUserStatus({ message: statusText, status: statusType }); + setCustomStatus(statusText); + dispatchToastMessage({ type: 'success', message: t('StatusMessage_Changed_Successfully') }); + } catch (error) { + dispatchToastMessage({ type: 'error', message: error }); + } + + onClose(); + }, [dispatchToastMessage, setUserStatus, statusText, statusType, onClose, t]); + + return ( + ) => ( + { + e.preventDefault(); + handleSaveStatus(); + }} + {...props} + /> + )} + > + + + {t('Edit_Status')} + + + + + + {t('StatusMessage')} + + } + /> + + {!allowUserStatusMessageChange && {t('StatusMessage_Change_Disabled')}} + {statusTextError} + + + + + + + + + + + ); +}; + +export default EditStatusModal; diff --git a/apps/meteor/client/NavBarV2/NavBarSettingsToolbar/UserMenu/UserMenu.tsx b/apps/meteor/client/NavBarV2/NavBarSettingsToolbar/UserMenu/UserMenu.tsx new file mode 100644 index 0000000000000..531ff8a74b663 --- /dev/null +++ b/apps/meteor/client/NavBarV2/NavBarSettingsToolbar/UserMenu/UserMenu.tsx @@ -0,0 +1,39 @@ +import type { IUser } from '@rocket.chat/core-typings'; +import { useTranslation } from '@rocket.chat/ui-contexts'; +import type { ComponentProps } from 'react'; +import React, { memo, useState } from 'react'; + +import GenericMenu from '../../../components/GenericMenu/GenericMenu'; +import type { GenericMenuItemProps } from '../../../components/GenericMenu/GenericMenuItem'; +import { useHandleMenuAction } from '../../../components/GenericMenu/hooks/useHandleMenuAction'; +import UserMenuButton from './UserMenuButton'; +import { useUserMenu } from './hooks/useUserMenu'; + +type UserMenuProps = { user: IUser } & Omit, 'sections' | 'items' | 'title'>; + +const UserMenu = function UserMenu({ user, ...props }: UserMenuProps) { + const t = useTranslation(); + const [isOpen, setIsOpen] = useState(false); + + const sections = useUserMenu(user); + const items = sections.reduce((acc, { items }) => [...acc, ...items], [] as GenericMenuItemProps[]); + + const handleAction = useHandleMenuAction(items, () => setIsOpen(false)); + + return ( + + ); +}; + +export default memo(UserMenu); diff --git a/apps/meteor/client/NavBarV2/NavBarSettingsToolbar/UserMenu/UserMenuButton.tsx b/apps/meteor/client/NavBarV2/NavBarSettingsToolbar/UserMenu/UserMenuButton.tsx new file mode 100644 index 0000000000000..9120678c75815 --- /dev/null +++ b/apps/meteor/client/NavBarV2/NavBarSettingsToolbar/UserMenu/UserMenuButton.tsx @@ -0,0 +1,59 @@ +import { css } from '@rocket.chat/css-in-js'; +import { Box, IconButton } from '@rocket.chat/fuselage'; +import { UserAvatar } from '@rocket.chat/ui-avatar'; +import { useSetting, useUser } from '@rocket.chat/ui-contexts'; +import type { ComponentPropsWithoutRef, ForwardedRef } from 'react'; +import React, { forwardRef } from 'react'; + +import { UserStatus } from '../../../components/UserStatus'; + +const anon = { + _id: '', + username: 'Anonymous', + status: 'online', + avatarETag: undefined, +} as const; + +type UserMenuButtonProps = ComponentPropsWithoutRef; + +const UserMenuButton = forwardRef(function UserMenuButton(props: UserMenuButtonProps, ref: ForwardedRef) { + const user = useUser(); + + const { status = !user ? 'online' : 'offline', username, avatarETag } = user || anon; + const presenceDisabled = useSetting('Presence_broadcast_disabled'); + + return ( + : 'user'} + > + + + + + ); +}); + +export default UserMenuButton; diff --git a/apps/meteor/client/NavBarV2/NavBarSettingsToolbar/UserMenu/UserMenuHeader.tsx b/apps/meteor/client/NavBarV2/NavBarSettingsToolbar/UserMenu/UserMenuHeader.tsx new file mode 100644 index 0000000000000..974af6be8ed86 --- /dev/null +++ b/apps/meteor/client/NavBarV2/NavBarSettingsToolbar/UserMenu/UserMenuHeader.tsx @@ -0,0 +1,45 @@ +import type { IUser } from '@rocket.chat/core-typings'; +import { Box, Margins } from '@rocket.chat/fuselage'; +import { UserAvatar } from '@rocket.chat/ui-avatar'; +import { useSetting, useTranslation } from '@rocket.chat/ui-contexts'; +import React from 'react'; + +import MarkdownText from '../../../components/MarkdownText'; +import { UserStatus } from '../../../components/UserStatus'; +import { useUserDisplayName } from '../../../hooks/useUserDisplayName'; + +type UserMenuHeaderProps = { user: IUser }; + +const UserMenuHeader = ({ user }: UserMenuHeaderProps) => { + const t = useTranslation(); + const presenceDisabled = useSetting('Presence_broadcast_disabled'); + const displayName = useUserDisplayName(user); + + return ( + + + + + + + + + + {displayName} + + + + + + + + + ); +}; + +export default UserMenuHeader; diff --git a/apps/meteor/client/NavBarV2/NavBarSettingsToolbar/UserMenu/hooks/useAccountItems.tsx b/apps/meteor/client/NavBarV2/NavBarSettingsToolbar/UserMenu/hooks/useAccountItems.tsx new file mode 100644 index 0000000000000..bf1b7e55f2445 --- /dev/null +++ b/apps/meteor/client/NavBarV2/NavBarSettingsToolbar/UserMenu/hooks/useAccountItems.tsx @@ -0,0 +1,63 @@ +import { Badge } from '@rocket.chat/fuselage'; +import { useEffectEvent } from '@rocket.chat/fuselage-hooks'; +import { defaultFeaturesPreview, useFeaturePreviewList } from '@rocket.chat/ui-client'; +import { useRouter, useTranslation } from '@rocket.chat/ui-contexts'; +import React from 'react'; + +import type { GenericMenuItemProps } from '../../../../components/GenericMenu/GenericMenuItem'; + +export const useAccountItems = (): GenericMenuItemProps[] => { + const t = useTranslation(); + const router = useRouter(); + + const { unseenFeatures, featurePreviewEnabled } = useFeaturePreviewList(); + + const handleMyAccount = useEffectEvent(() => { + router.navigate('/account'); + }); + const handlePreferences = useEffectEvent(() => { + router.navigate('/account/preferences'); + }); + const handleFeaturePreview = useEffectEvent(() => { + router.navigate('/account/feature-preview'); + }); + const handleAccessibility = useEffectEvent(() => { + router.navigate('/account/accessibility-and-appearance'); + }); + + const featurePreviewItem = { + id: 'feature-preview', + icon: 'flask' as const, + content: t('Feature_preview'), + onClick: handleFeaturePreview, + ...(unseenFeatures > 0 && { + addon: ( + + {unseenFeatures} + + ), + }), + }; + + return [ + { + id: 'profile', + icon: 'user', + content: t('Profile'), + onClick: handleMyAccount, + }, + { + id: 'preferences', + icon: 'customize', + content: t('Preferences'), + onClick: handlePreferences, + }, + { + id: 'accessibility', + icon: 'person-arms-spread', + content: t('Accessibility_and_Appearance'), + onClick: handleAccessibility, + }, + ...(featurePreviewEnabled && defaultFeaturesPreview.length > 0 ? [featurePreviewItem] : []), + ]; +}; diff --git a/apps/meteor/client/NavBarV2/NavBarSettingsToolbar/UserMenu/hooks/useCustomStatusModalHandler.tsx b/apps/meteor/client/NavBarV2/NavBarSettingsToolbar/UserMenu/hooks/useCustomStatusModalHandler.tsx new file mode 100644 index 0000000000000..f0f863f8efaba --- /dev/null +++ b/apps/meteor/client/NavBarV2/NavBarSettingsToolbar/UserMenu/hooks/useCustomStatusModalHandler.tsx @@ -0,0 +1,14 @@ +import { useSetModal, useUser } from '@rocket.chat/ui-contexts'; +import React from 'react'; + +import EditStatusModal from '../EditStatusModal'; + +export const useCustomStatusModalHandler = () => { + const user = useUser(); + const setModal = useSetModal(); + + return () => { + const handleModalClose = () => setModal(null); + setModal(); + }; +}; diff --git a/apps/meteor/client/NavBarV2/NavBarSettingsToolbar/UserMenu/hooks/useStatusItems.tsx b/apps/meteor/client/NavBarV2/NavBarSettingsToolbar/UserMenu/hooks/useStatusItems.tsx new file mode 100644 index 0000000000000..2957d22c5e32e --- /dev/null +++ b/apps/meteor/client/NavBarV2/NavBarSettingsToolbar/UserMenu/hooks/useStatusItems.tsx @@ -0,0 +1,87 @@ +import { Box } from '@rocket.chat/fuselage'; +import { useEndpoint, useSetting } from '@rocket.chat/ui-contexts'; +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import React, { useEffect } from 'react'; +import { useTranslation } from 'react-i18next'; + +import { callbacks } from '../../../../../lib/callbacks'; +import type { GenericMenuItemProps } from '../../../../components/GenericMenu/GenericMenuItem'; +import MarkdownText from '../../../../components/MarkdownText'; +import { UserStatus } from '../../../../components/UserStatus'; +import { userStatuses } from '../../../../lib/userStatuses'; +import type { UserStatusDescriptor } from '../../../../lib/userStatuses'; +import { useStatusDisabledModal } from '../../../../views/admin/customUserStatus/hooks/useStatusDisabledModal'; +import { useCustomStatusModalHandler } from './useCustomStatusModalHandler'; + +export const useStatusItems = (): GenericMenuItemProps[] => { + // We should lift this up to somewhere else if we want to use it in other places + + userStatuses.invisibleAllowed = useSetting('Accounts_AllowInvisibleStatusOption', true); + + const queryClient = useQueryClient(); + + useEffect( + () => + userStatuses.watch(() => { + queryClient.setQueryData(['user-statuses'], Array.from(userStatuses)); + }), + [queryClient], + ); + + const { t } = useTranslation(); + + const setStatus = useEndpoint('POST', '/v1/users.setStatus'); + const setStatusMutation = useMutation({ + mutationFn: async (status: UserStatusDescriptor) => { + void setStatus({ status: status.statusType, message: userStatuses.isValidType(status.id) ? '' : status.name }); + void callbacks.run('userStatusManuallySet', status); + }, + }); + + const presenceDisabled = useSetting('Presence_broadcast_disabled', false); + + const { data: statuses } = useQuery({ + queryKey: ['user-statuses'], + queryFn: async () => { + await userStatuses.sync(); + return Array.from(userStatuses); + }, + staleTime: Infinity, + select: (statuses) => + statuses.map((status): GenericMenuItemProps => { + const content = status.localizeName ? t(status.name) : status.name; + return { + id: status.id, + status: , + content: , + disabled: presenceDisabled, + onClick: () => setStatusMutation.mutate(status), + }; + }), + }); + + const handleStatusDisabledModal = useStatusDisabledModal(); + const handleCustomStatus = useCustomStatusModalHandler(); + + return [ + ...(presenceDisabled + ? [ + { + id: 'presence-disabled', + content: ( + + + {t('User_status_disabled')} + + + {t('Learn_more')} + + + ), + }, + ] + : []), + ...(statuses ?? []), + { id: 'custom-status', icon: 'emoji', content: t('Custom_Status'), onClick: handleCustomStatus, disabled: presenceDisabled }, + ]; +}; diff --git a/apps/meteor/client/NavBarV2/NavBarSettingsToolbar/UserMenu/hooks/useUserMenu.tsx b/apps/meteor/client/NavBarV2/NavBarSettingsToolbar/UserMenu/hooks/useUserMenu.tsx new file mode 100644 index 0000000000000..a969c853d7979 --- /dev/null +++ b/apps/meteor/client/NavBarV2/NavBarSettingsToolbar/UserMenu/hooks/useUserMenu.tsx @@ -0,0 +1,46 @@ +import type { IUser } from '@rocket.chat/core-typings'; +import { useEffectEvent } from '@rocket.chat/fuselage-hooks'; +import { useLogout, useTranslation } from '@rocket.chat/ui-contexts'; +import React from 'react'; + +import type { GenericMenuItemProps } from '../../../../components/GenericMenu/GenericMenuItem'; +import UserMenuHeader from '../UserMenuHeader'; +import { useAccountItems } from './useAccountItems'; +import { useStatusItems } from './useStatusItems'; + +export const useUserMenu = (user: IUser) => { + const t = useTranslation(); + + const statusItems = useStatusItems(); + const accountItems = useAccountItems(); + + const logout = useLogout(); + const handleLogout = useEffectEvent(() => { + logout(); + }); + + const logoutItem: GenericMenuItemProps = { + id: 'logout', + icon: 'sign-out', + content: t('Logout'), + onClick: handleLogout, + }; + + return [ + { + title: , + items: [], + }, + { + title: t('Status'), + items: statusItems, + }, + { + title: t('Account'), + items: accountItems, + }, + { + items: [logoutItem], + }, + ]; +}; diff --git a/apps/meteor/client/NavBarV2/NavBarSettingsToolbar/UserMenu/index.ts b/apps/meteor/client/NavBarV2/NavBarSettingsToolbar/UserMenu/index.ts new file mode 100644 index 0000000000000..63aab39921d74 --- /dev/null +++ b/apps/meteor/client/NavBarV2/NavBarSettingsToolbar/UserMenu/index.ts @@ -0,0 +1 @@ +export { default } from './UserMenu'; diff --git a/apps/meteor/client/NavBarV2/NavBarSettingsToolbar/hooks/useAdministrationMenu.spec.tsx b/apps/meteor/client/NavBarV2/NavBarSettingsToolbar/hooks/useAdministrationMenu.spec.tsx new file mode 100644 index 0000000000000..1315d10533920 --- /dev/null +++ b/apps/meteor/client/NavBarV2/NavBarSettingsToolbar/hooks/useAdministrationMenu.spec.tsx @@ -0,0 +1,54 @@ +import { mockAppRoot } from '@rocket.chat/mock-providers'; +import { renderHook } from '@testing-library/react-hooks'; + +import { useAdministrationMenu } from './useAdministrationMenu'; + +it('should return omnichannel item if has `view-livechat-manager` permission ', async () => { + const { result, waitFor } = renderHook(() => useAdministrationMenu(), { + wrapper: mockAppRoot() + .withEndpoint('GET', '/v1/licenses.info', () => ({ + // @ts-expect-error this is a mock + license: {}, + })) + .withEndpoint('GET', '/v1/cloud.registrationStatus', () => ({ + registrationStatus: { + workspaceRegistered: false, + } as any, + })) + .withPermission('view-livechat-manager') + .build(), + }); + + await waitFor(() => !!result.current.length); + + 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(), { + wrapper: mockAppRoot() + .withEndpoint('GET', '/v1/licenses.info', () => ({ + // @ts-expect-error this is a mock + license: {}, + })) + .withEndpoint('GET', '/v1/cloud.registrationStatus', () => ({ + registrationStatus: { + workspaceRegistered: false, + } as any, + })) + .withPermission('access-permissions') + .build(), + }); + + await waitFor(() => !!result.current.length); + + expect(result.current[0].items[0]).toEqual( + expect.objectContaining({ + id: 'workspace', + }), + ); +}); diff --git a/apps/meteor/client/NavBarV2/NavBarSettingsToolbar/hooks/useAdministrationMenu.tsx b/apps/meteor/client/NavBarV2/NavBarSettingsToolbar/hooks/useAdministrationMenu.tsx new file mode 100644 index 0000000000000..54d4818128ea7 --- /dev/null +++ b/apps/meteor/client/NavBarV2/NavBarSettingsToolbar/hooks/useAdministrationMenu.tsx @@ -0,0 +1,57 @@ +import { useAtLeastOnePermission, usePermission, useRouter, useTranslation } from '@rocket.chat/ui-contexts'; + +import type { GenericMenuItemProps } from '../../../components/GenericMenu/GenericMenuItem'; + +const ADMIN_PERMISSIONS = [ + 'view-statistics', + 'run-import', + 'view-user-administration', + 'view-room-administration', + 'create-invite-links', + 'manage-cloud', + 'view-logs', + 'manage-sounds', + 'view-federation-data', + 'manage-email-inbox', + 'manage-emoji', + 'manage-outgoing-integrations', + 'manage-own-outgoing-integrations', + 'manage-incoming-integrations', + 'manage-own-incoming-integrations', + 'manage-oauth-apps', + 'access-mailer', + 'manage-user-status', + 'access-permissions', + 'access-setting-permissions', + 'view-privileged-setting', + 'edit-privileged-setting', + 'manage-selected-settings', + 'view-engagement-dashboard', + 'view-moderation-console', +]; + +export const useAdministrationMenu = () => { + const router = useRouter(); + const t = useTranslation(); + + const isAdmin = useAtLeastOnePermission(ADMIN_PERMISSIONS); + const isOmnichannel = usePermission('view-livechat-manager'); + + const workspace: GenericMenuItemProps = { + id: 'workspace', + content: t('Workspace'), + onClick: () => router.navigate('/admin'), + }; + const omnichannel: GenericMenuItemProps = { + id: 'omnichannel', + content: t('Omnichannel'), + onClick: () => router.navigate('/omnichannel/current'), + }; + + return [ + { + title: t('Manage'), + items: [isAdmin && workspace, isOmnichannel && omnichannel].filter(Boolean) as GenericMenuItemProps[], + }, + ]; +}; diff --git a/apps/meteor/client/NavBarV2/NavBarSettingsToolbar/index.ts b/apps/meteor/client/NavBarV2/NavBarSettingsToolbar/index.ts new file mode 100644 index 0000000000000..9bc514a8088ad --- /dev/null +++ b/apps/meteor/client/NavBarV2/NavBarSettingsToolbar/index.ts @@ -0,0 +1,3 @@ +export { default as NavBarItemAdministrationMenu } from './NavBarItemAdministrationMenu'; +export { default as NavBarItemLoginPage } from './NavBarItemLoginPage'; +export { default as UserMenu } from './UserMenu'; diff --git a/apps/meteor/client/NavBarV2/index.ts b/apps/meteor/client/NavBarV2/index.ts new file mode 100644 index 0000000000000..902ee590de669 --- /dev/null +++ b/apps/meteor/client/NavBarV2/index.ts @@ -0,0 +1 @@ +export { default } from './NavBar'; diff --git a/apps/meteor/client/components/AutoCompleteAgentWithoutExtension.tsx b/apps/meteor/client/components/AutoCompleteAgentWithoutExtension.tsx index 62c78ef217059..c75c827067ec4 100644 --- a/apps/meteor/client/components/AutoCompleteAgentWithoutExtension.tsx +++ b/apps/meteor/client/components/AutoCompleteAgentWithoutExtension.tsx @@ -1,7 +1,6 @@ import type { ILivechatAgent } from '@rocket.chat/core-typings'; import { PaginatedSelectFiltered } from '@rocket.chat/fuselage'; import { useDebouncedValue } from '@rocket.chat/fuselage-hooks'; -import type { FC } from 'react'; import React, { memo, useMemo, useState } from 'react'; import { useRecordList } from '../hooks/lists/useRecordList'; @@ -15,7 +14,7 @@ type AutoCompleteAgentProps = { currentExtension?: string; }; -const AutoCompleteAgentWithoutExtension: FC = (props) => { +const AutoCompleteAgentWithoutExtension = (props: AutoCompleteAgentProps) => { const { value, currentExtension, onChange = (): void => undefined, haveAll = false } = props; const [agentsFilter, setAgentsFilter] = useState(''); diff --git a/apps/meteor/client/components/ConfirmOwnerChangeModal.tsx b/apps/meteor/client/components/ConfirmOwnerChangeModal.tsx index 0865a1c26ae9b..349341baf0034 100644 --- a/apps/meteor/client/components/ConfirmOwnerChangeModal.tsx +++ b/apps/meteor/client/components/ConfirmOwnerChangeModal.tsx @@ -1,6 +1,6 @@ import { Box } from '@rocket.chat/fuselage'; import { useTranslation } from '@rocket.chat/ui-contexts'; -import type { FC, ComponentProps } from 'react'; +import type { ComponentPropsWithoutRef } from 'react'; import React from 'react'; import GenericModal from './GenericModal'; @@ -10,16 +10,16 @@ type ConfirmOwnerChangeModalProps = { shouldChangeOwner: string[]; shouldBeRemoved: string[]; contentTitle?: string; -} & Pick, 'onConfirm' | 'onCancel' | 'confirmText'>; +} & Pick, 'onConfirm' | 'onCancel' | 'confirmText'>; -const ConfirmOwnerChangeModal: FC = ({ +const ConfirmOwnerChangeModal = ({ shouldChangeOwner, shouldBeRemoved, contentTitle, confirmText, onConfirm, onCancel, -}) => { +}: ConfirmOwnerChangeModalProps) => { const t = useTranslation(); let changeOwnerRooms = ''; diff --git a/apps/meteor/client/components/Contextualbar/Contextualbar.tsx b/apps/meteor/client/components/Contextualbar/Contextualbar.tsx new file mode 100644 index 0000000000000..481537d23f3e4 --- /dev/null +++ b/apps/meteor/client/components/Contextualbar/Contextualbar.tsx @@ -0,0 +1,19 @@ +import { ContextualbarV2, Contextualbar as ContextualbarComponent } from '@rocket.chat/fuselage'; +import { FeaturePreview, FeaturePreviewOff, FeaturePreviewOn } from '@rocket.chat/ui-client'; +import type { ComponentProps } from 'react'; +import React, { forwardRef, memo } from 'react'; + +const Contextualbar = forwardRef>(function Contextualbar(props, ref) { + return ( + + + + + + + + + ); +}); + +export default memo(Contextualbar); diff --git a/apps/meteor/client/components/Contextualbar/ContextualbarAction.tsx b/apps/meteor/client/components/Contextualbar/ContextualbarAction.tsx new file mode 100644 index 0000000000000..567bd4e276e1d --- /dev/null +++ b/apps/meteor/client/components/Contextualbar/ContextualbarAction.tsx @@ -0,0 +1,17 @@ +import { ContextualbarAction as ContextualbarActionComponent, ContextualbarV2Action } from '@rocket.chat/fuselage'; +import { FeaturePreview, FeaturePreviewOff, FeaturePreviewOn } from '@rocket.chat/ui-client'; +import type { ComponentProps } from 'react'; +import React, { memo } from 'react'; + +const ContextualbarAction = (props: ComponentProps) => ( + + + + + + + + +); + +export default memo(ContextualbarAction); diff --git a/apps/meteor/client/components/Contextualbar/ContextualbarActions.tsx b/apps/meteor/client/components/Contextualbar/ContextualbarActions.tsx new file mode 100644 index 0000000000000..869030ddb479d --- /dev/null +++ b/apps/meteor/client/components/Contextualbar/ContextualbarActions.tsx @@ -0,0 +1,17 @@ +import { ContextualbarV2Actions, ContextualbarActions as ContextualbarActionsComponent } from '@rocket.chat/fuselage'; +import { FeaturePreview, FeaturePreviewOff, FeaturePreviewOn } from '@rocket.chat/ui-client'; +import type { ComponentProps } from 'react'; +import React, { memo } from 'react'; + +const ContextualbarActions = (props: ComponentProps) => ( + + + + + + + + +); + +export default memo(ContextualbarActions); diff --git a/apps/meteor/client/components/Contextualbar/ContextualbarBack.tsx b/apps/meteor/client/components/Contextualbar/ContextualbarBack.tsx index c2ae717eda331..c8e17ab88d80c 100644 --- a/apps/meteor/client/components/Contextualbar/ContextualbarBack.tsx +++ b/apps/meteor/client/components/Contextualbar/ContextualbarBack.tsx @@ -1,8 +1,9 @@ -import { ContextualbarAction } from '@rocket.chat/fuselage'; import { useTranslation } from '@rocket.chat/ui-contexts'; import type { ReactElement, ComponentProps } from 'react'; import React, { memo } from 'react'; +import ContextualbarAction from './ContextualbarAction'; + type ContextualbarBackProps = Partial>; const ContextualbarBack = (props: ContextualbarBackProps): ReactElement => { diff --git a/apps/meteor/client/components/Contextualbar/ContextualbarButton.tsx b/apps/meteor/client/components/Contextualbar/ContextualbarButton.tsx new file mode 100644 index 0000000000000..ab2ab878503e2 --- /dev/null +++ b/apps/meteor/client/components/Contextualbar/ContextualbarButton.tsx @@ -0,0 +1,17 @@ +import { ContextualbarV2Button, ContextualbarButton as ContextualbarButtonComponent } from '@rocket.chat/fuselage'; +import { FeaturePreview, FeaturePreviewOff, FeaturePreviewOn } from '@rocket.chat/ui-client'; +import type { ComponentProps } from 'react'; +import React, { memo } from 'react'; + +const ContextualbarButton = (props: ComponentProps) => ( + + + + + + + + +); + +export default memo(ContextualbarButton); diff --git a/apps/meteor/client/components/Contextualbar/ContextualbarClose.tsx b/apps/meteor/client/components/Contextualbar/ContextualbarClose.tsx index 6c0fbd5c8ebe8..1670c9be58958 100644 --- a/apps/meteor/client/components/Contextualbar/ContextualbarClose.tsx +++ b/apps/meteor/client/components/Contextualbar/ContextualbarClose.tsx @@ -1,8 +1,9 @@ -import { ContextualbarAction } from '@rocket.chat/fuselage'; import { useTranslation } from '@rocket.chat/ui-contexts'; import type { ComponentProps, ReactElement } from 'react'; import React, { memo } from 'react'; +import ContextualbarAction from './ContextualbarAction'; + type ContextualbarCloseProps = Partial>; const ContextualbarClose = (props: ContextualbarCloseProps): ReactElement => { diff --git a/apps/meteor/client/components/Contextualbar/ContextualbarContent.tsx b/apps/meteor/client/components/Contextualbar/ContextualbarContent.tsx new file mode 100644 index 0000000000000..10d3d74b673be --- /dev/null +++ b/apps/meteor/client/components/Contextualbar/ContextualbarContent.tsx @@ -0,0 +1,22 @@ +import { ContextualbarV2Content, ContextualbarContent as ContextualbarContentComponent } from '@rocket.chat/fuselage'; +import { FeaturePreview, FeaturePreviewOff, FeaturePreviewOn } from '@rocket.chat/ui-client'; +import type { ComponentProps } from 'react'; +import React, { forwardRef, memo } from 'react'; + +const ContextualbarContent = forwardRef>(function ContextualbarContent( + props, + ref, +) { + return ( + + + + + + + + + ); +}); + +export default memo(ContextualbarContent); diff --git a/apps/meteor/client/components/Contextualbar/ContextualbarDialog.tsx b/apps/meteor/client/components/Contextualbar/ContextualbarDialog.tsx index c3421f3fc9d39..23def16a94a12 100644 --- a/apps/meteor/client/components/Contextualbar/ContextualbarDialog.tsx +++ b/apps/meteor/client/components/Contextualbar/ContextualbarDialog.tsx @@ -1,4 +1,3 @@ -import { Contextualbar } from '@rocket.chat/fuselage'; import { FeaturePreview, FeaturePreviewOff, FeaturePreviewOn } from '@rocket.chat/ui-client'; import { useLayoutSizes, useLayoutContextualBarPosition } from '@rocket.chat/ui-contexts'; import type { ComponentProps, KeyboardEvent } from 'react'; @@ -7,6 +6,7 @@ import type { AriaDialogProps } from 'react-aria'; import { FocusScope, useDialog } from 'react-aria'; import { useRoomToolbox } from '../../views/room/contexts/RoomToolboxContext'; +import Contextualbar from './Contextualbar'; import ContextualbarResizable from './ContextualbarResizable'; type ContextualbarDialogProps = AriaDialogProps & ComponentProps; diff --git a/apps/meteor/client/components/Contextualbar/ContextualbarEmptyContent.tsx b/apps/meteor/client/components/Contextualbar/ContextualbarEmptyContent.tsx new file mode 100644 index 0000000000000..be3b3aca7c532 --- /dev/null +++ b/apps/meteor/client/components/Contextualbar/ContextualbarEmptyContent.tsx @@ -0,0 +1,21 @@ +import { ContextualbarV2EmptyContent, ContextualbarEmptyContent as ContextualbarEmptyContentComponent } from '@rocket.chat/fuselage'; +import { FeaturePreview, FeaturePreviewOff, FeaturePreviewOn } from '@rocket.chat/ui-client'; +import type { ComponentProps } from 'react'; +import React, { forwardRef, memo } from 'react'; + +const ContextualbarEmptyContent = forwardRef>( + function ContextualbarEmptyContent(props, ref) { + return ( + + + + + + + + + ); + }, +); + +export default memo(ContextualbarEmptyContent); diff --git a/apps/meteor/client/components/Contextualbar/ContextualbarFooter.tsx b/apps/meteor/client/components/Contextualbar/ContextualbarFooter.tsx new file mode 100644 index 0000000000000..481823a1a13f7 --- /dev/null +++ b/apps/meteor/client/components/Contextualbar/ContextualbarFooter.tsx @@ -0,0 +1,22 @@ +import { ContextualbarV2Footer, ContextualbarFooter as ContextualbarFooterComponent } from '@rocket.chat/fuselage'; +import { FeaturePreview, FeaturePreviewOff, FeaturePreviewOn } from '@rocket.chat/ui-client'; +import type { ComponentProps } from 'react'; +import React, { forwardRef, memo } from 'react'; + +const ContextualbarFooter = forwardRef>(function ContextualbarFooter( + props, + ref, +) { + return ( + + + + + + + + + ); +}); + +export default memo(ContextualbarFooter); diff --git a/apps/meteor/client/components/Contextualbar/ContextualbarHeader.tsx b/apps/meteor/client/components/Contextualbar/ContextualbarHeader.tsx index d757cccce0eff..795182df8465a 100644 --- a/apps/meteor/client/components/Contextualbar/ContextualbarHeader.tsx +++ b/apps/meteor/client/components/Contextualbar/ContextualbarHeader.tsx @@ -1,16 +1,22 @@ -import { ContextualbarHeader as ContextualbarHeaderComponent } from '@rocket.chat/fuselage'; -import type { FC, ReactNode, ComponentProps } from 'react'; +import { ContextualbarV2Header, ContextualbarHeader as ContextualbarHeaderComponent } from '@rocket.chat/fuselage'; +import { FeaturePreview, FeaturePreviewOff, FeaturePreviewOn } from '@rocket.chat/ui-client'; +import type { ComponentPropsWithoutRef, ReactNode } from 'react'; import React, { memo } from 'react'; type ContextualbarHeaderProps = { expanded?: boolean; children: ReactNode; -} & ComponentProps; +} & ComponentPropsWithoutRef; -const ContextualbarHeader: FC = ({ children, expanded, ...props }) => ( - - {children} - +const ContextualbarHeader = (props: ContextualbarHeaderProps) => ( + + + + + + + + ); export default memo(ContextualbarHeader); diff --git a/apps/meteor/client/components/Contextualbar/ContextualbarIcon.tsx b/apps/meteor/client/components/Contextualbar/ContextualbarIcon.tsx new file mode 100644 index 0000000000000..5f6062fe351a1 --- /dev/null +++ b/apps/meteor/client/components/Contextualbar/ContextualbarIcon.tsx @@ -0,0 +1,17 @@ +import { ContextualbarV2Icon, ContextualbarIcon as ContextualbarIconComponent } from '@rocket.chat/fuselage'; +import { FeaturePreview, FeaturePreviewOff, FeaturePreviewOn } from '@rocket.chat/ui-client'; +import type { ComponentProps } from 'react'; +import React, { memo } from 'react'; + +const ContextualbarIcon = (props: ComponentProps) => ( + + + + + + + + +); + +export default memo(ContextualbarIcon); diff --git a/apps/meteor/client/components/Contextualbar/ContextualbarSection.tsx b/apps/meteor/client/components/Contextualbar/ContextualbarSection.tsx new file mode 100644 index 0000000000000..53ee192f54162 --- /dev/null +++ b/apps/meteor/client/components/Contextualbar/ContextualbarSection.tsx @@ -0,0 +1,22 @@ +import { ContextualbarV2Section, ContextualbarSection as ContextualbarSectionComponent } from '@rocket.chat/fuselage'; +import { FeaturePreview, FeaturePreviewOff, FeaturePreviewOn } from '@rocket.chat/ui-client'; +import type { ComponentProps } from 'react'; +import React, { forwardRef, memo } from 'react'; + +const ContextualbarSection = forwardRef>(function ContextualbarSection( + props, + ref, +) { + return ( + + + + + + + + + ); +}); + +export default memo(ContextualbarSection); diff --git a/apps/meteor/client/components/Contextualbar/ContextualbarSkeleton.tsx b/apps/meteor/client/components/Contextualbar/ContextualbarSkeleton.tsx new file mode 100644 index 0000000000000..92b74451b4500 --- /dev/null +++ b/apps/meteor/client/components/Contextualbar/ContextualbarSkeleton.tsx @@ -0,0 +1,17 @@ +import { ContextualbarV2Skeleton, ContextualbarSkeleton as ContextualbarSkeletonComponent } from '@rocket.chat/fuselage'; +import { FeaturePreview, FeaturePreviewOff, FeaturePreviewOn } from '@rocket.chat/ui-client'; +import type { ComponentProps } from 'react'; +import React, { memo } from 'react'; + +const ContextualbarSkeleton = (props: ComponentProps) => ( + + + + + + + + +); + +export default memo(ContextualbarSkeleton); diff --git a/apps/meteor/client/components/Contextualbar/ContextualbarTitle.tsx b/apps/meteor/client/components/Contextualbar/ContextualbarTitle.tsx index 506be155ce18a..bffcc5669ce4b 100644 --- a/apps/meteor/client/components/Contextualbar/ContextualbarTitle.tsx +++ b/apps/meteor/client/components/Contextualbar/ContextualbarTitle.tsx @@ -1,9 +1,17 @@ -import { ContextualbarTitle as ContextualbarTitleComponent } from '@rocket.chat/fuselage'; +import { ContextualbarV2Title, ContextualbarTitle as ContextualbarTitleComponent } from '@rocket.chat/fuselage'; +import { FeaturePreview, FeaturePreviewOff, FeaturePreviewOn } from '@rocket.chat/ui-client'; import type { ComponentProps } from 'react'; import React from 'react'; const ContextualbarTitle = (props: ComponentProps) => ( - + + + + + + + + ); export default ContextualbarTitle; diff --git a/apps/meteor/client/components/Contextualbar/index.ts b/apps/meteor/client/components/Contextualbar/index.ts index c370b7f790fc3..c8602186e09a1 100644 --- a/apps/meteor/client/components/Contextualbar/index.ts +++ b/apps/meteor/client/components/Contextualbar/index.ts @@ -1,20 +1,19 @@ -import { - Contextualbar, - ContextualbarAction, - ContextualbarActions, - ContextualbarContent, - ContextualbarSkeleton, - ContextualbarIcon, - ContextualbarFooter, - ContextualbarEmptyContent, -} from '@rocket.chat/fuselage'; - +import Contextualbar from './Contextualbar'; +import ContextualbarAction from './ContextualbarAction'; +import ContextualbarActions from './ContextualbarActions'; import ContextualbarBack from './ContextualbarBack'; +import ContextualbarButton from './ContextualbarButton'; import ContextualbarClose from './ContextualbarClose'; +import ContextualbarContent from './ContextualbarContent'; import ContextualbarDialog from './ContextualbarDialog'; +import ContextualbarEmptyContent from './ContextualbarEmptyContent'; +import ContextualbarFooter from './ContextualbarFooter'; import ContextualbarHeader from './ContextualbarHeader'; +import ContextualbarIcon from './ContextualbarIcon'; import ContextualbarInnerContent from './ContextualbarInnerContent'; import ContextualbarScrollableContent from './ContextualbarScrollableContent'; +import ContextualbarSection from './ContextualbarSection'; +import ContextualbarSkeleton from './ContextualbarSkeleton'; import ContextualbarTitle from './ContextualbarTitle'; export { @@ -24,6 +23,7 @@ export { ContextualbarAction, ContextualbarActions, ContextualbarBack, + ContextualbarButton, ContextualbarClose, ContextualbarContent, ContextualbarSkeleton, @@ -33,4 +33,5 @@ export { ContextualbarEmptyContent, ContextualbarScrollableContent, ContextualbarInnerContent, + ContextualbarSection, }; diff --git a/apps/meteor/client/components/CreateDiscussion/CreateDiscussion.tsx b/apps/meteor/client/components/CreateDiscussion/CreateDiscussion.tsx index e6bce31b0b87e..cd39a187cd895 100644 --- a/apps/meteor/client/components/CreateDiscussion/CreateDiscussion.tsx +++ b/apps/meteor/client/components/CreateDiscussion/CreateDiscussion.tsx @@ -46,11 +46,10 @@ const CreateDiscussion = ({ onClose, defaultParentRoom, parentMessageId, nameSug const t = useTranslation(); const { - formState: { isDirty, isSubmitting, isValidating, errors }, + formState: { isSubmitting, isValidating, errors }, handleSubmit, control, watch, - register, } = useForm({ mode: 'onBlur', defaultValues: { @@ -175,7 +174,11 @@ const CreateDiscussion = ({ onClose, defaultParentRoom, parentMessageId, nameSug {t('Topic')} - + } + /> {t('Displayed_next_to_name')} @@ -243,7 +246,7 @@ const CreateDiscussion = ({ onClose, defaultParentRoom, parentMessageId, nameSug - diff --git a/apps/meteor/client/components/CustomScrollbars/VirtuosoScrollbars.tsx b/apps/meteor/client/components/CustomScrollbars/VirtuosoScrollbars.tsx index b07083be1a036..06080ede2510a 100644 --- a/apps/meteor/client/components/CustomScrollbars/VirtuosoScrollbars.tsx +++ b/apps/meteor/client/components/CustomScrollbars/VirtuosoScrollbars.tsx @@ -10,7 +10,7 @@ const VirtuosoScrollbars = forwardRef(function VirtuosoScrollbars( ref: Ref, ) { return ( -
}> +
}> {children} ); diff --git a/apps/meteor/client/components/FilterByText.tsx b/apps/meteor/client/components/FilterByText.tsx index 0f317dea61e4d..1aeeb29a0a57a 100644 --- a/apps/meteor/client/components/FilterByText.tsx +++ b/apps/meteor/client/components/FilterByText.tsx @@ -54,6 +54,7 @@ const FilterByText = forwardRef(function Fi value={text} flexGrow={2} minWidth='x220' + aria-label={placeholder ?? t('Search')} /> {isFilterByTextPropsWithButton(props) ? ( diff --git a/apps/meteor/client/components/GenericCard/GenericCard.tsx b/apps/meteor/client/components/GenericCard/GenericCard.tsx index 335b8e6be959c..48e1d77d63fb4 100644 --- a/apps/meteor/client/components/GenericCard/GenericCard.tsx +++ b/apps/meteor/client/components/GenericCard/GenericCard.tsx @@ -13,7 +13,7 @@ type GenericCardProps = { type?: 'info' | 'success' | 'warning' | 'danger' | 'neutral'; } & ComponentProps; -export const GenericCard: React.FC = ({ title, body, buttons, icon, type, ...props }) => { +export const GenericCard = ({ title, body, buttons, icon, type, ...props }: GenericCardProps) => { const cardId = useUniqueId(); const descriptionId = useUniqueId(); diff --git a/apps/meteor/client/components/GenericMenu/GenericMenu.spec.tsx b/apps/meteor/client/components/GenericMenu/GenericMenu.spec.tsx new file mode 100644 index 0000000000000..99e62bac1a607 --- /dev/null +++ b/apps/meteor/client/components/GenericMenu/GenericMenu.spec.tsx @@ -0,0 +1,60 @@ +import '@testing-library/jest-dom'; +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import React from 'react'; + +import GenericMenu from './GenericMenu'; + +const mockedFunction = jest.fn(); +const regular = { + items: [ + { + id: 'edit', + content: 'Edit', + icon: 'pencil' as const, + onClick: mockedFunction, + }, + ], +}; +const danger = { + items: [ + { + id: 'delete', + content: 'Delete', + icon: 'trash' as const, + onClick: () => null, + variant: 'danger', + }, + ], +}; + +const sections = [regular, danger]; + +describe('Room Actions Menu', () => { + it('should render kebab menu with the list content', async () => { + render(); + + 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(); + + 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(); + + userEvent.click(screen.getByRole('button')); + userEvent.click(screen.getAllByRole('menuitem')[0]); + + expect(mockedFunction).toHaveBeenCalled(); + }); +}); diff --git a/apps/meteor/client/components/GenericMenu/GenericMenuItem.tsx b/apps/meteor/client/components/GenericMenu/GenericMenuItem.tsx index 44feedf861154..c01a64d708a02 100644 --- a/apps/meteor/client/components/GenericMenu/GenericMenuItem.tsx +++ b/apps/meteor/client/components/GenericMenu/GenericMenuItem.tsx @@ -13,6 +13,7 @@ export type GenericMenuItemProps = { description?: ReactNode; gap?: boolean; tooltip?: string; + variant?: string; }; const GenericMenuItem = ({ icon, content, addon, status, gap, tooltip }: GenericMenuItemProps) => ( diff --git a/apps/meteor/client/components/GenericModal/GenericModal.spec.tsx b/apps/meteor/client/components/GenericModal/GenericModal.spec.tsx new file mode 100644 index 0000000000000..0ef7235729c48 --- /dev/null +++ b/apps/meteor/client/components/GenericModal/GenericModal.spec.tsx @@ -0,0 +1,87 @@ +import { useSetModal } from '@rocket.chat/ui-contexts'; +import { act, screen } from '@testing-library/react'; +import { renderHook } from '@testing-library/react-hooks'; +import userEvent from '@testing-library/user-event'; +import type { ReactElement } from 'react'; +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(), { + wrapper: ({ children }) => ( + + {children} + + ), + }); + + act(() => { + setModal(modalElement); + }); + + return { setModal }; +}; + +describe('callbacks', () => { + it('should call onClose callback when dismissed', async () => { + const handleClose = jest.fn(); + + renderModal(); + + expect(await screen.findByRole('heading', { name: 'Modal', exact: true })).toBeInTheDocument(); + + userEvent.keyboard('{Escape}'); + + expect(screen.queryByRole('heading', { name: 'Modal', exact: true })).not.toBeInTheDocument(); + + expect(handleClose).toHaveBeenCalled(); + }); + + it('should NOT call onClose callback when confirmed', async () => { + const handleConfirm = jest.fn(); + const handleClose = jest.fn(); + + const { setModal } = renderModal(); + + expect(await screen.findByRole('heading', { name: 'Modal', exact: true })).toBeInTheDocument(); + + userEvent.click(screen.getByRole('button', { name: 'Ok', exact: true })); + + expect(handleConfirm).toHaveBeenCalled(); + + act(() => { + setModal(null); + }); + + expect(screen.queryByRole('heading', { name: 'Modal', exact: true })).not.toBeInTheDocument(); + + expect(handleClose).not.toHaveBeenCalled(); + }); + + it('should NOT call onClose callback when cancelled', async () => { + const handleCancel = jest.fn(); + const handleClose = jest.fn(); + + const { setModal } = renderModal(); + + expect(await screen.findByRole('heading', { name: 'Modal', exact: true })).toBeInTheDocument(); + + userEvent.click(screen.getByRole('button', { name: 'Cancel', exact: true })); + + expect(handleCancel).toHaveBeenCalled(); + + act(() => { + setModal(null); + }); + + expect(screen.queryByRole('heading', { name: 'Modal', exact: true })).not.toBeInTheDocument(); + + expect(handleClose).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/meteor/client/components/GenericModal/GenericModal.tsx b/apps/meteor/client/components/GenericModal/GenericModal.tsx index 2600a8a313520..d371e1ff4ef2c 100644 --- a/apps/meteor/client/components/GenericModal/GenericModal.tsx +++ b/apps/meteor/client/components/GenericModal/GenericModal.tsx @@ -1,9 +1,9 @@ import { Button, Modal } from '@rocket.chat/fuselage'; -import { useUniqueId } from '@rocket.chat/fuselage-hooks'; +import { useEffectEvent, useUniqueId } from '@rocket.chat/fuselage-hooks'; import type { Keys as IconName } from '@rocket.chat/icons'; import { useTranslation } from '@rocket.chat/ui-contexts'; -import type { FC, ComponentProps, ReactElement, ReactNode } from 'react'; -import React from 'react'; +import type { ComponentProps, ReactElement, ReactNode, ComponentPropsWithoutRef } from 'react'; +import React, { useEffect, useRef } from 'react'; import type { RequiredModalProps } from './withDoNotAskAgain'; import { withDoNotAskAgain } from './withDoNotAskAgain'; @@ -22,7 +22,7 @@ type GenericModalProps = RequiredModalProps & { onCancel?: () => Promise | void; onClose?: () => Promise | void; annotation?: ReactNode; -} & Omit, 'title'>; +} & Omit, 'title'>; const iconMap: Record = { danger: 'modal-warning', @@ -58,7 +58,7 @@ const renderIcon = (icon: GenericModalProps['icon'], variant: VariantType): Reac return icon; }; -const GenericModal: FC = ({ +const GenericModal = ({ variant = 'info', children, cancelText, @@ -74,10 +74,35 @@ const GenericModal: FC = ({ wrapperFunction, annotation, ...props -}) => { +}: GenericModalProps) => { const t = useTranslation(); const genericModalId = useUniqueId(); + const dismissedRef = useRef(true); + + const handleConfirm = useEffectEvent(() => { + dismissedRef.current = false; + onConfirm?.(); + }); + + const handleCancel = useEffectEvent(() => { + dismissedRef.current = false; + onCancel?.(); + }); + + const handleCloseButtonClick = useEffectEvent(() => { + dismissedRef.current = true; + onClose?.(); + }); + + useEffect( + () => () => { + if (!dismissedRef.current) return; + onClose?.(); + }, + [onClose], + ); + return ( @@ -86,7 +111,7 @@ const GenericModal: FC = ({ {tagline && {tagline}} {title ?? t('Are_you_sure')} - + {children} @@ -94,7 +119,7 @@ const GenericModal: FC = ({ {annotation && !dontAskAgain && {annotation}} {onCancel && ( - )} @@ -104,7 +129,7 @@ const GenericModal: FC = ({ )} {!wrapperFunction && onConfirm && ( - )} diff --git a/apps/meteor/client/components/GenericModal/withDoNotAskAgain.tsx b/apps/meteor/client/components/GenericModal/withDoNotAskAgain.tsx index 8d3644e0dc930..e8010bc10d952 100644 --- a/apps/meteor/client/components/GenericModal/withDoNotAskAgain.tsx +++ b/apps/meteor/client/components/GenericModal/withDoNotAskAgain.tsx @@ -1,7 +1,7 @@ import { Box, Label, CheckBox } from '@rocket.chat/fuselage'; import { useUniqueId } from '@rocket.chat/fuselage-hooks'; import { useUserPreference, useTranslation, useEndpoint } from '@rocket.chat/ui-contexts'; -import type { FC, ReactElement, ComponentType } from 'react'; +import type { ReactElement, ComponentType } from 'react'; import React, { useState } from 'react'; import type { DontAskAgainList } from '../../hooks/useDontAskAgain'; @@ -19,10 +19,9 @@ export type RequiredModalProps = { dontAskAgain?: ReactElement; }; -function withDoNotAskAgain( - Component: ComponentType, -): FC> { - const WrappedComponent: FC> = function ({ onConfirm, dontAskAgain, ...props }) { +function withDoNotAskAgain(Component: ComponentType) { + type WrappedComponentProps = DoNotAskAgainProps & Omit; + const WrappedComponent = function ({ onConfirm, dontAskAgain, ...props }: WrappedComponentProps) { const t = useTranslation(); const dontAskAgainId = useUniqueId(); const dontAskAgainList = useUserPreference('dontAskAgainList'); diff --git a/apps/meteor/client/components/GenericTable/GenericTableBody.tsx b/apps/meteor/client/components/GenericTable/GenericTableBody.tsx index 3b68ccff94a1c..0cf7c667192d6 100644 --- a/apps/meteor/client/components/GenericTable/GenericTableBody.tsx +++ b/apps/meteor/client/components/GenericTable/GenericTableBody.tsx @@ -1,5 +1,7 @@ import { TableBody } from '@rocket.chat/fuselage'; -import type { FC, ComponentProps } from 'react'; +import type { ComponentPropsWithoutRef } from 'react'; import React from 'react'; -export const GenericTableBody: FC> = (props) => ; +type GenericTableBodyProps = ComponentPropsWithoutRef; + +export const GenericTableBody = (props: GenericTableBodyProps) => ; diff --git a/apps/meteor/client/components/GenericTable/GenericTableCell.tsx b/apps/meteor/client/components/GenericTable/GenericTableCell.tsx index 8b783c1a7204b..033199156de79 100644 --- a/apps/meteor/client/components/GenericTable/GenericTableCell.tsx +++ b/apps/meteor/client/components/GenericTable/GenericTableCell.tsx @@ -1,5 +1,7 @@ import { TableCell } from '@rocket.chat/fuselage'; -import type { ComponentProps, FC } from 'react'; +import type { ComponentPropsWithoutRef } from 'react'; import React from 'react'; -export const GenericTableCell: FC> = (props) => ; +type GenericTableCellProps = ComponentPropsWithoutRef; + +export const GenericTableCell = (props: GenericTableCellProps) => ; diff --git a/apps/meteor/client/components/GenericTable/GenericTableHeader.tsx b/apps/meteor/client/components/GenericTable/GenericTableHeader.tsx index 2dbbaade6487c..f8aefa66aced3 100644 --- a/apps/meteor/client/components/GenericTable/GenericTableHeader.tsx +++ b/apps/meteor/client/components/GenericTable/GenericTableHeader.tsx @@ -1,10 +1,12 @@ import { TableHead } from '@rocket.chat/fuselage'; -import type { FC, ComponentProps } from 'react'; +import type { ComponentPropsWithoutRef } from 'react'; import React from 'react'; import { GenericTableRow } from './GenericTableRow'; -export const GenericTableHeader: FC> = ({ children, ...props }) => ( +type GenericTableHeaderProps = ComponentPropsWithoutRef; + +export const GenericTableHeader = ({ children, ...props }: GenericTableHeaderProps) => ( {children} diff --git a/apps/meteor/client/components/GenericTable/GenericTableRow.tsx b/apps/meteor/client/components/GenericTable/GenericTableRow.tsx index 6db18a8bfd1fa..491d6a75329a3 100644 --- a/apps/meteor/client/components/GenericTable/GenericTableRow.tsx +++ b/apps/meteor/client/components/GenericTable/GenericTableRow.tsx @@ -1,5 +1,7 @@ import { TableRow } from '@rocket.chat/fuselage'; -import type { ComponentProps, FC } from 'react'; +import type { ComponentPropsWithoutRef } from 'react'; import React from 'react'; -export const GenericTableRow: FC> = (props) => ; +type GenericTableRowProps = ComponentPropsWithoutRef; + +export const GenericTableRow = (props: GenericTableRowProps) => ; diff --git a/apps/meteor/client/components/GenericTable/SortIcon.tsx b/apps/meteor/client/components/GenericTable/SortIcon.tsx index a3e138c54c55f..0968252f7beb3 100644 --- a/apps/meteor/client/components/GenericTable/SortIcon.tsx +++ b/apps/meteor/client/components/GenericTable/SortIcon.tsx @@ -1,12 +1,11 @@ import { Box } from '@rocket.chat/fuselage'; -import type { FC } from 'react'; import React from 'react'; type SortIconProps = { direction?: 'asc' | 'desc'; }; -const SortIcon: FC = ({ direction }) => ( +const SortIcon = ({ direction }: SortIconProps) => ( ) => ( + + + + + + + + +); + +export default memo(Header); diff --git a/apps/meteor/client/components/Header/HeaderAvatar.tsx b/apps/meteor/client/components/Header/HeaderAvatar.tsx new file mode 100644 index 0000000000000..0c1c3665f823d --- /dev/null +++ b/apps/meteor/client/components/Header/HeaderAvatar.tsx @@ -0,0 +1,22 @@ +import { + FeaturePreview, + FeaturePreviewOff, + FeaturePreviewOn, + HeaderV2Avatar, + HeaderAvatar as HeaderAvatarComponent, +} from '@rocket.chat/ui-client'; +import type { ComponentProps } from 'react'; +import React, { memo } from 'react'; + +const HeaderAvatar = (props: ComponentProps) => ( + + + + + + + + +); + +export default memo(HeaderAvatar); diff --git a/apps/meteor/client/components/Header/HeaderContent.tsx b/apps/meteor/client/components/Header/HeaderContent.tsx new file mode 100644 index 0000000000000..622c85bf6baef --- /dev/null +++ b/apps/meteor/client/components/Header/HeaderContent.tsx @@ -0,0 +1,22 @@ +import { + FeaturePreview, + FeaturePreviewOff, + FeaturePreviewOn, + HeaderV2Content, + HeaderContent as HeaderContentComponent, +} from '@rocket.chat/ui-client'; +import type { ComponentProps } from 'react'; +import React, { memo } from 'react'; + +const HeaderContent = (props: ComponentProps) => ( + + + + + + + + +); + +export default memo(HeaderContent); diff --git a/apps/meteor/client/components/Header/HeaderContentRow.tsx b/apps/meteor/client/components/Header/HeaderContentRow.tsx new file mode 100644 index 0000000000000..4ab684ce23a0c --- /dev/null +++ b/apps/meteor/client/components/Header/HeaderContentRow.tsx @@ -0,0 +1,22 @@ +import { + FeaturePreview, + FeaturePreviewOff, + FeaturePreviewOn, + HeaderV2ContentRow, + HeaderContentRow as HeaderContentRowComponent, +} from '@rocket.chat/ui-client'; +import type { ComponentProps } from 'react'; +import React, { memo } from 'react'; + +const HeaderContentRow = (props: ComponentProps) => ( + + + + + + + + +); + +export default memo(HeaderContentRow); diff --git a/apps/meteor/client/components/Header/HeaderDivider.tsx b/apps/meteor/client/components/Header/HeaderDivider.tsx new file mode 100644 index 0000000000000..22861846852fa --- /dev/null +++ b/apps/meteor/client/components/Header/HeaderDivider.tsx @@ -0,0 +1,21 @@ +import { + FeaturePreview, + FeaturePreviewOff, + FeaturePreviewOn, + HeaderV2Divider, + HeaderDivider as HeaderDividerComponent, +} from '@rocket.chat/ui-client'; +import React, { memo } from 'react'; + +const HeaderDivider = () => ( + + + + + + + + +); + +export default memo(HeaderDivider); diff --git a/apps/meteor/client/components/Header/HeaderIcon.tsx b/apps/meteor/client/components/Header/HeaderIcon.tsx new file mode 100644 index 0000000000000..abcdba673fb0c --- /dev/null +++ b/apps/meteor/client/components/Header/HeaderIcon.tsx @@ -0,0 +1,22 @@ +import { + FeaturePreview, + FeaturePreviewOff, + FeaturePreviewOn, + HeaderV2Icon, + HeaderIcon as HeaderIconComponent, +} from '@rocket.chat/ui-client'; +import type { ComponentProps } from 'react'; +import React, { memo } from 'react'; + +const HeaderIcon = (props: ComponentProps) => ( + + + + + + + + +); + +export default memo(HeaderIcon); diff --git a/apps/meteor/client/components/Header/HeaderState.tsx b/apps/meteor/client/components/Header/HeaderState.tsx new file mode 100644 index 0000000000000..fee88b64b4e79 --- /dev/null +++ b/apps/meteor/client/components/Header/HeaderState.tsx @@ -0,0 +1,22 @@ +import { + FeaturePreview, + FeaturePreviewOff, + FeaturePreviewOn, + HeaderV2State, + HeaderState as HeaderStateComponent, +} from '@rocket.chat/ui-client'; +import type { ComponentProps } from 'react'; +import React, { memo } from 'react'; + +const HeaderState = (props: ComponentProps) => ( + + + + + + + + +); + +export default memo(HeaderState); diff --git a/apps/meteor/client/components/Header/HeaderSubtitle.tsx b/apps/meteor/client/components/Header/HeaderSubtitle.tsx new file mode 100644 index 0000000000000..f23db95f3ee1e --- /dev/null +++ b/apps/meteor/client/components/Header/HeaderSubtitle.tsx @@ -0,0 +1,22 @@ +import { + FeaturePreview, + FeaturePreviewOff, + FeaturePreviewOn, + HeaderV2Subtitle, + HeaderSubtitle as HeaderSubtitleComponent, +} from '@rocket.chat/ui-client'; +import type { ComponentProps } from 'react'; +import React, { memo } from 'react'; + +const HeaderSubtitle = (props: ComponentProps) => ( + + + + + + + + +); + +export default memo(HeaderSubtitle); diff --git a/apps/meteor/client/components/Header/HeaderTag.tsx b/apps/meteor/client/components/Header/HeaderTag.tsx new file mode 100644 index 0000000000000..ae3332f2246a3 --- /dev/null +++ b/apps/meteor/client/components/Header/HeaderTag.tsx @@ -0,0 +1,16 @@ +import { FeaturePreview, FeaturePreviewOff, FeaturePreviewOn, HeaderV2Tag, HeaderTag as HeaderTagComponent } from '@rocket.chat/ui-client'; +import type { ComponentProps } from 'react'; +import React, { memo } from 'react'; + +const HeaderTag = (props: ComponentProps) => ( + + + + + + + + +); + +export default memo(HeaderTag); diff --git a/apps/meteor/client/components/Header/HeaderTagIcon.tsx b/apps/meteor/client/components/Header/HeaderTagIcon.tsx new file mode 100644 index 0000000000000..c0fe4d086eca0 --- /dev/null +++ b/apps/meteor/client/components/Header/HeaderTagIcon.tsx @@ -0,0 +1,22 @@ +import { + FeaturePreview, + FeaturePreviewOff, + FeaturePreviewOn, + HeaderV2TagIcon, + HeaderTagIcon as HeaderTagIconComponent, +} from '@rocket.chat/ui-client'; +import type { ComponentProps } from 'react'; +import React, { memo } from 'react'; + +const HeaderTagIcon = (props: ComponentProps) => ( + + + + + + + + +); + +export default memo(HeaderTagIcon); diff --git a/apps/meteor/client/components/Header/HeaderTagSkeleton.tsx b/apps/meteor/client/components/Header/HeaderTagSkeleton.tsx new file mode 100644 index 0000000000000..40d4dfbf59e8e --- /dev/null +++ b/apps/meteor/client/components/Header/HeaderTagSkeleton.tsx @@ -0,0 +1,21 @@ +import { + FeaturePreview, + FeaturePreviewOff, + FeaturePreviewOn, + HeaderV2TagSkeleton, + HeaderTagSkeleton as HeaderTagSkeletonComponent, +} from '@rocket.chat/ui-client'; +import React, { memo } from 'react'; + +const HeaderTagSkeleton = () => ( + + + + + + + + +); + +export default memo(HeaderTagSkeleton); diff --git a/apps/meteor/client/components/Header/HeaderTitle.tsx b/apps/meteor/client/components/Header/HeaderTitle.tsx new file mode 100644 index 0000000000000..f5f2944781b5a --- /dev/null +++ b/apps/meteor/client/components/Header/HeaderTitle.tsx @@ -0,0 +1,22 @@ +import { + FeaturePreview, + FeaturePreviewOff, + FeaturePreviewOn, + HeaderV2Title, + HeaderTitle as HeaderTitleComponent, +} from '@rocket.chat/ui-client'; +import type { ComponentProps } from 'react'; +import React, { memo } from 'react'; + +const HeaderTitle = (props: ComponentProps) => ( + + + + + + + + +); + +export default memo(HeaderTitle); diff --git a/apps/meteor/client/components/Header/HeaderTitleButton.tsx b/apps/meteor/client/components/Header/HeaderTitleButton.tsx new file mode 100644 index 0000000000000..099bfb13fdd39 --- /dev/null +++ b/apps/meteor/client/components/Header/HeaderTitleButton.tsx @@ -0,0 +1,22 @@ +import { + FeaturePreview, + FeaturePreviewOff, + FeaturePreviewOn, + HeaderV2TitleButton, + HeaderTitleButton as HeaderTitleButtonComponent, +} from '@rocket.chat/ui-client'; +import type { ComponentProps } from 'react'; +import React, { memo } from 'react'; + +const HeaderTitleButton = (props: ComponentProps) => ( + + + + + + + + +); + +export default memo(HeaderTitleButton); diff --git a/apps/meteor/client/components/Header/HeaderToolbar.tsx b/apps/meteor/client/components/Header/HeaderToolbar.tsx new file mode 100644 index 0000000000000..f0eccfda04013 --- /dev/null +++ b/apps/meteor/client/components/Header/HeaderToolbar.tsx @@ -0,0 +1,22 @@ +import { + FeaturePreview, + FeaturePreviewOff, + FeaturePreviewOn, + HeaderV2Toolbar, + HeaderToolbar as HeaderToolbarComponent, +} from '@rocket.chat/ui-client'; +import type { ComponentProps } from 'react'; +import React, { memo } from 'react'; + +const HeaderToolbar = (props: ComponentProps) => ( + + + + + + + + +); + +export default memo(HeaderToolbar); diff --git a/apps/meteor/client/components/Header/HeaderToolbarAction.tsx b/apps/meteor/client/components/Header/HeaderToolbarAction.tsx new file mode 100644 index 0000000000000..bbf296ff23e1d --- /dev/null +++ b/apps/meteor/client/components/Header/HeaderToolbarAction.tsx @@ -0,0 +1,27 @@ +import { + FeaturePreview, + FeaturePreviewOff, + FeaturePreviewOn, + HeaderV2ToolbarAction, + HeaderToolbarAction as HeaderToolbarActionComponent, +} from '@rocket.chat/ui-client'; +import type { ComponentProps } from 'react'; +import React, { forwardRef, memo } from 'react'; + +const HeaderToolbarAction = forwardRef>(function HeaderToolbarAction( + props, + ref, +) { + return ( + + + + + + + + + ); +}); + +export default memo(HeaderToolbarAction); diff --git a/apps/meteor/client/components/Header/HeaderToolbarActionBadge.tsx b/apps/meteor/client/components/Header/HeaderToolbarActionBadge.tsx new file mode 100644 index 0000000000000..67aae03729f99 --- /dev/null +++ b/apps/meteor/client/components/Header/HeaderToolbarActionBadge.tsx @@ -0,0 +1,22 @@ +import { + FeaturePreview, + FeaturePreviewOff, + FeaturePreviewOn, + HeaderV2ToolbarActionBadge, + HeaderToolbarActionBadge as HeaderToolbarActionBadgeComponent, +} from '@rocket.chat/ui-client'; +import type { ComponentProps } from 'react'; +import React, { memo } from 'react'; + +const HeaderToolbarActionBadge = (props: ComponentProps) => ( + + + + + + + + +); + +export default memo(HeaderToolbarActionBadge); diff --git a/apps/meteor/client/components/Header/HeaderToolbarDivider.tsx b/apps/meteor/client/components/Header/HeaderToolbarDivider.tsx new file mode 100644 index 0000000000000..5986671ec8362 --- /dev/null +++ b/apps/meteor/client/components/Header/HeaderToolbarDivider.tsx @@ -0,0 +1,21 @@ +import { + FeaturePreview, + FeaturePreviewOff, + FeaturePreviewOn, + HeaderV2ToolbarDivider, + HeaderToolbarDivider as HeaderToolbarDividerComponent, +} from '@rocket.chat/ui-client'; +import React, { memo } from 'react'; + +const HeaderToolbarDivider = () => ( + + + + + + + + +); + +export default memo(HeaderToolbarDivider); diff --git a/apps/meteor/client/components/Header/index.ts b/apps/meteor/client/components/Header/index.ts new file mode 100644 index 0000000000000..be01ea638c980 --- /dev/null +++ b/apps/meteor/client/components/Header/index.ts @@ -0,0 +1,37 @@ +import Header from './Header'; +import HeaderAvatar from './HeaderAvatar'; +import HeaderContent from './HeaderContent'; +import HeaderContentRow from './HeaderContentRow'; +import HeaderDivider from './HeaderDivider'; +import HeaderIcon from './HeaderIcon'; +import HeaderState from './HeaderState'; +import HeaderSubtitle from './HeaderSubtitle'; +import HeaderTag from './HeaderTag'; +import HeaderTagIcon from './HeaderTagIcon'; +import HeaderTagSkeleton from './HeaderTagSkeleton'; +import HeaderTitle from './HeaderTitle'; +import HeaderTitleButton from './HeaderTitleButton'; +import HeaderToolbar from './HeaderToolbar'; +import HeaderToolbarAction from './HeaderToolbarAction'; +import HeaderToolbarActionBadge from './HeaderToolbarActionBadge'; +import HeaderToolbarDivider from './HeaderToolbarDivider'; + +export { + Header, + HeaderAvatar, + HeaderContent, + HeaderContentRow, + HeaderDivider, + HeaderIcon, + HeaderState, + HeaderSubtitle, + HeaderTag, + HeaderTagIcon, + HeaderTagSkeleton, + HeaderTitle, + HeaderTitleButton, + HeaderToolbar, + HeaderToolbarAction, + HeaderToolbarActionBadge, + HeaderToolbarDivider, +}; diff --git a/apps/meteor/client/components/InfoPanel/InfoPanel.stories.tsx b/apps/meteor/client/components/InfoPanel/InfoPanel.stories.tsx index 1d8987995e8cf..b94706b428dec 100644 --- a/apps/meteor/client/components/InfoPanel/InfoPanel.stories.tsx +++ b/apps/meteor/client/components/InfoPanel/InfoPanel.stories.tsx @@ -1,7 +1,17 @@ import type { ComponentMeta, ComponentStory } from '@storybook/react'; import React from 'react'; -import InfoPanel from '.'; +import { + InfoPanel, + InfoPanelAction, + InfoPanelActionGroup, + InfoPanelAvatar, + InfoPanelField, + InfoPanelLabel, + InfoPanelSection, + InfoPanelText, + InfoPanelTitle, +} from '.'; import { createFakeRoom } from '../../../tests/mocks/data'; import RetentionPolicyCallout from './RetentionPolicyCallout'; @@ -9,14 +19,14 @@ export default { title: 'Info Panel/InfoPanel', component: InfoPanel, subcomponents: { - 'InfoPanel.Action': InfoPanel.Action, - 'InfoPanel.ActionGroup': InfoPanel.ActionGroup, - 'InfoPanel.Avatar': InfoPanel.Avatar, - 'InfoPanel.Field': InfoPanel.Field, - 'InfoPanel.Label': InfoPanel.Label, - 'InfoPanel.Section': InfoPanel.Section, - 'InfoPanel.Text': InfoPanel.Text, - 'InfoPanel.Title': InfoPanel.Title, + InfoPanelAction, + InfoPanelActionGroup, + InfoPanelAvatar, + InfoPanelField, + InfoPanelLabel, + InfoPanelSection, + InfoPanelText, + InfoPanelTitle, RetentionPolicyCallout, }, } as ComponentMeta; @@ -25,62 +35,37 @@ const fakeRoom = createFakeRoom(); export const Default: ComponentStory = () => ( - - - - + + + + - - - Description - + + + Description + Lorem ipsum dolor sit amet, consectetur adipiscing elit. Aliquam mollis nisi vel arcu bibendum vehicula. Integer vitae suscipit libero - - - - Announcement - + + + + Announcement + Lorem ipsum dolor sit amet, consectetur adipiscing elit. Aliquam mollis nisi vel arcu bibendum vehicula. Integer vitae suscipit libero - - - - Topic - + + + + Topic + Lorem ipsum dolor sit amet, consectetur adipiscing elit. Aliquam mollis nisi vel arcu bibendum vehicula. Integer vitae suscipit libero - - - - - + + + + - + ); Default.storyName = 'InfoPanel'; - -// export const Archived = () => -// -// ; - -// export const Broadcast = () => -// -// ; diff --git a/apps/meteor/client/components/InfoPanel/InfoPanel.tsx b/apps/meteor/client/components/InfoPanel/InfoPanel.tsx index 6e3fe6c009384..601fdca2ff7c7 100644 --- a/apps/meteor/client/components/InfoPanel/InfoPanel.tsx +++ b/apps/meteor/client/components/InfoPanel/InfoPanel.tsx @@ -1,8 +1,12 @@ import { Box } from '@rocket.chat/fuselage'; -import type { FC } from 'react'; +import type { ReactNode } from 'react'; import React from 'react'; -const InfoPanel: FC = ({ children }) => ( +type InfoPanelProps = { + children?: ReactNode; +}; + +const InfoPanel = ({ children }: InfoPanelProps) => ( {children} diff --git a/apps/meteor/client/components/InfoPanel/InfoPanelActionGroup.tsx b/apps/meteor/client/components/InfoPanel/InfoPanelActionGroup.tsx index 00af64b0fa618..817cb0f3d9b9a 100644 --- a/apps/meteor/client/components/InfoPanel/InfoPanelActionGroup.tsx +++ b/apps/meteor/client/components/InfoPanel/InfoPanelActionGroup.tsx @@ -1,10 +1,12 @@ import { ButtonGroup } from '@rocket.chat/fuselage'; -import type { ComponentProps, FC } from 'react'; +import type { ComponentPropsWithoutRef } from 'react'; import React from 'react'; import Section from './InfoPanelSection'; -const InfoPanelActionGroup: FC> = (props) => ( +type InfoPanelActionGroupProps = ComponentPropsWithoutRef; + +const InfoPanelActionGroup = (props: InfoPanelActionGroupProps) => (
diff --git a/apps/meteor/client/components/InfoPanel/InfoPanelAvatar.tsx b/apps/meteor/client/components/InfoPanel/InfoPanelAvatar.tsx index 2e4f36601318a..1daeee307aa63 100644 --- a/apps/meteor/client/components/InfoPanel/InfoPanelAvatar.tsx +++ b/apps/meteor/client/components/InfoPanel/InfoPanelAvatar.tsx @@ -1,9 +1,13 @@ -import type { FC } from 'react'; +import type { ReactNode } from 'react'; import React from 'react'; import Section from './InfoPanelSection'; -const InfoPanelAvatar: FC = ({ children }) => ( +type InfoPanelAvatarProps = { + children?: ReactNode; +}; + +const InfoPanelAvatar = ({ children }: InfoPanelAvatarProps) => (
{children}
diff --git a/apps/meteor/client/components/InfoPanel/InfoPanelField.tsx b/apps/meteor/client/components/InfoPanel/InfoPanelField.tsx index 982e6ab8e25d5..257767e42170b 100644 --- a/apps/meteor/client/components/InfoPanel/InfoPanelField.tsx +++ b/apps/meteor/client/components/InfoPanel/InfoPanelField.tsx @@ -1,7 +1,11 @@ import { Box } from '@rocket.chat/fuselage'; -import type { FC } from 'react'; +import type { ReactNode } from 'react'; import React from 'react'; -const InfoPanelField: FC = ({ children }) => {children}; +type InfoPanelFieldProps = { + children?: ReactNode; +}; + +const InfoPanelField = ({ children }: InfoPanelFieldProps) => {children}; export default InfoPanelField; diff --git a/apps/meteor/client/components/InfoPanel/InfoPanelLabel.tsx b/apps/meteor/client/components/InfoPanel/InfoPanelLabel.tsx index 77450ea0e9ea5..701c2700a802b 100644 --- a/apps/meteor/client/components/InfoPanel/InfoPanelLabel.tsx +++ b/apps/meteor/client/components/InfoPanel/InfoPanelLabel.tsx @@ -1,7 +1,9 @@ import { Box } from '@rocket.chat/fuselage'; -import type { ComponentProps, FC } from 'react'; +import type { ComponentPropsWithoutRef } from 'react'; import React from 'react'; -const InfoPanelLabel: FC> = (props) => ; +type InfoPanelLabelProps = ComponentPropsWithoutRef; + +const InfoPanelLabel = (props: InfoPanelLabelProps) => ; export default InfoPanelLabel; diff --git a/apps/meteor/client/components/InfoPanel/InfoPanelSection.tsx b/apps/meteor/client/components/InfoPanel/InfoPanelSection.tsx index 7db13dad751f9..336b7a9fcb3ac 100644 --- a/apps/meteor/client/components/InfoPanel/InfoPanelSection.tsx +++ b/apps/meteor/client/components/InfoPanel/InfoPanelSection.tsx @@ -1,7 +1,9 @@ import { Box } from '@rocket.chat/fuselage'; -import type { ComponentProps, FC } from 'react'; +import type { ComponentPropsWithoutRef } from 'react'; import React from 'react'; -const InfoPanelSection: FC> = (props) => ; +type InfoPanelSectionProps = ComponentPropsWithoutRef; + +const InfoPanelSection = (props: InfoPanelSectionProps) => ; export default InfoPanelSection; diff --git a/apps/meteor/client/components/InfoPanel/InfoPanelText.tsx b/apps/meteor/client/components/InfoPanel/InfoPanelText.tsx index 7b82d0d02aa01..0bc30b8995d57 100644 --- a/apps/meteor/client/components/InfoPanel/InfoPanelText.tsx +++ b/apps/meteor/client/components/InfoPanel/InfoPanelText.tsx @@ -1,14 +1,14 @@ import { css } from '@rocket.chat/css-in-js'; import { Box } from '@rocket.chat/fuselage'; -import type { ComponentProps, FC } from 'react'; +import type { ComponentPropsWithoutRef } from 'react'; import React from 'react'; const wordBreak = css` word-break: break-word; `; -const InfoPanelText: FC> = (props) => ( - -); +type InfoPanelTextProps = ComponentPropsWithoutRef; + +const InfoPanelText = (props: InfoPanelTextProps) => ; export default InfoPanelText; diff --git a/apps/meteor/client/components/InfoPanel/InfoPanelTitle.tsx b/apps/meteor/client/components/InfoPanel/InfoPanelTitle.tsx index 7ea4de6d98679..a79aa583d1ec1 100644 --- a/apps/meteor/client/components/InfoPanel/InfoPanelTitle.tsx +++ b/apps/meteor/client/components/InfoPanel/InfoPanelTitle.tsx @@ -1,6 +1,6 @@ import { Box, Icon } from '@rocket.chat/fuselage'; import type { Keys as IconName } from '@rocket.chat/icons'; -import type { FC, ReactNode } from 'react'; +import type { ReactNode } from 'react'; import React from 'react'; type InfoPanelTitleProps = { @@ -10,7 +10,7 @@ type InfoPanelTitleProps = { const isValidIcon = (icon: ReactNode): icon is IconName => typeof icon === 'string'; -const InfoPanelTitle: FC = ({ title, icon }) => ( +const InfoPanelTitle = ({ title, icon }: InfoPanelTitleProps) => ( {isValidIcon(icon) ? : icon} diff --git a/apps/meteor/client/components/InfoPanel/index.ts b/apps/meteor/client/components/InfoPanel/index.ts index b2656d62ef373..6b93875224385 100644 --- a/apps/meteor/client/components/InfoPanel/index.ts +++ b/apps/meteor/client/components/InfoPanel/index.ts @@ -1,20 +1,9 @@ -import InfoPanel from './InfoPanel'; -import InfoPanelAction from './InfoPanelAction'; -import InfoPanelActionGroup from './InfoPanelActionGroup'; -import InfoPanelAvatar from './InfoPanelAvatar'; -import InfoPanelField from './InfoPanelField'; -import InfoPanelLabel from './InfoPanelLabel'; -import InfoPanelSection from './InfoPanelSection'; -import InfoPanelText from './InfoPanelText'; -import InfoPanelTitle from './InfoPanelTitle'; - -export default Object.assign(InfoPanel, { - Title: InfoPanelTitle, - Label: InfoPanelLabel, - Text: InfoPanelText, - Avatar: InfoPanelAvatar, - Field: InfoPanelField, - Action: InfoPanelAction, - Section: InfoPanelSection, - ActionGroup: InfoPanelActionGroup, -}); +export { default as InfoPanel } from './InfoPanel'; +export { default as InfoPanelAction } from './InfoPanelAction'; +export { default as InfoPanelActionGroup } from './InfoPanelActionGroup'; +export { default as InfoPanelAvatar } from './InfoPanelAvatar'; +export { default as InfoPanelField } from './InfoPanelField'; +export { default as InfoPanelLabel } from './InfoPanelLabel'; +export { default as InfoPanelSection } from './InfoPanelSection'; +export { default as InfoPanelText } from './InfoPanelText'; +export { default as InfoPanelTitle } from './InfoPanelTitle'; diff --git a/apps/meteor/client/components/MarkdownText.tsx b/apps/meteor/client/components/MarkdownText.tsx index 51e499f91d8f4..c9af942f6e1c2 100644 --- a/apps/meteor/client/components/MarkdownText.tsx +++ b/apps/meteor/client/components/MarkdownText.tsx @@ -3,7 +3,7 @@ import { isExternal, getBaseURI } from '@rocket.chat/ui-client'; import { useTranslation } from '@rocket.chat/ui-contexts'; import dompurify from 'dompurify'; import { marked } from 'marked'; -import type { ComponentProps, FC } from 'react'; +import type { ComponentProps } from 'react'; import React, { useMemo } from 'react'; import { renderMessageEmoji } from '../lib/utils/renderMessageEmoji'; @@ -78,14 +78,16 @@ const getRegexp = (schemeSetting: string): RegExp => { return new RegExp(`^(${schemes}):`, 'gim'); }; -const MarkdownText: FC> = ({ +type MarkdownTextProps = Partial; + +const MarkdownText = ({ content, variant = 'document', withTruncatedText = false, preserveHtml = false, parseEmoji = false, ...props -}) => { +}: MarkdownTextProps) => { const sanitizer = dompurify.sanitize; const t = useTranslation(); let markedOptions: marked.MarkedOptions; diff --git a/apps/meteor/client/components/Navbar/Navbar.tsx b/apps/meteor/client/components/Navbar/Navbar.tsx index 2963f06ae494d..c066a314f34f3 100644 --- a/apps/meteor/client/components/Navbar/Navbar.tsx +++ b/apps/meteor/client/components/Navbar/Navbar.tsx @@ -1,8 +1,12 @@ import { Box, ButtonGroup } from '@rocket.chat/fuselage'; -import type { FC } from 'react'; +import type { ReactNode } from 'react'; import React from 'react'; -export const Navbar: FC = ({ children }) => { +type NavbarProps = { + children?: ReactNode; +}; + +export const Navbar = ({ children }: NavbarProps) => { return ( diff --git a/apps/meteor/client/components/Navbar/NavbarAction.tsx b/apps/meteor/client/components/Navbar/NavbarAction.tsx index 470f754d861a9..88392b42aa491 100644 --- a/apps/meteor/client/components/Navbar/NavbarAction.tsx +++ b/apps/meteor/client/components/Navbar/NavbarAction.tsx @@ -1,7 +1,9 @@ -import type { FC } from 'react'; +import type { HTMLAttributes } from 'react'; import React from 'react'; -export const NavbarAction: FC = ({ children, ...props }) => { +type NavbarActionProps = HTMLAttributes; + +export const NavbarAction = ({ children, ...props }: NavbarActionProps) => { return (
  • {children} diff --git a/apps/meteor/client/components/Omnichannel/Skeleton.tsx b/apps/meteor/client/components/Omnichannel/Skeleton.tsx index ebbd07d361e7d..354d0e1790d33 100644 --- a/apps/meteor/client/components/Omnichannel/Skeleton.tsx +++ b/apps/meteor/client/components/Omnichannel/Skeleton.tsx @@ -1,8 +1,10 @@ import { Box, Skeleton } from '@rocket.chat/fuselage'; -import type { FC } from 'react'; +import type { ComponentPropsWithoutRef } from 'react'; import React from 'react'; -export const FormSkeleton: FC = (props) => ( +type FormSkeletonProps = ComponentPropsWithoutRef; + +export const FormSkeleton = (props: FormSkeletonProps) => ( diff --git a/apps/meteor/client/components/Omnichannel/modals/CloseChatModal.tsx b/apps/meteor/client/components/Omnichannel/modals/CloseChatModal.tsx index 401448ceb3966..7c028fb5c876a 100644 --- a/apps/meteor/client/components/Omnichannel/modals/CloseChatModal.tsx +++ b/apps/meteor/client/components/Omnichannel/modals/CloseChatModal.tsx @@ -52,6 +52,8 @@ const CloseChatModal = ({ } = useForm(); const commentRequired = useSetting('Livechat_request_comment_when_closing_conversation') as boolean; + const alwaysSendTranscript = useSetting('Livechat_transcript_send_always'); + const customSubject = useSetting('Livechat_transcript_email_subject'); const [tagRequired, setTagRequired] = useState(false); const tags = watch('tags'); @@ -65,7 +67,7 @@ const CloseChatModal = ({ const transcriptPDFPermission = usePermission('request-pdf-transcript'); const transcriptEmailPermission = usePermission('send-omnichannel-chat-transcript'); - const canSendTranscriptEmail = transcriptEmailPermission && visitorEmail; + const canSendTranscriptEmail = transcriptEmailPermission && visitorEmail && !alwaysSendTranscript; const canSendTranscriptPDF = transcriptPDFPermission && hasLicense; const canSendTranscript = canSendTranscriptEmail || canSendTranscriptPDF; @@ -77,7 +79,7 @@ const CloseChatModal = ({ ({ comment, tags, transcriptPDF, transcriptEmail, subject }): void => { const preferences = { omnichannelTranscriptPDF: !!transcriptPDF, - omnichannelTranscriptEmail: !!transcriptEmail, + omnichannelTranscriptEmail: alwaysSendTranscript ? true : !!transcriptEmail, }; const requestData = transcriptEmail && visitorEmail ? { email: visitorEmail, subject } : undefined; @@ -97,7 +99,7 @@ const CloseChatModal = ({ onConfirm(comment, tags, preferences, requestData); } }, - [commentRequired, tagRequired, visitorEmail, errors, setError, t, onConfirm], + [commentRequired, tagRequired, visitorEmail, errors, setError, t, onConfirm, alwaysSendTranscript], ); const cannotSubmit = useMemo(() => { @@ -132,9 +134,9 @@ const CloseChatModal = ({ dispatchToastMessage({ type: 'error', message: t('Customer_without_registered_email') }); return; } - setValue('subject', subject || t('Transcript_of_your_livechat_conversation')); + setValue('subject', subject || customSubject || t('Transcript_of_your_livechat_conversation')); } - }, [transcriptEmail, setValue, visitorEmail, subject, t]); + }, [transcriptEmail, setValue, visitorEmail, subject, t, customSubject]); if (commentRequired || tagRequired || canSendTranscript) { return ( diff --git a/apps/meteor/client/components/Omnichannel/modals/ReturnChatQueueModal.tsx b/apps/meteor/client/components/Omnichannel/modals/ReturnChatQueueModal.tsx index b4f7896186534..04fcb29eed01d 100644 --- a/apps/meteor/client/components/Omnichannel/modals/ReturnChatQueueModal.tsx +++ b/apps/meteor/client/components/Omnichannel/modals/ReturnChatQueueModal.tsx @@ -1,6 +1,5 @@ import { Button, Modal } from '@rocket.chat/fuselage'; import { useTranslation } from '@rocket.chat/ui-contexts'; -import type { FC } from 'react'; import React from 'react'; type ReturnChatQueueModalProps = { @@ -8,7 +7,7 @@ type ReturnChatQueueModalProps = { onCancel: () => void; }; -const ReturnChatQueueModal: FC = ({ onCancel, onMoveChat, ...props }) => { +const ReturnChatQueueModal = ({ onCancel, onMoveChat, ...props }: ReturnChatQueueModalProps) => { const t = useTranslation(); return ( diff --git a/apps/meteor/client/components/Omnichannel/modals/TranscriptModal.tsx b/apps/meteor/client/components/Omnichannel/modals/TranscriptModal.tsx index 0b3a94f5b16ce..95bda1e89107e 100644 --- a/apps/meteor/client/components/Omnichannel/modals/TranscriptModal.tsx +++ b/apps/meteor/client/components/Omnichannel/modals/TranscriptModal.tsx @@ -1,7 +1,6 @@ import type { IOmnichannelRoom } from '@rocket.chat/core-typings'; import { Field, Button, TextInput, Modal, Box, FieldGroup, FieldLabel, FieldRow, FieldError } from '@rocket.chat/fuselage'; import { useTranslation } from '@rocket.chat/ui-contexts'; -import type { FC } from 'react'; import React, { useCallback, useEffect } from 'react'; import { useForm } from 'react-hook-form'; @@ -14,15 +13,7 @@ type TranscriptModalProps = { onDiscard: () => void; }; -const TranscriptModal: FC = ({ - email: emailDefault = '', - room, - onRequest, - onSend, - onCancel, - onDiscard, - ...props -}) => { +const TranscriptModal = ({ email: emailDefault = '', room, onRequest, onSend, onCancel, onDiscard, ...props }: TranscriptModalProps) => { const t = useTranslation(); const { diff --git a/apps/meteor/client/components/Page/PageFooter.tsx b/apps/meteor/client/components/Page/PageFooter.tsx index 1cae326589a4c..459b4b052466f 100644 --- a/apps/meteor/client/components/Page/PageFooter.tsx +++ b/apps/meteor/client/components/Page/PageFooter.tsx @@ -1,10 +1,10 @@ import { AnimatedVisibility, Box } from '@rocket.chat/fuselage'; -import type { FC, ComponentProps } from 'react'; +import type { ComponentProps } from 'react'; import React from 'react'; type PageFooterProps = { isDirty: boolean } & ComponentProps; -const PageFooter: FC = ({ children, isDirty, ...props }) => { +const PageFooter = ({ children, isDirty, ...props }: PageFooterProps) => { return ( diff --git a/apps/meteor/client/components/Page/PageHeader.tsx b/apps/meteor/client/components/Page/PageHeader.tsx index 4549c69dccec9..c6667e4fc5cc9 100644 --- a/apps/meteor/client/components/Page/PageHeader.tsx +++ b/apps/meteor/client/components/Page/PageHeader.tsx @@ -1,9 +1,10 @@ import { Box, IconButton } from '@rocket.chat/fuselage'; -import { HeaderToolbar, useDocumentTitle } from '@rocket.chat/ui-client'; +import { useDocumentTitle } from '@rocket.chat/ui-client'; import { useLayout, useTranslation } from '@rocket.chat/ui-contexts'; -import type { FC, ComponentProps, ReactNode } from 'react'; +import type { ComponentPropsWithoutRef, ReactNode } from 'react'; import React, { useContext } from 'react'; +import { HeaderToolbar } from '../Header'; import SidebarToggler from '../SidebarToggler'; import PageContext from './PageContext'; @@ -11,9 +12,9 @@ type PageHeaderProps = { title: ReactNode; onClickBack?: () => void; borderBlockEndColor?: string; -} & Omit, 'title'>; +} & Omit, 'title'>; -const PageHeader: FC = ({ children = undefined, title, onClickBack, borderBlockEndColor, ...props }) => { +const PageHeader = ({ children = undefined, title, onClickBack, borderBlockEndColor, ...props }: PageHeaderProps) => { const t = useTranslation(); const [border] = useContext(PageContext); const { isMobile } = useLayout(); diff --git a/apps/meteor/client/components/Page/PageScrollableContentWithShadow.tsx b/apps/meteor/client/components/Page/PageScrollableContentWithShadow.tsx index 56590309cac78..dd15dc2c4e4b6 100644 --- a/apps/meteor/client/components/Page/PageScrollableContentWithShadow.tsx +++ b/apps/meteor/client/components/Page/PageScrollableContentWithShadow.tsx @@ -1,12 +1,12 @@ -import type { FC, ComponentProps } from 'react'; +import type { ComponentPropsWithoutRef } from 'react'; import React, { useContext } from 'react'; import PageContext from './PageContext'; import PageScrollableContent from './PageScrollableContent'; -type PageScrollableContentWithShadowProps = ComponentProps; +type PageScrollableContentWithShadowProps = ComponentPropsWithoutRef; -const PageScrollableContentWithShadow: FC = ({ onScrollContent, ...props }) => { +const PageScrollableContentWithShadow = ({ onScrollContent, ...props }: PageScrollableContentWithShadowProps) => { const [, setBorder] = useContext(PageContext); return ( { +type OmnichannelRoomIconProviderProps = { + children?: ReactNode; +}; + +export const OmnichannelRoomIconProvider = ({ children }: OmnichannelRoomIconProviderProps) => { const svgIcons = useSyncExternalStore( useCallback( (callback): (() => void) => diff --git a/apps/meteor/client/components/Sidebar/Content.tsx b/apps/meteor/client/components/Sidebar/Content.tsx index bccdec01b7d9e..5e5fa1c15e81e 100644 --- a/apps/meteor/client/components/Sidebar/Content.tsx +++ b/apps/meteor/client/components/Sidebar/Content.tsx @@ -1,10 +1,12 @@ import { Box } from '@rocket.chat/fuselage'; -import type { FC } from 'react'; +import type { ComponentPropsWithoutRef } from 'react'; import React from 'react'; import { CustomScrollbars } from '../CustomScrollbars'; -const Content: FC = ({ children, ...props }) => ( +type ContentProps = ComponentPropsWithoutRef; + +const Content = ({ children, ...props }: ContentProps) => ( diff --git a/apps/meteor/client/components/Sidebar/Header.tsx b/apps/meteor/client/components/Sidebar/Header.tsx index 4e87a96d6ec56..e4bf5a5e7041f 100644 --- a/apps/meteor/client/components/Sidebar/Header.tsx +++ b/apps/meteor/client/components/Sidebar/Header.tsx @@ -1,14 +1,15 @@ import { Box, IconButton } from '@rocket.chat/fuselage'; import { useTranslation } from '@rocket.chat/ui-contexts'; -import type { FC, ReactNode } from 'react'; +import type { ReactNode } from 'react'; import React from 'react'; type HeaderProps = { + children?: ReactNode; title?: ReactNode; onClose?: () => void; }; -const Header: FC = ({ title, onClose, children, ...props }) => { +const Header = ({ title, onClose, children, ...props }: HeaderProps) => { const t = useTranslation(); return ( diff --git a/apps/meteor/client/components/Sidebar/Sidebar.tsx b/apps/meteor/client/components/Sidebar/Sidebar.tsx index 103c680770c42..a066b4c5fcee5 100644 --- a/apps/meteor/client/components/Sidebar/Sidebar.tsx +++ b/apps/meteor/client/components/Sidebar/Sidebar.tsx @@ -1,9 +1,9 @@ import { Sidebar as FuselageSidebar } from '@rocket.chat/fuselage'; -import type { FC } from 'react'; +import type { ComponentPropsWithoutRef } from 'react'; import React from 'react'; -const Sidebar: FC = ({ children, ...props }) => ( - -); +type SidebarProps = ComponentPropsWithoutRef; + +const Sidebar = (props: SidebarProps) => ; export default Sidebar; diff --git a/apps/meteor/client/components/Sidebar/SidebarItemsAssembler.tsx b/apps/meteor/client/components/Sidebar/SidebarItemsAssembler.tsx index 3f08bcb121cb2..45eb6094572a0 100644 --- a/apps/meteor/client/components/Sidebar/SidebarItemsAssembler.tsx +++ b/apps/meteor/client/components/Sidebar/SidebarItemsAssembler.tsx @@ -1,6 +1,5 @@ import { Divider } from '@rocket.chat/fuselage'; import { useTranslation } from '@rocket.chat/ui-contexts'; -import type { FC } from 'react'; import React, { Fragment, memo } from 'react'; import type { SidebarItem } from '../../lib/createSidebarItems'; @@ -12,7 +11,7 @@ type SidebarItemsAssemblerProps = { currentPath?: string; }; -const SidebarItemsAssembler: FC = ({ items, currentPath }) => { +const SidebarItemsAssembler = ({ items, currentPath }: SidebarItemsAssemblerProps) => { const t = useTranslation(); return ( diff --git a/apps/meteor/client/components/Sidebar/SidebarNavigationItem.tsx b/apps/meteor/client/components/Sidebar/SidebarNavigationItem.tsx index c8cfec4862dab..408741863097c 100644 --- a/apps/meteor/client/components/Sidebar/SidebarNavigationItem.tsx +++ b/apps/meteor/client/components/Sidebar/SidebarNavigationItem.tsx @@ -1,6 +1,6 @@ import { Box, Icon, Tag } from '@rocket.chat/fuselage'; import type { Keys as IconName } from '@rocket.chat/icons'; -import type { FC, ReactElement } from 'react'; +import type { ReactElement } from 'react'; import React, { memo } from 'react'; import SidebarGenericItem from './SidebarGenericItem'; @@ -16,7 +16,7 @@ type SidebarNavigationItemProps = { badge?: () => ReactElement; }; -const SidebarNavigationItem: FC = ({ +const SidebarNavigationItem = ({ permissionGranted, pathSection, icon, @@ -26,7 +26,7 @@ const SidebarNavigationItem: FC = ({ externalUrl, // eslint-disable-next-line @typescript-eslint/naming-convention badge: Badge, -}) => { +}: SidebarNavigationItemProps) => { const path = pathSection; const isActive = !!path && currentPath?.includes(path as string); diff --git a/apps/meteor/client/components/UserInfo/UserInfo.tsx b/apps/meteor/client/components/UserInfo/UserInfo.tsx index a4656373b4885..a7f32f82f4546 100644 --- a/apps/meteor/client/components/UserInfo/UserInfo.tsx +++ b/apps/meteor/client/components/UserInfo/UserInfo.tsx @@ -9,7 +9,16 @@ import { useTimeAgo } from '../../hooks/useTimeAgo'; import { useUserCustomFields } from '../../hooks/useUserCustomFields'; import { useUserDisplayName } from '../../hooks/useUserDisplayName'; import { ContextualbarScrollableContent } from '../Contextualbar'; -import InfoPanel from '../InfoPanel'; +import { + InfoPanel, + InfoPanelActionGroup, + InfoPanelAvatar, + InfoPanelField, + InfoPanelLabel, + InfoPanelSection, + InfoPanelText, + InfoPanelTitle, +} from '../InfoPanel'; import MarkdownText from '../MarkdownText'; import UTCClock from '../UTCClock'; import { UserCardRoles } from '../UserCard'; @@ -72,119 +81,119 @@ const UserInfo = ({ {username && ( - + - + )} - {actions && {actions}} + {actions && {actions}} - - {userDisplayName && } + + {userDisplayName && } {statusText && ( - + - + )} - + - + {reason && ( - - {t('Reason_for_joining')} - {reason} - + + {t('Reason_for_joining')} + {reason} + )} {nickname && ( - - {t('Nickname')} - {nickname} - + + {t('Nickname')} + {nickname} + )} {roles.length !== 0 && ( - - {t('Roles')} + + {t('Roles')} {roles} - + )} {username && username !== name && ( - - {t('Username')} - {username} - + + {t('Username')} + {username} + )} {Number.isInteger(utcOffset) && ( - - {t('Local_Time')} - {utcOffset && } - + + {t('Local_Time')} + {utcOffset && } + )} {bio && ( - - {t('Bio')} - + + {t('Bio')} + - - + + )} {Number.isInteger(utcOffset) && canViewAllInfo && ( - - {t('Last_login')} - {lastLogin ? timeAgo(lastLogin) : t('Never')} - + + {t('Last_login')} + {lastLogin ? timeAgo(lastLogin) : t('Never')} + )} {phone && ( - - {t('Phone')} - + + {t('Phone')} + {phone} - - + + )} {email && ( - - {t('Email')} - + + {t('Email')} + {email} {verified ? t('Verified') : t('Not_verified')} - - + + )} {userCustomFields?.map( (customField) => customField?.value && ( - - {t(customField.label as TranslationKey)} - + + {t(customField.label as TranslationKey)} + - - + + ), )} {createdAt && ( - - {t('Created_at')} - {timeAgo(createdAt)} - + + {t('Created_at')} + {timeAgo(createdAt)} + )} - + ); diff --git a/apps/meteor/client/components/UserInfo/index.ts b/apps/meteor/client/components/UserInfo/index.ts index f8fe955dbe490..cc0d768541fe2 100644 --- a/apps/meteor/client/components/UserInfo/index.ts +++ b/apps/meteor/client/components/UserInfo/index.ts @@ -1,13 +1,4 @@ -import InfoPanel from '../InfoPanel'; -import UserInfo from './UserInfo'; -import UserInfoAction from './UserInfoAction'; -import UserInfoAvatar from './UserInfoAvatar'; -import UserInfoUsername from './UserInfoUsername'; - -export default Object.assign(UserInfo, { - Action: UserInfoAction, - Avatar: UserInfoAvatar, - Info: InfoPanel.Text, - Label: InfoPanel.Label, - Username: UserInfoUsername, -}); +export { default as UserInfo } from './UserInfo'; +export { default as UserInfoAction } from './UserInfoAction'; +export { default as UserInfoAvatar } from './UserInfoAvatar'; +export { default as UserInfoUsername } from './UserInfoUsername'; diff --git a/apps/meteor/client/components/message/content/attachments/default/ActionAttachtment.tsx b/apps/meteor/client/components/message/content/attachments/default/ActionAttachtment.tsx index 99b1f3de72900..2ecd19598b3b8 100644 --- a/apps/meteor/client/components/message/content/attachments/default/ActionAttachtment.tsx +++ b/apps/meteor/client/components/message/content/attachments/default/ActionAttachtment.tsx @@ -1,12 +1,13 @@ import type { MessageAttachmentAction } from '@rocket.chat/core-typings'; import { Box, Button, ButtonGroup } from '@rocket.chat/fuselage'; -import type { FC } from 'react'; import React from 'react'; import { useExternalLink } from '../../../../../hooks/useExternalLink'; import ActionAttachmentButton from './ActionAttachmentButton'; -export const ActionAttachment: FC = ({ actions }) => { +type ActionAttachmentProps = MessageAttachmentAction; + +export const ActionAttachment = ({ actions }: ActionAttachmentProps) => { const handleLinkClick = useExternalLink(); return ( diff --git a/apps/meteor/client/components/message/content/attachments/default/Field.tsx b/apps/meteor/client/components/message/content/attachments/default/Field.tsx index 935e9fb0d9ad8..b64e4123d6cf9 100644 --- a/apps/meteor/client/components/message/content/attachments/default/Field.tsx +++ b/apps/meteor/client/components/message/content/attachments/default/Field.tsx @@ -1,5 +1,5 @@ import { Box } from '@rocket.chat/fuselage'; -import type { ComponentProps, FC, ReactNode } from 'react'; +import type { ComponentProps, ReactNode } from 'react'; import React from 'react'; type FieldProps = { @@ -9,7 +9,7 @@ type FieldProps = { } & Omit, 'title' | 'value'>; // TODO: description missing color token -const Field: FC = ({ title, value, ...props }) => ( +const Field = ({ title, value, ...props }: FieldProps) => ( {title} {value} diff --git a/apps/meteor/client/components/message/content/attachments/default/FieldsAttachment.tsx b/apps/meteor/client/components/message/content/attachments/default/FieldsAttachment.tsx index 5e00092393261..4e6650e5ec98a 100644 --- a/apps/meteor/client/components/message/content/attachments/default/FieldsAttachment.tsx +++ b/apps/meteor/client/components/message/content/attachments/default/FieldsAttachment.tsx @@ -1,17 +1,19 @@ import { Box } from '@rocket.chat/fuselage'; -import type { FC, ReactNode } from 'react'; +import type { ReactNode } from 'react'; import React from 'react'; import Field from './Field'; import ShortField from './ShortField'; type FieldsAttachmentProps = { - short?: boolean; - title: ReactNode; - value: ReactNode; + fields: { + short?: boolean; + title: ReactNode; + value: ReactNode; + }[]; }; -const FieldsAttachment: FC<{ fields: FieldsAttachmentProps[] }> = ({ fields }): any => ( +const FieldsAttachment = ({ fields }: FieldsAttachmentProps) => ( {fields.map((field, index) => (field.short ? : ))} diff --git a/apps/meteor/client/components/message/content/attachments/default/ShortField.tsx b/apps/meteor/client/components/message/content/attachments/default/ShortField.tsx index f9fb7b48a703c..a75c6edc38706 100644 --- a/apps/meteor/client/components/message/content/attachments/default/ShortField.tsx +++ b/apps/meteor/client/components/message/content/attachments/default/ShortField.tsx @@ -1,8 +1,10 @@ -import type { ComponentProps, FC } from 'react'; +import type { ComponentPropsWithoutRef } from 'react'; import React from 'react'; import Field from './Field'; -const ShortField: FC> = (props) => ; +type ShortFieldProps = ComponentPropsWithoutRef; + +const ShortField = (props: ShortFieldProps) => ; export default ShortField; diff --git a/apps/meteor/client/components/message/content/attachments/file/GenericFileAttachment.tsx b/apps/meteor/client/components/message/content/attachments/file/GenericFileAttachment.tsx index b7bcd7d1e9dd8..86223cd7f2b47 100644 --- a/apps/meteor/client/components/message/content/attachments/file/GenericFileAttachment.tsx +++ b/apps/meteor/client/components/message/content/attachments/file/GenericFileAttachment.tsx @@ -38,15 +38,21 @@ const GenericFileAttachment = ({ const { t } = useTranslation(); const handleTitleClick = (event: UIEvent): void => { - if (openDocumentViewer && link) { + if (!link) { + return; + } + + if (openDocumentViewer && format === 'PDF') { event.preventDefault(); - if (format === 'PDF') { - const url = new URL(getURL(link), window.location.origin); - url.searchParams.set('contentDisposition', 'inline'); - openDocumentViewer(url.toString(), format, ''); - return; - } + const url = new URL(getURL(link), window.location.origin); + url.searchParams.set('contentDisposition', 'inline'); + openDocumentViewer(url.toString(), format, ''); + return; + } + + if (link.includes('/file-decrypt/')) { + event.preventDefault(); registerDownloadForUid(uid, t, title); forAttachmentDownload(uid, link); diff --git a/apps/meteor/client/components/message/content/attachments/structure/Attachment.tsx b/apps/meteor/client/components/message/content/attachments/structure/Attachment.tsx index 6b1d422eba451..11a15cb165055 100644 --- a/apps/meteor/client/components/message/content/attachments/structure/Attachment.tsx +++ b/apps/meteor/client/components/message/content/attachments/structure/Attachment.tsx @@ -1,14 +1,16 @@ import { css } from '@rocket.chat/css-in-js'; import { Box } from '@rocket.chat/fuselage'; import { useAttachmentDimensions } from '@rocket.chat/ui-contexts'; -import type { ComponentProps, FC } from 'react'; +import type { ComponentPropsWithoutRef } from 'react'; import React from 'react'; const className = css` white-space: normal; `; -const Attachment: FC> = (props) => { +type AttachmentProps = ComponentPropsWithoutRef; + +const Attachment = (props: AttachmentProps) => { const { width } = useAttachmentDimensions(); return ( > = (props) => ( +type AttachmentAuthorProps = ComponentPropsWithoutRef; + +const AttachmentAuthor = (props: AttachmentAuthorProps) => ( ); diff --git a/apps/meteor/client/components/message/content/attachments/structure/AttachmentAuthorName.tsx b/apps/meteor/client/components/message/content/attachments/structure/AttachmentAuthorName.tsx index 5eeda2f32bdfa..4c3ead636db05 100644 --- a/apps/meteor/client/components/message/content/attachments/structure/AttachmentAuthorName.tsx +++ b/apps/meteor/client/components/message/content/attachments/structure/AttachmentAuthorName.tsx @@ -1,7 +1,9 @@ import { Box } from '@rocket.chat/fuselage'; -import type { ComponentProps, FC } from 'react'; +import type { ComponentPropsWithoutRef } from 'react'; import React from 'react'; -const AttachmentAuthorName: FC> = (props) => ; +type AttachmentAuthorNameProps = ComponentPropsWithoutRef; + +const AttachmentAuthorName = (props: AttachmentAuthorNameProps) => ; export default AttachmentAuthorName; diff --git a/apps/meteor/client/components/message/content/attachments/structure/AttachmentBlock.tsx b/apps/meteor/client/components/message/content/attachments/structure/AttachmentBlock.tsx index a26c40ec51763..267e0dfe36015 100644 --- a/apps/meteor/client/components/message/content/attachments/structure/AttachmentBlock.tsx +++ b/apps/meteor/client/components/message/content/attachments/structure/AttachmentBlock.tsx @@ -1,14 +1,12 @@ import { Box } from '@rocket.chat/fuselage'; -import type { FC } from 'react'; +import type { ReactNode } from 'react'; import React from 'react'; import Attachment from './Attachment'; -const AttachmentBlock: FC<{ pre?: JSX.Element | string | undefined; color?: string | undefined }> = ({ - pre, - color = 'annotation', - children, -}) => ( +type AttachmentBlockProps = { pre?: ReactNode; color?: string | undefined; children?: ReactNode }; + +const AttachmentBlock = ({ pre, color = 'annotation', children }: AttachmentBlockProps) => ( {pre} > = ({ ...props }) => ; +type AttachmentContentProps = ComponentPropsWithoutRef; + +const AttachmentContent = (props: AttachmentContentProps) => ; export default AttachmentContent; diff --git a/apps/meteor/client/components/message/content/attachments/structure/AttachmentDetails.tsx b/apps/meteor/client/components/message/content/attachments/structure/AttachmentDetails.tsx index 189a58e44920f..8ed15fc120f4a 100644 --- a/apps/meteor/client/components/message/content/attachments/structure/AttachmentDetails.tsx +++ b/apps/meteor/client/components/message/content/attachments/structure/AttachmentDetails.tsx @@ -1,8 +1,10 @@ import { Box } from '@rocket.chat/fuselage'; -import type { FC, ComponentProps } from 'react'; +import type { ComponentPropsWithoutRef } from 'react'; import React from 'react'; -const AttachmentDetails: FC> = ({ ...props }) => ( +type AttachmentDetailsProps = ComponentPropsWithoutRef; + +const AttachmentDetails = (props: AttachmentDetailsProps) => ( ); diff --git a/apps/meteor/client/components/message/content/attachments/structure/AttachmentDownload.tsx b/apps/meteor/client/components/message/content/attachments/structure/AttachmentDownload.tsx index b76cb268bb2f8..ea81f48c034e4 100644 --- a/apps/meteor/client/components/message/content/attachments/structure/AttachmentDownload.tsx +++ b/apps/meteor/client/components/message/content/attachments/structure/AttachmentDownload.tsx @@ -1,13 +1,13 @@ -import type { ComponentProps, FC } from 'react'; +import type { ComponentPropsWithoutRef } from 'react'; import React from 'react'; import type Action from '../../Action'; import AttachmentDownloadBase from './AttachmentDownloadBase'; import AttachmentEncryptedDownload from './AttachmentEncryptedDownload'; -type AttachmentDownloadProps = Omit, 'icon'> & { title?: string | undefined; href: string }; +type AttachmentDownloadProps = Omit, 'icon'> & { title?: string | undefined; href: string }; -const AttachmentDownload: FC = ({ title, href, ...props }) => { +const AttachmentDownload = ({ title, href, ...props }: AttachmentDownloadProps) => { const isEncrypted = href.includes('/file-decrypt/'); if (isEncrypted) { diff --git a/apps/meteor/client/components/message/content/attachments/structure/AttachmentDownloadBase.tsx b/apps/meteor/client/components/message/content/attachments/structure/AttachmentDownloadBase.tsx index c9adc4533a972..284cb0cecbf20 100644 --- a/apps/meteor/client/components/message/content/attachments/structure/AttachmentDownloadBase.tsx +++ b/apps/meteor/client/components/message/content/attachments/structure/AttachmentDownloadBase.tsx @@ -1,12 +1,12 @@ import { useTranslation } from '@rocket.chat/ui-contexts'; -import type { ComponentProps, FC } from 'react'; +import type { ComponentProps } from 'react'; import React from 'react'; import Action from '../../Action'; type AttachmentDownloadBaseProps = Omit, 'icon'> & { title?: string | undefined; href: string }; -const AttachmentDownloadBase: FC = ({ title, href, disabled, ...props }) => { +const AttachmentDownloadBase = ({ title, href, disabled, ...props }: AttachmentDownloadBaseProps) => { const t = useTranslation(); return ( diff --git a/apps/meteor/client/components/message/content/attachments/structure/AttachmentEncryptedDownload.tsx b/apps/meteor/client/components/message/content/attachments/structure/AttachmentEncryptedDownload.tsx index 1dc6752abdd0a..f75b044c69a53 100644 --- a/apps/meteor/client/components/message/content/attachments/structure/AttachmentEncryptedDownload.tsx +++ b/apps/meteor/client/components/message/content/attachments/structure/AttachmentEncryptedDownload.tsx @@ -1,4 +1,4 @@ -import type { ComponentProps, FC } from 'react'; +import type { ComponentProps } from 'react'; import React from 'react'; import { useDownloadFromServiceWorker } from '../../../../../hooks/useDownloadFromServiceWorker'; @@ -6,7 +6,7 @@ import AttachmentDownloadBase from './AttachmentDownloadBase'; type AttachmentDownloadProps = ComponentProps; -const AttachmentEncryptedDownload: FC = ({ title, href, ...props }) => { +const AttachmentEncryptedDownload = ({ title, href, ...props }: AttachmentDownloadProps) => { const encryptedAnchorProps = useDownloadFromServiceWorker(href, title); return ; diff --git a/apps/meteor/client/components/message/content/attachments/structure/AttachmentImage.tsx b/apps/meteor/client/components/message/content/attachments/structure/AttachmentImage.tsx index 8195fdee5973c..d49a4ce8f989b 100644 --- a/apps/meteor/client/components/message/content/attachments/structure/AttachmentImage.tsx +++ b/apps/meteor/client/components/message/content/attachments/structure/AttachmentImage.tsx @@ -1,7 +1,6 @@ import type { Dimensions } from '@rocket.chat/core-typings'; import { Box } from '@rocket.chat/fuselage'; import { useAttachmentDimensions } from '@rocket.chat/ui-contexts'; -import type { FC } from 'react'; import React, { memo, useState, useMemo } from 'react'; import ImageBox from './image/ImageBox'; @@ -37,7 +36,7 @@ const getDimensions = ( return { width, height, ratio: (height / width) * 100 }; }; -const AttachmentImage: FC = ({ id, previewUrl, dataSrc, loadImage = true, setLoadImage, src, ...size }) => { +const AttachmentImage = ({ id, previewUrl, dataSrc, loadImage = true, setLoadImage, src, ...size }: AttachmentImageProps) => { const limits = useAttachmentDimensions(); const [error, setError] = useState(false); @@ -60,7 +59,7 @@ const AttachmentImage: FC = ({ id, previewUrl, dataSrc, lo } if (error) { - return ; + return ; } return ( diff --git a/apps/meteor/client/components/message/content/attachments/structure/AttachmentInner.tsx b/apps/meteor/client/components/message/content/attachments/structure/AttachmentInner.tsx index 5bd47294cf994..29551dd8a2096 100644 --- a/apps/meteor/client/components/message/content/attachments/structure/AttachmentInner.tsx +++ b/apps/meteor/client/components/message/content/attachments/structure/AttachmentInner.tsx @@ -1,7 +1,9 @@ import { Box } from '@rocket.chat/fuselage'; -import type { ComponentProps, FC } from 'react'; +import type { ComponentPropsWithoutRef } from 'react'; import React from 'react'; -const AttachmentInner: FC> = ({ ...props }) => ; +type AttachmentInnerProps = ComponentPropsWithoutRef; + +const AttachmentInner = (props: AttachmentInnerProps) => ; export default AttachmentInner; diff --git a/apps/meteor/client/components/message/content/attachments/structure/AttachmentRow.tsx b/apps/meteor/client/components/message/content/attachments/structure/AttachmentRow.tsx index d7bc8b18a84b6..22c6b0a04d4e3 100644 --- a/apps/meteor/client/components/message/content/attachments/structure/AttachmentRow.tsx +++ b/apps/meteor/client/components/message/content/attachments/structure/AttachmentRow.tsx @@ -1,8 +1,10 @@ import { Box } from '@rocket.chat/fuselage'; -import type { FC, ComponentProps } from 'react'; +import type { ComponentPropsWithoutRef } from 'react'; import React from 'react'; -const AttachmentRow: FC> = (props) => ( +type AttachmentRowProps = ComponentPropsWithoutRef; + +const AttachmentRow = (props: AttachmentRowProps) => ( ); diff --git a/apps/meteor/client/components/message/content/attachments/structure/AttachmentSize.tsx b/apps/meteor/client/components/message/content/attachments/structure/AttachmentSize.tsx index c3ad7e2910c71..75a13e6cba344 100644 --- a/apps/meteor/client/components/message/content/attachments/structure/AttachmentSize.tsx +++ b/apps/meteor/client/components/message/content/attachments/structure/AttachmentSize.tsx @@ -1,11 +1,13 @@ import type { Box } from '@rocket.chat/fuselage'; -import type { ComponentProps, FC } from 'react'; +import type { ComponentPropsWithoutRef } from 'react'; import React from 'react'; import { useFormatMemorySize } from '../../../../../hooks/useFormatMemorySize'; import Title from './AttachmentTitle'; -const AttachmentSize: FC & { size: number; wrapper?: boolean }> = ({ size, wrapper = true, ...props }) => { +type AttachmentSizeProps = ComponentPropsWithoutRef & { size: number; wrapper?: boolean }; + +const AttachmentSize = ({ size, wrapper = true, ...props }: AttachmentSizeProps) => { const format = useFormatMemorySize(); const formattedSize = wrapper ? `(${format(size)})` : format(size); diff --git a/apps/meteor/client/components/message/content/attachments/structure/AttachmentText.tsx b/apps/meteor/client/components/message/content/attachments/structure/AttachmentText.tsx index 1a16b7d2fe586..4026e4c88a667 100644 --- a/apps/meteor/client/components/message/content/attachments/structure/AttachmentText.tsx +++ b/apps/meteor/client/components/message/content/attachments/structure/AttachmentText.tsx @@ -1,7 +1,9 @@ import { Box } from '@rocket.chat/fuselage'; -import type { ComponentProps, FC } from 'react'; +import type { ComponentPropsWithoutRef } from 'react'; import React from 'react'; -const AttachmentText: FC> = (props) => ; +type AttachmentTextProps = ComponentPropsWithoutRef; + +const AttachmentText = (props: AttachmentTextProps) => ; export default AttachmentText; diff --git a/apps/meteor/client/components/message/content/attachments/structure/AttachmentThumb.tsx b/apps/meteor/client/components/message/content/attachments/structure/AttachmentThumb.tsx index 78b576d43b74e..cadc599d5e746 100644 --- a/apps/meteor/client/components/message/content/attachments/structure/AttachmentThumb.tsx +++ b/apps/meteor/client/components/message/content/attachments/structure/AttachmentThumb.tsx @@ -1,8 +1,9 @@ import { Box, Avatar } from '@rocket.chat/fuselage'; -import type { FC } from 'react'; import React, { memo } from 'react'; -const AttachmentThumb: FC<{ url: string }> = ({ url }) => ( +type AttachmentThumbProps = { url: string }; + +const AttachmentThumb = ({ url }: AttachmentThumbProps) => ( diff --git a/apps/meteor/client/components/message/content/attachments/structure/AttachmentTitle.tsx b/apps/meteor/client/components/message/content/attachments/structure/AttachmentTitle.tsx index 0224a2e7c5336..8035321edb96f 100644 --- a/apps/meteor/client/components/message/content/attachments/structure/AttachmentTitle.tsx +++ b/apps/meteor/client/components/message/content/attachments/structure/AttachmentTitle.tsx @@ -1,9 +1,9 @@ import { Box } from '@rocket.chat/fuselage'; -import type { ComponentProps, FC } from 'react'; +import type { ComponentPropsWithoutRef } from 'react'; import React from 'react'; -const AttachmentTitle: FC> = (props) => ( - -); +type AttachmentTitleProps = ComponentPropsWithoutRef; + +const AttachmentTitle = (props: AttachmentTitleProps) => ; export default AttachmentTitle; diff --git a/apps/meteor/client/components/message/content/attachments/structure/image/ImageBox.tsx b/apps/meteor/client/components/message/content/attachments/structure/image/ImageBox.tsx index d53256eee71b5..18b1b9e18031f 100644 --- a/apps/meteor/client/components/message/content/attachments/structure/image/ImageBox.tsx +++ b/apps/meteor/client/components/message/content/attachments/structure/image/ImageBox.tsx @@ -1,8 +1,10 @@ import { Box } from '@rocket.chat/fuselage'; -import type { ComponentProps, FC } from 'react'; +import type { ComponentPropsWithoutRef } from 'react'; import React from 'react'; -const ImageBox: FC> = (props) => ( +type ImageBoxProps = ComponentPropsWithoutRef; + +const ImageBox = (props: ImageBoxProps) => ( & { load: () => void }; +type LoadProps = ComponentPropsWithoutRef & { load: () => void }; -const Load: FC = ({ load, ...props }) => { +const Load = ({ load, ...props }: LoadProps) => { const t = useTranslation(); const clickable = css` cursor: pointer; diff --git a/apps/meteor/client/components/message/content/attachments/structure/image/Retry.tsx b/apps/meteor/client/components/message/content/attachments/structure/image/Retry.tsx index 9de4c9631ecdd..6654800f9e137 100644 --- a/apps/meteor/client/components/message/content/attachments/structure/image/Retry.tsx +++ b/apps/meteor/client/components/message/content/attachments/structure/image/Retry.tsx @@ -1,14 +1,13 @@ import { css } from '@rocket.chat/css-in-js'; import { Box, Icon, Palette } from '@rocket.chat/fuselage'; import { useTranslation } from '@rocket.chat/ui-contexts'; -import type { FC, ComponentProps } from 'react'; import React from 'react'; import ImageBox from './ImageBox'; -type RetryProps = ComponentProps & { retry: () => void }; +type RetryProps = { retry: () => void }; -const Retry: FC = ({ retry }) => { +const Retry = ({ retry }: RetryProps) => { const t = useTranslation(); const clickable = css` cursor: pointer; diff --git a/apps/meteor/client/components/message/content/location/MapView.tsx b/apps/meteor/client/components/message/content/location/MapView.tsx index db84df53f262c..77e404dac1a36 100644 --- a/apps/meteor/client/components/message/content/location/MapView.tsx +++ b/apps/meteor/client/components/message/content/location/MapView.tsx @@ -1,5 +1,4 @@ import { useSetting } from '@rocket.chat/ui-contexts'; -import type { FC } from 'react'; import React, { memo } from 'react'; import MapViewFallback from './MapViewFallback'; @@ -11,7 +10,7 @@ type MapViewProps = { longitude: number; }; -const MapView: FC = ({ latitude, longitude }) => { +const MapView = ({ latitude, longitude }: MapViewProps) => { const googleMapsApiKey = useSetting('MapView_GMapsAPIKey'); const linkUrl = `https://maps.google.com/maps?daddr=${latitude},${longitude}`; diff --git a/apps/meteor/client/components/message/content/location/MapViewFallback.tsx b/apps/meteor/client/components/message/content/location/MapViewFallback.tsx index 34b00d67e0fea..3e89cb03cec74 100644 --- a/apps/meteor/client/components/message/content/location/MapViewFallback.tsx +++ b/apps/meteor/client/components/message/content/location/MapViewFallback.tsx @@ -1,14 +1,13 @@ import { Box, Icon } from '@rocket.chat/fuselage'; import { ExternalLink } from '@rocket.chat/ui-client'; import { useTranslation } from '@rocket.chat/ui-contexts'; -import type { FC } from 'react'; import React from 'react'; type MapViewFallbackProps = { linkUrl: string; }; -const MapViewFallback: FC = ({ linkUrl }) => { +const MapViewFallback = ({ linkUrl }: MapViewFallbackProps) => { const t = useTranslation(); return ( diff --git a/apps/meteor/client/components/message/content/location/MapViewImage.tsx b/apps/meteor/client/components/message/content/location/MapViewImage.tsx index 0fd130b5075fd..4cf33040938d4 100644 --- a/apps/meteor/client/components/message/content/location/MapViewImage.tsx +++ b/apps/meteor/client/components/message/content/location/MapViewImage.tsx @@ -1,6 +1,5 @@ import { ExternalLink } from '@rocket.chat/ui-client'; import { useTranslation } from '@rocket.chat/ui-contexts'; -import type { FC } from 'react'; import React from 'react'; type MapViewImageProps = { @@ -8,7 +7,7 @@ type MapViewImageProps = { imageUrl: string; }; -const MapViewImage: FC = ({ linkUrl, imageUrl }) => { +const MapViewImage = ({ linkUrl, imageUrl }: MapViewImageProps) => { const t = useTranslation(); return ( diff --git a/apps/meteor/client/hooks/roomActions/useCallsRoomAction.ts b/apps/meteor/client/hooks/roomActions/useCallsRoomAction.ts index 31be4b2300ee4..cbc2a594eb6ec 100644 --- a/apps/meteor/client/hooks/roomActions/useCallsRoomAction.ts +++ b/apps/meteor/client/hooks/roomActions/useCallsRoomAction.ts @@ -21,7 +21,7 @@ export const useCallsRoomAction = () => { return { id: 'calls', - groups: ['channel', 'group', 'team'], + groups: ['channel', 'group', 'team', 'direct', 'direct_multiple'], icon: 'phone', title: 'Calls', ...(federated && { diff --git a/apps/meteor/client/hooks/roomActions/useThreadRoomAction.tsx b/apps/meteor/client/hooks/roomActions/useThreadRoomAction.tsx index 92cc93e339fd5..9b5cafe998334 100644 --- a/apps/meteor/client/hooks/roomActions/useThreadRoomAction.tsx +++ b/apps/meteor/client/hooks/roomActions/useThreadRoomAction.tsx @@ -1,9 +1,9 @@ import type { BadgeProps } from '@rocket.chat/fuselage'; -import { HeaderToolbarAction, HeaderToolbarActionBadge } from '@rocket.chat/ui-client'; import { useSetting } from '@rocket.chat/ui-contexts'; import React, { lazy, useMemo } from 'react'; import { useTranslation } from 'react-i18next'; +import { HeaderToolbarAction, HeaderToolbarActionBadge } from '../../components/Header'; import { useRoomSubscription } from '../../views/room/contexts/RoomContext'; import type { RoomToolboxActionConfig } from '../../views/room/contexts/RoomToolboxContext'; diff --git a/apps/meteor/client/lib/imperativeModal.tsx b/apps/meteor/client/lib/imperativeModal.tsx index 29543e539c12a..3740eb1ebc9c3 100644 --- a/apps/meteor/client/lib/imperativeModal.tsx +++ b/apps/meteor/client/lib/imperativeModal.tsx @@ -1,15 +1,15 @@ import { Emitter } from '@rocket.chat/emitter'; import React, { Suspense, createElement } from 'react'; -import type { ComponentType, ReactNode } from 'react'; +import type { ComponentProps, ComponentType, ReactNode } from 'react'; import { modalStore } from '../providers/ModalProvider/ModalStore'; -type ReactModalDescriptor = { - component: ComponentType; - props?: TProps; +type ReactModalDescriptor = ComponentType> = { + component: TComponent; + props?: ComponentProps; }; -type ModalDescriptor = ReactModalDescriptor> | null; +type ModalDescriptor = ReactModalDescriptor | null; type ModalInstance = { close: () => void; @@ -41,11 +41,11 @@ class ImperativeModalEmmiter extends Emitter<{ update: ModalDescriptor }> { this.store = store; } - open = (descriptor: ReactModalDescriptor): ModalInstance => { + open = >(descriptor: ReactModalDescriptor): ModalInstance => { return this.store.open(mapCurrentModal(descriptor as ModalDescriptor)); }; - push = (descriptor: ReactModalDescriptor): ModalInstance => { + push = >(descriptor: ReactModalDescriptor): ModalInstance => { return this.store.push(mapCurrentModal(descriptor as ModalDescriptor)); }; diff --git a/apps/meteor/client/lib/settings/PrivateSettingsCachedCollection.ts b/apps/meteor/client/lib/settings/PrivateSettingsCachedCollection.ts index b0276e753922e..6f4e1c95a5fa5 100644 --- a/apps/meteor/client/lib/settings/PrivateSettingsCachedCollection.ts +++ b/apps/meteor/client/lib/settings/PrivateSettingsCachedCollection.ts @@ -14,7 +14,7 @@ class PrivateSettingsCachedCollection extends CachedCollection { async setupListener(): Promise { sdk.stream('notify-logged', [this.eventName as 'private-settings-changed'], async (t: string, { _id, ...record }: { _id: string }) => { this.log('record received', t, { _id, ...record }); - this.collection.upsert({ _id }, record); + this.collection.update({ _id }, { $set: record }, { upsert: true }); this.sync(); }); } diff --git a/apps/meteor/client/lib/utils/renderMessageEmoji.ts b/apps/meteor/client/lib/utils/renderMessageEmoji.ts index 20986c803ab8b..7960ec1914e59 100644 --- a/apps/meteor/client/lib/utils/renderMessageEmoji.ts +++ b/apps/meteor/client/lib/utils/renderMessageEmoji.ts @@ -1,5 +1,3 @@ -import type { IMessage } from '@rocket.chat/core-typings'; +import { emojiParser } from '../../../app/emoji/client/emojiParser'; -import { emojiParser } from '../../../app/emoji/client/emojiParser.js'; - -export const renderMessageEmoji = & { html?: string }>(message: T): string => emojiParser(message)?.html; +export const renderMessageEmoji = ({ html }: { html: string }): string => emojiParser({ html }).html; diff --git a/apps/meteor/client/navbar/actions/NavbarHomeAction.tsx b/apps/meteor/client/navbar/actions/NavbarHomeAction.tsx index accd68817de92..ec668811122ea 100644 --- a/apps/meteor/client/navbar/actions/NavbarHomeAction.tsx +++ b/apps/meteor/client/navbar/actions/NavbarHomeAction.tsx @@ -1,12 +1,14 @@ import { IconButton } from '@rocket.chat/fuselage'; import { useMutableCallback } from '@rocket.chat/fuselage-hooks'; import { useRouter, useLayout, useSetting, useTranslation } from '@rocket.chat/ui-contexts'; -import type { HTMLAttributes, VFC } from 'react'; +import type { HTMLAttributes } from 'react'; import React from 'react'; import { NavbarAction } from '../../components/Navbar'; -const NavbarHomeAction: VFC, 'is'>> = (props) => { +type NavbarHomeActionProps = Omit, 'is'>; + +const NavbarHomeAction = (props: NavbarHomeActionProps) => { const t = useTranslation(); const router = useRouter(); const { sidebar } = useLayout(); diff --git a/apps/meteor/client/omnichannel/additionalForms/CurrentChatTags.tsx b/apps/meteor/client/omnichannel/additionalForms/CurrentChatTags.tsx index e654bec4336f8..5f3894b69132b 100644 --- a/apps/meteor/client/omnichannel/additionalForms/CurrentChatTags.tsx +++ b/apps/meteor/client/omnichannel/additionalForms/CurrentChatTags.tsx @@ -1,4 +1,3 @@ -import type { FC } from 'react'; import React from 'react'; import { useHasLicenseModule } from '../../hooks/useHasLicenseModule'; @@ -6,7 +5,7 @@ import AutoCompleteTagsMultiple from '../tags/AutoCompleteTagsMultiple'; type CurrentChatTagsProps = { value: Array<{ value: string; label: string }>; handler: any; department?: string; viewAll?: boolean }; -const CurrentChatTags: FC = ({ value, handler, department, viewAll }) => { +const CurrentChatTags = ({ value, handler, department, viewAll }: CurrentChatTagsProps) => { const hasLicense = useHasLicenseModule('livechat-enterprise'); if (!hasLicense) { diff --git a/apps/meteor/client/omnichannel/additionalForms/MaxChatsPerAgentDisplay.tsx b/apps/meteor/client/omnichannel/additionalForms/MaxChatsPerAgentDisplay.tsx index 91980f119316f..8d68f0ec29f35 100644 --- a/apps/meteor/client/omnichannel/additionalForms/MaxChatsPerAgentDisplay.tsx +++ b/apps/meteor/client/omnichannel/additionalForms/MaxChatsPerAgentDisplay.tsx @@ -1,7 +1,7 @@ import { useTranslation } from '@rocket.chat/ui-contexts'; import React from 'react'; -import UserInfo from '../../components/UserInfo'; +import { InfoPanelLabel, InfoPanelText } from '../../components/InfoPanel'; import { useHasLicenseModule } from '../../hooks/useHasLicenseModule'; const MaxChatsPerAgentDisplay = ({ maxNumberSimultaneousChat = 0 }) => { @@ -14,8 +14,8 @@ const MaxChatsPerAgentDisplay = ({ maxNumberSimultaneousChat = 0 }) => { return ( <> - {t('Max_number_of_chats_per_agent')} - {maxNumberSimultaneousChat} + {t('Max_number_of_chats_per_agent')} + {maxNumberSimultaneousChat} ); }; diff --git a/apps/meteor/client/omnichannel/cannedResponses/CannedResponsesRoute.tsx b/apps/meteor/client/omnichannel/cannedResponses/CannedResponsesRoute.tsx index 9e90ac015a46d..dc6a489320d1e 100644 --- a/apps/meteor/client/omnichannel/cannedResponses/CannedResponsesRoute.tsx +++ b/apps/meteor/client/omnichannel/cannedResponses/CannedResponsesRoute.tsx @@ -1,11 +1,10 @@ import { usePermission } from '@rocket.chat/ui-contexts'; -import type { FC } from 'react'; import React from 'react'; import NotAuthorizedPage from '../../views/notAuthorized/NotAuthorizedPage'; import CannedResponsesPage from './CannedResponsesPage'; -const CannedResponsesRoute: FC = () => { +const CannedResponsesRoute = () => { const canViewCannedResponses = usePermission('manage-livechat-canned-responses'); if (!canViewCannedResponses) { diff --git a/apps/meteor/client/omnichannel/cannedResponses/components/CannedResponsesComposer/CannedResponsesComposerPreview.tsx b/apps/meteor/client/omnichannel/cannedResponses/components/CannedResponsesComposer/CannedResponsesComposerPreview.tsx index 63dde93ac8d66..644c74c41295f 100644 --- a/apps/meteor/client/omnichannel/cannedResponses/components/CannedResponsesComposer/CannedResponsesComposerPreview.tsx +++ b/apps/meteor/client/omnichannel/cannedResponses/components/CannedResponsesComposer/CannedResponsesComposerPreview.tsx @@ -1,10 +1,11 @@ import { Box } from '@rocket.chat/fuselage'; -import type { FC } from 'react'; import React, { memo } from 'react'; import MarkdownText from '../../../../components/MarkdownText'; -const CannedResponsesComposerPreview: FC<{ text: string }> = ({ text }) => { +type CannedResponsesComposerPreviewProps = { text: string }; + +const CannedResponsesComposerPreview = ({ text }: CannedResponsesComposerPreviewProps) => { const textM = text.split(/\n/).join(' \n'); return ( diff --git a/apps/meteor/client/omnichannel/cannedResponses/components/CannedResponsesComposer/InsertPlaceholderDropdown.tsx b/apps/meteor/client/omnichannel/cannedResponses/components/CannedResponsesComposer/InsertPlaceholderDropdown.tsx index 72b0b00939c11..54ce88cccb7eb 100644 --- a/apps/meteor/client/omnichannel/cannedResponses/components/CannedResponsesComposer/InsertPlaceholderDropdown.tsx +++ b/apps/meteor/client/omnichannel/cannedResponses/components/CannedResponsesComposer/InsertPlaceholderDropdown.tsx @@ -1,14 +1,16 @@ import { css } from '@rocket.chat/css-in-js'; import { Box, Divider } from '@rocket.chat/fuselage'; import { useTranslation } from '@rocket.chat/ui-contexts'; -import type { Dispatch, FC, RefObject, SetStateAction } from 'react'; +import type { Dispatch, RefObject, SetStateAction } from 'react'; import React, { memo } from 'react'; -const InsertPlaceholderDropdown: FC<{ +type InsertPlaceholderDropdownProps = { onChange: any; textAreaRef: RefObject; setVisible: Dispatch>; -}> = ({ onChange, textAreaRef, setVisible }) => { +}; + +const InsertPlaceholderDropdown = ({ onChange, textAreaRef, setVisible }: InsertPlaceholderDropdownProps) => { const t = useTranslation(); const clickable = css` diff --git a/apps/meteor/client/omnichannel/cannedResponses/contextualBar/CannedResponse/CannedResponse.tsx b/apps/meteor/client/omnichannel/cannedResponses/contextualBar/CannedResponse/CannedResponse.tsx index 557d9672c027a..6bef3f10efdc1 100644 --- a/apps/meteor/client/omnichannel/cannedResponses/contextualBar/CannedResponse/CannedResponse.tsx +++ b/apps/meteor/client/omnichannel/cannedResponses/contextualBar/CannedResponse/CannedResponse.tsx @@ -1,7 +1,7 @@ import type { ILivechatDepartment, IOmnichannelCannedResponse } from '@rocket.chat/core-typings'; import { Box, Button, ButtonGroup, Tag } from '@rocket.chat/fuselage'; import { useTranslation } from '@rocket.chat/ui-contexts'; -import type { FC, MouseEventHandler } from 'react'; +import type { MouseEventHandler } from 'react'; import React, { memo } from 'react'; import { @@ -14,7 +14,7 @@ import { } from '../../../../components/Contextualbar'; import { useScopeDict } from '../../../hooks/useScopeDict'; -const CannedResponse: FC<{ +type CannedResponseProps = { allowEdit: boolean; allowUse: boolean; data: { @@ -27,7 +27,16 @@ const CannedResponse: FC<{ onClickBack: MouseEventHandler; onClickEdit: MouseEventHandler; onClickUse: MouseEventHandler; -}> = ({ allowEdit, allowUse, data: { departmentName, shortcut, text, scope: dataScope, tags }, onClickBack, onClickEdit, onClickUse }) => { +}; + +const CannedResponse = ({ + allowEdit, + allowUse, + data: { departmentName, shortcut, text, scope: dataScope, tags }, + onClickBack, + onClickEdit, + onClickUse, +}: CannedResponseProps) => { const t = useTranslation(); const scope = useScopeDict(dataScope, departmentName); diff --git a/apps/meteor/client/omnichannel/cannedResponses/contextualBar/CannedResponse/CannedResponseList.tsx b/apps/meteor/client/omnichannel/cannedResponses/contextualBar/CannedResponse/CannedResponseList.tsx index cc1be1da33c8c..cd9907f271a50 100644 --- a/apps/meteor/client/omnichannel/cannedResponses/contextualBar/CannedResponse/CannedResponseList.tsx +++ b/apps/meteor/client/omnichannel/cannedResponses/contextualBar/CannedResponse/CannedResponseList.tsx @@ -2,7 +2,7 @@ import type { ILivechatDepartment, IOmnichannelCannedResponse } from '@rocket.ch import { Box, Button, ButtonGroup, ContextualbarEmptyContent, Icon, Margins, Select, TextInput } from '@rocket.chat/fuselage'; import { useAutoFocus, useResizeObserver } from '@rocket.chat/fuselage-hooks'; import { useTranslation } from '@rocket.chat/ui-contexts'; -import type { Dispatch, FC, FormEventHandler, MouseEvent, ReactElement, SetStateAction } from 'react'; +import type { Dispatch, FormEventHandler, MouseEvent, ReactElement, SetStateAction } from 'react'; import React, { memo } from 'react'; import { Virtuoso } from 'react-virtuoso'; @@ -19,7 +19,7 @@ import { useRoomToolbox } from '../../../../views/room/contexts/RoomToolboxConte import Item from './Item'; import WrapCannedResponse from './WrapCannedResponse'; -const CannedResponseList: FC<{ +type CannedResponseListProps = { loadMoreItems: (start: number, end: number) => void; cannedItems: (IOmnichannelCannedResponse & { departmentName: ILivechatDepartment['name'] })[]; itemCount: number; @@ -35,7 +35,9 @@ const CannedResponseList: FC<{ onClickCreate: (e: MouseEvent) => void; onClickUse: (e: MouseEvent, text: string) => void; reload: () => void; -}> = ({ +}; + +const CannedResponseList = ({ loadMoreItems, cannedItems, itemCount, @@ -51,7 +53,7 @@ const CannedResponseList: FC<{ onClickCreate, onClickUse, reload, -}) => { +}: CannedResponseListProps) => { const t = useTranslation(); const inputRef = useAutoFocus(true); diff --git a/apps/meteor/client/omnichannel/cannedResponses/contextualBar/CannedResponse/Item.tsx b/apps/meteor/client/omnichannel/cannedResponses/contextualBar/CannedResponse/Item.tsx index bcb6a7d9949f6..224e0c5dff559 100644 --- a/apps/meteor/client/omnichannel/cannedResponses/contextualBar/CannedResponse/Item.tsx +++ b/apps/meteor/client/omnichannel/cannedResponses/contextualBar/CannedResponse/Item.tsx @@ -2,17 +2,19 @@ import type { ILivechatDepartment, IOmnichannelCannedResponse } from '@rocket.ch import { css } from '@rocket.chat/css-in-js'; import { Box, Button, Icon, Tag } from '@rocket.chat/fuselage'; import { useTranslation } from '@rocket.chat/ui-contexts'; -import type { FC, MouseEvent } from 'react'; +import type { MouseEvent } from 'react'; import React, { memo, useState } from 'react'; import { useScopeDict } from '../../../hooks/useScopeDict'; -const Item: FC<{ +type ItemProps = { data: IOmnichannelCannedResponse & { departmentName: ILivechatDepartment['name'] }; allowUse?: boolean; onClickItem: (e: MouseEvent) => void; onClickUse: (e: MouseEvent, text: string) => void; -}> = ({ data, allowUse, onClickItem, onClickUse }) => { +}; + +const Item = ({ data, allowUse, onClickItem, onClickUse }: ItemProps) => { const t = useTranslation(); const scope = useScopeDict(data.scope, data.departmentName); diff --git a/apps/meteor/client/omnichannel/cannedResponses/contextualBar/CannedResponse/WrapCannedResponse.tsx b/apps/meteor/client/omnichannel/cannedResponses/contextualBar/CannedResponse/WrapCannedResponse.tsx index 6cf89689f2969..eb118f50a7eff 100644 --- a/apps/meteor/client/omnichannel/cannedResponses/contextualBar/CannedResponse/WrapCannedResponse.tsx +++ b/apps/meteor/client/omnichannel/cannedResponses/contextualBar/CannedResponse/WrapCannedResponse.tsx @@ -1,18 +1,20 @@ import type { ILivechatDepartment, IOmnichannelCannedResponse } from '@rocket.chat/core-typings'; import { useSetModal, usePermission } from '@rocket.chat/ui-contexts'; -import type { FC, MouseEvent, MouseEventHandler } from 'react'; +import type { MouseEvent, MouseEventHandler } from 'react'; import React, { memo } from 'react'; import CreateCannedResponse from '../../modals/CreateCannedResponse'; import CannedResponse from './CannedResponse'; -const WrapCannedResponse: FC<{ +type WrapCannedResponseProps = { allowUse: boolean; cannedItem: IOmnichannelCannedResponse & { departmentName: ILivechatDepartment['name'] }; onClickBack: MouseEventHandler; onClickUse: (e: MouseEvent, text: string) => void; reload: () => void; -}> = ({ allowUse, cannedItem, onClickBack, onClickUse, reload }) => { +}; + +const WrapCannedResponse = ({ allowUse, cannedItem, onClickBack, onClickUse, reload }: WrapCannedResponseProps) => { const setModal = useSetModal(); const onClickEdit = (): void => { setModal( setModal(null)} reloadCannedList={reload} />); diff --git a/apps/meteor/client/polyfills/hoverTouchClick.ts b/apps/meteor/client/polyfills/hoverTouchClick.ts deleted file mode 100644 index 53706a45fb33b..0000000000000 --- a/apps/meteor/client/polyfills/hoverTouchClick.ts +++ /dev/null @@ -1,17 +0,0 @@ -import * as domEvents from '../lib/utils/domEvents'; -import { isIOsDevice } from '../lib/utils/isIOsDevice'; - -((): void => { - if (!isIOsDevice || !window.matchMedia('(hover: none)').matches) { - return; - } - - domEvents.delegate({ - parent: document.body, - eventName: 'touchend', - elementSelector: 'a:hover', - listener: (_, element): void => { - domEvents.triggerClick(element); - }, - }); -})(); diff --git a/apps/meteor/client/polyfills/index.ts b/apps/meteor/client/polyfills/index.ts index bc91265b04ba9..be470f261e265 100644 --- a/apps/meteor/client/polyfills/index.ts +++ b/apps/meteor/client/polyfills/index.ts @@ -3,5 +3,4 @@ import 'url-polyfill'; import './childNodeRemove'; import './cssVars'; import './customEventPolyfill'; -import './hoverTouchClick'; import './promiseFinally'; diff --git a/apps/meteor/client/portals/ModalPortal.tsx b/apps/meteor/client/portals/ModalPortal.tsx index d7c9ae9caa2d8..6b2210d569260 100644 --- a/apps/meteor/client/portals/ModalPortal.tsx +++ b/apps/meteor/client/portals/ModalPortal.tsx @@ -1,18 +1,32 @@ -import type { ReactElement, ReactNode } from 'react'; -import React, { memo, useEffect, useState } from 'react'; +import type { ReactNode } from 'react'; +import { memo } from 'react'; import { createPortal } from 'react-dom'; -import { createAnchor } from '../lib/utils/createAnchor'; -import { deleteAnchor } from '../lib/utils/deleteAnchor'; +const createModalRoot = (): HTMLElement => { + const id = 'modal-root'; + const existing = document.getElementById(id); + + if (existing) return existing; + + const newOne = document.createElement('div'); + newOne.id = id; + document.body.append(newOne); + + return newOne; +}; + +let modalRoot: HTMLElement | null = null; type ModalPortalProps = { children?: ReactNode; }; -const ModalPortal = ({ children }: ModalPortalProps): ReactElement => { - const [modalRoot] = useState(() => createAnchor('modal-root')); - useEffect(() => (): void => deleteAnchor(modalRoot), [modalRoot]); - return <>{createPortal(children, modalRoot)}; +const ModalPortal = ({ children }: ModalPortalProps) => { + if (!modalRoot) { + modalRoot = createModalRoot(); + } + + return createPortal(children, modalRoot); }; export default memo(ModalPortal); diff --git a/apps/meteor/client/portals/TooltipPortal.tsx b/apps/meteor/client/portals/TooltipPortal.tsx index 2ee0830313c48..6897a98ad5d60 100644 --- a/apps/meteor/client/portals/TooltipPortal.tsx +++ b/apps/meteor/client/portals/TooltipPortal.tsx @@ -1,14 +1,18 @@ -import type { FC } from 'react'; +import type { ReactNode } from 'react'; import React, { memo, useEffect, useState } from 'react'; import { createPortal } from 'react-dom'; import { createAnchor } from '../lib/utils/createAnchor'; import { deleteAnchor } from '../lib/utils/deleteAnchor'; -const TooltipPortal: FC = ({ children }) => { +type TooltipPortalProps = { + children?: ReactNode; +}; + +const TooltipPortal = ({ children }: TooltipPortalProps) => { const [tooltipRoot] = useState(() => createAnchor('tooltip-root')); useEffect(() => (): void => deleteAnchor(tooltipRoot), [tooltipRoot]); return <>{createPortal(children, tooltipRoot)}; }; -export default memo(TooltipPortal); +export default memo(TooltipPortal); diff --git a/apps/meteor/client/providers/AttachmentProvider.tsx b/apps/meteor/client/providers/AttachmentProvider.tsx index 56a5318cfa4f3..b7ec881856f76 100644 --- a/apps/meteor/client/providers/AttachmentProvider.tsx +++ b/apps/meteor/client/providers/AttachmentProvider.tsx @@ -1,15 +1,18 @@ import { usePrefersReducedData } from '@rocket.chat/fuselage-hooks'; import type { AttachmentContextValue } from '@rocket.chat/ui-contexts'; import { AttachmentContext, useLayout, useUserPreference } from '@rocket.chat/ui-contexts'; -import type { FC } from 'react'; +import type { ReactNode } from 'react'; import React, { useMemo } from 'react'; import { getURL } from '../../app/utils/client'; -const AttachmentProvider: FC<{ +type AttachmentProviderProps = { + children?: ReactNode; width?: number; height?: number; -}> = ({ children, width = 360, height = 360 }) => { +}; + +const AttachmentProvider = ({ children, width = 360, height = 360 }: AttachmentProviderProps) => { const { isMobile } = useLayout(); const reducedData = usePrefersReducedData(); const collapsedByDefault = !!useUserPreference('collapseMediaByDefault'); diff --git a/apps/meteor/client/providers/AuthorizationProvider.tsx b/apps/meteor/client/providers/AuthorizationProvider.tsx index 64d936b5cd65a..da088c05e90bb 100644 --- a/apps/meteor/client/providers/AuthorizationProvider.tsx +++ b/apps/meteor/client/providers/AuthorizationProvider.tsx @@ -2,7 +2,7 @@ import type { IRole } from '@rocket.chat/core-typings'; import { Emitter } from '@rocket.chat/emitter'; import { AuthorizationContext } from '@rocket.chat/ui-contexts'; import { Meteor } from 'meteor/meteor'; -import type { FC } from 'react'; +import type { ReactNode } from 'react'; import React, { useCallback, useEffect } from 'react'; import { hasPermission, hasAtLeastOnePermission, hasAllPermission, hasRole } from '../../app/authorization/client'; @@ -27,7 +27,11 @@ const contextValue = { roleStore: new RoleStore(), }; -const AuthorizationProvider: FC = ({ children }) => { +type AuthorizationProviderProps = { + children?: ReactNode; +}; + +const AuthorizationProvider = ({ children }: AuthorizationProviderProps) => { const roles = useReactiveValue( useCallback( () => diff --git a/apps/meteor/client/providers/AvatarUrlProvider.tsx b/apps/meteor/client/providers/AvatarUrlProvider.tsx index 0f00e447c75e8..6cdc9012f7145 100644 --- a/apps/meteor/client/providers/AvatarUrlProvider.tsx +++ b/apps/meteor/client/providers/AvatarUrlProvider.tsx @@ -1,11 +1,15 @@ import { AvatarUrlContext, useSetting } from '@rocket.chat/ui-contexts'; -import type { FC } from 'react'; +import type { ReactNode } from 'react'; import React, { useMemo } from 'react'; import { getURL } from '../../app/utils/client/getURL'; import { roomCoordinator } from '../lib/rooms/roomCoordinator'; -const AvatarUrlProvider: FC = ({ children }) => { +type AvatarUrlProviderProps = { + children?: ReactNode; +}; + +const AvatarUrlProvider = ({ children }: AvatarUrlProviderProps) => { const cdnAvatarUrl = String(useSetting('CDN_PREFIX') || ''); const externalProviderUrl = String(useSetting('Accounts_AvatarExternalProviderUrl') || ''); const contextValue = useMemo( diff --git a/apps/meteor/client/providers/CallProvider/CallProvider.tsx b/apps/meteor/client/providers/CallProvider/CallProvider.tsx index 38b7c12791cd2..f2c884aeb05ff 100644 --- a/apps/meteor/client/providers/CallProvider/CallProvider.tsx +++ b/apps/meteor/client/providers/CallProvider/CallProvider.tsx @@ -23,7 +23,7 @@ import { useSetModal, useTranslation, } from '@rocket.chat/ui-contexts'; -import type { FC } from 'react'; +import type { ReactNode } from 'react'; import React, { useMemo, useRef, useCallback, useEffect, useState } from 'react'; import { createPortal } from 'react-dom'; import type { OutgoingByeRequest } from 'sip.js/lib/core'; @@ -40,7 +40,11 @@ import { useVoipSounds } from './hooks/useVoipSounds'; type NetworkState = 'online' | 'offline'; -export const CallProvider: FC = ({ children }) => { +type CallProviderProps = { + children?: ReactNode; +}; + +export const CallProvider = ({ children }: CallProviderProps) => { const [clientState, setClientState] = useState<'registered' | 'unregistered'>('unregistered'); const voipEnabled = useSetting('VoIP_Enabled'); diff --git a/apps/meteor/client/providers/ConnectionStatusProvider.tsx b/apps/meteor/client/providers/ConnectionStatusProvider.tsx index 469cff990e295..2f2cb12722e1a 100644 --- a/apps/meteor/client/providers/ConnectionStatusProvider.tsx +++ b/apps/meteor/client/providers/ConnectionStatusProvider.tsx @@ -1,7 +1,7 @@ import type { ConnectionStatusContextValue } from '@rocket.chat/ui-contexts'; import { ConnectionStatusContext } from '@rocket.chat/ui-contexts'; import { Meteor } from 'meteor/meteor'; -import type { FC } from 'react'; +import type { ReactNode } from 'react'; import React from 'react'; import { useReactiveValue } from '../hooks/useReactiveValue'; @@ -11,7 +11,11 @@ const getValue = (): ConnectionStatusContextValue => ({ reconnect: Meteor.reconnect, }); -const ConnectionStatusProvider: FC = ({ children }) => { +type ConnectionStatusProviderProps = { + children?: ReactNode; +}; + +const ConnectionStatusProvider = ({ children }: ConnectionStatusProviderProps) => { const status = useReactiveValue(getValue); return ; diff --git a/apps/meteor/client/providers/CustomSoundProvider.tsx b/apps/meteor/client/providers/CustomSoundProvider.tsx index 5e1b3944b6e84..5404d37cf9183 100644 --- a/apps/meteor/client/providers/CustomSoundProvider.tsx +++ b/apps/meteor/client/providers/CustomSoundProvider.tsx @@ -1,11 +1,15 @@ import { CustomSoundContext, useUserId, useStream } from '@rocket.chat/ui-contexts'; -import type { FC } from 'react'; +import type { ReactNode } from 'react'; import React, { useEffect } from 'react'; import { CustomSounds } from '../../app/custom-sounds/client/lib/CustomSounds'; import { useContinuousSoundNotification } from '../hooks/useContinuousSoundNotification'; -const CustomSoundProvider: FC = ({ children }) => { +type CustomSoundProviderProps = { + children?: ReactNode; +}; + +const CustomSoundProvider = ({ children }: CustomSoundProviderProps) => { const userId = useUserId(); useEffect(() => { if (!userId) { diff --git a/apps/meteor/client/providers/LayoutProvider.tsx b/apps/meteor/client/providers/LayoutProvider.tsx index a4f8fa84f9ff4..04b72a593673b 100644 --- a/apps/meteor/client/providers/LayoutProvider.tsx +++ b/apps/meteor/client/providers/LayoutProvider.tsx @@ -1,6 +1,6 @@ import { useBreakpoints } from '@rocket.chat/fuselage-hooks'; import { LayoutContext, useRouter, useSetting } from '@rocket.chat/ui-contexts'; -import type { FC } from 'react'; +import type { ReactNode } from 'react'; import React, { useMemo, useState, useEffect } from 'react'; const hiddenActionsDefaultValue = { @@ -10,7 +10,11 @@ const hiddenActionsDefaultValue = { userToolbox: [], }; -const LayoutProvider: FC = ({ children }) => { +type LayoutProviderProps = { + children?: ReactNode; +}; + +const LayoutProvider = ({ children }: LayoutProviderProps) => { const showTopNavbarEmbeddedLayout = Boolean(useSetting('UI_Show_top_navbar_embedded_layout')); const [isCollapsed, setIsCollapsed] = useState(false); const breakpoints = useBreakpoints(); // ["xs", "sm", "md", "lg", "xl", xxl"] diff --git a/apps/meteor/client/providers/MeteorProvider.tsx b/apps/meteor/client/providers/MeteorProvider.tsx index cf6b14e7946b4..4817cee83317b 100644 --- a/apps/meteor/client/providers/MeteorProvider.tsx +++ b/apps/meteor/client/providers/MeteorProvider.tsx @@ -1,4 +1,4 @@ -import type { FC } from 'react'; +import type { ReactNode } from 'react'; import React from 'react'; import { OmnichannelRoomIconProvider } from '../components/RoomIcon/OmnichannelRoomIcon/provider/OmnichannelRoomIconProvider'; @@ -25,7 +25,11 @@ import UserPresenceProvider from './UserPresenceProvider'; import UserProvider from './UserProvider'; import VideoConfProvider from './VideoConfProvider'; -const MeteorProvider: FC = ({ children }) => ( +type MeteorProviderProps = { + children?: ReactNode; +}; + +const MeteorProvider = ({ children }: MeteorProviderProps) => ( diff --git a/apps/meteor/client/providers/ModalProvider/ModalProvider.spec.tsx b/apps/meteor/client/providers/ModalProvider/ModalProvider.spec.tsx index f779333374561..ea062c3248071 100644 --- a/apps/meteor/client/providers/ModalProvider/ModalProvider.spec.tsx +++ b/apps/meteor/client/providers/ModalProvider/ModalProvider.spec.tsx @@ -1,115 +1,138 @@ -// import type { IMessage } from '@rocket.chat/core-typings'; -import { Emitter } from '@rocket.chat/emitter'; import { useSetModal } from '@rocket.chat/ui-contexts'; -import { render, screen } from '@testing-library/react'; -import { expect } from 'chai'; -import type { ReactNode } from 'react'; -import React, { Suspense, createContext, useContext, useEffect } from 'react'; +import { act, render, screen } from '@testing-library/react'; +import type { ForwardedRef, ReactElement } from 'react'; +import React, { Suspense, createContext, createRef, forwardRef, useContext, useImperativeHandle } from 'react'; import GenericModal from '../../components/GenericModal'; 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 TestContext = createContext({ title: 'default' }); -const emitter = new Emitter(); +const renderWithSuspense = (ui: ReactElement) => + render(ui, { + wrapper: ({ children }) => {children}, + }); -const TestModal = ({ emitterEvent, modalFunc }: { emitterEvent: string; modalFunc?: () => ReactNode }) => { - const setModal = useSetModal(); - const { title } = useContext(TestContext); +describe('via useSetModal', () => { + const ModalTitleContext = createContext('default'); - useEffect(() => { - emitter.on(emitterEvent, () => { - setModal(modalFunc || undefined}>); - }); - }, [emitterEvent, setModal, title, modalFunc]); + type ModalOpenerAPI = { open: () => void }; - return <>; -}; + const ModalOpener = forwardRef((_: unknown, ref: ForwardedRef) => { + const setModal = useSetModal(); + const title = useContext(ModalTitleContext); + useImperativeHandle(ref, () => ({ + open: () => { + setModal(); + }, + })); + + return null; + }); -describe('Modal Provider', () => { it('should render a modal', async () => { - render( - + const modalOpenerRef = createRef(); + + renderWithSuspense( + + + , + ); + + act(() => { + modalOpenerRef.current?.open(); + }); + + expect(await screen.findByRole('dialog', { name: 'default' })).toBeInTheDocument(); + }); + + it('should render a modal that consumes a context', async () => { + const modalOpenerRef = createRef(); + + renderWithSuspense( + - + - , + , ); - emitter.emit('open'); - expect(await screen.findByText('default')).to.exist; + act(() => { + modalOpenerRef.current?.open(); + }); + + expect(await screen.findByRole('dialog', { name: 'title from context' })).toBeInTheDocument(); }); - it('should render a modal that is passed as a function', async () => { - render( - + it('should render a modal in another region', async () => { + const modalOpener1Ref = createRef(); + const modalOpener2Ref = createRef(); + + renderWithSuspense( + - undefined} />} /> + - , + + + + + + , ); - emitter.emit('open'); - expect(await screen.findByText('function modal')).to.exist; + + act(() => { + modalOpener1Ref.current?.open(); + }); + + expect(await screen.findByRole('dialog', { name: 'modal1' })).toBeInTheDocument(); + + act(() => { + modalOpener2Ref.current?.open(); + }); + + expect(await screen.findByRole('dialog', { name: 'modal2' })).toBeInTheDocument(); }); +}); + +describe('via imperativeModal', () => { + it('should render a modal through imperative modal', async () => { + renderWithSuspense( + + + , + ); - it('should render a modal through imperative modal', () => { - async () => { - render( - - - - - , - ); - - const { close } = imperativeModal.open({ + act(() => { + imperativeModal.open({ component: GenericModal, - props: { title: 'imperativeModal' }, + props: { title: 'imperativeModal', open: true }, }); + }); - expect(await screen.findByText('imperativeModal')).to.exist; + expect(await screen.findByRole('dialog', { name: 'imperativeModal' })).toBeInTheDocument(); - close(); + act(() => { + imperativeModal.close(); + }); - expect(screen.queryByText('imperativeModal')).to.not.exist; - }; + expect(screen.queryByText('imperativeModal')).not.toBeInTheDocument(); }); it('should not render a modal if no corresponding region exists', async () => { // ModalProviderWithRegion will always have a region identifier set // and imperativeModal will only render modals in the default region (e.g no region identifier) - render( - - - , - ); - - imperativeModal.open({ - component: GenericModal, - props: { title: 'imperativeModal' }, - }); - expect(screen.queryByText('imperativeModal')).to.not.exist; - }); + renderWithSuspense(); - it('should render a modal in another region', () => { - render( - - - - - - - - - - , - ); + act(() => { + imperativeModal.open({ + component: GenericModal, + props: { title: 'imperativeModal', open: true }, + }); + }); - emitter.emit('openModal1'); - expect(screen.getByText('modal1')).to.exist; - emitter.emit('openModal2'); - expect(screen.getByText('modal2')).to.exist; + expect(screen.queryByRole('dialog', { name: 'imperativeModal' })).not.toBeInTheDocument(); }); }); diff --git a/apps/meteor/client/providers/ModalProvider/ModalProvider.tsx b/apps/meteor/client/providers/ModalProvider/ModalProvider.tsx index 6c3f1026bc517..27092ea602b6a 100644 --- a/apps/meteor/client/providers/ModalProvider/ModalProvider.tsx +++ b/apps/meteor/client/providers/ModalProvider/ModalProvider.tsx @@ -33,7 +33,7 @@ const ModalProvider = ({ children, region }: ModalProviderProps) => { }, region, }), - [currentModal, region, setModal], + [currentModal?.node, currentModal?.region, region, setModal], ); return ; diff --git a/apps/meteor/client/providers/OmnichannelProvider.tsx b/apps/meteor/client/providers/OmnichannelProvider.tsx index 881275e2fc2bb..9517a9d3b1552 100644 --- a/apps/meteor/client/providers/OmnichannelProvider.tsx +++ b/apps/meteor/client/providers/OmnichannelProvider.tsx @@ -7,7 +7,7 @@ import type { import { useSafely } from '@rocket.chat/fuselage-hooks'; import { useUser, useSetting, usePermission, useMethod, useEndpoint, useStream } from '@rocket.chat/ui-contexts'; import { useQuery, useQueryClient } from '@tanstack/react-query'; -import type { FC } from 'react'; +import type { ReactNode } from 'react'; import React, { useState, useEffect, useMemo, useCallback, memo, useRef } from 'react'; import { LivechatInquiry } from '../../app/livechat/client/collections/LivechatInquiry'; @@ -36,7 +36,11 @@ const emptyContextValue: OmnichannelContextValue = { }, }; -const OmnichannelProvider: FC = ({ children }) => { +type OmnichannelProviderProps = { + children?: ReactNode; +}; + +const OmnichannelProvider = ({ children }: OmnichannelProviderProps) => { const omniChannelEnabled = useSetting('Livechat_enabled') as boolean; const omnichannelRouting = useSetting('Livechat_Routing_Method'); const showOmnichannelQueueLink = useSetting('Livechat_show_queue_list_link') as boolean; diff --git a/apps/meteor/client/providers/RouterProvider.tsx b/apps/meteor/client/providers/RouterProvider.tsx index 4f0aab3a602b8..d7fb25a1ed31c 100644 --- a/apps/meteor/client/providers/RouterProvider.tsx +++ b/apps/meteor/client/providers/RouterProvider.tsx @@ -1,3 +1,4 @@ +import type { RoomType, RoomRouteData } from '@rocket.chat/core-typings'; import type { RouterContextValue, RouteName, @@ -11,10 +12,11 @@ import { RouterContext } from '@rocket.chat/ui-contexts'; import type { LocationSearch } from '@rocket.chat/ui-contexts/src/RouterContext'; import { FlowRouter } from 'meteor/kadira:flow-router'; import { Tracker } from 'meteor/tracker'; -import type { FC } from 'react'; +import type { ReactNode } from 'react'; import React from 'react'; import { appLayout } from '../lib/appLayout'; +import { roomCoordinator } from '../lib/rooms/roomCoordinator'; import { queueMicrotask } from '../lib/utils/queueMicrotask'; const subscribers = new Set<() => void>(); @@ -195,8 +197,15 @@ export const router: RouterContextValue = { defineRoutes, getRoutes, subscribeToRoutesChange, + getRoomRoute(roomType: RoomType, routeData: RoomRouteData) { + return { path: roomCoordinator.getRouteLink(roomType, routeData) || '/' }; + }, +}; + +type RouterProviderProps = { + children?: ReactNode; }; -const RouterProvider: FC = ({ children }) => ; +const RouterProvider = ({ children }: RouterProviderProps) => ; export default RouterProvider; diff --git a/apps/meteor/client/providers/ServerProvider.tsx b/apps/meteor/client/providers/ServerProvider.tsx index 8eb5e2e37b6bf..53ac92287f784 100644 --- a/apps/meteor/client/providers/ServerProvider.tsx +++ b/apps/meteor/client/providers/ServerProvider.tsx @@ -12,7 +12,7 @@ import type { import { ServerContext } from '@rocket.chat/ui-contexts'; import { Meteor } from 'meteor/meteor'; import { compile } from 'path-to-regexp'; -import type { FC } from 'react'; +import type { ReactNode } from 'react'; import React from 'react'; import { sdk } from '../../app/utils/client/lib/SDKClient'; @@ -78,6 +78,8 @@ const contextValue = { getStream, }; -const ServerProvider: FC = ({ children }) => ; +type ServerProviderProps = { children?: ReactNode }; + +const ServerProvider = ({ children }: ServerProviderProps) => ; export default ServerProvider; diff --git a/apps/meteor/client/providers/SessionProvider.tsx b/apps/meteor/client/providers/SessionProvider.tsx index a934ddd53a90f..0fe771c9ee9c8 100644 --- a/apps/meteor/client/providers/SessionProvider.tsx +++ b/apps/meteor/client/providers/SessionProvider.tsx @@ -1,6 +1,6 @@ import { SessionContext } from '@rocket.chat/ui-contexts'; import { Session } from 'meteor/session'; -import type { FC } from 'react'; +import type { ReactNode } from 'react'; import React from 'react'; import { createReactiveSubscriptionFactory } from '../lib/createReactiveSubscriptionFactory'; @@ -12,6 +12,10 @@ const contextValue = { }, }; -const SessionProvider: FC = ({ children }) => ; +type SessionProviderProps = { + children?: ReactNode; +}; + +const SessionProvider = ({ children }: SessionProviderProps) => ; export default SessionProvider; diff --git a/apps/meteor/client/providers/SettingsProvider.tsx b/apps/meteor/client/providers/SettingsProvider.tsx index 87734c6afcca7..84b315bc940f1 100644 --- a/apps/meteor/client/providers/SettingsProvider.tsx +++ b/apps/meteor/client/providers/SettingsProvider.tsx @@ -2,7 +2,7 @@ import type { ISetting } from '@rocket.chat/core-typings'; import type { SettingsContextValue } from '@rocket.chat/ui-contexts'; import { SettingsContext, useAtLeastOnePermission, useMethod } from '@rocket.chat/ui-contexts'; import { Tracker } from 'meteor/tracker'; -import type { FunctionComponent } from 'react'; +import type { ReactNode } from 'react'; import React, { useCallback, useEffect, useMemo, useState } from 'react'; import { createReactiveSubscriptionFactory } from '../lib/createReactiveSubscriptionFactory'; @@ -11,10 +11,11 @@ import { PrivateSettingsCachedCollection } from '../lib/settings/PrivateSettings import { PublicSettingsCachedCollection } from '../lib/settings/PublicSettingsCachedCollection'; type SettingsProviderProps = { - readonly privileged?: boolean; + children?: ReactNode; + privileged?: boolean; }; -const SettingsProvider: FunctionComponent = ({ children, privileged = false }) => { +const SettingsProvider = ({ children, privileged = false }: SettingsProviderProps) => { const hasPrivilegedPermission = useAtLeastOnePermission( useMemo(() => ['view-privileged-setting', 'edit-privileged-setting', 'manage-selected-settings'], []), ); diff --git a/apps/meteor/client/providers/ToastMessagesProvider.tsx b/apps/meteor/client/providers/ToastMessagesProvider.tsx index d85e53996d336..fb011467d0882 100644 --- a/apps/meteor/client/providers/ToastMessagesProvider.tsx +++ b/apps/meteor/client/providers/ToastMessagesProvider.tsx @@ -1,6 +1,6 @@ import { ToastBarProvider, useToastBarDispatch } from '@rocket.chat/fuselage-toastbar'; import { ToastMessagesContext } from '@rocket.chat/ui-contexts'; -import type { FC } from 'react'; +import type { ReactNode } from 'react'; import React, { useEffect } from 'react'; import { getErrorMessage } from '../lib/errorHandling'; @@ -10,7 +10,11 @@ const contextValue = { dispatch: dispatchToastMessage, }; -const ToastMessageInnerProvider: FC = ({ children }) => { +type ToastMessageInnerProviderProps = { + children?: ReactNode; +}; + +const ToastMessageInnerProvider = ({ children }: ToastMessageInnerProviderProps) => { const dispatchToastBar = useToastBarDispatch(); useEffect( @@ -37,8 +41,12 @@ const ToastMessageInnerProvider: FC = ({ children }) => { return ; }; +type ToastMessagesProviderProps = { + children?: ReactNode; +}; + // eslint-disable-next-line react/no-multi-comp -const ToastMessagesProvider: FC = ({ children }) => ( +const ToastMessagesProvider = ({ children }: ToastMessagesProviderProps) => ( diff --git a/apps/meteor/client/providers/TooltipProvider.tsx b/apps/meteor/client/providers/TooltipProvider.tsx index 4cc9aa3a767c6..f3446d366877c 100644 --- a/apps/meteor/client/providers/TooltipProvider.tsx +++ b/apps/meteor/client/providers/TooltipProvider.tsx @@ -1,12 +1,16 @@ import { useDebouncedState, useMediaQuery } from '@rocket.chat/fuselage-hooks'; import { TooltipComponent } from '@rocket.chat/ui-client'; import { TooltipContext } from '@rocket.chat/ui-contexts'; -import type { FC, ReactNode } from 'react'; +import type { ReactNode } from 'react'; import React, { useEffect, useMemo, useRef, memo, useCallback, useState } from 'react'; import TooltipPortal from '../portals/TooltipPortal'; -const TooltipProvider: FC = ({ children }) => { +type TooltipProviderProps = { + children?: ReactNode; +}; + +const TooltipProvider = ({ children }: TooltipProviderProps) => { const lastAnchor = useRef(); const hasHover = !useMediaQuery('(hover: none)'); diff --git a/apps/meteor/client/sidebar/Item/Condensed.tsx b/apps/meteor/client/sidebar/Item/Condensed.tsx index 99222d2e9c70d..cb4f047a098a3 100644 --- a/apps/meteor/client/sidebar/Item/Condensed.tsx +++ b/apps/meteor/client/sidebar/Item/Condensed.tsx @@ -1,7 +1,7 @@ import { IconButton, Sidebar } from '@rocket.chat/fuselage'; import { useMutableCallback, usePrefersReducedMotion } from '@rocket.chat/fuselage-hooks'; import type { Keys as IconName } from '@rocket.chat/icons'; -import type { FC, ReactElement } from 'react'; +import type { ReactElement } from 'react'; import React, { memo, useState } from 'react'; type CondensedProps = { @@ -19,7 +19,7 @@ type CondensedProps = { clickable?: boolean; }; -const Condensed: FC = ({ icon, title = '', avatar, actions, href, unread, menu, badges, ...props }) => { +const Condensed = ({ icon, title = '', avatar, actions, href, unread, menu, badges, ...props }: CondensedProps) => { const [menuVisibility, setMenuVisibility] = useState(!!window.DISABLE_ANIMATION); const isReduceMotionEnabled = usePrefersReducedMotion(); diff --git a/apps/meteor/client/sidebar/Item/Extended.tsx b/apps/meteor/client/sidebar/Item/Extended.tsx index 73493a4aee8fc..e3c3e41cdd35c 100644 --- a/apps/meteor/client/sidebar/Item/Extended.tsx +++ b/apps/meteor/client/sidebar/Item/Extended.tsx @@ -1,7 +1,6 @@ import { Sidebar, IconButton } from '@rocket.chat/fuselage'; import { useMutableCallback, usePrefersReducedMotion } from '@rocket.chat/fuselage-hooks'; import type { Keys as IconName } from '@rocket.chat/icons'; -import type { VFC } from 'react'; import React, { memo, useState } from 'react'; import { useShortTimeAgo } from '../../hooks/useTimeAgo'; @@ -23,7 +22,7 @@ type ExtendedProps = { threadUnread?: boolean; }; -const Extended: VFC = ({ +const Extended = ({ icon, title = '', avatar, @@ -39,7 +38,7 @@ const Extended: VFC = ({ unread, selected, ...props -}) => { +}: ExtendedProps) => { const formatDate = useShortTimeAgo(); const [menuVisibility, setMenuVisibility] = useState(!!window.DISABLE_ANIMATION); diff --git a/apps/meteor/client/sidebar/Item/Medium.tsx b/apps/meteor/client/sidebar/Item/Medium.tsx index 6feed3071ffc7..fde0a20340acb 100644 --- a/apps/meteor/client/sidebar/Item/Medium.tsx +++ b/apps/meteor/client/sidebar/Item/Medium.tsx @@ -1,6 +1,5 @@ import { Sidebar, IconButton } from '@rocket.chat/fuselage'; import { useMutableCallback, usePrefersReducedMotion } from '@rocket.chat/fuselage-hooks'; -import type { VFC } from 'react'; import React, { memo, useState } from 'react'; type MediumProps = { @@ -17,7 +16,7 @@ type MediumProps = { menuOptions?: any; }; -const Medium: VFC = ({ icon, title = '', avatar, actions, href, badges, unread, menu, ...props }) => { +const Medium = ({ icon, title = '', avatar, actions, href, badges, unread, menu, ...props }: MediumProps) => { const [menuVisibility, setMenuVisibility] = useState(!!window.DISABLE_ANIMATION); const isReduceMotionEnabled = usePrefersReducedMotion(); diff --git a/apps/meteor/client/sidebar/RoomList/SideBarItemTemplateWithData.tsx b/apps/meteor/client/sidebar/RoomList/SideBarItemTemplateWithData.tsx index f9ec077e9e430..cc7cdfbe7761a 100644 --- a/apps/meteor/client/sidebar/RoomList/SideBarItemTemplateWithData.tsx +++ b/apps/meteor/client/sidebar/RoomList/SideBarItemTemplateWithData.tsx @@ -8,6 +8,7 @@ import React, { memo, useMemo } from 'react'; import { RoomIcon } from '../../components/RoomIcon'; import { roomCoordinator } from '../../lib/rooms/roomCoordinator'; +import { isIOsDevice } from '../../lib/utils/isIOsDevice'; import { useOmnichannelPriorities } from '../../omnichannel/hooks/useOmnichannelPriorities'; import RoomMenu from '../RoomMenu'; import { OmnichannelBadges } from '../badges/OmnichannelBadges'; @@ -195,6 +196,7 @@ function SideBarItemTemplateWithData({ avatar={AvatarTemplate && } actions={actions} menu={ + !isIOsDevice && !isAnonymous && (!isQueued || (isQueued && isPriorityEnabled)) && ((): ReactElement => ( diff --git a/apps/meteor/client/sidebar/RoomMenu.tsx b/apps/meteor/client/sidebar/RoomMenu.tsx index 8df55bd5d3594..06b1352d28031 100644 --- a/apps/meteor/client/sidebar/RoomMenu.tsx +++ b/apps/meteor/client/sidebar/RoomMenu.tsx @@ -200,10 +200,14 @@ const RoomMenu = ({ const menuOptions = useMemo( () => ({ ...(!hideDefaultOptions && { - hideRoom: { - label: { label: t('Hide'), icon: 'eye-off' }, - action: handleHide, - }, + ...(isOmnichannelRoom + ? {} + : { + hideRoom: { + label: { label: t('Hide'), icon: 'eye-off' }, + action: handleHide, + }, + }), toggleRead: { label: { label: isUnread ? t('Mark_read') : t('Mark_unread'), icon: 'flag' }, action: handleToggleRead, diff --git a/apps/meteor/client/sidebar/SidebarPortal.tsx b/apps/meteor/client/sidebar/SidebarPortal.tsx index 59856df71773d..6046c180ec3af 100644 --- a/apps/meteor/client/sidebar/SidebarPortal.tsx +++ b/apps/meteor/client/sidebar/SidebarPortal.tsx @@ -1,9 +1,13 @@ import { Box } from '@rocket.chat/fuselage'; -import type { FC } from 'react'; +import type { ReactNode } from 'react'; import React, { memo } from 'react'; import { createPortal } from 'react-dom'; -const SidebarPortal: FC = ({ children }) => { +type SidebarPortalProps = { + children?: ReactNode; +}; + +const SidebarPortal = ({ children }: SidebarPortalProps) => { const sidebarRoot = document.getElementById('sidebar-region'); if (!sidebarRoot) { diff --git a/apps/meteor/client/sidebar/header/Header.tsx b/apps/meteor/client/sidebar/header/Header.tsx index b7f00af460aed..b11a103006d2c 100644 --- a/apps/meteor/client/sidebar/header/Header.tsx +++ b/apps/meteor/client/sidebar/header/Header.tsx @@ -1,4 +1,5 @@ -import { Sidebar } from '@rocket.chat/fuselage'; +import { Sidebar, SidebarDivider, SidebarSection } from '@rocket.chat/fuselage'; +import { FeaturePreview, FeaturePreviewOff, FeaturePreviewOn } from '@rocket.chat/ui-client'; import { useTranslation, useUser } from '@rocket.chat/ui-contexts'; import type { ReactElement } from 'react'; import React, { memo } from 'react'; @@ -25,22 +26,45 @@ const Header = (): ReactElement => { const user = useUser(); return ( - - {user ? : } - - - - {user && ( - <> - - - - - - )} - {!user && } - - + + + + {user ? : } + + + + {user && ( + <> + + + + + + )} + {!user && } + + + + + + {user ? : } + + + + {user && ( + <> + + + + + + )} + {!user && } + + + + + ); }; diff --git a/apps/meteor/client/sidebar/header/MatrixFederationSearch/FederatedRoomList.tsx b/apps/meteor/client/sidebar/header/MatrixFederationSearch/FederatedRoomList.tsx index 61984030429dc..b4ddbf32419d1 100644 --- a/apps/meteor/client/sidebar/header/MatrixFederationSearch/FederatedRoomList.tsx +++ b/apps/meteor/client/sidebar/header/MatrixFederationSearch/FederatedRoomList.tsx @@ -2,7 +2,6 @@ import { Throbber, Box } from '@rocket.chat/fuselage'; import type { IFederationPublicRooms } from '@rocket.chat/rest-typings'; import { useSetModal, useEndpoint, useToastMessageDispatch, useTranslation } from '@rocket.chat/ui-contexts'; import { useMutation } from '@tanstack/react-query'; -import type { VFC } from 'react'; import React from 'react'; import { Virtuoso } from 'react-virtuoso'; @@ -19,7 +18,7 @@ type FederatedRoomListProps = { count?: number; }; -const FederatedRoomList: VFC = ({ serverName, roomName, count }) => { +const FederatedRoomList = ({ serverName, roomName, count }: FederatedRoomListProps) => { const joinExternalPublicRoom = useEndpoint('POST', '/v1/federation/joinExternalPublicRoom'); const setModal = useSetModal(); diff --git a/apps/meteor/client/sidebar/header/MatrixFederationSearch/FederatedRoomListEmptyPlaceholder.tsx b/apps/meteor/client/sidebar/header/MatrixFederationSearch/FederatedRoomListEmptyPlaceholder.tsx index 37abbcf850309..8f0a262226797 100644 --- a/apps/meteor/client/sidebar/header/MatrixFederationSearch/FederatedRoomListEmptyPlaceholder.tsx +++ b/apps/meteor/client/sidebar/header/MatrixFederationSearch/FederatedRoomListEmptyPlaceholder.tsx @@ -1,11 +1,10 @@ import { Box } from '@rocket.chat/fuselage'; import { useTranslation } from '@rocket.chat/ui-contexts'; -import type { VFC } from 'react'; import React from 'react'; import GenericNoResults from '../../../components/GenericNoResults'; -const FederatedRoomListEmptyPlaceholder: VFC = () => { +const FederatedRoomListEmptyPlaceholder = () => { const t = useTranslation(); return ( diff --git a/apps/meteor/client/sidebar/header/MatrixFederationSearch/FederatedRoomListItem.tsx b/apps/meteor/client/sidebar/header/MatrixFederationSearch/FederatedRoomListItem.tsx index ff583aa4e8845..dfaa79ed44deb 100644 --- a/apps/meteor/client/sidebar/header/MatrixFederationSearch/FederatedRoomListItem.tsx +++ b/apps/meteor/client/sidebar/header/MatrixFederationSearch/FederatedRoomListItem.tsx @@ -2,7 +2,6 @@ import { css } from '@rocket.chat/css-in-js'; import { Box, Button, Icon } from '@rocket.chat/fuselage'; import type { IFederationPublicRooms } from '@rocket.chat/rest-typings'; import { useTranslation } from '@rocket.chat/ui-contexts'; -import type { VFC } from 'react'; import React from 'react'; type FederatedRoomListItemProps = IFederationPublicRooms & { @@ -14,7 +13,7 @@ const clampLine = css` line-clamp: 6; `; -const FederatedRoomListItem: VFC = ({ +const FederatedRoomListItem = ({ name, topic, canonicalAlias, @@ -22,7 +21,7 @@ const FederatedRoomListItem: VFC = ({ onClickJoin, canJoin, disabled, -}) => { +}: FederatedRoomListItemProps) => { const t = useTranslation(); return ( diff --git a/apps/meteor/client/sidebar/header/MatrixFederationSearch/MatrixFederationManageServerModal.tsx b/apps/meteor/client/sidebar/header/MatrixFederationSearch/MatrixFederationManageServerModal.tsx index 51021a91549a5..e3c953dcb950c 100644 --- a/apps/meteor/client/sidebar/header/MatrixFederationSearch/MatrixFederationManageServerModal.tsx +++ b/apps/meteor/client/sidebar/header/MatrixFederationSearch/MatrixFederationManageServerModal.tsx @@ -2,7 +2,7 @@ import { Divider, Modal, ButtonGroup, Button, Field, TextInput, FieldLabel, Fiel 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'; -import type { VFC, FormEvent } from 'react'; +import type { FormEvent } from 'react'; import React, { useState } from 'react'; import MatrixFederationRemoveServerList from './MatrixFederationRemoveServerList'; @@ -25,7 +25,7 @@ const getErrorKey = (error: any): TranslationKey | undefined => { } }; -const MatrixFederationAddServerModal: VFC = ({ onClickClose }) => { +const MatrixFederationAddServerModal = ({ onClickClose }: MatrixFederationAddServerModalProps) => { const t = useTranslation(); const addMatrixServer = useEndpoint('POST', '/v1/federation/addServerByUser'); const [serverName, setServerName] = useState(''); diff --git a/apps/meteor/client/sidebar/header/MatrixFederationSearch/MatrixFederationRemoveServerList.tsx b/apps/meteor/client/sidebar/header/MatrixFederationSearch/MatrixFederationRemoveServerList.tsx index c6fb0487413e3..361950cd39c99 100644 --- a/apps/meteor/client/sidebar/header/MatrixFederationSearch/MatrixFederationRemoveServerList.tsx +++ b/apps/meteor/client/sidebar/header/MatrixFederationSearch/MatrixFederationRemoveServerList.tsx @@ -2,7 +2,6 @@ import { css } from '@rocket.chat/css-in-js'; import { Box, Option, Icon } from '@rocket.chat/fuselage'; import { useTranslation, useEndpoint } from '@rocket.chat/ui-contexts'; import { useMutation, useQueryClient } from '@tanstack/react-query'; -import type { VFC } from 'react'; import React from 'react'; type MatrixFederationRemoveServerListProps = { @@ -24,7 +23,7 @@ const style = css` } `; -const MatrixFederationRemoveServerList: VFC = ({ servers }) => { +const MatrixFederationRemoveServerList = ({ servers }: MatrixFederationRemoveServerListProps) => { const removeMatrixServer = useEndpoint('POST', '/v1/federation/removeServerByUser'); const queryClient = useQueryClient(); diff --git a/apps/meteor/client/sidebar/header/MatrixFederationSearch/MatrixFederationSearch.tsx b/apps/meteor/client/sidebar/header/MatrixFederationSearch/MatrixFederationSearch.tsx index 59bda6233907e..f3dc779d28c1a 100644 --- a/apps/meteor/client/sidebar/header/MatrixFederationSearch/MatrixFederationSearch.tsx +++ b/apps/meteor/client/sidebar/header/MatrixFederationSearch/MatrixFederationSearch.tsx @@ -1,7 +1,6 @@ import { Modal, Skeleton } from '@rocket.chat/fuselage'; import { useTranslation } from '@rocket.chat/ui-contexts'; import React from 'react'; -import type { VFC } from 'react'; import MatrixFederationSearchModalContent from './MatrixFederationSearchModalContent'; import { useMatrixServerList } from './useMatrixServerList'; @@ -11,7 +10,7 @@ type MatrixFederationSearchProps = { defaultSelectedServer?: string; }; -const MatrixFederationSearch: VFC = ({ onClose, defaultSelectedServer }) => { +const MatrixFederationSearch = ({ onClose, defaultSelectedServer }: MatrixFederationSearchProps) => { const t = useTranslation(); const { data, isLoading } = useMatrixServerList(); diff --git a/apps/meteor/client/sidebar/header/MatrixFederationSearch/MatrixFederationSearchModalContent.tsx b/apps/meteor/client/sidebar/header/MatrixFederationSearch/MatrixFederationSearchModalContent.tsx index ff9a1cbd822b1..ec6396a834409 100644 --- a/apps/meteor/client/sidebar/header/MatrixFederationSearch/MatrixFederationSearchModalContent.tsx +++ b/apps/meteor/client/sidebar/header/MatrixFederationSearch/MatrixFederationSearchModalContent.tsx @@ -2,7 +2,7 @@ import type { SelectOption } from '@rocket.chat/fuselage'; import { Box, Select, TextInput } from '@rocket.chat/fuselage'; import { useDebouncedValue } from '@rocket.chat/fuselage-hooks'; import { useSetModal, useTranslation } from '@rocket.chat/ui-contexts'; -import type { VFC, FormEvent } from 'react'; +import type { FormEvent } from 'react'; import React, { useCallback, useState, useMemo } from 'react'; import FederatedRoomList from './FederatedRoomList'; @@ -19,7 +19,7 @@ type MatrixFederationSearchModalContentProps = { defaultSelectedServer?: string; }; -const MatrixFederationSearchModalContent: VFC = ({ defaultSelectedServer, servers }) => { +const MatrixFederationSearchModalContent = ({ defaultSelectedServer, servers }: MatrixFederationSearchModalContentProps) => { const [serverName, setServerName] = useState(() => { const defaultServer = servers.find((server) => server.name === defaultSelectedServer); return defaultServer?.name ?? servers[0].name; diff --git a/apps/meteor/client/sidebar/header/actions/Administration.tsx b/apps/meteor/client/sidebar/header/actions/Administration.tsx index d2e51f1913709..fbed4afec4cb6 100644 --- a/apps/meteor/client/sidebar/header/actions/Administration.tsx +++ b/apps/meteor/client/sidebar/header/actions/Administration.tsx @@ -1,12 +1,14 @@ import { Sidebar } from '@rocket.chat/fuselage'; import { useTranslation } from '@rocket.chat/ui-contexts'; -import type { HTMLAttributes, VFC } from 'react'; +import type { HTMLAttributes } from 'react'; import React from 'react'; import GenericMenu from '../../../components/GenericMenu/GenericMenu'; import { useAdministrationMenu } from './hooks/useAdministrationMenu'; -const Administration: VFC, 'is'>> = (props) => { +type AdministrationProps = Omit, 'is'>; + +const Administration = (props: AdministrationProps) => { const t = useTranslation(); const sections = useAdministrationMenu(); diff --git a/apps/meteor/client/sidebar/header/actions/CreateRoom.tsx b/apps/meteor/client/sidebar/header/actions/CreateRoom.tsx index 5289a1a9d8a55..478e7cce33e1b 100644 --- a/apps/meteor/client/sidebar/header/actions/CreateRoom.tsx +++ b/apps/meteor/client/sidebar/header/actions/CreateRoom.tsx @@ -1,12 +1,14 @@ import { Sidebar } from '@rocket.chat/fuselage'; import { useTranslation } from '@rocket.chat/ui-contexts'; -import type { HTMLAttributes, VFC } from 'react'; +import type { HTMLAttributes } from 'react'; import React from 'react'; import GenericMenu from '../../../components/GenericMenu/GenericMenu'; import { useCreateRoom } from './hooks/useCreateRoomMenu'; -const CreateRoom: VFC, 'is'>> = (props) => { +type CreateRoomProps = Omit, 'is'>; + +const CreateRoom = (props: CreateRoomProps) => { const t = useTranslation(); const sections = useCreateRoom(); diff --git a/apps/meteor/client/sidebar/header/actions/Home.tsx b/apps/meteor/client/sidebar/header/actions/Home.tsx index 09dbc9de5a647..933ccde69fbf6 100644 --- a/apps/meteor/client/sidebar/header/actions/Home.tsx +++ b/apps/meteor/client/sidebar/header/actions/Home.tsx @@ -1,10 +1,12 @@ import { Sidebar } from '@rocket.chat/fuselage'; import { useMutableCallback } from '@rocket.chat/fuselage-hooks'; import { useRouter, useLayout, useSetting, useCurrentRoutePath } from '@rocket.chat/ui-contexts'; -import type { HTMLAttributes, VFC } from 'react'; +import type { HTMLAttributes } from 'react'; import React from 'react'; -const SidebarHeaderActionHome: VFC, 'is'>> = (props) => { +type SidebarHeaderActionHomeProps = Omit, 'is'>; + +const SidebarHeaderActionHome = (props: SidebarHeaderActionHomeProps) => { const router = useRouter(); const { sidebar } = useLayout(); const showHome = useSetting('Layout_Show_Home_Button'); diff --git a/apps/meteor/client/sidebar/header/actions/Login.tsx b/apps/meteor/client/sidebar/header/actions/Login.tsx index e1c699cc1db07..f5e97b4888c40 100644 --- a/apps/meteor/client/sidebar/header/actions/Login.tsx +++ b/apps/meteor/client/sidebar/header/actions/Login.tsx @@ -1,9 +1,11 @@ import { Sidebar } from '@rocket.chat/fuselage'; import { useSessionDispatch, useTranslation } from '@rocket.chat/ui-contexts'; -import type { HTMLAttributes, VFC } from 'react'; +import type { HTMLAttributes } from 'react'; import React from 'react'; -const Login: VFC, 'is'>> = (props) => { +type LoginProps = Omit, 'is'>; + +const Login = (props: LoginProps) => { const setForceLogin = useSessionDispatch('forceLogin'); const t = useTranslation(); diff --git a/apps/meteor/client/sidebar/header/actions/Search.tsx b/apps/meteor/client/sidebar/header/actions/Search.tsx index 40013375254fa..b056128f05689 100644 --- a/apps/meteor/client/sidebar/header/actions/Search.tsx +++ b/apps/meteor/client/sidebar/header/actions/Search.tsx @@ -1,12 +1,14 @@ import { Sidebar } from '@rocket.chat/fuselage'; import { useMutableCallback, useOutsideClick } from '@rocket.chat/fuselage-hooks'; -import type { VFC, HTMLAttributes } from 'react'; +import type { HTMLAttributes } from 'react'; import React, { useState, useEffect, useRef } from 'react'; import tinykeys from 'tinykeys'; import SearchList from '../../search/SearchList'; -const Search: VFC, 'is'>> = (props) => { +type SearchProps = Omit, 'is'>; + +const Search = (props: SearchProps) => { const [searchOpen, setSearchOpen] = useState(false); const ref = useRef(null); diff --git a/apps/meteor/client/sidebar/header/actions/Sort.tsx b/apps/meteor/client/sidebar/header/actions/Sort.tsx index 330ad6b233e6a..e7f3b398e5f62 100644 --- a/apps/meteor/client/sidebar/header/actions/Sort.tsx +++ b/apps/meteor/client/sidebar/header/actions/Sort.tsx @@ -1,12 +1,14 @@ import { Sidebar } from '@rocket.chat/fuselage'; import { useTranslation } from '@rocket.chat/ui-contexts'; -import type { VFC, HTMLAttributes } from 'react'; +import type { HTMLAttributes } from 'react'; import React from 'react'; import GenericMenu from '../../../components/GenericMenu/GenericMenu'; import { useSortMenu } from './hooks/useSortMenu'; -const Sort: VFC, 'is'>> = (props) => { +type SortProps = Omit, 'is'>; + +const Sort = (props: SortProps) => { const t = useTranslation(); const sections = useSortMenu(); diff --git a/apps/meteor/client/sidebar/header/hooks/useCreateRoomModal.tsx b/apps/meteor/client/sidebar/header/hooks/useCreateRoomModal.tsx index 2b371ec1b0eff..895467b57690e 100644 --- a/apps/meteor/client/sidebar/header/hooks/useCreateRoomModal.tsx +++ b/apps/meteor/client/sidebar/header/hooks/useCreateRoomModal.tsx @@ -1,9 +1,9 @@ import { useMutableCallback } from '@rocket.chat/fuselage-hooks'; import { useSetModal } from '@rocket.chat/ui-contexts'; -import type { FC } from 'react'; +import type { ElementType } from 'react'; import React from 'react'; -export const useCreateRoomModal = (Component: FC): (() => void) => { +export const useCreateRoomModal = (Component: ElementType<{ onClose: () => void }>): (() => void) => { const setModal = useSetModal(); return useMutableCallback(() => { diff --git a/apps/meteor/client/sidebar/hooks/useRoomList.ts b/apps/meteor/client/sidebar/hooks/useRoomList.ts index fa5dfd2797cb1..afdc57086dc44 100644 --- a/apps/meteor/client/sidebar/hooks/useRoomList.ts +++ b/apps/meteor/client/sidebar/hooks/useRoomList.ts @@ -12,12 +12,39 @@ const query = { open: { $ne: false } }; const emptyQueue: ILivechatInquiryRecord[] = []; +const order: ( + | 'Incoming_Calls' + | 'Incoming_Livechats' + | 'Open_Livechats' + | 'On_Hold_Chats' + | 'Unread' + | 'Favorites' + | 'Teams' + | 'Discussions' + | 'Channels' + | 'Direct_Messages' + | 'Conversations' +)[] = [ + 'Incoming_Calls', + 'Incoming_Livechats', + 'Open_Livechats', + 'On_Hold_Chats', + 'Unread', + 'Favorites', + 'Teams', + 'Discussions', + 'Channels', + 'Direct_Messages', + 'Conversations', +]; + export const useRoomList = (): Array => { const [roomList, setRoomList] = useDebouncedState<(ISubscription & IRoom)[]>([], 150); const showOmnichannel = useOmnichannelEnabled(); const sidebarGroupByType = useUserPreference('sidebarGroupByType'); const favoritesEnabled = useUserPreference('sidebarShowFavorites'); + const sidebarOrder = useUserPreference('sidebarSectionsOrder') ?? order; const isDiscussionEnabled = useSetting('Discussion_enabled'); const sidebarShowUnread = useUserPreference('sidebarShowUnread'); @@ -92,7 +119,7 @@ export const useRoomList = (): Array => { }); const groups = new Map(); - incomingCall.size && groups.set('Incoming Calls', incomingCall); + incomingCall.size && groups.set('Incoming_Calls', incomingCall); showOmnichannel && inquiries.enabled && queue.length && groups.set('Incoming_Livechats', queue); showOmnichannel && omnichannel.size && groups.set('Open_Livechats', omnichannel); showOmnichannel && onHold.size && groups.set('On_Hold_Chats', onHold); @@ -103,7 +130,16 @@ export const useRoomList = (): Array => { sidebarGroupByType && channels.size && groups.set('Channels', channels); sidebarGroupByType && direct.size && groups.set('Direct_Messages', direct); !sidebarGroupByType && groups.set('Conversations', conversation); - return [...groups.entries()].flatMap(([key, group]) => [key, ...group]); + return sidebarOrder + .map((key) => { + const group = groups.get(key); + if (!group) { + return []; + } + + return [key, ...group]; + }) + .flat(); }); }, [ rooms, @@ -116,6 +152,7 @@ export const useRoomList = (): Array => { sidebarGroupByType, setRoomList, isDiscussionEnabled, + sidebarOrder, ]); return roomList; diff --git a/apps/meteor/client/sidebar/search/SearchList.tsx b/apps/meteor/client/sidebar/search/SearchList.tsx index d215a77ce4bd5..dd97678f638ae 100644 --- a/apps/meteor/client/sidebar/search/SearchList.tsx +++ b/apps/meteor/client/sidebar/search/SearchList.tsx @@ -338,15 +338,17 @@ const SearchList = forwardRef(function SearchList({ onClose }: SearchListProps, role='search' > - } - /> + + } + /> + { <> {isWorkspaceOverMacLimit && } - - {t('Omnichannel')} - - {showOmnichannelQueueLink && ( - handleRoute('queue')} /> - )} - {isCallEnabled && } - - {hasPermissionToSeeContactCenter && ( - handleRoute('directory')} - /> - )} - {isCallReady && } - - + + + + {t('Omnichannel')} + + {showOmnichannelQueueLink && ( + handleRoute('queue')} /> + )} + {isCallEnabled && } + + {hasPermissionToSeeContactCenter && ( + handleRoute('directory')} + /> + )} + {isCallReady && } + + + + + + {t('Omnichannel')} + + {showOmnichannelQueueLink && ( + handleRoute('queue')} /> + )} + {isCallEnabled && } + + {hasPermissionToSeeContactCenter && ( + handleRoute('directory')} + /> + )} + {isCallReady && } + + + + + ); }; diff --git a/apps/meteor/client/sidebarv2/Item/Condensed.stories.tsx b/apps/meteor/client/sidebarv2/Item/Condensed.stories.tsx new file mode 100644 index 0000000000000..f63893a30a81b --- /dev/null +++ b/apps/meteor/client/sidebarv2/Item/Condensed.stories.tsx @@ -0,0 +1,73 @@ +import { Box, IconButton } from '@rocket.chat/fuselage'; +import { UserAvatar } from '@rocket.chat/ui-avatar'; +import { action } from '@storybook/addon-actions'; +import type { ComponentMeta, ComponentStory } from '@storybook/react'; +import React from 'react'; + +import * as Status from '../../components/UserStatus'; +import Condensed from './Condensed'; + +export default { + title: 'Sidebar/Condensed', + component: Condensed, + args: { + clickable: true, + title: 'John Doe', + }, + decorators: [ + (fn) => ( + + {fn()} + + ), + ], +} as ComponentMeta; + +const Template: ComponentStory = (args) => ( + + + + } + avatar={} + /> +); + +export const Normal = Template.bind({}); + +export const Selected = Template.bind({}); +Selected.args = { + selected: true, +}; + +export const Menu = Template.bind({}); +Menu.args = { + menuOptions: { + hide: { + label: { label: 'Hide', icon: 'eye-off' }, + action: action('action'), + }, + read: { + label: { label: 'Mark_read', icon: 'flag' }, + action: action('action'), + }, + favorite: { + label: { label: 'Favorite', icon: 'star' }, + action: action('action'), + }, + }, +}; + +export const Actions = Template.bind({}); +Actions.args = { + actions: ( + <> + + + + + + ), +}; diff --git a/apps/meteor/client/sidebarv2/Item/Condensed.tsx b/apps/meteor/client/sidebarv2/Item/Condensed.tsx new file mode 100644 index 0000000000000..db76935d4c3ff --- /dev/null +++ b/apps/meteor/client/sidebarv2/Item/Condensed.tsx @@ -0,0 +1,60 @@ +import { IconButton, Sidebar } from '@rocket.chat/fuselage'; +import { useEffectEvent, usePrefersReducedMotion } from '@rocket.chat/fuselage-hooks'; +import type { Keys as IconName } from '@rocket.chat/icons'; +import type { ReactElement } from 'react'; +import React, { memo, useState } from 'react'; + +type CondensedProps = { + title: ReactElement | string; + titleIcon?: ReactElement; + avatar: ReactElement | boolean; + icon?: IconName; + actions?: ReactElement; + href?: string; + unread?: boolean; + menu?: () => ReactElement; + menuOptions?: any; + selected?: boolean; + badges?: ReactElement; + clickable?: boolean; +}; + +const Condensed = ({ icon, title = '', avatar, actions, href, unread, menu, badges, ...props }: CondensedProps) => { + const [menuVisibility, setMenuVisibility] = useState(!!window.DISABLE_ANIMATION); + + const isReduceMotionEnabled = usePrefersReducedMotion(); + + const handleMenu = useEffectEvent((e) => { + setMenuVisibility(e.target.offsetWidth > 0 && Boolean(menu)); + }); + const handleMenuEvent = { + [isReduceMotionEnabled ? 'onMouseEnter' : 'onTransitionEnd']: handleMenu, + }; + + return ( + + {avatar && {avatar}} + + + {icon} + + {title} + + + {badges && {badges}} + {menu && ( + + {menuVisibility ? menu() : } + + )} + + {actions && ( + + {actions} + + )} + + ); +}; + +export default memo(Condensed); diff --git a/apps/meteor/client/sidebarv2/Item/Extended.stories.tsx b/apps/meteor/client/sidebarv2/Item/Extended.stories.tsx new file mode 100644 index 0000000000000..a6392eae5d61c --- /dev/null +++ b/apps/meteor/client/sidebarv2/Item/Extended.stories.tsx @@ -0,0 +1,98 @@ +import { Box, IconButton, Badge } from '@rocket.chat/fuselage'; +import { UserAvatar } from '@rocket.chat/ui-avatar'; +import { action } from '@storybook/addon-actions'; +import type { ComponentMeta, ComponentStory } from '@storybook/react'; +import React from 'react'; + +import * as Status from '../../components/UserStatus'; +import Extended from './Extended'; + +export default { + title: 'Sidebar/Extended', + component: Extended, + args: { + clickable: true, + }, + decorators: [ + (fn) => ( + + {fn()} + + ), + ], +} as ComponentMeta; + +const Template: ComponentStory = (args) => ( + + + John Doe + + 15:38 + + } + subtitle={ + + + John Doe: test 123 + + + 99 + + + } + titleIcon={ + + + + } + avatar={} + /> +); + +export const Normal = Template.bind({}); + +export const Selected = Template.bind({}); +Selected.args = { + selected: true, +}; + +export const Menu = Template.bind({}); +Menu.args = { + menuOptions: { + hide: { + label: { label: 'Hide', icon: 'eye-off' }, + action: action('action'), + }, + read: { + label: { label: 'Mark_read', icon: 'flag' }, + action: action('action'), + }, + favorite: { + label: { label: 'Favorite', icon: 'star' }, + action: action('action'), + }, + }, +}; + +export const Actions = Template.bind({}); +Actions.args = { + actions: ( + <> + + + + + + ), +}; diff --git a/apps/meteor/client/sidebarv2/Item/Extended.tsx b/apps/meteor/client/sidebarv2/Item/Extended.tsx new file mode 100644 index 0000000000000..f288f5fd35c6b --- /dev/null +++ b/apps/meteor/client/sidebarv2/Item/Extended.tsx @@ -0,0 +1,89 @@ +import { Sidebar, IconButton } from '@rocket.chat/fuselage'; +import { useEffectEvent, usePrefersReducedMotion } from '@rocket.chat/fuselage-hooks'; +import type { Keys as IconName } from '@rocket.chat/icons'; +import React, { memo, useState } from 'react'; + +import { useShortTimeAgo } from '../../hooks/useTimeAgo'; + +type ExtendedProps = { + icon?: IconName; + title?: React.ReactNode; + avatar?: React.ReactNode | boolean; + actions?: React.ReactNode; + href?: string; + time?: any; + menu?: () => React.ReactNode; + subtitle?: React.ReactNode; + badges?: React.ReactNode; + unread?: boolean; + selected?: boolean; + menuOptions?: any; + titleIcon?: React.ReactNode; + threadUnread?: boolean; +}; + +const Extended = ({ + icon, + title = '', + avatar, + actions, + href, + time, + menu, + menuOptions: _menuOptions, + subtitle = '', + titleIcon: _titleIcon, + badges, + threadUnread: _threadUnread, + unread, + selected, + ...props +}: ExtendedProps) => { + const formatDate = useShortTimeAgo(); + const [menuVisibility, setMenuVisibility] = useState(!!window.DISABLE_ANIMATION); + + const isReduceMotionEnabled = usePrefersReducedMotion(); + + const handleMenu = useEffectEvent((e) => { + setMenuVisibility(e.target.offsetWidth > 0 && Boolean(menu)); + }); + + const handleMenuEvent = { + [isReduceMotionEnabled ? 'onMouseEnter' : 'onTransitionEnd']: handleMenu, + }; + + return ( + + {avatar && {avatar}} + + + + {icon} + + {title} + + {time && {formatDate(time)}} + + + + + {subtitle} + {badges} + {menu && ( + + {menuVisibility ? menu() : } + + )} + + + + {actions && ( + + {actions} + + )} + + ); +}; + +export default memo(Extended); diff --git a/apps/meteor/client/sidebarv2/Item/Medium.stories.tsx b/apps/meteor/client/sidebarv2/Item/Medium.stories.tsx new file mode 100644 index 0000000000000..0c03cf33c5000 --- /dev/null +++ b/apps/meteor/client/sidebarv2/Item/Medium.stories.tsx @@ -0,0 +1,73 @@ +import { Box, IconButton } from '@rocket.chat/fuselage'; +import { UserAvatar } from '@rocket.chat/ui-avatar'; +import { action } from '@storybook/addon-actions'; +import type { ComponentMeta, ComponentStory } from '@storybook/react'; +import React from 'react'; + +import * as Status from '../../components/UserStatus'; +import Medium from './Medium'; + +export default { + title: 'Sidebar/Medium', + component: Medium, + args: { + clickable: true, + title: 'John Doe', + }, + decorators: [ + (fn) => ( + + {fn()} + + ), + ], +} as ComponentMeta; + +const Template: ComponentStory = (args) => ( + + + + } + avatar={} + /> +); + +export const Normal = Template.bind({}); + +export const Selected = Template.bind({}); +Selected.args = { + selected: true, +}; + +export const Menu = Template.bind({}); +Menu.args = { + menuOptions: { + hide: { + label: { label: 'Hide', icon: 'eye-off' }, + action: action('action'), + }, + read: { + label: { label: 'Mark_read', icon: 'flag' }, + action: action('action'), + }, + favorite: { + label: { label: 'Favorite', icon: 'star' }, + action: action('action'), + }, + }, +}; + +export const Actions = Template.bind({}); +Actions.args = { + actions: ( + <> + + + + + + ), +}; diff --git a/apps/meteor/client/sidebarv2/Item/Medium.tsx b/apps/meteor/client/sidebarv2/Item/Medium.tsx new file mode 100644 index 0000000000000..ffc13047f66d2 --- /dev/null +++ b/apps/meteor/client/sidebarv2/Item/Medium.tsx @@ -0,0 +1,57 @@ +import { Sidebar, IconButton } from '@rocket.chat/fuselage'; +import { useEffectEvent, usePrefersReducedMotion } from '@rocket.chat/fuselage-hooks'; +import React, { memo, useState } from 'react'; + +type MediumProps = { + title: React.ReactNode; + titleIcon?: React.ReactNode; + avatar: React.ReactNode | boolean; + icon?: string; + actions?: React.ReactNode; + href?: string; + unread?: boolean; + menu?: () => React.ReactNode; + badges?: React.ReactNode; + selected?: boolean; + menuOptions?: any; +}; + +const Medium = ({ icon, title = '', avatar, actions, href, badges, unread, menu, ...props }: MediumProps) => { + const [menuVisibility, setMenuVisibility] = useState(!!window.DISABLE_ANIMATION); + + const isReduceMotionEnabled = usePrefersReducedMotion(); + + const handleMenu = useEffectEvent((e) => { + setMenuVisibility(e.target.offsetWidth > 0 && Boolean(menu)); + }); + const handleMenuEvent = { + [isReduceMotionEnabled ? 'onMouseEnter' : 'onTransitionEnd']: handleMenu, + }; + + return ( + + {avatar && {avatar}} + + + {icon} + + {title} + + + {badges && {badges}} + {menu && ( + + {menuVisibility ? menu() : } + + )} + + {actions && ( + + {actions} + + )} + + ); +}; + +export default memo(Medium); diff --git a/apps/meteor/client/sidebarv2/RoomList/RoomList.tsx b/apps/meteor/client/sidebarv2/RoomList/RoomList.tsx new file mode 100644 index 0000000000000..3f137d4709c79 --- /dev/null +++ b/apps/meteor/client/sidebarv2/RoomList/RoomList.tsx @@ -0,0 +1,135 @@ +import type { IRoom } from '@rocket.chat/core-typings'; +import { css } from '@rocket.chat/css-in-js'; +import { Box } from '@rocket.chat/fuselage'; +import { useResizeObserver } from '@rocket.chat/fuselage-hooks'; +import { useUserPreference, useUserId, useTranslation } from '@rocket.chat/ui-contexts'; +import type { ReactElement } from 'react'; +import React, { useMemo } from 'react'; +import { Virtuoso } from 'react-virtuoso'; + +import { VirtuosoScrollbars } from '../../components/CustomScrollbars'; +import { useOpenedRoom } from '../../lib/RoomManager'; +import { useAvatarTemplate } from '../hooks/useAvatarTemplate'; +import { usePreventDefault } from '../hooks/usePreventDefault'; +import { useRoomList } from '../hooks/useRoomList'; +import { useShortcutOpenMenu } from '../hooks/useShortcutOpenMenu'; +import { useTemplateByViewMode } from '../hooks/useTemplateByViewMode'; +import RoomListRow from './RoomListRow'; +import RoomListRowWrapper from './RoomListRowWrapper'; +import RoomListWrapper from './RoomListWrapper'; + +const computeItemKey = (index: number, room: IRoom): IRoom['_id'] | number => room._id || index; + +const RoomList = () => { + const t = useTranslation(); + const isAnonymous = !useUserId(); + const roomsList = useRoomList(); + const avatarTemplate = useAvatarTemplate(); + const sideBarItemTemplate = useTemplateByViewMode(); + const { ref } = useResizeObserver({ debounceDelay: 100 }); + const openedRoom = useOpenedRoom() ?? ''; + const sidebarViewMode = useUserPreference<'extended' | 'medium' | 'condensed'>('sidebarViewMode') || 'extended'; + + const extended = sidebarViewMode === 'extended'; + const itemData = useMemo( + () => ({ + extended, + t, + SideBarItemTemplate: sideBarItemTemplate, + AvatarTemplate: avatarTemplate, + openedRoom, + sidebarViewMode, + isAnonymous, + }), + [avatarTemplate, extended, isAnonymous, openedRoom, sideBarItemTemplate, sidebarViewMode, t], + ); + + usePreventDefault(ref); + useShortcutOpenMenu(ref); + + const roomsListStyle = css` + position: relative; + + display: flex; + + overflow-x: hidden; + overflow-y: hidden; + + flex: 1 1 auto; + + height: 100%; + + &--embedded { + margin-top: 2rem; + } + + &__list:not(:last-child) { + margin-bottom: 22px; + } + + &__type { + display: flex; + + flex-direction: row; + + padding: 0 var(--sidebar-default-padding) 1rem var(--sidebar-default-padding); + + color: var(--rooms-list-title-color); + + font-size: var(--rooms-list-title-text-size); + align-items: center; + justify-content: space-between; + + &-text--livechat { + flex: 1; + } + } + + &__empty-room { + padding: 0 var(--sidebar-default-padding); + + color: var(--rooms-list-empty-text-color); + + font-size: var(--rooms-list-empty-text-size); + } + + &__toolbar-search { + position: absolute; + z-index: 10; + left: 0; + + overflow-y: scroll; + + height: 100%; + + background-color: var(--sidebar-background); + + padding-block-start: 12px; + } + + @media (max-width: 400px) { + padding: 0 calc(var(--sidebar-small-default-padding) - 4px); + + &__type, + &__empty-room { + padding: 0 calc(var(--sidebar-small-default-padding) - 4px) 0.5rem calc(var(--sidebar-small-default-padding) - 4px); + } + } + `; + + return ( + + + } + /> + + + ); +}; + +export default RoomList; diff --git a/apps/meteor/client/sidebarv2/RoomList/RoomListRow.tsx b/apps/meteor/client/sidebarv2/RoomList/RoomListRow.tsx new file mode 100644 index 0000000000000..64796d2e12e46 --- /dev/null +++ b/apps/meteor/client/sidebarv2/RoomList/RoomListRow.tsx @@ -0,0 +1,63 @@ +import type { IRoom, ISubscription } from '@rocket.chat/core-typings'; +import { SidebarSection } from '@rocket.chat/fuselage'; +import type { useTranslation } from '@rocket.chat/ui-contexts'; +import React, { memo, useMemo } from 'react'; + +import { useVideoConfAcceptCall, useVideoConfRejectIncomingCall, useVideoConfIncomingCalls } from '../../contexts/VideoConfContext'; +import type { useAvatarTemplate } from '../hooks/useAvatarTemplate'; +import type { useTemplateByViewMode } from '../hooks/useTemplateByViewMode'; +import SideBarItemTemplateWithData from './SideBarItemTemplateWithData'; + +type RoomListRowProps = { + data: { + extended: boolean; + t: ReturnType; + SideBarItemTemplate: ReturnType; + AvatarTemplate: ReturnType; + openedRoom: string; + sidebarViewMode: 'extended' | 'condensed' | 'medium'; + isAnonymous: boolean; + }; + item: ISubscription & IRoom; +}; + +const RoomListRow = ({ data, item }: RoomListRowProps) => { + const { extended, t, SideBarItemTemplate, AvatarTemplate, openedRoom, sidebarViewMode } = data; + + const acceptCall = useVideoConfAcceptCall(); + const rejectCall = useVideoConfRejectIncomingCall(); + const incomingCalls = useVideoConfIncomingCalls(); + const currentCall = incomingCalls.find((call) => call.rid === item.rid); + + const videoConfActions = useMemo( + () => + currentCall && { + acceptCall: (): void => acceptCall(currentCall.callId), + rejectCall: (): void => rejectCall(currentCall.callId), + }, + [acceptCall, rejectCall, currentCall], + ); + + if (typeof item === 'string') { + return ( + + {t(item)} + + ); + } + + return ( + + ); +}; + +export default memo(RoomListRow); diff --git a/apps/meteor/client/sidebarv2/RoomList/RoomListRowWrapper.tsx b/apps/meteor/client/sidebarv2/RoomList/RoomListRowWrapper.tsx new file mode 100644 index 0000000000000..b2cd751934660 --- /dev/null +++ b/apps/meteor/client/sidebarv2/RoomList/RoomListRowWrapper.tsx @@ -0,0 +1,10 @@ +import type { ForwardedRef, HTMLAttributes } from 'react'; +import React, { forwardRef } from 'react'; + +type RoomListRoomWrapperProps = HTMLAttributes; + +const RoomListRoomWrapper = forwardRef(function RoomListRoomWrapper(props: RoomListRoomWrapperProps, ref: ForwardedRef) { + return
    ; +}); + +export default RoomListRoomWrapper; diff --git a/apps/meteor/client/sidebarv2/RoomList/RoomListWrapper.tsx b/apps/meteor/client/sidebarv2/RoomList/RoomListWrapper.tsx new file mode 100644 index 0000000000000..b4d4b4a44c985 --- /dev/null +++ b/apps/meteor/client/sidebarv2/RoomList/RoomListWrapper.tsx @@ -0,0 +1,18 @@ +import { useMergedRefs } from '@rocket.chat/fuselage-hooks'; +import { useTranslation } from '@rocket.chat/ui-contexts'; +import type { ForwardedRef, HTMLAttributes } from 'react'; +import React, { forwardRef } from 'react'; + +import { useSidebarListNavigation } from './useSidebarListNavigation'; + +type RoomListWrapperProps = HTMLAttributes; + +const RoomListWrapper = forwardRef(function RoomListWrapper(props: RoomListWrapperProps, ref: ForwardedRef) { + const t = useTranslation(); + const { sidebarListRef } = useSidebarListNavigation(); + const mergedRefs = useMergedRefs(ref, sidebarListRef); + + return
    ; +}); + +export default RoomListWrapper; diff --git a/apps/meteor/client/sidebarv2/RoomList/SideBarItemTemplateWithData.tsx b/apps/meteor/client/sidebarv2/RoomList/SideBarItemTemplateWithData.tsx new file mode 100644 index 0000000000000..4eaba8cc37f04 --- /dev/null +++ b/apps/meteor/client/sidebarv2/RoomList/SideBarItemTemplateWithData.tsx @@ -0,0 +1,277 @@ +import type { IMessage, IRoom, ISubscription } from '@rocket.chat/core-typings'; +import { isDirectMessageRoom, isMultipleDirectMessageRoom, isOmnichannelRoom, isVideoConfMessage } from '@rocket.chat/core-typings'; +import { Badge, Sidebar, SidebarItemAction, SidebarItemActions, Margins } from '@rocket.chat/fuselage'; +import type { useTranslation } from '@rocket.chat/ui-contexts'; +import { useLayout } from '@rocket.chat/ui-contexts'; +import type { AllHTMLAttributes, ComponentType, ReactElement, ReactNode } from 'react'; +import React, { memo, useMemo } from 'react'; + +import { RoomIcon } from '../../components/RoomIcon'; +import { roomCoordinator } from '../../lib/rooms/roomCoordinator'; +import { isIOsDevice } from '../../lib/utils/isIOsDevice'; +import { useOmnichannelPriorities } from '../../omnichannel/hooks/useOmnichannelPriorities'; +import RoomMenu from '../RoomMenu'; +import { OmnichannelBadges } from '../badges/OmnichannelBadges'; +import type { useAvatarTemplate } from '../hooks/useAvatarTemplate'; +import { normalizeSidebarMessage } from './normalizeSidebarMessage'; + +const getMessage = (room: IRoom, lastMessage: IMessage | undefined, t: ReturnType): string | undefined => { + if (!lastMessage) { + return t('No_messages_yet'); + } + if (isVideoConfMessage(lastMessage)) { + return t('Call_started'); + } + if (!lastMessage.u) { + return normalizeSidebarMessage(lastMessage, t); + } + if (lastMessage.u?.username === room.u?.username) { + return `${t('You')}: ${normalizeSidebarMessage(lastMessage, t)}`; + } + if (isDirectMessageRoom(room) && !isMultipleDirectMessageRoom(room)) { + return normalizeSidebarMessage(lastMessage, t); + } + return `${lastMessage.u.name || lastMessage.u.username}: ${normalizeSidebarMessage(lastMessage, t)}`; +}; + +const getBadgeTitle = ( + userMentions: number, + threadUnread: number, + groupMentions: number, + unread: number, + t: ReturnType, +) => { + const title = [] as string[]; + if (userMentions) { + title.push(t('mentions_counter', { count: userMentions })); + } + if (threadUnread) { + title.push(t('threads_counter', { count: threadUnread })); + } + if (groupMentions) { + title.push(t('group_mentions_counter', { count: groupMentions })); + } + const count = unread - userMentions - groupMentions; + if (count > 0) { + title.push(t('unread_messages_counter', { count })); + } + return title.join(', '); +}; + +type RoomListRowProps = { + extended: boolean; + t: ReturnType; + SideBarItemTemplate: ComponentType< + { + icon: ReactNode; + title: ReactNode; + avatar: ReactNode; + actions: unknown; + href: string; + time?: Date; + menu?: ReactNode; + menuOptions?: unknown; + subtitle?: ReactNode; + titleIcon?: string; + badges?: ReactNode; + threadUnread?: boolean; + unread?: boolean; + selected?: boolean; + is?: string; + } & AllHTMLAttributes + >; + AvatarTemplate: ReturnType; + openedRoom?: string; + // sidebarViewMode: 'extended'; + isAnonymous?: boolean; + + room: ISubscription & IRoom; + id?: string; + /* @deprecated */ + style?: AllHTMLAttributes['style']; + + selected?: boolean; + + sidebarViewMode?: unknown; + videoConfActions?: { + [action: string]: () => void; + }; +}; + +const SideBarItemTemplateWithData = ({ + room, + id, + selected, + style, + extended, + SideBarItemTemplate, + AvatarTemplate, + t, + isAnonymous, + videoConfActions, +}: RoomListRowProps) => { + const { sidebar } = useLayout(); + + const href = roomCoordinator.getRouteLink(room.t, room) || ''; + const title = roomCoordinator.getRoomName(room.t, room) || ''; + + const { + lastMessage, + hideUnreadStatus, + hideMentionStatus, + unread = 0, + alert, + userMentions, + groupMentions, + tunread = [], + tunreadUser = [], + rid, + t: type, + cl, + } = room; + + const highlighted = Boolean(!hideUnreadStatus && (alert || unread)); + const icon = ( + // TODO: Remove icon='at' + + + + ); + + const actions = useMemo( + () => + videoConfActions && ( + + + + + ), + [videoConfActions], + ); + + const isQueued = isOmnichannelRoom(room) && room.status === 'queued'; + const { enabled: isPriorityEnabled } = useOmnichannelPriorities(); + + const message = extended && getMessage(room, lastMessage, t); + const subtitle = message ? : null; + + const threadUnread = tunread.length > 0; + const variant = + ((userMentions || tunreadUser.length) && 'danger') || (threadUnread && 'primary') || (groupMentions && 'warning') || 'secondary'; + + const isUnread = unread > 0 || threadUnread; + const showBadge = !hideUnreadStatus || (!hideMentionStatus && (Boolean(userMentions) || tunreadUser.length > 0)); + + const badgeTitle = getBadgeTitle(userMentions, tunread.length, groupMentions, unread, t); + + const badges = ( + + {showBadge && isUnread && ( + + {unread + tunread?.length} + + )} + {isOmnichannelRoom(room) && } + + ); + + return ( + { + !selected && sidebar.toggle(); + }} + aria-label={title} + title={title} + time={lastMessage?.ts} + subtitle={subtitle} + icon={icon} + style={style} + badges={badges} + avatar={AvatarTemplate && } + actions={actions} + menu={ + !isIOsDevice && + !isAnonymous && + (!isQueued || (isQueued && isPriorityEnabled)) && + ((): ReactElement => ( + + )) + } + /> + ); +}; + +function safeDateNotEqualCheck(a: Date | string | undefined, b: Date | string | undefined): boolean { + if (!a || !b) { + return a !== b; + } + return new Date(a).toISOString() !== new Date(b).toISOString(); +} + +const keys: (keyof RoomListRowProps)[] = [ + 'id', + 'style', + 'extended', + 'selected', + 'SideBarItemTemplate', + 'AvatarTemplate', + 't', + 'sidebarViewMode', + 'videoConfActions', +]; + +// eslint-disable-next-line react/no-multi-comp +export default memo(SideBarItemTemplateWithData, (prevProps, nextProps) => { + if (keys.some((key) => prevProps[key] !== nextProps[key])) { + return false; + } + + if (prevProps.room === nextProps.room) { + return true; + } + + if (prevProps.room._id !== nextProps.room._id) { + return false; + } + if (prevProps.room._updatedAt?.toISOString() !== nextProps.room._updatedAt?.toISOString()) { + return false; + } + if (safeDateNotEqualCheck(prevProps.room.lastMessage?._updatedAt, nextProps.room.lastMessage?._updatedAt)) { + return false; + } + if (prevProps.room.alert !== nextProps.room.alert) { + return false; + } + if (isOmnichannelRoom(prevProps.room) && isOmnichannelRoom(nextProps.room) && prevProps.room?.v?.status !== nextProps.room?.v?.status) { + return false; + } + if (prevProps.room.teamMain !== nextProps.room.teamMain) { + return false; + } + + if ( + isOmnichannelRoom(prevProps.room) && + isOmnichannelRoom(nextProps.room) && + prevProps.room.priorityWeight !== nextProps.room.priorityWeight + ) { + return false; + } + + return true; +}); diff --git a/apps/meteor/client/sidebarv2/RoomList/index.ts b/apps/meteor/client/sidebarv2/RoomList/index.ts new file mode 100644 index 0000000000000..5b0cd3b4b0f81 --- /dev/null +++ b/apps/meteor/client/sidebarv2/RoomList/index.ts @@ -0,0 +1 @@ +export { default } from './RoomList'; diff --git a/apps/meteor/client/sidebarv2/RoomList/normalizeSidebarMessage.ts b/apps/meteor/client/sidebarv2/RoomList/normalizeSidebarMessage.ts new file mode 100644 index 0000000000000..9a506b861e567 --- /dev/null +++ b/apps/meteor/client/sidebarv2/RoomList/normalizeSidebarMessage.ts @@ -0,0 +1,26 @@ +import type { IMessage } from '@rocket.chat/core-typings'; +import { escapeHTML } from '@rocket.chat/string-helpers'; +import type { useTranslation } from '@rocket.chat/ui-contexts'; +import emojione from 'emojione'; + +import { filterMarkdown } from '../../../app/markdown/lib/markdown'; + +export const normalizeSidebarMessage = (message: IMessage, t: ReturnType): string | undefined => { + if (message.msg) { + return escapeHTML(filterMarkdown(emojione.shortnameToUnicode(message.msg))); + } + + if (message.attachments) { + const attachment = message.attachments.find((attachment) => attachment.title || attachment.description); + + if (attachment?.description) { + return escapeHTML(attachment.description); + } + + if (attachment?.title) { + return escapeHTML(attachment.title); + } + + return t('Sent_an_attachment'); + } +}; diff --git a/apps/meteor/client/sidebarv2/RoomList/useSidebarListNavigation.ts b/apps/meteor/client/sidebarv2/RoomList/useSidebarListNavigation.ts new file mode 100644 index 0000000000000..f5c2d00d4b2c3 --- /dev/null +++ b/apps/meteor/client/sidebarv2/RoomList/useSidebarListNavigation.ts @@ -0,0 +1,99 @@ +import { useFocusManager } from '@react-aria/focus'; +import { useCallback } from 'react'; + +const isListItem = (node: EventTarget) => (node as HTMLElement).classList.contains('rcx-sidebar-item'); +const isListItemMenu = (node: EventTarget) => (node as HTMLElement).classList.contains('rcx-sidebar-item__menu'); + +/** + * Custom hook to provide the sidebar navigation by keyboard. + * @param ref - A ref to the message list DOM element. + */ +export const useSidebarListNavigation = () => { + const sidebarListFocusManager = useFocusManager(); + + const sidebarListRef = useCallback( + (node: HTMLElement | null) => { + let lastItemFocused: HTMLElement | null = null; + + if (!node) { + return; + } + + node.addEventListener('keydown', (e) => { + if (!e.target) { + return; + } + + if (!isListItem(e.target)) { + return; + } + + if (e.key === 'Tab') { + e.preventDefault(); + e.stopPropagation(); + + if (e.shiftKey) { + sidebarListFocusManager.focusPrevious({ + accept: (node) => !isListItem(node) && !isListItemMenu(node), + }); + } else if (isListItemMenu(e.target)) { + sidebarListFocusManager.focusNext({ + accept: (node) => !isListItem(node) && !isListItemMenu(node), + }); + } else { + sidebarListFocusManager.focusNext({ + accept: (node) => !isListItem(node), + }); + } + } + + if (e.key === 'ArrowUp' || e.key === 'ArrowDown') { + if (e.key === 'ArrowUp') { + sidebarListFocusManager.focusPrevious({ accept: (node) => isListItem(node) }); + } + + if (e.key === 'ArrowDown') { + sidebarListFocusManager.focusNext({ accept: (node) => isListItem(node) }); + } + + lastItemFocused = document.activeElement as HTMLElement; + } + }); + + node.addEventListener( + 'blur', + (e) => { + if ( + !(e.relatedTarget as HTMLElement)?.classList.contains('focus-visible') || + !(e.currentTarget instanceof HTMLElement && e.relatedTarget instanceof HTMLElement) + ) { + return; + } + + if (!e.currentTarget.contains(e.relatedTarget) && !lastItemFocused) { + lastItemFocused = e.target as HTMLElement; + } + }, + { capture: true }, + ); + + node.addEventListener( + 'focus', + (e) => { + const triggeredByKeyboard = (e.target as HTMLElement)?.classList.contains('focus-visible'); + if (!triggeredByKeyboard || !(e.currentTarget instanceof HTMLElement && e.relatedTarget instanceof HTMLElement)) { + return; + } + + if (lastItemFocused && !e.currentTarget.contains(e.relatedTarget) && node.contains(e.target as HTMLElement)) { + lastItemFocused?.focus(); + } + }, + { capture: true }, + ); + }, + [sidebarListFocusManager], + ); + + return { sidebarListRef }; +}; diff --git a/apps/meteor/client/sidebarv2/RoomMenu.tsx b/apps/meteor/client/sidebarv2/RoomMenu.tsx new file mode 100644 index 0000000000000..e88225df40ca7 --- /dev/null +++ b/apps/meteor/client/sidebarv2/RoomMenu.tsx @@ -0,0 +1,260 @@ +import type { RoomType } from '@rocket.chat/core-typings'; +import { Option, Menu } from '@rocket.chat/fuselage'; +import { useEffectEvent } from '@rocket.chat/fuselage-hooks'; +import type { TranslationKey, Fields } from '@rocket.chat/ui-contexts'; +import { + useRouter, + useSetModal, + useToastMessageDispatch, + useUserSubscription, + useSetting, + usePermission, + useMethod, + useTranslation, + useEndpoint, +} from '@rocket.chat/ui-contexts'; +import type { ReactElement } from 'react'; +import React, { memo, useMemo } from 'react'; + +import { LegacyRoomManager } from '../../app/ui-utils/client'; +import { UiTextContext } from '../../definition/IRoomTypeConfig'; +import { GenericModalDoNotAskAgain } from '../components/GenericModal'; +import WarningModal from '../components/WarningModal'; +import { useDontAskAgain } from '../hooks/useDontAskAgain'; +import { roomCoordinator } from '../lib/rooms/roomCoordinator'; +import { useOmnichannelPrioritiesMenu } from '../omnichannel/hooks/useOmnichannelPrioritiesMenu'; + +const fields: Fields = { + f: true, + t: true, + name: true, +}; + +type RoomMenuProps = { + rid: string; + unread?: boolean; + threadUnread?: boolean; + alert?: boolean; + roomOpen?: boolean; + type: RoomType; + cl?: boolean; + name?: string; + hideDefaultOptions: boolean; +}; + +const closeEndpoints = { + p: '/v1/groups.close', + c: '/v1/channels.close', + d: '/v1/im.close', + + v: '/v1/channels.close', + l: '/v1/groups.close', +} as const; + +const leaveEndpoints = { + p: '/v1/groups.leave', + c: '/v1/channels.leave', + d: '/v1/im.leave', + + v: '/v1/channels.leave', + l: '/v1/groups.leave', +} as const; + +const RoomMenu = ({ + rid, + unread, + threadUnread, + alert, + roomOpen, + type, + cl, + name = '', + hideDefaultOptions = false, +}: RoomMenuProps): ReactElement | null => { + const t = useTranslation(); + const dispatchToastMessage = useToastMessageDispatch(); + const setModal = useSetModal(); + + const closeModal = useEffectEvent(() => setModal()); + + const router = useRouter(); + + const subscription = useUserSubscription(rid, fields); + const canFavorite = useSetting('Favorite_Rooms'); + const isFavorite = Boolean(subscription?.f); + + const dontAskHideRoom = useDontAskAgain('hideRoom'); + + const hideRoom = useEndpoint('POST', closeEndpoints[type]); + const readMessages = useEndpoint('POST', '/v1/subscriptions.read'); + const toggleFavorite = useEndpoint('POST', '/v1/rooms.favorite'); + const leaveRoom = useEndpoint('POST', leaveEndpoints[type]); + + const unreadMessages = useMethod('unreadMessages'); + + const isUnread = alert || unread || threadUnread; + + const canLeaveChannel = usePermission('leave-c'); + const canLeavePrivate = usePermission('leave-p'); + + const isOmnichannelRoom = type === 'l'; + const prioritiesMenu = useOmnichannelPrioritiesMenu(rid); + + const canLeave = ((): boolean => { + if (type === 'c' && !canLeaveChannel) { + return false; + } + if (type === 'p' && !canLeavePrivate) { + return false; + } + return !((cl != null && !cl) || ['d', 'l'].includes(type)); + })(); + + const handleLeave = useEffectEvent(() => { + const leave = async (): Promise => { + try { + await leaveRoom({ roomId: rid }); + if (roomOpen) { + router.navigate('/home'); + } + LegacyRoomManager.close(rid); + } catch (error) { + dispatchToastMessage({ type: 'error', message: error }); + } + closeModal(); + }; + + const warnText = roomCoordinator.getRoomDirectives(type).getUiText(UiTextContext.LEAVE_WARNING); + + setModal( + , + ); + }); + + const handleHide = useEffectEvent(async () => { + const hide = async (): Promise => { + try { + await hideRoom({ roomId: rid }); + } catch (error) { + dispatchToastMessage({ type: 'error', message: error }); + } + closeModal(); + }; + + const warnText = roomCoordinator.getRoomDirectives(type).getUiText(UiTextContext.HIDE_WARNING); + + if (dontAskHideRoom) { + return hide(); + } + + setModal( + + {t(warnText as TranslationKey, name)} + , + ); + }); + + const handleToggleRead = useEffectEvent(async () => { + try { + if (isUnread) { + await readMessages({ rid, readThreads: true }); + return; + } + await unreadMessages(undefined, rid); + if (subscription == null) { + return; + } + LegacyRoomManager.close(subscription.t + subscription.name); + + router.navigate('/home'); + } catch (error) { + dispatchToastMessage({ type: 'error', message: error }); + } + }); + + const handleToggleFavorite = useEffectEvent(async () => { + try { + await toggleFavorite({ roomId: rid, favorite: !isFavorite }); + } catch (error) { + dispatchToastMessage({ type: 'error', message: error }); + } + }); + + const menuOptions = useMemo( + () => ({ + ...(!hideDefaultOptions && { + hideRoom: { + label: { label: t('Hide'), icon: 'eye-off' }, + action: handleHide, + }, + toggleRead: { + label: { label: isUnread ? t('Mark_read') : t('Mark_unread'), icon: 'flag' }, + action: handleToggleRead, + }, + ...(canFavorite + ? { + toggleFavorite: { + label: { + label: isFavorite ? t('Unfavorite') : t('Favorite'), + icon: isFavorite ? 'star-filled' : 'star', + }, + action: handleToggleFavorite, + }, + } + : {}), + ...(canLeave && { + leaveRoom: { + label: { label: t('Leave_room'), icon: 'sign-out' }, + action: handleLeave, + }, + }), + }), + ...(isOmnichannelRoom && prioritiesMenu), + }), + [ + hideDefaultOptions, + t, + handleHide, + isUnread, + handleToggleRead, + canFavorite, + isFavorite, + handleToggleFavorite, + canLeave, + handleLeave, + isOmnichannelRoom, + prioritiesMenu, + ], + ); + + return ( +