diff --git a/.changeset/afraid-guests-jog.md b/.changeset/afraid-guests-jog.md new file mode 100644 index 000000000000..420b9bb5d329 --- /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 000000000000..e1eaa7980afb --- /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 000000000000..e1eaa7980afb --- /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 000000000000..e1eaa7980afb --- /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 000000000000..e1eaa7980afb --- /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 000000000000..e1eaa7980afb --- /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 000000000000..e1eaa7980afb --- /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 000000000000..1a2d3a7de559 --- /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 000000000000..670fa24887b7 --- /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 000000000000..a7724b126695 --- /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 000000000000..24905bb91c62 --- /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 000000000000..b66ca5157cd5 --- /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 000000000000..b4bd075ef654 --- /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 000000000000..6281fc9941ec --- /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 000000000000..bdd74a60b1e9 --- /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 000000000000..e2364ccb05e5 --- /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 000000000000..fb9fab77b24c --- /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 000000000000..2dfb2151ced0 --- /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 000000000000..f1d7bb6f3f0e --- /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 000000000000..4e50b172e17e --- /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 000000000000..c1008b2ca06f --- /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 000000000000..3e23797025e1 --- /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 000000000000..79deda53edfc --- /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 000000000000..8f37283c6a96 --- /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 000000000000..c92293d6ae95 --- /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 000000000000..26e9276193de --- /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 000000000000..7d4e7cd3a57e --- /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 000000000000..fb962407b353 --- /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 000000000000..e99e4f219ef9 --- /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 000000000000..4dbddf965742 --- /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 000000000000..2f524c7e5f10 --- /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 000000000000..f38ef1f92367 --- /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 000000000000..40c93f4a63bd --- /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 000000000000..556fa3af80e1 --- /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 000000000000..ad628c13d087 --- /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 000000000000..187bd9d09ddc --- /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 000000000000..61cb0d2b7586 --- /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 000000000000..ddf76535087e --- /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 000000000000..16741e31e54a --- /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 000000000000..7d0ad6ee5047 --- /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 000000000000..315d674a1857 --- /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 000000000000..40ee9f8fbb55 --- /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 000000000000..7d13e02f0bd3 --- /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 000000000000..541d5069ee9c --- /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 000000000000..7273ddcffca4 --- /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 000000000000..2d1076845fa9 --- /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 000000000000..1a32e1ddebfb --- /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 000000000000..632026d6fe2e --- /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 000000000000..cbbe7c4aa08c --- /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 000000000000..abaa9c683d65 --- /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 000000000000..a2d435495cd7 --- /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 000000000000..91748a43c677 --- /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 000000000000..42c9409d9ef3 --- /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 c2a05cc61166..5a077e74a1d8 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 000000000000..803158ae1a25 --- /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 000000000000..d96fd7ee4554 --- /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 000000000000..889d959cba6d --- /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 000000000000..2a6658581540 --- /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 b46c124d149b..378769883f19 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 b542cfbf6523..411aa2cc5b1a 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 000000000000..e52b4870b369 --- /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 64dec811e1ca..56e38c111e97 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 2bbdbae00b89..2701a871d981 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 eca1284e62e5..b73a24a275e4 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 f17422bbb318..4f6de9c113b7 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 85fc0658542d..3136a6c16e13 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 410a65fe7eda..7ae585b89dfa 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 ab2632c912b0..13db1179310c 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 7067ab8e6a52..ec5cff29a99b 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 bbd24152716f..344acc74bda4 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 bebcb25a6f51..efab0f201f87 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 000000000000..3310574f0160 --- /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 187a6519339a..d7dae512e9a8 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 840f4f1613eb..e31ee094b4d7 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 bffbe1f9876d..2e4c599ce558 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 3ad61c4c42f0..ecf014248830 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 0f42f495e962..d8e3637575ab 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 0cc344ff5152..bbd6f208f35a 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 7b887bb0575f..0b3b722aaebd 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 57ea20f00cb1..b6ffc0ca4629 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 3b065c68f15c..c55ee382f10c 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 f4e948390c99..635c236fda27 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 44654428ae8f..49fcc0ea4725 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 c70f32c11c0d..41af8a22b6bb 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 f7d5ddb314c9..f80ed61a131e 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 f6502b70f68a..6f8ce64bc635 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 00229dae2de5..617d255cb6cb 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 b130e5c2c73a..26449dce3963 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 97c92eeb530f..b7eb6e1f684a 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 d2a76e53926f..b0f45a63ff87 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 9c19f5bbdec8..a5b3f2de35b1 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 5c3a2c0b54ab..24e1d685a0e6 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 dacd99be00f9..c0e85a8c7c2b 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 bf5014b984f1..ccca7a8eb68e 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 576b29990b33..e1ea79d84163 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 5782d01e318f..f4a2288305e5 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 000000000000..c6acbbc5bcbd --- /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 000000000000..76595a7ff640 --- /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 000000000000..74032121ee50 --- /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 01f720b85a4d..4a531d0c89e5 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 3433b4a33ae8..30a5dabb5717 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 3c3acdb17893..c7fe452e0829 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 ac53144d6d1b..260fc835d8a0 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 76747b599104..6e68518ef31c 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 87ced6e130df..6015780f118f 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 06cfad4a91a6..9a42569b4cf6 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 5783e2946dc1..d7d2fa0a79f8 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 9cd409ba0b83..fb31c3021b1b 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 78d48deb4993..0263d5369a4c 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 cead4a2cb584..20b023cc61aa 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 000000000000..d0fbd4360e24 --- /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 909a955d6724..df670145b494 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 3cb06b1e99ab..7cad52f21bcf 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 000000000000..908e729c956e --- /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 000000000000..af9b907df12e --- /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 000000000000..ce62cb51864b --- /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 000000000000..cf4e7ec240b4 --- /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 000000000000..c4b53acefabb --- /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 000000000000..8b51fc6c5b57 --- /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 000000000000..99cdbd9b4a16 --- /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 000000000000..5bf174362e19 --- /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 000000000000..8b1c00a2a17c --- /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 000000000000..8dacb885deb3 --- /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 000000000000..07936f6f4276 --- /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 000000000000..0cc26c6c1356 --- /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 000000000000..128a41ea97ae --- /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 000000000000..4a2bbc916b57 --- /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 000000000000..11eddf934055 --- /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 000000000000..88a2a5de31aa --- /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 000000000000..2a3d277e69fe --- /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 000000000000..fd704ffafe1f --- /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 000000000000..2b334cab4b2d --- /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 000000000000..045b36425512 --- /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 000000000000..a02c17db0b9b --- /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 000000000000..f4dce69af876 --- /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 000000000000..531ff8a74b66 --- /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 000000000000..9120678c7581 --- /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 000000000000..974af6be8ed8 --- /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 000000000000..bf1b7e55f244 --- /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 000000000000..f0f863f8efab --- /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 000000000000..2957d22c5e32 --- /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 000000000000..a969c853d797 --- /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 000000000000..63aab39921d7 --- /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 000000000000..1315d1053392 --- /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 000000000000..54d4818128ea --- /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 000000000000..9bc514a8088a --- /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 000000000000..902ee590de66 --- /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 62c78ef21705..c75c827067ec 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 0865a1c26ae9..349341baf003 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 000000000000..481537d23f3e --- /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 000000000000..567bd4e276e1 --- /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 000000000000..869030ddb479 --- /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 c2ae717eda33..c8e17ab88d80 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 000000000000..ab2ab878503e --- /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 6c0fbd5c8ebe..1670c9be5895 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 000000000000..10d3d74b673b --- /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 c3421f3fc9d3..23def16a94a1 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 000000000000..be3b3aca7c53 --- /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 000000000000..481823a1a13f --- /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 d757cccce0ef..795182df8465 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 000000000000..5f6062fe351a --- /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 000000000000..53ee192f5416 --- /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 000000000000..92b74451b450 --- /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 506be155ce18..bffcc5669ce4 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 c370b7f790fc..c8602186e09a 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 e6bce31b0b87..cd39a187cd89 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 b07083be1a03..06080ede2510 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 0f317dea61e4..1aeeb29a0a57 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 335b8e6be959..48e1d77d63fb 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 000000000000..99e62bac1a60 --- /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 44feedf86115..c01a64d708a0 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 000000000000..0ef7235729c4 --- /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 2600a8a31352..d371e1ff4ef2 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 8d3644e0dc93..e8010bc10d95 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 3b68ccff94a1..0cf7c667192d 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 8b783c1a7204..033199156de7 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 2dbbaade6487..f8aefa66aced 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 6db18a8bfd1f..491d6a75329a 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 a3e138c54c55..0968252f7beb 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 000000000000..0c1c3665f823 --- /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 000000000000..622c85bf6bae --- /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 000000000000..4ab684ce23a0 --- /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 000000000000..22861846852f --- /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 000000000000..abcdba673fb0 --- /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 000000000000..fee88b64b4e7 --- /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 000000000000..f23db95f3ee1 --- /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 000000000000..ae3332f2246a --- /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 000000000000..c0fe4d086eca --- /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 000000000000..40d4dfbf59e8 --- /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 000000000000..f5f2944781b5 --- /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 000000000000..099bfb13fdd3 --- /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 000000000000..f0eccfda0401 --- /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 000000000000..bbf296ff23e1 --- /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 000000000000..67aae03729f9 --- /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 000000000000..5986671ec836 --- /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 000000000000..be01ea638c98 --- /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 1d8987995e8c..b94706b428de 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 6e3fe6c00938..601fdca2ff7c 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 00af64b0fa61..817cb0f3d9b9 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 2e4f36601318..1daeee307aa6 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 982e6ab8e25d..257767e42170 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 77450ea0e9ea..701c2700a802 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 7db13dad751f..336b7a9fcb3a 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 7b82d0d02aa0..0bc30b8995d5 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 7ea4de6d9867..a79aa583d1ec 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 b2656d62ef37..6b9387522438 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 51e499f91d8f..c9af942f6e1c 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 2963f06ae494..c066a314f34f 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 470f754d861a..88392b42aa49 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 ebbd07d361e7..354d0e1790d3 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 401448ceb396..7c028fb5c876 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 b4f789618653..04fcb29eed01 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 0b3a94f5b16c..95bda1e89107 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 1cae326589a4..459b4b052466 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 4549c69dccec..c6667e4fc5cc 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 56590309cac7..dd15dc2c4e4b 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 bccdec01b7d9..5e5fa1c15e81 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 4e87a96d6ec5..e4bf5a5e7041 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 103c680770c4..a066b4c5fcee 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 3f08bcb121cb..45eb6094572a 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 c8cfec4862da..408741863097 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 a4656373b488..a7f32f82f454 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 f8fe955dbe49..cc0d768541fe 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 99b1f3de7290..2ecd19598b3b 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 935e9fb0d9ad..b64e4123d6cf 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 5e0009239326..4e6650e5ec98 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 f9fb7b48a703..a75c6edc3870 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 b7bcd7d1e9dd..86223cd7f2b4 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 6b1d422eba45..11a15cb16505 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 5eeda2f32bdf..4c3ead636db0 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 a26c40ec5176..267e0dfe3601 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 189a58e44920..8ed15fc120f4 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 b76cb268bb2f..ea81f48c034e 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 c9adc4533a97..284cb0cecbf2 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 1dc6752abdd0..f75b044c69a5 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 8195fdee5973..d49a4ce8f989 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 5bd47294cf99..29551dd8a209 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 d7bc8b18a84b..22c6b0a04d4e 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 c3ad7e2910c7..75a13e6cba34 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 1a16b7d2fe58..4026e4c88a66 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 78b576d43b74..cadc599d5e74 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 0224a2e7c533..8035321edb96 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 d53256eee71b..18b1b9e18031 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 9de4c9631ecd..6654800f9e13 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 db84df53f262..77e404dac1a3 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 34b00d67e0fe..3e89cb03cec7 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 0fd130b5075f..4cf33040938d 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 31be4b2300ee..cbc2a594eb6e 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 92cc93e339fd..9b5cafe99833 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 29543e539c12..3740eb1ebc9c 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 b0276e753922..6f4e1c95a5fa 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 20986c803ab8..7960ec1914e5 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 accd68817de9..ec668811122e 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 e654bec4336f..5f3894b69132 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 91980f119316..8d68f0ec29f3 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 9e90ac015a46..dc6a489320d1 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 63dde93ac8d6..644c74c41295 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 72b0b00939c1..54ce88cccb7e 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 557d9672c027..6bef3f10efdc 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 cc1be1da33c8..cd9907f271a5 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 bcb6a7d9949f..224e0c5dff55 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 6cf89689f296..eb118f50a7ef 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 53706a45fb33..000000000000 --- 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 bc91265b04ba..be470f261e26 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 d7c9ae9caa2d..6b2210d56926 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 2ee0830313c4..6897a98ad5d6 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 56a5318cfa4f..b7ec881856f7 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 64d936b5cd65..da088c05e90b 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 0f00e447c75e..6cdc9012f714 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 38b7c12791cd..f2c884aeb05f 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 469cff990e29..2f2cb12722e1 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 5e1b3944b6e8..5404d37cf918 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 a4f8fa84f9ff..04b72a593673 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 cf6b14e7946b..4817cee83317 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 f77933337456..ea062c324807 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 6c3f1026bc51..27092ea602b6 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 881275e2fc2b..9517a9d3b155 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 4f0aab3a602b..d7fb25a1ed31 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 8eb5e2e37b6b..53ac92287f78 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 a934ddd53a90..0fe771c9ee9c 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 87734c6afcca..84b315bc940f 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 d85e53996d33..fb011467d088 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 4cc9aa3a767c..f3446d366877 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 99222d2e9c70..cb4f047a098a 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 73493a4aee8f..e3c3e41cdd35 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 6feed3071ffc..fde0a20340ac 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 f9ec077e9e43..cc7cdfbe7761 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 8df55bd5d359..06b1352d2803 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 59856df71773..6046c180ec3a 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 b7f00af460ae..b11a103006d2 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 61984030429d..b4ddbf32419d 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 37abbcf85030..8f0a26222679 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 ff583aa4e884..dfaa79ed44de 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 51021a91549a..e3c953dcb950 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 c6fb0487413e..361950cd39c9 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 59bda6233907..f3dc779d28c1 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 ff9a1cbd822b..ec6396a83440 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 d2e51f191370..fbed4afec4cb 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 5289a1a9d8a5..478e7cce33e1 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 09dbc9de5a64..933ccde69fbf 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 e1c699cc1db0..f5e97b4888c4 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 40013375254f..b056128f0568 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 330ad6b233e6..e7f3b398e5f6 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 2b371ec1b0ef..895467b57690 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 fa5dfd2797cb..afdc57086dc4 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 d215a77ce4bd..dd97678f638a 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 000000000000..f63893a30a81 --- /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 000000000000..db76935d4c3f --- /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 000000000000..a6392eae5d61 --- /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 000000000000..f288f5fd35c6 --- /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 000000000000..0c03cf33c500 --- /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 000000000000..ffc13047f66d --- /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 000000000000..3f137d4709c7 --- /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 000000000000..64796d2e12e4 --- /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 000000000000..b2cd75193466 --- /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 000000000000..b4d4b4a44c98 --- /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 000000000000..4eaba8cc37f0 --- /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 000000000000..5b0cd3b4b0f8 --- /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 000000000000..9a506b861e56 --- /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 000000000000..f5c2d00d4b2c --- /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 000000000000..e88225df40ca --- /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 ( +