diff --git a/.changeset/beige-coats-wait.md b/.changeset/beige-coats-wait.md deleted file mode 100644 index 1dd4c6809a83c998c0be44d6ffe1f118a67f79c9..0000000000000000000000000000000000000000 --- a/.changeset/beige-coats-wait.md +++ /dev/null @@ -1,56 +0,0 @@ ---- -'@rocket.chat/ui-kit': minor -'@rocket.chat/server-cloud-communication': patch -'@rocket.chat/omnichannel-services': patch -'rocketchat-services': patch -'@rocket.chat/omnichannel-transcript': patch -'@rocket.chat/authorization-service': patch -'@rocket.chat/web-ui-registration': patch -'@rocket.chat/stream-hub-service': patch -'@rocket.chat/password-policies': patch -'@rocket.chat/uikit-playground': patch -'@rocket.chat/presence-service': patch -'@rocket.chat/fuselage-ui-kit': patch -'@rocket.chat/instance-status': patch -'@rocket.chat/account-service': patch -'@rocket.chat/mock-providers': patch -'@rocket.chat/release-action': patch -'@rocket.chat/api-client': patch -'@rocket.chat/ddp-client': patch -'@rocket.chat/pdf-worker': patch -'@rocket.chat/ui-theming': patch -'@rocket.chat/account-utils': patch -'@rocket.chat/core-services': patch -'@rocket.chat/eslint-config': patch -'@rocket.chat/model-typings': patch -'@rocket.chat/ui-video-conf': patch -'@rocket.chat/cas-validate': patch -'@rocket.chat/core-typings': patch -'@rocket.chat/rest-typings': patch -'@rocket.chat/server-fetch': patch -'@rocket.chat/ddp-streamer': patch -'@rocket.chat/queue-worker': patch -'@rocket.chat/presence': patch -'@rocket.chat/poplib': patch -'@rocket.chat/ui-composer': patch -'@rocket.chat/ui-contexts': patch -'@rocket.chat/license': patch -'@rocket.chat/log-format': patch -'@rocket.chat/gazzodown': patch -'@rocket.chat/ui-client': patch -'@rocket.chat/livechat': patch -'@rocket.chat/favicon': patch -'@rocket.chat/agenda': patch -'@rocket.chat/base64': patch -'@rocket.chat/logger': patch -'@rocket.chat/models': patch -'@rocket.chat/random': patch -'@rocket.chat/sha256': patch -'@rocket.chat/tools': patch -'@rocket.chat/cron': patch -'@rocket.chat/i18n': patch -'@rocket.chat/jwt': patch -'@rocket.chat/meteor': patch ---- - -feat(uikit): Move `@rocket.chat/ui-kit` package to the main monorepo diff --git a/.changeset/blue-files-deny.md b/.changeset/blue-files-deny.md new file mode 100644 index 0000000000000000000000000000000000000000..861a77367100d74986c3146e8f0a0be190455f1a --- /dev/null +++ b/.changeset/blue-files-deny.md @@ -0,0 +1,5 @@ +--- +'@rocket.chat/meteor': patch +--- + +Fix room name updation on admin edit room flow. diff --git a/.changeset/brown-ads-tell.md b/.changeset/brown-ads-tell.md deleted file mode 100644 index 4659ce4b4057eda85e8d5170abcd22a6967cf894..0000000000000000000000000000000000000000 --- a/.changeset/brown-ads-tell.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@rocket.chat/meteor': patch ---- - -Clear message box related items from local storage on logout diff --git a/.changeset/chatty-schools-notice.md b/.changeset/chatty-schools-notice.md deleted file mode 100644 index f759b1691cc20229d676469fe2997d508f6d99fc..0000000000000000000000000000000000000000 --- a/.changeset/chatty-schools-notice.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@rocket.chat/meteor': patch ---- - -fix: OAuth login by redirect failing on firefox diff --git a/.changeset/config.json b/.changeset/config.json index 9d77693b3b31b1e120a35c24289be983fbb57b73..b93fac2c92a3950f3085b6217b340dc99397e376 100644 --- a/.changeset/config.json +++ b/.changeset/config.json @@ -1,6 +1,6 @@ { "$schema": "https://unpkg.com/@changesets/config@2.3.0/schema.json", - "changelog": "@changesets/changelog-git", + "changelog": ["@rocket.chat/release-changelog", { "repo": "RocketChat/Rocket.Chat" }], "commit": false, "fixed": [ ["@rocket.chat/meteor", "@rocket.chat/core-typings", "@rocket.chat/rest-typings"] diff --git a/.changeset/cool-timers-fix.md b/.changeset/cool-timers-fix.md deleted file mode 100644 index 27e06d9231342d4e39081ab2b62df7313cfff453..0000000000000000000000000000000000000000 --- a/.changeset/cool-timers-fix.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@rocket.chat/meteor": patch ---- - -Fixed issue with read receipts for older messages not being created on the first time a user reads a DM diff --git a/.changeset/curly-dodos-tan.md b/.changeset/curly-dodos-tan.md new file mode 100644 index 0000000000000000000000000000000000000000..939f212754993d55bec8a19e59020ade1e4676f3 --- /dev/null +++ b/.changeset/curly-dodos-tan.md @@ -0,0 +1,5 @@ +--- +'@rocket.chat/meteor': patch +--- + +Fixes an issue not allowing admin users to edit the room name diff --git a/.changeset/curly-years-smile.md b/.changeset/curly-years-smile.md new file mode 100644 index 0000000000000000000000000000000000000000..78ce09097844227a5da0f98334e7611eabbd2b49 --- /dev/null +++ b/.changeset/curly-years-smile.md @@ -0,0 +1,5 @@ +--- +"@rocket.chat/meteor": patch +--- + +fixed an issue with the composer losing its edit state and highlighted after resizing the window. diff --git a/.changeset/cyan-countries-impress.md b/.changeset/cyan-countries-impress.md new file mode 100644 index 0000000000000000000000000000000000000000..6bd78b6f62f863f424e1f6693f3b045e381a2888 --- /dev/null +++ b/.changeset/cyan-countries-impress.md @@ -0,0 +1,5 @@ +--- +'@rocket.chat/meteor': patch +--- + +Fixed matrix homeserver domain setting not being visible in admin panel diff --git a/.changeset/dirty-islands-mate.md b/.changeset/dirty-islands-mate.md new file mode 100644 index 0000000000000000000000000000000000000000..22e4eaf85d78afb3f8b1b350284229f3396ff66c --- /dev/null +++ b/.changeset/dirty-islands-mate.md @@ -0,0 +1,5 @@ +--- +'@rocket.chat/meteor': patch +--- + +fixed some apps-engine bridges receiving data in a wrong format diff --git a/.changeset/fifty-ducks-sing.md b/.changeset/fifty-ducks-sing.md deleted file mode 100644 index 3f8d3bda1aa5766ac0db94dbfe6df14f247a1f07..0000000000000000000000000000000000000000 --- a/.changeset/fifty-ducks-sing.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@rocket.chat/meteor": patch ---- - -fix: Discussion messages deleted despite the "Do not delete discussion messages" retention policy enabled diff --git a/.changeset/fifty-maps-deny.md b/.changeset/fifty-maps-deny.md deleted file mode 100644 index a01c5e4c712128657dd91c93e1c80411818fe2af..0000000000000000000000000000000000000000 --- a/.changeset/fifty-maps-deny.md +++ /dev/null @@ -1,6 +0,0 @@ ---- -"@rocket.chat/meteor": minor -"@rocket.chat/rest-typings": minor ---- - -Added `push.info` endpoint to enable users to retrieve info about the workspace's push gateway diff --git a/.changeset/five-dragons-joke.md b/.changeset/five-dragons-joke.md new file mode 100644 index 0000000000000000000000000000000000000000..09a636ec3213b9ed79cd334125d4efa8cbaf4cf6 --- /dev/null +++ b/.changeset/five-dragons-joke.md @@ -0,0 +1,5 @@ +--- +'@rocket.chat/meteor': minor +--- + +Freezes the permission table's first column allowing the user to visualize the permission name when scrolling horizontally diff --git a/.changeset/flat-fishes-sniff.md b/.changeset/flat-fishes-sniff.md new file mode 100644 index 0000000000000000000000000000000000000000..836b83123f7d0e44b2fbe7f9538356e121345bcb --- /dev/null +++ b/.changeset/flat-fishes-sniff.md @@ -0,0 +1,5 @@ +--- +'@rocket.chat/meteor': patch +--- + +Fix notifications specially for DMs when preference is set to mentions. diff --git a/.changeset/flat-windows-juggle.md b/.changeset/flat-windows-juggle.md new file mode 100644 index 0000000000000000000000000000000000000000..4a0410b7fab16bad2691f11a4c7ec83f4a3658ff --- /dev/null +++ b/.changeset/flat-windows-juggle.md @@ -0,0 +1,6 @@ +--- +"@rocket.chat/meteor": patch +"@rocket.chat/web-ui-registration": patch +--- + +Fixed login email verification flow when a user tries to join with username diff --git a/.changeset/fresh-radios-whisper.md b/.changeset/fresh-radios-whisper.md deleted file mode 100644 index cba234524dcea46cc850581d82398e27f96edaf7..0000000000000000000000000000000000000000 --- a/.changeset/fresh-radios-whisper.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@rocket.chat/meteor": patch ---- - -Fixed issue with the new `custom-roles` license module not being checked throughout the application diff --git a/.changeset/giant-dancers-relate.md b/.changeset/giant-dancers-relate.md deleted file mode 100644 index d58385c5da7a2285992c0afa2384f32ca0a5ab04..0000000000000000000000000000000000000000 --- a/.changeset/giant-dancers-relate.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@rocket.chat/meteor": patch ---- - -Fixed an `UnhandledPromiseRejection` error on `PUT livechat/departments/:_id` endpoint when `agents` array failed validation diff --git a/.changeset/good-panthers-call.md b/.changeset/good-panthers-call.md new file mode 100644 index 0000000000000000000000000000000000000000..a55e198cc1912b9663b23f99dccb8d489a4e575c --- /dev/null +++ b/.changeset/good-panthers-call.md @@ -0,0 +1,5 @@ +--- +"@rocket.chat/meteor": patch +--- + +This fix allows links such as ones starting with "notes://" and other specific apps to be rendered in the User panel as they are in the messages diff --git a/.changeset/great-kings-tickle.md b/.changeset/great-kings-tickle.md deleted file mode 100644 index 73e792673f0833bcbcedd13606c0d849fdf4201c..0000000000000000000000000000000000000000 --- a/.changeset/great-kings-tickle.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@rocket.chat/meteor": patch ---- - -fix: Off the record feature was calling a deprecated and useless method. diff --git a/.changeset/grumpy-eagles-roll.md b/.changeset/grumpy-eagles-roll.md new file mode 100644 index 0000000000000000000000000000000000000000..37e0c7af16887a773b6b9643579ddd0acc65f00c --- /dev/null +++ b/.changeset/grumpy-eagles-roll.md @@ -0,0 +1,5 @@ +--- +"@rocket.chat/meteor": patch +--- + +Fixed Mail dryrun sending email to all users diff --git a/.changeset/kind-beers-share.md b/.changeset/kind-beers-share.md deleted file mode 100644 index b7871e568d1066f0e37274f0442f1c7b0692e360..0000000000000000000000000000000000000000 --- a/.changeset/kind-beers-share.md +++ /dev/null @@ -1,7 +0,0 @@ ---- -"@rocket.chat/meteor": minor -"@rocket.chat/ui-client": minor -"@rocket.chat/web-ui-registration": minor ---- - -feat: Skip to main content shortcut and useDocumentTitle diff --git a/.changeset/kind-dragons-flash.md b/.changeset/kind-dragons-flash.md new file mode 100644 index 0000000000000000000000000000000000000000..a7619ccf5d958e1005e3cb42027192ae4120187c --- /dev/null +++ b/.changeset/kind-dragons-flash.md @@ -0,0 +1,5 @@ +--- +'@rocket.chat/meteor': minor +--- + +feat: add a11y doc links diff --git a/.changeset/large-toys-matter.md b/.changeset/large-toys-matter.md new file mode 100644 index 0000000000000000000000000000000000000000..5dae3df44a4666bfa6ba799b6fb64979e88d908f --- /dev/null +++ b/.changeset/large-toys-matter.md @@ -0,0 +1,5 @@ +--- +"@rocket.chat/meteor": patch +--- + +fixed an issue with the user presence not updating automatically for other users. diff --git a/.changeset/loud-foxes-grin.md b/.changeset/loud-foxes-grin.md deleted file mode 100644 index 3fa989aa0dd2b4419eb5320a9b1e2a21ee5a2baf..0000000000000000000000000000000000000000 --- a/.changeset/loud-foxes-grin.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@rocket.chat/meteor": patch ---- - -fix: Visitor message not being sent to webhook due to wrong validation of settings diff --git a/.changeset/lucky-ducks-join.md b/.changeset/lucky-ducks-join.md new file mode 100644 index 0000000000000000000000000000000000000000..e64661b0841b044473a5927484d8b950647ef251 --- /dev/null +++ b/.changeset/lucky-ducks-join.md @@ -0,0 +1,5 @@ +--- +'@rocket.chat/meteor': patch +--- + +Fixed an issue where the login button for Custom OAuth services would not work if any non-custom login service was also available diff --git a/.changeset/moody-cups-search.md b/.changeset/moody-cups-search.md new file mode 100644 index 0000000000000000000000000000000000000000..a4846da652a48ea5af704813d779cedcf9f3aa0d --- /dev/null +++ b/.changeset/moody-cups-search.md @@ -0,0 +1,5 @@ +--- +'@rocket.chat/meteor': patch +--- + +Fixes an issue allowing only numbers, if trigger's condition is 'visitor time on site' diff --git a/.changeset/nasty-islands-trade.md b/.changeset/nasty-islands-trade.md deleted file mode 100644 index b6df94282dd5114c3508c551fa055074e8b8bd81..0000000000000000000000000000000000000000 --- a/.changeset/nasty-islands-trade.md +++ /dev/null @@ -1,6 +0,0 @@ ---- -'@rocket.chat/rest-typings': minor -'@rocket.chat/meteor': minor ---- - -fix Federation Regression, builds service correctly diff --git a/.changeset/rich-dingos-mix.md b/.changeset/rich-dingos-mix.md new file mode 100644 index 0000000000000000000000000000000000000000..d514535a97b4f42a13b01c9ccdda5f1cf2fcc126 --- /dev/null +++ b/.changeset/rich-dingos-mix.md @@ -0,0 +1,5 @@ +--- +'@rocket.chat/meteor': patch +--- + +Added reactions tooltip loader diff --git a/.changeset/rude-avocados-notice.md b/.changeset/rude-avocados-notice.md new file mode 100644 index 0000000000000000000000000000000000000000..8b8b8715657c6fe8c82e45c89c33f381f132b23c --- /dev/null +++ b/.changeset/rude-avocados-notice.md @@ -0,0 +1,5 @@ +--- +'@rocket.chat/meteor': patch +--- + +Fixed discussion names displaying as IDs in sidebar search results diff --git a/.changeset/serious-cows-compete.md b/.changeset/serious-cows-compete.md new file mode 100644 index 0000000000000000000000000000000000000000..be419a9bd9cda09b5982797188aa85f8491c5081 --- /dev/null +++ b/.changeset/serious-cows-compete.md @@ -0,0 +1,5 @@ +--- +"@rocket.chat/meteor": patch +--- + +Fixed a bug on the rooms page's "Favorite" setting, which previously failed to designate selected rooms as favorites by default. diff --git a/.changeset/silver-chicken-learn.md b/.changeset/silver-chicken-learn.md new file mode 100644 index 0000000000000000000000000000000000000000..3257849ba45c922c10867fedac930df6fadf4a2f --- /dev/null +++ b/.changeset/silver-chicken-learn.md @@ -0,0 +1,5 @@ +--- +"@rocket.chat/livechat": patch +--- + +fixed livechat UI blinking different colors when the chat is finished diff --git a/.changeset/silver-mice-allow.md b/.changeset/silver-mice-allow.md deleted file mode 100644 index 0be0670a11f858f9da4afa6954c7c75c877cb5e1..0000000000000000000000000000000000000000 --- a/.changeset/silver-mice-allow.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@rocket.chat/meteor": patch ---- - -fix: mention channel redirecting to own DM diff --git a/.changeset/small-beers-call.md b/.changeset/small-beers-call.md new file mode 100644 index 0000000000000000000000000000000000000000..0e0f7414b8fcc5066f9f5e9458926ac5490cd501 --- /dev/null +++ b/.changeset/small-beers-call.md @@ -0,0 +1,5 @@ +--- +"@rocket.chat/meteor": patch +--- + +Looking at the user's permission before rendering the 'Start Call' button on the UserInfo panel, so if the user does not have the permissions, the button does not show diff --git a/.changeset/spicy-kiwis-argue.md b/.changeset/spicy-kiwis-argue.md deleted file mode 100644 index 520fdfe220150aa91b87b94b4ba599f86b8baf3b..0000000000000000000000000000000000000000 --- a/.changeset/spicy-kiwis-argue.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@rocket.chat/meteor': patch ---- - -Fix desktop notification routing for direct rooms diff --git a/.changeset/spicy-wombats-shout.md b/.changeset/spicy-wombats-shout.md new file mode 100644 index 0000000000000000000000000000000000000000..d84a815241dce9f8071bdb44ce148d1f73c2c284 --- /dev/null +++ b/.changeset/spicy-wombats-shout.md @@ -0,0 +1,5 @@ +--- +'@rocket.chat/meteor': minor +--- + +Introduces message navigability, allowing users to navigate on messages through keyboard diff --git a/.changeset/tall-bears-compete.md b/.changeset/tall-bears-compete.md new file mode 100644 index 0000000000000000000000000000000000000000..6929ba4d0afd944e399fc352c201de9d62ac1edf --- /dev/null +++ b/.changeset/tall-bears-compete.md @@ -0,0 +1,5 @@ +--- +"@rocket.chat/web-ui-registration": patch +--- + +Fixed a bug that caused the Login page to crash when closing the Two-Factor Authentication modal using the Cancel button or the X button. diff --git a/.changeset/ten-bobcats-occur.md b/.changeset/ten-bobcats-occur.md new file mode 100644 index 0000000000000000000000000000000000000000..325e40a21fa600e3ca0aa30180ad6959b098d0ec --- /dev/null +++ b/.changeset/ten-bobcats-occur.md @@ -0,0 +1,5 @@ +--- +'@rocket.chat/meteor': patch +--- + +Fixed an issue where the sync ldap avatars background process would never run diff --git a/.changeset/thirty-beds-taste.md b/.changeset/thirty-beds-taste.md new file mode 100644 index 0000000000000000000000000000000000000000..fd525635c87036f6abda64dbfab63649d1902cad --- /dev/null +++ b/.changeset/thirty-beds-taste.md @@ -0,0 +1,5 @@ +--- +"@rocket.chat/meteor": patch +--- + +Improved the layout of the 2FA modals and changed the email 2FA resend email anchor to a button. diff --git a/.changeset/twenty-yaks-grab.md b/.changeset/twenty-yaks-grab.md new file mode 100644 index 0000000000000000000000000000000000000000..38cf79704956e78de9a87a3a74ec8c51aa4af8f1 --- /dev/null +++ b/.changeset/twenty-yaks-grab.md @@ -0,0 +1,5 @@ +--- +'@rocket.chat/meteor': patch +--- + +Fixes an issue where messages are not updating properly after pruning the room diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 6e14d4935eaf07e327c39b3dec17ed679f94831d..0b2af2ef7881680b2e5a10243fe4958dace2a7e8 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -22,15 +22,13 @@ --> ## Proposed changes (including videos or screenshots) - - - ## Issue(s) diff --git a/.github/actions/meteor-build/action.yml b/.github/actions/meteor-build/action.yml index 0229e5bb5ba044a28194d95842246fa38046dec3..d261000ceb872ab3c2961b137b32261ffff04ab0 100644 --- a/.github/actions/meteor-build/action.yml +++ b/.github/actions/meteor-build/action.yml @@ -103,9 +103,8 @@ runs: working-directory: ./apps/meteor run: meteor reset - - name: Build Rocket.Chat From Pull Request + - name: Build Rocket.Chat shell: bash - if: startsWith(github.ref, 'refs/pull/') == true env: METEOR_PROFILE: 1000 BABEL_ENV: ${{ inputs.coverage == 'true' && 'coverage' || '' }} @@ -116,12 +115,7 @@ runs: echo "Coverage enabled" fi - yarn build:ci -- --directory /tmp/dist - - - name: Build Rocket.Chat - shell: bash - if: startsWith(github.ref, 'refs/pull/') != true - run: yarn build:ci -- --directory /tmp/dist + yarn build:ci - name: Prepare build shell: bash diff --git a/.github/workflows/ci-test-e2e.yml b/.github/workflows/ci-test-e2e.yml index eb7b228022c3c930650afe9a7eb7be07b90a8aff..3f75695b48771f5cdf6779b087919ef8dadb20e0 100644 --- a/.github/workflows/ci-test-e2e.yml +++ b/.github/workflows/ci-test-e2e.yml @@ -160,7 +160,7 @@ jobs: path: | ~/.cache/ms-playwright # This is the version of Playwright that we are using, if you are willing to upgrade, you should update this. - key: playwright-1.37.1 + key: playwright-1.40.1 - name: Install Playwright if: inputs.type == 'ui' && steps.cache-playwright.outputs.cache-hit != 'true' diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index be796470323adc0230d556b70b68f157d5259404..a8f8d29610bb3239217e004a068a918bec450bb7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -183,7 +183,7 @@ jobs: - uses: ./.github/actions/meteor-build with: node-version: ${{ needs.release-versions.outputs.node-version }} - coverage: true + coverage: ${{ github.event_name != 'release' }} build-prod: name: 📦 Meteor Build - official @@ -207,7 +207,7 @@ jobs: - uses: ./.github/actions/meteor-build with: node-version: ${{ needs.release-versions.outputs.node-version }} - coverage: false + coverage: ${{ github.event_name != 'release' }} build-gh-docker-coverage: name: 🚢 Build Docker Images for Testing @@ -724,21 +724,6 @@ jobs: # Makes build fail if the release isn't there curl --fail https://releases.rocket.chat/$RC_VERSION/info - - name: RedHat Registry - if: github.event_name == 'release' - env: - REDHAT_REGISTRY_PID: ${{ secrets.REDHAT_REGISTRY_PID }} - REDHAT_REGISTRY_KEY: ${{ secrets.REDHAT_REGISTRY_KEY }} - run: | - GIT_TAG="${GITHUB_REF#*tags/}" - - curl -X POST \ - https://connect.redhat.com/api/v2/projects/$REDHAT_REGISTRY_PID/build \ - -H "Authorization: Bearer $REDHAT_REGISTRY_KEY" \ - -H 'Cache-Control: no-cache' \ - -H 'Content-Type: application/json' \ - -d '{"tag":"'$GIT_TAG'"}' - trigger-dependent-workflows: runs-on: ubuntu-latest if: github.event_name == 'release' diff --git a/.github/workflows/pr-update-description.yml b/.github/workflows/pr-update-description.yml new file mode 100644 index 0000000000000000000000000000000000000000..71b4ffeda801e8a7269dc49f2634044ce6a7f8b9 --- /dev/null +++ b/.github/workflows/pr-update-description.yml @@ -0,0 +1,39 @@ +name: 'Release PR Description' + +on: + pull_request: + branches: + - master + +concurrency: ${{ github.workflow }}-${{ github.ref }} + +jobs: + update-pr: + runs-on: ubuntu-latest + if: startsWith(github.head_ref, 'release-') + steps: + - name: Checkout Repo + uses: actions/checkout@v4 + with: + fetch-depth: 0 + token: ${{ secrets.CI_PAT }} + + - name: Setup NodeJS + uses: ./.github/actions/setup-node + with: + node-version: 14.21.3 + cache-modules: true + install: true + + - uses: dtinth/setup-github-actions-caching-for-turbo@v1 + + - name: Build packages + run: yarn build + + - name: Update PR description + uses: ./packages/release-action + with: + action: update-pr-description + env: + GITHUB_TOKEN: ${{ secrets.CI_PAT }} + diff --git a/.gitignore b/.gitignore index 13ee265952bb28f1607bd64fb6bf135dee252e32..fcf2b8cd07c7ca3e825b3159c0247e04067d3b16 100644 --- a/.gitignore +++ b/.gitignore @@ -48,3 +48,5 @@ yarn-error.log* .envrc *.sublime-workspace + +**/.vim/ diff --git a/.gitpod.yml b/.gitpod.yml new file mode 100644 index 0000000000000000000000000000000000000000..e24ff7d2ebf113361844042a7073ebe5a5d816a3 --- /dev/null +++ b/.gitpod.yml @@ -0,0 +1,26 @@ +tasks: + - init: | + nvm install $(jq -r .engines.node package.json) && + curl https://install.meteor.com/ | sh && + export PATH="$PATH:$HOME/.meteor" && + yarn && + export ROOT_URL=$(gp url 3000) + command: yarn build && yarn dev + +ports: + - port: 3000 + visibility: public + onOpen: open-preview + +github: + prebuilds: + master: true + pullRequests: true + pullRequestsFromForks: true + addCheck: true + addComment: true + addBadge: true + +vscode: + extensions: + - esbenp.prettier-vscode \ No newline at end of file diff --git a/.kodiak.toml b/.kodiak.toml index 884f356a171256ae8645d06fafad7ea25a76f3a1..7f89eed8f16978cfc1b3e3396425d3fa35d8ef2e 100644 --- a/.kodiak.toml +++ b/.kodiak.toml @@ -5,9 +5,8 @@ version = 1 method = "squash" automerge_label = ["stat: ready to merge", "automerge"] block_on_neutral_required_check_runs = true -blocking_labels = ["stat: needs QA", "Invalid PR Title"] +blocking_labels = ["stat: needs QA", "Invalid PR Title", "do not merge"] prioritize_ready_to_merge = true -merge.do_not_merge=true [merge.message] title = "pull_request_title" diff --git a/README.md b/README.md index a63baba65dd0072dae880b79ef6efbab7d5f5ce0..64dec811e1ca766506e7589ff2cdbba279bda77e 100644 --- a/README.md +++ b/README.md @@ -67,6 +67,14 @@ yarn dsv # run only meteor (front and back) with pre-built packages After initialized, you can access the server at http://localhost:3000 +# Gitpod Setup + +1. Click the button below to open this project in Gitpod. + +2. This will open a fully configured workspace in your browser with all the necessary dependencies already installed. + +[![Open in Gitpod](https://gitpod.io/button/open-in-gitpod.svg)](https://gitpod.io/#https://github.com/RocketChat/Rocket.Chat) + **Starting Rocket.Chat in microservices mode:** ```bash diff --git a/.sublime-project b/Rocket.Chat.sublime-project similarity index 100% rename from .sublime-project rename to Rocket.Chat.sublime-project diff --git a/apps/meteor/.docker/Dockerfile.rhel b/apps/meteor/.docker/Dockerfile.rhel deleted file mode 100644 index dc90b0f8813330d027d156ac953a6eec2345fc0d..0000000000000000000000000000000000000000 --- a/apps/meteor/.docker/Dockerfile.rhel +++ /dev/null @@ -1,44 +0,0 @@ -FROM registry.access.redhat.com/ubi8/nodejs-12 - -ENV RC_VERSION 6.6.0-develop - -MAINTAINER buildmaster@rocket.chat - -LABEL name="Rocket.Chat" \ - vendor="Rocket.Chat" \ - version="${RC_VERSION}" \ - release="1" \ - url="https://rocket.chat" \ - summary="The Ultimate Open Source Web Chat Platform" \ - description="The Ultimate Open Source Web Chat Platform" \ - run="docker run -d --name ${NAME} ${IMAGE}" - -USER root -RUN dnf install -y python38 && rm -rf /var/cache /var/log/dnf* /var/log/yum.* -USER default - -RUN set -x \ - && gpg --keyserver keys.openpgp.org --recv-keys 0E163286C20D07B9787EBE9FD7F9D0414FD08104 \ - && curl -SLf "https://releases.rocket.chat/${RC_VERSION}/download" -o rocket.chat.tgz \ - && curl -SLf "https://releases.rocket.chat/${RC_VERSION}/asc" -o rocket.chat.tgz.asc \ - && gpg --verify rocket.chat.tgz.asc \ - && tar -zxf rocket.chat.tgz -C /opt/app-root/src/ \ - && cd /opt/app-root/src/bundle/programs/server \ - && npm install - -COPY licenses /licenses - -VOLUME /opt/app-root/src/uploads - -WORKDIR /opt/app-root/src/bundle - -ENV DEPLOY_METHOD=docker-redhat \ - NODE_ENV=production \ - MONGO_URL=mongodb://mongo:27017/rocketchat \ - HOME=/tmp \ - PORT=3000 \ - ROOT_URL=http://localhost:3000 - -EXPOSE 3000 - -CMD ["node", "main.js"] diff --git a/apps/meteor/.meteor/packages b/apps/meteor/.meteor/packages index e99aa0bdfb67176b68fac9572e27ec4816458474..8107c249add2a2e3c77581585ccbdfe0eba35316 100644 --- a/apps/meteor/.meteor/packages +++ b/apps/meteor/.meteor/packages @@ -15,34 +15,34 @@ rocketchat:streamer rocketchat:version rocketchat:user-presence -accounts-base@2.2.8 +accounts-base@2.2.10 accounts-facebook@1.3.3 accounts-github@1.5.0 accounts-google@1.4.0 accounts-meteor-developer@1.5.0 -accounts-oauth@1.4.2 -accounts-password@2.3.4 +accounts-oauth@1.4.3 +accounts-password@2.4.0 accounts-twitter@1.5.0 pauli:accounts-linkedin google-oauth@1.4.4 -oauth@2.2.0 +oauth@2.2.1 oauth2@1.3.2 check@1.3.2 -ddp-rate-limiter@1.2.0 +ddp-rate-limiter@1.2.1 rate-limit@1.1.1 email@2.2.5 http@2.0.0 meteor-base@1.5.1 ddp-common@1.4.0 -webapp@1.13.5 +webapp@1.13.8 -mongo@1.16.7 +mongo@1.16.8 reload@1.3.1 -service-configuration@1.3.1 +service-configuration@1.3.3 session@1.2.1 shell-server@0.5.0 @@ -58,15 +58,15 @@ routepolicy@1.1.1 webapp-hashing@1.1.1 facts-base@1.0.1 -tracker@1.3.2 +tracker@1.3.3 reactive-dict@1.3.1 reactive-var@1.0.12 -babel-compiler@7.10.4 +babel-compiler@7.10.5 standard-minifier-css@1.9.2 dynamic-import@0.7.3 -ecmascript@0.16.7 -typescript@4.9.4 +ecmascript@0.16.8 +typescript@4.9.5 autoupdate@1.8.0 diff --git a/apps/meteor/.meteor/release b/apps/meteor/.meteor/release index 6641d0478a104140e259b23dd2203f83229ab81b..966586ce54fe9f9d4cae918cd2f5c74677c0fdba 100644 --- a/apps/meteor/.meteor/release +++ b/apps/meteor/.meteor/release @@ -1 +1 @@ -METEOR@2.13.3 +METEOR@2.15 diff --git a/apps/meteor/.meteor/versions b/apps/meteor/.meteor/versions index 6dab889e3e38e7572a50a72398d2607d4093c82c..a4483a5cf40e1a27b67241c8ee867ddad965b375 100644 --- a/apps/meteor/.meteor/versions +++ b/apps/meteor/.meteor/versions @@ -1,18 +1,18 @@ -accounts-base@2.2.8 +accounts-base@2.2.10 accounts-facebook@1.3.3 accounts-github@1.5.0 accounts-google@1.4.0 accounts-meteor-developer@1.5.0 -accounts-oauth@1.4.2 -accounts-password@2.3.4 +accounts-oauth@1.4.3 +accounts-password@2.4.0 accounts-twitter@1.5.0 allow-deny@1.1.1 autoupdate@1.8.0 -babel-compiler@7.10.4 +babel-compiler@7.10.5 babel-runtime@1.5.1 base64@1.0.12 binary-heap@1.0.11 -boilerplate-generator@1.7.1 +boilerplate-generator@1.7.2 caching-compiler@1.2.2 callback-hook@1.5.1 check@1.3.2 @@ -21,21 +21,21 @@ coffeescript-compiler@2.4.1 ddp@1.4.1 ddp-client@2.6.1 ddp-common@1.4.0 -ddp-rate-limiter@1.2.0 -ddp-server@2.6.2 +ddp-rate-limiter@1.2.1 +ddp-server@2.7.0 diff-sequence@1.1.2 dispatch:run-as-user@1.1.1 dynamic-import@0.7.3 -ecmascript@0.16.7 +ecmascript@0.16.8 ecmascript-runtime@0.8.1 ecmascript-runtime-client@0.12.1 ecmascript-runtime-server@0.11.0 ejson@1.1.3 email@2.2.5 es5-shim@4.8.0 -facebook-oauth@1.11.2 +facebook-oauth@1.11.3 facts-base@1.0.1 -fetch@0.1.3 +fetch@0.1.4 geojson-utils@1.0.11 github-oauth@1.4.1 google-oauth@1.4.4 @@ -45,22 +45,22 @@ id-map@1.1.1 inter-process-messaging@0.1.1 kadira:flow-router@2.12.1 localstorage@1.2.0 -logging@1.3.2 -meteor@1.11.3 +logging@1.3.3 +meteor@1.11.5 meteor-base@1.5.1 meteor-developer-oauth@1.3.2 meteorhacks:inject-initial@1.0.5 minifier-css@1.6.4 minimongo@1.9.3 -modern-browsers@0.1.9 -modules@0.19.0 +modern-browsers@0.1.10 +modules@0.20.0 modules-runtime@0.13.1 -mongo@1.16.7 +mongo@1.16.8 mongo-decimal@0.1.3 mongo-dev-server@1.1.0 mongo-id@1.0.8 -npm-mongo@4.16.0 -oauth@2.2.0 +npm-mongo@4.17.2 +oauth@2.2.1 oauth1@1.5.1 oauth2@1.3.2 ordered-dict@1.1.0 @@ -70,7 +70,7 @@ pauli:linkedin-oauth@6.0.0 promise@0.12.2 random@1.2.1 rate-limit@1.1.1 -react-fast-refresh@0.2.7 +react-fast-refresh@0.2.8 reactive-dict@1.3.1 reactive-var@1.0.12 reload@1.3.1 @@ -84,19 +84,19 @@ rocketchat:streamer@1.1.0 rocketchat:user-presence@2.6.3 rocketchat:version@1.0.0 routepolicy@1.1.1 -service-configuration@1.3.1 +service-configuration@1.3.3 session@1.2.1 sha@1.0.9 shell-server@0.5.0 -socket-stream-client@0.5.1 +socket-stream-client@0.5.2 standard-minifier-css@1.9.2 -tracker@1.3.2 +tracker@1.3.3 twitter-oauth@1.3.3 -typescript@4.9.4 -underscore@1.0.13 +typescript@4.9.5 +underscore@1.6.0 url@1.3.2 -webapp@1.13.5 +webapp@1.13.8 webapp-hashing@1.1.1 zodern:caching-minifier@0.5.0 -zodern:standard-minifier-js@5.1.2 -zodern:types@1.0.9 +zodern:standard-minifier-js@5.3.1 +zodern:types@1.0.11 diff --git a/apps/meteor/.scripts/check-i18n.js b/apps/meteor/.scripts/check-i18n.js deleted file mode 100644 index a56980f2406aa9140fd85ece59c8c8eeb83b66ce..0000000000000000000000000000000000000000 --- a/apps/meteor/.scripts/check-i18n.js +++ /dev/null @@ -1,109 +0,0 @@ -const fs = require('fs'); -const path = require('path'); - -const fg = require('fast-glob'); - -const regexVar = /__[a-zA-Z_]+__/g; - -const validateKeys = (json, usedKeys) => - usedKeys - .filter(({ key }) => typeof json[key] !== 'undefined') - .reduce((prev, cur) => { - const { key, replaces } = cur; - - const miss = replaces.filter((replace) => json[key] && json[key].indexOf(replace) === -1); - - if (miss.length > 0) { - prev.push({ key, miss }); - } - - return prev; - }, []); - -const removeMissingKeys = (i18nFiles, usedKeys) => { - i18nFiles.forEach((file) => { - const json = JSON.parse(fs.readFileSync(file, 'utf8')); - if (Object.keys(json).length === 0) { - return; - } - - validateKeys(json, usedKeys).forEach(({ key }) => { - json[key] = null; - }); - - fs.writeFileSync(file, JSON.stringify(json, null, 2)); - }); -}; - -const checkUniqueKeys = (content, json, filename) => { - const matchKeys = content.matchAll(/^\s+"([^"]+)"/gm); - - const allKeys = [...matchKeys]; - - if (allKeys.length !== Object.keys(json).length) { - throw new Error(`Duplicated keys found on file ${filename}`); - } -}; - -const validate = (i18nFiles, usedKeys) => { - const totalErrors = i18nFiles.reduce((errors, file) => { - const content = fs.readFileSync(file, 'utf8'); - const json = JSON.parse(content); - - checkUniqueKeys(content, json, file); - - // console.log('json, usedKeys2', json, usedKeys); - - const result = validateKeys(json, usedKeys); - - if (result.length === 0) { - return errors; - } - - console.log('\n## File', file, `(${result.length} errors)`); - - result.forEach(({ key, miss }) => { - console.log('\n- Key:', key, '\n Missing variables:', miss.join(', ')); - }); - - return errors + result.length; - }, 0); - - if (totalErrors > 0) { - throw new Error(`\n${totalErrors} errors found`); - } -}; - -const checkFiles = async (sourcePath, sourceFile, fix = false) => { - const content = fs.readFileSync(path.join(sourcePath, sourceFile), 'utf8'); - const sourceContent = JSON.parse(content); - - checkUniqueKeys(content, sourceContent, sourceFile); - - const usedKeys = Object.entries(sourceContent).map(([key, value]) => { - const replaces = value.match(regexVar); - return { - key, - replaces, - }; - }); - - const keysWithInterpolation = usedKeys.filter(({ replaces }) => !!replaces); - - const i18nFiles = await fg([`${sourcePath}/**/*.i18n.json`]); - - if (fix) { - return removeMissingKeys(i18nFiles, keysWithInterpolation); - } - - validate(i18nFiles, keysWithInterpolation); -}; - -(async () => { - try { - await checkFiles('./packages/rocketchat-i18n/i18n', 'en.i18n.json', process.argv[2] === '--fix'); - } catch (e) { - console.error(e); - process.exit(1); - } -})(); diff --git a/apps/meteor/.scripts/translation-check.ts b/apps/meteor/.scripts/translation-check.ts new file mode 100644 index 0000000000000000000000000000000000000000..11782c8db649b61740f5090bceaf9e947fd9a3f9 --- /dev/null +++ b/apps/meteor/.scripts/translation-check.ts @@ -0,0 +1,249 @@ +import type { PathLike } from 'node:fs'; +import { readFile, writeFile } from 'node:fs/promises'; +import { join } from 'node:path'; +import { inspect } from 'node:util'; + +import fg from 'fast-glob'; +import i18next from 'i18next'; +import supportsColor from 'supports-color'; + +const hasDuplicatedKeys = (content: string, json: Record) => { + const matchKeys = content.matchAll(/^\s+"([^"]+)"/gm); + + const allKeys = [...matchKeys]; + + return allKeys.length !== Object.keys(json).length; +}; + +const parseFile = async (path: PathLike) => { + const content = await readFile(path, 'utf-8'); + let json: Record; + try { + json = JSON.parse(content); + } catch (e) { + if (e instanceof SyntaxError) { + const matches = /^Unexpected token .* in JSON at position (\d+)$/.exec(e.message); + + if (matches) { + const [, positionStr] = matches; + const position = parseInt(positionStr, 10); + const line = content.slice(0, position).split('\n').length; + const column = position - content.slice(0, position).lastIndexOf('\n'); + throw new SyntaxError(`Invalid JSON on file ${path}:${line}:${column}`); + } + } + throw new SyntaxError(`Invalid JSON on file ${path}: ${e.message}`); + } + + if (hasDuplicatedKeys(content, json)) { + throw new SyntaxError(`Duplicated keys found on file ${path}`); + } + + return json; +}; + +const insertTranslation = (json: Record, refKey: string, [key, value]: [key: string, value: string]) => { + const entries = Object.entries(json); + + const refIndex = entries.findIndex(([entryKey]) => entryKey === refKey); + + if (refIndex === -1) { + throw new Error(`Reference key ${refKey} not found`); + } + + const movingEntries = entries.slice(refIndex + 1); + + for (const [key] of movingEntries) { + delete json[key]; + } + + json[key] = value; + + for (const [key, value] of movingEntries) { + json[key] = value; + } +}; + +const persistFile = async (path: PathLike, json: Record) => { + const content = JSON.stringify(json, null, 2); + + await writeFile(path, content, 'utf-8'); +}; + +const oldPlaceholderFormat = /__([a-zA-Z_]+)__/g; + +const checkPlaceholdersFormat = async ({ json, path, fix = false }: { json: Record; path: PathLike; fix?: boolean }) => { + const outdatedKeys = Object.entries(json) + .map(([key, value]) => ({ + key, + value, + placeholders: value.match(oldPlaceholderFormat), + })) + .filter((outdatedKey): outdatedKey is { key: string; value: string; placeholders: RegExpMatchArray } => !!outdatedKey.placeholders); + + if (outdatedKeys.length > 0) { + const message = `Outdated placeholder format on file ${path}: ${inspect(outdatedKeys, { colors: !!supportsColor.stdout })}`; + + if (fix) { + console.warn(message); + + for (const { key, value } of outdatedKeys) { + const newValue = value.replace(oldPlaceholderFormat, (_, name) => `{{${name}}}`); + + json[key] = newValue; + } + + await persistFile(path, json); + + return; + } + + throw new Error(message); + } +}; + +export const extractSingularKeys = (json: Record, lng: string) => { + if (!i18next.isInitialized) { + i18next.init({ initImmediate: false }); + } + + const pluralSuffixes = i18next.services.pluralResolver.getSuffixes(lng) as string[]; + + const singularKeys = new Set( + Object.keys(json).map((key) => { + for (const pluralSuffix of pluralSuffixes) { + if (key.endsWith(pluralSuffix)) { + return key.slice(0, -pluralSuffix.length); + } + } + + return key; + }), + ); + + return [singularKeys, pluralSuffixes] as const; +}; + +const checkMissingPlurals = async ({ + json, + path, + lng, + fix = false, +}: { + json: Record; + path: PathLike; + lng: string; + fix?: boolean; +}) => { + const [singularKeys, pluralSuffixes] = extractSingularKeys(json, lng); + + const missingPluralKeys: { singularKey: string; existing: string[]; missing: string[] }[] = []; + + for (const singularKey of singularKeys) { + if (singularKey in json) { + continue; + } + + const pluralKeys = pluralSuffixes.map((suffix) => `${singularKey}${suffix}`); + + const existing = pluralKeys.filter((key) => key in json); + const missing = pluralKeys.filter((key) => !(key in json)); + + if (missing.length > 0) { + missingPluralKeys.push({ singularKey, existing, missing }); + } + } + + if (missingPluralKeys.length > 0) { + const message = `Missing plural keys on file ${path}: ${inspect(missingPluralKeys, { colors: !!supportsColor.stdout })}`; + + if (fix) { + console.warn(message); + + for (const { existing, missing } of missingPluralKeys) { + for (const missingKey of missing) { + const refKey = existing.slice(-1)[0]; + const value = json[refKey]; + insertTranslation(json, refKey, [missingKey, value]); + } + } + + await persistFile(path, json); + + return; + } + + throw new Error(message); + } +}; + +const checkExceedingKeys = async ({ + json, + path, + lng, + sourceJson, + sourceLng, + fix = false, +}: { + json: Record; + path: PathLike; + lng: string; + sourceJson: Record; + sourceLng: string; + fix?: boolean; +}) => { + const [singularKeys] = extractSingularKeys(json, lng); + const [sourceSingularKeys] = extractSingularKeys(sourceJson, sourceLng); + + const exceedingKeys = [...singularKeys].filter((key) => !sourceSingularKeys.has(key)); + + if (exceedingKeys.length > 0) { + const message = `Exceeding keys on file ${path}: ${inspect(exceedingKeys, { colors: !!supportsColor.stdout })}`; + + if (fix) { + for (const key of exceedingKeys) { + delete json[key]; + } + + await persistFile(path, json); + + return; + } + + throw new Error(message); + } +}; + +const checkFiles = async (sourceDirPath: string, sourceLng: string, fix = false) => { + const sourcePath = join(sourceDirPath, `${sourceLng}.i18n.json`); + const sourceJson = await parseFile(sourcePath); + + await checkPlaceholdersFormat({ json: sourceJson, path: sourcePath, fix }); + await checkMissingPlurals({ json: sourceJson, path: sourcePath, lng: sourceLng, fix }); + + const i18nFiles = await fg([join(sourceDirPath, `**/*.i18n.json`), `!${sourcePath}`]); + + const languageFileRegex = /\/([^\/]*?).i18n.json$/; + const translations = await Promise.all( + i18nFiles.map(async (path) => { + const lng = languageFileRegex.exec(path)?.[1]; + if (!lng) { + throw new Error(`Invalid language file path ${path}`); + } + + return { path, json: await parseFile(path), lng }; + }), + ); + + for await (const { path, json, lng } of translations) { + await checkPlaceholdersFormat({ json, path, fix }); + await checkExceedingKeys({ json, path, lng, sourceJson, sourceLng, fix }); + await checkMissingPlurals({ json, path, lng, fix }); + } +}; + +const fix = process.argv[2] === '--fix'; +checkFiles('./packages/rocketchat-i18n/i18n', 'en', fix).catch((e) => { + console.error(e); + process.exit(1); +}); diff --git a/apps/meteor/.scripts/translationDiff.js b/apps/meteor/.scripts/translation-diff.ts similarity index 54% rename from apps/meteor/.scripts/translationDiff.js rename to apps/meteor/.scripts/translation-diff.ts index 7c83e33c76eea8ab21565136a615859e5deac67b..0ee7a1c72b9d411ef060073e4ca7e7ddd1ac76da 100644 --- a/apps/meteor/.scripts/translationDiff.js +++ b/apps/meteor/.scripts/translation-diff.ts @@ -1,18 +1,14 @@ -#!/usr/bin/env node +#!/usr/bin/env ts-node -const fs = require('fs'); -const path = require('path'); -const util = require('util'); - -// Convert fs.readFile into Promise version of same -const readFile = util.promisify(fs.readFile); +import { readFile } from 'fs/promises'; +import path from 'path'; const translationDir = path.resolve(__dirname, '../packages/rocketchat-i18n/i18n/'); -async function translationDiff(source, target) { +async function translationDiff(source: string, target: string) { console.debug('loading translations from', translationDir); - function diffKeys(a, b) { + function diffKeys(a: Record, b: Record) { const diff = {}; Object.keys(a).forEach((key) => { if (!b[key]) { @@ -29,10 +25,9 @@ async function translationDiff(source, target) { return diffKeys(sourceTranslations, targetTranslations); } -console.log('Note: You can set the source and target language of the comparison with env-variables SOURCE/TARGET_LANGUAGE'); -const sourceLang = process.env.SOURCE_LANGUAGE || 'en'; -const targetLang = process.env.TARGET_LANGUAGE || 'de'; +const sourceLang = process.argv[2] || 'en'; +const targetLang = process.argv[3] || 'de'; translationDiff(sourceLang, targetLang).then((diff) => { console.log('Diff between', sourceLang, 'and', targetLang); - console.log(JSON.stringify(diff, '', 2)); + console.log(JSON.stringify(diff, undefined, 2)); }); diff --git a/apps/meteor/.scripts/fix-i18n.js b/apps/meteor/.scripts/translation-fix-order.ts similarity index 84% rename from apps/meteor/.scripts/fix-i18n.js rename to apps/meteor/.scripts/translation-fix-order.ts index f0002c8ca4eb6d4b5548466c0327d02138143a32..14eba2e736825783d42476970edab8ce68436073 100644 --- a/apps/meteor/.scripts/fix-i18n.js +++ b/apps/meteor/.scripts/translation-fix-order.ts @@ -6,11 +6,11 @@ * - remove all keys not present in source i18n file */ -const fs = require('fs'); +import fs from 'fs'; -const fg = require('fast-glob'); +import fg from 'fast-glob'; -const fixFiles = (path, source, newlineAtEnd = false) => { +const fixFiles = (path: string, source: string, newlineAtEnd = false) => { const sourceFile = JSON.parse(fs.readFileSync(`${path}${source}`, 'utf8')); const sourceKeys = Object.keys(sourceFile); diff --git a/apps/meteor/CHANGELOG.md b/apps/meteor/CHANGELOG.md index 874fc20d33b69e9ce01583462f8a405fe7d644f9..2ed3ce7e07a0cb41d95ecacebed0653a919ff282 100644 --- a/apps/meteor/CHANGELOG.md +++ b/apps/meteor/CHANGELOG.md @@ -1,5 +1,705 @@ # @rocket.chat/meteor +## 6.6.1 + +### Patch Changes + +- Bump @rocket.chat/meteor version. + +- Bump @rocket.chat/meteor version. + +- ([#31713](https://github.com/RocketChat/Rocket.Chat/pull/31713)) Fixes an issue not allowing admin users to edit the room name + +- ([#31723](https://github.com/RocketChat/Rocket.Chat/pull/31723)) fixed an issue with the user presence not updating automatically for other users. + +- ([#31753](https://github.com/RocketChat/Rocket.Chat/pull/31753)) Fixed an issue where the login button for Custom OAuth services would not work if any non-custom login service was also available + +- ([#31554](https://github.com/RocketChat/Rocket.Chat/pull/31554) by [@shivang-16](https://github.com/shivang-16)) Fixed a bug on the rooms page's "Favorite" setting, which previously failed to designate selected rooms as favorites by default. + +-
Updated dependencies []: + + - @rocket.chat/core-typings@6.6.1 + - @rocket.chat/rest-typings@6.6.1 + - @rocket.chat/api-client@0.1.23 + - @rocket.chat/license@0.1.5 + - @rocket.chat/omnichannel-services@0.1.5 + - @rocket.chat/pdf-worker@0.0.29 + - @rocket.chat/presence@0.1.5 + - @rocket.chat/core-services@0.3.5 + - @rocket.chat/cron@0.0.25 + - @rocket.chat/gazzodown@4.0.1 + - @rocket.chat/model-typings@0.3.1 + - @rocket.chat/ui-contexts@4.0.1 + - @rocket.chat/server-cloud-communication@0.0.2 + - @rocket.chat/fuselage-ui-kit@4.0.1 + - @rocket.chat/models@0.0.29 + - @rocket.chat/ui-theming@0.1.2 + - @rocket.chat/ui-client@4.0.1 + - @rocket.chat/ui-video-conf@4.0.1 + - @rocket.chat/web-ui-registration@4.0.1 + - @rocket.chat/instance-status@0.0.29 +
+ +## 6.6.0 + +### Minor Changes + +- ([#31184](https://github.com/RocketChat/Rocket.Chat/pull/31184)) Add the possibility to hide some elements through postMessage events. + +- ([#31516](https://github.com/RocketChat/Rocket.Chat/pull/31516)) Room header keyboard navigability + + ![Kapture 2024-01-22 at 11 33 14](https://github.com/RocketChat/Rocket.Chat/assets/27704687/f116c1e6-4ec7-4175-a01b-fa98eade2416) + +- ([#30868](https://github.com/RocketChat/Rocket.Chat/pull/30868)) Added `push.info` endpoint to enable users to retrieve info about the workspace's push gateway + +- ([#31510](https://github.com/RocketChat/Rocket.Chat/pull/31510)) Composer keyboard navigability + + ![Kapture 2024-01-22 at 11 33 14](https://github.com/RocketChat/Rocket.Chat/assets/27704687/f116c1e6-4ec7-4175-a01b-fa98eade2416) + +- ([#30464](https://github.com/RocketChat/Rocket.Chat/pull/30464)) Mentioning users that are not in the channel now dispatches a warning message with actions + +- ([#31369](https://github.com/RocketChat/Rocket.Chat/pull/31369)) feat: add `ImageGallery` zoom controls + +- ([#31393](https://github.com/RocketChat/Rocket.Chat/pull/31393) by [@hardikbhatia777](https://github.com/hardikbhatia777)) Fixes an issue where avatars are not being disabled based on preference on quote attachments + +- ([#30680](https://github.com/RocketChat/Rocket.Chat/pull/30680)) feat: Skip to main content shortcut and useDocumentTitle + +- ([#31299](https://github.com/RocketChat/Rocket.Chat/pull/31299)) fix: Loading state for `Marketplace` related lists + +- ([#31347](https://github.com/RocketChat/Rocket.Chat/pull/31347) by [@Sayan4444](https://github.com/Sayan4444)) New feature to support cancel message editing message and hints for shortcuts. + +- ([#30554](https://github.com/RocketChat/Rocket.Chat/pull/30554)) **Added ‘Reported Users’ Tab to Moderation Console:** Enhances user monitoring by displaying reported users. + +- ([#31417](https://github.com/RocketChat/Rocket.Chat/pull/31417)) Added feature to sync the user's language preference with the autotranslate setting. + +- ([#31478](https://github.com/RocketChat/Rocket.Chat/pull/31478)) feat: `Bubble` on new messages indicators + image +- ([#31348](https://github.com/RocketChat/Rocket.Chat/pull/31348) by [@Sayan4444](https://github.com/Sayan4444)) Added a modal to confirm the intention to pin a message, preventing users from doing it by mistake + +### Patch Changes + +- ([#31318](https://github.com/RocketChat/Rocket.Chat/pull/31318) by [@hardikbhatia777](https://github.com/hardikbhatia777)) Fixed Attachments not respecting collapse property when using incoming webhook + +- ([#31138](https://github.com/RocketChat/Rocket.Chat/pull/31138)) feat(uikit): Move `@rocket.chat/ui-kit` package to the main monorepo + +- ([#31380](https://github.com/RocketChat/Rocket.Chat/pull/31380)) Fix user being logged out after using 2FA + +- ([#31281](https://github.com/RocketChat/Rocket.Chat/pull/31281)) Improved support for higlighted words in threads (rooms are now marked as unread and notifications are sent) + +- ([#31104](https://github.com/RocketChat/Rocket.Chat/pull/31104)) Clear message box related items from local storage on logout + +- ([#31556](https://github.com/RocketChat/Rocket.Chat/pull/31556)) Bump @rocket.chat/meteor version. + +- ([#31556](https://github.com/RocketChat/Rocket.Chat/pull/31556)) Bump @rocket.chat/meteor version. + +- ([#31556](https://github.com/RocketChat/Rocket.Chat/pull/31556)) Bump @rocket.chat/meteor version. + +- ([#31556](https://github.com/RocketChat/Rocket.Chat/pull/31556)) Bump @rocket.chat/meteor version. + +- ([#31556](https://github.com/RocketChat/Rocket.Chat/pull/31556)) Bump @rocket.chat/meteor version. + +- ([#31556](https://github.com/RocketChat/Rocket.Chat/pull/31556)) Bump @rocket.chat/meteor version. + +- ([#31556](https://github.com/RocketChat/Rocket.Chat/pull/31556)) Bump @rocket.chat/meteor version. + +- Bump @rocket.chat/meteor version. + +- ([#31349](https://github.com/RocketChat/Rocket.Chat/pull/31349) by [@Subhojit-Dey1234](https://github.com/Subhojit-Dey1234)) feat: Implemented InlineCode handling in Bold, Italic and Strike + +- ([#31181](https://github.com/RocketChat/Rocket.Chat/pull/31181)) Fixed issue with notifications for thread messages still being sent after thread has been read + +- ([#30750](https://github.com/RocketChat/Rocket.Chat/pull/30750)) fix: OAuth login by redirect failing on firefox + +- ([#31312](https://github.com/RocketChat/Rocket.Chat/pull/31312) by [@Sayan4444](https://github.com/Sayan4444)) Fixed an issue displaying the language selection preference empty when it should display 'Default' on the initial value + +- ([#31204](https://github.com/RocketChat/Rocket.Chat/pull/31204)) Fixed an issue that caused Omnichannel Business Units to be saved even when the "monitors" list passed on the endpoint included users without monitor role + +- ([#31332](https://github.com/RocketChat/Rocket.Chat/pull/31332) by [@Sayan4444](https://github.com/Sayan4444)) Fixed image dropping from another browser window creates two upload dialogs in some OS and browsers + +- ([#31487](https://github.com/RocketChat/Rocket.Chat/pull/31487)) Fixed a bug where some sessions were being saved without a sessionId + +- ([#31070](https://github.com/RocketChat/Rocket.Chat/pull/31070)) Fixed issue with read receipts for older messages not being created on the first time a user reads a DM + +- ([#29367](https://github.com/RocketChat/Rocket.Chat/pull/29367) by [@anefzaoui](https://github.com/anefzaoui)) Fixes an issue where texts are not being displayed in the correct direction on messages + +- ([#31296](https://github.com/RocketChat/Rocket.Chat/pull/31296)) Fixed a problem with the Fallback Forward Department functionality when transferring rooms, caused by a missing return. This provoked the system to transfer to fallback department, as expected, but then continue the process and transfer to the department with no agents anyways. Also, a duplicated "user joined" message was removed from "Forward to department" functionality. + +- ([#31546](https://github.com/RocketChat/Rocket.Chat/pull/31546)) fixed UI crashing for users reading a room when it's deleted. + +- ([#31113](https://github.com/RocketChat/Rocket.Chat/pull/31113)) fix: Discussion messages deleted despite the "Do not delete discussion messages" retention policy enabled + +- ([#31269](https://github.com/RocketChat/Rocket.Chat/pull/31269) by [@ChaudharyRaman](https://github.com/ChaudharyRaman)) fix: Resolved Search List Issue when pressing ENTER + +- ([#31507](https://github.com/RocketChat/Rocket.Chat/pull/31507) by [@Spiral-Memory](https://github.com/Spiral-Memory)) Fixed an issue not allowing users to remove the password to join the room on room edit + +- ([#31413](https://github.com/RocketChat/Rocket.Chat/pull/31413)) fix: multiple indexes creation error during 304 migration + +- ([#31433](https://github.com/RocketChat/Rocket.Chat/pull/31433)) Fixed values discrepancy with downloaded report from Active users at Engagement Dashboard + +- ([#31049](https://github.com/RocketChat/Rocket.Chat/pull/31049)) Fixed an `UnhandledPromiseRejection` error on `PUT livechat/departments/:_id` endpoint when `agents` array failed validation + +- ([#31019](https://github.com/RocketChat/Rocket.Chat/pull/31019)) fix: Off the record feature was calling a deprecated and useless method. + +- ([#31225](https://github.com/RocketChat/Rocket.Chat/pull/31225)) notification emails should now show emojis properly + +- ([#31288](https://github.com/RocketChat/Rocket.Chat/pull/31288)) Fixed toolbox sub-menu not being displayed when in smaller resolutions + +- ([#30645](https://github.com/RocketChat/Rocket.Chat/pull/30645)) Apply plural translations at a few places. + +- ([#31514](https://github.com/RocketChat/Rocket.Chat/pull/31514)) Show marketplace apps installed as private in the right place (private tab) + +- ([#31289](https://github.com/RocketChat/Rocket.Chat/pull/31289)) Added `push.test` POST endpoint for sending test push notification to user (requires `test-push-notifications` permission) + +- ([#31346](https://github.com/RocketChat/Rocket.Chat/pull/31346) by [@Sayan4444](https://github.com/Sayan4444)) Fixed error message when uploading a file that is not allowed + +- ([#31371](https://github.com/RocketChat/Rocket.Chat/pull/31371)) Fixed an issue that caused login buttons to not be reactively removed from the login page when the related authentication service was disabled by an admin. + +- ([#30933](https://github.com/RocketChat/Rocket.Chat/pull/30933) by [@ldebowczyk](https://github.com/ldebowczyk)) fix: Visitor message not being sent to webhook due to wrong validation of settings + +- ([#31205](https://github.com/RocketChat/Rocket.Chat/pull/31205)) Fixed a problem that caused the wrong system message to be sent when a chat was resumed from on hold status. + Note: This fix is not retroactive so rooms where a wrong message was already sent will still show the wrong message. New calls to the resume actions will have the proper message. +- ([#30478](https://github.com/RocketChat/Rocket.Chat/pull/30478)) Added `chat.getURLPreview` endpoint to enable users to retrieve previews for URL (ready to be provided in message send/update) + +- ([#31432](https://github.com/RocketChat/Rocket.Chat/pull/31432)) Fixed SHIFT+ESCAPE inconsistency for clearing unread messages across browsers. + +- ([#31537](https://github.com/RocketChat/Rocket.Chat/pull/31537)) Fixed an issue where the webclient didn't properly clear the message caches from memory when a room is deleted. When this happened to basic DMs and the user started a new DM with the same target user, the client would show the old messages in the room history even though they no longer existed in the server. + +- ([#31387](https://github.com/RocketChat/Rocket.Chat/pull/31387)) fixed an issue when editing a channel's type or name sometimes showing "Room not found" error. + +- ([#31128](https://github.com/RocketChat/Rocket.Chat/pull/31128)) Fix: Mentioning discussions are appearing as ID + +- ([#31594](https://github.com/RocketChat/Rocket.Chat/pull/31594)) fix: missing slashcommand permissions for archive and unarchive + +- ([#31134](https://github.com/RocketChat/Rocket.Chat/pull/31134)) Fixed issue searching connected users on spotlight + +- ([#31248](https://github.com/RocketChat/Rocket.Chat/pull/31248)) Fixed Engagement Dashboard timezone selector freezing UI + +- ([#31336](https://github.com/RocketChat/Rocket.Chat/pull/31336)) Fixed issue with OEmbed cache not being cleared daily + +- ([#30069](https://github.com/RocketChat/Rocket.Chat/pull/30069)) Fixed using real names on messages reactions + +- ([#31099](https://github.com/RocketChat/Rocket.Chat/pull/31099)) fix: mention channel redirecting to own DM + +- ([#31415](https://github.com/RocketChat/Rocket.Chat/pull/31415)) fix: quote image gallery + +- ([#31209](https://github.com/RocketChat/Rocket.Chat/pull/31209)) Fixes the `overview` endpoint to show busiest time of the day in users timezone instead of UTC. + +- ([#31164](https://github.com/RocketChat/Rocket.Chat/pull/31164)) Improved the experience of receiving conference calls on the mobile app by disabling the push notification for the "new call" message if a push is already being sent to trigger the phone's ringing tone. + +- ([#31540](https://github.com/RocketChat/Rocket.Chat/pull/31540)) Fix multi-instance data formats being lost + +- ([#31270](https://github.com/RocketChat/Rocket.Chat/pull/31270)) Fixed an issue where room access and creation were hindered due to join codes not being fetched correctly in the API. + +- ([#31368](https://github.com/RocketChat/Rocket.Chat/pull/31368)) Added missing labels to "Users by time of the day" card at Engagement Dashboard page + +- ([#31277](https://github.com/RocketChat/Rocket.Chat/pull/31277)) Fixed the problem of not being possible to add a join code to a public room + +- ([#31292](https://github.com/RocketChat/Rocket.Chat/pull/31292)) Fixed the problem of displaying the wrong composer for archived room + +- ([#31287](https://github.com/RocketChat/Rocket.Chat/pull/31287)) Removed an old behavior that allowed visitors to be created with an empty token on `livechat/visitor` endpoint. + +- ([#31267](https://github.com/RocketChat/Rocket.Chat/pull/31267)) Fixed conversations in queue being limited to 50 items + +- ([#31328](https://github.com/RocketChat/Rocket.Chat/pull/31328)) Fixed an issue caused by the `Fallback Forward Department` feature. Feature could be configured by admins in a way that mimis a loop, causing a chat to be forwarded "infinitely" between those departments. System will now prevent Self & 1-level deep circular references from being saved, and a new setting is added to control the maximum number of hops that the system will do between fallback departments before considering a transfer failure. + +- ([#31467](https://github.com/RocketChat/Rocket.Chat/pull/31467) by [@apurb-coder](https://github.com/apurb-coder)) Fix an issue that breaks the avatar if you hit the button adding an invalid link + +- ([#31228](https://github.com/RocketChat/Rocket.Chat/pull/31228)) Fixed the filter for file type in the list of room files + +- ([#31377](https://github.com/RocketChat/Rocket.Chat/pull/31377)) Fixed LDAP "Group filter" malfunction, which prevented LDAP users from logging in. + +- ([#31461](https://github.com/RocketChat/Rocket.Chat/pull/31461)) Fixed issue with user presence displayed as offline on SAML login + +- ([#31391](https://github.com/RocketChat/Rocket.Chat/pull/31391)) Fixed Atlassian Crowd integration with Rocket.Chat not working + +- ([#30910](https://github.com/RocketChat/Rocket.Chat/pull/30910)) fix: change the push sound sent when the push is from video conference + +-
Updated dependencies [b223cbde14, b2b0035162, 9cb97965ba, dbb08ef948, fae558bd5d, 748e57984d, 4c2771fd0c, dd5fd6d2c8, 7c6198f49f, 9a6e9b4e28, fdd9852079, e1fa2b84fb, 2260c04ec6, c8ab6583dc, e7d3cdeef0, b4b2cd20a8, db2551906c]: + + - @rocket.chat/ui-kit@0.33.0 + - @rocket.chat/server-cloud-communication@0.0.2 + - @rocket.chat/omnichannel-services@0.1.4 + - @rocket.chat/web-ui-registration@4.0.0 + - @rocket.chat/password-policies@0.0.2 + - @rocket.chat/fuselage-ui-kit@4.0.0 + - @rocket.chat/instance-status@0.0.28 + - @rocket.chat/api-client@0.1.22 + - @rocket.chat/pdf-worker@0.0.28 + - @rocket.chat/ui-theming@0.1.2 + - @rocket.chat/account-utils@0.0.2 + - @rocket.chat/core-services@0.3.4 + - @rocket.chat/model-typings@0.3.0 + - @rocket.chat/ui-video-conf@4.0.0 + - @rocket.chat/cas-validate@0.0.2 + - @rocket.chat/core-typings@6.6.0 + - @rocket.chat/rest-typings@6.6.0 + - @rocket.chat/server-fetch@0.0.3 + - @rocket.chat/presence@0.1.4 + - @rocket.chat/poplib@0.0.2 + - @rocket.chat/ui-composer@0.1.0 + - @rocket.chat/ui-contexts@4.0.0 + - @rocket.chat/license@0.1.4 + - @rocket.chat/log-format@0.0.2 + - @rocket.chat/gazzodown@4.0.0 + - @rocket.chat/ui-client@4.0.0 + - @rocket.chat/favicon@0.0.2 + - @rocket.chat/agenda@0.1.0 + - @rocket.chat/base64@1.0.13 + - @rocket.chat/logger@0.0.2 + - @rocket.chat/models@0.0.28 + - @rocket.chat/random@1.2.2 + - @rocket.chat/sha256@1.0.10 + - @rocket.chat/tools@0.2.1 + - @rocket.chat/cron@0.0.24 + - @rocket.chat/i18n@0.1.0 + - @rocket.chat/jwt@0.1.1 +
+ +## 6.6.0-rc.7 + +### Patch Changes + +- Bump @rocket.chat/meteor version. + +-
Updated dependencies []: + + - @rocket.chat/core-typings@6.6.0-rc.7 + - @rocket.chat/rest-typings@6.6.0-rc.7 + - @rocket.chat/api-client@0.1.22-rc.7 + - @rocket.chat/license@0.1.4-rc.7 + - @rocket.chat/omnichannel-services@0.1.4-rc.7 + - @rocket.chat/pdf-worker@0.0.28-rc.7 + - @rocket.chat/presence@0.1.4-rc.7 + - @rocket.chat/core-services@0.3.4-rc.7 + - @rocket.chat/cron@0.0.24-rc.7 + - @rocket.chat/gazzodown@4.0.0-rc.7 + - @rocket.chat/model-typings@0.3.0-rc.7 + - @rocket.chat/ui-contexts@4.0.0-rc.7 + - @rocket.chat/server-cloud-communication@0.0.2-rc.0 + - @rocket.chat/fuselage-ui-kit@4.0.0-rc.7 + - @rocket.chat/models@0.0.28-rc.7 + - @rocket.chat/ui-theming@0.1.2-rc.0 + - @rocket.chat/ui-client@4.0.0-rc.7 + - @rocket.chat/ui-video-conf@4.0.0-rc.7 + - @rocket.chat/web-ui-registration@4.0.0-rc.7 + - @rocket.chat/instance-status@0.0.28-rc.7 +
+ +## 6.6.0-rc.6 + +### Patch Changes + +- Bump @rocket.chat/meteor version. + +- ([#31128](https://github.com/RocketChat/Rocket.Chat/pull/31128)) Fix: Mentioning discussions are appearing as ID + +-
Updated dependencies []: + + - @rocket.chat/core-typings@6.6.0-rc.6 + - @rocket.chat/rest-typings@6.6.0-rc.6 + - @rocket.chat/api-client@0.1.22-rc.6 + - @rocket.chat/license@0.1.4-rc.6 + - @rocket.chat/omnichannel-services@0.1.4-rc.6 + - @rocket.chat/pdf-worker@0.0.28-rc.6 + - @rocket.chat/presence@0.1.4-rc.6 + - @rocket.chat/core-services@0.3.4-rc.6 + - @rocket.chat/cron@0.0.24-rc.6 + - @rocket.chat/gazzodown@4.0.0-rc.6 + - @rocket.chat/model-typings@0.3.0-rc.6 + - @rocket.chat/ui-contexts@4.0.0-rc.6 + - @rocket.chat/server-cloud-communication@0.0.2-rc.0 + - @rocket.chat/fuselage-ui-kit@4.0.0-rc.6 + - @rocket.chat/models@0.0.28-rc.6 + - @rocket.chat/ui-theming@0.1.2-rc.0 + - @rocket.chat/ui-client@4.0.0-rc.6 + - @rocket.chat/ui-video-conf@4.0.0-rc.6 + - @rocket.chat/web-ui-registration@4.0.0-rc.6 + - @rocket.chat/instance-status@0.0.28-rc.6 +
+ +## 6.6.0-rc.5 + +### Patch Changes + +- Bump @rocket.chat/meteor version. + +- ([#31594](https://github.com/RocketChat/Rocket.Chat/pull/31594)) fix: missing slashcommand permissions for archive and unarchive + +-
Updated dependencies []: + + - @rocket.chat/core-typings@6.6.0-rc.5 + - @rocket.chat/rest-typings@6.6.0-rc.5 + - @rocket.chat/api-client@0.1.22-rc.5 + - @rocket.chat/license@0.1.4-rc.5 + - @rocket.chat/omnichannel-services@0.1.4-rc.5 + - @rocket.chat/pdf-worker@0.0.28-rc.5 + - @rocket.chat/presence@0.1.4-rc.5 + - @rocket.chat/core-services@0.3.4-rc.5 + - @rocket.chat/cron@0.0.24-rc.5 + - @rocket.chat/gazzodown@4.0.0-rc.5 + - @rocket.chat/model-typings@0.3.0-rc.5 + - @rocket.chat/ui-contexts@4.0.0-rc.5 + - @rocket.chat/server-cloud-communication@0.0.2-rc.0 + - @rocket.chat/fuselage-ui-kit@4.0.0-rc.5 + - @rocket.chat/models@0.0.28-rc.5 + - @rocket.chat/ui-theming@0.1.2-rc.0 + - @rocket.chat/ui-client@4.0.0-rc.5 + - @rocket.chat/ui-video-conf@4.0.0-rc.5 + - @rocket.chat/web-ui-registration@4.0.0-rc.5 + - @rocket.chat/instance-status@0.0.28-rc.5 +
+ +## 6.6.0-rc.4 + +### Patch Changes + +- Bump @rocket.chat/meteor version. + - @rocket.chat/core-typings@6.6.0-rc.4 + - @rocket.chat/rest-typings@6.6.0-rc.4 + - @rocket.chat/api-client@0.1.22-rc.4 + - @rocket.chat/license@0.1.4-rc.4 + - @rocket.chat/omnichannel-services@0.1.4-rc.4 + - @rocket.chat/pdf-worker@0.0.28-rc.4 + - @rocket.chat/presence@0.1.4-rc.4 + - @rocket.chat/core-services@0.3.4-rc.4 + - @rocket.chat/cron@0.0.24-rc.4 + - @rocket.chat/gazzodown@4.0.0-rc.4 + - @rocket.chat/model-typings@0.3.0-rc.4 + - @rocket.chat/ui-contexts@4.0.0-rc.4 + - @rocket.chat/server-cloud-communication@0.0.2-rc.0 + - @rocket.chat/fuselage-ui-kit@4.0.0-rc.4 + - @rocket.chat/models@0.0.28-rc.4 + - @rocket.chat/ui-theming@0.1.2-rc.0 + - @rocket.chat/ui-client@4.0.0-rc.4 + - @rocket.chat/ui-video-conf@4.0.0-rc.4 + - @rocket.chat/web-ui-registration@4.0.0-rc.4 + - @rocket.chat/instance-status@0.0.28-rc.4 + +## 6.6.0-rc.3 + +### Patch Changes + +- Bump @rocket.chat/meteor version. + - @rocket.chat/core-typings@6.6.0-rc.3 + - @rocket.chat/rest-typings@6.6.0-rc.3 + - @rocket.chat/api-client@0.1.22-rc.3 + - @rocket.chat/license@0.1.4-rc.3 + - @rocket.chat/omnichannel-services@0.1.4-rc.3 + - @rocket.chat/pdf-worker@0.0.28-rc.3 + - @rocket.chat/presence@0.1.4-rc.3 + - @rocket.chat/core-services@0.3.4-rc.3 + - @rocket.chat/cron@0.0.24-rc.3 + - @rocket.chat/gazzodown@4.0.0-rc.3 + - @rocket.chat/model-typings@0.3.0-rc.3 + - @rocket.chat/ui-contexts@4.0.0-rc.3 + - @rocket.chat/server-cloud-communication@0.0.2-rc.0 + - @rocket.chat/fuselage-ui-kit@4.0.0-rc.3 + - @rocket.chat/models@0.0.28-rc.3 + - @rocket.chat/ui-theming@0.1.2-rc.0 + - @rocket.chat/ui-client@4.0.0-rc.3 + - @rocket.chat/ui-video-conf@4.0.0-rc.3 + - @rocket.chat/web-ui-registration@4.0.0-rc.3 + - @rocket.chat/instance-status@0.0.28-rc.3 + +## 6.6.0-rc.2 + +### Patch Changes + +- Bump @rocket.chat/meteor version. + - @rocket.chat/core-typings@6.6.0-rc.2 + - @rocket.chat/rest-typings@6.6.0-rc.2 + - @rocket.chat/api-client@0.1.22-rc.2 + - @rocket.chat/license@0.1.4-rc.2 + - @rocket.chat/omnichannel-services@0.1.4-rc.2 + - @rocket.chat/pdf-worker@0.0.28-rc.2 + - @rocket.chat/presence@0.1.4-rc.2 + - @rocket.chat/core-services@0.3.4-rc.2 + - @rocket.chat/cron@0.0.24-rc.2 + - @rocket.chat/gazzodown@4.0.0-rc.2 + - @rocket.chat/model-typings@0.3.0-rc.2 + - @rocket.chat/ui-contexts@4.0.0-rc.2 + - @rocket.chat/server-cloud-communication@0.0.2-rc.0 + - @rocket.chat/fuselage-ui-kit@4.0.0-rc.2 + - @rocket.chat/models@0.0.28-rc.2 + - @rocket.chat/ui-theming@0.1.2-rc.0 + - @rocket.chat/ui-client@4.0.0-rc.2 + - @rocket.chat/ui-video-conf@4.0.0-rc.2 + - @rocket.chat/web-ui-registration@4.0.0-rc.2 + - @rocket.chat/instance-status@0.0.28-rc.2 + +## 6.6.0-rc.1 + +### Patch Changes + +- Bump @rocket.chat/meteor version. + - @rocket.chat/core-typings@6.6.0-rc.1 + - @rocket.chat/rest-typings@6.6.0-rc.1 + - @rocket.chat/api-client@0.1.22-rc.1 + - @rocket.chat/license@0.1.4-rc.1 + - @rocket.chat/omnichannel-services@0.1.4-rc.1 + - @rocket.chat/pdf-worker@0.0.28-rc.1 + - @rocket.chat/presence@0.1.4-rc.1 + - @rocket.chat/core-services@0.3.4-rc.1 + - @rocket.chat/cron@0.0.24-rc.1 + - @rocket.chat/gazzodown@4.0.0-rc.1 + - @rocket.chat/model-typings@0.3.0-rc.1 + - @rocket.chat/ui-contexts@4.0.0-rc.1 + - @rocket.chat/server-cloud-communication@0.0.2-rc.0 + - @rocket.chat/fuselage-ui-kit@4.0.0-rc.1 + - @rocket.chat/models@0.0.28-rc.1 + - @rocket.chat/ui-theming@0.1.2-rc.0 + - @rocket.chat/ui-client@4.0.0-rc.1 + - @rocket.chat/ui-video-conf@4.0.0-rc.1 + - @rocket.chat/web-ui-registration@4.0.0-rc.1 + - @rocket.chat/instance-status@0.0.28-rc.1 + +## 6.6.0-rc.0 + +### Minor Changes + +- b2b0035162: Add the possibility to hide some elements through postMessage events. +- 9cb97965ba: Room header keyboard navigability + + ![Kapture 2024-01-22 at 11 33 14](https://github.com/RocketChat/Rocket.Chat/assets/27704687/f116c1e6-4ec7-4175-a01b-fa98eade2416) + +- 748e57984d: Added `push.info` endpoint to enable users to retrieve info about the workspace's push gateway +- 4c2771fd0c: Composer keyboard navigability + + ![Kapture 2024-01-22 at 11 33 14](https://github.com/RocketChat/Rocket.Chat/assets/27704687/f116c1e6-4ec7-4175-a01b-fa98eade2416) + +- 44dd24da73: Mentioning users that are not in the channel now dispatches a warning message with actions +- 8c69edd01f: feat: add `ImageGallery` zoom controls +- d6165ad77f: Fixes an issue where avatars are not being disabled based on preference on quote attachments +- dd5fd6d2c8: feat: Skip to main content shortcut and useDocumentTitle +- caa7707bba: fix: Loading state for `Marketplace` related lists +- e1fa2b84fb: New feature to support cancel message editing message and hints for shortcuts. +- 2260c04ec6: **Added ‘Reported Users’ Tab to Moderation Console:** Enhances user monitoring by displaying reported users. +- e7d3cdeef0: Added feature to sync the user's language preference with the autotranslate setting. +- 0ed84cb3b9: feat: `Bubble` on new messages indicators + image +- 47331bacc3: Added a modal to confirm the intention to pin a message, preventing users from doing it by mistake + +### Patch Changes + +- 87bba6d039: Fixed Attachments not respecting collapse property when using incoming webhook +- b223cbde14: feat(uikit): Move `@rocket.chat/ui-kit` package to the main monorepo +- 319f05ec79: Fix user being logged out after using 2FA +- 371698ef5a: Improved support for higlighted words in threads (rooms are now marked as unread and notifications are sent) +- 5c145e3170: Clear message box related items from local storage on logout +- dbb08ef948: feat: Implemented InlineCode handling in Bold, Italic and Strike +- fae558bd5d: Fixed issue with notifications for thread messages still being sent after thread has been read +- 1345ce4bf3: fix: OAuth login by redirect failing on firefox +- 631f6a4fa6: Fixed an issue displaying the language selection preference empty when it should display 'Default' on the initial value +- f4664f00a0: Fixed an issue that caused Omnichannel Business Units to be saved even when the "monitors" list passed on the endpoint included users without monitor role +- 7a187dcbaa: Fixed image dropping from another browser window creates two upload dialogs in some OS and browsers +- 36d793a375: Fixed a bug where some sessions were being saved without a sessionId +- a202542140: Fixed issue with read receipts for older messages not being created on the first time a user reads a DM +- 2f8c98f7a8: Fixes an issue where texts are not being displayed in the correct direction on messages +- 1ccfc5d1a0: Fixed a problem with the Fallback Forward Department functionality when transferring rooms, caused by a missing return. This provoked the system to transfer to fallback department, as expected, but then continue the process and transfer to the department with no agents anyways. Also, a duplicated "user joined" message was removed from "Forward to department" functionality. +- 62bd2788dd: fixed UI crashing for users reading a room when it's deleted. +- 92ee9fa284: fix: Discussion messages deleted despite the "Do not delete discussion messages" retention policy enabled +- f126ecd56a: fix: Resolved Search List Issue when pressing ENTER +- 8aa1ac2d34: Fixed an issue not allowing users to remove the password to join the room on room edit +- c5693fb8c8: fix: multiple indexes creation error during 304 migration +- 37086095cf: Fixed values discrepancy with downloaded report from Active users at Engagement Dashboard +- f340139d87: Fixed an `UnhandledPromiseRejection` error on `PUT livechat/departments/:_id` endpoint when `agents` array failed validation +- 78c3dc3d6a: fix: Off the record feature was calling a deprecated and useless method. +- 9217c4fcf7: notification emails should now show emojis properly +- eee67dc412: Fixed toolbox sub-menu not being displayed when in smaller resolutions +- 0d04eb9691: Apply plural translations at a few places. +- da410efa10: Show marketplace apps installed as private in the right place (private tab) +- 7c6198f49f: Added `push.test` POST endpoint for sending test push notification to user (requires `test-push-notifications` permission) +- 9ef1442e07: Fixed error message when uploading a file that is not allowed +- 9a6e9b4e28: Fixed an issue that caused login buttons to not be reactively removed from the login page when the related authentication service was disabled by an admin. +- 6000b63a91: fix: Visitor message not being sent to webhook due to wrong validation of settings +- faf4121927: Fixed a problem that caused the wrong system message to be sent when a chat was resumed from on hold status. + Note: This fix is not retroactive so rooms where a wrong message was already sent will still show the wrong message. New calls to the resume actions will have the proper message. +- fdd9852079: Added `chat.getURLPreview` endpoint to enable users to retrieve previews for URL (ready to be provided in message send/update) +- 54bdda3743: Fixed SHIFT+ESCAPE inconsistency for clearing unread messages across browsers. +- 4e138ea5b2: Fixed an issue where the webclient didn't properly clear the message caches from memory when a room is deleted. When this happened to basic DMs and the user started a new DM with the same target user, the client would show the old messages in the room history even though they no longer existed in the server. +- 43335c8385: fixed an issue when editing a channel's type or name sometimes showing "Room not found" error. +- 83bcf04664: Fixed issue searching connected users on spotlight +- 18a9d658b2: Fixed Engagement Dashboard timezone selector freezing UI +- c8ab6583dc: Fixed issue with OEmbed cache not being cleared daily +- 9310e8495d: Fixed using real names on messages reactions +- d71876ccc8: fix: mention channel redirecting to own DM +- e3252f5448: fix: quote image gallery +- 4212491b71: Fixes the `overview` endpoint to show busiest time of the day in users timezone instead of UTC. +- 334a723e5b: Improved the experience of receiving conference calls on the mobile app by disabling the push notification for the "new call" message if a push is already being sent to trigger the phone's ringing tone. +- c8ec364733: Fix multi-instance data formats being lost +- 9c59a87c45: Fixed an issue where room access and creation were hindered due to join codes not being fetched correctly in the API. +- 75d235ada7: Added missing labels to "Users by time of the day" card at Engagement Dashboard page +- 74cad8411a: Fixed the problem of not being possible to add a join code to a public room +- 132853aa96: Fixed the problem of displaying the wrong composer for archived room +- b6b719856f: Removed an old behavior that allowed visitors to be created with an empty token on `livechat/visitor` endpoint. +- 4a9d37cba0: Fixed conversations in queue being limited to 50 items +- b4b2cd20a8: Fixed an issue caused by the `Fallback Forward Department` feature. Feature could be configured by admins in a way that mimis a loop, causing a chat to be forwarded "infinitely" between those departments. System will now prevent Self & 1-level deep circular references from being saved, and a new setting is added to control the maximum number of hops that the system will do between fallback departments before considering a transfer failure. +- 61a655fc5d: Fix an issue that breaks the avatar if you hit the button adding an invalid link +- 097f64b36f: Fixed the filter for file type in the list of room files +- afd5fdd521: Fixed LDAP "Group filter" malfunction, which prevented LDAP users from logging in. +- 224e194089: Fixed issue with user presence displayed as offline on SAML login +- 1499e89500: Fixed Atlassian Crowd integration with Rocket.Chat not working +- 1726b50132: fix: change the push sound sent when the push is from video conference +- Updated dependencies [b223cbde14] +- Updated dependencies [b2b0035162] +- Updated dependencies [9cb97965ba] +- Updated dependencies [dbb08ef948] +- Updated dependencies [fae558bd5d] +- Updated dependencies [748e57984d] +- Updated dependencies [4c2771fd0c] +- Updated dependencies [dd5fd6d2c8] +- Updated dependencies [7c6198f49f] +- Updated dependencies [9a6e9b4e28] +- Updated dependencies [fdd9852079] +- Updated dependencies [e1fa2b84fb] +- Updated dependencies [2260c04ec6] +- Updated dependencies [c8ab6583dc] +- Updated dependencies [e7d3cdeef0] +- Updated dependencies [b4b2cd20a8] +- Updated dependencies [db2551906c] + - @rocket.chat/ui-kit@0.33.0-rc.0 + - @rocket.chat/server-cloud-communication@0.0.2-rc.0 + - @rocket.chat/omnichannel-services@0.1.4-rc.0 + - @rocket.chat/web-ui-registration@4.0.0-rc.0 + - @rocket.chat/password-policies@0.0.2-rc.0 + - @rocket.chat/fuselage-ui-kit@4.0.0-rc.0 + - @rocket.chat/instance-status@0.0.28-rc.0 + - @rocket.chat/api-client@0.1.22-rc.0 + - @rocket.chat/pdf-worker@0.0.28-rc.0 + - @rocket.chat/ui-theming@0.1.2-rc.0 + - @rocket.chat/account-utils@0.0.2-rc.0 + - @rocket.chat/core-services@0.3.4-rc.0 + - @rocket.chat/model-typings@0.3.0-rc.0 + - @rocket.chat/ui-video-conf@4.0.0-rc.0 + - @rocket.chat/cas-validate@0.0.2-rc.0 + - @rocket.chat/core-typings@6.6.0-rc.0 + - @rocket.chat/rest-typings@6.6.0-rc.0 + - @rocket.chat/server-fetch@0.0.3-rc.0 + - @rocket.chat/presence@0.1.4-rc.0 + - @rocket.chat/poplib@0.0.2-rc.0 + - @rocket.chat/ui-composer@0.1.0-rc.0 + - @rocket.chat/ui-contexts@4.0.0-rc.0 + - @rocket.chat/license@0.1.4-rc.0 + - @rocket.chat/log-format@0.0.2-rc.0 + - @rocket.chat/gazzodown@4.0.0-rc.0 + - @rocket.chat/ui-client@4.0.0-rc.0 + - @rocket.chat/favicon@0.0.2-rc.0 + - @rocket.chat/agenda@0.1.0-rc.0 + - @rocket.chat/base64@1.0.13-rc.0 + - @rocket.chat/logger@0.0.2-rc.0 + - @rocket.chat/models@0.0.28-rc.0 + - @rocket.chat/random@1.2.2-rc.0 + - @rocket.chat/sha256@1.0.10-rc.0 + - @rocket.chat/tools@0.2.1-rc.0 + - @rocket.chat/cron@0.0.24-rc.0 + - @rocket.chat/i18n@0.1.0-rc.0 + - @rocket.chat/jwt@0.1.1-rc.0 + +## 6.5.3 + +### Patch Changes + +- b1e72a84d9: Fix user being logged out after using 2FA +- de2658e874: Bump @rocket.chat/meteor version. +- Bump @rocket.chat/meteor version. +- 2a04cc850b: fix: multiple indexes creation error during 304 migration + - @rocket.chat/core-typings@6.5.3 + - @rocket.chat/rest-typings@6.5.3 + - @rocket.chat/api-client@0.1.21 + - @rocket.chat/license@0.1.3 + - @rocket.chat/omnichannel-services@0.1.3 + - @rocket.chat/pdf-worker@0.0.27 + - @rocket.chat/presence@0.1.3 + - @rocket.chat/core-services@0.3.3 + - @rocket.chat/cron@0.0.23 + - @rocket.chat/gazzodown@3.0.3 + - @rocket.chat/model-typings@0.2.3 + - @rocket.chat/ui-contexts@3.0.3 + - @rocket.chat/server-cloud-communication@0.0.1 + - @rocket.chat/fuselage-ui-kit@3.0.3 + - @rocket.chat/models@0.0.27 + - @rocket.chat/ui-theming@0.1.1 + - @rocket.chat/ui-client@3.0.3 + - @rocket.chat/ui-video-conf@3.0.3 + - @rocket.chat/web-ui-registration@3.0.3 + - @rocket.chat/instance-status@0.0.27 + +## 6.5.2 + +### Patch Changes + +- a075950e23: Bump @rocket.chat/meteor version. +- Bump @rocket.chat/meteor version. +- 84c4b0709e: Fixed conversations in queue being limited to 50 items +- 886d92009e: Fix wrong value used for Workspace Registration + - @rocket.chat/core-typings@6.5.2 + - @rocket.chat/rest-typings@6.5.2 + - @rocket.chat/api-client@0.1.20 + - @rocket.chat/license@0.1.2 + - @rocket.chat/omnichannel-services@0.1.2 + - @rocket.chat/pdf-worker@0.0.26 + - @rocket.chat/presence@0.1.2 + - @rocket.chat/core-services@0.3.2 + - @rocket.chat/cron@0.0.22 + - @rocket.chat/gazzodown@3.0.2 + - @rocket.chat/model-typings@0.2.2 + - @rocket.chat/ui-contexts@3.0.2 + - @rocket.chat/server-cloud-communication@0.0.1 + - @rocket.chat/fuselage-ui-kit@3.0.2 + - @rocket.chat/models@0.0.26 + - @rocket.chat/ui-theming@0.1.1 + - @rocket.chat/ui-client@3.0.2 + - @rocket.chat/ui-video-conf@3.0.2 + - @rocket.chat/web-ui-registration@3.0.2 + - @rocket.chat/instance-status@0.0.26 + +## 6.5.1 + +### Patch Changes + +- c2b224fd82: Bump @rocket.chat/meteor version. +- Bump @rocket.chat/meteor version. +- c2b224fd82: Security improvements +- c2b224fd82: Fixed issue with the new `custom-roles` license module not being checked throughout the application +- c2b224fd82: fix: stop refetching banner data each 5 minutes +- c2b224fd82: Fixed an issue allowing admin user cancelling subscription when license's trial param is provided +- c2b224fd82: Fixed Country select component at Organization form from `onboarding-ui` package +- c2b224fd82: fix Federation Regression, builds service correctly +- c2b224fd82: fix: Wrong `Message Roundtrip Time` metric + + Removes the wrong metric gauge named `rocketchat_messages_roundtrip_time` and replace it by a new summary metric named `rocketchat_messages_roundtrip_time_summary`. Add new percentiles `0.5, 0.95 and 1` to all summary metrics. + +- c2b224fd82: Exceeding API calls when sending OTR messages +- c2b224fd82: Fixed a problem with the subscription creation on Omnichannel rooms. + Rooms were being created as seen, causing sound notifications to not work +- c2b224fd82: Fixed a problem where chained callbacks' return value was being overrided by some callbacks returning something different, causing callbacks with lower priority to operate on invalid values +- c2b224fd82: Fix desktop notification routing for direct rooms +- c2b224fd82: Improved the experience of receiving conference calls on the mobile app by disabling the push notification for the "new call" message if a push is already being sent to trigger the phone's ringing tone. +- c2b224fd82: Fixed verify the account through email link +- c2b224fd82: Fixed the filter for file type in the list of room files +- Updated dependencies [c2b224fd82] +- Updated dependencies [c2b224fd82] + - @rocket.chat/rest-typings@6.5.1 + - @rocket.chat/core-typings@6.5.1 + - @rocket.chat/api-client@0.1.19 + - @rocket.chat/omnichannel-services@0.1.1 + - @rocket.chat/presence@0.1.1 + - @rocket.chat/core-services@0.3.1 + - @rocket.chat/ui-contexts@3.0.1 + - @rocket.chat/license@0.1.1 + - @rocket.chat/pdf-worker@0.0.25 + - @rocket.chat/cron@0.0.21 + - @rocket.chat/gazzodown@3.0.1 + - @rocket.chat/model-typings@0.2.1 + - @rocket.chat/ui-theming@0.1.1 + - @rocket.chat/fuselage-ui-kit@3.0.1 + - @rocket.chat/ui-client@3.0.1 + - @rocket.chat/ui-video-conf@3.0.1 + - @rocket.chat/web-ui-registration@3.0.1 + - @rocket.chat/server-cloud-communication@0.0.1 + - @rocket.chat/models@0.0.25 + - @rocket.chat/instance-status@0.0.25 + ## 6.5.0 ### Minor Changes diff --git a/apps/meteor/app/2fa/client/TOTPCrowd.js b/apps/meteor/app/2fa/client/TOTPCrowd.js deleted file mode 100644 index 6b4e55a8521147dac963f1101b05c7bc2f2ac602..0000000000000000000000000000000000000000 --- a/apps/meteor/app/2fa/client/TOTPCrowd.js +++ /dev/null @@ -1,38 +0,0 @@ -import { Accounts } from 'meteor/accounts-base'; -import { Meteor } from 'meteor/meteor'; - -import '../../crowd/client/index'; -import { overrideLoginMethod } from '../../../client/lib/2fa/overrideLoginMethod'; -import { reportError } from '../../../client/lib/2fa/utils'; - -Meteor.loginWithCrowdAndTOTP = function (username, password, code, callback) { - const loginRequest = { - crowd: true, - username, - crowdPassword: password, - }; - - Accounts.callLoginMethod({ - methodArguments: [ - { - totp: { - login: loginRequest, - code, - }, - }, - ], - userCallback(error) { - if (error) { - reportError(error, callback); - } else { - callback && callback(); - } - }, - }); -}; - -const { loginWithCrowd } = Meteor; - -Meteor.loginWithCrowd = function (username, password, callback) { - overrideLoginMethod(loginWithCrowd, [username, password], callback, Meteor.loginWithCrowdAndTOTP); -}; diff --git a/apps/meteor/app/2fa/client/TOTPGoogle.js b/apps/meteor/app/2fa/client/TOTPGoogle.js deleted file mode 100644 index bb1e509a46d76af8f7d3c3a352294163382b5aa5..0000000000000000000000000000000000000000 --- a/apps/meteor/app/2fa/client/TOTPGoogle.js +++ /dev/null @@ -1,39 +0,0 @@ -import { Accounts } from 'meteor/accounts-base'; -import { Google } from 'meteor/google-oauth'; -import { Meteor } from 'meteor/meteor'; - -import { overrideLoginMethod } from '../../../client/lib/2fa/overrideLoginMethod'; - -const loginWithGoogleAndTOTP = function (options, code, callback) { - // support a callback without options - if (!callback && typeof options === 'function') { - callback = options; - options = null; - } - - if (Meteor.isCordova && Google.signIn) { - // After 20 April 2017, Google OAuth login will no longer work from - // a WebView, so Cordova apps must use Google Sign-In instead. - // https://github.com/meteor/meteor/issues/8253 - Google.signIn(options, callback); - return; - } // Use Google's domain-specific login page if we want to restrict creation to - // a particular email domain. (Don't use it if restrictCreationByEmailDomain - // is a function.) Note that all this does is change Google's UI --- - // accounts-base/accounts_server.js still checks server-side that the server - // has the proper email address after the OAuth conversation. - - if (typeof Accounts._options.restrictCreationByEmailDomain === 'string') { - options = Object.assign({}, options || {}); - options.loginUrlParameters = Object.assign({}, options.loginUrlParameters || {}); - options.loginUrlParameters.hd = Accounts._options.restrictCreationByEmailDomain; - } - - const credentialRequestCompleteCallback = Accounts.oauth.credentialRequestCompleteHandler(callback, code); - Google.requestCredential(options, credentialRequestCompleteCallback); -}; - -const { loginWithGoogle } = Meteor; -Meteor.loginWithGoogle = function (options, cb) { - overrideLoginMethod(loginWithGoogle, [options], cb, loginWithGoogleAndTOTP); -}; diff --git a/apps/meteor/app/2fa/client/TOTPLDAP.js b/apps/meteor/app/2fa/client/TOTPLDAP.js deleted file mode 100644 index f3b833d04a7202d535198706cd6581e25b4624ab..0000000000000000000000000000000000000000 --- a/apps/meteor/app/2fa/client/TOTPLDAP.js +++ /dev/null @@ -1,54 +0,0 @@ -import { Accounts } from 'meteor/accounts-base'; -import { Meteor } from 'meteor/meteor'; - -import '../../../client/startup/ldap'; -import { overrideLoginMethod } from '../../../client/lib/2fa/overrideLoginMethod'; -import { reportError } from '../../../client/lib/2fa/utils'; - -Meteor.loginWithLDAPAndTOTP = function (...args) { - // Pull username and password - const username = args.shift(); - const ldapPass = args.shift(); - - // Check if last argument is a function. if it is, pop it off and set callback to it - const callback = typeof args[args.length - 1] === 'function' ? args.pop() : null; - // The last argument before the callback is the totp code - const code = args.pop(); - - // if args still holds options item, grab it - const ldapOptions = args.length > 0 ? args.shift() : {}; - - // Set up loginRequest object - const loginRequest = { - ldap: true, - username, - ldapPass, - ldapOptions, - }; - - Accounts.callLoginMethod({ - methodArguments: [ - { - totp: { - login: loginRequest, - code, - }, - }, - ], - userCallback(error) { - if (error) { - reportError(error, callback); - } else { - callback && callback(); - } - }, - }); -}; - -const { loginWithLDAP } = Meteor; - -Meteor.loginWithLDAP = function (...args) { - const callback = typeof args[args.length - 1] === 'function' ? args.pop() : null; - - overrideLoginMethod(loginWithLDAP, args, callback, Meteor.loginWithLDAPAndTOTP, args[0]); -}; diff --git a/apps/meteor/app/2fa/client/TOTPOAuth.js b/apps/meteor/app/2fa/client/TOTPOAuth.js deleted file mode 100644 index 47c5e70998b697b30cb4184a5a407979a8354e8c..0000000000000000000000000000000000000000 --- a/apps/meteor/app/2fa/client/TOTPOAuth.js +++ /dev/null @@ -1,142 +0,0 @@ -import { capitalize } from '@rocket.chat/string-helpers'; -import { Accounts } from 'meteor/accounts-base'; -import { Facebook } from 'meteor/facebook-oauth'; -import { Github } from 'meteor/github-oauth'; -import { Meteor } from 'meteor/meteor'; -import { MeteorDeveloperAccounts } from 'meteor/meteor-developer-oauth'; -import { OAuth } from 'meteor/oauth'; -import { Linkedin } from 'meteor/pauli:linkedin-oauth'; -import { Twitter } from 'meteor/twitter-oauth'; - -import { overrideLoginMethod } from '../../../client/lib/2fa/overrideLoginMethod'; -import { process2faReturn } from '../../../client/lib/2fa/process2faReturn'; -import { convertError } from '../../../client/lib/2fa/utils'; -import { CustomOAuth } from '../../custom-oauth/client/custom_oauth_client'; - -let lastCredentialToken = null; -let lastCredentialSecret = null; - -Accounts.oauth.tryLoginAfterPopupClosed = function (credentialToken, callback, totpCode, credentialSecret = null) { - credentialSecret = credentialSecret || OAuth._retrieveCredentialSecret(credentialToken) || null; - const methodArgument = { - oauth: { - credentialToken, - credentialSecret, - }, - }; - - lastCredentialToken = credentialToken; - lastCredentialSecret = credentialSecret; - - if (totpCode && typeof totpCode === 'string') { - methodArgument.totp = { - code: totpCode, - }; - } - - Accounts.callLoginMethod({ - methodArguments: [methodArgument], - userCallback: - callback && - function (err) { - callback(convertError(err)); - }, - }); -}; - -Accounts.oauth.credentialRequestCompleteHandler = function (callback, totpCode) { - return function (credentialTokenOrError) { - if (credentialTokenOrError && credentialTokenOrError instanceof Error) { - callback && callback(credentialTokenOrError); - } else { - Accounts.oauth.tryLoginAfterPopupClosed(credentialTokenOrError, callback, totpCode); - } - }; -}; - -const createOAuthTotpLoginMethod = (credentialProvider) => (options, code, callback) => { - // support a callback without options - if (!callback && typeof options === 'function') { - callback = options; - options = null; - } - - if (lastCredentialToken && lastCredentialSecret) { - Accounts.oauth.tryLoginAfterPopupClosed(lastCredentialToken, callback, code, lastCredentialSecret); - } else { - const provider = (credentialProvider && credentialProvider()) || this; - const credentialRequestCompleteCallback = Accounts.oauth.credentialRequestCompleteHandler(callback, code); - provider.requestCredential(options, credentialRequestCompleteCallback); - } - - lastCredentialToken = null; - lastCredentialSecret = null; -}; - -const loginWithOAuthTokenAndTOTP = createOAuthTotpLoginMethod(); - -const loginWithFacebookAndTOTP = createOAuthTotpLoginMethod(() => Facebook); -const { loginWithFacebook } = Meteor; -Meteor.loginWithFacebook = function (options, cb) { - overrideLoginMethod(loginWithFacebook, [options], cb, loginWithFacebookAndTOTP); -}; - -const loginWithGithubAndTOTP = createOAuthTotpLoginMethod(() => Github); -const { loginWithGithub } = Meteor; -Meteor.loginWithGithub = function (options, cb) { - overrideLoginMethod(loginWithGithub, [options], cb, loginWithGithubAndTOTP); -}; - -const loginWithMeteorDeveloperAccountAndTOTP = createOAuthTotpLoginMethod(() => MeteorDeveloperAccounts); -const { loginWithMeteorDeveloperAccount } = Meteor; -Meteor.loginWithMeteorDeveloperAccount = function (options, cb) { - overrideLoginMethod(loginWithMeteorDeveloperAccount, [options], cb, loginWithMeteorDeveloperAccountAndTOTP); -}; - -const loginWithTwitterAndTOTP = createOAuthTotpLoginMethod(() => Twitter); -const { loginWithTwitter } = Meteor; -Meteor.loginWithTwitter = function (options, cb) { - overrideLoginMethod(loginWithTwitter, [options], cb, loginWithTwitterAndTOTP); -}; - -const loginWithLinkedinAndTOTP = createOAuthTotpLoginMethod(() => Linkedin); -const { loginWithLinkedin } = Meteor; -Meteor.loginWithLinkedin = function (options, cb) { - overrideLoginMethod(loginWithLinkedin, [options], cb, loginWithLinkedinAndTOTP); -}; - -Accounts.onPageLoadLogin(async (loginAttempt) => { - if (loginAttempt?.error?.error !== 'totp-required') { - return; - } - - const { methodArguments } = loginAttempt; - if (!methodArguments?.length) { - return; - } - - const oAuthArgs = methodArguments.find((arg) => arg.oauth); - const { credentialToken, credentialSecret } = oAuthArgs.oauth; - const cb = loginAttempt.userCallback; - - await process2faReturn({ - error: loginAttempt.error, - originalCallback: cb, - onCode: (code) => { - Accounts.oauth.tryLoginAfterPopupClosed(credentialToken, cb, code, credentialSecret); - }, - }); -}); - -const oldConfigureLogin = CustomOAuth.prototype.configureLogin; -CustomOAuth.prototype.configureLogin = function (...args) { - const loginWithService = `loginWith${capitalize(String(this.name || ''))}`; - - oldConfigureLogin.apply(this, args); - - const oldMethod = Meteor[loginWithService]; - - Meteor[loginWithService] = function (options, cb) { - overrideLoginMethod(oldMethod, [options], cb, loginWithOAuthTokenAndTOTP); - }; -}; diff --git a/apps/meteor/app/2fa/client/TOTPPassword.js b/apps/meteor/app/2fa/client/TOTPPassword.js deleted file mode 100644 index 3fccb646bad110992bd403a3382aa526e23af918..0000000000000000000000000000000000000000 --- a/apps/meteor/app/2fa/client/TOTPPassword.js +++ /dev/null @@ -1,64 +0,0 @@ -import { Accounts } from 'meteor/accounts-base'; -import { Meteor } from 'meteor/meteor'; - -import { process2faReturn } from '../../../client/lib/2fa/process2faReturn'; -import { isTotpInvalidError, reportError } from '../../../client/lib/2fa/utils'; -import { dispatchToastMessage } from '../../../client/lib/toast'; -import { t } from '../../utils/lib/i18n'; - -Meteor.loginWithPasswordAndTOTP = function (selector, password, code, callback) { - if (typeof selector === 'string') { - if (selector.indexOf('@') === -1) { - selector = { username: selector }; - } else { - selector = { email: selector }; - } - } - - Accounts.callLoginMethod({ - methodArguments: [ - { - totp: { - login: { - user: selector, - password: Accounts._hashPassword(password), - }, - code, - }, - }, - ], - userCallback(error) { - if (error) { - reportError(error, callback); - } else { - callback && callback(); - } - }, - }); -}; - -const { loginWithPassword } = Meteor; - -Meteor.loginWithPassword = function (email, password, cb) { - loginWithPassword(email, password, async (error) => { - await process2faReturn({ - error, - originalCallback: cb, - emailOrUsername: email, - onCode: (code) => { - Meteor.loginWithPasswordAndTOTP(email, password, code, (error) => { - if (isTotpInvalidError(error)) { - dispatchToastMessage({ - type: 'error', - message: t('Invalid_two_factor_code'), - }); - cb(); - return; - } - - cb(error); - }); - }, - }); - }); -}; diff --git a/apps/meteor/app/2fa/client/TOTPSaml.js b/apps/meteor/app/2fa/client/TOTPSaml.js deleted file mode 100644 index 7d9ec34541dfb27ed6c6e30ec4b5e5dde97f11aa..0000000000000000000000000000000000000000 --- a/apps/meteor/app/2fa/client/TOTPSaml.js +++ /dev/null @@ -1,35 +0,0 @@ -import { Accounts } from 'meteor/accounts-base'; -import { Meteor } from 'meteor/meteor'; - -import '../../meteor-accounts-saml/client/saml_client'; -import { overrideLoginMethod } from '../../../client/lib/2fa/overrideLoginMethod'; -import { reportError } from '../../../client/lib/2fa/utils'; - -Meteor.loginWithSamlTokenAndTOTP = function (credentialToken, code, callback) { - Accounts.callLoginMethod({ - methodArguments: [ - { - totp: { - login: { - saml: true, - credentialToken, - }, - code, - }, - }, - ], - userCallback(error) { - if (error) { - reportError(error, callback); - } else { - callback && callback(); - } - }, - }); -}; - -const { loginWithSamlToken } = Meteor; - -Meteor.loginWithSamlToken = function (options, callback) { - overrideLoginMethod(loginWithSamlToken, [options], callback, Meteor.loginWithSamlTokenAndTOTP); -}; diff --git a/apps/meteor/app/2fa/client/index.ts b/apps/meteor/app/2fa/client/index.ts deleted file mode 100644 index 1e8f20eb784cbd6a079c47f66c37f685792a859e..0000000000000000000000000000000000000000 --- a/apps/meteor/app/2fa/client/index.ts +++ /dev/null @@ -1,7 +0,0 @@ -import './TOTPPassword'; -import './TOTPOAuth'; -import './TOTPGoogle'; -import './TOTPSaml'; -import './TOTPLDAP'; -import './TOTPCrowd'; -import './overrideMeteorCall'; diff --git a/apps/meteor/app/2fa/client/overrideMeteorCall.ts b/apps/meteor/app/2fa/client/overrideMeteorCall.ts deleted file mode 100644 index e373c8a421be1ddc5f78b00adf823268bb9a00f0..0000000000000000000000000000000000000000 --- a/apps/meteor/app/2fa/client/overrideMeteorCall.ts +++ /dev/null @@ -1,53 +0,0 @@ -import { Meteor } from 'meteor/meteor'; - -import { process2faReturn, process2faAsyncReturn } from '../../../client/lib/2fa/process2faReturn'; -import { isTotpInvalidError } from '../../../client/lib/2fa/utils'; -import { t } from '../../utils/lib/i18n'; - -const { call, callAsync } = Meteor; - -type Callback = { - (error: unknown): void; - (error: unknown, result: unknown): void; -}; - -const callWithTotp = - (methodName: string, args: unknown[], callback: Callback) => - (twoFactorCode: string, twoFactorMethod: string): unknown => - call(methodName, ...args, { twoFactorCode, twoFactorMethod }, (error: unknown, result: unknown): void => { - if (isTotpInvalidError(error)) { - callback(new Error(twoFactorMethod === 'password' ? t('Invalid_password') : t('Invalid_two_factor_code'))); - return; - } - - callback(error, result); - }); - -const callWithoutTotp = (methodName: string, args: unknown[], callback: Callback) => (): unknown => - call(methodName, ...args, async (error: unknown, result: unknown): Promise => { - await process2faReturn({ - error, - result, - onCode: callWithTotp(methodName, args, callback), - originalCallback: callback, - emailOrUsername: undefined, - }); - }); - -Meteor.call = function (methodName: string, ...args: unknown[]): unknown { - const callback = args.length > 0 && typeof args[args.length - 1] === 'function' ? (args.pop() as Callback) : (): void => undefined; - - return callWithoutTotp(methodName, args, callback)(); -}; - -Meteor.callAsync = async function _callAsyncWithTotp(methodName: string, ...args: unknown[]): Promise { - try { - return await callAsync(methodName, ...args); - } catch (error: unknown) { - return process2faAsyncReturn({ - error, - onCode: (twoFactorCode, twoFactorMethod) => Meteor.callAsync(methodName, ...args, { twoFactorCode, twoFactorMethod }), - emailOrUsername: undefined, - }); - } -}; diff --git a/apps/meteor/app/2fa/server/code/EmailCheck.ts b/apps/meteor/app/2fa/server/code/EmailCheck.ts index bf2e4aa170c2c1de168c3910eca7652fe63a866a..123df96ee264c7ef09fe86b28bb3df86c2d8dbcf 100644 --- a/apps/meteor/app/2fa/server/code/EmailCheck.ts +++ b/apps/meteor/app/2fa/server/code/EmailCheck.ts @@ -69,26 +69,26 @@ ${t('If_you_didnt_try_to_login_in_your_account_please_ignore_this_email')} return false; } - if (!user.services || !Array.isArray(user.services?.emailCode)) { + if (!user.services?.emailCode) { return false; } // Remove non digits codeFromEmail = codeFromEmail.replace(/([^\d])/g, ''); - await Users.removeExpiredEmailCodesOfUserId(user._id); + const { code, expire } = user.services.emailCode; - for await (const { code, expire } of user.services.emailCode) { - if (expire < new Date()) { - continue; - } + if (expire < new Date()) { + return false; + } - if (await bcrypt.compare(codeFromEmail, code)) { - await Users.removeEmailCodeByUserIdAndCode(user._id, code); - return true; - } + if (await bcrypt.compare(codeFromEmail, code)) { + await Users.removeEmailCodeOfUserId(user._id); + return true; } + await Users.incrementInvalidEmailCodeAttempt(user._id); + return false; } @@ -109,7 +109,7 @@ ${t('If_you_didnt_try_to_login_in_your_account_please_ignore_this_email')} } public async processInvalidCode(user: IUser): Promise { - await Users.removeExpiredEmailCodesOfUserId(user._id); + await Users.removeExpiredEmailCodeOfUserId(user._id); // Generate new code if the there isn't any code with more than 5 minutes to expire const expireWithDelta = new Date(); @@ -119,13 +119,15 @@ ${t('If_you_didnt_try_to_login_in_your_account_please_ignore_this_email')} const emailOrUsername = user.username || emails[0]; - const hasValidCode = user.services?.emailCode?.filter(({ expire }) => expire > expireWithDelta); - if (hasValidCode?.length) { + const hasValidCode = + user.services?.emailCode?.expire && + user.services?.emailCode?.expire > expireWithDelta && + !(await this.maxFaildedAttemtpsReached(user)); + if (hasValidCode) { return { emailOrUsername, codeGenerated: false, - codeCount: hasValidCode.length, - codeExpires: hasValidCode.map((i) => i.expire), + codeExpires: user.services?.emailCode?.expire, }; } @@ -136,4 +138,9 @@ ${t('If_you_didnt_try_to_login_in_your_account_please_ignore_this_email')} emailOrUsername, }; } + + public async maxFaildedAttemtpsReached(user: IUser) { + const maxAttempts = settings.get('Accounts_TwoFactorAuthentication_Max_Invalid_Email_Code_Attempts'); + return (await Users.maxInvalidEmailCodeAttemptsReached(user._id, maxAttempts)) as boolean; + } } diff --git a/apps/meteor/app/2fa/server/code/ICodeCheck.ts b/apps/meteor/app/2fa/server/code/ICodeCheck.ts index f0e165735ce82d4ad463a85e27d6f9ecaf7005a1..1cd1ba68e0db9b4a4ef0db4c9bd920e13dc60a31 100644 --- a/apps/meteor/app/2fa/server/code/ICodeCheck.ts +++ b/apps/meteor/app/2fa/server/code/ICodeCheck.ts @@ -2,8 +2,7 @@ import type { IUser } from '@rocket.chat/core-typings'; export interface IProcessInvalidCodeResult { codeGenerated: boolean; - codeCount?: number; - codeExpires?: Date[]; + codeExpires?: Date; emailOrUsername?: string; } @@ -15,4 +14,6 @@ export interface ICodeCheck { verify(user: IUser, code: string, force?: boolean): Promise; processInvalidCode(user: IUser): Promise; + + maxFaildedAttemtpsReached(user: IUser): Promise; } diff --git a/apps/meteor/app/2fa/server/code/PasswordCheckFallback.ts b/apps/meteor/app/2fa/server/code/PasswordCheckFallback.ts index 10d2f01fadbb42a4969b965730796b63c79ae2e3..6c441127f79d555ac612b93ecb6f99ffb8c17e0f 100644 --- a/apps/meteor/app/2fa/server/code/PasswordCheckFallback.ts +++ b/apps/meteor/app/2fa/server/code/PasswordCheckFallback.ts @@ -41,4 +41,8 @@ export class PasswordCheckFallback implements ICodeCheck { codeGenerated: false, }; } + + public async maxFaildedAttemtpsReached(_user: IUser): Promise { + return false; + } } diff --git a/apps/meteor/app/2fa/server/code/TOTPCheck.ts b/apps/meteor/app/2fa/server/code/TOTPCheck.ts index 2ed91d7ec2aa1fa5960090a5ea4afb8c5f4bd2a6..3aa2604c796565620fc7b38eab2589b8f2d5aca1 100644 --- a/apps/meteor/app/2fa/server/code/TOTPCheck.ts +++ b/apps/meteor/app/2fa/server/code/TOTPCheck.ts @@ -38,4 +38,8 @@ export class TOTPCheck implements ICodeCheck { codeGenerated: false, }; } + + public async maxFaildedAttemtpsReached(_user: IUser): Promise { + return false; + } } diff --git a/apps/meteor/app/2fa/server/code/index.ts b/apps/meteor/app/2fa/server/code/index.ts index 250fd5b158cad75009104555a205624093811a2b..1fbe658e5682af2f07a997b08da6cd035985e9fe 100644 --- a/apps/meteor/app/2fa/server/code/index.ts +++ b/apps/meteor/app/2fa/server/code/index.ts @@ -217,6 +217,15 @@ export async function checkCodeForUser({ user, code, method, options = {}, conne const valid = await selectedMethod.verify(existingUser, code, options.requireSecondFactor); if (!valid) { + const tooManyFailedAttempts = await selectedMethod.maxFaildedAttemtpsReached(existingUser); + if (tooManyFailedAttempts) { + throw new Meteor.Error('totp-max-attempts', 'TOTP Maximun Failed Attempts Reached', { + method: selectedMethod.name, + ...data, + availableMethods, + }); + } + throw new Meteor.Error('totp-invalid', 'TOTP Invalid', { method: selectedMethod.name, ...data, diff --git a/apps/meteor/app/2fa/server/definitions/MeteorUser.d.ts b/apps/meteor/app/2fa/server/definitions/MeteorUser.d.ts new file mode 100644 index 0000000000000000000000000000000000000000..5c7d97a21230661207b1404644e90d772f6a133b --- /dev/null +++ b/apps/meteor/app/2fa/server/definitions/MeteorUser.d.ts @@ -0,0 +1,12 @@ +declare module 'meteor/meteor' { + namespace Meteor { + interface UserServices { + totp?: { + enabled: boolean; + hashedBackup: string[]; + secret: string; + tempSecret?: string; + }; + } + } +} diff --git a/apps/meteor/app/2fa/server/loginHandler.ts b/apps/meteor/app/2fa/server/loginHandler.ts index feb1923c879907a549b4351a710dd3305fa94bcc..26c9869a132fff2ab8ff781640ca3e5579f84341 100644 --- a/apps/meteor/app/2fa/server/loginHandler.ts +++ b/apps/meteor/app/2fa/server/loginHandler.ts @@ -26,19 +26,14 @@ Accounts.registerLoginHandler('totp', function (options) { callbacks.add( 'onValidateLogin', async (login) => { - if (login.methodName === 'verifyEmail') { - throw new Meteor.Error('verify-email', 'E-mail verified'); - } - - if (login.type === 'resume' || login.type === 'proxy' || (login.type === 'password' && login.methodName === 'resetPassword')) { - return login; - } - // CAS login doesn't yet support 2FA. - if (login.type === 'cas') { - return login; - } - - if (!login.user) { + if ( + !login.user || + login.type === 'resume' || + login.type === 'proxy' || + login.type === 'cas' || + (login.type === 'password' && login.methodName === 'resetPassword') || + login.methodName === 'verifyEmail' + ) { return login; } diff --git a/apps/meteor/app/2fa/server/methods/disable.ts b/apps/meteor/app/2fa/server/methods/disable.ts index 8768f86c042423303b19fdc634c72748e8400d19..0927b3f854ac2afeae492a6762675398de8a3eca 100644 --- a/apps/meteor/app/2fa/server/methods/disable.ts +++ b/apps/meteor/app/2fa/server/methods/disable.ts @@ -26,6 +26,10 @@ Meteor.methods({ }); } + if (!user.services?.totp) { + return false; + } + const verified = await TOTP.verify({ secret: user.services.totp.secret, token: code, diff --git a/apps/meteor/app/2fa/server/methods/validateTempToken.ts b/apps/meteor/app/2fa/server/methods/validateTempToken.ts index 5931d0a8e80dcbe9ad08a06227600f48d145db16..e1804930a48cc907df59d8c392f962c0c4acbfaa 100644 --- a/apps/meteor/app/2fa/server/methods/validateTempToken.ts +++ b/apps/meteor/app/2fa/server/methods/validateTempToken.ts @@ -33,12 +33,24 @@ Meteor.methods({ secret: user.services.totp.tempSecret, token: userToken, }); + if (!verified) { + throw new Meteor.Error('invalid-totp'); + } + + const { codes, hashedCodes } = TOTP.generateCodes(); - if (verified) { - const { codes, hashedCodes } = TOTP.generateCodes(); + await Users.enable2FAAndSetSecretAndCodesByUserId(userId, user.services.totp.tempSecret, hashedCodes); - await Users.enable2FAAndSetSecretAndCodesByUserId(userId, user.services.totp.tempSecret, hashedCodes); - return { codes }; + // Once the TOTP is validated we logout all other clients + const { 'x-auth-token': xAuthToken } = this.connection?.httpHeaders ?? {}; + if (xAuthToken) { + const hashedToken = Accounts._hashLoginToken(xAuthToken); + + if (!(await Users.removeNonPATLoginTokensExcept(this.userId, hashedToken))) { + throw new Meteor.Error('error-logging-out-other-clients', 'Error logging out other clients'); + } } + + return { codes }; }, }); diff --git a/apps/meteor/app/api/server/api.ts b/apps/meteor/app/api/server/api.ts index d4e8377a4dd71e840888d2dbb6a859b2c6cffa16..08e1ef17e34858784ca8ebbe4811734e463ba424 100644 --- a/apps/meteor/app/api/server/api.ts +++ b/apps/meteor/app/api/server/api.ts @@ -108,6 +108,22 @@ const getRequestIP = (req: Request): string | null => { return forwardedFor[forwardedFor.length - httpForwardedCount]; }; +const generateConnection = ( + ipAddress: string, + httpHeaders: Record, +): { + id: string; + close: () => void; + clientAddress: string; + httpHeaders: Record; +} => ({ + id: Random.id(), + // eslint-disable-next-line @typescript-eslint/no-empty-function + close() {}, + httpHeaders, + clientAddress: ipAddress, +}); + let prometheusAPIUserAgent = false; export class APIClass extends Restivus { @@ -322,7 +338,7 @@ export class APIClass extends Restivus { } rateLimiterDictionary[objectForRateLimitMatch.route].rateLimiter.increment(objectForRateLimitMatch); - const attemptResult = rateLimiterDictionary[objectForRateLimitMatch.route].rateLimiter.check(objectForRateLimitMatch); + const attemptResult = await rateLimiterDictionary[objectForRateLimitMatch.route].rateLimiter.check(objectForRateLimitMatch); const timeToResetAttempsInSeconds = Math.ceil(attemptResult.timeToReset / 1000); response.setHeader('X-RateLimit-Limit', rateLimiterDictionary[objectForRateLimitMatch.route].options.numRequestsAllowed); response.setHeader('X-RateLimit-Remaining', attemptResult.numInvocationsLeft); @@ -569,14 +585,7 @@ export class APIClass extends Restivus { let result; - const connection = { - id: Random.id(), - // eslint-disable-next-line @typescript-eslint/no-empty-function - close() {}, - token: this.token, - httpHeaders: this.request.headers, - clientAddress: this.requestIp, - }; + const connection = { ...generateConnection(this.requestIp, this.request.headers), token: this.token }; try { if (options.deprecationVersion) { @@ -761,12 +770,7 @@ export class APIClass extends Restivus { const args = loginCompatibility(this.bodyParams, request); const invocation = new DDPCommon.MethodInvocation({ - connection: { - // eslint-disable-next-line @typescript-eslint/no-empty-function - close() {}, - httpHeaders: this.request.headers, - clientAddress: getRequestIP(request) || '', - }, + connection: generateConnection(getRequestIP(request) || '', this.request.headers), }); let auth; diff --git a/apps/meteor/app/api/server/v1/chat.ts b/apps/meteor/app/api/server/v1/chat.ts index 75fc98e69c4d872161aeed5b45ba7d13131bfd1c..98fc278594aeb3128ceaed562433379a820af403 100644 --- a/apps/meteor/app/api/server/v1/chat.ts +++ b/apps/meteor/app/api/server/v1/chat.ts @@ -1,7 +1,7 @@ import { Message } from '@rocket.chat/core-services'; import type { IMessage } from '@rocket.chat/core-typings'; import { Messages, Users, Rooms, Subscriptions } from '@rocket.chat/models'; -import { isChatReportMessageProps } from '@rocket.chat/rest-typings'; +import { isChatReportMessageProps, isChatGetURLPreviewProps } from '@rocket.chat/rest-typings'; import { escapeRegExp } from '@rocket.chat/string-helpers'; import { Match, check } from 'meteor/check'; import { Meteor } from 'meteor/meteor'; @@ -15,6 +15,7 @@ import { deleteMessageValidatingPermission } from '../../../lib/server/functions import { processWebhookMessage } from '../../../lib/server/functions/processWebhookMessage'; import { executeSendMessage } from '../../../lib/server/methods/sendMessage'; import { executeUpdateMessage } from '../../../lib/server/methods/updateMessage'; +import { OEmbed } from '../../../oembed/server/server'; import { executeSetReaction } from '../../../reactions/server/setReaction'; import { settings } from '../../../settings/server'; import { normalizeMessagesForUser } from '../../../utils/server/lib/normalizeMessagesForUser'; @@ -822,3 +823,22 @@ API.v1.addRoute( }, }, ); + +API.v1.addRoute( + 'chat.getURLPreview', + { authRequired: true, validateParams: isChatGetURLPreviewProps }, + { + async get() { + const { roomId, url } = this.queryParams; + + if (!(await canAccessRoomIdAsync(roomId, this.userId))) { + throw new Meteor.Error('error-not-allowed', 'Not allowed'); + } + + const { urlPreview } = await OEmbed.parseUrl(url); + urlPreview.ignoreParse = true; + + return API.v1.success({ urlPreview }); + }, + }, +); diff --git a/apps/meteor/app/api/server/v1/ldap.ts b/apps/meteor/app/api/server/v1/ldap.ts index f0d3a52b504fbe38c259f9ea047dac93e25c102a..9a057c9b0afc41a43f678fee54772b9716393507 100644 --- a/apps/meteor/app/api/server/v1/ldap.ts +++ b/apps/meteor/app/api/server/v1/ldap.ts @@ -31,7 +31,7 @@ API.v1.addRoute( } return API.v1.success({ - message: 'Connection_success' as const, + message: 'LDAP_Connection_successful' as const, }); }, }, diff --git a/apps/meteor/app/api/server/v1/misc.ts b/apps/meteor/app/api/server/v1/misc.ts index ae5a79719cce37c39b302ca8ae47588ca240b589..7b6c964a50bb10f898d29eb9cf63530884ebc9a1 100644 --- a/apps/meteor/app/api/server/v1/misc.ts +++ b/apps/meteor/app/api/server/v1/misc.ts @@ -538,7 +538,7 @@ API.v1.addRoute( this.token || crypto .createHash('md5') - .update(this.requestIp + this.request.headers['user-agent']) + .update(this.requestIp + this.user._id) .digest('hex'); const rateLimiterInput = { @@ -594,12 +594,7 @@ API.v1.addRoute( const { method, params, id } = data; - const connectionId = - this.token || - crypto - .createHash('md5') - .update(this.requestIp + this.request.headers['user-agent']) - .digest('hex'); + const connectionId = this.token || crypto.createHash('md5').update(this.requestIp).digest('hex'); const rateLimiterInput = { userId: this.userId || undefined, diff --git a/apps/meteor/app/api/server/v1/moderation.ts b/apps/meteor/app/api/server/v1/moderation.ts index fe31487bdc474cffd041aa7eb81d22837fb92af9..d04259b123e223a947c5153bbd42a25195fbd4b7 100644 --- a/apps/meteor/app/api/server/v1/moderation.ts +++ b/apps/meteor/app/api/server/v1/moderation.ts @@ -1,10 +1,10 @@ -import type { IModerationReport, IUser } from '@rocket.chat/core-typings'; +import type { IModerationReport, IUser, IUserEmail } from '@rocket.chat/core-typings'; import { ModerationReports, Users } from '@rocket.chat/models'; import { isReportHistoryProps, isArchiveReportProps, isReportInfoParams, - isReportMessageHistoryParams, + isGetUserReportsParams, isModerationReportUserPost, isModerationDeleteMsgHistoryParams, isReportsByMsgIdParams, @@ -51,7 +51,7 @@ API.v1.addRoute( }); } - const total = await ModerationReports.countMessageReportsInRange(latest, oldest, escapedSelector); + const total = await ModerationReports.getTotalUniqueReportedUsers(latest, oldest, escapedSelector, true); return API.v1.success({ reports, @@ -63,11 +63,60 @@ API.v1.addRoute( }, ); +API.v1.addRoute( + 'moderation.userReports', + { + authRequired: true, + validateParams: isReportHistoryProps, + permissionsRequired: ['view-moderation-console'], + }, + { + async get() { + const { latest: _latest, oldest: _oldest, selector = '' } = this.queryParams; + + const { count = 20, offset = 0 } = await getPaginationItems(this.queryParams); + + const { sort } = await this.parseJsonQuery(); + + const latest = _latest ? new Date(_latest) : new Date(); + + const oldest = _oldest ? new Date(_oldest) : new Date(0); + + const escapedSelector = escapeRegExp(selector); + + const reports = await ModerationReports.findUserReports(latest, oldest, escapedSelector, { + offset, + count, + sort, + }).toArray(); + + if (reports.length === 0) { + return API.v1.success({ + reports, + count: 0, + offset, + total: 0, + }); + } + + const total = await ModerationReports.getTotalUniqueReportedUsers(latest, oldest, escapedSelector); + + const result = { + reports, + count: reports.length, + offset, + total, + }; + return API.v1.success(result); + }, + }, +); + API.v1.addRoute( 'moderation.user.reportedMessages', { authRequired: true, - validateParams: isReportMessageHistoryParams, + validateParams: isGetUserReportsParams, permissionsRequired: ['view-moderation-console'], }, { @@ -113,6 +162,64 @@ API.v1.addRoute( }, ); +API.v1.addRoute( + 'moderation.user.reportsByUserId', + { + authRequired: true, + validateParams: isGetUserReportsParams, + permissionsRequired: ['view-moderation-console'], + }, + { + async get() { + const { userId, selector = '' } = this.queryParams; + const { sort } = await this.parseJsonQuery(); + const { count = 50, offset = 0 } = await getPaginationItems(this.queryParams); + + const user = await Users.findOneById(userId, { + projection: { + _id: 1, + username: 1, + name: 1, + avatarETag: 1, + active: 1, + roles: 1, + emails: 1, + createdAt: 1, + }, + }); + + const escapedSelector = escapeRegExp(selector); + const { cursor, totalCount } = ModerationReports.findUserReportsByReportedUserId(userId, escapedSelector, { + offset, + count, + sort, + }); + + const [reports, total] = await Promise.all([cursor.toArray(), totalCount]); + + const emailSet = new Map(); + + reports.forEach((report) => { + const email = report.reportedUser?.emails?.[0]; + if (email) { + emailSet.set(email.address, email); + } + }); + if (user) { + user.emails = Array.from(emailSet.values()); + } + + return API.v1.success({ + user, + reports, + count: reports.length, + total, + offset, + }); + }, + }, +); + API.v1.addRoute( 'moderation.user.deleteReportedMessages', { @@ -196,6 +303,33 @@ API.v1.addRoute( }, ); +API.v1.addRoute( + 'moderation.dismissUserReports', + { + authRequired: true, + validateParams: isArchiveReportProps, + permissionsRequired: ['manage-moderation-actions'], + }, + { + async post() { + const { userId, reason, action: actionParam } = this.bodyParams; + + if (!userId) { + return API.v1.failure('error-user-id-param-not-provided'); + } + + const sanitizedReason: string = reason ?? 'No reason provided'; + const action: string = actionParam ?? 'None'; + + const { userId: moderatorId } = this; + + await ModerationReports.hideUserReportsByUserId(userId, moderatorId, sanitizedReason, action); + + return API.v1.success(); + }, + }, +); + API.v1.addRoute( 'moderation.reports', { diff --git a/apps/meteor/app/api/server/v1/oauthapps.ts b/apps/meteor/app/api/server/v1/oauthapps.ts index ca6a2c3a56beb9b22f015a51bc3ae33f3e0e3bd7..034a73f541046b68b02b4939a5b7b43c53124672 100644 --- a/apps/meteor/app/api/server/v1/oauthapps.ts +++ b/apps/meteor/app/api/server/v1/oauthapps.ts @@ -27,6 +27,10 @@ API.v1.addRoute( { authRequired: true, validateParams: isOauthAppsGetParams }, { async get() { + if (!(await hasPermissionAsync(this.userId, 'manage-oauth-apps'))) { + return API.v1.unauthorized(); + } + const oauthApp = await OAuthApps.findOneAuthAppByIdOrClientId(this.queryParams); if (!oauthApp) { diff --git a/apps/meteor/app/api/server/v1/push.ts b/apps/meteor/app/api/server/v1/push.ts index 5910d47e4c76f46d4298532f3e57d7f1e29ad3be..a2c29f85db40788f677577266641e4444f032fb8 100644 --- a/apps/meteor/app/api/server/v1/push.ts +++ b/apps/meteor/app/api/server/v1/push.ts @@ -3,6 +3,7 @@ import { Random } from '@rocket.chat/random'; import { Match, check } from 'meteor/check'; import { Meteor } from 'meteor/meteor'; +import { executePushTest } from '../../../../server/lib/pushConfig'; import { canAccessRoomAsync } from '../../../authorization/server/functions/canAccessRoom'; import PushNotification from '../../../push-notifications/server/lib/PushNotification'; import { settings } from '../../../settings/server'; @@ -126,3 +127,27 @@ API.v1.addRoute( }, }, ); + +API.v1.addRoute( + 'push.test', + { + authRequired: true, + rateLimiterOptions: { + numRequestsAllowed: 1, + intervalTimeInMS: 1000, + }, + permissionsRequired: ['test-push-notifications'], + }, + { + async post() { + if (settings.get('Push_enable') !== true) { + throw new Meteor.Error('error-push-disabled', 'Push is disabled', { + method: 'push_test', + }); + } + + const tokensCount = await executePushTest(this.userId, this.user.username); + return API.v1.success({ tokensCount }); + }, + }, +); diff --git a/apps/meteor/app/api/server/v1/settings.ts b/apps/meteor/app/api/server/v1/settings.ts index cbaff50729ffe7cdb2b800ab4f7a28dce5b65f11..011988f5ba2e633cfa6d7efa6ed662b7ae2ec4e5 100644 --- a/apps/meteor/app/api/server/v1/settings.ts +++ b/apps/meteor/app/api/server/v1/settings.ts @@ -1,14 +1,14 @@ -import type { ISetting, ISettingColor } from '@rocket.chat/core-typings'; +import type { + FacebookOAuthConfiguration, + ISetting, + ISettingColor, + TwitterOAuthConfiguration, + OAuthConfiguration, +} from '@rocket.chat/core-typings'; import { isSettingAction, isSettingColor } from '@rocket.chat/core-typings'; -import { Settings } from '@rocket.chat/models'; -import { - isOauthCustomConfiguration, - isSettingsUpdatePropDefault, - isSettingsUpdatePropsActions, - isSettingsUpdatePropsColor, -} from '@rocket.chat/rest-typings'; +import { LoginServiceConfiguration as LoginServiceConfigurationModel, Settings } from '@rocket.chat/models'; +import { isSettingsUpdatePropDefault, isSettingsUpdatePropsActions, isSettingsUpdatePropsColor } from '@rocket.chat/rest-typings'; import { Meteor } from 'meteor/meteor'; -import { ServiceConfiguration } from 'meteor/service-configuration'; import type { FindOptions } from 'mongodb'; import _ from 'underscore'; @@ -71,22 +71,25 @@ API.v1.addRoute( { authRequired: false }, { async get() { - const oAuthServicesEnabled = await ServiceConfiguration.configurations.find({}, { fields: { secret: 0 } }).fetchAsync(); + const oAuthServicesEnabled = await LoginServiceConfigurationModel.find({}, { projection: { secret: 0 } }).toArray(); return API.v1.success({ services: oAuthServicesEnabled.map((service) => { - if (!isOauthCustomConfiguration(service)) { + if (!service) { return service; } - if (service.custom || (service.service && ['saml', 'cas', 'wordpress'].includes(service.service))) { + if ((service as OAuthConfiguration).custom || (service.service && ['saml', 'cas', 'wordpress'].includes(service.service))) { return { ...service }; } return { _id: service._id, name: service.service, - clientId: service.appId || service.clientId || service.consumerKey, + clientId: + (service as FacebookOAuthConfiguration).appId || + (service as OAuthConfiguration).clientId || + (service as TwitterOAuthConfiguration).consumerKey, buttonLabelText: service.buttonLabelText || '', buttonColor: service.buttonColor || '', buttonLabelColor: service.buttonLabelColor || '', @@ -215,7 +218,7 @@ API.v1.addRoute( { async get() { return API.v1.success({ - configurations: await ServiceConfiguration.configurations.find({}, { fields: { secret: 0 } }).fetchAsync(), + configurations: await LoginServiceConfigurationModel.find({}, { projection: { secret: 0 } }).toArray(), }); }, }, diff --git a/apps/meteor/app/api/server/v1/users.ts b/apps/meteor/app/api/server/v1/users.ts index b23d41255c3b3ff05fa302e85357db8d39fc4c5a..10ea2f0b5ac2254f0a78a294fc53832b91e91f78 100644 --- a/apps/meteor/app/api/server/v1/users.ts +++ b/apps/meteor/app/api/server/v1/users.ts @@ -1,4 +1,4 @@ -import { Team, api } from '@rocket.chat/core-services'; +import { MeteorError, Team, api } from '@rocket.chat/core-services'; import type { IExportOperation, ILoginToken, IPersonalAccessToken, IUser, UserStatus } from '@rocket.chat/core-typings'; import { Users, Subscriptions } from '@rocket.chat/models'; import { @@ -792,8 +792,23 @@ API.v1.addRoute( { authRequired: true }, { async post() { + const hasUnverifiedEmail = this.user.emails?.some((email) => !email.verified); + if (hasUnverifiedEmail) { + throw new MeteorError('error-invalid-user', 'You need to verify your emails before setting up 2FA'); + } + await Users.enableEmail2FAByUserId(this.userId); + // When 2FA is enable we logout all other clients + const xAuthToken = this.request.headers['x-auth-token'] as string; + if (xAuthToken) { + const hashedToken = Accounts._hashLoginToken(xAuthToken); + + if (!(await Users.removeNonPATLoginTokensExcept(this.userId, hashedToken))) { + throw new MeteorError('error-logging-out-other-clients', 'Error logging out other clients'); + } + } + return API.v1.success(); }, }, diff --git a/apps/meteor/app/apple/client/index.ts b/apps/meteor/app/apple/client/index.ts index 3e4d15a67fe134a65f9bd9cefa767bf2537f6395..2c59dbe5b3d4592a533d2d8e2f195173e17a0d5c 100644 --- a/apps/meteor/app/apple/client/index.ts +++ b/apps/meteor/app/apple/client/index.ts @@ -1,4 +1,4 @@ -import { CustomOAuth } from '../../custom-oauth/client/custom_oauth_client'; +import { CustomOAuth } from '../../custom-oauth/client/CustomOAuth'; import { config } from '../lib/config'; new CustomOAuth('apple', config); diff --git a/apps/meteor/app/apple/server/appleOauthRegisterService.ts b/apps/meteor/app/apple/server/appleOauthRegisterService.ts index e19564542e1577d6c7fa4acbb160fccc077e1faf..b9558fa701f77b506a53125a99f29836102b31ed 100644 --- a/apps/meteor/app/apple/server/appleOauthRegisterService.ts +++ b/apps/meteor/app/apple/server/appleOauthRegisterService.ts @@ -70,7 +70,7 @@ settings.watchMultiple( secret, enabled: settings.get('Accounts_OAuth_Apple'), loginStyle: 'popup', - clientId, + clientId: clientId as string, buttonColor: '#000', buttonLabelColor: '#FFF', }, diff --git a/apps/meteor/app/apps/server/bridges/messages.ts b/apps/meteor/app/apps/server/bridges/messages.ts index e4d09018176d5b685caaae835a5be6ca09aaaa1f..311e3aaca1e1bbc8b6fc11b5919d11253a5fb1a4 100644 --- a/apps/meteor/app/apps/server/bridges/messages.ts +++ b/apps/meteor/app/apps/server/bridges/messages.ts @@ -62,7 +62,7 @@ export class AppMessageBridge extends MessageBridge { } const convertedMsg = await this.orch.getConverters()?.get('messages').convertAppMessage(message); - const convertedUser = await this.orch.getConverters()?.get('users').convertById(user.id); + const convertedUser = (await Users.findOneById(user.id)) || this.orch.getConverters()?.get('users').convertToRocketChat(user); await deleteMessage(convertedMsg, convertedUser); } diff --git a/apps/meteor/app/apps/server/bridges/persistence.ts b/apps/meteor/app/apps/server/bridges/persistence.ts index 6876a9bcc79f81c880fe885a1aab16fec247ed1f..3810ed367e990d8361ad7b8db2f57d364d25b36b 100644 --- a/apps/meteor/app/apps/server/bridges/persistence.ts +++ b/apps/meteor/app/apps/server/bridges/persistence.ts @@ -1,5 +1,6 @@ import type { RocketChatAssociationRecord } from '@rocket.chat/apps-engine/definition/metadata'; import { PersistenceBridge } from '@rocket.chat/apps-engine/server/bridges/PersistenceBridge'; +import type { InsertOneResult, UpdateResult } from 'mongodb'; import type { AppServerOrchestrator } from '../../../../ee/server/apps/orchestrator'; @@ -21,7 +22,10 @@ export class AppPersistenceBridge extends PersistenceBridge { throw new Error('Attempted to store an invalid data type, it must be an object.'); } - return this.orch.getPersistenceModel().insertOne({ appId, data }); + return this.orch + .getPersistenceModel() + .insertOne({ appId, data }) + .then(({ insertedId }: InsertOneResult) => insertedId || ''); } protected async createWithAssociations(data: object, associations: Array, appId: string): Promise { @@ -35,7 +39,10 @@ export class AppPersistenceBridge extends PersistenceBridge { throw new Error('Attempted to store an invalid data type, it must be an object.'); } - return this.orch.getPersistenceModel().insertOne({ appId, associations, data }); + return this.orch + .getPersistenceModel() + .insertOne({ appId, associations, data }) + .then(({ insertedId }: InsertOneResult) => insertedId || ''); } protected async readById(id: string, appId: string): Promise { @@ -89,7 +96,7 @@ export class AppPersistenceBridge extends PersistenceBridge { const records = await this.orch.getPersistenceModel().find(query).toArray(); - if (!records || !records.length) { + if (!records?.length) { return undefined; } @@ -125,6 +132,9 @@ export class AppPersistenceBridge extends PersistenceBridge { associations, }; - return this.orch.getPersistenceModel().update(query, { $set: { data } }, { upsert }); + return this.orch + .getPersistenceModel() + .update(query, { $set: { data } }, { upsert }) + .then(({ upsertedId }: UpdateResult) => upsertedId || ''); } } diff --git a/apps/meteor/app/apps/server/converters/threads.ts b/apps/meteor/app/apps/server/converters/threads.ts index 1fa52d29cb169b7873b506b7ee5b54c8464c5627..19d3b4aeae6d974a8048eda99acb0e3e0ec6dd8e 100644 --- a/apps/meteor/app/apps/server/converters/threads.ts +++ b/apps/meteor/app/apps/server/converters/threads.ts @@ -101,7 +101,7 @@ export class AppThreadsConverter { groupable: 'groupable', token: 'token', blocks: 'blocks', - room, + room: () => room, editor: async (message: IMessage) => { if (!isEditedMessage(message)) { return undefined; diff --git a/apps/meteor/app/autotranslate/server/autotranslate.ts b/apps/meteor/app/autotranslate/server/autotranslate.ts index 6cac8028e1f24db12e2b16fb5dbd8fefe019c063..7a9eb8780a2df0b8e7213913cb21ec3e95bfd40c 100644 --- a/apps/meteor/app/autotranslate/server/autotranslate.ts +++ b/apps/meteor/app/autotranslate/server/autotranslate.ts @@ -1,4 +1,3 @@ -import { api } from '@rocket.chat/core-services'; import type { IMessage, IRoom, @@ -16,7 +15,7 @@ import _ from 'underscore'; import { callbacks } from '../../../lib/callbacks'; import { isTruthy } from '../../../lib/isTruthy'; -import { broadcastMessageSentEvent } from '../../../server/modules/watchers/lib/messages'; +import { broadcastMessageFromData } from '../../../server/modules/watchers/lib/messages'; import { Markdown } from '../../markdown/server'; import { settings } from '../../settings/server'; @@ -333,9 +332,8 @@ export abstract class AutoTranslate { } private notifyTranslatedMessage(messageId: string): void { - void broadcastMessageSentEvent({ + void broadcastMessageFromData({ id: messageId, - broadcastCallback: (message) => api.broadcast('message.sent', message), }); } diff --git a/apps/meteor/app/cas/client/cas_client.ts b/apps/meteor/app/cas/client/cas_client.ts deleted file mode 100644 index ea4b3047f6bf2b497f8c3f7e16c99e8737e8ecf8..0000000000000000000000000000000000000000 --- a/apps/meteor/app/cas/client/cas_client.ts +++ /dev/null @@ -1,68 +0,0 @@ -import { Random } from '@rocket.chat/random'; -import { Accounts } from 'meteor/accounts-base'; -import { Meteor } from 'meteor/meteor'; - -import { settings } from '../../settings/client'; - -const openCenteredPopup = (url: string, width: number, height: number) => { - const screenX = typeof window.screenX !== 'undefined' ? window.screenX : window.screenLeft; - const screenY = typeof window.screenY !== 'undefined' ? window.screenY : window.screenTop; - const outerWidth = typeof window.outerWidth !== 'undefined' ? window.outerWidth : document.body.clientWidth; - const outerHeight = typeof window.outerHeight !== 'undefined' ? window.outerHeight : document.body.clientHeight - 22; - // XXX what is the 22? - - // Use `outerWidth - width` and `outerHeight - height` for help in - // positioning the popup centered relative to the current window - const left = screenX + (outerWidth - width) / 2; - const top = screenY + (outerHeight - height) / 2; - const features = `width=${width},height=${height},left=${left},top=${top},scrollbars=yes`; - - const newwindow = window.open(url, 'Login', features); - newwindow?.focus(); - - return newwindow; -}; - -(Meteor as any).loginWithCas = (_?: unknown, callback?: () => void) => { - const credentialToken = Random.id(); - const loginUrl = settings.get('CAS_login_url'); - const popupWidth = settings.get('CAS_popup_width') || 800; - const popupHeight = settings.get('CAS_popup_height') || 600; - - if (!loginUrl) { - return; - } - - const appUrl = Meteor.absoluteUrl().replace(/\/$/, '') + __meteor_runtime_config__.ROOT_URL_PATH_PREFIX; - // check if the provided CAS URL already has some parameters - const delim = loginUrl.split('?').length > 1 ? '&' : '?'; - const popupUrl = `${loginUrl}${delim}service=${appUrl}/_cas/${credentialToken}`; - - const popup = openCenteredPopup(popupUrl, popupWidth, popupHeight); - - const checkPopupOpen = setInterval(() => { - let popupClosed; - try { - // Fix for #328 - added a second test criteria (popup.closed === undefined) - // to humour this Android quirk: - // http://code.google.com/p/android/issues/detail?id=21061 - popupClosed = popup?.closed || popup?.closed === undefined; - } catch (e) { - // For some unknown reason, IE9 (and others?) sometimes (when - // the popup closes too quickly?) throws "SCRIPT16386: No such - // interface supported" when trying to read 'popup.closed'. Try - // again in 100ms. - return; - } - - if (popupClosed) { - clearInterval(checkPopupOpen); - - // check auth on server. - Accounts.callLoginMethod({ - methodArguments: [{ cas: { credentialToken } }], - userCallback: callback, - }); - } - }, 100); -}; diff --git a/apps/meteor/app/cas/client/index.ts b/apps/meteor/app/cas/client/index.ts deleted file mode 100644 index 75213558d6d8f8f2562cc07803153de0de85e47c..0000000000000000000000000000000000000000 --- a/apps/meteor/app/cas/client/index.ts +++ /dev/null @@ -1 +0,0 @@ -import './cas_client'; diff --git a/apps/meteor/app/cas/server/cas_rocketchat.js b/apps/meteor/app/cas/server/cas_rocketchat.js deleted file mode 100644 index f0b62b6ccb8aab22c0f9e1ea5f54381b64eec66f..0000000000000000000000000000000000000000 --- a/apps/meteor/app/cas/server/cas_rocketchat.js +++ /dev/null @@ -1,43 +0,0 @@ -import { Logger } from '@rocket.chat/logger'; -import { ServiceConfiguration } from 'meteor/service-configuration'; - -import { settings } from '../../settings/server'; - -export const logger = new Logger('CAS'); - -let timer; - -async function updateServices(/* record*/) { - if (typeof timer !== 'undefined') { - clearTimeout(timer); - } - - timer = setTimeout(async () => { - const data = { - // These will pe passed to 'node-cas' as options - enabled: settings.get('CAS_enabled'), - base_url: settings.get('CAS_base_url'), - login_url: settings.get('CAS_login_url'), - // Rocketchat Visuals - buttonLabelText: settings.get('CAS_button_label_text'), - buttonLabelColor: settings.get('CAS_button_label_color'), - buttonColor: settings.get('CAS_button_color'), - width: settings.get('CAS_popup_width'), - height: settings.get('CAS_popup_height'), - autoclose: settings.get('CAS_autoclose'), - }; - - // Either register or deregister the CAS login service based upon its configuration - if (data.enabled) { - logger.info('Enabling CAS login service'); - await ServiceConfiguration.configurations.upsertAsync({ service: 'cas' }, { $set: data }); - } else { - logger.info('Disabling CAS login service'); - await ServiceConfiguration.configurations.removeAsync({ service: 'cas' }); - } - }, 2000); -} - -settings.watchByRegex(/^CAS_.+/, async (key, value) => { - await updateServices(value); -}); diff --git a/apps/meteor/app/cas/server/cas_server.js b/apps/meteor/app/cas/server/cas_server.js deleted file mode 100644 index 60880c77d4f406bec5fcb3e77d8186e364edbc21..0000000000000000000000000000000000000000 --- a/apps/meteor/app/cas/server/cas_server.js +++ /dev/null @@ -1,272 +0,0 @@ -import url from 'url'; - -import { validate } from '@rocket.chat/cas-validate'; -import { CredentialTokens, Rooms, Users } from '@rocket.chat/models'; -import { Accounts } from 'meteor/accounts-base'; -import { Meteor } from 'meteor/meteor'; -import { RoutePolicy } from 'meteor/routepolicy'; -import { WebApp } from 'meteor/webapp'; -import _ from 'underscore'; - -import { createRoom } from '../../lib/server/functions/createRoom'; -import { _setRealName } from '../../lib/server/functions/setRealName'; -import { settings } from '../../settings/server'; -import { logger } from './cas_rocketchat'; - -RoutePolicy.declare('/_cas/', 'network'); - -const closePopup = function (res) { - res.writeHead(200, { 'Content-Type': 'text/html' }); - const content = ''; - res.end(content, 'utf-8'); -}; - -const casTicket = function (req, token, callback) { - // get configuration - if (!settings.get('CAS_enabled')) { - logger.error('Got ticket validation request, but CAS is not enabled'); - callback(); - } - - // get ticket and validate. - const parsedUrl = url.parse(req.url, true); - const ticketId = parsedUrl.query.ticket; - const baseUrl = settings.get('CAS_base_url'); - const cas_version = parseFloat(settings.get('CAS_version')); - const appUrl = Meteor.absoluteUrl().replace(/\/$/, '') + __meteor_runtime_config__.ROOT_URL_PATH_PREFIX; - logger.debug(`Using CAS_base_url: ${baseUrl}`); - - validate( - { - base_url: baseUrl, - version: cas_version, - service: `${appUrl}/_cas/${token}`, - }, - ticketId, - async (err, status, username, details) => { - if (err) { - logger.error(`error when trying to validate: ${err.message}`); - } else if (status) { - logger.info(`Validated user: ${username}`); - const user_info = { username }; - - // CAS 2.0 attributes handling - if (details && details.attributes) { - _.extend(user_info, { attributes: details.attributes }); - } - await CredentialTokens.create(token, user_info); - } else { - logger.error(`Unable to validate ticket: ${ticketId}`); - } - // logger.debug("Received response: " + JSON.stringify(details, null , 4)); - - callback(); - }, - ); -}; - -const middleware = function (req, res, next) { - // Make sure to catch any exceptions because otherwise we'd crash - // the runner - try { - const barePath = req.url.substring(0, req.url.indexOf('?')); - const splitPath = barePath.split('/'); - - // Any non-cas request will continue down the default - // middlewares. - if (splitPath[1] !== '_cas') { - next(); - return; - } - - // get auth token - const credentialToken = splitPath[2]; - if (!credentialToken) { - closePopup(res); - return; - } - - // validate ticket - casTicket(req, credentialToken, () => { - closePopup(res); - }); - } catch (err) { - logger.error({ msg: 'Unexpected error', err }); - closePopup(res); - } -}; - -// Listen to incoming OAuth http requests -WebApp.connectHandlers.use((req, res, next) => { - middleware(req, res, next); -}); - -/* - * Register a server-side login handle. - * It is call after Accounts.callLoginMethod() is call from client. - * - */ -Accounts.registerLoginHandler('cas', async (options) => { - if (!options.cas) { - return undefined; - } - - // TODO: Sync wrapper due to the chain conversion to async models - const credentials = await CredentialTokens.findOneNotExpiredById(options.cas.credentialToken); - if (credentials === undefined) { - throw new Meteor.Error(Accounts.LoginCancelledError.numericError, 'no matching login attempt found'); - } - - const result = credentials.userInfo; - const syncUserDataFieldMap = settings.get('CAS_Sync_User_Data_FieldMap').trim(); - const cas_version = parseFloat(settings.get('CAS_version')); - const sync_enabled = settings.get('CAS_Sync_User_Data_Enabled'); - const trustUsername = settings.get('CAS_trust_username'); - const verified = settings.get('Accounts_Verify_Email_For_External_Accounts'); - const userCreationEnabled = settings.get('CAS_Creation_User_Enabled'); - - // We have these - const ext_attrs = { - username: result.username, - }; - - // We need these - const int_attrs = { - email: undefined, - name: undefined, - username: undefined, - rooms: undefined, - }; - - // Import response attributes - if (cas_version >= 2.0) { - // Clean & import external attributes - _.each(result.attributes, (value, ext_name) => { - if (value) { - ext_attrs[ext_name] = value[0]; - } - }); - } - - // Source internal attributes - if (syncUserDataFieldMap) { - // Our mapping table: key(int_attr) -> value(ext_attr) - // Spoken: Source this internal attribute from these external attributes - const attr_map = JSON.parse(syncUserDataFieldMap); - - _.each(attr_map, (source, int_name) => { - // Source is our String to interpolate - if (source && typeof source.valueOf() === 'string') { - let replacedValue = source; - _.each(ext_attrs, (value, ext_name) => { - replacedValue = replacedValue.replace(`%${ext_name}%`, ext_attrs[ext_name]); - }); - - if (source !== replacedValue) { - int_attrs[int_name] = replacedValue; - logger.debug(`Sourced internal attribute: ${int_name} = ${replacedValue}`); - } else { - logger.debug(`Sourced internal attribute: ${int_name} skipped.`); - } - } - }); - } - - // Search existing user by its external service id - logger.debug(`Looking up user by id: ${result.username}`); - // First, look for a user that has logged in from CAS with this username before - let user = await Users.findOne({ 'services.cas.external_id': result.username }); - if (!user) { - // If that user was not found, check if there's any Rocket.Chat user with that username - // With this, CAS login will continue to work if the user is renamed on both sides and also if the user is renamed only on Rocket.Chat. - // It'll also allow non-CAS users to switch to CAS based login - if (trustUsername) { - const username = new RegExp(`^${result.username}$`, 'i'); - user = await Users.findOne({ username }); - if (user) { - // Update the user's external_id to reflect this new username. - await Users.updateOne({ _id: user._id }, { $set: { 'services.cas.external_id': result.username } }); - } - } - } - - if (user) { - logger.debug(`Using existing user for '${result.username}' with id: ${user._id}`); - if (sync_enabled) { - logger.debug('Syncing user attributes'); - // Update name - if (int_attrs.name) { - await _setRealName(user._id, int_attrs.name); - } - - // Update email - if (int_attrs.email) { - await Users.updateOne({ _id: user._id }, { $set: { emails: [{ address: int_attrs.email, verified }] } }); - } - } - } else if (userCreationEnabled) { - // Define new user - const newUser = { - username: result.username, - active: true, - globalRoles: ['user'], - emails: [], - services: { - cas: { - external_id: result.username, - version: cas_version, - attrs: int_attrs, - }, - }, - }; - - // Add username - if (int_attrs.username) { - _.extend(newUser, { - username: int_attrs.username, - }); - } - - // Add User.name - if (int_attrs.name) { - _.extend(newUser, { - name: int_attrs.name, - }); - } - - // Add email - if (int_attrs.email) { - _.extend(newUser, { - emails: [{ address: int_attrs.email, verified }], - }); - } - - // Create the user - logger.debug(`User "${result.username}" does not exist yet, creating it`); - const userId = Accounts.insertUserDoc({}, newUser); - - // Fetch and use it - user = await Users.findOneById(userId); - logger.debug(`Created new user for '${result.username}' with id: ${user._id}`); - // logger.debug(JSON.stringify(user, undefined, 4)); - - logger.debug(`Joining user to attribute channels: ${int_attrs.rooms}`); - if (int_attrs.rooms) { - const roomNames = int_attrs.rooms.split(','); - for await (const roomName of roomNames) { - if (roomName) { - let room = await Rooms.findOneByNameAndType(roomName, 'c'); - if (!room) { - room = await createRoom('c', roomName, user); - } - } - } - } - } else { - // Should fail as no user exist and can't be created - logger.debug(`User "${result.username}" does not exist yet, will fail as no user creation is enabled`); - throw new Meteor.Error(Accounts.LoginCancelledError.numericError, 'no matching user account found'); - } - - return { userId: user._id }; -}); diff --git a/apps/meteor/app/cas/server/index.ts b/apps/meteor/app/cas/server/index.ts deleted file mode 100644 index 0ad22d77b198d7cbfa9fb802a1532af46c64dc87..0000000000000000000000000000000000000000 --- a/apps/meteor/app/cas/server/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -import './cas_rocketchat'; -import './cas_server'; diff --git a/apps/meteor/app/cloud/server/functions/buildRegistrationData.ts b/apps/meteor/app/cloud/server/functions/buildRegistrationData.ts index cc1af6fb176706d95148ec1392dcd8795c007031..07db80c5cca9ff595c79c996900c59353dace0ff 100644 --- a/apps/meteor/app/cloud/server/functions/buildRegistrationData.ts +++ b/apps/meteor/app/cloud/server/functions/buildRegistrationData.ts @@ -65,7 +65,7 @@ export async function buildWorkspaceRegistrationData('Language'); const organizationType = settings.get('Organization_Type'); const industry = settings.get('Industry'); - const orgSize = settings.get('Organization_Size'); + const orgSize = settings.get('Size'); const workspaceType = settings.get('Server_Type'); const seats = await Users.getActiveLocalUserCount(); diff --git a/apps/meteor/app/crowd/client/index.ts b/apps/meteor/app/crowd/client/index.ts deleted file mode 100644 index fecf898e1ae444c90891397402222a0de50af738..0000000000000000000000000000000000000000 --- a/apps/meteor/app/crowd/client/index.ts +++ /dev/null @@ -1 +0,0 @@ -import './loginHelper'; diff --git a/apps/meteor/app/crowd/client/loginHelper.js b/apps/meteor/app/crowd/client/loginHelper.js deleted file mode 100644 index a2bb14023b3ae2dd24dc7bed5c6bc0d9a1ef1c9d..0000000000000000000000000000000000000000 --- a/apps/meteor/app/crowd/client/loginHelper.js +++ /dev/null @@ -1,26 +0,0 @@ -import { Accounts } from 'meteor/accounts-base'; -import { Meteor } from 'meteor/meteor'; - -Meteor.loginWithCrowd = function (...args) { - // Pull username and password - const username = args.shift(); - const password = args.shift(); - const callback = args.shift(); - - const loginRequest = { - crowd: true, - username, - crowdPassword: password, - }; - Accounts.callLoginMethod({ - methodArguments: [loginRequest], - userCallback(error) { - if (callback) { - if (error) { - return callback(error); - } - return callback(); - } - }, - }); -}; diff --git a/apps/meteor/app/crowd/server/crowd.ts b/apps/meteor/app/crowd/server/crowd.ts index b6b94f33e56683252122c0a6d448a745aca5b65a..e43c65c70f9183df11340bdd9531371bf6113adc 100644 --- a/apps/meteor/app/crowd/server/crowd.ts +++ b/apps/meteor/app/crowd/server/crowd.ts @@ -68,23 +68,46 @@ export class CROWD { this.crowdClient = new AtlassianCrowd(this.options); } - async checkConnection() { - await this.crowdClient.ping(); + async checkConnection(): Promise { + return new Promise((resolve, reject) => + this.crowdClient.ping((err: any) => { + if (err) { + reject(err); + } + resolve(); + }), + ); } - async fetchCrowdUser(crowdUsername: string) { - const userResponse = await this.crowdClient.user.find(crowdUsername); + async fetchCrowdUser(crowdUsername: string): Promise> { + return new Promise((resolve, reject) => + this.crowdClient.user.find(crowdUsername, (err: any, userResponse: Record) => { + if (err) { + reject(err); + } + resolve({ + displayname: userResponse['display-name'], + username: userResponse.name, + email: userResponse.email, + active: userResponse.active, + crowd_username: crowdUsername, + }); + }), + ); + } - return { - displayname: userResponse['display-name'], - username: userResponse.name, - email: userResponse.email, - active: userResponse.active, - crowd_username: crowdUsername, - }; + async searchForCrowdUserByMail(email?: string): Promise | undefined> { + return new Promise((resolve) => + this.crowdClient.search('user', `email=" ${email} "`, (err: any, response: Record) => { + if (err) { + resolve(undefined); + } + resolve(response); + }), + ); } - async authenticate(username: string, password: string) { + async authenticate(username: string, password: string): Promise | undefined> { if (!username || !password) { logger.error('No username or password'); return; @@ -134,24 +157,30 @@ export class CROWD { logger.debug('New user. User is not synced yet.'); } logger.debug('Going to crowd:', crowdUsername); - const auth = await this.crowdClient.user.authenticate(crowdUsername, password); - - if (!auth) { - return; - } - const crowdUser: Record = await this.fetchCrowdUser(crowdUsername); - - if (user && settings.get('CROWD_Allow_Custom_Username') === true) { - crowdUser.username = user.username; - } + return new Promise((resolve, reject) => + this.crowdClient.user.authenticate(crowdUsername, password, async (err: any, res: Record) => { + if (err) { + reject(err); + } + const user = res; + try { + const crowdUser: Record = await this.fetchCrowdUser(crowdUsername); + if (user && settings.get('CROWD_Allow_Custom_Username') === true) { + crowdUser.username = user.name; + } - if (user) { - crowdUser._id = user._id; - } - crowdUser.password = password; + if (user) { + crowdUser._id = user._id; + } + crowdUser.password = password; - return crowdUser; + resolve(crowdUser); + } catch (err) { + reject(err); + } + }), + ); } async syncDataToUser(crowdUser: Record, id: string) { @@ -219,7 +248,7 @@ export class CROWD { const email = user.emails?.[0].address; logger.info('Attempting to find for user by email', email); - const response = this.crowdClient.searchSync('user', `email=" ${email} "`); + const response = await this.searchForCrowdUserByMail(email); if (!response || response.users.length === 0) { logger.warn('Could not find user in CROWD with username or email:', crowdUsername, email); if (settings.get('CROWD_Remove_Orphaned_Users') === true) { @@ -257,8 +286,15 @@ export class CROWD { } async updateUserCollection(crowdUser: Record) { + const username = crowdUser.crowd_username || crowdUser.username; + const mail = crowdUser.email; + + // If id is not provided, user is linked by crowd_username or email address const userQuery = { - _id: crowdUser._id, + ...(crowdUser._id && { _id: crowdUser._id }), + ...(!crowdUser._id && { + $or: [{ crowd_username: username }, { 'emails.address': mail }], + }), }; // find our existing user if they exist @@ -321,16 +357,17 @@ Accounts.registerLoginHandler('crowd', async function (this: typeof Accounts, lo } if (!user) { - logger.debug(`User ${loginRequest.username} is not allowd to access Rocket.Chat`); + logger.debug(`User ${loginRequest.username} is not allowed to access Rocket.Chat`); return new Meteor.Error('not-authorized', 'User is not authorized by crowd'); } const result = await crowd.updateUserCollection(user); return result; - } catch (err) { + } catch (err: any) { logger.debug({ err }); logger.error('Crowd user not authenticated due to an error'); + throw new Meteor.Error('user-not-found', err.message); } }); diff --git a/apps/meteor/app/crowd/server/methods.ts b/apps/meteor/app/crowd/server/methods.ts index 758ebd1fcb3c7650f1fa17cd9efffba9b0df6eed..a621e3c8d0273177762ae5ba3753e3cfed8f06b9 100644 --- a/apps/meteor/app/crowd/server/methods.ts +++ b/apps/meteor/app/crowd/server/methods.ts @@ -38,7 +38,7 @@ Meteor.methods({ await crowd.checkConnection(); return { - message: 'Connection_success' as const, + message: 'Crowd_Connection_successful' as const, params: [], }; } catch (err) { diff --git a/apps/meteor/app/custom-oauth/client/custom_oauth_client.js b/apps/meteor/app/custom-oauth/client/CustomOAuth.ts similarity index 63% rename from apps/meteor/app/custom-oauth/client/custom_oauth_client.js rename to apps/meteor/app/custom-oauth/client/CustomOAuth.ts index c516f115aede23d5345b29d9c5c8db4483970efc..1d57d1969d939bca2a2f6c1035eeaeecf8b0b0dc 100644 --- a/apps/meteor/app/custom-oauth/client/custom_oauth_client.js +++ b/apps/meteor/app/custom-oauth/client/CustomOAuth.ts @@ -1,11 +1,15 @@ +import type { OAuthConfiguration, OauthConfig } from '@rocket.chat/core-typings'; import { Random } from '@rocket.chat/random'; import { capitalize } from '@rocket.chat/string-helpers'; import { Accounts } from 'meteor/accounts-base'; import { Match } from 'meteor/check'; import { Meteor } from 'meteor/meteor'; import { OAuth } from 'meteor/oauth'; -import { ServiceConfiguration } from 'meteor/service-configuration'; +import type { IOAuthProvider } from '../../../client/definitions/IOAuthProvider'; +import { overrideLoginMethod, type LoginCallback } from '../../../client/lib/2fa/overrideLoginMethod'; +import { loginServices } from '../../../client/lib/loginServices'; +import { createOAuthTotpLoginMethod } from '../../../client/meteorOverrides/login/oauth'; import { isURL } from '../../../lib/utils/isURL'; // Request custom OAuth credentials for the user @@ -14,8 +18,16 @@ import { isURL } from '../../../lib/utils/isURL'; // completion. Takes one argument, credentialToken on success, or Error on // error. -export class CustomOAuth { - constructor(name, options) { +export class CustomOAuth implements IOAuthProvider { + public serverURL: string; + + public authorizePath: string; + + public scope: string; + + public responseType: string; + + constructor(public readonly name: string, options: OauthConfig) { this.name = name; if (!Match.test(this.name, String)) { throw new Meteor.Error('CustomOAuth: Name is required and must be String'); @@ -28,7 +40,7 @@ export class CustomOAuth { this.configureLogin(); } - configure(options) { + configure(options: OauthConfig) { if (!Match.test(options, Object)) { throw new Meteor.Error('CustomOAuth: Options is required and must be Object'); } @@ -56,37 +68,34 @@ export class CustomOAuth { } configureLogin() { - const loginWithService = `loginWith${capitalize(String(this.name || ''))}`; + const loginWithService = `loginWith${capitalize(String(this.name || ''))}` as const; - Meteor[loginWithService] = async (options, callback) => { - // support a callback without options - if (!callback && typeof options === 'function') { - callback = options; - options = null; - } + const loginWithOAuthTokenAndTOTP = createOAuthTotpLoginMethod(this); + const loginWithOAuthToken = async (options?: Meteor.LoginWithExternalServiceOptions, callback?: LoginCallback) => { const credentialRequestCompleteCallback = Accounts.oauth.credentialRequestCompleteHandler(callback); await this.requestCredential(options, credentialRequestCompleteCallback); }; - } - async requestCredential(options, credentialRequestCompleteCallback) { - // support both (options, callback) and (callback). - if (!credentialRequestCompleteCallback && typeof options === 'function') { - credentialRequestCompleteCallback = options; - options = {}; - } + (Meteor as any)[loginWithService] = (options: Meteor.LoginWithExternalServiceOptions, callback: LoginCallback) => { + overrideLoginMethod(loginWithOAuthToken, [options], callback, loginWithOAuthTokenAndTOTP); + }; + } - const config = await ServiceConfiguration.configurations.findOneAsync({ service: this.name }); + async requestCredential( + options: Meteor.LoginWithExternalServiceOptions = {}, + credentialRequestCompleteCallback: (credentialTokenOrError?: string | Error) => void, + ) { + const config = await loginServices.loadLoginService(this.name); if (!config) { if (credentialRequestCompleteCallback) { - credentialRequestCompleteCallback(new ServiceConfiguration.ConfigError()); + credentialRequestCompleteCallback(new Accounts.ConfigError()); } return; } const credentialToken = Random.secret(); - const loginStyle = OAuth._loginStyle(this.name, config, options); + const loginStyle = OAuth._loginStyle(this.name, config); const separator = this.authorizePath.indexOf('?') !== -1 ? '&' : '?'; diff --git a/apps/meteor/app/custom-oauth/server/custom_oauth_server.js b/apps/meteor/app/custom-oauth/server/custom_oauth_server.js index bb939febaef801673bab11c4d1d2392f2596ee47..6b225069734d49b6df167199b65d2bc696a92a04 100644 --- a/apps/meteor/app/custom-oauth/server/custom_oauth_server.js +++ b/apps/meteor/app/custom-oauth/server/custom_oauth_server.js @@ -106,7 +106,7 @@ export class CustomOAuth { async getAccessToken(query) { const config = await ServiceConfiguration.configurations.findOneAsync({ service: this.name }); if (!config) { - throw new ServiceConfiguration.ConfigError(); + throw new Accounts.ConfigError(); } let response = undefined; diff --git a/apps/meteor/app/discussion/server/hooks/propagateDiscussionMetadata.ts b/apps/meteor/app/discussion/server/hooks/propagateDiscussionMetadata.ts index e366216ed7f933d3342b9bbc50e841a01c7fdf24..0f42f495e9626748c866f71f91527aabb316fd2c 100644 --- a/apps/meteor/app/discussion/server/hooks/propagateDiscussionMetadata.ts +++ b/apps/meteor/app/discussion/server/hooks/propagateDiscussionMetadata.ts @@ -1,9 +1,8 @@ -import { api } from '@rocket.chat/core-services'; import type { IRoom } from '@rocket.chat/core-typings'; import { Messages, Rooms } from '@rocket.chat/models'; import { callbacks } from '../../../../lib/callbacks'; -import { broadcastMessageSentEvent } from '../../../../server/modules/watchers/lib/messages'; +import { broadcastMessageFromData } from '../../../../server/modules/watchers/lib/messages'; import { deleteRoom } from '../../../lib/server/functions/deleteRoom'; const updateAndNotifyParentRoomWithParentMessage = async (room: IRoom): Promise => { @@ -11,10 +10,9 @@ const updateAndNotifyParentRoomWithParentMessage = async (room: IRoom): Promise< if (!parentMessage) { return; } - void broadcastMessageSentEvent({ + void broadcastMessageFromData({ id: parentMessage._id, data: parentMessage, - broadcastCallback: (message) => api.broadcast('message.sent', message), }); }; diff --git a/apps/meteor/app/dolphin/client/lib.ts b/apps/meteor/app/dolphin/client/lib.ts index c04ee1b7859d4b0b199185afe94aa0189847760d..31a767dd555619277ad3c79bd30842357ace1ba5 100644 --- a/apps/meteor/app/dolphin/client/lib.ts +++ b/apps/meteor/app/dolphin/client/lib.ts @@ -1,7 +1,7 @@ import { Meteor } from 'meteor/meteor'; import { Tracker } from 'meteor/tracker'; -import { CustomOAuth } from '../../custom-oauth/client/custom_oauth_client'; +import { CustomOAuth } from '../../custom-oauth/client/CustomOAuth'; import { settings } from '../../settings/client'; const config = { diff --git a/apps/meteor/app/drupal/client/lib.ts b/apps/meteor/app/drupal/client/lib.ts index 9edbb560a450ba829aafbc3aceebbcbacc4d63fc..f477a326d7062d192dbf5613b8ff01bc3b3bfd2f 100644 --- a/apps/meteor/app/drupal/client/lib.ts +++ b/apps/meteor/app/drupal/client/lib.ts @@ -2,7 +2,7 @@ import type { OauthConfig } from '@rocket.chat/core-typings'; import { Meteor } from 'meteor/meteor'; import { Tracker } from 'meteor/tracker'; -import { CustomOAuth } from '../../custom-oauth/client/custom_oauth_client'; +import { CustomOAuth } from '../../custom-oauth/client/CustomOAuth'; import { settings } from '../../settings/client'; // Drupal Server CallBack URL needs to be http(s)://{rocketchat.server}[:port]/_oauth/drupal diff --git a/apps/meteor/app/e2e/client/rocketchat.e2e.room.js b/apps/meteor/app/e2e/client/rocketchat.e2e.room.js index f64b243e0d88232f9527e462179be9929d82bc43..bd0863d691a9abf7195f792d1bff38c8459374a0 100644 --- a/apps/meteor/app/e2e/client/rocketchat.e2e.room.js +++ b/apps/meteor/app/e2e/client/rocketchat.e2e.room.js @@ -7,7 +7,6 @@ import { RoomManager } from '../../../client/lib/RoomManager'; import { roomCoordinator } from '../../../client/lib/rooms/roomCoordinator'; import { RoomSettingsEnum } from '../../../definition/IRoomTypeConfig'; import { ChatRoom, Subscriptions, Messages } from '../../models/client'; -import { Notifications } from '../../notifications/client'; import { sdk } from '../../utils/client/lib/SDKClient'; import { E2ERoomState } from './E2ERoomState'; import { @@ -240,7 +239,7 @@ export class E2ERoom extends Emitter { this.setState(E2ERoomState.WAITING_KEYS); this.log('Requesting room key'); - Notifications.notifyUsersOfRoom(this.roomId, 'e2ekeyRequest', this.roomId, room.e2eKeyId); + sdk.publish('notify-room-users', [`${this.roomId}/e2ekeyRequest`, this.roomId, room.e2eKeyId]); } catch (error) { // this.error = error; this.setState(E2ERoomState.ERROR); diff --git a/apps/meteor/app/emoji-custom/client/index.ts b/apps/meteor/app/emoji-custom/client/index.ts index d8a1b75e275dbf39909db039ab703585f1042f90..780a12a3898f5e2b9434d30324f25df272ee7acd 100644 --- a/apps/meteor/app/emoji-custom/client/index.ts +++ b/apps/meteor/app/emoji-custom/client/index.ts @@ -1,3 +1 @@ import './lib/emojiCustom'; -import './notifications/deleteEmojiCustom'; -import './notifications/updateEmojiCustom'; diff --git a/apps/meteor/app/emoji-custom/client/notifications/deleteEmojiCustom.ts b/apps/meteor/app/emoji-custom/client/notifications/deleteEmojiCustom.ts deleted file mode 100644 index 0ee00976956ea4751aeb84a5a4e5d5a67c3be900..0000000000000000000000000000000000000000 --- a/apps/meteor/app/emoji-custom/client/notifications/deleteEmojiCustom.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { Meteor } from 'meteor/meteor'; - -import { Notifications } from '../../../notifications/client'; -import { deleteEmojiCustom } from '../lib/emojiCustom'; - -Meteor.startup(() => Notifications.onLogged('deleteEmojiCustom', (data) => deleteEmojiCustom(data.emojiData))); diff --git a/apps/meteor/app/emoji-custom/client/notifications/updateEmojiCustom.ts b/apps/meteor/app/emoji-custom/client/notifications/updateEmojiCustom.ts deleted file mode 100644 index 326edef0fa6c1433efc28e365229f245afd3060b..0000000000000000000000000000000000000000 --- a/apps/meteor/app/emoji-custom/client/notifications/updateEmojiCustom.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { Meteor } from 'meteor/meteor'; - -import { Notifications } from '../../../notifications/client'; -import { updateEmojiCustom } from '../lib/emojiCustom'; - -Meteor.startup(() => Notifications.onLogged('updateEmojiCustom', (data) => updateEmojiCustom(data.emojiData))); diff --git a/apps/meteor/app/federation/server/endpoints/dispatch.js b/apps/meteor/app/federation/server/endpoints/dispatch.js index e54441a7aa9db4d94b4a14367fe3b46f011a36b3..4b3e148bbdad1b16dce6f9442a16f1b08bfcf4d8 100644 --- a/apps/meteor/app/federation/server/endpoints/dispatch.js +++ b/apps/meteor/app/federation/server/endpoints/dispatch.js @@ -3,7 +3,7 @@ import { eventTypes } from '@rocket.chat/core-typings'; import { FederationServers, FederationRoomEvents, Rooms, Messages, Subscriptions, Users, ReadReceipts } from '@rocket.chat/models'; import EJSON from 'ejson'; -import { broadcastMessageSentEvent } from '../../../../server/modules/watchers/lib/messages'; +import { broadcastMessageFromData } from '../../../../server/modules/watchers/lib/messages'; import { API } from '../../../api/server'; import { FileUpload } from '../../../file-upload/server'; import { deleteRoom } from '../../../lib/server/functions/deleteRoom'; @@ -284,10 +284,9 @@ const eventHandlers = { } } if (messageForNotification) { - void broadcastMessageSentEvent({ + void broadcastMessageFromData({ id: messageForNotification._id, data: messageForNotification, - broadcastCallback: (message) => api.broadcast('message.sent', message), }); } } @@ -316,14 +315,13 @@ const eventHandlers = { } else { // Update the message await Messages.updateOne({ _id: persistedMessage._id }, { $set: { msg: message.msg, federation: message.federation } }); - void broadcastMessageSentEvent({ + void broadcastMessageFromData({ id: persistedMessage._id, data: { ...persistedMessage, msg: message.msg, federation: message.federation, }, - broadcastCallback: (message) => api.broadcast('message.sent', message), }); } } @@ -387,7 +385,7 @@ const eventHandlers = { // Update the property await Messages.updateOne({ _id: messageId }, { $set: { [`reactions.${reaction}`]: reactionObj } }); - void broadcastMessageSentEvent({ + void broadcastMessageFromData({ id: persistedMessage._id, data: { ...persistedMessage, @@ -396,7 +394,6 @@ const eventHandlers = { [reaction]: reactionObj, }, }, - broadcastCallback: (message) => api.broadcast('message.sent', message), }); } @@ -446,7 +443,7 @@ const eventHandlers = { // Otherwise, update the property await Messages.updateOne({ _id: messageId }, { $set: { [`reactions.${reaction}`]: reactionObj } }); } - void broadcastMessageSentEvent({ + void broadcastMessageFromData({ id: persistedMessage._id, data: { ...persistedMessage, @@ -455,7 +452,6 @@ const eventHandlers = { [reaction]: reactionObj, }, }, - broadcastCallback: (message) => api.broadcast('message.sent', message), }); } diff --git a/apps/meteor/app/file-upload/server/methods/sendFileMessage.ts b/apps/meteor/app/file-upload/server/methods/sendFileMessage.ts index d922b5adab7855e621704a282af15b5aa1804fd6..255bd4ee7c79df134efe1fef16e97a9ebd6b8af5 100644 --- a/apps/meteor/app/file-upload/server/methods/sendFileMessage.ts +++ b/apps/meteor/app/file-upload/server/methods/sendFileMessage.ts @@ -1,4 +1,4 @@ -import type { MessageAttachment, FileAttachmentProps, IUser, IUpload, AtLeast } from '@rocket.chat/core-typings'; +import type { MessageAttachment, FileAttachmentProps, IUser, IUpload, AtLeast, FilesAndAttachments } from '@rocket.chat/core-typings'; import { Rooms, Uploads, Users } from '@rocket.chat/models'; import type { ServerMethods } from '@rocket.chat/ui-contexts'; import { Match, check } from 'meteor/check'; @@ -25,7 +25,7 @@ export const parseFileIntoMessageAttachments = async ( file: Partial, roomId: string, user: IUser, -): Promise> => { +): Promise => { validateFileRequiredFields(file); await Uploads.updateFileComplete(file._id, user._id, omit(file, '_id')); @@ -37,8 +37,10 @@ export const parseFileIntoMessageAttachments = async ( const files = [ { _id: file._id, - name: file.name, - type: file.type, + name: file.name || '', + type: file.type || 'file', + size: file.size || 0, + format: file.identify?.format || '', }, ]; @@ -73,8 +75,10 @@ export const parseFileIntoMessageAttachments = async ( }; files.push({ _id: thumbnail._id, - name: file.name, - type: thumbnail.type, + name: file.name || '', + type: thumbnail.type || 'file', + size: thumbnail.size || 0, + format: thumbnail.identify?.format || '', }); } } catch (e) { diff --git a/apps/meteor/app/github-enterprise/client/lib.ts b/apps/meteor/app/github-enterprise/client/lib.ts index ec03985df0cfc30f975f2e41c99f17a52cdc5f1e..97b9e68677992dc5ef1c4fa622a051575a3d7577 100644 --- a/apps/meteor/app/github-enterprise/client/lib.ts +++ b/apps/meteor/app/github-enterprise/client/lib.ts @@ -2,7 +2,7 @@ import type { OauthConfig } from '@rocket.chat/core-typings'; import { Meteor } from 'meteor/meteor'; import { Tracker } from 'meteor/tracker'; -import { CustomOAuth } from '../../custom-oauth/client/custom_oauth_client'; +import { CustomOAuth } from '../../custom-oauth/client/CustomOAuth'; import { settings } from '../../settings/client'; // GitHub Enterprise Server CallBack URL needs to be http(s)://{rocketchat.server}[:port]/_oauth/github_enterprise diff --git a/apps/meteor/app/gitlab/client/lib.ts b/apps/meteor/app/gitlab/client/lib.ts index a1b2ded0cc1a80abfa782610f6b169f0ed63b5ea..518478f91227edbb184bff1efc8f5f77f40583db 100644 --- a/apps/meteor/app/gitlab/client/lib.ts +++ b/apps/meteor/app/gitlab/client/lib.ts @@ -2,7 +2,7 @@ import type { OauthConfig } from '@rocket.chat/core-typings'; import { Meteor } from 'meteor/meteor'; import { Tracker } from 'meteor/tracker'; -import { CustomOAuth } from '../../custom-oauth/client/custom_oauth_client'; +import { CustomOAuth } from '../../custom-oauth/client/CustomOAuth'; import { settings } from '../../settings/client'; const config: OauthConfig = { diff --git a/apps/meteor/app/importer-pending-files/server/PendingFileImporter.ts b/apps/meteor/app/importer-pending-files/server/PendingFileImporter.ts index 657a64002a5a9bfda3a06903c59270656a7e8463..da85e9b732963af96e981110965a33cd3185d29b 100644 --- a/apps/meteor/app/importer-pending-files/server/PendingFileImporter.ts +++ b/apps/meteor/app/importer-pending-files/server/PendingFileImporter.ts @@ -1,6 +1,7 @@ import http from 'http'; import https from 'https'; +import { api } from '@rocket.chat/core-services'; import type { IImport, MessageAttachment, IUpload } from '@rocket.chat/core-typings'; import { Messages } from '@rocket.chat/models'; import { Random } from '@rocket.chat/random'; @@ -80,6 +81,7 @@ export class PendingFileImporter extends Importer { try { const pendingFileMessageList = Messages.findAllImportedMessagesWithFilesToDownload(); + const importedRoomIds = new Set(); for await (const message of pendingFileMessageList) { try { const { _importFile } = message; @@ -140,6 +142,7 @@ export class PendingFileImporter extends Importer { await Messages.setImportFileRocketChatAttachment(_importFile.id, url, attachment); await completeFile(details); + importedRoomIds.add(message.rid); } catch (error) { await completeFile(details); logError(error); @@ -150,6 +153,8 @@ export class PendingFileImporter extends Importer { this.logger.error(error); } } + + void api.broadcast('notify.importedMessages', { roomIds: Array.from(importedRoomIds) }); } catch (error) { // If the cursor expired, restart the method if (this.isCursorNotFoundError(error)) { diff --git a/apps/meteor/app/importer-slack/server/SlackImporter.ts b/apps/meteor/app/importer-slack/server/SlackImporter.ts index 7d56c7784b76bbdde4fdc395e36553ae823d50a9..0ef81c69a1e06c7a9cd6af124b96ad07208975a4 100644 --- a/apps/meteor/app/importer-slack/server/SlackImporter.ts +++ b/apps/meteor/app/importer-slack/server/SlackImporter.ts @@ -408,7 +408,7 @@ export class SlackImporter extends Importer { parseMentions(newMessage: IImportMessage): void { const mentionsParser = new MentionsParser({ pattern: () => '[0-9a-zA-Z]+', - useRealName: () => settings.get('UI_Use_Real_Name'), + useRealName: () => settings.get('UI_Use_Real_Name'), me: () => 'me', }); diff --git a/apps/meteor/app/importer/server/classes/ImportDataConverter.ts b/apps/meteor/app/importer/server/classes/ImportDataConverter.ts index fff8e8c9efd484da6bf8912d2c368f4e43af3a92..1ce40b9415e5ca8cb60c43bfc9ee187b043f208d 100644 --- a/apps/meteor/app/importer/server/classes/ImportDataConverter.ts +++ b/apps/meteor/app/importer/server/classes/ImportDataConverter.ts @@ -715,7 +715,12 @@ export class ImportDataConverter { return ImportData.getAllMessages().toArray(); } - async convertMessages({ beforeImportFn, afterImportFn, onErrorFn }: IConversionCallbacks = {}): Promise { + async convertMessages({ + beforeImportFn, + afterImportFn, + onErrorFn, + afterImportAllMessagesFn, + }: IConversionCallbacks & { afterImportAllMessagesFn?: (roomIds: string[]) => Promise }): Promise { const rids: Array = []; const messages = await this.getMessagesToImport(); @@ -740,7 +745,6 @@ export class ImportDataConverter { this._logger.warn(`Imported user not found: ${data.u._id}`); throw new Error('importer-message-unknown-user'); } - const rid = await this.findImportedRoomId(data.rid); if (!rid) { throw new Error('importer-message-unknown-room'); @@ -807,12 +811,15 @@ export class ImportDataConverter { for await (const rid of rids) { try { - await Rooms.resetLastMessageById(rid); + await Rooms.resetLastMessageById(rid, null); } catch (e) { this._logger.warn(`Failed to update last message of room ${rid}`); this._logger.error(e); } } + if (afterImportAllMessagesFn) { + await afterImportAllMessagesFn(rids); + } } async updateRoom(room: IRoom, roomData: IImportChannel, startedByUserId: string): Promise { diff --git a/apps/meteor/app/importer/server/classes/Importer.ts b/apps/meteor/app/importer/server/classes/Importer.ts index 92f506b379ad08e109778c2c65c7d0592b753447..68a12513a06c6d4b7ac03abb7ecf5098e55d2a6a 100644 --- a/apps/meteor/app/importer/server/classes/Importer.ts +++ b/apps/meteor/app/importer/server/classes/Importer.ts @@ -1,3 +1,4 @@ +import { api } from '@rocket.chat/core-services'; import type { IImport, IImportRecord, IImportChannel, IImportUser, IImportProgress } from '@rocket.chat/core-typings'; import { Logger } from '@rocket.chat/logger'; import { Settings, ImportData, Imports } from '@rocket.chat/models'; @@ -170,6 +171,9 @@ export class Importer { } }; + const afterImportAllMessagesFn = async (importedRoomIds: string[]): Promise => + api.broadcast('notify.importedMessages', { roomIds: importedRoomIds }); + const afterBatchFn = async (successCount: number, errorCount: number) => { if (successCount) { await this.addCountCompleted(successCount); @@ -203,7 +207,7 @@ export class Importer { await this.converter.convertChannels(startedByUserId, { beforeImportFn, afterImportFn, onErrorFn }); await this.updateProgress(ProgressStep.IMPORTING_MESSAGES); - await this.converter.convertMessages({ afterImportFn, onErrorFn }); + await this.converter.convertMessages({ afterImportFn, onErrorFn, afterImportAllMessagesFn }); await this.updateProgress(ProgressStep.FINISHING); diff --git a/apps/meteor/app/integrations/server/lib/triggerHandler.js b/apps/meteor/app/integrations/server/lib/triggerHandler.js index b5050b8c4716d7a8e14296b83a55e741daa5f5cc..07f7a3d903a2467c13bc54026729de847494fd91 100644 --- a/apps/meteor/app/integrations/server/lib/triggerHandler.js +++ b/apps/meteor/app/integrations/server/lib/triggerHandler.js @@ -503,6 +503,7 @@ class RocketChatIntegrationHandler { { method: opts.method, headers: opts.headers, + ...(opts.timeout && { timeout: opts.timeout }), ...(opts.data && { body: opts.data }), }, settings.get('Allow_Invalid_SelfSigned_Certs'), diff --git a/apps/meteor/app/lib/client/methods/sendMessage.ts b/apps/meteor/app/lib/client/methods/sendMessage.ts index 65da03ac0e6e098f4bed1e6cf043de2221423cc7..e824c9e491afc8cab287e570fe6a86d94dd1e5e4 100644 --- a/apps/meteor/app/lib/client/methods/sendMessage.ts +++ b/apps/meteor/app/lib/client/methods/sendMessage.ts @@ -41,7 +41,6 @@ Meteor.methods({ return; } - message = await callbacks.run('beforeSaveMessage', message); await onClientMessageReceived(message as IMessage).then((message) => { ChatMessage.insert(message); return callbacks.run('afterSaveMessage', message, room); diff --git a/apps/meteor/app/lib/server/functions/cleanRoomHistory.ts b/apps/meteor/app/lib/server/functions/cleanRoomHistory.ts index 64202ab39dabc6f16b04a857a7f2104632ce3a01..133eba555a6965d190b837930efe7ca93e74b96c 100644 --- a/apps/meteor/app/lib/server/functions/cleanRoomHistory.ts +++ b/apps/meteor/app/lib/server/functions/cleanRoomHistory.ts @@ -111,13 +111,16 @@ export async function cleanRoomHistory({ if (count) { const lastMessage = await Messages.getLastVisibleMessageSentWithNoTypeByRoomId(rid); - await Rooms.resetLastMessageById(rid, lastMessage); + + await Rooms.resetLastMessageById(rid, lastMessage, -count); + void api.broadcast('notify.deleteMessageBulk', rid, { rid, excludePinned, ignoreDiscussion, ts, users: fromUsers, + ids: selectedMessageIds, }); } return count; diff --git a/apps/meteor/app/lib/server/functions/createDirectRoom.ts b/apps/meteor/app/lib/server/functions/createDirectRoom.ts index d76fd4b34507c8d2b4995b6b6feeb15d260dc1ad..b8383875444fe10602007e6e8824c23e2143c992 100644 --- a/apps/meteor/app/lib/server/functions/createDirectRoom.ts +++ b/apps/meteor/app/lib/server/functions/createDirectRoom.ts @@ -48,9 +48,17 @@ export async function createDirectRoom( subscriptionExtra?: ISubscriptionExtraData; }, ): Promise { - if (members.length > (settings.get('DirectMesssage_maxUsers') || 1)) { - throw new Error('error-direct-message-max-user-exceeded'); + const maxUsers = settings.get('DirectMesssage_maxUsers') || 1; + if (members.length > maxUsers) { + throw new Meteor.Error( + 'error-direct-message-max-user-exceeded', + `You cannot add more than ${maxUsers} users, including yourself to a direct message`, + { + method: 'createDirectRoom', + }, + ); } + await callbacks.run('beforeCreateDirectRoom', members); const membersUsernames: string[] = members diff --git a/apps/meteor/app/lib/server/functions/deleteMessage.ts b/apps/meteor/app/lib/server/functions/deleteMessage.ts index c197f93518ce08e4b76969f77bbb27a43f64405b..cd4456b24514cd1a99be59a2880011f6abf5b227 100644 --- a/apps/meteor/app/lib/server/functions/deleteMessage.ts +++ b/apps/meteor/app/lib/server/functions/deleteMessage.ts @@ -5,7 +5,7 @@ import { Meteor } from 'meteor/meteor'; import { Apps } from '../../../../ee/server/apps'; import { callbacks } from '../../../../lib/callbacks'; -import { broadcastMessageSentEvent } from '../../../../server/modules/watchers/lib/messages'; +import { broadcastMessageFromData } from '../../../../server/modules/watchers/lib/messages'; import { canDeleteMessageAsync } from '../../../authorization/server/functions/canDeleteMessage'; import { FileUpload } from '../../../file-upload/server'; import { settings } from '../../../settings/server'; @@ -79,22 +79,19 @@ export async function deleteMessage(message: IMessage, user: IUser): Promise api.broadcast('message.sent', message), }); } diff --git a/apps/meteor/app/lib/server/functions/deleteUser.ts b/apps/meteor/app/lib/server/functions/deleteUser.ts index 7a01dab2fcd13fb8217ce4d461b4dc32ad39987a..24ef854d48f3a1d35a42637473ce8cb57654338e 100644 --- a/apps/meteor/app/lib/server/functions/deleteUser.ts +++ b/apps/meteor/app/lib/server/functions/deleteUser.ts @@ -25,6 +25,13 @@ import { relinquishRoomOwnerships } from './relinquishRoomOwnerships'; import { updateGroupDMsName } from './updateGroupDMsName'; export async function deleteUser(userId: string, confirmRelinquish = false, deletedBy?: IUser['_id']): Promise { + if (userId === 'rocket.cat') { + throw new Meteor.Error('error-action-not-allowed', 'Deleting the rocket.cat user is not allowed', { + method: 'deleteUser', + action: 'Delete_user', + }); + } + const user = await Users.findOneById(userId, { projection: { username: 1, avatarOrigin: 1, roles: 1, federated: 1 }, }); diff --git a/apps/meteor/app/lib/server/functions/notifications/desktop.ts b/apps/meteor/app/lib/server/functions/notifications/desktop.ts index 2d06cc51f222dddf15a5dcee379fb6b0d299f31e..1afac17f9f58a21f4833b53e78c99054550b740c 100644 --- a/apps/meteor/app/lib/server/functions/notifications/desktop.ts +++ b/apps/meteor/app/lib/server/functions/notifications/desktop.ts @@ -1,5 +1,5 @@ import { api } from '@rocket.chat/core-services'; -import type { IMessage, IRoom, IUser } from '@rocket.chat/core-typings'; +import type { IMessage, IRoom, IUser, AtLeast } from '@rocket.chat/core-typings'; import { roomCoordinator } from '../../../../../server/lib/rooms/roomCoordinator'; import { metrics } from '../../../../metrics/server'; @@ -24,10 +24,10 @@ export async function notifyDesktopUser({ notificationMessage, }: { userId: string; - user: IUser; - message: IMessage; + user: AtLeast; + message: IMessage | Pick; room: IRoom; - duration: number; + duration?: number; notificationMessage: string; }): Promise { const { title, text, name } = await roomCoordinator @@ -39,14 +39,22 @@ export async function notifyDesktopUser({ text, duration, payload: { - _id: message._id, - rid: message.rid, - tmid: message.tmid, + _id: '', + rid: '', + tmid: '', + ...('_id' in message && { + // TODO: omnichannel is not sending _id, rid, tmid + _id: message._id, + rid: message.rid, + tmid: message.tmid, + }), sender: message.u, type: room.t, message: { - msg: message.msg, - t: message.t, + msg: 'msg' in message ? message.msg : '', + ...('t' in message && { + t: message.t, + }), }, name, }, @@ -73,7 +81,7 @@ export function shouldNotifyDesktop({ disableAllMessageNotifications: boolean; status: string; statusConnection: string; - desktopNotifications: string; + desktopNotifications: string | undefined; hasMentionToAll: boolean; hasMentionToHere: boolean; isHighlighted: boolean; @@ -82,7 +90,7 @@ export function shouldNotifyDesktop({ roomType: string; isThread: boolean; }): boolean { - if (disableAllMessageNotifications && desktopNotifications == null && !isHighlighted && !hasMentionToUser && !hasReplyToThread) { + if (disableAllMessageNotifications && !desktopNotifications && !isHighlighted && !hasMentionToUser && !hasReplyToThread) { return false; } @@ -105,6 +113,6 @@ export function shouldNotifyDesktop({ isHighlighted || desktopNotifications === 'all' || hasMentionToUser) && - (!isThread || hasReplyToThread) + (isHighlighted || !isThread || hasReplyToThread) ); } diff --git a/apps/meteor/app/lib/server/functions/notifications/email.js b/apps/meteor/app/lib/server/functions/notifications/email.js index b0953293ac73646dc610338a437055694e82291d..dfc6a1716703abef03e7aec5328ca645d1e29a8f 100644 --- a/apps/meteor/app/lib/server/functions/notifications/email.js +++ b/apps/meteor/app/lib/server/functions/notifications/email.js @@ -224,6 +224,6 @@ export function shouldNotifyEmail({ emailNotifications === 'all' || hasMentionToUser || (!disableAllMessageNotifications && hasMentionToAll)) && - (!isThread || hasReplyToThread) + (isHighlighted || !isThread || hasReplyToThread) ); } diff --git a/apps/meteor/app/lib/server/functions/notifications/index.ts b/apps/meteor/app/lib/server/functions/notifications/index.ts index 11e4418c4510dcb237b51075bbf3595eb3d867f1..54b18f502ae7045b8e26f5e0edca1b2dd9f1ade4 100644 --- a/apps/meteor/app/lib/server/functions/notifications/index.ts +++ b/apps/meteor/app/lib/server/functions/notifications/index.ts @@ -11,7 +11,11 @@ import { settings } from '../../../../settings/server'; * * @param {object} message the message to be parsed */ -export async function parseMessageTextPerUser(messageText: string, message: IMessage, receiver: IUser): Promise { +export async function parseMessageTextPerUser( + messageText: string, + message: Pick, + receiver: Pick, +): Promise { const lng = receiver.language || settings.get('Language') || 'en'; const firstAttachment = message.attachments?.[0]; @@ -36,9 +40,6 @@ export async function parseMessageTextPerUser(messageText: string, message: IMes * @returns {string} */ export function replaceMentionedUsernamesWithFullNames(message: string, mentions: NonNullable): string { - if (!mentions?.length) { - return message; - } mentions.forEach((mention) => { if (mention.name) { message = message.replace(new RegExp(escapeRegExp(`@${mention.username}`), 'g'), mention.name); @@ -55,7 +56,7 @@ export function replaceMentionedUsernamesWithFullNames(message: string, mentions * * @returns {boolean} */ -export function messageContainsHighlight(message: IMessage, highlights: string[]): boolean { +export function messageContainsHighlight(message: Pick, highlights: string[] | undefined): boolean { if (!highlights || highlights.length === 0) { return false; } diff --git a/apps/meteor/app/lib/server/functions/notifications/mobile.js b/apps/meteor/app/lib/server/functions/notifications/mobile.js index b4139bea6f12efe6f55897704e5e3113c4f769d2..cf8b526a6abcd9ab3d9c2d06055f280e7d884e75 100644 --- a/apps/meteor/app/lib/server/functions/notifications/mobile.js +++ b/apps/meteor/app/lib/server/functions/notifications/mobile.js @@ -1,6 +1,7 @@ import { Subscriptions } from '@rocket.chat/models'; import { i18n } from '../../../../../server/lib/i18n'; +import { isRoomCompatibleWithVideoConfRinging } from '../../../../../server/lib/isRoomCompatibleWithVideoConfRinging'; import { roomCoordinator } from '../../../../../server/lib/rooms/roomCoordinator'; import { settings } from '../../../../settings/server'; @@ -73,6 +74,9 @@ export function shouldNotifyMobile({ hasReplyToThread, roomType, isThread, + isVideoConf, + userPreferences, + roomUids, }) { if (settings.get('Push_enable') !== true) { return false; @@ -86,6 +90,16 @@ export function shouldNotifyMobile({ return false; } + // If the user is going to receive a ringing push notification, do not send another push for the message generated by that videoconference + if ( + isVideoConf && + settings.get('VideoConf_Mobile_Ringing') && + isRoomCompatibleWithVideoConfRinging(roomType, roomUids) && + (userPreferences?.enableMobileRinging ?? settings.get(`Accounts_Default_User_Preferences_enableMobileRinging`)) + ) { + return false; + } + if (!mobilePushNotifications) { if (settings.get('Accounts_Default_User_Preferences_pushNotifications') === 'all' && (!isThread || hasReplyToThread)) { return true; @@ -101,6 +115,6 @@ export function shouldNotifyMobile({ isHighlighted || mobilePushNotifications === 'all' || hasMentionToUser) && - (!isThread || hasReplyToThread) + (isHighlighted || !isThread || hasReplyToThread) ); } diff --git a/apps/meteor/app/lib/server/functions/sendMessage.js b/apps/meteor/app/lib/server/functions/sendMessage.ts similarity index 63% rename from apps/meteor/app/lib/server/functions/sendMessage.js rename to apps/meteor/app/lib/server/functions/sendMessage.ts index e2f45d38fcbccf38e2a3be414f2a075f54b09130..486f7c360f99633cbad4afe2c69b82b58f886c18 100644 --- a/apps/meteor/app/lib/server/functions/sendMessage.js +++ b/apps/meteor/app/lib/server/functions/sendMessage.ts @@ -1,4 +1,5 @@ -import { Message, api } from '@rocket.chat/core-services'; +import { Message } from '@rocket.chat/core-services'; +import type { IMessage, IRoom } from '@rocket.chat/core-typings'; import { Messages } from '@rocket.chat/models'; import { Match, check } from 'meteor/check'; @@ -6,13 +7,15 @@ import { Apps } from '../../../../ee/server/apps'; import { callbacks } from '../../../../lib/callbacks'; import { isRelativeURL } from '../../../../lib/utils/isRelativeURL'; import { isURL } from '../../../../lib/utils/isURL'; -import { broadcastMessageSentEvent } from '../../../../server/modules/watchers/lib/messages'; +import { broadcastMessageFromData } from '../../../../server/modules/watchers/lib/messages'; import { hasPermissionAsync } from '../../../authorization/server/functions/hasPermission'; import { FileUpload } from '../../../file-upload/server'; import notifications from '../../../notifications/server/lib/Notifications'; import { settings } from '../../../settings/server'; import { parseUrlsInMessage } from './parseUrlsInMessage'; +// TODO: most of the types here are wrong, but I don't want to change them now + /** * IMPORTANT * @@ -50,13 +53,13 @@ const validPartialURLParam = Match.Where((value) => { return true; }); -const objectMaybeIncluding = (types) => - Match.Where((value) => { +const objectMaybeIncluding = (types: any) => + Match.Where((value: any) => { Object.keys(types).forEach((field) => { if (value[field] != null) { try { check(value[field], types[field]); - } catch (error) { + } catch (error: any) { error.path = field; throw error; } @@ -66,7 +69,7 @@ const objectMaybeIncluding = (types) => return true; }); -const validateAttachmentsFields = (attachmentField) => { +const validateAttachmentsFields = (attachmentField: any) => { check( attachmentField, objectMaybeIncluding({ @@ -81,7 +84,7 @@ const validateAttachmentsFields = (attachmentField) => { } }; -const validateAttachmentsActions = (attachmentActions) => { +const validateAttachmentsActions = (attachmentActions: any) => { check( attachmentActions, objectMaybeIncluding({ @@ -97,7 +100,7 @@ const validateAttachmentsActions = (attachmentActions) => { ); }; -const validateAttachment = (attachment) => { +const validateAttachment = (attachment: any) => { check( attachment, objectMaybeIncluding({ @@ -139,9 +142,9 @@ const validateAttachment = (attachment) => { } }; -const validateBodyAttachments = (attachments) => attachments.map(validateAttachment); +const validateBodyAttachments = (attachments: any[]) => attachments.map(validateAttachment); -export const validateMessage = async (message, room, user) => { +export const validateMessage = async (message: any, room: any, user: any) => { check( message, objectMaybeIncluding({ @@ -171,7 +174,11 @@ export const validateMessage = async (message, room, user) => { } }; -export const prepareMessageObject = function (message, rid, user) { +export function prepareMessageObject( + message: Partial, + rid: IRoom['_id'], + user: { _id: string; username?: string; name?: string }, +): asserts message is IMessage { if (!message.ts) { message.ts = new Date(); } @@ -183,7 +190,7 @@ export const prepareMessageObject = function (message, rid, user) { const { _id, username, name } = user; message.u = { _id, - username, + username: username as string, // FIXME: this is wrong but I don't want to change it now name, }; message.rid = rid; @@ -195,26 +202,12 @@ export const prepareMessageObject = function (message, rid, user) { if (message.ts == null) { message.ts = new Date(); } -}; - -/** - * Clean up the message object before saving on db - * @param {IMessage} message - */ -function cleanupMessageObject(message) { - ['customClass'].forEach((field) => delete message[field]); } /** * Validates and sends the message object. - * @param {IUser} user - * @param {AtLeast} message - * @param {IRoom} room - * @param {boolean} [upsert=false] - * @param {string[]} [previewUrls] - * @returns {Promise} */ -export const sendMessage = async function (user, message, room, upsert = false, previewUrls = undefined) { +export const sendMessage = async function (user: any, message: any, room: any, upsert = false, previewUrls?: string[]) { if (!user || !message || !room._id) { return false; } @@ -222,20 +215,28 @@ export const sendMessage = async function (user, message, room, upsert = false, await validateMessage(message, room, user); prepareMessageObject(message, room._id, user); + if (message.t === 'otr') { + notifications.streamRoomMessage.emit(message.rid, message, user, room); + return message; + } + if (settings.get('Message_Read_Receipt_Enabled')) { message.unread = true; } // For the Rocket.Chat Apps :) - if (Apps && Apps.isLoaded()) { - const prevent = await Apps.getBridges()?.getListenerBridge().messageEvent('IPreMessageSentPrevent', message); + if (Apps?.isLoaded()) { + const listenerBridge = Apps.getBridges()?.getListenerBridge(); + + const prevent = await listenerBridge?.messageEvent('IPreMessageSentPrevent', message); if (prevent) { return; } - let result; - result = await Apps.getBridges()?.getListenerBridge().messageEvent('IPreMessageSentExtend', message); - result = await Apps.getBridges()?.getListenerBridge().messageEvent('IPreMessageSentModify', result); + const result = await listenerBridge?.messageEvent( + 'IPreMessageSentModify', + await listenerBridge?.messageEvent('IPreMessageSentExtend', message), + ); if (typeof result === 'object') { message = Object.assign(message, result); @@ -245,54 +246,47 @@ export const sendMessage = async function (user, message, room, upsert = false, } } - cleanupMessageObject(message); - parseUrlsInMessage(message, previewUrls); - message = await callbacks.run('beforeSaveMessage', message, room); - message = await Message.beforeSave({ message, room, user }); - if (message) { - if (message.t === 'otr') { - const otrStreamer = notifications.streamRoomMessage; - otrStreamer.emit(message.rid, message, user, room); - } else if (message._id && upsert) { - const { _id } = message; - delete message._id; - await Messages.updateOne( - { - _id, - 'u._id': message.u._id, - }, - { $set: message }, - { upsert: true }, - ); - message._id = _id; - } else { - const messageAlreadyExists = message._id && (await Messages.findOneById(message._id, { projection: { _id: 1 } })); - if (messageAlreadyExists) { - return; - } - const result = await Messages.insertOne(message); - message._id = result.insertedId; - } - if (Apps && Apps.isLoaded()) { - // This returns a promise, but it won't mutate anything about the message - // so, we don't really care if it is successful or fails - void Apps.getBridges()?.getListenerBridge().messageEvent('IPostMessageSent', message); - } + if (!message) { + return; + } - /* - Defer other updates as their return is not interesting to the user - */ + if (message._id && upsert) { + const { _id } = message; + delete message._id; + await Messages.updateOne( + { + _id, + 'u._id': message.u._id, + }, + { $set: message }, + { upsert: true }, + ); + message._id = _id; + } else { + const messageAlreadyExists = message._id && (await Messages.findOneById(message._id, { projection: { _id: 1 } })); + if (messageAlreadyExists) { + return; + } + const { insertedId } = await Messages.insertOne(message); + message._id = insertedId; + } - // Execute all callbacks - await callbacks.run('afterSaveMessage', message, room); - void broadcastMessageSentEvent({ - id: message._id, - broadcastCallback: (message) => api.broadcast('message.sent', message), - }); - return message; + if (Apps?.isLoaded()) { + // This returns a promise, but it won't mutate anything about the message + // so, we don't really care if it is successful or fails + void Apps.getBridges()?.getListenerBridge().messageEvent('IPostMessageSent', message); } + + /* Defer other updates as their return is not interesting to the user */ + + // Execute all callbacks + await callbacks.run('afterSaveMessage', message, room); + void broadcastMessageFromData({ + id: message._id, + }); + return message; }; diff --git a/apps/meteor/app/lib/server/functions/updateMessage.ts b/apps/meteor/app/lib/server/functions/updateMessage.ts index 88c0b829e77d0a30dbf9a93f18d70e2ffc3f8dbf..5cfe29ef41aeb800b0b037ef243b9f1fa2449e14 100644 --- a/apps/meteor/app/lib/server/functions/updateMessage.ts +++ b/apps/meteor/app/lib/server/functions/updateMessage.ts @@ -1,11 +1,11 @@ -import { Message, api } from '@rocket.chat/core-services'; +import { Message } from '@rocket.chat/core-services'; import type { IEditedMessage, IMessage, IUser, AtLeast } from '@rocket.chat/core-typings'; import { Messages, Rooms } from '@rocket.chat/models'; import { Meteor } from 'meteor/meteor'; import { Apps } from '../../../../ee/server/apps'; import { callbacks } from '../../../../lib/callbacks'; -import { broadcastMessageSentEvent } from '../../../../server/modules/watchers/lib/messages'; +import { broadcastMessageFromData } from '../../../../server/modules/watchers/lib/messages'; import { settings } from '../../../settings/server'; import { parseUrlsInMessage } from './parseUrlsInMessage'; @@ -55,8 +55,6 @@ export const updateMessage = async function ( return; } - message = await callbacks.run('beforeSaveMessage', message); - // TODO remove type cast message = await Message.beforeSave({ message: message as IMessage, room, user }); @@ -87,10 +85,9 @@ export const updateMessage = async function ( const msg = await Messages.findOneById(_id); if (msg) { await callbacks.run('afterSaveMessage', msg, room, user._id); - void broadcastMessageSentEvent({ + void broadcastMessageFromData({ id: msg._id, data: msg, - broadcastCallback: (message) => api.broadcast('message.sent', message), }); } }); diff --git a/apps/meteor/app/lib/server/index.ts b/apps/meteor/app/lib/server/index.ts index c2d4bdda74723b58cadee7743e7f380b947f7786..80aaa2a64a9ed2b2a95fb32063a45a0990cd3538 100644 --- a/apps/meteor/app/lib/server/index.ts +++ b/apps/meteor/app/lib/server/index.ts @@ -21,6 +21,7 @@ import './methods/createToken'; import './methods/deleteMessage'; import './methods/deleteUserOwnAccount'; import './methods/executeSlashCommandPreview'; +import './startup/mentionUserNotInChannel'; import './methods/getChannelHistory'; import './methods/getRoomJoinCode'; import './methods/getRoomRoles'; diff --git a/apps/meteor/app/lib/server/lib/RateLimiter.js b/apps/meteor/app/lib/server/lib/RateLimiter.js index b8c069ed290e66172885fa5155d86ee9ed0efae2..126b236426dad257d61e05ebf6567cee0b4b4017 100644 --- a/apps/meteor/app/lib/server/lib/RateLimiter.js +++ b/apps/meteor/app/lib/server/lib/RateLimiter.js @@ -7,13 +7,60 @@ export const RateLimiterClass = new (class { if (process.env.TEST_MODE === 'true') { return fn; } - const rateLimiter = new RateLimiter(); + const rateLimiter = new (class extends RateLimiter { + async check(input) { + const reply = { + allowed: true, + timeToReset: 0, + numInvocationsLeft: Infinity, + }; + + const matchedRules = this._findAllMatchingRules(input); + + for await (const rule of matchedRules) { + const ruleResult = await rule.apply(input); + let numInvocations = rule.counters[ruleResult.key]; + + if (ruleResult.timeToNextReset < 0) { + // Reset all the counters since the rule has reset + await rule.resetCounter(); + ruleResult.timeSinceLastReset = new Date().getTime() - rule._lastResetTime; + ruleResult.timeToNextReset = rule.options.intervalTime; + numInvocations = 0; + } + + if (numInvocations > rule.options.numRequestsAllowed) { + // Only update timeToReset if the new time would be longer than the + // previously set time. This is to ensure that if this input triggers + // multiple rules, we return the longest period of time until they can + // successfully make another call + if (reply.timeToReset < ruleResult.timeToNextReset) { + reply.timeToReset = ruleResult.timeToNextReset; + } + reply.allowed = false; + reply.numInvocationsLeft = 0; + reply.ruleId = rule.id; + await rule._executeCallback(reply, input); + } else { + // If this is an allowed attempt and we haven't failed on any of the + // other rules that match, update the reply field. + if (rule.options.numRequestsAllowed - numInvocations < reply.numInvocationsLeft && reply.allowed) { + reply.timeToReset = ruleResult.timeToNextReset; + reply.numInvocationsLeft = rule.options.numRequestsAllowed - numInvocations; + } + reply.ruleId = rule.id; + await rule._executeCallback(reply, input); + } + } + return reply; + } + })(); Object.entries(matchers).forEach(([key, matcher]) => { - matchers[key] = (...args) => Promise.await(matcher(...args)); + matchers[key] = matcher; }); rateLimiter.addRule(matchers, numRequests, timeInterval); - return function (...args) { + return async function (...args) { const match = {}; Object.keys(matchers).forEach((key) => { @@ -21,7 +68,7 @@ export const RateLimiterClass = new (class { }); rateLimiter.increment(match); - const rateLimitResult = rateLimiter.check(match); + const rateLimitResult = await rateLimiter.check(match); if (rateLimitResult.allowed) { return fn.apply(null, args); } diff --git a/apps/meteor/app/lib/server/lib/notifyUsersOnMessage.js b/apps/meteor/app/lib/server/lib/notifyUsersOnMessage.js index b55a272de558c5df02fc36873f266198b61f86ea..ccd06e886b9ffdc57b3327f8c8d1f369202eeec0 100644 --- a/apps/meteor/app/lib/server/lib/notifyUsersOnMessage.js +++ b/apps/meteor/app/lib/server/lib/notifyUsersOnMessage.js @@ -73,7 +73,7 @@ const incUserMentions = async (rid, roomType, uids, unreadCount) => { await Subscriptions.incUserMentionsAndUnreadForRoomIdAndUserIds(rid, uids, 1, incUnread); }; -const getUserIdsFromHighlights = async (rid, message) => { +export const getUserIdsFromHighlights = async (rid, message) => { const highlightOptions = { projection: { 'userHighlights': 1, 'u._id': 1 } }; const subs = await Subscriptions.findByRoomWithUserHighlights(rid, highlightOptions).toArray(); diff --git a/apps/meteor/app/lib/server/lib/sendNotificationsOnMessage.js b/apps/meteor/app/lib/server/lib/sendNotificationsOnMessage.ts similarity index 66% rename from apps/meteor/app/lib/server/lib/sendNotificationsOnMessage.js rename to apps/meteor/app/lib/server/lib/sendNotificationsOnMessage.ts index ce262e4e675653fc3dc334cec3781a2f5e002ab8..44654428ae8fb5a9244dc709531f748c40abc6c2 100644 --- a/apps/meteor/app/lib/server/lib/sendNotificationsOnMessage.js +++ b/apps/meteor/app/lib/server/lib/sendNotificationsOnMessage.ts @@ -1,7 +1,16 @@ -import { Room } from '@rocket.chat/core-services'; +import { + type IMessage, + type ISubscription, + type IUser, + type IRoom, + type NotificationItem, + isEditedMessage, + type AtLeast, +} from '@rocket.chat/core-typings'; import { Subscriptions, Users } from '@rocket.chat/models'; -import { Meteor } from 'meteor/meteor'; +import emojione from 'emojione'; import moment from 'moment'; +import type { RootFilterOperators } from 'mongodb'; import { callbacks } from '../../../../lib/callbacks'; import { roomCoordinator } from '../../../../server/lib/rooms/roomCoordinator'; @@ -14,7 +23,16 @@ import { getEmailData, shouldNotifyEmail } from '../functions/notifications/emai import { getPushData, shouldNotifyMobile } from '../functions/notifications/mobile'; import { getMentions } from './notifyUsersOnMessage'; -let TroubleshootDisableNotifications; +type SubscriptionAggregation = { + receiver: [Pick | null]; +} & Pick< + ISubscription, + 'desktopNotifications' | 'emailNotifications' | 'mobilePushNotifications' | 'muteGroupMentions' | 'name' | 'rid' | 'userHighlights' | 'u' +>; + +type WithRequiredProperty = Type & { + [Property in Key]-?: Type[Property]; +}; export const sendNotification = async ({ subscription, @@ -27,8 +45,20 @@ export const sendNotification = async ({ room, mentionIds, disableAllMessageNotifications, +}: { + subscription: SubscriptionAggregation; + sender: Pick; + + hasReplyToThread: boolean; + hasMentionToAll: boolean; + hasMentionToHere: boolean; + message: AtLeast; + notificationMessage: string; + room: IRoom; + mentionIds: string[]; + disableAllMessageNotifications: boolean; }) => { - if (TroubleshootDisableNotifications === true) { + if (settings.get('Troubleshoot_Disable_Notifications') === true) { return; } @@ -48,12 +78,13 @@ export const sendNotification = async ({ subscription.receiver = [ await Users.findOneById(subscription.u._id, { projection: { - active: 1, - emails: 1, - language: 1, - status: 1, - statusConnection: 1, - username: 1, + 'active': 1, + 'emails': 1, + 'language': 1, + 'status': 1, + 'statusConnection': 1, + 'username': 1, + 'settings.preferences.enableMobileRinging': 1, }, }), ]; @@ -61,6 +92,10 @@ export const sendNotification = async ({ const [receiver] = subscription.receiver; + if (!receiver) { + throw new Error('receiver not found'); + } + const roomType = room.t; // If the user doesn't have permission to view direct messages, don't send notification of direct messages. if (roomType === 'd' && !(await hasPermissionAsync(subscription.u._id, 'view-d-room'))) { @@ -68,6 +103,7 @@ export const sendNotification = async ({ } const isThread = !!message.tmid && !message.tshow; + const isVideoConf = message.t === 'videoconf'; notificationMessage = await parseMessageTextPerUser(notificationMessage, message, receiver); @@ -79,8 +115,8 @@ export const sendNotification = async ({ if ( shouldNotifyDesktop({ disableAllMessageNotifications, - status: receiver.status, - statusConnection: receiver.statusConnection, + status: receiver.status ?? 'offline', + statusConnection: receiver.statusConnection ?? 'offline', desktopNotifications, hasMentionToAll, hasMentionToHere, @@ -100,7 +136,7 @@ export const sendNotification = async ({ }); } - const queueItems = []; + const queueItems: NotificationItem[] = []; if ( shouldNotifyMobile({ @@ -112,6 +148,9 @@ export const sendNotification = async ({ hasReplyToThread, roomType, isThread, + isVideoConf, + userPreferences: receiver.settings?.preferences, + roomUids: room.uids, }) ) { queueItems.push({ @@ -142,12 +181,26 @@ export const sendNotification = async ({ isThread, }) ) { + const messageWithUnicode = message.msg ? emojione.shortnameToUnicode(message.msg) : message.msg; + const firstAttachment = message.attachments?.length && message.attachments.shift(); + + if (firstAttachment) { + firstAttachment.description = + typeof firstAttachment.description === 'string' ? emojione.shortnameToUnicode(firstAttachment.description) : undefined; + firstAttachment.text = typeof firstAttachment.text === 'string' ? emojione.shortnameToUnicode(firstAttachment.text) : undefined; + } + + const attachments = firstAttachment ? [firstAttachment, ...(message.attachments ?? [])].filter(Boolean) : []; for await (const email of receiver.emails) { if (email.verified) { queueItems.push({ type: 'email', data: await getEmailData({ - message, + message: { + ...message, + msg: messageWithUnicode, + ...(attachments.length > 0 ? { attachments } : {}), + }, receiver, sender, subscription, @@ -163,7 +216,7 @@ export const sendNotification = async ({ } if (queueItems.length) { - Notification.scheduleItem({ + void Notification.scheduleItem({ user: receiver, uid: subscription.u._id, rid: room._id, @@ -189,14 +242,15 @@ const project = { 'receiver.status': 1, 'receiver.statusConnection': 1, 'receiver.username': 1, + 'receiver.settings.preferences.enableMobileRinging': 1, }, -}; +} as const; const filter = { $match: { 'receiver.active': true, }, -}; +} as const; const lookup = { $lookup: { @@ -205,10 +259,10 @@ const lookup = { foreignField: '_id', as: 'receiver', }, -}; +} as const; -export async function sendMessageNotifications(message, room, usersInThread = []) { - if (TroubleshootDisableNotifications === true) { +export async function sendMessageNotifications(message: IMessage, room: IRoom, usersInThread: string[] = []) { + if (settings.get('Troubleshoot_Disable_Notifications') === true) { return; } @@ -234,49 +288,48 @@ export async function sendMessageNotifications(message, room, usersInThread = [] let notificationMessage = await callbacks.run('beforeSendMessageNotifications', message.msg); if (mentionIds.length > 0 && settings.get('UI_Use_Real_Name')) { - notificationMessage = replaceMentionedUsernamesWithFullNames(message.msg, message.mentions); + notificationMessage = replaceMentionedUsernamesWithFullNames(message.msg, message.mentions ?? []); } // Don't fetch all users if room exceeds max members - const maxMembersForNotification = settings.get('Notifications_Max_Room_Members'); + const maxMembersForNotification = settings.get('Notifications_Max_Room_Members'); const roomMembersCount = await Users.countRoomMembers(room._id); const disableAllMessageNotifications = roomMembersCount > maxMembersForNotification && maxMembersForNotification !== 0; - const query = { + const query: WithRequiredProperty, '$or'> = { rid: room._id, ignored: { $ne: sender._id }, disableNotifications: { $ne: true }, $or: [{ 'userHighlights.0': { $exists: 1 } }, ...(usersInThread.length > 0 ? [{ 'u._id': { $in: usersInThread } }] : [])], - }; + } as const; - ['audio', 'desktop', 'mobile', 'email'].forEach((kind) => { - const notificationField = `${kind === 'mobile' ? 'mobilePush' : kind}Notifications`; + (['desktop', 'mobile', 'email'] as const).forEach((kind) => { + const notificationField = kind === 'mobile' ? 'mobilePush' : `${kind}Notifications`; - const filter = { [notificationField]: 'all' }; + query.$or.push({ + [notificationField]: 'all', + ...(disableAllMessageNotifications ? { [`${kind}PrefOrigin`]: { $ne: 'user' } } : {}), + }); if (disableAllMessageNotifications) { - filter[`${kind}PrefOrigin`] = { $ne: 'user' }; + return; } - query.$or.push(filter); - - if (mentionIdsWithoutGroups.length > 0) { + if (room.t === 'd') { query.$or.push({ [notificationField]: 'mentions', - 'u._id': { $in: mentionIdsWithoutGroups }, }); - } else if (!disableAllMessageNotifications && (hasMentionToAll || hasMentionToHere)) { + } else if (mentionIdsWithoutGroups.length > 0) { query.$or.push({ [notificationField]: 'mentions', + 'u._id': { $in: mentionIdsWithoutGroups }, }); } const serverField = kind === 'email' ? 'emailNotificationMode' : `${kind}Notifications`; const serverPreference = settings.get(`Accounts_Default_User_Preferences_${serverField}`); - if ( - (room.t === 'd' && serverPreference !== 'nothing') || - (!disableAllMessageNotifications && (serverPreference === 'all' || hasMentionToAll || hasMentionToHere)) - ) { + + if (serverPreference === 'all' || hasMentionToAll || hasMentionToHere || room.t === 'd') { query.$or.push({ [notificationField]: { $exists: false }, }); @@ -291,7 +344,7 @@ export async function sendMessageNotifications(message, room, usersInThread = [] // the find below is crucial. All subscription records returned will receive at least one kind of notification. // the query is defined by the server's default values and Notifications_Max_Room_Members setting. - const subscriptions = await Subscriptions.col.aggregate([{ $match: query }, lookup, filter, project]).toArray(); + const subscriptions = await Subscriptions.col.aggregate([{ $match: query }, lookup, filter, project]).toArray(); subscriptions.forEach( (subscription) => @@ -305,7 +358,7 @@ export async function sendMessageNotifications(message, room, usersInThread = [] room, mentionIds, disableAllMessageNotifications, - hasReplyToThread: usersInThread && usersInThread.includes(subscription.u._id), + hasReplyToThread: usersInThread?.includes(subscription.u._id), }), ); @@ -319,8 +372,8 @@ export async function sendMessageNotifications(message, room, usersInThread = [] }; } -export async function sendAllNotifications(message, room) { - if (TroubleshootDisableNotifications === true) { +export async function sendAllNotifications(message: IMessage, room: IRoom) { + if (settings.get('Troubleshoot_Disable_Notifications') === true) { return message; } @@ -329,11 +382,11 @@ export async function sendAllNotifications(message, room) { return message; } // skips this callback if the message was edited - if (message.editedAt) { + if (isEditedMessage(message)) { return message; } - if (message.ts && Math.abs(moment(message.ts).diff()) > 60000) { + if (message.ts && Math.abs(moment(message.ts).diff(new Date())) > 60000) { return message; } @@ -341,60 +394,12 @@ export async function sendAllNotifications(message, room) { return message; } - const { sender, hasMentionToAll, hasMentionToHere, notificationMessage, mentionIds, mentionIdsWithoutGroups } = - await sendMessageNotifications(message, room); - - // on public channels, if a mentioned user is not member of the channel yet, he will first join the channel and then be notified based on his preferences. - if (room.t === 'c') { - // get subscriptions from users already in room (to not send them a notification) - const mentions = [...mentionIdsWithoutGroups]; - const cursor = Subscriptions.findByRoomIdAndUserIds(room._id, mentionIdsWithoutGroups, { - projection: { 'u._id': 1 }, - }); - - for await (const subscription of cursor) { - const index = mentions.indexOf(subscription.u._id); - if (index !== -1) { - mentions.splice(index, 1); - } - } - - const users = await Promise.all( - mentions.map(async (userId) => { - await Room.join({ room, user: { _id: userId } }); - - return userId; - }), - ).catch((error) => { - throw new Meteor.Error(error); - }); - - const subscriptions = await Subscriptions.findByRoomIdAndUserIds(room._id, users).toArray(); - users.forEach((userId) => { - const subscription = subscriptions.find((subscription) => subscription.u._id === userId); - - void sendNotification({ - subscription, - sender, - hasMentionToAll, - hasMentionToHere, - message, - notificationMessage, - room, - mentionIds, - }); - }); - } + await sendMessageNotifications(message, room); return message; } settings.watch('Troubleshoot_Disable_Notifications', (value) => { - if (TroubleshootDisableNotifications === value) { - return; - } - TroubleshootDisableNotifications = value; - if (value) { return callbacks.remove('afterSaveMessage', 'sendNotificationsOnMessage'); } diff --git a/apps/meteor/app/lib/server/methods/addOAuthService.ts b/apps/meteor/app/lib/server/methods/addOAuthService.ts index abf1b7035af158942cd16f4ed221591929d20ec4..05b0e5a7e4e60c87079f4f98dfd3ba4acea45ba7 100644 --- a/apps/meteor/app/lib/server/methods/addOAuthService.ts +++ b/apps/meteor/app/lib/server/methods/addOAuthService.ts @@ -2,8 +2,8 @@ import type { ServerMethods } from '@rocket.chat/ui-contexts'; import { check } from 'meteor/check'; import { Meteor } from 'meteor/meteor'; +import { addOAuthService } from '../../../../server/lib/oauth/addOAuthService'; import { hasPermissionAsync } from '../../../authorization/server/functions/hasPermission'; -import { addOAuthService } from '../functions/addOAuthService'; declare module '@rocket.chat/ui-contexts' { // eslint-disable-next-line @typescript-eslint/naming-convention diff --git a/apps/meteor/app/lib/server/methods/refreshOAuthService.ts b/apps/meteor/app/lib/server/methods/refreshOAuthService.ts index 9faa67f239a1e73b810596cfc3943e5f26e7fbdc..e5b1c377a33e75cbb1abdeedc76168c4ea76f126 100644 --- a/apps/meteor/app/lib/server/methods/refreshOAuthService.ts +++ b/apps/meteor/app/lib/server/methods/refreshOAuthService.ts @@ -1,8 +1,7 @@ -import { Settings } from '@rocket.chat/models'; import type { ServerMethods } from '@rocket.chat/ui-contexts'; import { Meteor } from 'meteor/meteor'; -import { ServiceConfiguration } from 'meteor/service-configuration'; +import { refreshLoginServices } from '../../../../server/lib/refreshLoginServices'; import { hasPermissionAsync } from '../../../authorization/server/functions/hasPermission'; declare module '@rocket.chat/ui-contexts' { @@ -29,8 +28,6 @@ Meteor.methods({ }); } - await ServiceConfiguration.configurations.removeAsync({}); - - await Settings.update({ _id: /^(Accounts_OAuth_|SAML_|CAS_).+/ }, { $set: { _updatedAt: new Date() } }, { multi: true }); + await refreshLoginServices(); }, }); diff --git a/apps/meteor/app/lib/server/oauth/oauth.js b/apps/meteor/app/lib/server/oauth/oauth.js index 2618a6e7a569bbe0706b1a3a253005d7c9f781d8..27342416bedbd006f7c2c3e74fbf1e1231f1321b 100644 --- a/apps/meteor/app/lib/server/oauth/oauth.js +++ b/apps/meteor/app/lib/server/oauth/oauth.js @@ -36,7 +36,7 @@ Accounts.registerLoginHandler(async (options) => { // Make sure we're configured if (!(await ServiceConfiguration.configurations.findOneAsync({ service: options.serviceName }))) { - throw new ServiceConfiguration.ConfigError(); + throw new Accounts.ConfigError(); } if (!_.contains(Accounts.oauth.serviceNames(), service.serviceName)) { diff --git a/apps/meteor/app/lib/server/startup/index.ts b/apps/meteor/app/lib/server/startup/index.ts index d4e5183ad7f5aa8f0da76f3cbe6b01e0ac7f33e3..deadb8a44c06a027dd182070a7b3dd28f2913e04 100644 --- a/apps/meteor/app/lib/server/startup/index.ts +++ b/apps/meteor/app/lib/server/startup/index.ts @@ -1,4 +1,3 @@ -import './oAuthServicesUpdate'; import './rateLimiter'; import './robots'; import './settingsOnLoadCdnPrefix'; diff --git a/apps/meteor/app/lib/server/startup/mentionUserNotInChannel.ts b/apps/meteor/app/lib/server/startup/mentionUserNotInChannel.ts new file mode 100644 index 0000000000000000000000000000000000000000..160defcb94edc816b2bf1643cae32ec97eba2725 --- /dev/null +++ b/apps/meteor/app/lib/server/startup/mentionUserNotInChannel.ts @@ -0,0 +1,141 @@ +import { api } from '@rocket.chat/core-services'; +import type { IMessage } from '@rocket.chat/core-typings'; +import { isDirectMessageRoom, isEditedMessage, isOmnichannelRoom, isRoomFederated } from '@rocket.chat/core-typings'; +import { Subscriptions, Users } from '@rocket.chat/models'; +import type { ActionsBlock } from '@rocket.chat/ui-kit'; +import moment from 'moment'; + +import { callbacks } from '../../../../lib/callbacks'; +import { getUserDisplayName } from '../../../../lib/getUserDisplayName'; +import { isTruthy } from '../../../../lib/isTruthy'; +import { i18n } from '../../../../server/lib/i18n'; +import { hasPermissionAsync } from '../../../authorization/server/functions/hasPermission'; +import { settings } from '../../../settings/server'; + +const APP_ID = 'mention-core'; +const getBlocks = (mentions: IMessage['mentions'], messageId: string, lng: string | undefined) => { + const stringifiedMentions = JSON.stringify(mentions); + return { + addUsersBlock: { + type: 'button', + appId: APP_ID, + blockId: messageId, + value: stringifiedMentions, + actionId: 'add-users', + text: { + type: 'plain_text', + text: i18n.t('Add_them', undefined, lng), + }, + }, + dismissBlock: { + type: 'button', + appId: APP_ID, + blockId: messageId, + value: stringifiedMentions, + actionId: 'dismiss', + text: { + type: 'plain_text', + text: i18n.t('Do_nothing', undefined, lng), + }, + }, + dmBlock: { + type: 'button', + appId: APP_ID, + value: stringifiedMentions, + blockId: messageId, + actionId: 'share-message', + text: { + type: 'plain_text', + text: i18n.t('Let_them_know', undefined, lng), + }, + }, + } as const; +}; + +callbacks.add( + 'afterSaveMessage', + async (message, room) => { + // TODO: check if I need to test this 60 second rule. + // If the message was edited, or is older than 60 seconds (imported) + // the notifications will be skipped, so we can also skip this validation + if (isEditedMessage(message) || (message.ts && Math.abs(moment(message.ts).diff(moment())) > 60000) || !message.mentions) { + return message; + } + + const mentions = message.mentions.filter(({ _id }) => _id !== 'all' && _id !== 'here'); + if (!mentions.length) { + return message; + } + + if (isDirectMessageRoom(room) || isRoomFederated(room) || isOmnichannelRoom(room)) { + return message; + } + + const subs = await Subscriptions.findByRoomIdAndUserIds( + message.rid, + mentions.map(({ _id }) => _id), + { projection: { u: 1 } }, + ).toArray(); + + // get all users that are mentioned but not in the channel + const mentionsUsersNotInChannel = mentions.filter(({ _id }) => !subs.some((sub) => sub.u._id === _id)); + + if (!mentionsUsersNotInChannel.length) { + return message; + } + + const canAddUsersToThisRoom = await hasPermissionAsync(message.u._id, 'add-user-to-joined-room', message.rid); + const canAddToAnyRoom = await (room.t === 'c' + ? hasPermissionAsync(message.u._id, 'add-user-to-any-c-room') + : hasPermissionAsync(message.u._id, 'add-user-to-any-p-room')); + const canDMUsers = await hasPermissionAsync(message.u._id, 'create-d'); // TODO: Perhaps check if user has DM with mentioned user (might be too expensive) + const canAddUsers = canAddUsersToThisRoom || canAddToAnyRoom; + + const { language } = (await Users.findOneById(message.u._id)) || {}; + + const actionBlocks = getBlocks(mentionsUsersNotInChannel, message._id, language); + const elements: ActionsBlock['elements'] = [ + canAddUsers && actionBlocks.addUsersBlock, + (canAddUsers || canDMUsers) && actionBlocks.dismissBlock, + canDMUsers && actionBlocks.dmBlock, + ].filter(isTruthy); + + const messageLabel = canAddUsers + ? 'You_mentioned___mentions__but_theyre_not_in_this_room' + : 'You_mentioned___mentions__but_theyre_not_in_this_room_You_can_ask_a_room_admin_to_add_them'; + + const useRealName = settings.get('UI_Use_Real_Name'); + + const usernamesOrNames = mentionsUsersNotInChannel.map(({ username, name }) => `*${getUserDisplayName(name, username, useRealName)}*`); + + const mentionsText = usernamesOrNames.join(', '); + + // TODO: Mentions style + void api.broadcast('notify.ephemeralMessage', message.u._id, message.rid, { + msg: '', + mentions: mentionsUsersNotInChannel, + tmid: message.tmid, + blocks: [ + { + appId: APP_ID, + type: 'section', + text: { + type: 'mrkdwn', + text: i18n.t(messageLabel, { mentions: mentionsText }, language), + }, + } as const, + Boolean(elements.length) && + ({ + type: 'actions', + appId: APP_ID, + elements, + } as const), + ].filter(isTruthy), + private: true, + }); + + return message; + }, + callbacks.priority.LOW, + 'mention-user-not-in-channel', +); diff --git a/apps/meteor/app/lib/server/startup/oAuthServicesUpdate.js b/apps/meteor/app/lib/server/startup/oAuthServicesUpdate.js deleted file mode 100644 index b01ef2f9fb0c607a9cbb9aaba1d127244dbe64cd..0000000000000000000000000000000000000000 --- a/apps/meteor/app/lib/server/startup/oAuthServicesUpdate.js +++ /dev/null @@ -1,206 +0,0 @@ -import { Logger } from '@rocket.chat/logger'; -import { ServiceConfiguration } from 'meteor/service-configuration'; -import _ from 'underscore'; - -import { CustomOAuth } from '../../../custom-oauth/server/custom_oauth_server'; -import { settings } from '../../../settings/server'; -import { addOAuthService } from '../functions/addOAuthService'; - -const logger = new Logger('rocketchat:lib'); - -async function _OAuthServicesUpdate() { - const services = settings.getByRegexp(/^(Accounts_OAuth_|Accounts_OAuth_Custom-)[a-z0-9_]+$/i); - const filteredServices = services.filter(([, value]) => typeof value === 'boolean'); - for await (const [key, value] of filteredServices) { - logger.debug({ oauth_updated: key }); - let serviceName = key.replace('Accounts_OAuth_', ''); - if (serviceName === 'Meteor') { - serviceName = 'meteor-developer'; - } - if (/Accounts_OAuth_Custom-/.test(key)) { - serviceName = key.replace('Accounts_OAuth_Custom-', ''); - } - - if (value === true) { - const data = { - clientId: settings.get(`${key}_id`), - secret: settings.get(`${key}_secret`), - }; - - if (/Accounts_OAuth_Custom-/.test(key)) { - data.custom = true; - data.clientId = settings.get(`${key}-id`); - data.secret = settings.get(`${key}-secret`); - data.serverURL = settings.get(`${key}-url`); - data.tokenPath = settings.get(`${key}-token_path`); - data.identityPath = settings.get(`${key}-identity_path`); - data.authorizePath = settings.get(`${key}-authorize_path`); - data.scope = settings.get(`${key}-scope`); - data.accessTokenParam = settings.get(`${key}-access_token_param`); - data.buttonLabelText = settings.get(`${key}-button_label_text`); - data.buttonLabelColor = settings.get(`${key}-button_label_color`); - data.loginStyle = settings.get(`${key}-login_style`); - data.buttonColor = settings.get(`${key}-button_color`); - data.tokenSentVia = settings.get(`${key}-token_sent_via`); - data.identityTokenSentVia = settings.get(`${key}-identity_token_sent_via`); - data.keyField = settings.get(`${key}-key_field`); - data.usernameField = settings.get(`${key}-username_field`); - data.emailField = settings.get(`${key}-email_field`); - data.nameField = settings.get(`${key}-name_field`); - data.avatarField = settings.get(`${key}-avatar_field`); - data.rolesClaim = settings.get(`${key}-roles_claim`); - data.groupsClaim = settings.get(`${key}-groups_claim`); - data.channelsMap = settings.get(`${key}-groups_channel_map`); - data.channelsAdmin = settings.get(`${key}-channels_admin`); - data.mergeUsers = settings.get(`${key}-merge_users`); - data.mergeUsersDistinctServices = settings.get(`${key}-merge_users_distinct_services`); - data.mapChannels = settings.get(`${key}-map_channels`); - data.mergeRoles = settings.get(`${key}-merge_roles`); - data.rolesToSync = settings.get(`${key}-roles_to_sync`); - data.showButton = settings.get(`${key}-show_button`); - - new CustomOAuth(serviceName.toLowerCase(), { - serverURL: data.serverURL, - tokenPath: data.tokenPath, - identityPath: data.identityPath, - authorizePath: data.authorizePath, - scope: data.scope, - loginStyle: data.loginStyle, - tokenSentVia: data.tokenSentVia, - identityTokenSentVia: data.identityTokenSentVia, - keyField: data.keyField, - usernameField: data.usernameField, - emailField: data.emailField, - nameField: data.nameField, - avatarField: data.avatarField, - rolesClaim: data.rolesClaim, - groupsClaim: data.groupsClaim, - mapChannels: data.mapChannels, - channelsMap: data.channelsMap, - channelsAdmin: data.channelsAdmin, - mergeUsers: data.mergeUsers, - mergeUsersDistinctServices: data.mergeUsersDistinctServices, - mergeRoles: data.mergeRoles, - rolesToSync: data.rolesToSync, - accessTokenParam: data.accessTokenParam, - showButton: data.showButton, - }); - } - if (serviceName === 'Facebook') { - data.appId = data.clientId; - delete data.clientId; - } - if (serviceName === 'Twitter') { - data.consumerKey = data.clientId; - delete data.clientId; - } - - if (serviceName === 'Linkedin') { - data.clientConfig = { - requestPermissions: ['openid', 'email', 'profile'], - }; - } - - if (serviceName === 'Nextcloud') { - data.buttonLabelText = settings.get('Accounts_OAuth_Nextcloud_button_label_text'); - data.buttonLabelColor = settings.get('Accounts_OAuth_Nextcloud_button_label_color'); - data.buttonColor = settings.get('Accounts_OAuth_Nextcloud_button_color'); - } - - // If there's no data other than the service name, then put the service name in the data object so the operation won't fail - const keys = Object.keys(data).filter((key) => data[key] !== undefined); - if (!keys.length) { - data.service = serviceName.toLowerCase(); - } - - await ServiceConfiguration.configurations.upsertAsync( - { - service: serviceName.toLowerCase(), - }, - { - $set: data, - }, - ); - } else { - await ServiceConfiguration.configurations.removeAsync({ - service: serviceName.toLowerCase(), - }); - } - } -} - -const OAuthServicesUpdate = _.debounce(_OAuthServicesUpdate, 2000); - -async function OAuthServicesRemove(_id) { - const serviceName = _id.replace('Accounts_OAuth_Custom-', ''); - return ServiceConfiguration.configurations.removeAsync({ - service: serviceName.toLowerCase(), - }); -} - -settings.watchByRegex(/^Accounts_OAuth_.+/, () => { - return OAuthServicesUpdate(); // eslint-disable-line new-cap -}); - -settings.watchByRegex(/^Accounts_OAuth_Custom-[a-z0-9_]+/, (key, value) => { - if (!value) { - return OAuthServicesRemove(key); // eslint-disable-line new-cap - } -}); - -async function customOAuthServicesInit() { - // Add settings for custom OAuth providers to the settings so they get - // automatically added when they are defined in ENV variables - for await (const key of Object.keys(process.env)) { - if (/Accounts_OAuth_Custom_[a-zA-Z0-9_-]+$/.test(key)) { - // Most all shells actually prohibit the usage of - in environment variables - // So this will allow replacing - with _ and translate it back to the setting name - let name = key.replace('Accounts_OAuth_Custom_', ''); - - if (name.indexOf('_') > -1) { - name = name.replace(name.substr(name.indexOf('_')), ''); - } - - const serviceKey = `Accounts_OAuth_Custom_${name}`; - - if (key === serviceKey) { - const values = { - enabled: process.env[`${serviceKey}`] === 'true', - clientId: process.env[`${serviceKey}_id`], - clientSecret: process.env[`${serviceKey}_secret`], - serverURL: process.env[`${serviceKey}_url`], - tokenPath: process.env[`${serviceKey}_token_path`], - identityPath: process.env[`${serviceKey}_identity_path`], - authorizePath: process.env[`${serviceKey}_authorize_path`], - scope: process.env[`${serviceKey}_scope`], - accessTokenParam: process.env[`${serviceKey}_access_token_param`], - buttonLabelText: process.env[`${serviceKey}_button_label_text`], - buttonLabelColor: process.env[`${serviceKey}_button_label_color`], - loginStyle: process.env[`${serviceKey}_login_style`], - buttonColor: process.env[`${serviceKey}_button_color`], - tokenSentVia: process.env[`${serviceKey}_token_sent_via`], - identityTokenSentVia: process.env[`${serviceKey}_identity_token_sent_via`], - keyField: process.env[`${serviceKey}_key_field`], - usernameField: process.env[`${serviceKey}_username_field`], - nameField: process.env[`${serviceKey}_name_field`], - emailField: process.env[`${serviceKey}_email_field`], - rolesClaim: process.env[`${serviceKey}_roles_claim`], - groupsClaim: process.env[`${serviceKey}_groups_claim`], - channelsMap: process.env[`${serviceKey}_groups_channel_map`], - channelsAdmin: process.env[`${serviceKey}_channels_admin`], - mergeUsers: process.env[`${serviceKey}_merge_users`] === 'true', - mergeUsersDistinctServices: process.env[`${serviceKey}_merge_users_distinct_services`] === 'true', - mapChannels: process.env[`${serviceKey}_map_channels`], - mergeRoles: process.env[`${serviceKey}_merge_roles`] === 'true', - rolesToSync: process.env[`${serviceKey}_roles_to_sync`], - showButton: process.env[`${serviceKey}_show_button`] === 'true', - avatarField: process.env[`${serviceKey}_avatar_field`], - }; - - await addOAuthService(name, values); - } - } - } -} - -await customOAuthServicesInit(); diff --git a/apps/meteor/app/lib/server/startup/rateLimiter.js b/apps/meteor/app/lib/server/startup/rateLimiter.js index a1ddfe87886ce709eb7987bf6dc6157dfce677e3..5a312f4520d46078e722b3728f07f448748646c6 100644 --- a/apps/meteor/app/lib/server/startup/rateLimiter.js +++ b/apps/meteor/app/lib/server/startup/rateLimiter.js @@ -123,7 +123,7 @@ const checkNameForStream = (name) => name && !names.has(name) && name.startsWith const ruleIds = {}; -const callback = (msg, name) => (reply, input) => { +const callback = (msg, name) => async (reply, input) => { if (reply.allowed === false) { rateLimiterLog({ msg, reply, input }); metrics.ddpRateLimitExceeded.inc({ @@ -136,7 +136,7 @@ const callback = (msg, name) => (reply, input) => { }); // sleep before sending the error to slow down next requests if (slowDownRate > 0 && reply.numInvocationsExceeded) { - Promise.await(sleep(slowDownRate * reply.numInvocationsExceeded)); + await sleep(slowDownRate * reply.numInvocationsExceeded); } // } else { // console.log('DDP RATE LIMIT:', message); diff --git a/apps/meteor/app/livechat/client/lib/stream/queueManager.ts b/apps/meteor/app/livechat/client/lib/stream/queueManager.ts index 906ace402bb98f6e79b3b0d162a027993aa12976..5ba2cf0d979190950fb72e0e3d48264cfebfbbcf 100644 --- a/apps/meteor/app/livechat/client/lib/stream/queueManager.ts +++ b/apps/meteor/app/livechat/client/lib/stream/queueManager.ts @@ -2,6 +2,7 @@ import type { ILivechatDepartment, ILivechatInquiryRecord, IOmnichannelAgent } f import { queryClient } from '../../../../../client/lib/queryClient'; import { callWithErrorHandling } from '../../../../../client/lib/utils/callWithErrorHandling'; +import { settings } from '../../../../settings/client'; import { sdk } from '../../../../utils/client/lib/SDKClient'; import { LivechatInquiry } from '../../collections/LivechatInquiry'; @@ -39,7 +40,8 @@ const removeInquiry = async (inquiry: ILivechatInquiryRecord) => { }; const getInquiriesFromAPI = async () => { - const { inquiries } = await sdk.rest.get('/v1/livechat/inquiries.queuedForUser', {}); + const count = settings.get('Livechat_guest_pool_max_number_incoming_livechats_displayed') ?? 0; + const { inquiries } = await sdk.rest.get('/v1/livechat/inquiries.queuedForUser', { count }); return inquiries; }; @@ -96,9 +98,12 @@ const subscribe = async (userId: IOmnichannelAgent['_id']) => { // Register to all depts + public queue always to match the inquiry list returned by backend const cleanDepartmentListeners = addListenerForeachDepartment(agentDepartments); const globalCleanup = addGlobalListener(); - const inquiriesFromAPI = (await getInquiriesFromAPI()) as unknown as ILivechatInquiryRecord[]; - await updateInquiries(inquiriesFromAPI); + const computation = Tracker.autorun(async () => { + const inquiriesFromAPI = (await getInquiriesFromAPI()) as unknown as ILivechatInquiryRecord[]; + + await updateInquiries(inquiriesFromAPI); + }); return () => { LivechatInquiry.remove({}); @@ -106,14 +111,15 @@ const subscribe = async (userId: IOmnichannelAgent['_id']) => { cleanDepartmentListeners?.(); globalCleanup?.(); departments.clear(); + computation.stop(); }; }; export const initializeLivechatInquiryStream = (() => { let cleanUp: (() => void) | undefined; - return async (...args: any[]) => { + return async (...args: Parameters) => { cleanUp?.(); - cleanUp = await subscribe(...(args as [IOmnichannelAgent['_id']])); + cleanUp = await subscribe(...args); }; })(); diff --git a/apps/meteor/app/livechat/client/startup/notifyUnreadRooms.js b/apps/meteor/app/livechat/client/startup/notifyUnreadRooms.js index 3aaace847f69bb6421e7886a9e2c91c02df55846..a758c72cadd8c56c2df94e158b8567941401af01 100644 --- a/apps/meteor/app/livechat/client/startup/notifyUnreadRooms.js +++ b/apps/meteor/app/livechat/client/startup/notifyUnreadRooms.js @@ -1,9 +1,8 @@ -import { Users } from '@rocket.chat/models'; import { Meteor } from 'meteor/meteor'; import { Tracker } from 'meteor/tracker'; import { CustomSounds } from '../../../custom-sounds/client'; -import { Subscriptions } from '../../../models/client'; +import { Subscriptions, Users } from '../../../models/client'; import { settings } from '../../../settings/client'; import { getUserPreference } from '../../../utils/client'; @@ -16,17 +15,20 @@ Meteor.startup(() => { return; } - const subs = await Subscriptions.find({ t: 'l', ls: { $exists: 0 }, open: true }).count(); + const subs = await Subscriptions.find({ t: 'l', ls: { $exists: false }, open: true }).count(); if (subs === 0) { audio && audio.pause(); return; } - const user = await Users.findOne(Meteor.userId(), { - projection: { - 'settings.preferences.newRoomNotification': 1, + const user = await Users.findOne( + { _id: Meteor.userId() }, + { + projection: { + 'settings.preferences.newRoomNotification': 1, + }, }, - }); + ); const newRoomNotification = getUserPreference(user, 'newRoomNotification'); diff --git a/apps/meteor/app/livechat/imports/server/rest/appearance.ts b/apps/meteor/app/livechat/imports/server/rest/appearance.ts index 0fa365be18711b9a8395725186d0eb5bdc224e05..5a0a884d97b2d08c59ee0ae825da8e35d2a00bd9 100644 --- a/apps/meteor/app/livechat/imports/server/rest/appearance.ts +++ b/apps/meteor/app/livechat/imports/server/rest/appearance.ts @@ -1,6 +1,7 @@ import { Settings } from '@rocket.chat/models'; import { isPOSTLivechatAppearanceParams } from '@rocket.chat/rest-typings'; +import { isTruthy } from '../../../../../lib/isTruthy'; import { API } from '../../../../api/server'; import { findAppearance } from '../../../server/api/lib/appearance'; @@ -52,8 +53,35 @@ API.v1.addRoute( throw new Error('invalid-setting'); } + const dbSettings = await Settings.findByIds(validSettingList, { projection: { _id: 1, value: 1, type: 1 } }) + .map((dbSetting) => { + const setting = settings.find(({ _id }) => _id === dbSetting._id); + if (!setting || dbSetting.value === setting.value) { + return; + } + + switch (dbSetting?.type) { + case 'boolean': + return { + _id: dbSetting._id, + value: setting.value === 'true' || setting.value === true, + }; + case 'int': + return { + _id: dbSetting._id, + value: coerceInt(setting.value), + }; + default: + return { + _id: dbSetting._id, + value: setting?.value, + }; + } + }) + .toArray(); + await Promise.all( - settings.map((setting) => { + dbSettings.filter(isTruthy).map((setting) => { return Settings.updateValueById(setting._id, setting.value); }), ); @@ -62,3 +90,20 @@ API.v1.addRoute( }, }, ); + +function coerceInt(value: string | number | boolean): number { + if (typeof value === 'number') { + return value; + } + + if (typeof value === 'boolean') { + return 0; + } + + const parsedValue = parseInt(value, 10); + if (Number.isNaN(parsedValue)) { + return 0; + } + + return parsedValue; +} diff --git a/apps/meteor/app/livechat/imports/server/rest/departments.ts b/apps/meteor/app/livechat/imports/server/rest/departments.ts index b8788aae2eed9da6a322781cfca6a91ab4b88dc8..816c298f0a057c3f14b15a4bf793242affdd5b0d 100644 --- a/apps/meteor/app/livechat/imports/server/rest/departments.ts +++ b/apps/meteor/app/livechat/imports/server/rest/departments.ts @@ -117,23 +117,17 @@ API.v1.addRoute( const { _id } = this.urlParams; const { department, agents } = this.bodyParams; - let success; - if (permissionToSave) { - success = await LivechatEnterprise.saveDepartment(_id, department); + if (!permissionToSave) { + throw new Error('error-not-allowed'); } - if (success && agents && permissionToAddAgents) { - success = await LivechatTs.saveDepartmentAgents(_id, { upsert: agents }); - } + const agentParam = permissionToAddAgents && agents ? { upsert: agents } : {}; + await LivechatEnterprise.saveDepartment(_id, department, agentParam); - if (success) { - return API.v1.success({ - department: await LivechatDepartment.findOneById(_id), - agents: await LivechatDepartmentAgents.findByDepartmentId(_id).toArray(), - }); - } - - return API.v1.failure(); + return API.v1.success({ + department: await LivechatDepartment.findOneById(_id), + agents: await LivechatDepartmentAgents.findByDepartmentId(_id).toArray(), + }); }, async delete() { check(this.urlParams, { diff --git a/apps/meteor/app/livechat/imports/server/rest/inquiries.ts b/apps/meteor/app/livechat/imports/server/rest/inquiries.ts index d3a9eec7494d87411e591d464a360272ff952a5a..8118f353b1674c7c2136fb3cbb44cb492b69638a 100644 --- a/apps/meteor/app/livechat/imports/server/rest/inquiries.ts +++ b/apps/meteor/app/livechat/imports/server/rest/inquiries.ts @@ -23,7 +23,7 @@ API.v1.addRoute( const { department } = this.queryParams; const ourQuery: { status: string; department?: string } = { status: 'queued' }; if (department) { - const departmentFromDB = await LivechatDepartment.findOneByIdOrName(department); + const departmentFromDB = await LivechatDepartment.findOneByIdOrName(department, { projection: { _id: 1 } }); if (departmentFromDB) { ourQuery.department = departmentFromDB._id; } diff --git a/apps/meteor/app/livechat/lib/messageTypes.ts b/apps/meteor/app/livechat/lib/messageTypes.ts index ace0d4e65bfade260d2be8e56c0230ecbb72abf9..8a1931a300dcdc1e9be01e2c9a4a03c41a67566b 100644 --- a/apps/meteor/app/livechat/lib/messageTypes.ts +++ b/apps/meteor/app/livechat/lib/messageTypes.ts @@ -108,19 +108,25 @@ MessageTypes.registerType({ MessageTypes.registerType({ id: 'livechat_webrtc_video_call', - render(message) { + message: 'room_changed_type', + data(message) { if (message.msg === 'ended' && message.webRtcCallEndTs && message.ts) { - return t('WebRTC_call_ended_message', { - callDuration: formatDistance(new Date(message.webRtcCallEndTs), new Date(message.ts)), - endTime: moment(message.webRtcCallEndTs).format('h:mm A'), - }); + return { + message: t('WebRTC_call_ended_message', { + callDuration: formatDistance(new Date(message.webRtcCallEndTs), new Date(message.ts)), + endTime: moment(message.webRtcCallEndTs).format('h:mm A'), + }), + }; } if (message.msg === 'declined' && message.webRtcCallEndTs) { - return t('WebRTC_call_declined_message'); + return { + message: t('WebRTC_call_declined_message'), + }; } - return escapeHTML(message.msg); + return { + message: escapeHTML(message.msg), + }; }, - message: 'room_changed_type', }); MessageTypes.registerType({ diff --git a/apps/meteor/app/livechat/server/api/v1/room.ts b/apps/meteor/app/livechat/server/api/v1/room.ts index 23d7fe2c507a377ae7ba1eb1953dcb8d6ae3ccdf..f610b9a9d3de79d341db0a41388f841bef9de1a8 100644 --- a/apps/meteor/app/livechat/server/api/v1/room.ts +++ b/apps/meteor/app/livechat/server/api/v1/room.ts @@ -416,6 +416,10 @@ API.v1.addRoute( throw new Error('error-invalid-room'); } + if (!room.open) { + throw new Error('room-closed'); + } + if (!(await Omnichannel.isWithinMACLimit(room))) { throw new Error('error-mac-limit-reached'); } diff --git a/apps/meteor/app/livechat/server/api/v1/visitor.ts b/apps/meteor/app/livechat/server/api/v1/visitor.ts index 59be77d298f130befab815f1e39d2a5ab4a57234..3d78280c5109353f81ecf0296c5e7014b496a24f 100644 --- a/apps/meteor/app/livechat/server/api/v1/visitor.ts +++ b/apps/meteor/app/livechat/server/api/v1/visitor.ts @@ -30,6 +30,11 @@ API.v1.addRoute('livechat/visitor', { }); const { customFields, id, token, name, email, department, phone, username, connectionData } = this.bodyParams.visitor; + + if (!token?.trim()) { + throw new Meteor.Error('error-invalid-token', 'Token cannot be empty', { method: 'livechat/visitor' }); + } + const guest = { token, ...(id && { id }), diff --git a/apps/meteor/app/livechat/server/business-hour/AbstractBusinessHour.ts b/apps/meteor/app/livechat/server/business-hour/AbstractBusinessHour.ts index 55de5bbf631508a6f7a096edac9fd2d053cc6e90..a5f11caaab63efc71da0ba6ae89f64de947caf5e 100644 --- a/apps/meteor/app/livechat/server/business-hour/AbstractBusinessHour.ts +++ b/apps/meteor/app/livechat/server/business-hour/AbstractBusinessHour.ts @@ -1,4 +1,4 @@ -import type { ILivechatAgentStatus, ILivechatBusinessHour, ILivechatDepartment } from '@rocket.chat/core-typings'; +import type { AtLeast, ILivechatAgentStatus, ILivechatBusinessHour, ILivechatDepartment } from '@rocket.chat/core-typings'; import type { ILivechatBusinessHoursModel, IUsersModel } from '@rocket.chat/model-typings'; import { LivechatBusinessHours, Users } from '@rocket.chat/models'; import moment from 'moment-timezone'; @@ -14,8 +14,8 @@ export interface IBusinessHourBehavior { onAddAgentToDepartment(options?: { departmentId: string; agentsId: string[] }): Promise; onRemoveAgentFromDepartment(options?: Record): Promise; onRemoveDepartment(options: { department: ILivechatDepartment; agentsIds: string[] }): Promise; - onDepartmentDisabled(department?: ILivechatDepartment): Promise; - onDepartmentArchived(department: Pick): Promise; + onDepartmentDisabled(department?: AtLeast): Promise; + onDepartmentArchived(department: Pick): Promise; onStartBusinessHours(): Promise; afterSaveBusinessHours(businessHourData: ILivechatBusinessHour): Promise; allowAgentChangeServiceStatus(agentId: string): Promise; diff --git a/apps/meteor/app/livechat/server/hooks/checkMAC.ts b/apps/meteor/app/livechat/server/hooks/checkMAC.ts deleted file mode 100644 index 4d0789252b50a6ae4fcd8b716a322b89abc5e6ba..0000000000000000000000000000000000000000 --- a/apps/meteor/app/livechat/server/hooks/checkMAC.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { Omnichannel } from '@rocket.chat/core-services'; -import type { IOmnichannelRoom } from '@rocket.chat/core-typings'; -import { isEditedMessage } from '@rocket.chat/core-typings'; - -import { callbacks } from '../../../../lib/callbacks'; - -callbacks.add('beforeSaveMessage', async (message, room) => { - if (!room || room.t !== 'l') { - return message; - } - - if (isEditedMessage(message)) { - return message; - } - - if (message.token) { - return message; - } - - if (message.t) { - return message; - } - - const canSendMessage = await Omnichannel.isWithinMACLimit(room as IOmnichannelRoom); - if (!canSendMessage) { - throw new Error('error-mac-limit-reached'); - } - - return message; -}); diff --git a/apps/meteor/app/livechat/server/index.ts b/apps/meteor/app/livechat/server/index.ts index 7b650f5b2963d98f800404d50f88d30e7dd86502..fc96f2a921a960b5bbeffd987cdf950db4264f96 100644 --- a/apps/meteor/app/livechat/server/index.ts +++ b/apps/meteor/app/livechat/server/index.ts @@ -16,7 +16,6 @@ import './hooks/saveContactLastChat'; import './hooks/saveLastMessageToInquiry'; import './hooks/afterUserActions'; import './hooks/afterAgentRemoved'; -import './hooks/checkMAC'; import './methods/addAgent'; import './methods/addManager'; import './methods/changeLivechatStatus'; diff --git a/apps/meteor/app/livechat/server/lib/Departments.ts b/apps/meteor/app/livechat/server/lib/Departments.ts index f17015e52e795652bf24a9319e9193db09983105..ed55a856e0b8137e32907f9ab4b7396667372c21 100644 --- a/apps/meteor/app/livechat/server/lib/Departments.ts +++ b/apps/meteor/app/livechat/server/lib/Departments.ts @@ -1,4 +1,4 @@ -import type { ILivechatDepartmentAgents } from '@rocket.chat/core-typings'; +import type { ILivechatDepartment, ILivechatDepartmentAgents } from '@rocket.chat/core-typings'; import { Logger } from '@rocket.chat/logger'; import { LivechatDepartment, LivechatDepartmentAgents, LivechatRooms } from '@rocket.chat/models'; @@ -10,7 +10,9 @@ class DepartmentHelperClass { async removeDepartment(departmentId: string) { this.logger.debug(`Removing department: ${departmentId}`); - const department = await LivechatDepartment.findOneById(departmentId); + const department = await LivechatDepartment.findOneById>(departmentId, { + projection: { _id: 1, businessHourId: 1 }, + }); if (!department) { throw new Error('error-department-not-found'); } @@ -44,9 +46,7 @@ class DepartmentHelperClass { } }); - setImmediate(() => { - void callbacks.run('livechat.afterRemoveDepartment', { department, agentsIds }); - }); + await callbacks.run('livechat.afterRemoveDepartment', { department, agentsIds }); return ret; } diff --git a/apps/meteor/app/livechat/server/lib/Helper.ts b/apps/meteor/app/livechat/server/lib/Helper.ts index f360004fc77efda7963cf934065fffe0faa762ae..771f50724c38936d2b453d52ad693eb89f7a9794 100644 --- a/apps/meteor/app/livechat/server/lib/Helper.ts +++ b/apps/meteor/app/livechat/server/lib/Helper.ts @@ -256,10 +256,8 @@ export const createLivechatSubscription = async ( status, }, ts: new Date(), - lr: new Date(), - ls: new Date(), ...(department && { department }), - }; + } as InsertionModel; return Subscriptions.insertOne(subscriptionData); }; @@ -374,7 +372,6 @@ export const dispatchInquiryQueued = async (inquiry: ILivechatInquiryRecord, age // fake a subscription in order to make use of the function defined above subscription: { rid, - t: 'l', u: { _id, }, @@ -388,13 +385,14 @@ export const dispatchInquiryQueued = async (inquiry: ILivechatInquiryRecord, age username, }, ], + name: '', }, sender: v, hasMentionToAll: true, // consider all agents to be in the room hasReplyToThread: false, disableAllMessageNotifications: false, hasMentionToHere: false, - message: Object.assign({}, { u: v }), + 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: Object.assign(room, { name: i18n.t('New_chat_in_queue', {}, language) }), @@ -563,21 +561,44 @@ export const forwardRoomToDepartment = async (room: IOmnichannelRoom, guest: ILi const { servedBy, chatQueued } = roomTaken; if (!chatQueued && oldServedBy && servedBy && oldServedBy._id === servedBy._id) { const department = departmentId - ? await LivechatDepartment.findOneById>(departmentId, { - projection: { fallbackForwardDepartment: 1 }, + ? await LivechatDepartment.findOneById>(departmentId, { + projection: { fallbackForwardDepartment: 1, name: 1 }, }) : null; if (!department?.fallbackForwardDepartment?.length) { logger.debug(`Cannot forward room ${room._id}. Chat assigned to agent ${servedBy._id} (Previous was ${oldServedBy._id})`); throw new Error('error-no-agents-online-in-department'); } + + if (!transferData.originalDepartmentName) { + transferData.originalDepartmentName = department.name; + } // if a chat has a fallback department, attempt to redirect chat to there [EE] - const transferSuccess = !!(await callbacks.run('livechat:onTransferFailure', room, { guest, transferData })); + const transferSuccess = !!(await callbacks.run('livechat:onTransferFailure', room, { guest, transferData, department })); // On CE theres no callback so it will return the room if (typeof transferSuccess !== 'boolean' || !transferSuccess) { logger.debug(`Cannot forward room ${room._id}. Unable to delegate inquiry`); return false; } + + return true; + } + + // Send just 1 message to the room to inform the user that the chat was transferred + if (transferData.usingFallbackDep) { + const { _id, username } = transferData.transferredBy; + await Message.saveSystemMessage( + 'livechat_transfer_history_fallback', + room._id, + '', + { _id, username }, + { + transferData: { + ...transferData, + prevDepartment: transferData.originalDepartmentName, + }, + }, + ); } await LivechatTyped.saveTransferHistory(room, transferData); @@ -586,9 +607,6 @@ export const forwardRoomToDepartment = async (room: IOmnichannelRoom, guest: ILi // point the chat is not assigned to him/her and it is still in the queue await RoutingManager.removeAllRoomSubscriptions(room, !chatQueued ? servedBy : undefined); } - if (!chatQueued && servedBy) { - await Message.saveSystemMessage('uj', rid, servedBy.username || '', servedBy); - } await updateChatDepartment({ rid, newDepartmentId: departmentId, oldDepartmentId }); @@ -607,10 +625,6 @@ export const forwardRoomToDepartment = async (room: IOmnichannelRoom, guest: ILi logger.debug(`Inquiry ${inquiry._id} queued succesfully`); } - const { token } = guest; - await LivechatTyped.setDepartmentForGuest({ token, department: departmentId }); - logger.debug(`Department for visitor with token ${token} was updated to ${departmentId}`); - return true; }; @@ -655,13 +669,24 @@ export const updateDepartmentAgents = async ( departmentEnabled: boolean, ) => { check(departmentId, String); - check( - agents, - Match.ObjectIncluding({ - upsert: Match.Maybe(Array), - remove: Match.Maybe(Array), - }), - ); + check(agents, { + upsert: Match.Maybe([ + Match.ObjectIncluding({ + agentId: String, + username: Match.Maybe(String), + count: Match.Maybe(Match.Integer), + order: Match.Maybe(Match.Integer), + }), + ]), + remove: Match.Maybe([ + Match.ObjectIncluding({ + agentId: String, + username: Match.Maybe(String), + count: Match.Maybe(Match.Integer), + order: Match.Maybe(Match.Integer), + }), + ]), + }); const { upsert = [], remove = [] } = agents; const agentsRemoved = []; diff --git a/apps/meteor/app/livechat/server/lib/LivechatTyped.ts b/apps/meteor/app/livechat/server/lib/LivechatTyped.ts index ea508b04788278bdaa6cc5d1210e5293a91bdb92..79d225626ea1dc6feb46d3a8061773bbc91beeeb 100644 --- a/apps/meteor/app/livechat/server/lib/LivechatTyped.ts +++ b/apps/meteor/app/livechat/server/lib/LivechatTyped.ts @@ -290,11 +290,17 @@ class LivechatClass { this.logger.debug(`Updating DB for room ${room._id} with close data`); - await Promise.all([ - LivechatRooms.closeRoomById(rid, closeData), - LivechatInquiry.removeByRoomId(rid), - Subscriptions.removeByRoomId(rid), - ]); + const removedInquiry = await LivechatInquiry.removeByRoomId(rid); + if (removedInquiry && removedInquiry.deletedCount !== 1) { + throw new Error('Error removing inquiry'); + } + + const updatedRoom = await LivechatRooms.closeRoomById(rid, closeData); + if (!updatedRoom || updatedRoom.modifiedCount !== 1) { + throw new Error('Error closing room'); + } + + await Subscriptions.removeByRoomId(rid); this.logger.debug(`DB updated for room ${room._id}`); @@ -469,7 +475,7 @@ class LivechatClass { }, }; - const dep = await LivechatDepartment.findOneById(department); + const dep = await LivechatDepartment.findOneById>(department, { projection: { _id: 1 } }); if (!dep) { throw new Meteor.Error('invalid-department', 'Provided department does not exists'); } @@ -981,7 +987,9 @@ class LivechatClass { } async archiveDepartment(_id: string) { - const department = await LivechatDepartment.findOneById(_id, { projection: { _id: 1 } }); + const department = await LivechatDepartment.findOneById>(_id, { + projection: { _id: 1, businessHourId: 1 }, + }); if (!department) { throw new Error('department-not-found'); @@ -1047,7 +1055,7 @@ class LivechatClass { } if (transferData.departmentId) { - const department = await LivechatDepartment.findOneById(transferData.departmentId, { + const department = await LivechatDepartment.findOneById>(transferData.departmentId, { projection: { name: 1 }, }); if (!department) { diff --git a/apps/meteor/app/livechat/server/livechat.ts b/apps/meteor/app/livechat/server/livechat.ts index 4a3847fecd9d25377cbcbda93cb730bfe8a160f0..795deaba34525b90b58af18f975c880135043a90 100644 --- a/apps/meteor/app/livechat/server/livechat.ts +++ b/apps/meteor/app/livechat/server/livechat.ts @@ -1,5 +1,7 @@ import url from 'url'; +import jsdom from 'jsdom'; +import mem from 'mem'; import { WebApp } from 'meteor/webapp'; import { settings } from '../../settings/server'; @@ -7,6 +9,37 @@ import { addServerUrlToIndex } from '../lib/Assets'; const indexHtmlWithServerURL = addServerUrlToIndex((await Assets.getTextAsync('livechat/index.html')) || ''); +function parseExtraAttributes(widgetData: string): string { + const liveChatAdditionalScripts = settings.get('Livechat_AdditionalWidgetScripts'); + const additionalClass = settings.get('Livechat_WidgetLayoutClasses'); + + if (liveChatAdditionalScripts == null || additionalClass == null) { + return widgetData; + } + + const domParser = new jsdom.JSDOM(widgetData); + const doc = domParser.window.document; + const head = doc.querySelector('head'); + const body = doc.querySelector('body'); + + liveChatAdditionalScripts.split(',').forEach((script) => { + const scriptElement = doc.createElement('script'); + scriptElement.src = script; + body?.appendChild(scriptElement); + }); + + additionalClass.split(',').forEach((css) => { + const linkElement = doc.createElement('link'); + linkElement.rel = 'stylesheet'; + linkElement.href = css; + head?.appendChild(linkElement); + }); + + return doc.documentElement.innerHTML; +} + +const memoizedParseExtraAttributes = mem(parseExtraAttributes, { maxAge: process.env.TEST_MODE === 'true' ? 1 : 60000 }); + WebApp.connectHandlers.use('/livechat', (req, res, next) => { if (!req.url) { return next(); @@ -18,24 +51,21 @@ WebApp.connectHandlers.use('/livechat', (req, res, next) => { } res.setHeader('content-type', 'text/html; charset=utf-8'); - const domainWhiteListSetting = settings.get('Livechat_AllowedDomainsList'); let domainWhiteList = []; if (req.headers.referer && domainWhiteListSetting.trim()) { domainWhiteList = domainWhiteListSetting.split(',').map((domain) => domain.trim()); - const referer = url.parse(req.headers.referer); if (referer.host && !domainWhiteList.includes(referer.host)) { res.setHeader('Content-Security-Policy', "frame-ancestors 'none'"); return next(); } - res.setHeader('Content-Security-Policy', `frame-ancestors ${referer.protocol}//${referer.host}`); } else { // TODO need to remove inline scripts from this route to be able to enable CSP here as well res.removeHeader('Content-Security-Policy'); } - res.write(indexHtmlWithServerURL); + res.write(memoizedParseExtraAttributes(indexHtmlWithServerURL)); res.end(); }); diff --git a/apps/meteor/app/livechat/server/methods/sendFileLivechatMessage.ts b/apps/meteor/app/livechat/server/methods/sendFileLivechatMessage.ts index 7d64763cd63422f12cb7cc02e34c47683ec1257c..15577abd76e30152098c9c84b8c6f1c3dcfb824c 100644 --- a/apps/meteor/app/livechat/server/methods/sendFileLivechatMessage.ts +++ b/apps/meteor/app/livechat/server/methods/sendFileLivechatMessage.ts @@ -1,5 +1,5 @@ import type { - MessageAttachment, + FileAttachmentProps, ImageAttachmentProps, AudioAttachmentProps, VideoAttachmentProps, @@ -56,7 +56,7 @@ export const sendFileLivechatMessage = async ({ roomId, visitorToken, file, msgD const fileUrl = file.name && FileUpload.getPath(`${file._id}/${encodeURI(file.name)}`); - const attachment: MessageAttachment = { + const attachment: Partial = { title: file.name, type: 'file', description: file.description, diff --git a/apps/meteor/app/livechat/server/startup.ts b/apps/meteor/app/livechat/server/startup.ts index 3ea87f3d568fe903bd7c5f604c584e4e7bd6d459..547deb6044ce62045ffab371726afd7906b2964f 100644 --- a/apps/meteor/app/livechat/server/startup.ts +++ b/apps/meteor/app/livechat/server/startup.ts @@ -65,7 +65,7 @@ Meteor.startup(async () => { await createDefaultBusinessHourIfNotExists(); settings.watch('Livechat_enable_business_hours', async (value) => { - logger.info(`Changing business hour type to ${value}`); + logger.debug(`Starting business hour manager ${value}`); if (value) { await businessHourManager.startManager(); return; diff --git a/apps/meteor/app/mail-messages/server/functions/sendMail.ts b/apps/meteor/app/mail-messages/server/functions/sendMail.ts index ce57fc06b1c8c47f695732c56cbe6d4686c15d41..50435bc248129d0dbc3c362c2aa751ed20cb5fa3 100644 --- a/apps/meteor/app/mail-messages/server/functions/sendMail.ts +++ b/apps/meteor/app/mail-messages/server/functions/sendMail.ts @@ -36,33 +36,38 @@ export const sendMail = async function ({ userQuery = { $and: [userQuery, EJSON.parse(query)] }; } - const users = await Users.find(userQuery).toArray(); - if (dryrun) { - for await (const u of users) { - const user: Partial & Pick = u; - const email = `${user.name} <${user.emails?.[0].address}>`; - const html = placeholders.replace(body, { - unsubscribe: Meteor.absoluteUrl( - generatePath('mailer/unsubscribe/:_id/:createdAt', { - _id: user._id, - createdAt: user.createdAt?.getTime().toString() || '', - }), - ), - name: user.name, - email, - }); + const user = await Users.findOneByEmailAddress(from); - SystemLogger.debug(`Sending email to ${email}`); - await Mailer.send({ - to: email, - from, - subject, - html, + if (!user) { + throw new Meteor.Error('error-invalid-user', 'Invalid user', { + function: 'Mailer.sendMail', }); } + + const email = `${user.name} <${user.emails?.[0].address}>`; + const html = placeholders.replace(body, { + unsubscribe: Meteor.absoluteUrl( + generatePath('mailer/unsubscribe/:_id/:createdAt', { + _id: user._id, + createdAt: user.createdAt?.getTime().toString() || '', + }), + ), + name: user.name, + email, + }); + + SystemLogger.debug(`Sending email to ${email}`); + return Mailer.send({ + to: email, + from, + subject, + html, + }); } + const users = await Users.find(userQuery).toArray(); + for await (const u of users) { const user: Partial & Pick = u; if (user?.emails && Array.isArray(user.emails) && user.emails.length) { diff --git a/apps/meteor/app/mentions/lib/MentionsParser.js b/apps/meteor/app/mentions/lib/MentionsParser.js deleted file mode 100644 index 87329ac9f1207263b79559d5490e6df7b53f8252..0000000000000000000000000000000000000000 --- a/apps/meteor/app/mentions/lib/MentionsParser.js +++ /dev/null @@ -1,135 +0,0 @@ -import { escapeHTML } from '@rocket.chat/string-helpers'; - -const userTemplateDefault = ({ prefix, className, mention, title, label, type = 'username' }) => - `${prefix}${label}`; -const roomTemplateDefault = ({ prefix, reference, mention }) => - `${prefix}${`#${mention}`}`; -export class MentionsParser { - constructor({ pattern, useRealName, me, roomTemplate = roomTemplateDefault, userTemplate = userTemplateDefault }) { - this.pattern = pattern; - this.useRealName = useRealName; - this.me = me; - this.userTemplate = userTemplate; - this.roomTemplate = roomTemplate; - } - - set me(m) { - this._me = m; - } - - get me() { - return typeof this._me === 'function' ? this._me() : this._me; - } - - set pattern(p) { - this._pattern = p; - } - - get pattern() { - return typeof this._pattern === 'function' ? this._pattern() : this._pattern; - } - - set useRealName(s) { - this._useRealName = s; - } - - get useRealName() { - return typeof this._useRealName === 'function' ? this._useRealName() : this._useRealName; - } - - get userMentionRegex() { - return new RegExp(`(^|\\s|>)@(${this.pattern}(@(${this.pattern}))?(:([0-9a-zA-Z-_.]+))?)`, 'gm'); - } - - get channelMentionRegex() { - return new RegExp(`(^|\\s|>)#(${this.pattern}(@(${this.pattern}))?)`, 'gm'); - } - - replaceUsers = (msg, { mentions, temp }, me) => - msg.replace(this.userMentionRegex, (match, prefix, mention) => { - const classNames = ['mention-link']; - - if (mention === 'all') { - classNames.push('mention-link--all'); - classNames.push('mention-link--group'); - } else if (mention === 'here') { - classNames.push('mention-link--here'); - classNames.push('mention-link--group'); - } else if (mention === me) { - classNames.push('mention-link--me'); - classNames.push('mention-link--user'); - } else { - classNames.push('mention-link--user'); - } - - const className = classNames.join(' '); - - if (mention === 'all' || mention === 'here') { - return this.userTemplate({ prefix, className, mention, label: mention, type: 'group' }); - } - - const filterUser = ({ username, type }) => (!type || type === 'user') && username === mention; - const filterTeam = ({ name, type }) => type === 'team' && name === mention; - - const [mentionObj] = (mentions || []).filter((m) => filterUser(m) || filterTeam(m)); - - const label = temp - ? mention && escapeHTML(mention) - : mentionObj && escapeHTML(mentionObj.type === 'team' || this.useRealName ? mentionObj.name : mentionObj.username); - - if (!label) { - return match; - } - - return this.userTemplate({ - prefix, - className, - mention, - label, - type: mentionObj?.type === 'team' ? 'team' : 'username', - title: this.useRealName ? mention : label, - }); - }); - - replaceChannels = (msg, { temp, channels }) => - msg.replace(/'/g, "'").replace(this.channelMentionRegex, (match, prefix, mention) => { - if ( - !temp && - !( - channels && - channels.find((c) => { - return c.dname ? c.dname === mention : c.name === mention; - }) - ) - ) { - return match; - } - - const channel = - channels && - channels.find(({ name, dname }) => { - return dname ? dname === mention : name === mention; - }); - const reference = channel ? channel._id : mention; - return this.roomTemplate({ prefix, reference, channel, mention }); - }); - - getUserMentions(str) { - return (str.match(this.userMentionRegex) || []).map((match) => match.trim()); - } - - getChannelMentions(str) { - return (str.match(this.channelMentionRegex) || []).map((match) => match.trim()); - } - - parse(message) { - let msg = (message && message.html) || ''; - if (!msg.trim()) { - return message; - } - msg = this.replaceUsers(msg, message, this.me); - msg = this.replaceChannels(msg, message, this.me); - message.html = msg; - return message; - } -} diff --git a/apps/meteor/app/mentions/lib/MentionsParser.ts b/apps/meteor/app/mentions/lib/MentionsParser.ts new file mode 100644 index 0000000000000000000000000000000000000000..c4180eecf8459e70e7ac9b3f17a18c371bf9efb9 --- /dev/null +++ b/apps/meteor/app/mentions/lib/MentionsParser.ts @@ -0,0 +1,140 @@ +import type { IMessage } from '@rocket.chat/core-typings'; +import { escapeHTML } from '@rocket.chat/string-helpers'; + +export type MentionsParserArgs = { + pattern: () => string; + useRealName?: () => boolean; + me?: () => string; + roomTemplate?: (args: { prefix: string; reference: string; mention: string; channel?: any }) => string; + userTemplate?: (args: { prefix: string; className: string; mention: string; title?: string; label: string; type?: string }) => string; +}; + +const userTemplateDefault = ({ + prefix, + className, + mention, + title = '', + label, + type = 'username', +}: { + prefix: string; + className: string; + mention: string; + title?: string; + label?: string; + type?: string; +}) => `${prefix}${label}`; + +const roomTemplateDefault = ({ prefix, reference, mention }: { prefix: string; reference: string; mention: string }) => + `${prefix}${`#${mention}`}`; + +export class MentionsParser { + me: () => string; + + pattern: MentionsParserArgs['pattern']; + + userTemplate: (args: { prefix: string; className: string; mention: string; title?: string; label: string; type?: string }) => string; + + roomTemplate: (args: { prefix: string; reference: string; mention: string; channel?: any }) => string; + + useRealName: () => boolean; + + constructor({ pattern, useRealName, me, roomTemplate = roomTemplateDefault, userTemplate = userTemplateDefault }: MentionsParserArgs) { + this.pattern = pattern; + this.useRealName = useRealName || (() => false); + this.me = me || (() => ''); + this.userTemplate = userTemplate; + this.roomTemplate = roomTemplate; + } + + get userMentionRegex() { + return new RegExp(`(^|\\s|>)@(${this.pattern()}(@(${this.pattern()}))?(:([0-9a-zA-Z-_.]+))?)`, 'gm'); + } + + get channelMentionRegex() { + return new RegExp(`(^|\\s|>)#(${this.pattern()}(@(${this.pattern()}))?)`, 'gm'); + } + + replaceUsers = (msg: string, { mentions, temp }: IMessage, me: string) => + msg.replace(this.userMentionRegex, (match, prefix, mention) => { + const classNames = ['mention-link']; + + if (mention === 'all') { + classNames.push('mention-link--all'); + classNames.push('mention-link--group'); + } else if (mention === 'here') { + classNames.push('mention-link--here'); + classNames.push('mention-link--group'); + } else if (mention === me) { + classNames.push('mention-link--me'); + classNames.push('mention-link--user'); + } else { + classNames.push('mention-link--user'); + } + + const className = classNames.join(' '); + + if (mention === 'all' || mention === 'here') { + return this.userTemplate({ prefix, className, mention, label: mention, type: 'group' }); + } + + const filterUser = ({ username, type }: { username?: string; type?: string }) => (!type || type === 'user') && username === mention; + const filterTeam = ({ name, type }: { name?: string; type?: string }) => type === 'team' && name === mention; + + const [mentionObj] = (mentions || []).filter((m) => m && (filterUser(m) || filterTeam(m))); + + const label = temp + ? mention && escapeHTML(mention) + : mentionObj && escapeHTML((mentionObj.type === 'team' || this.useRealName() ? mentionObj.name : mentionObj.username) || ''); + + if (!label) { + return match; + } + + return this.userTemplate({ + prefix, + className, + mention, + label, + type: mentionObj?.type === 'team' ? 'team' : 'username', + title: this.useRealName() ? mention : label, + }); + }); + + replaceChannels = (msg: string, { temp, channels }: IMessage) => + msg.replace(/'/g, "'").replace(this.channelMentionRegex, (match, prefix, mention) => { + if ( + !temp && + !channels?.find((c) => { + return c.name === mention; + }) + ) { + return match; + } + + const channel = channels?.find(({ name }) => { + return name === mention; + }); + const reference = channel ? channel._id : mention; + return this.roomTemplate({ prefix, reference, channel, mention }); + }); + + getUserMentions(str: string) { + return (str.match(this.userMentionRegex) || []).map((match) => match.trim()); + } + + getChannelMentions(str: string) { + return (str.match(this.channelMentionRegex) || []).map((match) => match.trim()); + } + + parse(message: IMessage) { + let msg = message?.html || ''; + if (!msg.trim()) { + return message; + } + msg = this.replaceUsers(msg, message, this.me()); + msg = this.replaceChannels(msg, message); + message.html = msg; + return message; + } +} diff --git a/apps/meteor/app/mentions/server/Mentions.js b/apps/meteor/app/mentions/server/Mentions.js deleted file mode 100644 index 91518fd748559bd160891d6d3f69fa34b8e5a87a..0000000000000000000000000000000000000000 --- a/apps/meteor/app/mentions/server/Mentions.js +++ /dev/null @@ -1,89 +0,0 @@ -/* - * Mentions is a named function that will process Mentions - * @param {Object} message - The message object - */ -import { MentionsParser } from '../lib/MentionsParser'; - -export default class MentionsServer extends MentionsParser { - constructor(args) { - super(args); - this.messageMaxAll = args.messageMaxAll; - this.getChannel = args.getChannel; - this.getChannels = args.getChannels; - this.getUsers = args.getUsers; - this.getUser = args.getUser; - this.getTotalChannelMembers = args.getTotalChannelMembers; - this.onMaxRoomMembersExceeded = args.onMaxRoomMembersExceeded || (() => {}); - } - - set getUsers(m) { - this._getUsers = m; - } - - get getUsers() { - return typeof this._getUsers === 'function' ? this._getUsers : async () => this._getUsers; - } - - set getChannels(m) { - this._getChannels = m; - } - - get getChannels() { - return typeof this._getChannels === 'function' ? this._getChannels : () => this._getChannels; - } - - set getChannel(m) { - this._getChannel = m; - } - - get getChannel() { - return typeof this._getChannel === 'function' ? this._getChannel : () => this._getChannel; - } - - set messageMaxAll(m) { - this._messageMaxAll = m; - } - - get messageMaxAll() { - return typeof this._messageMaxAll === 'function' ? this._messageMaxAll() : this._messageMaxAll; - } - - async getUsersByMentions({ msg, rid, u: sender }) { - let mentions = this.getUserMentions(msg); - const mentionsAll = []; - const userMentions = []; - - for await (const m of mentions) { - const mention = m.trim().substr(1); - if (mention !== 'all' && mention !== 'here') { - userMentions.push(mention); - continue; - } - if (this.messageMaxAll > 0 && (await this.getTotalChannelMembers(rid)) > this.messageMaxAll) { - await this.onMaxRoomMembersExceeded({ sender, rid }); - continue; - } - mentionsAll.push({ - _id: mention, - username: mention, - }); - } - mentions = userMentions.length ? await this.getUsers(userMentions) : []; - return [...mentionsAll, ...mentions]; - } - - async getChannelbyMentions({ msg }) { - const channels = this.getChannelMentions(msg); - return this.getChannels(channels.map((c) => c.trim().substr(1))); - } - - async execute(message) { - const mentionsAll = await this.getUsersByMentions(message); - const channels = await this.getChannelbyMentions(message); - - message.mentions = mentionsAll; - message.channels = channels; - - return message; - } -} diff --git a/apps/meteor/app/mentions/server/Mentions.ts b/apps/meteor/app/mentions/server/Mentions.ts new file mode 100644 index 0000000000000000000000000000000000000000..9eda56fea21c224146e7a3ff606b701aa05ee7d3 --- /dev/null +++ b/apps/meteor/app/mentions/server/Mentions.ts @@ -0,0 +1,84 @@ +/* + * Mentions is a named function that will process Mentions + * @param {Object} message - The message object + */ +import type { IMessage, IRoom, IUser } from '@rocket.chat/core-typings'; + +import { type MentionsParserArgs, MentionsParser } from '../lib/MentionsParser'; + +type MentionsServerArgs = MentionsParserArgs & { + messageMaxAll: () => number; + getChannels: (c: string[]) => Promise[]>; + getUsers: (u: string[]) => Promise<{ type: 'team' | 'user'; _id: string; username?: string; name?: string }[]>; + getUser: (u: string) => Promise; + getTotalChannelMembers: (rid: string) => Promise; + onMaxRoomMembersExceeded: ({ sender, rid }: { sender: IMessage['u']; rid: string }) => Promise; +}; + +export class MentionsServer extends MentionsParser { + messageMaxAll: MentionsServerArgs['messageMaxAll']; + + getChannels: MentionsServerArgs['getChannels']; + + getUsers: MentionsServerArgs['getUsers']; + + getUser: MentionsServerArgs['getUser']; + + getTotalChannelMembers: MentionsServerArgs['getTotalChannelMembers']; + + onMaxRoomMembersExceeded: MentionsServerArgs['onMaxRoomMembersExceeded']; + + constructor(args: MentionsServerArgs) { + super(args); + + this.messageMaxAll = args.messageMaxAll; + this.getChannels = args.getChannels; + this.getUsers = args.getUsers; + this.getUser = args.getUser; + this.getTotalChannelMembers = args.getTotalChannelMembers; + this.onMaxRoomMembersExceeded = + args.onMaxRoomMembersExceeded || + (() => { + /* do nothing */ + }); + } + + async getUsersByMentions({ msg, rid, u: sender }: Pick): Promise { + const mentions = this.getUserMentions(msg); + const mentionsAll: { _id: string; username: string }[] = []; + const userMentions = []; + + for await (const m of mentions) { + const mention = m.trim().substr(1); + if (mention !== 'all' && mention !== 'here') { + userMentions.push(mention); + continue; + } + if (this.messageMaxAll() > 0 && (await this.getTotalChannelMembers(rid)) > this.messageMaxAll()) { + await this.onMaxRoomMembersExceeded({ sender, rid }); + continue; + } + mentionsAll.push({ + _id: mention, + username: mention, + }); + } + + return [...mentionsAll, ...(userMentions.length ? await this.getUsers(userMentions) : [])]; + } + + async getChannelbyMentions({ msg }: Pick) { + const channels = this.getChannelMentions(msg); + return this.getChannels(channels.map((c) => c.trim().substr(1))); + } + + async execute(message: IMessage) { + const mentionsAll = await this.getUsersByMentions(message); + const channels = await this.getChannelbyMentions(message); + + message.mentions = mentionsAll; + message.channels = channels; + + return message; + } +} diff --git a/apps/meteor/app/mentions/server/index.ts b/apps/meteor/app/mentions/server/index.ts index a04af05b9db1569820c04eddacfcd9ffd4a4a47e..b16d62185a64e6bb2a83e8ef93c8b14cce52fb7d 100644 --- a/apps/meteor/app/mentions/server/index.ts +++ b/apps/meteor/app/mentions/server/index.ts @@ -1,3 +1,2 @@ import './getMentionedTeamMembers'; import './methods/getUserMentionsByChannel'; -import './server'; diff --git a/apps/meteor/app/message-pin/server/pinMessage.ts b/apps/meteor/app/message-pin/server/pinMessage.ts index 652f465188c1f08da10493acfcf1dc46973cac7c..1ed0a172028bc3ab43f82c5cbd5c2cdc4941e33c 100644 --- a/apps/meteor/app/message-pin/server/pinMessage.ts +++ b/apps/meteor/app/message-pin/server/pinMessage.ts @@ -1,4 +1,4 @@ -import { Message, api } from '@rocket.chat/core-services'; +import { Message } from '@rocket.chat/core-services'; import { isQuoteAttachment, isRegisterUser } from '@rocket.chat/core-typings'; import type { IMessage, MessageAttachment, MessageQuoteAttachment } from '@rocket.chat/core-typings'; import { Messages, Rooms, Subscriptions, Users, ReadReceipts } from '@rocket.chat/models'; @@ -7,9 +7,8 @@ import { check } from 'meteor/check'; import { Meteor } from 'meteor/meteor'; import { Apps, AppEvents } from '../../../ee/server/apps/orchestrator'; -import { callbacks } from '../../../lib/callbacks'; import { isTruthy } from '../../../lib/isTruthy'; -import { broadcastMessageSentEvent } from '../../../server/modules/watchers/lib/messages'; +import { broadcastMessageFromData } from '../../../server/modules/watchers/lib/messages'; import { canAccessRoomAsync, roomAccessAttributes } from '../../authorization/server'; import { hasPermissionAsync } from '../../authorization/server/functions/hasPermission'; import { isTheLastMessage } from '../../lib/server/functions/isTheLastMessage'; @@ -109,8 +108,6 @@ Meteor.methods({ username: me.username, }; - originalMessage = await callbacks.run('beforeSaveMessage', originalMessage); - originalMessage = await Message.beforeSave({ message: originalMessage, room, user: me }); await Messages.setPinnedByIdAndUserId(originalMessage._id, originalMessage.pinnedBy, originalMessage.pinned); @@ -212,8 +209,6 @@ Meteor.methods({ throw new Meteor.Error('not-authorized', 'Not Authorized', { method: 'unpinMessage' }); } - originalMessage = await callbacks.run('beforeSaveMessage', originalMessage); - originalMessage = await Message.beforeSave({ message: originalMessage, room, user: me }); if (isTheLastMessage(room, message)) { @@ -227,9 +222,8 @@ Meteor.methods({ if (settings.get('Message_Read_Receipt_Store_Users')) { await ReadReceipts.setPinnedByMessageId(originalMessage._id, originalMessage.pinned); } - void broadcastMessageSentEvent({ + void broadcastMessageFromData({ id: message._id, - broadcastCallback: (message) => api.broadcast('message.sent', message), }); return true; diff --git a/apps/meteor/app/message-star/server/starMessage.ts b/apps/meteor/app/message-star/server/starMessage.ts index aaa5657c5b351b68dd740eec5a61ee6d75311208..8f025d920057145c212356b6c4e61a6fb60af77c 100644 --- a/apps/meteor/app/message-star/server/starMessage.ts +++ b/apps/meteor/app/message-star/server/starMessage.ts @@ -1,11 +1,10 @@ -import { api } from '@rocket.chat/core-services'; import type { IMessage } from '@rocket.chat/core-typings'; import { Messages, Subscriptions, Rooms } from '@rocket.chat/models'; import type { ServerMethods } from '@rocket.chat/ui-contexts'; import { Meteor } from 'meteor/meteor'; import { Apps, AppEvents } from '../../../ee/server/apps/orchestrator'; -import { broadcastMessageSentEvent } from '../../../server/modules/watchers/lib/messages'; +import { broadcastMessageFromData } from '../../../server/modules/watchers/lib/messages'; import { canAccessRoomAsync, roomAccessAttributes } from '../../authorization/server'; import { isTheLastMessage } from '../../lib/server/functions/isTheLastMessage'; import { settings } from '../../settings/server'; @@ -62,9 +61,8 @@ Meteor.methods({ await Messages.updateUserStarById(message._id, uid, message.starred); - void broadcastMessageSentEvent({ + void broadcastMessageFromData({ id: message._id, - broadcastCallback: (message) => api.broadcast('message.sent', message), }); return true; diff --git a/apps/meteor/app/meteor-accounts-saml/client/index.ts b/apps/meteor/app/meteor-accounts-saml/client/index.ts deleted file mode 100644 index 5ca4ae3d5c181afd22afcaac3f3cc52b9cb6ef5c..0000000000000000000000000000000000000000 --- a/apps/meteor/app/meteor-accounts-saml/client/index.ts +++ /dev/null @@ -1 +0,0 @@ -import './saml_client'; diff --git a/apps/meteor/app/meteor-accounts-saml/client/saml_client.js b/apps/meteor/app/meteor-accounts-saml/client/saml_client.js deleted file mode 100644 index f1f14be530dd31e430c685a551279463d5904630..0000000000000000000000000000000000000000 --- a/apps/meteor/app/meteor-accounts-saml/client/saml_client.js +++ /dev/null @@ -1,86 +0,0 @@ -import { Random } from '@rocket.chat/random'; -import { Accounts } from 'meteor/accounts-base'; -import { Meteor } from 'meteor/meteor'; -import { ServiceConfiguration } from 'meteor/service-configuration'; - -import { sdk } from '../../utils/client/lib/SDKClient'; - -if (!Accounts.saml) { - Accounts.saml = {}; -} - -// Override the standard logout behaviour. -// -// If we find a samlProvider, and we are using single -// logout we will initiate logout from rocketchat via saml. -// If not using single logout, we just do the standard logout. -// This can be overridden by a configured logout behaviour. -// -// TODO: This may need some work as it is not clear if we are really -// logging out of the idp when doing the standard logout. - -const MeteorLogout = Meteor.logout; -const logoutBehaviour = { - TERMINATE_SAML: 'SAML', - ONLY_RC: 'Local', -}; - -Meteor.logout = async function (...args) { - const samlService = await ServiceConfiguration.configurations.findOneAsync({ service: 'saml' }); - if (samlService) { - const provider = samlService.clientConfig && samlService.clientConfig.provider; - if (provider) { - if (samlService.logoutBehaviour == null || samlService.logoutBehaviour === logoutBehaviour.TERMINATE_SAML) { - if (samlService.idpSLORedirectURL) { - console.info('SAML session terminated via SLO'); - return Meteor.logoutWithSaml({ provider }); - } - } - - if (samlService.logoutBehaviour === logoutBehaviour.ONLY_RC) { - console.info('SAML session not terminated, only the Rocket.Chat session is going to be killed'); - } - } - } - return MeteorLogout.apply(Meteor, args); -}; - -Meteor.loginWithSaml = function (options /* , callback*/) { - options = options || {}; - const credentialToken = `id-${Random.id()}`; - options.credentialToken = credentialToken; - - window.location.href = `_saml/authorize/${options.provider}/${options.credentialToken}`; -}; - -Meteor.logoutWithSaml = function (options /* , callback*/) { - // Accounts.saml.idpInitiatedSLO(options, callback); - sdk - .call('samlLogout', options.provider) - .then((result) => { - if (!result) { - MeteorLogout.apply(Meteor); - return; - } - - // Remove the userId from the client to prevent calls to the server while the logout is processed. - // If the logout fails, the userId will be reloaded on the resume call - Meteor._localStorage.removeItem(Accounts.USER_ID_KEY); - - // A nasty bounce: 'result' has the SAML LogoutRequest but we need a proper 302 to redirected from the server. - window.location.replace(Meteor.absoluteUrl(`_saml/sloRedirect/${options.provider}/?redirect=${encodeURIComponent(result)}`)); - }) - .catch(() => MeteorLogout.apply(Meteor)); -}; - -Meteor.loginWithSamlToken = function (token, userCallback) { - Accounts.callLoginMethod({ - methodArguments: [ - { - saml: true, - credentialToken: token, - }, - ], - userCallback, - }); -}; diff --git a/apps/meteor/app/meteor-accounts-saml/server/lib/settings.ts b/apps/meteor/app/meteor-accounts-saml/server/lib/settings.ts index 31bb8e37cfacb4c30e139797b973a46506df5635..8f6791c36302de8b584d3ec43ebb2393fe9eb837 100644 --- a/apps/meteor/app/meteor-accounts-saml/server/lib/settings.ts +++ b/apps/meteor/app/meteor-accounts-saml/server/lib/settings.ts @@ -1,5 +1,6 @@ +import type { SAMLConfiguration } from '@rocket.chat/core-typings'; +import { LoginServiceConfiguration } from '@rocket.chat/models'; import { Meteor } from 'meteor/meteor'; -import { ServiceConfiguration } from 'meteor/service-configuration'; import { SystemLogger } from '../../../../server/lib/logger/system'; import { settings, settingsRegistry } from '../../../settings/server'; @@ -17,13 +18,13 @@ import { defaultMetadataCertificateTemplate, } from './constants'; -const getSamlConfigs = function (service: string): Record { - const configs = { +const getSamlConfigs = function (service: string): SAMLConfiguration { + const configs: SAMLConfiguration = { buttonLabelText: settings.get(`${service}_button_label_text`), buttonLabelColor: settings.get(`${service}_button_label_color`), buttonColor: settings.get(`${service}_button_color`), clientConfig: { - provider: settings.get(`${service}_provider`), + provider: settings.get(`${service}_provider`), }, entryPoint: settings.get(`${service}_entry_point`), idpSLORedirectURL: settings.get(`${service}_idp_slo_redirect_url`), @@ -115,19 +116,10 @@ export const loadSamlServiceProviders = async function (): Promise { if (value === true) { const samlConfigs = getSamlConfigs(key); SAMLUtils.log(key); - await ServiceConfiguration.configurations.upsertAsync( - { - service: serviceName.toLowerCase(), - }, - { - $set: samlConfigs, - }, - ); + await LoginServiceConfiguration.createOrUpdateService(serviceName, samlConfigs); return configureSamlService(samlConfigs); } - await ServiceConfiguration.configurations.removeAsync({ - service: serviceName.toLowerCase(), - }); + await LoginServiceConfiguration.removeService(serviceName); return false; }), ) @@ -150,10 +142,12 @@ export const addSettings = async function (name: string): Promise { await this.add(`SAML_Custom_${name}`, false, { type: 'boolean', i18nLabel: 'Accounts_OAuth_Custom_Enable', + public: true, }); await this.add(`SAML_Custom_${name}_provider`, 'provider-name', { type: 'string', i18nLabel: 'SAML_Custom_Provider', + public: true, }); await this.add(`SAML_Custom_${name}_entry_point`, 'https://example.com/simplesaml/saml2/idp/SSOService.php', { type: 'string', @@ -162,6 +156,7 @@ export const addSettings = async function (name: string): Promise { await this.add(`SAML_Custom_${name}_idp_slo_redirect_url`, 'https://example.com/simplesaml/saml2/idp/SingleLogoutService.php', { type: 'string', i18nLabel: 'SAML_Custom_IDP_SLO_Redirect_URL', + public: true, }); await this.add(`SAML_Custom_${name}_issuer`, 'https://your-rocket-chat/_saml/metadata/provider-name', { type: 'string', @@ -262,6 +257,7 @@ export const addSettings = async function (name: string): Promise { { key: 'Local', i18nLabel: 'SAML_Custom_Logout_Behaviour_End_Only_RocketChat' }, ], i18nLabel: 'SAML_Custom_Logout_Behaviour', + public: true, }); await this.add(`SAML_Custom_${name}_channels_update`, false, { type: 'boolean', diff --git a/apps/meteor/app/meteor-accounts-saml/server/methods/samlLogout.ts b/apps/meteor/app/meteor-accounts-saml/server/methods/samlLogout.ts index 2b960059f16404689e9c09cbb7b747ec5d61f604..956426082d405624dac8a54ea14863c37a5ec170 100644 --- a/apps/meteor/app/meteor-accounts-saml/server/methods/samlLogout.ts +++ b/apps/meteor/app/meteor-accounts-saml/server/methods/samlLogout.ts @@ -28,7 +28,7 @@ function getSamlServiceProviderOptions(provider: string): IServiceProviderOption declare module '@rocket.chat/ui-contexts' { // eslint-disable-next-line @typescript-eslint/naming-convention interface ServerMethods { - samlLogout(provider: string): Promise; + samlLogout(provider: string): string | undefined; } } diff --git a/apps/meteor/app/meteor-accounts-saml/server/startup.ts b/apps/meteor/app/meteor-accounts-saml/server/startup.ts index 7a2bf16d3244e1a9f8f6d84519be400675f57237..556ab7df13e7d4625ee14ca5fca34e31b1c4cdf8 100644 --- a/apps/meteor/app/meteor-accounts-saml/server/startup.ts +++ b/apps/meteor/app/meteor-accounts-saml/server/startup.ts @@ -1,4 +1,5 @@ import { Logger } from '@rocket.chat/logger'; +import debounce from 'lodash.debounce'; import { Meteor } from 'meteor/meteor'; import { settings } from '../../settings/server'; @@ -10,5 +11,6 @@ SAMLUtils.setLoggerInstance(logger); Meteor.startup(async () => { await addSettings('Default'); - settings.watchByRegex(/^SAML_.+/, loadSamlServiceProviders); }); + +settings.watchByRegex(/^SAML_.+/, debounce(loadSamlServiceProviders, 2000)); diff --git a/apps/meteor/app/metrics/server/lib/collectMetrics.ts b/apps/meteor/app/metrics/server/lib/collectMetrics.ts index 8213e27ed4f47e3a4ea14510b71b9038dbc0db94..136686f49c9e3ef3a88d07b7a34a10af375fca31 100644 --- a/apps/meteor/app/metrics/server/lib/collectMetrics.ts +++ b/apps/meteor/app/metrics/server/lib/collectMetrics.ts @@ -175,17 +175,10 @@ const updatePrometheusConfig = async (): Promise => { clearInterval(resetTimer); if (is.resetInterval) { resetTimer = setInterval(() => { - client.register - .getMetricsAsArray() - .then((metrics) => { - metrics.forEach((metric) => { - // @ts-expect-error Property 'hashMap' does not exist on type 'metric'. - metric.hashMap = {}; - }); - }) - .catch((err) => { - SystemLogger.error({ msg: 'Error while collecting metrics', err }); - }); + client.register.getMetricsAsArray().forEach((metric) => { + // @ts-expect-error Property 'hashMap' does not exist on type 'metric'. + metric.hashMap = {}; + }); }, is.resetInterval); } diff --git a/apps/meteor/app/metrics/server/lib/metrics.ts b/apps/meteor/app/metrics/server/lib/metrics.ts index 410a218761a01306d56c682cd08f793f1ddfa56b..00bbedce287d0d41d5e27a8b1e72ef680afac540 100644 --- a/apps/meteor/app/metrics/server/lib/metrics.ts +++ b/apps/meteor/app/metrics/server/lib/metrics.ts @@ -1,6 +1,6 @@ import client from 'prom-client'; -const percentiles = [0.01, 0.1, 0.9, 0.99]; +const percentiles = [0.01, 0.1, 0.5, 0.9, 0.95, 0.99, 1]; export const metrics = { deprecations: new client.Counter({ @@ -63,9 +63,13 @@ export const metrics = { labelNames: ['notification_type'], help: 'cumulated number of notifications sent', }), - messageRoundtripTime: new client.Gauge({ - name: 'rocketchat_messages_roundtrip_time', + messageRoundtripTime: new client.Summary({ + name: 'rocketchat_messages_roundtrip_time_summary', help: 'time spent by a message from save to receive back', + percentiles, + maxAgeSeconds: 60, + ageBuckets: 5, + // pruneAgedBuckets: true, // Type not added to prom-client on 14.2 https://github.com/siimon/prom-client/pull/558 }), ddpSessions: new client.Gauge({ diff --git a/apps/meteor/app/models/client/index.ts b/apps/meteor/app/models/client/index.ts index b4c540318a9bfccdc409b6fd2035c641b291811a..354baa2b71fd5b6778f32e3f220ae9c2ff9c753c 100644 --- a/apps/meteor/app/models/client/index.ts +++ b/apps/meteor/app/models/client/index.ts @@ -14,20 +14,6 @@ import { RoomRoles } from './models/RoomRoles'; import { UserAndRoom } from './models/UserAndRoom'; import { UserRoles } from './models/UserRoles'; import { Users } from './models/Users'; -import { WebdavAccounts } from './models/WebdavAccounts'; - -// overwrite Meteor.users collection so records on it don't get erased whenever the client reconnects to websocket -const meteorUserOverwrite = () => { - const uid = Meteor.userId(); - - if (!uid) { - return null; - } - - return (Users.findOne({ _id: uid }) ?? null) as Meteor.User | null; -}; -Meteor.users = Users as typeof Meteor.users; -Meteor.user = meteorUserOverwrite; export { Base, @@ -43,7 +29,6 @@ export { ChatPermissions, CustomSounds, EmojiCustom, - WebdavAccounts, /** @deprecated */ Users, /** @deprecated */ diff --git a/apps/meteor/app/models/client/models/ChatPermissions.ts b/apps/meteor/app/models/client/models/ChatPermissions.ts index 8f1c7b18c060c59a27e0ae992c1e8f100d61d621..e836f58ebb2e1a4c05896b522254929ad663077e 100644 --- a/apps/meteor/app/models/client/models/ChatPermissions.ts +++ b/apps/meteor/app/models/client/models/ChatPermissions.ts @@ -4,7 +4,7 @@ import { CachedCollection } from '../../../ui-cached-collection/client'; export const AuthzCachedCollection = new CachedCollection({ name: 'permissions', - eventType: 'onLogged', + eventType: 'notify-logged', }); export const ChatPermissions = AuthzCachedCollection.collection; diff --git a/apps/meteor/app/models/client/models/WebdavAccounts.ts b/apps/meteor/app/models/client/models/WebdavAccounts.ts deleted file mode 100644 index fbc1092cd86e3d44391c4ffbcb29e553bedbb48d..0000000000000000000000000000000000000000 --- a/apps/meteor/app/models/client/models/WebdavAccounts.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { Mongo } from 'meteor/mongo'; - -/** @deprecated */ -export const WebdavAccounts = new Mongo.Collection<{ - _id: string; - name: string; - username: string; - serverURL: string; -}>(null); diff --git a/apps/meteor/app/nextcloud/client/lib.ts b/apps/meteor/app/nextcloud/client/lib.ts index 12a54217691c84a0310f39d753d4b44d37114382..fb7f5391bc3a99404c5d9422bd975d6eb781c3c5 100644 --- a/apps/meteor/app/nextcloud/client/lib.ts +++ b/apps/meteor/app/nextcloud/client/lib.ts @@ -3,7 +3,7 @@ import { Meteor } from 'meteor/meteor'; import { Tracker } from 'meteor/tracker'; import _ from 'underscore'; -import { CustomOAuth } from '../../custom-oauth/client/custom_oauth_client'; +import { CustomOAuth } from '../../custom-oauth/client/CustomOAuth'; import { settings } from '../../settings/client'; const config: OauthConfig = { diff --git a/apps/meteor/app/notifications/client/index.ts b/apps/meteor/app/notifications/client/index.ts index 1cb2af14e70edd6925b1e306bcb809f618bcd49b..47c2de413740719c0843951dab1ef6bdbac2ea73 100644 --- a/apps/meteor/app/notifications/client/index.ts +++ b/apps/meteor/app/notifications/client/index.ts @@ -1,3 +1 @@ -import Notifications from './lib/Notifications'; - -export { Notifications }; +import './lib/Presence'; diff --git a/apps/meteor/app/notifications/client/lib/Notifications.ts b/apps/meteor/app/notifications/client/lib/Notifications.ts deleted file mode 100644 index 8d77b9e3a0cb7cf8fa721cd699e7d3248ec5d371..0000000000000000000000000000000000000000 --- a/apps/meteor/app/notifications/client/lib/Notifications.ts +++ /dev/null @@ -1,102 +0,0 @@ -import type { StreamKeys, StreamerCallback } from '@rocket.chat/ddp-client/src/types/streams'; -import { Meteor } from 'meteor/meteor'; -import { Tracker } from 'meteor/tracker'; - -import './Presence'; -import { sdk } from '../../../utils/client/lib/SDKClient'; - -type ExtractSecondString = E extends `${string}/${infer X}` ? X : never; - -class Notifications { - private logged: boolean; - - private loginCb: any[]; - - private debug: boolean; - - constructor() { - this.logged = Meteor.userId() !== null; - this.loginCb = []; - Tracker.autorun(() => { - if (Meteor.userId() !== null && this.logged === false) { - this.loginCb.forEach((cb) => cb()); - } - this.logged = Meteor.userId() !== null; - }); - this.debug = false; - } - - onLogged>(eventName: E, callback: StreamerCallback<'notify-logged', E>) { - return this.onLogin(() => sdk.stream('notify-logged', [eventName], callback)); - } - - onAll>(eventName: E, callback: StreamerCallback<'notify-all', E>) { - return sdk.stream('notify-all', [eventName], callback); - } - - onRoom>>( - room: string, - eventName: E, - callback: StreamerCallback<'notify-room', `${string}/${E}`>, - ) { - return sdk.stream('notify-room', [`${room}/${eventName}`], callback); - } - - onUser>>( - eventName: E, - callback: StreamerCallback<'notify-user', `${string}/${E}`>, - ) { - return sdk.stream('notify-user', [`${Meteor.userId()}/${eventName}`], callback); - } - - onVisitor>>( - visitor: string, - eventName: E, - callback: StreamerCallback<'notify-user', `${string}/${E}`>, - ) { - return sdk.stream('notify-user', [`${visitor}/${eventName}`], callback); - } - - unUser>>(eventName: E) { - return sdk.stop('notify-user', `${Meteor.userId()}/${eventName}`); - } - - unRoom>>(room: string, eventName: E) { - return sdk.stop('notify-room', `${room}/${eventName}`); - } - - onLogin(cb: () => void) { - this.loginCb.push(cb); - if (this.logged) { - return cb(); - } - } - - notifyRoom>>(room: string, eventName: E, ...args: any[]) { - args.unshift(`${room}/${eventName}`); - return sdk.publish('notify-room', args); - } - - notifyVisitor>>(visitor: string, eventName: E, ...args: any[]) { - args.unshift(`${visitor}/${eventName}`); - return sdk.publish('notify-user', args); - } - - notifyUser>>(uid: string, eventName: E, ...args: any[]) { - args.unshift(`${uid}/${eventName}`); - return sdk.publish('notify-user', args); - } - - notifyUsersOfRoom>>(room: string, eventName: E, ...args: any[]) { - if (this.debug === true) { - console.log('RocketChat.Notifications: notifyUsersOfRoomExceptSender', [room, eventName, ...args]); - } - args.unshift(`${room}/${eventName}`); - return sdk.publish('notify-room-users', args); - } -} - -/** @deprecated it should be used `sdk`instead both perform the same */ -const ns = new Notifications(); - -export default ns; diff --git a/apps/meteor/app/notifications/client/lib/Presence.ts b/apps/meteor/app/notifications/client/lib/Presence.ts index 69c255aaf1f966e5a63ff868444e4a6e6d5bfb44..8cff2ed84f61720f0b57b62c9a4b4a54930a483e 100644 --- a/apps/meteor/app/notifications/client/lib/Presence.ts +++ b/apps/meteor/app/notifications/client/lib/Presence.ts @@ -1,17 +1,15 @@ -import type { StreamerEvents } from '@rocket.chat/ui-contexts'; +import type { UserStatus } from '@rocket.chat/core-typings'; import { Meteor } from 'meteor/meteor'; -import { Presence, STATUS_MAP } from '../../../../client/lib/presence'; +import { Presence } from '../../../../client/lib/presence'; // TODO implement API on Streamer to be able to listen to all streamed data // this is a hacky way to listen to all streamed data from user-presence Streamer new Meteor.Streamer('user-presence'); -(Meteor as any).StreamerCentral.on('stream-user-presence', (uid: string, ...args: StreamerEvents['user-presence'][number]['args']) => { - if (!Array.isArray(args)) { - throw new Error('Presence event must be an array'); - } - const [[username, status, statusText]] = args; - Presence.notify({ _id: uid, username, status: STATUS_MAP[status ?? 0], statusText }); +type args = [username: string, statusChanged?: UserStatus, statusText?: string]; + +Meteor.StreamerCentral.on('stream-user-presence', (uid: string, [username, statusChanged, statusText]: args) => { + Presence.notify({ _id: uid, username, status: statusChanged, statusText }); }); diff --git a/apps/meteor/app/oembed/server/server.ts b/apps/meteor/app/oembed/server/server.ts index 79de0402043f5ad2d0c2c8fe7c0fb5bf282c9e8f..1e758b1371e8788ae40878aeb518af5055ad3b95 100644 --- a/apps/meteor/app/oembed/server/server.ts +++ b/apps/meteor/app/oembed/server/server.ts @@ -1,5 +1,12 @@ -import type { OEmbedUrlContentResult, OEmbedUrlWithMetadata, IMessage, MessageAttachment, OEmbedMeta } from '@rocket.chat/core-typings'; -import { isOEmbedUrlContentResult, isOEmbedUrlWithMetadata } from '@rocket.chat/core-typings'; +import type { + OEmbedUrlContentResult, + OEmbedUrlWithMetadata, + IMessage, + MessageAttachment, + OEmbedMeta, + MessageUrl, +} from '@rocket.chat/core-typings'; +import { isOEmbedUrlWithMetadata } from '@rocket.chat/core-typings'; import { Logger } from '@rocket.chat/logger'; import { Messages, OEmbedCache } from '@rocket.chat/models'; import { serverFetch as fetch } from '@rocket.chat/server-fetch'; @@ -128,6 +135,41 @@ const getUrlContent = async (urlObj: URL, redirectCount = 5): Promise { + const parsedUrlObject: MessageUrl = { url, meta: {} }; + let foundMeta = false; + if (!isURL(url)) { + return { urlPreview: parsedUrlObject, foundMeta }; + } + + const data = await getUrlMetaWithCache(url); + if (!data) { + return { urlPreview: parsedUrlObject, foundMeta }; + } + + if (isOEmbedUrlWithMetadata(data) && data.meta) { + parsedUrlObject.meta = getRelevantMetaTags(data.meta) || {}; + if (parsedUrlObject.meta?.oembedHtml) { + parsedUrlObject.meta.oembedHtml = insertMaxWidthInOembedHtml(parsedUrlObject.meta.oembedHtml) || ''; + } + } + + foundMeta = true; + return { + urlPreview: { + ...parsedUrlObject, + ...((parsedUrlObject.headers || data.headers) && { + headers: { + ...parsedUrlObject.headers, + ...(data.headers?.contentLength && { contentLength: data.headers.contentLength }), + ...(data.headers?.contentType && { contentType: data.headers.contentType }), + }, + }), + }, + foundMeta, + }; +}; + const getUrlMeta = async function ( url: string, withFragment?: boolean, @@ -151,10 +193,6 @@ const getUrlMeta = async function ( return; } - if (content.attachments) { - return content; - } - log.debug('Parsing metadata for URL', url); const metas: { [k: string]: string } = {}; @@ -273,37 +311,10 @@ const rocketUrlParser = async function (message: IMessage): Promise { continue; } - if (!isURL(item.url)) { - continue; - } - - const data = await getUrlMetaWithCache(item.url); - - if (!data) { - continue; - } - - if (isOEmbedUrlContentResult(data) && data.attachments) { - attachments.push(...data.attachments); - break; - } - - if (isOEmbedUrlWithMetadata(data) && data.meta) { - item.meta = getRelevantMetaTags(data.meta) || {}; - if (item.meta?.oembedHtml) { - item.meta.oembedHtml = insertMaxWidthInOembedHtml(item.meta.oembedHtml) || ''; - } - } - - if (data.headers?.contentLength) { - item.headers = { ...item.headers, contentLength: data.headers.contentLength }; - } - - if (data.headers?.contentType) { - item.headers = { ...item.headers, contentType: data.headers.contentType }; - } + const { urlPreview, foundMeta } = await parseUrl(item.url); - changed = true; + Object.assign(item, foundMeta ? urlPreview : {}); + changed = changed || foundMeta; } if (attachments.length) { @@ -321,10 +332,12 @@ const OEmbed: { getUrlMeta: (url: string, withFragment?: boolean) => Promise; getUrlMetaWithCache: (url: string, withFragment?: boolean) => Promise; rocketUrlParser: (message: IMessage) => Promise; + parseUrl: (url: string) => Promise<{ urlPreview: MessageUrl; foundMeta: boolean }>; } = { rocketUrlParser, getUrlMetaWithCache, getUrlMeta, + parseUrl, }; settings.watch('API_Embed', (value) => { diff --git a/apps/meteor/app/otr/client/OTR.ts b/apps/meteor/app/otr/client/OTR.ts index 55e0a1289a67989ed1cc906f18d9a1e676a27e03..a855381bd9c05edac259da8bc108eaaf9d27f57b 100644 --- a/apps/meteor/app/otr/client/OTR.ts +++ b/apps/meteor/app/otr/client/OTR.ts @@ -1,51 +1,28 @@ -import { Meteor } from 'meteor/meteor'; -import { ReactiveVar } from 'meteor/reactive-var'; +import type { IRoom, IUser } from '@rocket.chat/core-typings'; -import { Subscriptions } from '../../models/client'; import type { IOTR } from '../lib/IOTR'; import { OTRRoom } from './OTRRoom'; class OTR implements IOTR { - private enabled: ReactiveVar; - private instancesByRoomId: { [rid: string]: OTRRoom }; constructor() { - this.enabled = new ReactiveVar(false); this.instancesByRoomId = {}; } - isEnabled(): boolean { - return this.enabled.get(); - } - - setEnabled(enabled: boolean): void { - this.enabled.set(enabled); - } - - getInstanceByRoomId(roomId: string): OTRRoom | undefined { - const userId = Meteor.userId(); - if (!userId) { - return; - } - if (!this.enabled.get()) { - return; - } - - if (this.instancesByRoomId[roomId]) { - return this.instancesByRoomId[roomId]; + getInstanceByRoomId(uid: IUser['_id'], rid: IRoom['_id']): OTRRoom | undefined { + if (this.instancesByRoomId[rid]) { + return this.instancesByRoomId[rid]; } - const subscription = Subscriptions.findOne({ - rid: roomId, - }); + const otrRoom = OTRRoom.create(uid, rid); - if (!subscription || subscription.t !== 'd') { - return; + if (!otrRoom) { + return undefined; } - this.instancesByRoomId[roomId] = new OTRRoom(userId, roomId); - return this.instancesByRoomId[roomId]; + this.instancesByRoomId[rid] = otrRoom; + return this.instancesByRoomId[rid]; } } diff --git a/apps/meteor/app/otr/client/OTRRoom.ts b/apps/meteor/app/otr/client/OTRRoom.ts index 0fdcecd91a2863bed657bde9952fe2db1b7e231a..8899de13b1908317e371d8eb5d954d5b72ee0cac 100644 --- a/apps/meteor/app/otr/client/OTRRoom.ts +++ b/apps/meteor/app/otr/client/OTRRoom.ts @@ -1,4 +1,4 @@ -import type { IMessage } from '@rocket.chat/core-typings'; +import type { IRoom, IMessage, IUser } from '@rocket.chat/core-typings'; import { Random } from '@rocket.chat/random'; import EJSON from 'ejson'; import { Meteor } from 'meteor/meteor'; @@ -11,7 +11,6 @@ import { Presence } from '../../../client/lib/presence'; import { dispatchToastMessage } from '../../../client/lib/toast'; import { getUidDirectMessage } from '../../../client/lib/utils/getUidDirectMessage'; import { goToRoomById } from '../../../client/lib/utils/goToRoomById'; -import { Notifications } from '../../notifications/client'; import { sdk } from '../../utils/client/lib/SDKClient'; import { t } from '../../utils/lib/i18n'; import type { IOnUserStreamData, IOTRAlgorithm, IOTRDecrypt, IOTRRoom } from '../lib/IOTR'; @@ -48,15 +47,25 @@ export class OTRRoom implements IOTRRoom { private isFirstOTR: boolean; - constructor(userId: string, roomId: string) { - this._userId = userId; - this._roomId = roomId; + protected constructor(uid: IUser['_id'], rid: IRoom['_id'], peerId: IUser['_id']) { + this._userId = uid; + this._roomId = rid; this._keyPair = null; this._sessionKey = null; - this.peerId = getUidDirectMessage(roomId) as string; + this.peerId = peerId; this.isFirstOTR = true; } + public static create(uid: IUser['_id'], rid: IRoom['_id']): OTRRoom | undefined { + const peerId = getUidDirectMessage(rid); + + if (!peerId) { + return undefined; + } + + return new OTRRoom(uid, rid, peerId); + } + getPeerId(): string { return this.peerId; } @@ -75,61 +84,71 @@ export class OTRRoom implements IOTRRoom { async handshake(refresh?: boolean): Promise { this.setState(OtrRoomState.ESTABLISHING); - try { - await this.generateKeyPair(); - this.peerId && - Notifications.notifyUser(this.peerId, 'otr', 'handshake', { - roomId: this._roomId, - userId: this._userId, - publicKey: EJSON.stringify(this._exportedPublicKey), - refresh, - }); - if (refresh) { - const user = Meteor.user(); - if (!user) { - return; - } - await sdk.rest.post('/v1/chat.otr', { - roomId: this._roomId, - type: otrSystemMessages.USER_REQUESTED_OTR_KEY_REFRESH, - }); - this.isFirstOTR = false; + + await this.generateKeyPair(); + sdk.publish('notify-user', [ + `${this.peerId}/otr`, + 'handshake', + { + roomId: this._roomId, + userId: this._userId, + publicKey: EJSON.stringify(this._exportedPublicKey), + refresh, + }, + ]); + + if (refresh) { + const user = Meteor.user(); + if (!user) { + return; } - } catch (e) { - throw e; + await sdk.rest.post('/v1/chat.otr', { + roomId: this._roomId, + type: otrSystemMessages.USER_REQUESTED_OTR_KEY_REFRESH, + }); + this.isFirstOTR = false; } } acknowledge(): void { void sdk.rest.post('/v1/statistics.telemetry', { params: [{ eventName: 'otrStats', timestamp: Date.now(), rid: this._roomId }] }); - this.peerId && - Notifications.notifyUser(this.peerId, 'otr', 'acknowledge', { + sdk.publish('notify-user', [ + `${this.peerId}/otr`, + 'acknowledge', + { roomId: this._roomId, userId: this._userId, publicKey: EJSON.stringify(this._exportedPublicKey), - }); + }, + ]); } deny(): void { this.reset(); this.setState(OtrRoomState.DECLINED); - this.peerId && - Notifications.notifyUser(this.peerId, 'otr', 'deny', { + sdk.publish('notify-user', [ + `${this.peerId}/otr`, + 'deny', + { roomId: this._roomId, userId: this._userId, - }); + }, + ]); } end(): void { this.isFirstOTR = true; this.reset(); this.setState(OtrRoomState.NOT_STARTED); - this.peerId && - Notifications.notifyUser(this.peerId, 'otr', 'end', { + sdk.publish('notify-user', [ + `${this.peerId}/otr`, + 'end', + { roomId: this._roomId, userId: this._userId, - }); + }, + ]); } reset(): void { @@ -145,14 +164,14 @@ export class OTRRoom implements IOTRRoom { } this._userOnlineComputation = Tracker.autorun(() => { - const $room = $(`#chat-window-${this._roomId}`); - const $title = $('.rc-header__title', $room); + const $room = document.querySelector(`#chat-window-${this._roomId}`); + const $title = $room?.querySelector('.rc-header__title'); if (this.getState() === OtrRoomState.ESTABLISHED) { - if ($room.length && $title.length && !$('.otr-icon', $title).length) { + if ($room && $title && !$title.querySelector('.otr-icon')) { $title.prepend(""); } - } else if ($title.length) { - $('.otr-icon', $title).remove(); + } else if ($title) { + $title.querySelector('.otr-icon')?.remove(); } }); try { diff --git a/apps/meteor/app/otr/lib/IOTR.ts b/apps/meteor/app/otr/lib/IOTR.ts index 051752ab947fb2eb62554707009021129803c5a5..9e7cd4ca6b8e25b07223bc250221df4b4e619aba 100644 --- a/apps/meteor/app/otr/lib/IOTR.ts +++ b/apps/meteor/app/otr/lib/IOTR.ts @@ -36,9 +36,7 @@ export interface IOTRRoom { } export interface IOTR { - isEnabled(): boolean; - setEnabled(enabled: boolean): void; - getInstanceByRoomId(roomId: IRoom['_id']): OTRRoom | undefined; + getInstanceByRoomId(userId: IUser['_id'], roomId: IRoom['_id']): OTRRoom | undefined; } export interface IOTRAlgorithm extends EcKeyAlgorithm, EcdhKeyDeriveParams {} diff --git a/apps/meteor/app/otr/server/methods/updateOTRAck.ts b/apps/meteor/app/otr/server/methods/updateOTRAck.ts index eaba2906ed06e470c223562c40eaf5a731e2546e..745c70aa4538aaa8391d142b516141672feedcf6 100644 --- a/apps/meteor/app/otr/server/methods/updateOTRAck.ts +++ b/apps/meteor/app/otr/server/methods/updateOTRAck.ts @@ -1,3 +1,4 @@ +import type { IOTRMessage } from '@rocket.chat/core-typings'; import type { ServerMethods } from '@rocket.chat/ui-contexts'; import { Meteor } from 'meteor/meteor'; @@ -6,7 +7,7 @@ import notifications from '../../../notifications/server/lib/Notifications'; declare module '@rocket.chat/ui-contexts' { // eslint-disable-next-line @typescript-eslint/naming-convention interface ServerMethods { - updateOTRAck({ message, ack }: { message: any; ack: any }): void; + updateOTRAck({ message, ack }: { message: IOTRMessage; ack: string }): void; } } @@ -15,7 +16,7 @@ Meteor.methods({ if (!Meteor.userId()) { throw new Meteor.Error('error-invalid-user', 'Invalid user', { method: 'updateOTRAck' }); } - const otrStreamer = notifications.streamRoomMessage; - otrStreamer.emit(message.rid, { ...message, otr: { ack } }); + const acknowlegedMessage: IOTRMessage = { ...message, otrAck: ack }; + notifications.streamRoomMessage.emit(message.rid, acknowlegedMessage); }, }); diff --git a/apps/meteor/app/push/server/push.ts b/apps/meteor/app/push/server/push.ts index a80631d335bf0a4687b16bb906ca56d99cb51b73..2594625155faf5b1fa2f00d8884d4ef79dbe6e75 100644 --- a/apps/meteor/app/push/server/push.ts +++ b/apps/meteor/app/push/server/push.ts @@ -283,11 +283,11 @@ class PushClass { logger.debug('GUIDE: The "AppsTokens" is empty - No clients have registered on the server yet...'); } } else if (!countApn.length) { - if ((await AppsTokens.col.countDocuments({ 'token.apn': { $exists: true } })) === 0) { + if ((await AppsTokens.countApnTokens()) === 0) { logger.debug('GUIDE: The "AppsTokens" - No APN clients have registered on the server yet...'); } } else if (!countGcm.length) { - if ((await AppsTokens.col.countDocuments({ 'token.gcm': { $exists: true } })) === 0) { + if ((await AppsTokens.countGcmTokens()) === 0) { logger.debug('GUIDE: The "AppsTokens" - No GCM clients have registered on the server yet...'); } } diff --git a/apps/meteor/app/reactions/client/init.ts b/apps/meteor/app/reactions/client/init.ts index 24840b9de7cf1d5328bd4bc81c5e9368a46515f3..1943d7262939a982a7ba858f6fa33bfd4cbb172c 100644 --- a/apps/meteor/app/reactions/client/init.ts +++ b/apps/meteor/app/reactions/client/init.ts @@ -13,8 +13,8 @@ Meteor.startup(() => { context: ['message', 'message-mobile', 'threads', 'federated', 'videoconf', 'videoconf-threads'], action(event, props) { const { message = messageArgs(this).msg, chat } = props; - event.stopPropagation(); - chat?.emojiPicker.open(event.currentTarget! as Element, (emoji) => sdk.call('setReaction', `:${emoji}:`, message._id)); + event?.stopPropagation(); + chat?.emojiPicker.open(event?.currentTarget as Element, (emoji) => sdk.call('setReaction', `:${emoji}:`, message._id)); }, condition({ message, user, room, subscription }) { if (!room) { diff --git a/apps/meteor/app/reactions/server/setReaction.ts b/apps/meteor/app/reactions/server/setReaction.ts index fab1100fc6159a4abb16a24181557c732118071f..27fe4d36a053f5b8a541b5dea00923144411e92e 100644 --- a/apps/meteor/app/reactions/server/setReaction.ts +++ b/apps/meteor/app/reactions/server/setReaction.ts @@ -8,7 +8,7 @@ import _ from 'underscore'; import { AppEvents, Apps } from '../../../ee/server/apps/orchestrator'; import { callbacks } from '../../../lib/callbacks'; import { i18n } from '../../../server/lib/i18n'; -import { broadcastMessageSentEvent } from '../../../server/modules/watchers/lib/messages'; +import { broadcastMessageFromData } from '../../../server/modules/watchers/lib/messages'; import { canAccessRoomAsync } from '../../authorization/server'; import { hasPermissionAsync } from '../../authorization/server/functions/hasPermission'; import { emoji } from '../../emoji/server'; @@ -108,9 +108,8 @@ async function setReaction(room: IRoom, user: IUser, message: IMessage, reaction await Apps.triggerEvent(AppEvents.IPostMessageReacted, message, user, reaction, isReacted); - void broadcastMessageSentEvent({ + void broadcastMessageFromData({ id: message._id, - broadcastCallback: (message) => api.broadcast('message.sent', message), }); } diff --git a/apps/meteor/app/slackbridge/server/SlackAdapter.js b/apps/meteor/app/slackbridge/server/SlackAdapter.js index 0fb4ee8a71254ad3e010a00795ad0b919f91aa28..78d48deb4993591cabbf57353b8a88c1b46f3487 100644 --- a/apps/meteor/app/slackbridge/server/SlackAdapter.js +++ b/apps/meteor/app/slackbridge/server/SlackAdapter.js @@ -1169,6 +1169,7 @@ export default class SlackAdapter { async processPinnedItemMessage(rocketChannel, rocketUser, slackMessage, isImporting) { if (slackMessage.attachments && slackMessage.attachments[0] && slackMessage.attachments[0].text) { + // TODO: refactor this logic to use the service to send this system message instead of using sendMessage const rocketMsgObj = { rid: rocketChannel._id, t: 'message_pinned', @@ -1380,6 +1381,7 @@ export default class SlackAdapter { for await (const pin of items) { if (pin.message) { const user = await this.rocket.findUser(pin.message.user); + // TODO: send this system message to the room as well (using the service) const msgObj = { rid, t: 'message_pinned', diff --git a/apps/meteor/app/slashcommands-archiveroom/server/server.ts b/apps/meteor/app/slashcommands-archiveroom/server/server.ts index 46708c6676788d2b19ebb4d2d79e2006e8e1a468..99bcec2cd7b34f06745ba78f049b741c5ed09685 100644 --- a/apps/meteor/app/slashcommands-archiveroom/server/server.ts +++ b/apps/meteor/app/slashcommands-archiveroom/server/server.ts @@ -4,7 +4,10 @@ import { isRegisterUser } from '@rocket.chat/core-typings'; import { Users, Rooms } from '@rocket.chat/models'; import { Meteor } from 'meteor/meteor'; +import { RoomMemberActions } from '../../../definition/IRoomTypeConfig'; import { i18n } from '../../../server/lib/i18n'; +import { roomCoordinator } from '../../../server/lib/rooms/roomCoordinator'; +import { hasPermissionAsync } from '../../authorization/server/functions/hasPermission'; import { archiveRoom } from '../../lib/server/functions/archiveRoom'; import { settings } from '../../settings/server'; import { slashCommands } from '../../utils/lib/slashCommand'; @@ -25,6 +28,7 @@ slashCommands.add({ channel = channel.replace('#', ''); room = await Rooms.findOneByName(channel); } + if (!userId) { return; } @@ -45,9 +49,12 @@ slashCommands.add({ return; } - // You can not archive direct messages. - if (room.t === 'd') { - return; + if (!(await roomCoordinator.getRoomDirectives(room.t).allowMemberAction(room, RoomMemberActions.ARCHIVE, userId))) { + throw new Meteor.Error('error-room-type-not-archivable', `Room type: ${room.t} can not be archived`); + } + + if (!(await hasPermissionAsync(userId, 'archive-room', room._id))) { + throw new Meteor.Error('error-not-authorized', 'Not authorized'); } if (room.archived) { diff --git a/apps/meteor/app/slashcommands-unarchiveroom/server/server.ts b/apps/meteor/app/slashcommands-unarchiveroom/server/server.ts index 9c6a68b311551cbb5c27d2b989302e23a5fc1006..d87981bd65a24136362ef74633c67aa8010f8a41 100644 --- a/apps/meteor/app/slashcommands-unarchiveroom/server/server.ts +++ b/apps/meteor/app/slashcommands-unarchiveroom/server/server.ts @@ -7,6 +7,7 @@ import { Meteor } from 'meteor/meteor'; import { RoomMemberActions } from '../../../definition/IRoomTypeConfig'; import { i18n } from '../../../server/lib/i18n'; import { roomCoordinator } from '../../../server/lib/rooms/roomCoordinator'; +import { hasPermissionAsync } from '../../authorization/server/functions/hasPermission'; import { unarchiveRoom } from '../../lib/server/functions/unarchiveRoom'; import { settings } from '../../settings/server'; import { slashCommands } from '../../utils/lib/slashCommand'; @@ -47,9 +48,12 @@ slashCommands.add({ return; } - // You can not archive direct messages. if (!(await roomCoordinator.getRoomDirectives(room.t).allowMemberAction(room, RoomMemberActions.ARCHIVE, userId))) { - return; + throw new Meteor.Error('error-room-type-not-unarchivable', `Room type: ${room.t} can not be unarchived`); + } + + if (!(await hasPermissionAsync(userId, 'unarchive-room', room._id))) { + throw new Meteor.Error('error-not-authorized', 'Not authorized'); } if (!room.archived) { diff --git a/apps/meteor/app/statistics/server/lib/SAUMonitor.ts b/apps/meteor/app/statistics/server/lib/SAUMonitor.ts index b3aa683371066326f1e0bbe8f5b90a798f003d78..0db63a92949145fb97945b96484a3fbd4a903d52 100644 --- a/apps/meteor/app/statistics/server/lib/SAUMonitor.ts +++ b/apps/meteor/app/statistics/server/lib/SAUMonitor.ts @@ -146,7 +146,7 @@ export class SAUMonitorClass { const searchTerm = this._getSearchTerm(data); - await Sessions.insertOne({ ...data, searchTerm, createdAt: new Date() }); + await Sessions.createOrUpdate({ ...data, searchTerm }); } private async _finishSessionsFromDate(yesterday: Date, today: Date): Promise { diff --git a/apps/meteor/app/statistics/server/lib/statistics.ts b/apps/meteor/app/statistics/server/lib/statistics.ts index c0ef16b5025b80675de4d912413857155ee41a15..8887f4ce36f2108805ad0c6d4ec549329c889156 100644 --- a/apps/meteor/app/statistics/server/lib/statistics.ts +++ b/apps/meteor/app/statistics/server/lib/statistics.ts @@ -555,7 +555,7 @@ export const statistics = { statistics.totalLinkInvitationUses = await Invites.countUses(); statistics.totalEmailInvitation = settings.get('Invitation_Email_Count'); statistics.totalE2ERooms = await Rooms.findByE2E({ readPreference }).count(); - statistics.logoChange = Object.keys(settings.get('Assets_logo')).includes('url'); + statistics.logoChange = Object.keys(settings.get('Assets_logo') || {}).includes('url'); statistics.showHomeButton = settings.get('Layout_Show_Home_Button'); statistics.totalEncryptedMessages = await Messages.countByType('e2e', { readPreference }); statistics.totalManuallyAddedUsers = settings.get('Manual_Entry_User_Count'); @@ -592,7 +592,11 @@ export const statistics = { const defaultLoggedInCustomScript = (await Settings.findOneById('Custom_Script_Logged_In'))?.packageValue; statistics.loggedInCustomScriptChanged = settings.get('Custom_Script_Logged_In') !== defaultLoggedInCustomScript; - statistics.dailyPeakConnections = await Presence.getPeakConnections(true); + try { + statistics.dailyPeakConnections = await Presence.getPeakConnections(true); + } catch { + statistics.dailyPeakConnections = 0; + } const peak = await Statistics.findMonthlyPeakConnections(); statistics.maxMonthlyPeakConnections = Math.max(statistics.dailyPeakConnections, peak?.dailyPeakConnections || 0); diff --git a/apps/meteor/app/theme/client/imports/general/base.css b/apps/meteor/app/theme/client/imports/general/base.css index d1f4b8d11fb64f7b76273934efe7c4baea81c744..9aa9b6c5ea9f9b75d20710c28fcdc94b6dfe91a0 100644 --- a/apps/meteor/app/theme/client/imports/general/base.css +++ b/apps/meteor/app/theme/client/imports/general/base.css @@ -34,7 +34,7 @@ body { } :focus { - outline: 0 !important; + outline: 0; outline-style: none; outline-color: transparent; } @@ -50,10 +50,6 @@ body { } } -a { - cursor: pointer; -} - button { padding: 0; 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 7f1ede6067fc570e2100dfe3947e6c0e30044f01..cead4a2cb584ee6fcac925d33562512680b7396a 100644 --- a/apps/meteor/app/theme/client/imports/general/base_old.css +++ b/apps/meteor/app/theme/client/imports/general/base_old.css @@ -86,8 +86,6 @@ font-size: smaller; & a { - text-decoration: underline; - font-weight: bold !important; } } @@ -605,7 +603,7 @@ overflow: hidden; flex-direction: column; - margin: 5px 10px 0; + margin: 8px 10px 0; transition: transform 0.4s ease, visibility 0.3s ease, opacity 0.3s ease; transform: translateY(-10px); diff --git a/apps/meteor/app/theme/client/imports/general/reset.css b/apps/meteor/app/theme/client/imports/general/reset.css index ad5cbd9cddf24876dc161de246a0f1ce6d3c8122..425d9ff98319225a7814b6d6e66af138c1bb0b80 100644 --- a/apps/meteor/app/theme/client/imports/general/reset.css +++ b/apps/meteor/app/theme/client/imports/general/reset.css @@ -137,7 +137,3 @@ table { border-collapse: collapse; } - -a { - color: var(--rcx-color-font-info, #095ad2); -} diff --git a/apps/meteor/app/threads/client/lib/normalizeThreadTitle.ts b/apps/meteor/app/threads/client/lib/normalizeThreadTitle.ts index 41a55aefc5cfab391ff8c71a907cef59c169095e..70a2a6008e56d235559091eff80993cdc868a1b4 100644 --- a/apps/meteor/app/threads/client/lib/normalizeThreadTitle.ts +++ b/apps/meteor/app/threads/client/lib/normalizeThreadTitle.ts @@ -15,7 +15,7 @@ export function normalizeThreadTitle({ ...message }: Readonly) { return filteredMessage; } const uid = Meteor.userId(); - const me = uid && Users.findOne(uid, { fields: { username: 1 } })?.username; + const me = (uid && Users.findOne(uid, { fields: { username: 1 } })?.username) || ''; const pattern = settings.get('UTF8_User_Names_Validation'); const useRealName = settings.get('UI_Use_Real_Name'); diff --git a/apps/meteor/app/threads/client/messageAction/replyInThread.ts b/apps/meteor/app/threads/client/messageAction/replyInThread.ts index 03f6606a2073f9de05de54a0a43465f61c5d0811..01d007e0d95321697597c5718d4db7a771664a5c 100644 --- a/apps/meteor/app/threads/client/messageAction/replyInThread.ts +++ b/apps/meteor/app/threads/client/messageAction/replyInThread.ts @@ -19,7 +19,7 @@ Meteor.startup(() => { context: ['message', 'message-mobile', 'federated', 'videoconf'], action(e, props) { const { message = messageArgs(this).msg } = props; - e.stopPropagation(); + e?.stopPropagation(); router.navigate({ name: router.getRouteName()!, params: { diff --git a/apps/meteor/app/threads/server/functions.ts b/apps/meteor/app/threads/server/functions.ts index e79eb72ba0c58e66c13f91b41542c7ae04b0259f..30daef8b8b933bab69fdf97f6d4d174e1fa9671b 100644 --- a/apps/meteor/app/threads/server/functions.ts +++ b/apps/meteor/app/threads/server/functions.ts @@ -1,8 +1,8 @@ import type { IMessage } from '@rocket.chat/core-typings'; import { isEditedMessage } from '@rocket.chat/core-typings'; -import { Messages, Subscriptions, ReadReceipts } from '@rocket.chat/models'; +import { Messages, Subscriptions, ReadReceipts, NotificationQueue } from '@rocket.chat/models'; -import { getMentions } from '../../lib/server/lib/notifyUsersOnMessage'; +import { getMentions, getUserIdsFromHighlights } from '../../lib/server/lib/notifyUsersOnMessage'; export async function reply({ tmid }: { tmid?: string }, message: IMessage, parentMessage: IMessage, followers: string[]) { const { rid, ts, u } = message; @@ -19,7 +19,9 @@ export async function reply({ tmid }: { tmid?: string }, message: IMessage, pare ...(Array.isArray(parentMessage.replies) && parentMessage.replies.length ? [u._id] : [parentMessage.u._id, u._id]), ]), ]; + const highlightedUserIds = new Set(); + (await getUserIdsFromHighlights(rid, message)).forEach((uid) => highlightedUserIds.add(uid)); await Messages.updateRepliesByThreadId(tmid, addToReplies, ts); await ReadReceipts.setAsThreadById(tmid); @@ -35,9 +37,16 @@ export async function reply({ tmid }: { tmid?: string }, message: IMessage, pare await Subscriptions.addUnreadThreadByRoomIdAndUserIds(rid, repliesFiltered, tmid, {}); } - for await (const userId of mentionIds) { + const mentionedUsers = new Set([...mentionIds, ...highlightedUserIds]); + for await (const userId of mentionedUsers) { await Subscriptions.addUnreadThreadByRoomIdAndUserIds(rid, [userId], tmid, { userMention: true }); } + + const highlightIds = Array.from(highlightedUserIds); + if (highlightIds.length) { + await Subscriptions.setAlertForRoomIdAndUserIds(rid, highlightIds); + await Subscriptions.setOpenForRoomIdAndUserIds(rid, highlightIds); + } } export async function follow({ tmid, uid }: { tmid: string; uid: string }) { @@ -58,12 +67,8 @@ export async function unfollow({ tmid, rid, uid }: { tmid: string; rid: string; await Messages.removeThreadFollowerByThreadId(tmid, uid); } -export const readThread = async ({ userId, rid, tmid }: { userId?: string; rid: string; tmid: string }) => { +export const readThread = async ({ userId, rid, tmid }: { userId: string; rid: string; tmid: string }) => { const projection = { tunread: 1 }; - if (!userId) { - return; - } - const sub = await Subscriptions.findOneByRoomIdAndUserId(rid, userId, { projection }); if (!sub) { return; @@ -72,4 +77,5 @@ export const readThread = async ({ userId, rid, tmid }: { userId?: string; rid: const clearAlert = sub.tunread && sub.tunread?.length <= 1 && sub.tunread.includes(tmid); await Subscriptions.removeUnreadThreadByRoomIdAndUserId(rid, userId, tmid, clearAlert); + await NotificationQueue.clearQueueByUserId(userId); }; diff --git a/apps/meteor/app/threads/server/hooks/aftersavemessage.ts b/apps/meteor/app/threads/server/hooks/aftersavemessage.ts index 6fa780e12f8d7bf70122e247988bf1a244024ca2..4b0f94aa8b529ffb5818d9dd8f52d751bf4d01b9 100644 --- a/apps/meteor/app/threads/server/hooks/aftersavemessage.ts +++ b/apps/meteor/app/threads/server/hooks/aftersavemessage.ts @@ -1,11 +1,10 @@ -import { api } from '@rocket.chat/core-services'; import type { IMessage, IRoom } from '@rocket.chat/core-typings'; import { isEditedMessage } from '@rocket.chat/core-typings'; import { Messages } from '@rocket.chat/models'; import { Meteor } from 'meteor/meteor'; import { callbacks } from '../../../../lib/callbacks'; -import { broadcastMessageSentEvent } from '../../../../server/modules/watchers/lib/messages'; +import { broadcastMessageFromData } from '../../../../server/modules/watchers/lib/messages'; import { updateThreadUsersSubscriptions, getMentions } from '../../../lib/server/lib/notifyUsersOnMessage'; import { sendMessageNotifications } from '../../../lib/server/lib/sendNotificationsOnMessage'; import { settings } from '../../../settings/server'; @@ -63,9 +62,8 @@ export async function processThreads(message: IMessage, room: IRoom) { await notifyUsersOnReply(message, replies, room); await metaData(message, parentMessage, replies); await notification(message, room, replies); - void broadcastMessageSentEvent({ + void broadcastMessageFromData({ id: message.tmid, - broadcastCallback: (message) => api.broadcast('message.sent', message), }); return message; diff --git a/apps/meteor/app/tokenpass/client/lib.ts b/apps/meteor/app/tokenpass/client/lib.ts index c8c1daf1cd60ff72e459a67fe2f0ec99791d3d76..e0b40a9b6de9881bf440bf701ece826e70029ab0 100644 --- a/apps/meteor/app/tokenpass/client/lib.ts +++ b/apps/meteor/app/tokenpass/client/lib.ts @@ -2,7 +2,7 @@ import type { OauthConfig } from '@rocket.chat/core-typings'; import { Meteor } from 'meteor/meteor'; import { Tracker } from 'meteor/tracker'; -import { CustomOAuth } from '../../custom-oauth/client/custom_oauth_client'; +import { CustomOAuth } from '../../custom-oauth/client/CustomOAuth'; import { settings } from '../../settings/client'; const config: OauthConfig = { diff --git a/apps/meteor/app/ui-cached-collection/client/models/CachedCollection.ts b/apps/meteor/app/ui-cached-collection/client/models/CachedCollection.ts index 9c970ccf697b29306bc2fc75620cf8ea4ac769d5..77190992612a888cf6203bd95acca11c9235fbdb 100644 --- a/apps/meteor/app/ui-cached-collection/client/models/CachedCollection.ts +++ b/apps/meteor/app/ui-cached-collection/client/models/CachedCollection.ts @@ -1,4 +1,5 @@ import { Emitter } from '@rocket.chat/emitter'; +import type { StreamNames } from '@rocket.chat/ui-contexts'; import localforage from 'localforage'; import { Accounts } from 'meteor/accounts-base'; import { Meteor } from 'meteor/meteor'; @@ -10,11 +11,10 @@ import { baseURI } from '../../../../client/lib/baseURI'; import { getConfig } from '../../../../client/lib/utils/getConfig'; import { isTruthy } from '../../../../lib/isTruthy'; import { withDebouncing } from '../../../../lib/utils/highOrderFunctions'; -import Notifications from '../../../notifications/client/lib/Notifications'; import { sdk } from '../../../utils/client/lib/SDKClient'; import { CachedCollectionManager } from './CachedCollectionManager'; -export type EventType = Extract; +export type EventType = 'notify-logged' | 'notify-all' | 'notify-user'; type Name = 'rooms' | 'subscriptions' | 'permissions' | 'public-settings' | 'private-settings'; @@ -48,7 +48,7 @@ export class CachedCollection extends Emitter< public name: Name; - public eventType: EventType; + public eventType: StreamNames; public version = 18; @@ -60,7 +60,7 @@ export class CachedCollection extends Emitter< public timer: ReturnType; - constructor({ name, eventType = 'onUser', userRelated = true }: { name: Name; eventType?: EventType; userRelated?: boolean }) { + constructor({ name, eventType = 'notify-user', userRelated = true }: { name: Name; eventType?: StreamNames; userRelated?: boolean }) { super(); this.collection = new Mongo.Collection(null) as MinimongoCollection; @@ -85,7 +85,10 @@ export class CachedCollection extends Emitter< }); } - protected get eventName(): `${Name}-changed` { + protected get eventName(): `${Name}-changed` | `${string}/${Name}-changed` { + if (this.eventType === 'notify-user') { + return `${Meteor.userId()}/${this.name}-changed`; + } return `${this.name}-changed`; } @@ -232,7 +235,7 @@ export class CachedCollection extends Emitter< } async setupListener() { - (Notifications[this.eventType] as any)(this.eventName, async (action: 'removed' | 'changed', record: any) => { + sdk.stream(this.eventType, [this.eventName], (async (action: 'removed' | 'changed', record: any) => { this.log('record received', action, record); const newRecord = this.handleReceived(record, action); @@ -250,7 +253,7 @@ export class CachedCollection extends Emitter< this.collection.upsert({ _id } as any, newRecord); } await this.save(); - }); + }) as (...args: unknown[]) => void); } trySync(delay = 10) { diff --git a/apps/meteor/app/ui-utils/client/lib/LegacyRoomManager.ts b/apps/meteor/app/ui-utils/client/lib/LegacyRoomManager.ts index 85aafa4bb1854b4b1c1be9f916ebec0ee5bedcad..91b848ffefde4e20ff806f9dad1d09614d7ef994 100644 --- a/apps/meteor/app/ui-utils/client/lib/LegacyRoomManager.ts +++ b/apps/meteor/app/ui-utils/client/lib/LegacyRoomManager.ts @@ -7,10 +7,8 @@ import { RoomManager } from '../../../../client/lib/RoomManager'; import { roomCoordinator } from '../../../../client/lib/rooms/roomCoordinator'; import { fireGlobalEvent } from '../../../../client/lib/utils/fireGlobalEvent'; import { getConfig } from '../../../../client/lib/utils/getConfig'; -import { router } from '../../../../client/providers/RouterProvider'; import { callbacks } from '../../../../lib/callbacks'; -import { CachedChatRoom, ChatMessage, ChatSubscription, CachedChatSubscription, ChatRoom } from '../../../models/client'; -import { Notifications } from '../../../notifications/client'; +import { CachedChatRoom, ChatMessage, ChatSubscription, CachedChatSubscription } from '../../../models/client'; import { sdk } from '../../../utils/client/lib/SDKClient'; import { upsertMessage, RoomHistoryManager } from './RoomHistoryManager'; import { mainReady } from './mainReady'; @@ -38,8 +36,8 @@ function close(typeName: string) { if (openedRooms[typeName]) { if (openedRooms[typeName].rid) { sdk.stop('room-messages', openedRooms[typeName].rid); - Notifications.unRoom(openedRooms[typeName].rid, 'deleteMessage'); - Notifications.unRoom(openedRooms[typeName].rid, 'deleteMessageBulk'); + sdk.stop('notify-room', `${openedRooms[typeName].rid}/deleteMessage`); + sdk.stop('notify-room', `${openedRooms[typeName].rid}/deleteMessageBulk`); } openedRooms[typeName].ready = false; @@ -80,41 +78,6 @@ function getOpenedRoomByRid(rid: IRoom['_id']) { .find((openedRoom) => openedRoom.rid === rid); } -const handleTrackSettingsChange = (msg: IMessage) => { - const openedRoom = RoomManager.opened; - if (openedRoom !== msg.rid) { - return; - } - - void Tracker.nonreactive(async () => { - if (msg.t === 'room_changed_privacy') { - const type = router.getRouteName() === 'channel' ? 'c' : 'p'; - await close(type + router.getRouteParameters().name); - - const subscription = ChatSubscription.findOne({ rid: msg.rid }); - if (!subscription) { - throw new Error('Subscription not found'); - } - router.navigate({ - pattern: subscription.t === 'c' ? '/channel/:name/:tab?/:context?' : '/group/:name/:tab?/:context?', - params: { name: subscription.name }, - search: router.getSearchParameters(), - }); - } - - if (msg.t === 'r') { - const room = ChatRoom.findOne(msg.rid); - if (!room) { - throw new Error('Room not found'); - } - if (room.name !== router.getRouteParameters().name) { - await close(room.t + router.getRouteParameters().name); - roomCoordinator.openRouteLink(room.t, room, router.getSearchParameters()); - } - } - }); -}; - const computation = Tracker.autorun(() => { const ready = CachedChatRoom.ready.get() && mainReady.get(); if (ready !== true) { @@ -152,8 +115,6 @@ const computation = Tracker.autorun(() => { } } - handleTrackSettingsChange({ ...msg }); - await callbacks.run('streamMessage', { ...msg, name: room.name || '' }); fireGlobalEvent('new-message', { @@ -171,24 +132,80 @@ const computation = Tracker.autorun(() => { record.streamActive = true; openedRoomsDependency.changed(); }); - Notifications.onRoom(record.rid, 'deleteMessage', (msg) => { + + // when we receive a messages imported event we just clear the room history and fetch it again + sdk.stream('notify-room', [`${record.rid}/messagesImported`], async () => { + await RoomHistoryManager.clear(record.rid); + await RoomHistoryManager.getMore(record.rid); + }); + + sdk.stream('notify-room', [`${record.rid}/deleteMessage`], (msg) => { ChatMessage.remove({ _id: msg._id }); // remove thread refenrece from deleted message ChatMessage.update({ tmid: msg._id }, { $unset: { tmid: 1 } }, { multi: true }); }); - Notifications.onRoom(record.rid, 'deleteMessageBulk', ({ rid, ts, excludePinned, ignoreDiscussion, users }) => { - const query: Mongo.Selector = { rid, ts }; - if (excludePinned) { - query.pinned = { $ne: true }; - } - if (ignoreDiscussion) { - query.drid = { $exists: false }; - } - if (users?.length) { - query['u.username'] = { $in: users }; + + sdk.stream( + 'notify-room', + [`${record.rid}/deleteMessageBulk`], + ({ rid, ts, excludePinned, ignoreDiscussion, users, ids, showDeletedStatus }) => { + const query: Mongo.Selector = { rid }; + + if (ids) { + query._id = { $in: ids }; + } else { + query.ts = ts; + } + if (excludePinned) { + query.pinned = { $ne: true }; + } + if (ignoreDiscussion) { + query.drid = { $exists: false }; + } + if (users?.length) { + query['u.username'] = { $in: users }; + } + + if (showDeletedStatus) { + return ChatMessage.update( + query, + { $set: { t: 'rm', msg: '', urls: [], mentions: [], attachments: [], reactions: {} } }, + { multi: true }, + ); + } + return ChatMessage.remove(query); + }, + ); + + sdk.stream('notify-room', [`${record.rid}/messagesRead`], ({ tmid, until }) => { + if (tmid) { + return ChatMessage.update( + { + tmid, + unread: true, + }, + { $unset: { unread: 1 } }, + { multi: true }, + ); } - ChatMessage.remove(query); + ChatMessage.update( + { + rid: record.rid, + unread: true, + ts: { $lt: until }, + $or: [ + { + tmid: { $exists: false }, + }, + { + tshow: true, + }, + ], + }, + { $unset: { unread: 1 } }, + { multi: true }, + ); }); } } diff --git a/apps/meteor/app/ui-utils/client/lib/MessageAction.ts b/apps/meteor/app/ui-utils/client/lib/MessageAction.ts index 043dfe87b6061391989a76c9ad1eb81e1c31ae8b..6a3ddd45ca66df990afbde96a74282d860958f59 100644 --- a/apps/meteor/app/ui-utils/client/lib/MessageAction.ts +++ b/apps/meteor/app/ui-utils/client/lib/MessageAction.ts @@ -48,7 +48,7 @@ export type MessageActionConfig = { context?: MessageActionContext[]; action: ( this: any, - e: Pick, + e: Pick | undefined, { message, tabbar, diff --git a/apps/meteor/app/ui-utils/client/lib/messageActionDefault.ts b/apps/meteor/app/ui-utils/client/lib/messageActionDefault.ts index 5807673188e5310d83c03e678ca87368b18c3c6b..834fd0bb05a2d8e990991ba50ce50e4cc43b24fd 100644 --- a/apps/meteor/app/ui-utils/client/lib/messageActionDefault.ts +++ b/apps/meteor/app/ui-utils/client/lib/messageActionDefault.ts @@ -10,7 +10,7 @@ import { dispatchToastMessage } from '../../../../client/lib/toast'; import { messageArgs } from '../../../../client/lib/utils/messageArgs'; import { router } from '../../../../client/providers/RouterProvider'; import ForwardMessageModal from '../../../../client/views/room/modals/ForwardMessageModal/ForwardMessageModal'; -import ReactionList from '../../../../client/views/room/modals/ReactionListModal'; +import ReactionListModal from '../../../../client/views/room/modals/ReactionListModal'; import ReportMessageModal from '../../../../client/views/room/modals/ReportMessageModal'; import { hasAtLeastOnePermission, hasPermission } from '../../../authorization/client'; import { ChatRoom, Subscriptions } from '../../../models/client'; @@ -266,10 +266,10 @@ Meteor.startup(async () => { label: 'Reactions', context: ['message', 'message-mobile', 'threads', 'videoconf', 'videoconf-threads'], type: 'interaction', - action(this: unknown, _, { message: { reactions = {} } = messageArgs(this).msg }) { + action(this: unknown, _, { message: { reactions = {} } = messageArgs(this).msg, chat }) { imperativeModal.open({ - component: ReactionList, - props: { reactions, onClose: imperativeModal.close }, + component: ReactionListModal, + props: { reactions, onOpenUserCard: chat?.userCard.openUserCard, onClose: imperativeModal.close }, }); }, condition({ message: { reactions } }) { diff --git a/apps/meteor/app/ui-utils/client/lib/messageBox.ts b/apps/meteor/app/ui-utils/client/lib/messageBox.ts index 3f3c545af57e262d9ee996471f9d137dcb49c5eb..3418adef1c1cd9372f32f98448abfcc5e3d84486 100644 --- a/apps/meteor/app/ui-utils/client/lib/messageBox.ts +++ b/apps/meteor/app/ui-utils/client/lib/messageBox.ts @@ -1,4 +1,5 @@ import type { IMessage, IRoom } from '@rocket.chat/core-typings'; +import type { Keys as IconName } from '@rocket.chat/icons'; import type { TranslationKey } from '@rocket.chat/ui-contexts'; import type { ChatAPI } from '../../../../client/lib/chats/ChatAPI'; @@ -6,7 +7,7 @@ import type { ChatAPI } from '../../../../client/lib/chats/ChatAPI'; export type MessageBoxAction = { label: TranslationKey; id: string; - icon?: string; + icon: IconName; action: (params: { rid: IRoom['_id']; tmid?: IMessage['_id']; event: Event; chat: ChatAPI }) => void; condition?: () => boolean; }; diff --git a/apps/meteor/app/ui/client/lib/ChatMessages.ts b/apps/meteor/app/ui/client/lib/ChatMessages.ts index 2e7c4a479e866882c2d89973fea20b6ffd543a77..59efeff49af1aa3fe617395f810d7d0e32613f96 100644 --- a/apps/meteor/app/ui/client/lib/ChatMessages.ts +++ b/apps/meteor/app/ui/client/lib/ChatMessages.ts @@ -45,7 +45,10 @@ export class ChatMessages implements ChatAPI { public ActionManager: any; - public userCard: { open(username: string): (event: UIEvent) => void; close(): void }; + public userCard: { + openUserCard(event: UIEvent, username: string): void; + closeUserCard(): void; + }; public emojiPicker: { open(el: Element, cb: (emoji: string) => void): void; @@ -160,8 +163,8 @@ export class ChatMessages implements ChatAPI { this.readStateManager = new ReadStateManager(rid); this.userCard = { - open: unimplemented, - close: unimplemented, + openUserCard: unimplemented, + closeUserCard: unimplemented, }; this.emojiPicker = { diff --git a/apps/meteor/app/ui/client/lib/UserAction.ts b/apps/meteor/app/ui/client/lib/UserAction.ts index ab58d641c1490a85467598ab974fee9fa3d02816..00ff3113e5860a32264f34ca60e4639c694dd4b4 100644 --- a/apps/meteor/app/ui/client/lib/UserAction.ts +++ b/apps/meteor/app/ui/client/lib/UserAction.ts @@ -3,8 +3,8 @@ import { debounce } from 'lodash'; import { Meteor } from 'meteor/meteor'; import { ReactiveDict } from 'meteor/reactive-dict'; -import { Notifications } from '../../../notifications/client'; import { settings } from '../../../settings/client'; +import { sdk } from '../../../utils/client/lib/SDKClient'; const TIMEOUT = 15000; const RENEW = TIMEOUT / 3; @@ -38,7 +38,7 @@ const shownName = function (user: IUser | null | undefined): string | undefined const emitActivities = debounce(async (rid: string, extras: IExtras): Promise => { const activities = roomActivities.get(extras?.tmid || rid) || new Set(); - Notifications.notifyRoom(rid, USER_ACTIVITY, shownName(Meteor.user() as unknown as IUser), [...activities], extras); + sdk.publish('notify-room', [`${rid}/${USER_ACTIVITY}`, shownName(Meteor.user() as unknown as IUser), [...activities], extras]); }, 500); function handleStreamAction(rid: string, username: string, activityTypes: string[], extras?: IExtras): void { @@ -65,10 +65,11 @@ function handleStreamAction(rid: string, username: string, activityTypes: string performingUsers.set(rid, roomActivities); } export const UserAction = new (class { - addStream(rid: string): void { + addStream(rid: string): () => void { if (rooms.get(rid)) { - return; + throw new Error('UserAction - addStream should only be called once per room'); } + const handler = function (username: string, activityType: string[], extras?: object): void { const user = Meteor.users.findOne(Meteor.userId() || undefined, { fields: { name: 1, username: 1 }, @@ -79,7 +80,15 @@ export const UserAction = new (class { handleStreamAction(rid, username, activityType, extras); }; rooms.set(rid, handler); - Notifications.onRoom(rid, USER_ACTIVITY, handler); + + const { stop } = sdk.stream('notify-room', [`${rid}/${USER_ACTIVITY}`], handler); + return () => { + if (!rooms.get(rid)) { + return; + } + stop(); + rooms.delete(rid); + }; } performContinuously(rid: string, activityType: string, extras: IExtras = {}): void { @@ -156,15 +165,6 @@ export const UserAction = new (class { void emitActivities(rid, extras); } - cancel(rid: string): void { - if (!rooms.get(rid)) { - return; - } - - Notifications.unRoom(rid, USER_ACTIVITY); - rooms.delete(rid); - } - get(roomId: string): IRoomActivity | undefined { return performingUsers.get(roomId); } diff --git a/apps/meteor/app/ui/client/lib/userCard.tsx b/apps/meteor/app/ui/client/lib/userCard.tsx deleted file mode 100644 index e4fd5b343140b244e4ee1cca9157a23a4047e8c7..0000000000000000000000000000000000000000 --- a/apps/meteor/app/ui/client/lib/userCard.tsx +++ /dev/null @@ -1,76 +0,0 @@ -import type { ComponentProps } from 'react'; -import React, { Suspense, createElement, lazy } from 'react'; -import { createPortal } from 'react-dom'; -import { useSyncExternalStore } from 'use-sync-external-store/shim'; - -import { registerPortal } from '../../../../client/lib/portals/portalsSubscription'; -import { queueMicrotask } from '../../../../client/lib/utils/queueMicrotask'; - -const UserCard = lazy(() => import('../../../../client/views/room/UserCard')); - -type UserCardProps = ComponentProps; - -let props: UserCardProps; - -const subscribers = new Set<() => void>(); - -const updateProps = (newProps: Partial) => { - props = { ...props, ...newProps }; - subscribers.forEach((subscriber) => subscriber()); -}; - -const getProps = () => props; - -const subscribeToProps = (callback: () => void) => { - subscribers.add(callback); - - return () => { - subscribers.delete(callback); - }; -}; - -const UserCardWithProps = () => { - const props = useSyncExternalStore(subscribeToProps, getProps); - - return ( - - - - ); -}; - -const createContainer = () => { - const container = document.createElement('div'); - container.id = 'react-user-card'; - document.body.appendChild(container); - - return container; -}; - -let container: HTMLDivElement | undefined; -let unregisterPortal: (() => void) | undefined; - -export const closeUserCard = () => { - queueMicrotask(() => { - if (unregisterPortal) { - unregisterPortal(); - unregisterPortal = undefined; - } - }); -}; - -export const openUserCard = (params: Omit) => { - updateProps({ ...params, onClose: closeUserCard }); - - if (!container) { - container = createContainer(); - } - - if (!unregisterPortal) { - const children = createElement(UserCardWithProps); - const portal = <>{createPortal(children, container)}; - unregisterPortal = registerPortal(container, portal); - } - - return closeUserCard; -}; diff --git a/apps/meteor/app/user-status/client/index.ts b/apps/meteor/app/user-status/client/index.ts deleted file mode 100644 index 122887520cc841b5053a1e5d24bd4f0805a747f9..0000000000000000000000000000000000000000 --- a/apps/meteor/app/user-status/client/index.ts +++ /dev/null @@ -1,5 +0,0 @@ -import './notifications/deleteCustomUserStatus'; -import './notifications/updateCustomUserStatus'; -import './lib/customUserStatus'; - -export { userStatus } from './lib/userStatus'; diff --git a/apps/meteor/app/user-status/client/lib/customUserStatus.js b/apps/meteor/app/user-status/client/lib/customUserStatus.js deleted file mode 100644 index 8d429d22c3ec2ab473af39d451a4de34eae0f0d8..0000000000000000000000000000000000000000 --- a/apps/meteor/app/user-status/client/lib/customUserStatus.js +++ /dev/null @@ -1,62 +0,0 @@ -import { Meteor } from 'meteor/meteor'; -import { Tracker } from 'meteor/tracker'; - -import { sdk } from '../../../utils/client/lib/SDKClient'; -import { userStatus } from './userStatus'; - -userStatus.packages.customUserStatus = { - list: [], -}; - -export const deleteCustomUserStatus = function (customUserStatusData) { - delete userStatus.list[customUserStatusData._id]; - - const arrayIndex = userStatus.packages.customUserStatus.list.indexOf(customUserStatusData._id); - if (arrayIndex !== -1) { - userStatus.packages.customUserStatusData.list.splice(arrayIndex, 1); - } -}; - -export const updateCustomUserStatus = function (customUserStatusData) { - const newUserStatus = { - name: customUserStatusData.name, - id: customUserStatusData._id, - statusType: customUserStatusData.statusType, - localizeName: false, - }; - - const arrayIndex = userStatus.packages.customUserStatus.list.indexOf(newUserStatus.id); - if (arrayIndex === -1) { - userStatus.packages.customUserStatus.list.push(newUserStatus); - } else { - userStatus.packages.customUserStatus.list[arrayIndex] = newUserStatus; - } - - userStatus.list[newUserStatus.id] = newUserStatus; -}; - -Meteor.startup(() => { - Tracker.autorun(() => { - if (!Meteor.userId()) { - return; - } - - void sdk.call('listCustomUserStatus').then((result) => { - if (!result) { - return; - } - - for (const customStatus of result) { - const newUserStatus = { - name: customStatus.name, - id: customStatus._id, - statusType: customStatus.statusType, - localizeName: false, - }; - - userStatus.packages.customUserStatus.list.push(newUserStatus); - userStatus.list[newUserStatus.id] = newUserStatus; - } - }); - }); -}); diff --git a/apps/meteor/app/user-status/client/lib/userStatus.ts b/apps/meteor/app/user-status/client/lib/userStatus.ts deleted file mode 100644 index eec57acd29f1da74fed29c6f5beba901a61a9e79..0000000000000000000000000000000000000000 --- a/apps/meteor/app/user-status/client/lib/userStatus.ts +++ /dev/null @@ -1,52 +0,0 @@ -import { UserStatus } from '@rocket.chat/core-typings'; - -type Status = { - name: string; - localizeName: boolean; - id: string; - statusType: UserStatus; -}; - -type UserStatusTypes = { - packages: any; - list: { - [status: string]: Status; - }; -}; - -export const userStatus: UserStatusTypes = { - packages: { - base: { - render(html: string): string { - return html; - }, - }, - }, - - list: { - online: { - name: UserStatus.ONLINE, - localizeName: true, - id: UserStatus.ONLINE, - statusType: UserStatus.ONLINE, - }, - away: { - name: UserStatus.AWAY, - localizeName: true, - id: UserStatus.AWAY, - statusType: UserStatus.AWAY, - }, - busy: { - name: UserStatus.BUSY, - localizeName: true, - id: UserStatus.BUSY, - statusType: UserStatus.BUSY, - }, - offline: { - name: UserStatus.OFFLINE, - localizeName: true, - id: UserStatus.OFFLINE, - statusType: UserStatus.OFFLINE, - }, - }, -} as const; diff --git a/apps/meteor/app/user-status/client/notifications/deleteCustomUserStatus.js b/apps/meteor/app/user-status/client/notifications/deleteCustomUserStatus.js deleted file mode 100644 index 24d503d57d7248d73ce68193a0713e192843e438..0000000000000000000000000000000000000000 --- a/apps/meteor/app/user-status/client/notifications/deleteCustomUserStatus.js +++ /dev/null @@ -1,6 +0,0 @@ -import { Meteor } from 'meteor/meteor'; - -import { Notifications } from '../../../notifications/client'; -import { deleteCustomUserStatus } from '../lib/customUserStatus'; - -Meteor.startup(() => Notifications.onLogged('deleteCustomUserStatus', (data) => deleteCustomUserStatus(data.userStatusData))); diff --git a/apps/meteor/app/user-status/client/notifications/updateCustomUserStatus.js b/apps/meteor/app/user-status/client/notifications/updateCustomUserStatus.js deleted file mode 100644 index f5949b03894865634823011b417a8f251daba68a..0000000000000000000000000000000000000000 --- a/apps/meteor/app/user-status/client/notifications/updateCustomUserStatus.js +++ /dev/null @@ -1,6 +0,0 @@ -import { Meteor } from 'meteor/meteor'; - -import { Notifications } from '../../../notifications/client'; -import { updateCustomUserStatus } from '../lib/customUserStatus'; - -Meteor.startup(() => Notifications.onLogged('updateCustomUserStatus', (data) => updateCustomUserStatus(data.userStatusData))); diff --git a/apps/meteor/app/user-status/server/methods/listCustomUserStatus.ts b/apps/meteor/app/user-status/server/methods/listCustomUserStatus.ts index bb11a1be73bdfc8c6c4006dcb2b961b7167c66de..3a962121d65c781fe754c88d96c60311fddd1057 100644 --- a/apps/meteor/app/user-status/server/methods/listCustomUserStatus.ts +++ b/apps/meteor/app/user-status/server/methods/listCustomUserStatus.ts @@ -6,7 +6,7 @@ import { Meteor } from 'meteor/meteor'; declare module '@rocket.chat/ui-contexts' { // eslint-disable-next-line @typescript-eslint/naming-convention interface ServerMethods { - listCustomUserStatus(): Promise; + listCustomUserStatus(): ICustomUserStatus[]; } } diff --git a/apps/meteor/app/utils/client/lib/SDKClient.ts b/apps/meteor/app/utils/client/lib/SDKClient.ts index e9e20bbe658bd62312bf5c17fcff48d7c735b094..18ff309970dfc5981823eae7624ea5386a929d46 100644 --- a/apps/meteor/app/utils/client/lib/SDKClient.ts +++ b/apps/meteor/app/utils/client/lib/SDKClient.ts @@ -45,114 +45,166 @@ const isChangedCollectionPayload = ( return true; }; -export const createSDK = (rest: RestClientInterface) => { - const ev = new Emitter(); +type EventMap = StreamKeys> = { + [key in `stream-${N}/${K}`]: StreamerCallbackArgs; +}; + +type StreamMapValue = { + stop: () => void; + onChange: ReturnType['onChange']; + ready: () => Promise; + isReady: boolean; + unsubList: Set<() => void>; +}; + +const createNewMeteorStream = (streamName: StreamNames, key: StreamKeys, args: unknown[]): StreamMapValue => { + const ee = new Emitter(); + const meta = { + ready: false, + }; + const sub = Meteor.connection.subscribe( + `stream-${streamName}`, + key, + { useCollection: false, args }, + { + onReady: (args: any) => { + meta.ready = true; + ee.emit('ready', [undefined, args]); + }, + onError: (err: any) => { + console.error(err); + ee.emit('ready', [err]); + }, + }, + ); + + const onChange: ReturnType['onChange'] = (cb) => { + if (meta.ready) { + cb({ + msg: 'ready', + + subs: [], + }); + return; + } + ee.once('ready', ([error, result]) => { + if (error) { + cb({ + msg: 'nosub', + + id: '', + error, + }); + return; + } - const streams = new Map void>(); + cb(result); + }); + }; + + const ready = () => { + if (meta.ready) { + return Promise.resolve(); + } + return new Promise((r) => { + ee.once('ready', r); + }); + }; + + return { + stop: sub.stop, + onChange, + ready, + get isReady() { + return meta.ready; + }, + unsubList: new Set(), + }; +}; + +const createStreamManager = () => { + // Emitter that replicates stream messages to registered callbacks + const streamProxy = new Emitter(); + + // Collection of unsubscribe callbacks for each stream. + // const proxyUnsubLists = new Map void>>(); + + const streams = new Map(); Meteor.connection._stream.on('message', (rawMsg: string) => { const msg = DDPCommon.parseDDP(rawMsg); if (!isChangedCollectionPayload(msg)) { return; } - ev.emit(`${msg.collection}/${msg.fields.eventName}`, msg.fields.args); + streamProxy.emit(`${msg.collection}/${msg.fields.eventName}` as any, msg.fields.args as any); }); const stream: SDK['stream'] = >( name: N, data: [key: K, ...args: unknown[]], - cb: (...args: StreamerCallbackArgs) => void, + callback: (...args: StreamerCallbackArgs) => void, + _options?: { + retransmit?: boolean | undefined; + retransmitToSelf?: boolean | undefined; + }, ): ReturnType => { const [key, ...args] = data; - const streamName = `stream-${name}`; - const streamKey = `${streamName}/${key}`; - - const ee = new Emitter(); + const eventLiteral = `stream-${name}/${key}` as const; - const meta = { - ready: false, + const proxyCallback = (args?: unknown): void => { + if (!args || !Array.isArray(args)) { + throw new Error('Invalid streamer callback'); + } + callback(...(args as StreamerCallbackArgs)); }; - const sub = Meteor.connection.subscribe( - streamName, - key, - { useCollection: false, args }, - { - onReady: (args: any) => { - meta.ready = true; - ee.emit('ready', [undefined, args]); - }, - onError: (err: any) => { - console.error(err); - ee.emit('ready', [err]); - }, - }, - ); + streamProxy.on(eventLiteral, proxyCallback); - const onChange: ReturnType['onChange'] = (cb) => { - if (meta.ready) { - cb({ - msg: 'ready', + const stop = (): void => { + streamProxy.off(eventLiteral, proxyCallback); - subs: [], - }); + // If someone is still listening, don't unsubscribe + if (streamProxy.has(eventLiteral)) { return; } - ee.once('ready', ([error, result]) => { - if (error) { - cb({ - msg: 'nosub', - - id: '', - error, - }); - return; - } - - cb(result); - }); - }; - const ready = () => { - if (meta.ready) { - return Promise.resolve(); + if (stream) { + stream.stop(); + streams.delete(eventLiteral); } - return new Promise((r) => { - ee.once('ready', r); - }); - }; - - const removeEv = ev.on(`${streamKey}`, (args) => cb(...args)); - - const stop = () => { - streams.delete(`${streamKey}`); - sub.stop(); - removeEv(); }; - streams.set(`${streamKey}`, stop); + const stream = streams.get(eventLiteral) || createNewMeteorStream(name, key, args); + stream.unsubList.add(stop); + if (!streams.has(eventLiteral)) { + streams.set(eventLiteral, stream); + } return { id: '', name, params: data as any, stop, - ready, - onChange, - get isReady() { - return meta.ready; - }, + ready: stream.ready, + onChange: stream.onChange, + isReady: stream.isReady, }; }; - const stop = (name: string, key: string) => { - const streamKey = `stream-${name}/${key}`; - const stop = streams.get(streamKey); - if (stop) { - stop(); + const stopAll = (streamName: string, key: string) => { + const stream = streams.get(`stream-${streamName}/${key}`); + + if (stream) { + stream.unsubList.forEach((stop) => stop()); } }; + return { stream, stopAll }; +}; + +export const createSDK = (rest: RestClientInterface) => { + const { stream, stopAll } = createStreamManager(); + const publish = (name: string, args: unknown[]) => { Meteor.call(`stream-${name}`, ...args); }; @@ -163,7 +215,7 @@ export const createSDK = (rest: RestClientInterface) => { return { rest, - stop, + stop: stopAll, stream, publish, call, diff --git a/apps/meteor/app/utils/lib/i18n.ts b/apps/meteor/app/utils/lib/i18n.ts index a491159e49e9d27a5db20e0a8b02762464e875ef..9d3fbc59d245e501c2f8ae6e998b33c28856440d 100644 --- a/apps/meteor/app/utils/lib/i18n.ts +++ b/apps/meteor/app/utils/lib/i18n.ts @@ -1,3 +1,4 @@ +import type { RocketchatI18nKeys } from '@rocket.chat/i18n'; import i18next from 'i18next'; import sprintf from 'i18next-sprintf-postprocessor'; @@ -5,9 +6,13 @@ import { isObject } from '../../../lib/utils/isObject'; export const i18n = i18next.use(sprintf); -export const addSprinfToI18n = function (t: (key: string, ...replaces: any) => string) { +export const addSprinfToI18n = function (t: (typeof i18n)['t']) { return function (key: string, ...replaces: any): string { - if (replaces[0] === undefined || (isObject(replaces[0]) && !Array.isArray(replaces[0]))) { + if (replaces[0] === undefined) { + return t(key, ...replaces); + } + + if (isObject(replaces[0]) && !Array.isArray(replaces[0])) { return t(key, ...replaces); } @@ -19,3 +24,123 @@ export const addSprinfToI18n = function (t: (key: string, ...replaces: any) => s }; export const t = addSprinfToI18n(i18n.t.bind(i18n)); + +/** + * Extract the translation keys from a flat object and group them by namespace + * + * Example: + * + * ```js + * const source = { + * 'core.key1': 'value1', + * 'core.key2': 'value2', + * 'onboarding.key1': 'value1', + * 'onboarding.key2': 'value2', + * 'registration.key1': 'value1', + * 'registration.key2': 'value2', + * 'cloud.key1': 'value1', + * 'cloud.key2': 'value2', + * 'subscription.key1': 'value1', + * 'subscription.key2': 'value2', + * }; + * + * const result = extractTranslationNamespaces(source); + * + * console.log(result); + * + * // { + * // core: { + * // key1: 'value1', + * // key2: 'value2' + * // }, + * // onboarding: { + * // key1: 'value1', + * // key2: 'value2' + * // }, + * // registration: { + * // key1: 'value1', + * // key2: 'value2' + * // }, + * // cloud: { + * // key1: 'value1', + * // key2: 'value2' + * // }, + * // subscription: { + * // key1: 'value1', + * // key2: 'value2' + * // } + * // } + * ``` + * + * @param source the flat object with the translation keys + */ +export const extractTranslationNamespaces = (source: Record): Record> => { + const result: Record> = { + core: {}, + onboarding: {}, + registration: {}, + cloud: {}, + subscription: {}, + }; + + for (const [key, value] of Object.entries(source)) { + const prefix = availableTranslationNamespaces.find((namespace) => key.startsWith(`${namespace}.`)); + const keyWithoutNamespace = prefix ? key.slice(prefix.length + 1) : key; + const ns = prefix ?? defaultTranslationNamespace; + result[ns][keyWithoutNamespace] = value; + } + + return result; +}; + +/** + * Extract only the translation keys that match the given namespaces + * + * @param source the flat object with the translation keys + * @param namespaces the namespaces to extract + */ +export const extractTranslationKeys = (source: Record, namespaces: string | string[] = []): { [key: string]: any } => { + const all = extractTranslationNamespaces(source); + return Array.isArray(namespaces) + ? (namespaces as TranslationNamespace[]).reduce((result, namespace) => ({ ...result, ...all[namespace] }), {}) + : all[namespaces as TranslationNamespace]; +}; + +export type TranslationNamespace = + | (Extract extends `${infer T}.${string}` ? (T extends Lowercase ? T : never) : never) + | 'core'; + +const namespacesMap: Record = { + core: true, + onboarding: true, + registration: true, + cloud: true, + subscription: true, +}; + +export const availableTranslationNamespaces = Object.keys(namespacesMap) as TranslationNamespace[]; +export const defaultTranslationNamespace: TranslationNamespace = 'core'; + +export const applyCustomTranslations = ( + i18n: typeof i18next, + parsedCustomTranslations: Record>, + { namespaces, languages }: { namespaces?: string[]; languages?: string[] } = {}, +) => { + for (const [lng, translations] of Object.entries(parsedCustomTranslations)) { + if (languages && !languages.includes(lng)) { + continue; + } + + for (const [key, value] of Object.entries(translations)) { + const prefix = availableTranslationNamespaces.find((namespace) => key.startsWith(`${namespace}.`)); + const keyWithoutNamespace = prefix ? key.slice(prefix.length + 1) : key; + const ns = prefix ?? defaultTranslationNamespace; + + if (namespaces && !namespaces.includes(ns)) { + continue; + } + + i18n.addResourceBundle(lng, ns, { [keyWithoutNamespace]: value }, true, true); + } + } +}; diff --git a/apps/meteor/app/utils/rocketchat.info b/apps/meteor/app/utils/rocketchat.info index a193b82aaedc5368d6d46833947e5f357113e907..f2031f21f05d9d71c5a3b3d0777dfacc14cddf7c 100644 --- a/apps/meteor/app/utils/rocketchat.info +++ b/apps/meteor/app/utils/rocketchat.info @@ -1,3 +1,3 @@ { - "version": "6.6.0-develop" + "version": "6.7.0-develop" } diff --git a/apps/meteor/app/webdav/client/actionButton.ts b/apps/meteor/app/webdav/client/actionButton.ts deleted file mode 100644 index 9a2f8152e5c0b32d3ea788137a86588e210167df..0000000000000000000000000000000000000000 --- a/apps/meteor/app/webdav/client/actionButton.ts +++ /dev/null @@ -1,47 +0,0 @@ -import { Meteor } from 'meteor/meteor'; - -import { imperativeModal } from '../../../client/lib/imperativeModal'; -import { messageArgs } from '../../../client/lib/utils/messageArgs'; -import SaveToWebdav from '../../../client/views/room/webdav/SaveToWebdavModal'; -import { WebdavAccounts } from '../../models/client'; -import { settings } from '../../settings/client'; -import { MessageAction } from '../../ui-utils/client'; -import { getURL } from '../../utils/client'; - -Meteor.startup(() => { - MessageAction.addButton({ - id: 'webdav-upload', - icon: 'upload', - label: 'Save_To_Webdav', - condition: ({ message, subscription }) => { - if (subscription == null) { - return false; - } - if (WebdavAccounts.findOne() == null) { - return false; - } - if (!message.file) { - return false; - } - - return settings.get('Webdav_Integration_Enabled'); - }, - action(_, props) { - const { message = messageArgs(this).msg } = props; - const [attachment] = message.attachments || []; - const url = getURL(attachment.title_link as string, { full: true }); - imperativeModal.open({ - component: SaveToWebdav, - props: { - data: { - attachment, - url, - }, - onClose: imperativeModal.close, - }, - }); - }, - order: 100, - group: 'menu', - }); -}); diff --git a/apps/meteor/app/webdav/client/index.js b/apps/meteor/app/webdav/client/index.js deleted file mode 100644 index b4f7aa4ef133c436bd95e2db6e8f7de0831738ce..0000000000000000000000000000000000000000 --- a/apps/meteor/app/webdav/client/index.js +++ /dev/null @@ -1,15 +0,0 @@ -import { Meteor } from 'meteor/meteor'; -import { Tracker } from 'meteor/tracker'; - -import { settings } from '../../settings/client'; - -Meteor.startup(() => { - Tracker.autorun((c) => { - if (!settings.get('Webdav_Integration_Enabled')) { - return; - } - c.stop(); - import('./startup/sync'); - import('./actionButton'); - }); -}); diff --git a/apps/meteor/app/webdav/client/startup/sync.js b/apps/meteor/app/webdav/client/startup/sync.js deleted file mode 100644 index e048472aad7152e2d587077cd11ffcfbc995ac08..0000000000000000000000000000000000000000 --- a/apps/meteor/app/webdav/client/startup/sync.js +++ /dev/null @@ -1,20 +0,0 @@ -import { Meteor } from 'meteor/meteor'; -import { Tracker } from 'meteor/tracker'; - -import { WebdavAccounts } from '../../../models/client'; -import { Notifications } from '../../../notifications/client'; -import { sdk } from '../../../utils/client/lib/SDKClient'; - -const events = { - changed: (account) => WebdavAccounts.upsert({ _id: account._id }, account), - removed: ({ _id }) => WebdavAccounts.remove({ _id }), -}; - -Tracker.autorun(async () => { - if (!Meteor.userId()) { - return; - } - const { accounts } = await sdk.rest.get('/v1/webdav.getMyAccounts'); - accounts.forEach((account) => WebdavAccounts.insert(account)); - Notifications.onUser('webdav', ({ type, account }) => events[type](account)); -}); diff --git a/apps/meteor/app/webrtc/client/WebRTCClass.js b/apps/meteor/app/webrtc/client/WebRTCClass.js index 721c7591c6470e085a61e8752e3bbbb1df168c46..eb977296657585d653d7d643ab92c0a7ef0b3b00 100644 --- a/apps/meteor/app/webrtc/client/WebRTCClass.js +++ b/apps/meteor/app/webrtc/client/WebRTCClass.js @@ -7,8 +7,8 @@ import GenericModal from '../../../client/components/GenericModal'; import { imperativeModal } from '../../../client/lib/imperativeModal'; import { goToRoomById } from '../../../client/lib/utils/goToRoomById'; import { ChatSubscription } from '../../models/client'; -import { Notifications } from '../../notifications/client'; import { settings } from '../../settings/client'; +import { sdk } from '../../utils/client/lib/SDKClient'; import { t } from '../../utils/lib/i18n'; import { WEB_RTC_EVENTS } from '../lib/constants'; import { ChromeScreenShare } from './screenShare'; @@ -18,7 +18,7 @@ class WebRTCTransportClass extends Emitter { super(); this.debug = false; this.webrtcInstance = webrtcInstance; - Notifications.onRoom(this.webrtcInstance.room, WEB_RTC_EVENTS.WEB_RTC, (type, data) => { + sdk.stream('notify-room', [`${this.webrtcInstance.room}/${WEB_RTC_EVENTS.WEB_RTC}`], (type, data) => { this.log('WebRTCTransportClass - onRoom', type, data); this.emit(type, data); }); @@ -41,30 +41,42 @@ class WebRTCTransportClass extends Emitter { startCall(data) { this.log('WebRTCTransportClass - startCall', this.webrtcInstance.room, this.webrtcInstance.selfId); - Notifications.notifyUsersOfRoom(this.webrtcInstance.room, WEB_RTC_EVENTS.WEB_RTC, WEB_RTC_EVENTS.CALL, { - from: this.webrtcInstance.selfId, - room: this.webrtcInstance.room, - media: data.media, - monitor: data.monitor, - }); + sdk.publish('notify-room-users', [ + `${this.webrtcInstance.room}/${WEB_RTC_EVENTS.WEB_RTC}`, + WEB_RTC_EVENTS.CALL, + { + from: this.webrtcInstance.selfId, + room: this.webrtcInstance.room, + media: data.media, + monitor: data.monitor, + }, + ]); } joinCall(data) { this.log('WebRTCTransportClass - joinCall', this.webrtcInstance.room, this.webrtcInstance.selfId); if (data.monitor === true) { - Notifications.notifyUser(data.to, WEB_RTC_EVENTS.WEB_RTC, WEB_RTC_EVENTS.JOIN, { - from: this.webrtcInstance.selfId, - room: this.webrtcInstance.room, - media: data.media, - monitor: data.monitor, - }); + sdk.publish('notify-user', [ + `${data.to}/${WEB_RTC_EVENTS.WEB_RTC}`, + WEB_RTC_EVENTS.JOIN, + { + from: this.webrtcInstance.selfId, + room: this.webrtcInstance.room, + media: data.media, + monitor: data.monitor, + }, + ]); } else { - Notifications.notifyUsersOfRoom(this.webrtcInstance.room, WEB_RTC_EVENTS.WEB_RTC, WEB_RTC_EVENTS.JOIN, { - from: this.webrtcInstance.selfId, - room: this.webrtcInstance.room, - media: data.media, - monitor: data.monitor, - }); + sdk.publish('notify-room-users', [ + `${this.webrtcInstance.room}/${WEB_RTC_EVENTS.WEB_RTC}`, + WEB_RTC_EVENTS.JOIN, + { + from: this.webrtcInstance.selfId, + room: this.webrtcInstance.room, + media: data.media, + monitor: data.monitor, + }, + ]); } } @@ -72,20 +84,20 @@ class WebRTCTransportClass extends Emitter { data.from = this.webrtcInstance.selfId; data.room = this.webrtcInstance.room; this.log('WebRTCTransportClass - sendCandidate', data); - Notifications.notifyUser(data.to, WEB_RTC_EVENTS.WEB_RTC, WEB_RTC_EVENTS.CANDIDATE, data); + sdk.publish('notify-user', [`${data.to}/${WEB_RTC_EVENTS.WEB_RTC}`, WEB_RTC_EVENTS.CANDIDATE, data]); } sendDescription(data) { data.from = this.webrtcInstance.selfId; data.room = this.webrtcInstance.room; this.log('WebRTCTransportClass - sendDescription', data); - Notifications.notifyUser(data.to, WEB_RTC_EVENTS.WEB_RTC, WEB_RTC_EVENTS.DESCRIPTION, data); + sdk.publish('notify-user', [`${data.to}/${WEB_RTC_EVENTS.WEB_RTC}`, WEB_RTC_EVENTS.DESCRIPTION, data]); } sendStatus(data) { this.log('WebRTCTransportClass - sendStatus', data, this.webrtcInstance.room); data.from = this.webrtcInstance.selfId; - Notifications.notifyRoom(this.webrtcInstance.room, WEB_RTC_EVENTS.WEB_RTC, WEB_RTC_EVENTS.STATUS, data); + sdk.publish('notify-room', [`${this.webrtcInstance.room}/${WEB_RTC_EVENTS.WEB_RTC}`, WEB_RTC_EVENTS.STATUS, data]); } onRemoteCall(fn) { @@ -969,7 +981,7 @@ const WebRTC = new (class { Meteor.startup(() => { Tracker.autorun(() => { if (Meteor.userId()) { - Notifications.onUser(WEB_RTC_EVENTS.WEB_RTC, (type, data) => { + sdk.stream('notify-user', [`${Meteor.userId()}/${WEB_RTC_EVENTS.WEB_RTC}`], (type, data) => { if (data.room == null) { return; } diff --git a/apps/meteor/app/webrtc/client/actionLink.tsx b/apps/meteor/app/webrtc/client/actionLink.tsx index f9848518b868d7a69056592ff0983541e8873da2..d4575f2dd60f3c15a743f6df3aca803d6b36aab7 100644 --- a/apps/meteor/app/webrtc/client/actionLink.tsx +++ b/apps/meteor/app/webrtc/client/actionLink.tsx @@ -3,7 +3,6 @@ import type { IMessage } from '@rocket.chat/core-typings'; import { actionLinks } from '../../../client/lib/actionLinks'; import { dispatchToastMessage } from '../../../client/lib/toast'; import { ChatRoom } from '../../models/client'; -import { Notifications } from '../../notifications/client'; import { sdk } from '../../utils/client/lib/SDKClient'; import { t } from '../../utils/lib/i18n'; @@ -31,5 +30,5 @@ actionLinks.register('endLivechatWebRTCCall', async (message: IMessage) => { return; } await sdk.rest.put(`/v1/livechat/webrtc.call/${message._id}`, { rid: _id, status: 'ended' }); - Notifications.notifyRoom(_id, 'webrtc' as any, 'callStatus', { callStatus: 'ended' }); + sdk.publish('notify-room', [`${_id}/webrtc`, 'callStatus', { callStatus: 'ended' }]); }); diff --git a/apps/meteor/app/webrtc/lib/constants.ts b/apps/meteor/app/webrtc/lib/constants.ts index 1beb27756f88063ccaad42dd3283baac7352915a..fd3f26933d96a3caf3e572e13b071ec613fa40ef 100644 --- a/apps/meteor/app/webrtc/lib/constants.ts +++ b/apps/meteor/app/webrtc/lib/constants.ts @@ -5,4 +5,4 @@ export const WEB_RTC_EVENTS = { JOIN: 'join', CANDIDATE: 'candidate', DESCRIPTION: 'description', -}; +} as const; diff --git a/apps/meteor/app/wordpress/client/lib.ts b/apps/meteor/app/wordpress/client/lib.ts index 7dd5215ccc60929d460fe79dad3a4ca9369f46db..b213d5fb88c2aa1d3dcb357617f1bfae60e9ab80 100644 --- a/apps/meteor/app/wordpress/client/lib.ts +++ b/apps/meteor/app/wordpress/client/lib.ts @@ -3,7 +3,7 @@ import { Meteor } from 'meteor/meteor'; import { Tracker } from 'meteor/tracker'; import _ from 'underscore'; -import { CustomOAuth } from '../../custom-oauth/client/custom_oauth_client'; +import { CustomOAuth } from '../../custom-oauth/client/CustomOAuth'; import { settings } from '../../settings/client'; const config: OauthConfig = { diff --git a/apps/meteor/client/components/CreateDiscussion/CreateDiscussion.tsx b/apps/meteor/client/components/CreateDiscussion/CreateDiscussion.tsx index 95c4c7b2c437f432e8faa14f9c26b9652bfd562f..c191fb15087339518bb3623385fa94f670d570ee 100644 --- a/apps/meteor/client/components/CreateDiscussion/CreateDiscussion.tsx +++ b/apps/meteor/client/components/CreateDiscussion/CreateDiscussion.tsx @@ -141,16 +141,14 @@ const CreateDiscussion = ({ onClose, defaultParentRoom, parentMessageId, nameSug )} - + {t('Encrypted')} - - } - /> - - + } + /> + diff --git a/apps/meteor/client/components/GazzodownText.tsx b/apps/meteor/client/components/GazzodownText.tsx index 76868f84e3af904aac8652728960b1f805b1da1c..76232984a5944c9489a7e1fd22652bf41773cb3f 100644 --- a/apps/meteor/client/components/GazzodownText.tsx +++ b/apps/meteor/client/components/GazzodownText.tsx @@ -15,7 +15,7 @@ import { useMessageListHighlights } from './message/list/MessageListContext'; type GazzodownTextProps = { children: JSX.Element; mentions?: { - type: 'user' | 'team'; + type?: 'user' | 'team'; _id: string; username?: string; name?: string; @@ -25,7 +25,9 @@ type GazzodownTextProps = { }; const GazzodownText = ({ mentions, channels, searchText, children }: GazzodownTextProps) => { + const chat = useChat(); const highlights = useMessageListHighlights(); + const highlightRegex = useMemo(() => { if (!highlights?.length) { return; @@ -51,8 +53,6 @@ const GazzodownText = ({ mentions, channels, searchText, children }: GazzodownTe const ownUserId = useUserId(); const showMentionSymbol = Boolean(useUserPreference('mentionsWithSymbol')); - const chat = useChat(); - const resolveUserMention = useCallback( (mention: string) => { if (mention === 'all' || mention === 'here') { @@ -75,7 +75,7 @@ const GazzodownText = ({ mentions, channels, searchText, children }: GazzodownTe return (event: UIEvent): void => { event.stopPropagation(); - chat?.userCard.open(username)(event); + chat?.userCard.openUserCard(event, username); }; }, [chat?.userCard], diff --git a/apps/meteor/client/components/GenericCard/GenericCard.tsx b/apps/meteor/client/components/GenericCard/GenericCard.tsx new file mode 100644 index 0000000000000000000000000000000000000000..335b8e6be959ca888b2f7750d0a09982c86501f8 --- /dev/null +++ b/apps/meteor/client/components/GenericCard/GenericCard.tsx @@ -0,0 +1,34 @@ +import { Card, CardTitle, CardBody, CardControls, CardHeader, FramedIcon } from '@rocket.chat/fuselage'; +import { useUniqueId } from '@rocket.chat/fuselage-hooks'; +import type { ComponentProps, ReactElement } from 'react'; +import React from 'react'; + +import type { GenericCardButton } from './GenericCardButton'; + +type GenericCardProps = { + title: string; + body: string; + buttons?: ReactElement[]; + icon?: ComponentProps['icon']; + type?: 'info' | 'success' | 'warning' | 'danger' | 'neutral'; +} & ComponentProps; + +export const GenericCard: React.FC = ({ title, body, buttons, icon, type, ...props }) => { + const cardId = useUniqueId(); + const descriptionId = useUniqueId(); + + const iconType = type && { + [type]: true, + }; + + return ( + + + {icon && } + {title} + + {body} + {buttons && {buttons}} + + ); +}; diff --git a/apps/meteor/client/components/GenericCard/GenericCardButton.tsx b/apps/meteor/client/components/GenericCard/GenericCardButton.tsx new file mode 100644 index 0000000000000000000000000000000000000000..b89ec26b6e9b4e192412d21d4075acde513a4fc2 --- /dev/null +++ b/apps/meteor/client/components/GenericCard/GenericCardButton.tsx @@ -0,0 +1,5 @@ +import { Button } from '@rocket.chat/fuselage'; +import type { ComponentProps } from 'react'; +import React from 'react'; + +export const GenericCardButton = (props: ComponentProps) => - - - - - ) : ( + )} + {canSendTranscriptEmail && ( + <> + + + {t('Omnichannel_transcript_email')} + + + + {transcriptEmail && ( + <> + + {t('Contact_email')} + + + + + + {t('Subject')} + + + + {errors.subject?.message} + + + )} + + )} + + + {canSendTranscriptPDF && canSendTranscriptEmail + ? t('These_options_affect_this_conversation_only_To_set_default_selections_go_to_My_Account_Omnichannel') + : t('This_option_affect_this_conversation_only_To_set_default_selection_go_to_My_Account_Omnichannel')} + + + + )} + + + + + + + + + + ); + } + + return ( + /> ); }; diff --git a/apps/meteor/client/components/Omnichannel/modals/CloseChatModalData.tsx b/apps/meteor/client/components/Omnichannel/modals/CloseChatModalData.tsx index eadaa353c806bf9f081467b455e6ce95dd344d7a..0edc7bc7af91985e7fa7fc3fdbbf13644f3e3ec1 100644 --- a/apps/meteor/client/components/Omnichannel/modals/CloseChatModalData.tsx +++ b/apps/meteor/client/components/Omnichannel/modals/CloseChatModalData.tsx @@ -1,9 +1,8 @@ -import type { ILivechatDepartment, ILivechatDepartmentAgents } from '@rocket.chat/core-typings'; -import type { ReactElement } from 'react'; +import type { ILivechatDepartment } from '@rocket.chat/core-typings'; +import { useEndpoint } from '@rocket.chat/ui-contexts'; +import { useQuery } from '@tanstack/react-query'; import React from 'react'; -import { AsyncStatePhase } from '../../../hooks/useAsyncState'; -import { useEndpointData } from '../../../hooks/useEndpointData'; import { FormSkeleton } from '../Skeleton'; import CloseChatModal from './CloseChatModal'; @@ -21,32 +20,14 @@ const CloseChatModalData = ({ tags?: string[], preferences?: { omnichannelTranscriptPDF: boolean; omnichannelTranscriptEmail: boolean }, ) => Promise; -}): ReactElement => { - const { value: data, phase: state } = useEndpointData('/v1/livechat/department/:_id', { keys: { _id: departmentId } }); +}) => { + const getDepartment = useEndpoint('GET', '/v1/livechat/department/:_id', { _id: departmentId }); + const { data, isLoading } = useQuery(['/v1/livechat/department/:_id', departmentId], () => getDepartment({})); - if ([state].includes(AsyncStatePhase.LOADING)) { + if (isLoading) { return ; } - // TODO: chapter day: fix issue with rest typing - // TODO: This is necessary because of a weird problem - // There is an endpoint livechat/department/${departmentId}/agents - // that is causing the problem. type A | type B | undefined - - return ( - - ); + return ; }; export default CloseChatModalData; diff --git a/apps/meteor/client/components/Omnichannel/modals/ForwardChatModal.tsx b/apps/meteor/client/components/Omnichannel/modals/ForwardChatModal.tsx index a4d095fdb90d837d5a5e918e0d837dbafe29070f..5e65c43a957c58380c8e4f13d0af2f2a617f919f 100644 --- a/apps/meteor/client/components/Omnichannel/modals/ForwardChatModal.tsx +++ b/apps/meteor/client/components/Omnichannel/modals/ForwardChatModal.tsx @@ -82,7 +82,11 @@ const ForwardChatModal = ({ }, [register]); return ( - } {...props}> + } + {...props} + data-qa-id='forward-chat-modal' + > {t('Forward_chat')} @@ -100,6 +104,7 @@ const ForwardChatModal = ({ options={departments} maxWidth='100%' placeholder={t('Select_an_option')} + data-qa-id='forward-to-department' onChange={(value: string): void => { setValue('department', value); }} diff --git a/apps/meteor/client/components/Omnichannel/modals/ReturnChatQueueModal.tsx b/apps/meteor/client/components/Omnichannel/modals/ReturnChatQueueModal.tsx index 476368aed970ce0b26c25153240159c5cd1acb29..b4f7896186534bea3ee17254c994680de14d41d9 100644 --- a/apps/meteor/client/components/Omnichannel/modals/ReturnChatQueueModal.tsx +++ b/apps/meteor/client/components/Omnichannel/modals/ReturnChatQueueModal.tsx @@ -12,7 +12,7 @@ const ReturnChatQueueModal: FC = ({ onCancel, onMoveC const t = useTranslation(); return ( - + {t('Return_to_the_queue')} diff --git a/apps/meteor/client/components/Page/PageHeader.tsx b/apps/meteor/client/components/Page/PageHeader.tsx index e955ac20a528293823016b2ecc8cd5ec4ca71690..1a2f53739c9afb422247a2b8d921dc5bbd63b358 100644 --- a/apps/meteor/client/components/Page/PageHeader.tsx +++ b/apps/meteor/client/components/Page/PageHeader.tsx @@ -1,5 +1,5 @@ -import { Box, Button } from '@rocket.chat/fuselage'; -import { HeaderToolbox, useDocumentTitle } from '@rocket.chat/ui-client'; +import { Box, IconButton } from '@rocket.chat/fuselage'; +import { HeaderToolbar, useDocumentTitle } from '@rocket.chat/ui-client'; import { useLayout, useTranslation } from '@rocket.chat/ui-contexts'; import type { FC, ComponentProps, ReactNode } from 'react'; import React, { useContext } from 'react'; @@ -31,18 +31,14 @@ const PageHeader: FC = ({ children = undefined, title, onClickB > {isMobile && ( - + - + )} + {onClickBack && } {title} - {onClickBack && ( - - )} {children} diff --git a/apps/meteor/client/components/RoomAutoComplete/Avatar.tsx b/apps/meteor/client/components/RoomAutoComplete/Avatar.tsx index d28546428513d4641efe5de90aecdc00ad8c6b2a..1437c4ebbe89d974c7a2f88ef9e926fa013dfdc6 100644 --- a/apps/meteor/client/components/RoomAutoComplete/Avatar.tsx +++ b/apps/meteor/client/components/RoomAutoComplete/Avatar.tsx @@ -1,9 +1,8 @@ import { Options } from '@rocket.chat/fuselage'; +import { RoomAvatar } from '@rocket.chat/ui-avatar'; import type { FC } from 'react'; import React from 'react'; -import RoomAvatar from '../avatar/RoomAvatar'; - type AvatarProps = { value: string; type: string; diff --git a/apps/meteor/client/components/RoomAutoComplete/RoomAutoComplete.tsx b/apps/meteor/client/components/RoomAutoComplete/RoomAutoComplete.tsx index 93509618827ca06cb3ee8e95f7bb400d4aca7694..39fbf9577776d69078925a8e818a206df5f766c6 100644 --- a/apps/meteor/client/components/RoomAutoComplete/RoomAutoComplete.tsx +++ b/apps/meteor/client/components/RoomAutoComplete/RoomAutoComplete.tsx @@ -1,11 +1,11 @@ import { AutoComplete, Option, Box } from '@rocket.chat/fuselage'; import { useDebouncedValue } from '@rocket.chat/fuselage-hooks'; +import { RoomAvatar } from '@rocket.chat/ui-avatar'; import { useEndpoint } from '@rocket.chat/ui-contexts'; import { useQuery } from '@tanstack/react-query'; import type { ReactElement, ComponentProps } from 'react'; import React, { memo, useMemo, useState } from 'react'; -import RoomAvatar from '../avatar/RoomAvatar'; import Avatar from './Avatar'; const generateQuery = ( diff --git a/apps/meteor/client/components/RoomAutoCompleteMultiple/RoomAutoCompleteMultiple.tsx b/apps/meteor/client/components/RoomAutoCompleteMultiple/RoomAutoCompleteMultiple.tsx index eb0414e7da14472e1ff53069db0f0da335c45886..49006e0251112694e40c8b6ed702a65e3799776e 100644 --- a/apps/meteor/client/components/RoomAutoCompleteMultiple/RoomAutoCompleteMultiple.tsx +++ b/apps/meteor/client/components/RoomAutoCompleteMultiple/RoomAutoCompleteMultiple.tsx @@ -1,12 +1,11 @@ import { AutoComplete, Option, Chip, Box, Skeleton } from '@rocket.chat/fuselage'; import { useDebouncedValue } from '@rocket.chat/fuselage-hooks'; +import { RoomAvatar } from '@rocket.chat/ui-avatar'; import { useEndpoint } from '@rocket.chat/ui-contexts'; import { useQuery } from '@tanstack/react-query'; import type { ReactElement, ComponentProps } from 'react'; import React, { memo, useMemo, useState } from 'react'; -import RoomAvatar from '../avatar/RoomAvatar'; - const generateQuery = ( term = '', ): { diff --git a/apps/meteor/client/components/TextCopy.tsx b/apps/meteor/client/components/TextCopy.tsx index 049ef6a9447a0fa2772ec9b0da60b643679061c3..467e954ddd65797785baa7bb727dd6ba840aab62 100644 --- a/apps/meteor/client/components/TextCopy.tsx +++ b/apps/meteor/client/components/TextCopy.tsx @@ -1,7 +1,9 @@ import { Box, Button, Scrollable } from '@rocket.chat/fuselage'; -import { useToastMessageDispatch, useTranslation } from '@rocket.chat/ui-contexts'; +import { useTranslation } from '@rocket.chat/ui-contexts'; import type { ComponentProps, ReactElement } from 'react'; -import React, { useCallback } from 'react'; +import React from 'react'; + +import useClipboardWithToast from '../hooks/useClipboardWithToast'; const defaultWrapperRenderer = (text: string): ReactElement => ( @@ -14,19 +16,14 @@ type TextCopyProps = { wrapper?: (text: string) => ReactElement; } & ComponentProps; -// TODO: useClipboard instead of navigator API. const TextCopy = ({ text, wrapper = defaultWrapperRenderer, ...props }: TextCopyProps): ReactElement => { const t = useTranslation(); - const dispatchToastMessage = useToastMessageDispatch(); - const onClick = useCallback(() => { - try { - navigator.clipboard.writeText(text); - dispatchToastMessage({ type: 'success', message: t('Copied') }); - } catch (e) { - dispatchToastMessage({ type: 'error', message: e }); - } - }, [dispatchToastMessage, t, text]); + const { copy } = useClipboardWithToast(text); + + const handleClick = () => { + copy(); + }; return ( {wrapper(text)} - ); }; diff --git a/apps/meteor/client/components/TwoFactorModal/TwoFactorModal.tsx b/apps/meteor/client/components/TwoFactorModal/TwoFactorModal.tsx index 3e9824673bf28cfd0df98a4f899da366238fec3a..0382b6eab52e5175d491a223785c3240c43a2e36 100644 --- a/apps/meteor/client/components/TwoFactorModal/TwoFactorModal.tsx +++ b/apps/meteor/client/components/TwoFactorModal/TwoFactorModal.tsx @@ -1,4 +1,3 @@ -import { useEndpoint } from '@rocket.chat/ui-contexts'; import type { ReactElement } from 'react'; import React from 'react'; @@ -29,24 +28,18 @@ type TwoFactorModalProps = { ); const TwoFactorModal = ({ onConfirm, onClose, invalidAttempt, ...props }: TwoFactorModalProps): ReactElement => { - const logoutOtherSessions = useEndpoint('POST', '/v1/users.logoutOtherClients'); - - const confirm = (code: any, method: Method): void => { - onConfirm(code, method); - logoutOtherSessions(); - }; if (props.method === Method.TOTP) { - return ; + return ; } if (props.method === Method.EMAIL) { const { emailOrUsername } = props; - return ; + return ; } if (props.method === Method.PASSWORD) { - return ; + return ; } throw new Error('Invalid Two Factor method'); diff --git a/apps/meteor/client/components/TwoFactorModal/TwoFactorTotpModal.tsx b/apps/meteor/client/components/TwoFactorModal/TwoFactorTotpModal.tsx index 04aa7a4f94f0594fa5303d54d57145915ac0c66c..6f36c9c8ce26c99d2935081e48dd8228fcc0d8d7 100644 --- a/apps/meteor/client/components/TwoFactorModal/TwoFactorTotpModal.tsx +++ b/apps/meteor/client/components/TwoFactorModal/TwoFactorTotpModal.tsx @@ -34,19 +34,20 @@ const TwoFactorTotpModal = ({ onConfirm, onClose, invalidAttempt }: TwoFactorTot wrapperFunction={(props) => } onCancel={onClose} confirmText={t('Verify')} - title={t('Two Factor Authentication')} + title={t('Enter_TOTP_password')} onClose={onClose} variant='warning' - icon='info' confirmDisabled={!code} + tagline={t('Two-factor_authentication')} + icon={null} > - {t('Open_your_authentication_app_and_enter_the_code')} + {t('Enter_the_code_provided_by_your_authentication_app_to_continue')} - + {invalidAttempt && {t('Invalid_password')}} diff --git a/apps/meteor/client/components/UserAndRoomAutoCompleteMultiple/UserAndRoomAutoCompleteMultiple.tsx b/apps/meteor/client/components/UserAndRoomAutoCompleteMultiple/UserAndRoomAutoCompleteMultiple.tsx index f96c198865ccefd3cbef379fb97dd1a47c0c5898..b8ff0b472fa45bb3641675111bd7a471017d076a 100644 --- a/apps/meteor/client/components/UserAndRoomAutoCompleteMultiple/UserAndRoomAutoCompleteMultiple.tsx +++ b/apps/meteor/client/components/UserAndRoomAutoCompleteMultiple/UserAndRoomAutoCompleteMultiple.tsx @@ -2,13 +2,12 @@ import { isDirectMessageRoom } from '@rocket.chat/core-typings'; import { AutoComplete, Box, Option, OptionAvatar, OptionContent, Chip } from '@rocket.chat/fuselage'; import { useDebouncedValue } from '@rocket.chat/fuselage-hooks'; import { escapeRegExp } from '@rocket.chat/string-helpers'; +import { RoomAvatar, UserAvatar } from '@rocket.chat/ui-avatar'; import { useUser, useUserSubscriptions } from '@rocket.chat/ui-contexts'; import type { ComponentProps, ReactElement } from 'react'; import React, { memo, useMemo, useState } from 'react'; import { roomCoordinator } from '../../lib/rooms/roomCoordinator'; -import RoomAvatar from '../avatar/RoomAvatar'; -import UserAvatar from '../avatar/UserAvatar'; type UserAndRoomAutoCompleteMultipleProps = Omit, 'filter'>; diff --git a/apps/meteor/client/components/UserAutoComplete/UserAutoComplete.tsx b/apps/meteor/client/components/UserAutoComplete/UserAutoComplete.tsx index ebe3ecc5ee72915a7a2cf18890987b29d198d8c6..1f12f29ee13b3b84068ee792db00078337a6825a 100644 --- a/apps/meteor/client/components/UserAutoComplete/UserAutoComplete.tsx +++ b/apps/meteor/client/components/UserAutoComplete/UserAutoComplete.tsx @@ -1,12 +1,11 @@ import { AutoComplete, Option, Box, Chip, Options } from '@rocket.chat/fuselage'; import { useDebouncedValue } from '@rocket.chat/fuselage-hooks'; +import { UserAvatar } from '@rocket.chat/ui-avatar'; import { useEndpoint } from '@rocket.chat/ui-contexts'; import { useQuery } from '@tanstack/react-query'; import type { ComponentProps, ReactElement } from 'react'; import React, { memo, useMemo, useState } from 'react'; -import UserAvatar from '../avatar/UserAvatar'; - const query = ( term = '', conditions = {}, diff --git a/apps/meteor/client/components/UserAutoCompleteMultiple/UserAutoCompleteMultiple.tsx b/apps/meteor/client/components/UserAutoCompleteMultiple/UserAutoCompleteMultiple.tsx index 857af5e9c43f0630d39c7bfae7a4a7ab39252e63..f9887d693ca0cf996543e26915fcb9864962d3ac 100644 --- a/apps/meteor/client/components/UserAutoCompleteMultiple/UserAutoCompleteMultiple.tsx +++ b/apps/meteor/client/components/UserAutoCompleteMultiple/UserAutoCompleteMultiple.tsx @@ -1,12 +1,11 @@ import { AutoComplete, Box, OptionAvatar, Option, OptionContent, Chip, OptionDescription } from '@rocket.chat/fuselage'; import { useDebouncedValue } from '@rocket.chat/fuselage-hooks'; +import { UserAvatar } from '@rocket.chat/ui-avatar'; import { useEndpoint } from '@rocket.chat/ui-contexts'; import { useQuery } from '@tanstack/react-query'; import type { ComponentProps, ReactElement } from 'react'; import React, { memo, useMemo, useState } from 'react'; -import UserAvatar from '../avatar/UserAvatar'; - const query = ( term = '', ): { diff --git a/apps/meteor/client/components/UserAutoCompleteMultiple/UserAutoCompleteMultipleFederated.tsx b/apps/meteor/client/components/UserAutoCompleteMultiple/UserAutoCompleteMultipleFederated.tsx index 457593e2c5db4006592b7d0b4dcead1eb624d713..5a372ee39dd2a16553b61bf31a4f293adf73d3d2 100644 --- a/apps/meteor/client/components/UserAutoCompleteMultiple/UserAutoCompleteMultipleFederated.tsx +++ b/apps/meteor/client/components/UserAutoCompleteMultiple/UserAutoCompleteMultipleFederated.tsx @@ -1,11 +1,11 @@ import { MultiSelectFiltered, Icon, Box, Chip } from '@rocket.chat/fuselage'; import { useDebouncedValue } from '@rocket.chat/fuselage-hooks'; +import { UserAvatar } from '@rocket.chat/ui-avatar'; import { useEndpoint } from '@rocket.chat/ui-contexts'; import { useQuery } from '@tanstack/react-query'; import type { ReactElement, AllHTMLAttributes } from 'react'; import React, { memo, useState, useCallback, useMemo } from 'react'; -import UserAvatar from '../avatar/UserAvatar'; import AutocompleteOptions, { OptionsContext } from './UserAutoCompleteMultipleOptions'; type UserAutoCompleteMultipleFederatedProps = { diff --git a/apps/meteor/client/components/UserAutoCompleteMultiple/UserAutoCompleteMultipleOption.tsx b/apps/meteor/client/components/UserAutoCompleteMultiple/UserAutoCompleteMultipleOption.tsx index e6762c188abde827b44a6a520c0b0fbbd16548c0..0dc773af9596252e54ade0f75dbf0f23c3490bdb 100644 --- a/apps/meteor/client/components/UserAutoCompleteMultiple/UserAutoCompleteMultipleOption.tsx +++ b/apps/meteor/client/components/UserAutoCompleteMultiple/UserAutoCompleteMultipleOption.tsx @@ -1,10 +1,9 @@ import type { IUser } from '@rocket.chat/core-typings'; import { Option, OptionDescription } from '@rocket.chat/fuselage'; +import { UserAvatar } from '@rocket.chat/ui-avatar'; import type { ReactElement } from 'react'; import React from 'react'; -import UserAvatar from '../avatar/UserAvatar'; - type UserAutoCompleteMultipleOptionProps = { label: { _federated?: boolean; diff --git a/apps/meteor/client/components/UserCard/UserCard.stories.tsx b/apps/meteor/client/components/UserCard/UserCard.stories.tsx index b07d4a09cc7dc6fee7e6c423294356eb9ff30c8e..e3174381d85d1aca1133d1225a95af1dcf5a02c8 100644 --- a/apps/meteor/client/components/UserCard/UserCard.stories.tsx +++ b/apps/meteor/client/components/UserCard/UserCard.stories.tsx @@ -1,7 +1,7 @@ import type { ComponentMeta, ComponentStory } from '@storybook/react'; import React from 'react'; -import UserCard from '.'; +import { UserCard, UserCardRole, UserCardAction } from '.'; export default { title: 'Components/UserCard', @@ -14,16 +14,16 @@ export default { customStatus: '🛴 currently working on User Card', roles: ( <> - Admin - Rocket.Chat - Team + Admin + Rocket.Chat + Team ), bio: 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Nulla tempus, eros convallis vulputate cursus, nisi neque eleifend libero, eget lacinia justo purus nec est. In at sodales ipsum. Sed lacinia quis purus eget pulvinar. Aenean eu pretium nunc, at aliquam magna. Praesent dignissim, tortor sed volutpat mattis, mauris diam pulvinar leo, porta commodo risus est non purus. Mauris in justo vel lorem ullamcorper hendrerit. Nam est metus, viverra a pellentesque vitae, ornare eget odio. Morbi tempor feugiat mattis. Morbi non felis tempor, aliquam justo sed, sagittis nibh. Mauris consequat ex metus. Praesent sodales sit amet nibh a vulputate. Integer commodo, mi vel bibendum sollicitudin, urna lectus accumsan ante, eget faucibus augue ex id neque. Aenean consectetur, orci a pellentesque mattis, tortor tellus fringilla elit, non ullamcorper risus nunc feugiat risus. Fusce sit amet nisi dapibus turpis commodo placerat. In tortor ante, vehicula sit amet augue et, imperdiet porta sem.', actions: ( <> - - + + ), localTime: 'Local Time: 7:44 AM', diff --git a/apps/meteor/client/components/UserCard/UserCard.tsx b/apps/meteor/client/components/UserCard/UserCard.tsx index 83edd9e3d8cb419c8f6735cc8ddef72aa287e5f1..2d440427ccbd32cb79c4249d99ef04927f8ba210 100644 --- a/apps/meteor/client/components/UserCard/UserCard.tsx +++ b/apps/meteor/client/components/UserCard/UserCard.tsx @@ -1,13 +1,14 @@ import { css } from '@rocket.chat/css-in-js'; -import { Box, IconButton, Skeleton } from '@rocket.chat/fuselage'; +import { Box, Button, IconButton } from '@rocket.chat/fuselage'; +import { UserAvatar } from '@rocket.chat/ui-avatar'; import { useTranslation } from '@rocket.chat/ui-contexts'; -import type { ReactNode, ComponentProps, MouseEvent } from 'react'; +import type { ReactNode, ComponentProps } from 'react'; import React, { forwardRef } from 'react'; import { useEmbeddedLayout } from '../../hooks/useEmbeddedLayout'; import MarkdownText from '../MarkdownText'; import * as Status from '../UserStatus'; -import UserAvatar from '../avatar/UserAvatar'; +import UserCardActions from './UserCardActions'; import UserCardContainer from './UserCardContainer'; import UserCardInfo from './UserCardInfo'; import UserCardRoles from './UserCardRoles'; @@ -22,9 +23,7 @@ const clampStyle = css` `; type UserCardProps = { - className?: string; - style?: ComponentProps['style']; - open?: (e: MouseEvent) => void; + onOpenUserInfo?: () => void; name?: string; username?: string; etag?: string; @@ -36,61 +35,40 @@ type UserCardProps = { localTime?: ReactNode; onClose?: () => void; nickname?: string; - isLoading?: boolean; -}; +} & ComponentProps; -const UserCard = forwardRef(function UserCard( +const UserCard = forwardRef(function UserCard( { - className, - style, - open, + onOpenUserInfo, name, username, etag, - customStatus = , - roles = ( - <> - - - - - ), - bio = , + customStatus, + roles, + bio, status = , actions, - localTime = , + localTime, onClose, nickname, - isLoading, - }: UserCardProps, + ...props + }, ref, ) { const t = useTranslation(); const isLayoutEmbedded = useEmbeddedLayout(); return ( - - - {!isLoading && username ? ( - - ) : ( - - )} + +
+ {username && } - {isLoading ? ( - <> - - - - - ) : ( - actions - )} + {actions} - - +
+ - {isLoading ? : } + {nickname && ( ({nickname}) @@ -113,13 +91,15 @@ const UserCard = forwardRef(function UserCard( {typeof bio === 'string' ? : bio} )} - {!isLoading && open && !isLayoutEmbedded && {t('See_full_profile')}} + {onOpenUserInfo && !isLayoutEmbedded && ( +
+ +
+ )}
- {onClose && ( - - - - )} + {onClose && }
); }); diff --git a/apps/meteor/client/components/UserCard/UserCardAction.tsx b/apps/meteor/client/components/UserCard/UserCardAction.tsx index f13e71b601b521b5c45c4791d53e76487f289d33..c96e742be94e093795fcbd0b27e294583d1f5e95 100644 --- a/apps/meteor/client/components/UserCard/UserCardAction.tsx +++ b/apps/meteor/client/components/UserCard/UserCardAction.tsx @@ -5,7 +5,7 @@ import React from 'react'; type UserCardActionProps = ComponentProps; const UserCardAction = ({ label, icon, ...props }: UserCardActionProps): ReactElement => ( - + ); export default UserCardAction; diff --git a/apps/meteor/client/components/UserCard/UserCardActions.tsx b/apps/meteor/client/components/UserCard/UserCardActions.tsx new file mode 100644 index 0000000000000000000000000000000000000000..6a1258c029971119857f69e6e8f9df5a18519d32 --- /dev/null +++ b/apps/meteor/client/components/UserCard/UserCardActions.tsx @@ -0,0 +1,15 @@ +import { useToolbar } from '@react-aria/toolbar'; +import { ButtonGroup } from '@rocket.chat/fuselage'; +import type { ReactElement, ComponentProps } from 'react'; +import React, { useRef } from 'react'; + +type UserCardActionsProps = ComponentProps; + +const UserCardActions = (props: UserCardActionsProps): ReactElement => { + const ref = useRef(null); + const { toolbarProps } = useToolbar(props, ref); + + return ; +}; + +export default UserCardActions; diff --git a/apps/meteor/client/components/UserCard/UserCardContainer.tsx b/apps/meteor/client/components/UserCard/UserCardContainer.tsx index c1279e4ff513197730c142586b39d818542ed8b8..2434077922e346263a86dc3afd4cc1eed5839a57 100644 --- a/apps/meteor/client/components/UserCard/UserCardContainer.tsx +++ b/apps/meteor/client/components/UserCard/UserCardContainer.tsx @@ -3,7 +3,21 @@ import type { ComponentProps } from 'react'; import React, { forwardRef } from 'react'; const UserCardContainer = forwardRef(function UserCardContainer(props: ComponentProps, ref) { - return ; + return ( + + ); }); export default UserCardContainer; diff --git a/apps/meteor/client/components/UserCard/UserCardSkeleton.tsx b/apps/meteor/client/components/UserCard/UserCardSkeleton.tsx new file mode 100644 index 0000000000000000000000000000000000000000..9339eca8f665990a9dab4f80e6031adc22e25df4 --- /dev/null +++ b/apps/meteor/client/components/UserCard/UserCardSkeleton.tsx @@ -0,0 +1,34 @@ +import { Box, Skeleton } from '@rocket.chat/fuselage'; +import type { ComponentProps } from 'react'; +import React, { forwardRef } from 'react'; + +import UserCardContainer from './UserCardContainer'; + +const UserCardSkeleton = forwardRef>(function UserCardSkeleton(props, ref) { + return ( + + + + + {Array.from({ length: 3 }).map((_, i) => ( + + ))} + + + + + + + + {Array.from({ length: 3 }).map((_, i) => ( + + ))} + {Array.from({ length: 2 }).map((_, i) => ( + + ))} + + + ); +}); + +export default UserCardSkeleton; diff --git a/apps/meteor/client/components/UserCard/index.ts b/apps/meteor/client/components/UserCard/index.ts index defc7140be156df366972c2aa7443c9c6e810870..75a5ad84cd6cdfe1e47a7589d7a94b919646d19e 100644 --- a/apps/meteor/client/components/UserCard/index.ts +++ b/apps/meteor/client/components/UserCard/index.ts @@ -3,12 +3,7 @@ import UserCardAction from './UserCardAction'; import UserCardInfo from './UserCardInfo'; import UserCardRole from './UserCardRole'; import UserCardRoles from './UserCardRoles'; +import UserCardSkeleton from './UserCardSkeleton'; import UserCardUsername from './UserCardUsername'; -export default Object.assign(UserCard, { - Action: UserCardAction, - Role: UserCardRole, - Roles: UserCardRoles, - Info: UserCardInfo, - Username: UserCardUsername, -}); +export { UserCard, UserCardAction, UserCardInfo, UserCardRole, UserCardRoles, UserCardUsername, UserCardSkeleton }; diff --git a/apps/meteor/client/components/UserInfo/UserInfo.tsx b/apps/meteor/client/components/UserInfo/UserInfo.tsx index bdce27d028ada8a7d08bdf50a01434db9ceae96b..72722e8e91568b136720bdc1dd27b8c039803dc2 100644 --- a/apps/meteor/client/components/UserInfo/UserInfo.tsx +++ b/apps/meteor/client/components/UserInfo/UserInfo.tsx @@ -12,7 +12,7 @@ import { ContextualbarScrollableContent } from '../Contextualbar'; import InfoPanel from '../InfoPanel'; import MarkdownText from '../MarkdownText'; import UTCClock from '../UTCClock'; -import UserCard from '../UserCard'; +import { UserCardRoles } from '../UserCard'; import UserInfoAvatar from './UserInfoAvatar'; type UserInfoDataProps = Serialized< @@ -90,7 +90,7 @@ const UserInfo = ({ {roles.length !== 0 && ( {t('Roles')} - {roles} + {roles} )} diff --git a/apps/meteor/client/components/UserInfo/UserInfoAvatar.tsx b/apps/meteor/client/components/UserInfo/UserInfoAvatar.tsx index 2fbe4a05ed55944cd620d0a921c36e6064a1c694..c1599248926494b765c72aacd4b79f16419daa18 100644 --- a/apps/meteor/client/components/UserInfo/UserInfoAvatar.tsx +++ b/apps/meteor/client/components/UserInfo/UserInfoAvatar.tsx @@ -1,8 +1,7 @@ +import { UserAvatar } from '@rocket.chat/ui-avatar'; import type { ComponentProps, ReactElement } from 'react'; import React from 'react'; -import UserAvatar from '../avatar/UserAvatar'; - const UserInfoAvatar = ({ username, ...props }: ComponentProps): ReactElement => ( ); diff --git a/apps/meteor/client/components/UserInfo/UserInfoUsername.tsx b/apps/meteor/client/components/UserInfo/UserInfoUsername.tsx index 75d2c87a82c011f490cd084cd1dd873bd2c1dfce..171c5c68d8c423da1607b7355efb24823db7d215 100644 --- a/apps/meteor/client/components/UserInfo/UserInfoUsername.tsx +++ b/apps/meteor/client/components/UserInfo/UserInfoUsername.tsx @@ -3,16 +3,15 @@ import type { Box } from '@rocket.chat/fuselage'; import type { ReactElement, ComponentProps } from 'react'; import React from 'react'; -import UserCard from '../UserCard'; +import { UserCardUsername } from '../UserCard'; type UserInfoUsernameProps = { username: IUser['username']; status: ReactElement; } & ComponentProps; -// TODO: Remove UserCard.Username const UserInfoUsername = ({ username, status, ...props }: UserInfoUsernameProps): ReactElement => ( - + ); export default UserInfoUsername; diff --git a/apps/meteor/client/components/avatar/AppAvatar.tsx b/apps/meteor/client/components/avatar/AppAvatar.tsx deleted file mode 100644 index c146eb4b10fc9a7090c294fd7af2d5a6cd0b620d..0000000000000000000000000000000000000000 --- a/apps/meteor/client/components/avatar/AppAvatar.tsx +++ /dev/null @@ -1,19 +0,0 @@ -import { Box } from '@rocket.chat/fuselage'; -import type { ComponentProps, ReactElement } from 'react'; -import React from 'react'; - -import BaseAvatar from './BaseAvatar'; - -type AppAvatarProps = { - iconFileContent: string; - iconFileData: string; - size: ComponentProps['size']; -} & ComponentProps; - -export default function AppAvatar({ iconFileContent, size, iconFileData, ...props }: AppAvatarProps): ReactElement { - return ( - - - - ); -} diff --git a/apps/meteor/client/components/avatar/BaseAvatar.tsx b/apps/meteor/client/components/avatar/BaseAvatar.tsx deleted file mode 100644 index f264f841bc8e836dd0929011f72b41a5341c3715..0000000000000000000000000000000000000000 --- a/apps/meteor/client/components/avatar/BaseAvatar.tsx +++ /dev/null @@ -1,18 +0,0 @@ -import type { AvatarProps } from '@rocket.chat/fuselage'; -import { Avatar, Skeleton } from '@rocket.chat/fuselage'; -import type { FC } from 'react'; -import React, { useState } from 'react'; - -export type BaseAvatarProps = Omit; - -const BaseAvatar: FC = ({ size, ...props }) => { - const [error, setError] = useState(false); - - if (error) { - return ; - } - - return ; -}; - -export default BaseAvatar; diff --git a/apps/meteor/client/components/avatar/RoomAvatar.tsx b/apps/meteor/client/components/avatar/RoomAvatar.tsx deleted file mode 100644 index bf3c965090468e2f6db5c9fa0bebd3b791c1dbc0..0000000000000000000000000000000000000000 --- a/apps/meteor/client/components/avatar/RoomAvatar.tsx +++ /dev/null @@ -1,29 +0,0 @@ -import { useRoomAvatarPath } from '@rocket.chat/ui-contexts'; -import type { ReactElement } from 'react'; -import React, { memo } from 'react'; - -import BaseAvatar from './BaseAvatar'; - -// TODO: frontend chapter day - Remove inline Styling - -type RoomAvatarProps = { - /* @deprecated */ - size?: 'x16' | 'x20' | 'x28' | 'x36' | 'x40' | 'x124' | 'x332'; - /* @deprecated */ - url?: string; - - room: { - _id: string; - type?: string; - t?: string; - avatarETag?: string; - }; -}; - -const RoomAvatar = function RoomAvatar({ room, ...rest }: RoomAvatarProps): ReactElement { - const getRoomPathAvatar = useRoomAvatarPath(); - const { url = getRoomPathAvatar(room), ...props } = rest; - return ; -}; - -export default memo(RoomAvatar); diff --git a/apps/meteor/client/components/avatar/RoomAvatarEditor.tsx b/apps/meteor/client/components/avatar/RoomAvatarEditor.tsx index 4a780e4ab1732b554cc16f5c911d4cffd33543c8..de4108d8f86696e5e1853797bd690619e8a1a454 100644 --- a/apps/meteor/client/components/avatar/RoomAvatarEditor.tsx +++ b/apps/meteor/client/components/avatar/RoomAvatarEditor.tsx @@ -3,6 +3,7 @@ import type { IRoom, RoomAdminFieldsType } from '@rocket.chat/core-typings'; import { css } from '@rocket.chat/css-in-js'; import { Box, Button, ButtonGroup } from '@rocket.chat/fuselage'; import { useMutableCallback } from '@rocket.chat/fuselage-hooks'; +import { RoomAvatar } from '@rocket.chat/ui-avatar'; import { useToastMessageDispatch, useTranslation } from '@rocket.chat/ui-contexts'; import type { ReactElement } from 'react'; import React, { useEffect } from 'react'; @@ -10,7 +11,6 @@ import React, { useEffect } from 'react'; import { getAvatarURL } from '../../../app/utils/client/getAvatarURL'; import { useSingleFileInput } from '../../hooks/useSingleFileInput'; import { isValidImageFormat } from '../../lib/utils/isValidImageFormat'; -import RoomAvatar from './RoomAvatar'; type RoomAvatarEditorProps = { room: Pick; diff --git a/apps/meteor/client/components/avatar/UserAvatarEditor/UserAvatarEditor.tsx b/apps/meteor/client/components/avatar/UserAvatarEditor/UserAvatarEditor.tsx index ae03418be755579b93271688250458341efe819f..05d96d9536be18b13b7a678fdfd5d5e399508b85 100644 --- a/apps/meteor/client/components/avatar/UserAvatarEditor/UserAvatarEditor.tsx +++ b/apps/meteor/client/components/avatar/UserAvatarEditor/UserAvatarEditor.tsx @@ -1,23 +1,18 @@ import type { IUser, AvatarObject } from '@rocket.chat/core-typings'; -import { Box, Button, TextInput, Margins, Avatar, IconButton } from '@rocket.chat/fuselage'; +import { Box, Button, Avatar, TextInput, IconButton, Label } from '@rocket.chat/fuselage'; +import { useUniqueId } from '@rocket.chat/fuselage-hooks'; +import { UserAvatar } from '@rocket.chat/ui-avatar'; import { useToastMessageDispatch, useSetting, useTranslation } from '@rocket.chat/ui-contexts'; import type { ReactElement, ChangeEvent } from 'react'; import React, { useState, useCallback } from 'react'; import { useSingleFileInput } from '../../../hooks/useSingleFileInput'; import { isValidImageFormat } from '../../../lib/utils/isValidImageFormat'; -import UserAvatar from '../UserAvatar'; +import type { UserAvatarSuggestion } from './UserAvatarSuggestion'; import UserAvatarSuggestions from './UserAvatarSuggestions'; +import { readFileAsDataURL } from './readFileAsDataURL'; -const toDataURL = (file: File, callback: (result: FileReader['result']) => void): void => { - const reader = new FileReader(); - reader.onloadend = function (e): void { - callback(e?.target?.result || null); - }; - reader.readAsDataURL(file); -}; - -type UserAvatarEditorType = { +type UserAvatarEditorProps = { currentUsername: IUser['username']; username: IUser['username']; setAvatarObj: (obj: AvatarObject) => void; @@ -25,31 +20,33 @@ type UserAvatarEditorType = { etag: IUser['avatarETag']; }; -function UserAvatarEditor({ currentUsername, username, setAvatarObj, disabled, etag }: UserAvatarEditorType): ReactElement { +function UserAvatarEditor({ currentUsername, username, setAvatarObj, disabled, etag }: UserAvatarEditorProps): ReactElement { const t = useTranslation(); const rotateImages = useSetting('FileUpload_RotateImages'); const [avatarFromUrl, setAvatarFromUrl] = useState(''); const [newAvatarSource, setNewAvatarSource] = useState(); + const imageUrlField = useUniqueId(); const dispatchToastMessage = useToastMessageDispatch(); const setUploadedPreview = useCallback( async (file, avatarObj) => { setAvatarObj(avatarObj); - toDataURL(file, async (dataURL) => { - if (typeof dataURL === 'string' && (await isValidImageFormat(dataURL))) { + try { + const dataURL = await readFileAsDataURL(file); + + if (await isValidImageFormat(dataURL)) { setNewAvatarSource(dataURL); - return; } - + } catch (error) { dispatchToastMessage({ type: 'error', message: t('Avatar_format_invalid') }); - }); + } }, [setAvatarObj, t, dispatchToastMessage], ); const [clickUpload] = useSingleFileInput(setUploadedPreview); - const clickUrl = (): void => { + const handleAddUrl = (): void => { setNewAvatarSource(avatarFromUrl); setAvatarObj({ avatarUrl: avatarFromUrl }); }; @@ -65,6 +62,14 @@ function UserAvatarEditor({ currentUsername, username, setAvatarObj, disabled, e setAvatarFromUrl(event.currentTarget.value); }; + const handleSelectSuggestion = useCallback( + (suggestion: UserAvatarSuggestion) => { + setAvatarObj(suggestion as unknown as AvatarObject); + setNewAvatarSource(suggestion.blob); + }, + [setAvatarObj, setNewAvatarSource], + ); + return ( {t('Profile_picture')} @@ -72,41 +77,45 @@ function UserAvatarEditor({ currentUsername, username, setAvatarObj, disabled, e dispatchToastMessage({ type: 'error', message: t('error-invalid-image-url') })} /> - - - - - - - - - {t('Use_url_for_avatar')} - + + + + - + + + + diff --git a/apps/meteor/client/components/avatar/UserAvatarEditor/UserAvatarSuggestion.ts b/apps/meteor/client/components/avatar/UserAvatarEditor/UserAvatarSuggestion.ts new file mode 100644 index 0000000000000000000000000000000000000000..e2ac26c6d0f37fb2bdaf4ba44c1aaf90330b752e --- /dev/null +++ b/apps/meteor/client/components/avatar/UserAvatarEditor/UserAvatarSuggestion.ts @@ -0,0 +1,6 @@ +export type UserAvatarSuggestion = { + blob: string; + contentType: string; + service: string; + url: string; +}; diff --git a/apps/meteor/client/components/avatar/UserAvatarEditor/UserAvatarSuggestions.tsx b/apps/meteor/client/components/avatar/UserAvatarEditor/UserAvatarSuggestions.tsx index 04b0c92acd957ac9ec04d55f608306702d290958..4ccb7d304683ac25db743be8e12f49873d10ebbd 100644 --- a/apps/meteor/client/components/avatar/UserAvatarEditor/UserAvatarSuggestions.tsx +++ b/apps/meteor/client/components/avatar/UserAvatarEditor/UserAvatarSuggestions.tsx @@ -1,43 +1,31 @@ -import type { AvatarObject } from '@rocket.chat/core-typings'; -import { Box, Button, Margins, Avatar } from '@rocket.chat/fuselage'; +import { Button, Avatar } from '@rocket.chat/fuselage'; import React, { useCallback } from 'react'; -import { useAvatarSuggestions } from '../../../hooks/useAvatarSuggestions'; +import type { UserAvatarSuggestion } from './UserAvatarSuggestion'; +import { useUserAvatarSuggestions } from './useUserAvatarSuggestions'; type UserAvatarSuggestionsProps = { - setAvatarObj: (obj: AvatarObject) => void; - setNewAvatarSource: (source: string) => void; disabled?: boolean; + onSelectOne?: (suggestion: UserAvatarSuggestion) => void; }; -const UserAvatarSuggestions = ({ setAvatarObj, setNewAvatarSource, disabled }: UserAvatarSuggestionsProps) => { - const handleClick = useCallback( - (suggestion) => () => { - setAvatarObj(suggestion); - setNewAvatarSource(suggestion.blob); - }, - [setAvatarObj, setNewAvatarSource], - ); +function UserAvatarSuggestions({ disabled, onSelectOne }: UserAvatarSuggestionsProps) { + const { data: suggestions = [] } = useUserAvatarSuggestions(); - const { data } = useAvatarSuggestions(); - const suggestions = Object.values(data?.suggestions || {}); + const handleClick = useCallback((suggestion: UserAvatarSuggestion) => () => onSelectOne?.(suggestion), [onSelectOne]); return ( - - {suggestions && - suggestions.length > 0 && - suggestions.map( - (suggestion) => - suggestion.blob && ( - - ), - )} - + <> + {suggestions.map( + (suggestion) => + suggestion.blob && ( + + ), + )} + ); -}; +} export default UserAvatarSuggestions; diff --git a/apps/meteor/client/components/avatar/UserAvatarEditor/readFileAsDataURL.ts b/apps/meteor/client/components/avatar/UserAvatarEditor/readFileAsDataURL.ts new file mode 100644 index 0000000000000000000000000000000000000000..d2bb3f7beed188c2383d01a43df5d1077ed22848 --- /dev/null +++ b/apps/meteor/client/components/avatar/UserAvatarEditor/readFileAsDataURL.ts @@ -0,0 +1,16 @@ +export const readFileAsDataURL = (file: File) => + new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onloadend = (event) => { + const result = event.target?.result; + if (typeof result === 'string') { + resolve(result); + return; + } + reject(new Error('Failed to read file')); + }; + reader.onerror = (event) => { + reject(new Error(`Failed to read file: ${event}`)); + }; + reader.readAsDataURL(file); + }); diff --git a/apps/meteor/client/components/avatar/UserAvatarEditor/useUserAvatarSuggestions.ts b/apps/meteor/client/components/avatar/UserAvatarEditor/useUserAvatarSuggestions.ts new file mode 100644 index 0000000000000000000000000000000000000000..42bb09406d3bc83b5c3293dbc9c545b7596706a4 --- /dev/null +++ b/apps/meteor/client/components/avatar/UserAvatarEditor/useUserAvatarSuggestions.ts @@ -0,0 +1,12 @@ +import { useEndpoint } from '@rocket.chat/ui-contexts'; +import { useQuery } from '@tanstack/react-query'; + +export const useUserAvatarSuggestions = () => { + const getAvatarSuggestions = useEndpoint('GET', '/v1/users.getAvatarSuggestion'); + + return useQuery({ + queryKey: ['account', 'profile', 'avatar-suggestions'], + queryFn: async () => getAvatarSuggestions(), + select: (data) => Object.values(data.suggestions), + }); +}; diff --git a/apps/meteor/client/components/connectionStatus/ConnectionStatusBar.styles.css b/apps/meteor/client/components/connectionStatus/ConnectionStatusBar.styles.css deleted file mode 100644 index f9a363aaa28ad1f2dc937d66091c79e141308263..0000000000000000000000000000000000000000 --- a/apps/meteor/client/components/connectionStatus/ConnectionStatusBar.styles.css +++ /dev/null @@ -1,18 +0,0 @@ -.ConnectionStatusBar { - position: fixed; - z-index: 1000000; - top: 0; - - width: 100%; - padding: 2px; - - text-align: center; - - color: #916302; - border-bottom-width: 1px; - background-color: #fffdf9; - - &__retry-link { - color: var(--color-blue); - } -} diff --git a/apps/meteor/client/components/connectionStatus/ConnectionStatusBar.tsx b/apps/meteor/client/components/connectionStatus/ConnectionStatusBar.tsx index 2737a6fc4d4a3dfa77d79f3e18dad24e94674a34..6046aecc0bae27b851fc36e2929627e84dd3931b 100644 --- a/apps/meteor/client/components/connectionStatus/ConnectionStatusBar.tsx +++ b/apps/meteor/client/components/connectionStatus/ConnectionStatusBar.tsx @@ -1,82 +1,53 @@ -import { Icon } from '@rocket.chat/fuselage'; -import { useConnectionStatus, useTranslation } from '@rocket.chat/ui-contexts'; -import type { MouseEventHandler, FC } from 'react'; -import React, { useEffect, useRef, useState } from 'react'; +import { Box, Icon } from '@rocket.chat/fuselage'; +import { useConnectionStatus } from '@rocket.chat/ui-contexts'; +import type { MouseEvent } from 'react'; +import React from 'react'; +import { useTranslation } from 'react-i18next'; -import './ConnectionStatusBar.styles.css'; +import { useReconnectCountdown } from './useReconnectCountdown'; -// TODO: frontend chapter day - fix unknown translation keys - -const getReconnectCountdown = (retryTime: number): number => { - const timeDiff = retryTime - Date.now(); - return (timeDiff > 0 && Math.round(timeDiff / 1000)) || 0; -}; - -const useReconnectCountdown = ( - retryTime: number | undefined, - status: 'connected' | 'connecting' | 'failed' | 'waiting' | 'offline', -): number => { - const reconnectionTimerRef = useRef>(); - const [reconnectCountdown, setReconnectCountdown] = useState(() => (retryTime ? getReconnectCountdown(retryTime) : 0)); - - useEffect(() => { - if (status === 'waiting') { - if (reconnectionTimerRef.current) { - return; - } - - reconnectionTimerRef.current = setInterval(() => { - retryTime && setReconnectCountdown(getReconnectCountdown(retryTime)); - }, 500); - return; - } - - reconnectionTimerRef.current && clearInterval(reconnectionTimerRef.current); - reconnectionTimerRef.current = undefined; - }, [retryTime, status]); - - useEffect( - () => (): void => { - reconnectionTimerRef.current && clearInterval(reconnectionTimerRef.current); - }, - [], - ); - - return reconnectCountdown; -}; - -const ConnectionStatusBar: FC = function ConnectionStatusBar() { +function ConnectionStatusBar() { const { connected, retryTime, status, reconnect } = useConnectionStatus(); const reconnectCountdown = useReconnectCountdown(retryTime, status); - const t = useTranslation(); + const { t } = useTranslation(); if (connected) { return null; } - const handleRetryClick: MouseEventHandler = (event) => { + const handleRetryClick = (event: MouseEvent) => { event.preventDefault(); reconnect?.(); }; return ( -
- - {t('meteor_status' as Parameters[0], { context: status })} - - - {status === 'waiting' && <> {t('meteor_status_reconnect_in', { count: reconnectCountdown })}} - - {['waiting', 'offline'].includes(status) && ( - <> - {' '} - - {t('meteor_status_try_now' as Parameters[0], { context: status })} - - - )} -
+ + {' '} + + {t('meteor_status', { context: status })} + {status === 'waiting' && <> {t('meteor_status_reconnect_in', { count: reconnectCountdown })}} + {['waiting', 'offline'].includes(status) && ( + <> + {' '} + + {t('meteor_status_try_now', { context: status })} + + + )} + + ); -}; +} export default ConnectionStatusBar; diff --git a/apps/meteor/client/components/connectionStatus/useReconnectCountdown.ts b/apps/meteor/client/components/connectionStatus/useReconnectCountdown.ts new file mode 100644 index 0000000000000000000000000000000000000000..7a409e752ce448672e6ad7f87ece8f1ee7768226 --- /dev/null +++ b/apps/meteor/client/components/connectionStatus/useReconnectCountdown.ts @@ -0,0 +1,39 @@ +import { useEffect, useRef, useState } from 'react'; + +const getReconnectCountdown = (retryTime: number): number => { + const timeDiff = retryTime - Date.now(); + return (timeDiff > 0 && Math.round(timeDiff / 1000)) || 0; +}; + +export const useReconnectCountdown = ( + retryTime: number | undefined, + status: 'connected' | 'connecting' | 'failed' | 'waiting' | 'offline', +): number => { + const reconnectionTimerRef = useRef>(); + const [reconnectCountdown, setReconnectCountdown] = useState(() => (retryTime ? getReconnectCountdown(retryTime) : 0)); + + useEffect(() => { + if (status === 'waiting') { + if (reconnectionTimerRef.current) { + return; + } + + reconnectionTimerRef.current = setInterval(() => { + retryTime && setReconnectCountdown(getReconnectCountdown(retryTime)); + }, 500); + return; + } + + reconnectionTimerRef.current && clearInterval(reconnectionTimerRef.current); + reconnectionTimerRef.current = undefined; + }, [retryTime, status]); + + useEffect( + () => (): void => { + reconnectionTimerRef.current && clearInterval(reconnectionTimerRef.current); + }, + [], + ); + + return reconnectCountdown; +}; diff --git a/apps/meteor/client/components/message/IgnoredContent.tsx b/apps/meteor/client/components/message/IgnoredContent.tsx index 31b62639b8498255c6a5e6555fb223211d6e36bb..b1973b13178a7d2756f3f8a97eaf1f6e2d031292 100644 --- a/apps/meteor/client/components/message/IgnoredContent.tsx +++ b/apps/meteor/client/components/message/IgnoredContent.tsx @@ -17,7 +17,7 @@ const IgnoredContent = ({ onShowMessageIgnored }: IgnoredContentProps): ReactEle }; return ( - +

{t('Message_Ignored')} diff --git a/apps/meteor/client/components/message/MessageContentBody.tsx b/apps/meteor/client/components/message/MessageContentBody.tsx index 5552e6da0745298fc2a72fca01455f0c66c7ff06..b3c612fbe7251185d9c68c42bac5874873378ba6 100644 --- a/apps/meteor/client/components/message/MessageContentBody.tsx +++ b/apps/meteor/client/components/message/MessageContentBody.tsx @@ -1,5 +1,4 @@ -import { css } from '@rocket.chat/css-in-js'; -import { MessageBody, Box, Palette, Skeleton } from '@rocket.chat/fuselage'; +import { MessageBody, Skeleton } from '@rocket.chat/fuselage'; import { Markup } from '@rocket.chat/gazzodown'; import React, { Suspense } from 'react'; @@ -10,59 +9,14 @@ type MessageContentBodyProps = Pick { - // TODO: this style should go to Fuselage repository - const messageBodyAdditionalStyles = css` - > blockquote { - padding-inline: 8px; - border: 1px solid ${Palette.stroke['stroke-extra-light']}; - border-radius: 2px; - background-color: ${Palette.surface['surface-tint']}; - border-inline-start-color: ${Palette.stroke['stroke-medium']}; - - &:hover, - &:focus { - background-color: ${Palette.surface['surface-hover']}; - border-color: ${Palette.stroke['stroke-light']}; - border-inline-start-color: ${Palette.stroke['stroke-medium']}; - } - } - > ul.task-list { - > li::before { - display: none; - } - - > li > .rcx-check-box > .rcx-check-box__input:focus + .rcx-check-box__fake { - z-index: 1; - } - - list-style: none; - margin-inline-start: 0; - padding-inline-start: 0; - } - a { - color: ${Palette.text['font-info']}; - &:hover { - text-decoration: underline; - } - &:focus { - box-shadow: 0 0 0 2px ${Palette.stroke['stroke-extra-light-highlight']}; - border-radius: 2px; - } - } - `; - - return ( - - - }> - - - - - - - ); -}; +const MessageContentBody = ({ mentions, channels, md, searchText }: MessageContentBodyProps) => ( + + }> + + + + + +); export default MessageContentBody; diff --git a/apps/meteor/client/components/message/MessageHeader.tsx b/apps/meteor/client/components/message/MessageHeader.tsx index 3189aa2972bca0c6a7f2f65818051782e8b3e5d7..d708d933ac6d2b2f669697b81883ce934e71796a 100644 --- a/apps/meteor/client/components/message/MessageHeader.tsx +++ b/apps/meteor/client/components/message/MessageHeader.tsx @@ -8,7 +8,7 @@ import { MessageNameContainer, } from '@rocket.chat/fuselage'; import { useTranslation } from '@rocket.chat/ui-contexts'; -import type { ReactElement } from 'react'; +import type { KeyboardEvent, ReactElement } from 'react'; import React, { memo } from 'react'; import { getUserDisplayName } from '../../../lib/getUserDisplayName'; @@ -45,37 +45,35 @@ const MessageHeader = ({ message }: MessageHeaderProps): ReactElement => { return ( - + chat?.userCard.openUserCard(e, message.u.username), + onKeyDown: (e: KeyboardEvent) => { + (e.code === 'Enter' || e.code === 'Space') && chat?.userCard.openUserCard(e, message.u.username); + }, + style: { cursor: 'pointer' }, + })} + > {message.alias || getUserDisplayName(user.name, user.username, showRealName)} {showUsername && ( <> {' '} - + @{user.username} )} - {shouldShowRolesList && } {formatTime(message.ts)} {message.private && {t('Only_you_can_see_this_message')}} diff --git a/apps/meteor/client/components/message/MessageToolboxHolder.tsx b/apps/meteor/client/components/message/MessageToolbarHolder.tsx similarity index 50% rename from apps/meteor/client/components/message/MessageToolboxHolder.tsx rename to apps/meteor/client/components/message/MessageToolbarHolder.tsx index 06a9fbf42b77b4e1fce858a12dea03c90a88b9f7..cd0b8d6574bb0d9e8d0b405512742a2a6b882d24 100644 --- a/apps/meteor/client/components/message/MessageToolboxHolder.tsx +++ b/apps/meteor/client/components/message/MessageToolbarHolder.tsx @@ -1,29 +1,22 @@ import type { IMessage } from '@rocket.chat/core-typings'; -import { MessageToolboxWrapper } from '@rocket.chat/fuselage'; +import { MessageToolbarWrapper } from '@rocket.chat/fuselage'; import { useQuery } from '@tanstack/react-query'; import type { ReactElement } from 'react'; -import React, { Suspense, lazy, memo, useRef, useState } from 'react'; +import React, { Suspense, lazy, memo, useState } from 'react'; import type { MessageActionContext } from '../../../app/ui-utils/client/lib/MessageAction'; import { useChat } from '../../views/room/contexts/ChatContext'; -import { useIsVisible } from '../../views/room/hooks/useIsVisible'; -type MessageToolboxHolderProps = { +type MessageToolbarHolderProps = { message: IMessage; context?: MessageActionContext; }; -const MessageToolbox = lazy(() => import('./toolbox/MessageToolbox')); - -const MessageToolboxHolder = ({ message, context }: MessageToolboxHolderProps): ReactElement => { - const ref = useRef(null); - - const [isVisible] = useIsVisible(ref); - const [kebabOpen, setKebabOpen] = useState(false); - - const showToolbox = isVisible || kebabOpen; +const MessageToolbar = lazy(() => import('./toolbar/MessageToolbar')); +const MessageToolbarHolder = ({ message, context }: MessageToolbarHolderProps): ReactElement => { const chat = useChat(); + const [showToolbar, setShowToolbar] = useState(false); const depsQueryResult = useQuery(['toolbox', message._id, context], async () => { const room = await chat?.data.findRoom(); @@ -35,20 +28,20 @@ const MessageToolboxHolder = ({ message, context }: MessageToolboxHolderProps): }); return ( - - {showToolbox && depsQueryResult.isSuccess && depsQueryResult.data.room && ( + + {depsQueryResult.isSuccess && depsQueryResult.data.room && ( - )} - + ); }; -export default memo(MessageToolboxHolder); +export default memo(MessageToolbarHolder); diff --git a/apps/meteor/client/components/message/StatusIndicators.tsx b/apps/meteor/client/components/message/StatusIndicators.tsx index 48ec47e5e28084cc732ef40a2ea0d4e2f9a6e7e3..f47d25e7c7b2d3a19e7fd33cd64c71621fc432b9 100644 --- a/apps/meteor/client/components/message/StatusIndicators.tsx +++ b/apps/meteor/client/components/message/StatusIndicators.tsx @@ -1,5 +1,5 @@ import type { IMessage, ITranslatedMessage } from '@rocket.chat/core-typings'; -import { isEditedMessage, isE2EEMessage, isOTRMessage } from '@rocket.chat/core-typings'; +import { isEditedMessage, isE2EEMessage, isOTRMessage, isOTRAckMessage } from '@rocket.chat/core-typings'; import { MessageStatusIndicator, MessageStatusIndicatorItem } from '@rocket.chat/fuselage'; import { useUserId, useTranslation } from '@rocket.chat/ui-contexts'; import type { ReactElement } from 'react'; @@ -18,7 +18,7 @@ const StatusIndicators = ({ message }: StatusIndicatorsProps): ReactElement => { const following = useShowFollowing({ message }); const isEncryptedMessage = isE2EEMessage(message); - const isOtrMessage = isOTRMessage(message); + const isOtrMessage = isOTRMessage(message) || isOTRAckMessage(message); const uid = useUserId(); diff --git a/apps/meteor/client/components/message/content/Attachments.tsx b/apps/meteor/client/components/message/content/Attachments.tsx index 2c1b6675cb7bd5490da1d2b2391c4877e0e0c99d..ea9c03b9e7d33e6a4d513048acf75db3b5c90f57 100644 --- a/apps/meteor/client/components/message/content/Attachments.tsx +++ b/apps/meteor/client/components/message/content/Attachments.tsx @@ -6,15 +6,14 @@ import AttachmentsItem from './attachments/AttachmentsItem'; type AttachmentsProps = { attachments: MessageAttachmentBase[]; - collapsed?: boolean; id?: string | undefined; }; -const Attachments = ({ attachments, collapsed, id }: AttachmentsProps): ReactElement => { +const Attachments = ({ attachments, id }: AttachmentsProps): ReactElement => { return ( <> {attachments?.map((attachment, index) => ( - + ))} ); diff --git a/apps/meteor/client/components/message/content/DiscussionMetrics.tsx b/apps/meteor/client/components/message/content/DiscussionMetrics.tsx index 5589d127890565ac824949d4b53739330e872d63..dc84828e607a942120fa66ad53b4b3bdbd102784 100644 --- a/apps/meteor/client/components/message/content/DiscussionMetrics.tsx +++ b/apps/meteor/client/components/message/content/DiscussionMetrics.tsx @@ -23,7 +23,7 @@ const DiscussionMetrics = ({ lm, count, rid, drid }: DiscussionMetricsProps): Re goToRoom(drid)}> - {count ? t('message_counter', { counter: count, count }) : t('Reply')} + {count ? t('message_counter', { count }) : t('Reply')} diff --git a/apps/meteor/client/components/message/content/Reactions.tsx b/apps/meteor/client/components/message/content/Reactions.tsx index 712504ec7a2c027934c7575de002df3e934ce498..41daf046c5448b318dfa0c40d4d46648b2fd3d8a 100644 --- a/apps/meteor/client/components/message/content/Reactions.tsx +++ b/apps/meteor/client/components/message/content/Reactions.tsx @@ -1,9 +1,9 @@ import type { IMessage } from '@rocket.chat/core-typings'; import { MessageReactions, MessageReactionAction } from '@rocket.chat/fuselage'; import type { ReactElement } from 'react'; -import React from 'react'; +import React, { useContext } from 'react'; -import { useOpenEmojiPicker, useReactionsFilter, useUserHasReacted } from '../list/MessageListContext'; +import { MessageListContext, useOpenEmojiPicker, useUserHasReacted } from '../list/MessageListContext'; import Reaction from './reactions/Reaction'; import { useToggleReactionMutation } from './reactions/useToggleReactionMutation'; @@ -13,9 +13,8 @@ type ReactionsProps = { const Reactions = ({ message }: ReactionsProps): ReactElement => { const hasReacted = useUserHasReacted(message); - const filterReactions = useReactionsFilter(message); const openEmojiPicker = useOpenEmojiPicker(message); - + const { username } = useContext(MessageListContext); const toggleReactionMutation = useToggleReactionMutation(); return ( @@ -27,7 +26,8 @@ const Reactions = ({ message }: ReactionsProps): ReactElement => { counter={reactions.usernames.length} hasReacted={hasReacted} name={name} - names={filterReactions(name)} + names={reactions.usernames.filter((user) => user !== username).map((username) => `@${username}`)} + messageId={message._id} onClick={() => toggleReactionMutation.mutate({ mid: message._id, reaction: name })} /> ))} diff --git a/apps/meteor/client/components/message/content/attachments/AttachmentsItem.tsx b/apps/meteor/client/components/message/content/attachments/AttachmentsItem.tsx index 589549d4bcc1ed9bae1fca79bfda90c74449f385..dd4f9bac9d2829c8dcd15e1f26677f025dab6ecc 100644 --- a/apps/meteor/client/components/message/content/attachments/AttachmentsItem.tsx +++ b/apps/meteor/client/components/message/content/attachments/AttachmentsItem.tsx @@ -4,7 +4,7 @@ import type { ReactElement } from 'react'; import React, { memo } from 'react'; import DefaultAttachment from './DefaultAttachment'; -import { FileAttachment } from './FileAttachment'; +import FileAttachment from './FileAttachment'; import { QuoteAttachment } from './QuoteAttachment'; type AttachmentsItemProps = { diff --git a/apps/meteor/client/components/message/content/attachments/FileAttachment.tsx b/apps/meteor/client/components/message/content/attachments/FileAttachment.tsx index 942ace9055d3896c44198c48db303f2d746751af..f80fa62890ec4fcfc064e984be1b0693523f146a 100644 --- a/apps/meteor/client/components/message/content/attachments/FileAttachment.tsx +++ b/apps/meteor/client/components/message/content/attachments/FileAttachment.tsx @@ -1,23 +1,25 @@ -import type { FileAttachmentProps } from '@rocket.chat/core-typings'; -import { isFileAudioAttachment, isFileImageAttachment, isFileVideoAttachment } from '@rocket.chat/core-typings'; -import type { FC } from 'react'; +import { type FileAttachmentProps, isFileAudioAttachment, isFileImageAttachment, isFileVideoAttachment } from '@rocket.chat/core-typings'; import React from 'react'; -import { AudioAttachment } from './file/AudioAttachment'; -import { GenericFileAttachment } from './file/GenericFileAttachment'; -import { ImageAttachment } from './file/ImageAttachment'; -import { VideoAttachment } from './file/VideoAttachment'; +import AudioAttachment from './file/AudioAttachment'; +import GenericFileAttachment from './file/GenericFileAttachment'; +import ImageAttachment from './file/ImageAttachment'; +import VideoAttachment from './file/VideoAttachment'; -export const FileAttachment: FC = (attachment) => { +const FileAttachment = (attachment: FileAttachmentProps) => { if (isFileImageAttachment(attachment)) { return ; } + if (isFileAudioAttachment(attachment)) { return ; } + if (isFileVideoAttachment(attachment)) { return ; } - return ; // TODO: fix this + return ; }; + +export default FileAttachment; diff --git a/apps/meteor/client/components/message/content/attachments/QuoteAttachment.tsx b/apps/meteor/client/components/message/content/attachments/QuoteAttachment.tsx index 67232cbd441f803394b9738b0062f37a8cdb4ebd..7c2c2011cac9222d4453487f393b7d31baf0293b 100644 --- a/apps/meteor/client/components/message/content/attachments/QuoteAttachment.tsx +++ b/apps/meteor/client/components/message/content/attachments/QuoteAttachment.tsx @@ -1,6 +1,7 @@ import type { MessageQuoteAttachment } from '@rocket.chat/core-typings'; import { css } from '@rocket.chat/css-in-js'; import { Box, Palette } from '@rocket.chat/fuselage'; +import { useUserPreference } from '@rocket.chat/ui-contexts'; import type { ReactElement } from 'react'; import React from 'react'; @@ -37,6 +38,7 @@ type QuoteAttachmentProps = { export const QuoteAttachment = ({ attachment }: QuoteAttachmentProps): ReactElement => { const formatTime = useTimeAgo(); + const displayAvatarPreference = useUserPreference('displayAvatars'); return ( <> @@ -50,7 +52,7 @@ export const QuoteAttachment = ({ attachment }: QuoteAttachmentProps): ReactElem borderInlineStartColor='light' > - + {displayAvatarPreference && } @@ -68,7 +70,7 @@ export const QuoteAttachment = ({ attachment }: QuoteAttachmentProps): ReactElem {attachment.md ? : attachment.text.substring(attachment.text.indexOf('\n') + 1)} {attachment.attachments && ( - + )} 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 df3b2674b897d8411aa08d4e69a50f005e80ea92..99b1f3de729005e83fc79bbed81675e08f4fa3e7 100644 --- a/apps/meteor/client/components/message/content/attachments/default/ActionAttachtment.tsx +++ b/apps/meteor/client/components/message/content/attachments/default/ActionAttachtment.tsx @@ -10,27 +10,29 @@ export const ActionAttachment: FC = ({ actions }) => { const handleLinkClick = useExternalLink(); return ( - - {actions - .filter( - ({ type, msg_in_chat_window: msgInChatWindow, url, image_url: image, text }) => - type === 'button' && (image || text) && (url || msgInChatWindow), - ) - .map(({ text, url, msgId, msg, msg_processing_type: processingType = 'sendMessage', image_url: image }, index) => { - const content = image ? : text; - if (url) { + + + {actions + .filter( + ({ type, msg_in_chat_window: msgInChatWindow, url, image_url: image, text }) => + type === 'button' && (image || text) && (url || msgInChatWindow), + ) + .map(({ text, url, msgId, msg, msg_processing_type: processingType = 'sendMessage', image_url: image }, index) => { + const content = image ? : text; + if (url) { + return ( + + ); + } return ( - + ); - } - return ( - - {content} - - ); - })} - + })} + + ); }; diff --git a/apps/meteor/client/components/message/content/attachments/file/AudioAttachment.tsx b/apps/meteor/client/components/message/content/attachments/file/AudioAttachment.tsx index 4dd06de466553580d3a682dc064e59f21f141c75..0f2082f96e3195ad2aa263b0b0e3d85d26ddb330 100644 --- a/apps/meteor/client/components/message/content/attachments/file/AudioAttachment.tsx +++ b/apps/meteor/client/components/message/content/attachments/file/AudioAttachment.tsx @@ -1,14 +1,13 @@ import type { AudioAttachmentProps } from '@rocket.chat/core-typings'; import { AudioPlayer } from '@rocket.chat/fuselage'; import { useMediaUrl } from '@rocket.chat/ui-contexts'; -import type { FC } from 'react'; import React from 'react'; import MarkdownText from '../../../../MarkdownText'; import MessageCollapsible from '../../../MessageCollapsible'; import MessageContentBody from '../../../MessageContentBody'; -export const AudioAttachment: FC = ({ +const AudioAttachment = ({ title, audio_url: url, audio_type: type, @@ -18,7 +17,7 @@ export const AudioAttachment: FC = ({ title_link: link, title_link_download: hasDownload, collapsed, -}) => { +}: AudioAttachmentProps) => { const getURL = useMediaUrl(); return ( <> @@ -29,3 +28,5 @@ export const AudioAttachment: FC = ({ ); }; + +export default AudioAttachment; 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 cbf1749b9dcd903d933cd0f030d2723f6a81cfe6..4301520c61738ee2c987ac4b98e40e0b244c3613 100644 --- a/apps/meteor/client/components/message/content/attachments/file/GenericFileAttachment.tsx +++ b/apps/meteor/client/components/message/content/attachments/file/GenericFileAttachment.tsx @@ -7,7 +7,7 @@ import { MessageGenericPreviewDescription, } from '@rocket.chat/fuselage'; import { useMediaUrl } from '@rocket.chat/ui-contexts'; -import type { FC } from 'react'; +import type { UIEvent } from 'react'; import React from 'react'; import { getFileExtension } from '../../../../../../lib/utils/getFileExtension'; @@ -16,7 +16,11 @@ import MessageCollapsible from '../../../MessageCollapsible'; import MessageContentBody from '../../../MessageContentBody'; import AttachmentSize from '../structure/AttachmentSize'; -export const GenericFileAttachment: FC = ({ +const openDocumentViewer = window.RocketChatDesktop?.openDocumentViewer; + +type GenericFileAttachmentProps = MessageAttachmentBase; + +const GenericFileAttachment = ({ title, description, descriptionMd, @@ -25,9 +29,24 @@ export const GenericFileAttachment: FC = ({ size, format, collapsed, -}) => { +}: GenericFileAttachmentProps) => { const getURL = useMediaUrl(); + const handleTitleClick = (event: UIEvent): void => { + if (openDocumentViewer && link && format === 'PDF') { + event.preventDefault(); + openDocumentViewer(getURL(link), format, ''); + } + }; + + const getExternalUrl = () => { + if (!hasDownload || !link) return undefined; + + if (openDocumentViewer) return `${getURL(link)}?download`; + + return getURL(link); + }; + return ( <> {descriptionMd ? : } @@ -36,7 +55,7 @@ export const GenericFileAttachment: FC = ({ } > - + {title} {size && ( @@ -50,3 +69,5 @@ export const GenericFileAttachment: FC = ({ ); }; + +export default GenericFileAttachment; diff --git a/apps/meteor/client/components/message/content/attachments/file/ImageAttachment.tsx b/apps/meteor/client/components/message/content/attachments/file/ImageAttachment.tsx index 13cd375c7eebb899762cd98ae8dc43218841d593..a7e4036e12887cb76a2177f00ece3802dd6708a4 100644 --- a/apps/meteor/client/components/message/content/attachments/file/ImageAttachment.tsx +++ b/apps/meteor/client/components/message/content/attachments/file/ImageAttachment.tsx @@ -1,6 +1,5 @@ import type { ImageAttachmentProps } from '@rocket.chat/core-typings'; import { useMediaUrl } from '@rocket.chat/ui-contexts'; -import type { FC } from 'react'; import React from 'react'; import MarkdownText from '../../../../MarkdownText'; @@ -9,7 +8,7 @@ import MessageContentBody from '../../../MessageContentBody'; import AttachmentImage from '../structure/AttachmentImage'; import { useLoadImage } from './hooks/useLoadImage'; -export const ImageAttachment: FC = ({ +const ImageAttachment = ({ id, title, image_url: url, @@ -24,7 +23,7 @@ export const ImageAttachment: FC { +}: ImageAttachmentProps) => { const [loadImage, setLoadImage] = useLoadImage(); const getURL = useMediaUrl(); @@ -45,3 +44,5 @@ export const ImageAttachment: FC ); }; + +export default ImageAttachment; diff --git a/apps/meteor/client/components/message/content/attachments/file/VideoAttachment.tsx b/apps/meteor/client/components/message/content/attachments/file/VideoAttachment.tsx index dd32db8d8523dfdbfb7d3aa780fd1fa74b1b5a81..5954baf092d05faab707307499629522f9aa6db2 100644 --- a/apps/meteor/client/components/message/content/attachments/file/VideoAttachment.tsx +++ b/apps/meteor/client/components/message/content/attachments/file/VideoAttachment.tsx @@ -1,7 +1,6 @@ import type { VideoAttachmentProps } from '@rocket.chat/core-typings'; import { Box, MessageGenericPreview } from '@rocket.chat/fuselage'; import { useMediaUrl } from '@rocket.chat/ui-contexts'; -import type { FC } from 'react'; import React from 'react'; import { userAgentMIMETypeFallback } from '../../../../../lib/utils/userAgentMIMETypeFallback'; @@ -9,7 +8,7 @@ import MarkdownText from '../../../../MarkdownText'; import MessageCollapsible from '../../../MessageCollapsible'; import MessageContentBody from '../../../MessageContentBody'; -export const VideoAttachment: FC = ({ +const VideoAttachment = ({ title, video_url: url, video_type: type, @@ -19,7 +18,7 @@ export const VideoAttachment: FC = ({ title_link: link, title_link_download: hasDownload, collapsed, -}) => { +}: VideoAttachmentProps) => { const getURL = useMediaUrl(); return ( @@ -35,3 +34,5 @@ export const VideoAttachment: FC = ({ ); }; + +export default VideoAttachment; diff --git a/apps/meteor/client/components/message/content/attachments/structure/AttachmentAuthorAvatar.tsx b/apps/meteor/client/components/message/content/attachments/structure/AttachmentAuthorAvatar.tsx index 22e1f107b027240e8a1477360a4c9bf112b806f3..fb0b3e448f12465af163e9057ef2e46a9a0ac138 100644 --- a/apps/meteor/client/components/message/content/attachments/structure/AttachmentAuthorAvatar.tsx +++ b/apps/meteor/client/components/message/content/attachments/structure/AttachmentAuthorAvatar.tsx @@ -1,8 +1,7 @@ +import { BaseAvatar } from '@rocket.chat/ui-avatar'; import type { ReactElement } from 'react'; import React from 'react'; -import BaseAvatar from '../../../../avatar/BaseAvatar'; - const AttachmentAuthorAvatar = ({ url }: { url: string }): ReactElement => ; export default AttachmentAuthorAvatar; diff --git a/apps/meteor/client/components/message/content/reactions/Reaction.tsx b/apps/meteor/client/components/message/content/reactions/Reaction.tsx index cf68e65b88298cef33e0ceb0264e8fd6b489f97f..893f6456c8c604f1555eab2607101fd6cca2a63d 100644 --- a/apps/meteor/client/components/message/content/reactions/Reaction.tsx +++ b/apps/meteor/client/components/message/content/reactions/Reaction.tsx @@ -1,51 +1,30 @@ import { MessageReaction as MessageReactionTemplate, MessageReactionEmoji, MessageReactionCounter } from '@rocket.chat/fuselage'; -import type { TranslationKey } from '@rocket.chat/ui-contexts'; -import { useTooltipClose, useTooltipOpen, useTranslation } from '@rocket.chat/ui-contexts'; +import { useTooltipClose, useTooltipOpen } from '@rocket.chat/ui-contexts'; import type { ReactElement } from 'react'; -import React, { useRef } from 'react'; +import React, { useRef, useContext } from 'react'; import { getEmojiClassNameAndDataTitle } from '../../../../lib/utils/renderEmoji'; -import MarkdownText from '../../../MarkdownText'; +import { MessageListContext } from '../../list/MessageListContext'; +import ReactionTooltip from './ReactionTooltip'; // TODO: replace it with proper usage of i18next plurals -const getTranslationKey = (users: string[], mine: boolean): TranslationKey => { - if (users.length === 0) { - if (mine) { - return 'You_reacted_with'; - } - } - - if (users.length > 10) { - if (mine) { - return 'You_users_and_more_Reacted_with'; - } - return 'Users_and_more_reacted_with'; - } - - if (mine) { - return 'You_and_users_Reacted_with'; - } - return 'Users_reacted_with'; -}; - type ReactionProps = { hasReacted: (name: string) => boolean; counter: number; name: string; names: string[]; + messageId: string; onClick: () => void; }; -const Reaction = ({ hasReacted, counter, name, names, ...props }: ReactionProps): ReactElement => { - const t = useTranslation(); +const Reaction = ({ hasReacted, counter, name, names, messageId, ...props }: ReactionProps): ReactElement => { const ref = useRef(null); const openTooltip = useTooltipOpen(); const closeTooltip = useTooltipClose(); + const { showRealName, username } = useContext(MessageListContext); const mine = hasReacted(name); - const key = getTranslationKey(names, mine); - const emojiProps = getEmojiClassNameAndDataTitle(name); return ( @@ -55,18 +34,21 @@ const Reaction = ({ hasReacted, counter, name, names, ...props }: ReactionProps) mine={mine} tabIndex={0} role='button' - onMouseOver={(e): void => { + // if data-tooltip is not set, the tooltip will close on first mouse enter + data-tooltip='' + onMouseEnter={async (e) => { e.stopPropagation(); e.preventDefault(); + ref.current && openTooltip( - 10 ? names.length - 10 : names.length, - users: names.slice(0, 10).join(', '), - emoji: name, - })} - variant='inline' + , ref.current, ); diff --git a/apps/meteor/client/components/message/content/reactions/ReactionTooltip.tsx b/apps/meteor/client/components/message/content/reactions/ReactionTooltip.tsx new file mode 100644 index 0000000000000000000000000000000000000000..a71f50a953b61615a57e5a8b58cc3151a717ac6f --- /dev/null +++ b/apps/meteor/client/components/message/content/reactions/ReactionTooltip.tsx @@ -0,0 +1,100 @@ +import { Skeleton } from '@rocket.chat/fuselage'; +import { useTranslation } from '@rocket.chat/ui-contexts'; +import type { TranslationKey } from '@rocket.chat/ui-contexts'; +import { useQuery } from '@tanstack/react-query'; +import React from 'react'; + +import { useGetMessageByID } from '../../../../views/room/contextualBar/Threads/hooks/useGetMessageByID'; +import MarkdownText from '../../../MarkdownText'; + +type ReactionTooltipProps = { + emojiName: string; + usernames: string[]; + username: string | undefined; + mine: boolean; + showRealName: boolean; + messageId: string; +}; + +const getTranslationKey = (users: string[], mine: boolean): TranslationKey => { + if (users.length === 0) { + if (mine) { + return 'You_reacted_with'; + } + } + + if (users.length > 10) { + if (mine) { + return 'You_users_and_more_Reacted_with'; + } + return 'Users_and_more_reacted_with'; + } + + if (mine) { + return 'You_and_users_Reacted_with'; + } + return 'Users_reacted_with'; +}; + +const ReactionTooltip = ({ emojiName, usernames, mine, messageId, showRealName, username }: ReactionTooltipProps) => { + const t = useTranslation(); + + const key = getTranslationKey(usernames, mine); + + const getMessage = useGetMessageByID(); + + const { data: users, isLoading } = useQuery( + ['chat.getMessage', 'reactions', messageId, usernames], + async () => { + // This happens if the only reaction is from the current user + if (!usernames.length) { + return []; + } + + if (!showRealName) { + return usernames; + } + + const data = await getMessage(messageId); + + const { reactions } = data; + + if (!reactions) { + return []; + } + + if (username) { + const index = reactions[emojiName].usernames.indexOf(username); + index >= 0 && reactions[emojiName].names?.splice(index, 1); + return (reactions[emojiName].names || usernames).filter(Boolean); + } + + return reactions[emojiName].names || usernames; + }, + { staleTime: 1000 * 60 * 5 }, + ); + + if (isLoading) { + return ( + <> + + + {usernames.length > 5 && } + {usernames.length > 8 && } + + ); + } + + return ( + 10 ? usernames.length - 10 : usernames.length, + users: users?.slice(0, 10).join(', ') || '', + emoji: emojiName, + })} + variant='inline' + /> + ); +}; + +export default ReactionTooltip; diff --git a/apps/meteor/client/components/message/list/MessageListContext.tsx b/apps/meteor/client/components/message/list/MessageListContext.tsx index 156679fe54632deb944965bdf5bcca99dd99ce01..82cca7377bb68439d8d3fd7c4b4e63971d25120c 100644 --- a/apps/meteor/client/components/message/list/MessageListContext.tsx +++ b/apps/meteor/client/components/message/list/MessageListContext.tsx @@ -7,7 +7,6 @@ export type MessageListContextValue = { useShowFollowing: ({ message }: { message: IMessage }) => boolean; useMessageDateFormatter: () => (date: Date) => string; useUserHasReacted: (message: IMessage) => (reaction: string) => boolean; - useReactionsFilter: (message: IMessage) => (reaction: string) => string[]; useOpenEmojiPicker: (message: IMessage) => (event: React.MouseEvent) => void; showRoles: boolean; showRealName: boolean; @@ -25,6 +24,7 @@ export type MessageListContextValue = { autoTranslateLanguage?: string; showColors: boolean; jumpToMessageParam?: string; + username: string | undefined; scrollMessageList?: (callback: (wrapper: HTMLDivElement | null) => ScrollToOptions | void) => void; }; @@ -38,15 +38,12 @@ export const MessageListContext = createContext({ (date: Date): string => date.toString(), useOpenEmojiPicker: () => (): void => undefined, - useReactionsFilter: - (message) => - (reaction: string): string[] => - message.reactions ? message.reactions[reaction]?.usernames || [] : [], showRoles: false, showRealName: false, showUsername: false, showColors: false, scrollMessageList: () => undefined, + username: undefined, }); export const useShowTranslated: MessageListContextValue['useShowTranslated'] = (...args) => @@ -69,5 +66,3 @@ export const useUserHasReacted: MessageListContextValue['useUserHasReacted'] = ( useContext(MessageListContext).useUserHasReacted(message); export const useOpenEmojiPicker: MessageListContextValue['useOpenEmojiPicker'] = (...args) => useContext(MessageListContext).useOpenEmojiPicker(...args); -export const useReactionsFilter: MessageListContextValue['useReactionsFilter'] = (message: IMessage) => - useContext(MessageListContext).useReactionsFilter(message); diff --git a/apps/meteor/client/components/message/toolbar/MessageActionMenu.tsx b/apps/meteor/client/components/message/toolbar/MessageActionMenu.tsx new file mode 100644 index 0000000000000000000000000000000000000000..8d266fe35d0eb741de9fa70870d40d72f59a3809 --- /dev/null +++ b/apps/meteor/client/components/message/toolbar/MessageActionMenu.tsx @@ -0,0 +1,62 @@ +import { useTranslation } from '@rocket.chat/ui-contexts'; +import type { MouseEvent, ReactElement } from 'react'; +import React from 'react'; + +import type { MessageActionConfig } from '../../../../app/ui-utils/client/lib/MessageAction'; +import GenericMenu from '../../GenericMenu/GenericMenu'; +import type { GenericMenuItemProps } from '../../GenericMenu/GenericMenuItem'; + +type MessageActionConfigOption = Omit & { + action: (e?: MouseEvent) => void; +}; + +type MessageActionSection = { + id: string; + title: string; + items: GenericMenuItemProps[]; +}; + +type MessageActionMenuProps = { + onChangeMenuVisibility: (visible: boolean) => void; + options: MessageActionConfigOption[]; +}; + +const MessageActionMenu = ({ options, onChangeMenuVisibility }: MessageActionMenuProps): ReactElement => { + const t = useTranslation(); + + const groupOptions = options + .map((option) => ({ + variant: option.color === 'alert' ? 'danger' : '', + id: option.id, + icon: option.icon, + content: t(option.label), + onClick: option.action, + type: option.type, + })) + .reduce((acc, option) => { + const group = option.type ? option.type : ''; + const section = acc.find((section: { id: string }) => section.id === group); + if (section) { + section.items.push(option); + return acc; + } + const newSection = { id: group, title: group === 'apps' ? t('Apps') : '', items: [option] }; + acc.push(newSection); + + return acc; + }, [] as unknown as MessageActionSection[]); + + return ( + + ); +}; + +export default MessageActionMenu; diff --git a/apps/meteor/client/components/message/toolbox/MessageToolbox.tsx b/apps/meteor/client/components/message/toolbar/MessageToolbar.tsx similarity index 71% rename from apps/meteor/client/components/message/toolbox/MessageToolbox.tsx rename to apps/meteor/client/components/message/toolbar/MessageToolbar.tsx index 3b9cdd84c25de467924bcf3b49f86e1f2462a2fb..a51ca4f3b3d4b94ebf007385d8c0de26eb3ebba5 100644 --- a/apps/meteor/client/components/message/toolbox/MessageToolbox.tsx +++ b/apps/meteor/client/components/message/toolbar/MessageToolbar.tsx @@ -1,22 +1,25 @@ +import { useToolbar } from '@react-aria/toolbar'; import type { IMessage, IRoom, ISubscription, ITranslatedMessage } from '@rocket.chat/core-typings'; import { isThreadMessage, isRoomFederated, isVideoConfMessage } from '@rocket.chat/core-typings'; -import { MessageToolbox as FuselageMessageToolbox, MessageToolboxItem } from '@rocket.chat/fuselage'; +import { MessageToolbar as FuselageMessageToolbar, MessageToolbarItem } from '@rocket.chat/fuselage'; import { useFeaturePreview } from '@rocket.chat/ui-client'; -import { useUser, useSettings, useTranslation, useMethod } from '@rocket.chat/ui-contexts'; +import { useUser, useSettings, useTranslation, useMethod, useLayoutHiddenActions } from '@rocket.chat/ui-contexts'; import { useQuery } from '@tanstack/react-query'; -import type { ReactElement } from 'react'; -import React, { memo, useMemo } from 'react'; +import type { ComponentProps, ReactElement } from 'react'; +import React, { memo, useMemo, useRef } from 'react'; import type { MessageActionContext } from '../../../../app/ui-utils/client/lib/MessageAction'; import { MessageAction } from '../../../../app/ui-utils/client/lib/MessageAction'; import { useEmojiPickerData } from '../../../contexts/EmojiPickerContext'; import { useMessageActionAppsActionButtons } from '../../../hooks/useAppActionButtons'; +import { useEmbeddedLayout } from '../../../hooks/useEmbeddedLayout'; import EmojiElement from '../../../views/composer/EmojiPicker/EmojiElement'; import { useIsSelecting } from '../../../views/room/MessageList/contexts/SelectedMessagesContext'; import { useAutoTranslate } from '../../../views/room/MessageList/hooks/useAutoTranslate'; import { useChat } from '../../../views/room/contexts/ChatContext'; import { useRoomToolbox } from '../../../views/room/contexts/RoomToolboxContext'; import MessageActionMenu from './MessageActionMenu'; +import { useWebDAVMessageAction } from './useWebDAVMessageAction'; const getMessageContext = (message: IMessage, room: IRoom, context?: MessageActionContext): MessageActionContext => { if (context) { @@ -38,24 +41,29 @@ const getMessageContext = (message: IMessage, room: IRoom, context?: MessageActi return 'message'; }; -type MessageToolboxProps = { +type MessageToolbarProps = { message: IMessage & Partial; messageContext?: MessageActionContext; room: IRoom; subscription?: ISubscription; onChangeMenuVisibility: (visible: boolean) => void; -}; +} & ComponentProps; -const MessageToolbox = ({ +const MessageToolbar = ({ message, messageContext, room, subscription, onChangeMenuVisibility, -}: MessageToolboxProps): ReactElement | null => { + ...props +}: MessageToolbarProps): ReactElement | null => { const t = useTranslation(); const user = useUser() ?? undefined; const settings = useSettings(); + const isLayoutEmbedded = useEmbeddedLayout(); + + const toolbarRef = useRef(null); + const { toolbarProps } = useToolbar(props, toolbarRef); const quickReactionsEnabled = useFeaturePreview('quickReactions'); @@ -70,13 +78,21 @@ const MessageToolbox = ({ const actionButtonApps = useMessageActionAppsActionButtons(context); + const { messageToolbox: hiddenActions } = useLayoutHiddenActions(); + + // TODO: move this to another place + useWebDAVMessageAction(); + const actionsQueryResult = useQuery(['rooms', room._id, 'messages', message._id, 'actions'] as const, async () => { const props = { message, room, user, subscription, settings: mapSettings, chat }; const toolboxItems = await MessageAction.getAll(props, context, 'message'); const menuItems = await MessageAction.getAll(props, context, 'menu'); - return { message: toolboxItems, menu: menuItems }; + return { + message: toolboxItems.filter((action) => !hiddenActions.includes(action.id)), + menu: menuItems.filter((action) => !(isLayoutEmbedded && action.id === 'reply-directly') && !hiddenActions.includes(action.id)), + }; }); const toolbox = useRoomToolbox(); @@ -85,7 +101,7 @@ const MessageToolbox = ({ const autoTranslateOptions = useAutoTranslate(subscription); - if (selecting) { + if (selecting || (!actionsQueryResult.data?.message.length && !actionsQueryResult.data?.menu.length)) { return null; } @@ -97,7 +113,7 @@ const MessageToolbox = ({ }; return ( - + {quickReactionsEnabled && isReactionAllowed && quickReactions.slice(0, 3).map(({ emoji, image }) => { @@ -105,7 +121,7 @@ const MessageToolbox = ({ })} {actionsQueryResult.isSuccess && actionsQueryResult.data.message.map((action) => ( - action.action(e, { message, tabbar: toolbox, room, chat, autoTranslateOptions })} key={action.id} icon={action.icon} @@ -116,16 +132,16 @@ const MessageToolbox = ({ ))} {actionsQueryResult.isSuccess && actionsQueryResult.data.menu.length > 0 && ( ({ ...action, - action: (e): void => action.action(e, { message, tabbar: toolbox, room, chat, autoTranslateOptions }), + action: (e) => action.action(e, { message, tabbar: toolbox, room, chat, autoTranslateOptions }), }))} + onChangeMenuVisibility={onChangeMenuVisibility} data-qa-type='message-action-menu-options' /> )} - + ); }; -export default memo(MessageToolbox); +export default memo(MessageToolbar); diff --git a/apps/meteor/client/components/message/toolbar/useWebDAVMessageAction.tsx b/apps/meteor/client/components/message/toolbar/useWebDAVMessageAction.tsx new file mode 100644 index 0000000000000000000000000000000000000000..a2be70077054626f72804cc2bd4f99ca641bf4e2 --- /dev/null +++ b/apps/meteor/client/components/message/toolbar/useWebDAVMessageAction.tsx @@ -0,0 +1,44 @@ +import { useSetModal, useSetting } from '@rocket.chat/ui-contexts'; +import React, { useEffect } from 'react'; + +import { MessageAction } from '../../../../app/ui-utils/client/lib/MessageAction'; +import { getURL } from '../../../../app/utils/client'; +import { useWebDAVAccountIntegrationsQuery } from '../../../hooks/webdav/useWebDAVAccountIntegrationsQuery'; +import { messageArgs } from '../../../lib/utils/messageArgs'; +import SaveToWebdavModal from '../../../views/room/webdav/SaveToWebdavModal'; + +export const useWebDAVMessageAction = () => { + const enabled = useSetting('Webdav_Integration_Enabled', false); + + const { data } = useWebDAVAccountIntegrationsQuery({ enabled }); + + const setModal = useSetModal(); + + useEffect(() => { + if (!enabled) { + return; + } + + MessageAction.addButton({ + id: 'webdav-upload', + icon: 'upload', + label: 'Save_To_Webdav', + condition: ({ message, subscription }) => { + return !!subscription && !!data?.length && !!message.file; + }, + action(_, props) { + const { message = messageArgs(this).msg } = props; + const [attachment] = message.attachments || []; + const url = getURL(attachment.title_link as string, { full: true }); + + setModal( setModal(undefined)} />); + }, + order: 100, + group: 'menu', + }); + + return () => { + MessageAction.removeButton('webdav-upload'); + }; + }, [data?.length, enabled, setModal]); +}; diff --git a/apps/meteor/client/components/message/toolbox/DesktopToolboxDropdown.tsx b/apps/meteor/client/components/message/toolbox/DesktopToolboxDropdown.tsx deleted file mode 100644 index 0310cf44c0139f719f5b08864ee41ab9aa15a5ad..0000000000000000000000000000000000000000 --- a/apps/meteor/client/components/message/toolbox/DesktopToolboxDropdown.tsx +++ /dev/null @@ -1,23 +0,0 @@ -import { Tile, PositionAnimated } from '@rocket.chat/fuselage'; -import type { ReactNode, Ref, RefObject } from 'react'; -import React, { forwardRef } from 'react'; - -type DesktopToolboxDropdownProps = { - children: ReactNode; - reference: RefObject; -}; - -const DesktopToolboxDropdown = forwardRef(function ToolboxDropdownDesktop( - { reference, children }: DesktopToolboxDropdownProps, - ref: Ref, -) { - return ( - - - {children} - - - ); -}); - -export default DesktopToolboxDropdown; diff --git a/apps/meteor/client/components/message/toolbox/MessageActionMenu.tsx b/apps/meteor/client/components/message/toolbox/MessageActionMenu.tsx deleted file mode 100644 index 937667b817c796fbd08d34bfbc94505ab2846ccb..0000000000000000000000000000000000000000 --- a/apps/meteor/client/components/message/toolbox/MessageActionMenu.tsx +++ /dev/null @@ -1,120 +0,0 @@ -import { MessageToolboxItem, Option, OptionDivider, OptionTitle } from '@rocket.chat/fuselage'; -import { useTranslation } from '@rocket.chat/ui-contexts'; -import type { ComponentProps, MouseEvent, MouseEventHandler, ReactElement } from 'react'; -import React, { Fragment, useCallback, useRef, useState } from 'react'; - -import type { MessageActionConfig } from '../../../../app/ui-utils/client/lib/MessageAction'; -import { useEmbeddedLayout } from '../../../hooks/useEmbeddedLayout'; -import ToolboxDropdown from './ToolboxDropdown'; - -type MessageActionConfigOption = Omit & { - action: ((event: MouseEvent) => void) & MouseEventHandler; -}; - -type MessageActionMenuProps = { - onChangeMenuVisibility: (visible: boolean) => void; - options: MessageActionConfigOption[]; -}; - -const getSectionOrder = (section: string): number => { - switch (section) { - case 'communication': - return 0; - case 'interaction': - return 1; - case 'duplication': - return 2; - case 'apps': - return 3; - case 'management': - return 4; - default: - return 5; - } -}; - -const MessageActionMenu = ({ options, onChangeMenuVisibility, ...props }: MessageActionMenuProps): ReactElement => { - const buttonRef = useRef(null); - const t = useTranslation(); - const [visible, setVisible] = useState(false); - const isLayoutEmbedded = useEmbeddedLayout(); - - const handleChangeMenuVisibility = useCallback( - (visible: boolean): void => { - setVisible(visible); - onChangeMenuVisibility(visible); - }, - [onChangeMenuVisibility], - ); - - const groupOptions = options.reduce((acc, option) => { - const { type = '' } = option; - - if (option.color === 'alert') { - option.variant = 'danger' as const; - } - - const order = getSectionOrder(type); - - const [sectionType, options] = acc[getSectionOrder(type)] ?? [type, []]; - - if (!(isLayoutEmbedded && option.id === 'reply-directly')) { - options.push(option); - } - - if (options.length === 0) { - return acc; - } - - acc[order] = [sectionType, options]; - - return acc; - }, [] as unknown as [section: string, options: Array][]); - - const handleClose = useCallback(() => { - handleChangeMenuVisibility(false); - }, [handleChangeMenuVisibility]); - return ( - <> - handleChangeMenuVisibility(!visible)} - data-qa-id='menu' - data-qa-type='message-action-menu' - title={t('More')} - /> - {visible && ( - <> - - {groupOptions.map(([section, options], index, arr) => ( - - {section === 'apps' && Apps} - {options.map((option) => ( - - ))} - - - )} - - ); -}; - -export default MessageActionMenu; diff --git a/apps/meteor/client/components/message/toolbox/MobileToolboxDropdown.tsx b/apps/meteor/client/components/message/toolbox/MobileToolboxDropdown.tsx deleted file mode 100644 index d97d1ff46aee8fce8fd84aa171921fcd6c42baed..0000000000000000000000000000000000000000 --- a/apps/meteor/client/components/message/toolbox/MobileToolboxDropdown.tsx +++ /dev/null @@ -1,38 +0,0 @@ -import { Tile } from '@rocket.chat/fuselage'; -import type { ReactNode, Ref } from 'react'; -import React, { forwardRef } from 'react'; - -import ScrollableContentWrapper from '../../ScrollableContentWrapper'; - -type MobileToolboxDropdownProps = { - children: ReactNode; -}; - -const MobileToolboxDropdown = forwardRef(function MobileToolboxDropdown( - { children, ...props }: MobileToolboxDropdownProps, - ref: Ref, -) { - return ( - - {children} - - ); -}); - -export default MobileToolboxDropdown; diff --git a/apps/meteor/client/components/message/toolbox/ToolboxDropdown.tsx b/apps/meteor/client/components/message/toolbox/ToolboxDropdown.tsx deleted file mode 100644 index eee619454f776033949e192ad895c73842767adc..0000000000000000000000000000000000000000 --- a/apps/meteor/client/components/message/toolbox/ToolboxDropdown.tsx +++ /dev/null @@ -1,38 +0,0 @@ -import { Box } from '@rocket.chat/fuselage'; -import { useOutsideClick } from '@rocket.chat/fuselage-hooks'; -import { useLayout } from '@rocket.chat/ui-contexts'; -import type { ReactNode, ReactElement } from 'react'; -import React, { useRef } from 'react'; - -import DesktopToolboxDropdown from './DesktopToolboxDropdown'; -import MobileToolboxDropdown from './MobileToolboxDropdown'; - -type ToolboxDropdownProps = { - children: ReactNode; - reference: React.RefObject; - handleClose: () => void; -}; - -const ToolboxDropdown = ({ - children, - handleClose, - reference, -}: ToolboxDropdownProps): ReactElement => { - const { isMobile } = useLayout(); - const target = useRef(null); - const boxRef = useRef(null); - - const Dropdown = isMobile ? MobileToolboxDropdown : DesktopToolboxDropdown; - - useOutsideClick([boxRef], handleClose); - - return ( - - - {children} - - - ); -}; - -export default ToolboxDropdown; diff --git a/apps/meteor/client/components/message/uikit/UiKitMessageBlock.tsx b/apps/meteor/client/components/message/uikit/UiKitMessageBlock.tsx index dbdd8f4e731bdfda6467e419801f6d20ead85272..3b8570e0de22d1875b945c16e60112cc51bdc0e2 100644 --- a/apps/meteor/client/components/message/uikit/UiKitMessageBlock.tsx +++ b/apps/meteor/client/components/message/uikit/UiKitMessageBlock.tsx @@ -1,8 +1,8 @@ import type { IMessage, IRoom } from '@rocket.chat/core-typings'; -import { MessageBlock } from '@rocket.chat/fuselage'; +import { MessageBlock, Skeleton } from '@rocket.chat/fuselage'; import { UiKitComponent, UiKitMessage as UiKitMessageSurfaceRender, UiKitContext } from '@rocket.chat/fuselage-ui-kit'; import type { MessageSurfaceLayout } from '@rocket.chat/ui-kit'; -import React from 'react'; +import React, { Suspense } from 'react'; import { useMessageBlockContextValue } from '../../../uikit/hooks/useMessageBlockContextValue'; import GazzodownText from '../../GazzodownText'; @@ -20,7 +20,9 @@ const UiKitMessageBlock = ({ rid, mid, blocks }: UiKitMessageBlockProps) => { - + }> + + diff --git a/apps/meteor/client/components/message/variants/RoomMessage.tsx b/apps/meteor/client/components/message/variants/RoomMessage.tsx index 2e82753051aecf518561e0ba2baac55e5a5647f8..31ebbbceb4520b843831788e25525493c62cf806 100644 --- a/apps/meteor/client/components/message/variants/RoomMessage.tsx +++ b/apps/meteor/client/components/message/variants/RoomMessage.tsx @@ -1,8 +1,9 @@ import type { IMessage } from '@rocket.chat/core-typings'; import { Message, MessageLeftContainer, MessageContainer, CheckBox } from '@rocket.chat/fuselage'; import { useToggle } from '@rocket.chat/fuselage-hooks'; +import { MessageAvatar } from '@rocket.chat/ui-avatar'; import { useUserId } from '@rocket.chat/ui-contexts'; -import type { ReactElement } from 'react'; +import type { ComponentProps, ReactElement } from 'react'; import React, { useRef, memo } from 'react'; import type { MessageActionContext } from '../../../../app/ui-utils/client/lib/MessageAction'; @@ -15,11 +16,11 @@ import { } from '../../../views/room/MessageList/contexts/SelectedMessagesContext'; import { useJumpToMessage } from '../../../views/room/MessageList/hooks/useJumpToMessage'; import { useChat } from '../../../views/room/contexts/ChatContext'; +import Emoji from '../../Emoji'; import IgnoredContent from '../IgnoredContent'; import MessageHeader from '../MessageHeader'; -import MessageToolboxHolder from '../MessageToolboxHolder'; +import MessageToolbarHolder from '../MessageToolbarHolder'; import StatusIndicators from '../StatusIndicators'; -import MessageAvatar from '../header/MessageAvatar'; import RoomMessageContent from './room/RoomMessageContent'; type RoomMessageProps = { @@ -32,7 +33,7 @@ type RoomMessageProps = { context?: MessageActionContext; ignoredUser?: boolean; searchText?: string; -}; +} & ComponentProps; const RoomMessage = ({ message, @@ -44,6 +45,7 @@ const RoomMessage = ({ context, ignoredUser, searchText, + ...props }: RoomMessageProps): ReactElement => { const uid = useUserId(); const editing = useIsMessageHighlight(message._id); @@ -59,10 +61,13 @@ const RoomMessage = ({ useCountSelected(); useJumpToMessage(message._id, messageRef); + return ( {!sequential && message.u.username && !selecting && showUserAvatar && ( : undefined} avatarUrl={message.avatar} username={message.u.username} size='x36' {...(chat?.userCard && { - onClick: chat?.userCard.open(message.u.username), + onClick: (e) => chat?.userCard.openUserCard(e, message.u.username), style: { cursor: 'pointer' }, })} /> @@ -94,17 +100,15 @@ const RoomMessage = ({ {selecting && } {sequential && } - {!sequential && } - {ignored ? ( ) : ( )} - {!message.private && } + {!message.private && } ); }; diff --git a/apps/meteor/client/components/message/variants/SystemMessage.tsx b/apps/meteor/client/components/message/variants/SystemMessage.tsx index bef39112bb11210179ee81d27efd46b51474348d..9bb254beaec510cfcc0d30a16f6c5a3e6bee8821 100644 --- a/apps/meteor/client/components/message/variants/SystemMessage.tsx +++ b/apps/meteor/client/components/message/variants/SystemMessage.tsx @@ -11,9 +11,10 @@ import { MessageUsername, MessageNameContainer, } from '@rocket.chat/fuselage'; +import { UserAvatar } from '@rocket.chat/ui-avatar'; import type { TranslationKey } from '@rocket.chat/ui-contexts'; import { useTranslation } from '@rocket.chat/ui-contexts'; -import type { ReactElement } from 'react'; +import type { ComponentProps, ReactElement } from 'react'; import React, { memo } from 'react'; import { MessageTypes } from '../../../../app/ui-utils/client'; @@ -29,7 +30,6 @@ import { useCountSelected, } from '../../../views/room/MessageList/contexts/SelectedMessagesContext'; import { useChat } from '../../../views/room/contexts/ChatContext'; -import UserAvatar from '../../avatar/UserAvatar'; import Attachments from '../content/Attachments'; import MessageActions from '../content/MessageActions'; import { useMessageListShowRealName, useMessageListShowUsername } from '../list/MessageListContext'; @@ -37,9 +37,9 @@ import { useMessageListShowRealName, useMessageListShowUsername } from '../list/ type SystemMessageProps = { message: IMessage; showUserAvatar: boolean; -}; +} & ComponentProps; -const SystemMessage = ({ message, showUserAvatar }: SystemMessageProps): ReactElement => { +const SystemMessage = ({ message, showUserAvatar, ...props }: SystemMessageProps): ReactElement => { const t = useTranslation(); const formatTime = useFormatTime(); const formatDateAndTime = useFormatDateAndTime(); @@ -59,11 +59,14 @@ const SystemMessage = ({ message, showUserAvatar }: SystemMessageProps): ReactEl return ( {!isSelecting && showUserAvatar && } @@ -75,7 +78,7 @@ const SystemMessage = ({ message, showUserAvatar }: SystemMessageProps): ReactEl user.username && chat?.userCard.openUserCard(e, user.username), style: { cursor: 'pointer' }, })} > @@ -88,7 +91,7 @@ const SystemMessage = ({ message, showUserAvatar }: SystemMessageProps): ReactEl data-username={user.username} {...(user.username !== undefined && chat?.userCard && { - onClick: chat?.userCard.open(user.username), + onClick: (e) => user.username && chat?.userCard.openUserCard(e, user.username), style: { cursor: 'pointer' }, })} > diff --git a/apps/meteor/client/components/message/variants/ThreadMessage.tsx b/apps/meteor/client/components/message/variants/ThreadMessage.tsx index bc39de79e48d521cdb7513d6eb3aafba1a00ff9b..6019c7d0402332ddf66c9f995fd4bb39af4a19f0 100644 --- a/apps/meteor/client/components/message/variants/ThreadMessage.tsx +++ b/apps/meteor/client/components/message/variants/ThreadMessage.tsx @@ -1,6 +1,7 @@ import { type IThreadMessage, type IThreadMainMessage, isVideoConfMessage } from '@rocket.chat/core-typings'; import { Message, MessageLeftContainer, MessageContainer } from '@rocket.chat/fuselage'; import { useToggle } from '@rocket.chat/fuselage-hooks'; +import { MessageAvatar } from '@rocket.chat/ui-avatar'; import { useUserId } from '@rocket.chat/ui-contexts'; import type { ReactElement } from 'react'; import React, { memo, useRef } from 'react'; @@ -9,11 +10,11 @@ import type { MessageActionContext } from '../../../../app/ui-utils/client/lib/M import { useIsMessageHighlight } from '../../../views/room/MessageList/contexts/MessageHighlightContext'; import { useJumpToMessage } from '../../../views/room/MessageList/hooks/useJumpToMessage'; import { useChat } from '../../../views/room/contexts/ChatContext'; +import Emoji from '../../Emoji'; import IgnoredContent from '../IgnoredContent'; import MessageHeader from '../MessageHeader'; -import MessageToolboxHolder from '../MessageToolboxHolder'; +import MessageToolbarHolder from '../MessageToolbarHolder'; import StatusIndicators from '../StatusIndicators'; -import MessageAvatar from '../header/MessageAvatar'; import ThreadMessageContent from './thread/ThreadMessageContent'; type ThreadMessageProps = { @@ -38,6 +39,8 @@ const ThreadMessage = ({ message, sequential, unread, showUserAvatar }: ThreadMe return ( {!sequential && message.u.username && showUserAvatar && ( : undefined} avatarUrl={message.avatar} username={message.u.username} size='x36' {...(chat?.userCard && { - onClick: chat?.userCard.open(message.u.username), + onClick: (e) => chat?.userCard.openUserCard(e, message.u.username), style: { cursor: 'pointer' }, })} /> @@ -72,7 +75,7 @@ const ThreadMessage = ({ message, sequential, unread, showUserAvatar }: ThreadMe {ignored ? : } - {!message.private && } + {!message.private && } ); }; diff --git a/apps/meteor/client/components/message/variants/ThreadMessagePreview.tsx b/apps/meteor/client/components/message/variants/ThreadMessagePreview.tsx index eb6fe03d9d0fc2418c34eb1df2cd3c8e35536091..dcd9c673d40ae24428663507a88c311edab32808 100644 --- a/apps/meteor/client/components/message/variants/ThreadMessagePreview.tsx +++ b/apps/meteor/client/components/message/variants/ThreadMessagePreview.tsx @@ -12,8 +12,9 @@ import { CheckBox, MessageStatusIndicatorItem, } from '@rocket.chat/fuselage'; +import { MessageAvatar } from '@rocket.chat/ui-avatar'; import { useTranslation } from '@rocket.chat/ui-contexts'; -import type { ReactElement } from 'react'; +import type { ComponentProps, ReactElement } from 'react'; import React, { memo } from 'react'; import { MessageTypes } from '../../../../app/ui-utils/client'; @@ -27,7 +28,7 @@ import { useMessageBody } from '../../../views/room/MessageList/hooks/useMessage import { useParentMessage } from '../../../views/room/MessageList/hooks/useParentMessage'; import { isParsedMessage } from '../../../views/room/MessageList/lib/isParsedMessage'; import { useGoToThread } from '../../../views/room/hooks/useGoToThread'; -import MessageAvatar from '../header/MessageAvatar'; +import Emoji from '../../Emoji'; import { useShowTranslated } from '../list/MessageListContext'; import ThreadMessagePreviewBody from './threadPreview/ThreadMessagePreviewBody'; @@ -35,7 +36,7 @@ type ThreadMessagePreviewProps = { message: IThreadMessage; showUserAvatar: boolean; sequential: boolean; -}; +} & ComponentProps; const ThreadMessagePreview = ({ message, showUserAvatar, sequential, ...props }: ThreadMessagePreviewProps): ReactElement => { const parentMessage = useParentMessage(message.tmid); @@ -55,23 +56,30 @@ const ThreadMessagePreview = ({ message, showUserAvatar, sequential, ...props }: const goToThread = useGoToThread(); + const handleThreadClick = () => { + if (!isSelecting) { + if (!sequential) { + return parentMessage.isSuccess && goToThread({ rid: message.rid, tmid: message.tmid, msg: parentMessage.data?._id }); + } + + return goToThread({ rid: message.rid, tmid: message.tmid, msg: message._id }); + } + + return toggleSelected(); + }; + return ( e.code === 'Enter' && handleThreadClick()} isSelected={isSelected} data-qa-selected={isSelected} role='link' + {...props} > {!sequential && ( - goToThread({ rid: message.rid, tmid: message.tmid, msg: parentMessage.data?._id }) - : undefined - } - > + @@ -99,9 +107,15 @@ const ThreadMessagePreview = ({ message, showUserAvatar, sequential, ...props }: )} - goToThread({ rid: message.rid, tmid: message.tmid, msg: message._id }) : undefined}> + - {!isSelecting && showUserAvatar && } + {!isSelecting && showUserAvatar && ( + : undefined} + username={message.u.username} + size='x18' + /> + )} {isSelecting && } diff --git a/apps/meteor/client/components/message/variants/thread/ThreadMessageContent.tsx b/apps/meteor/client/components/message/variants/thread/ThreadMessageContent.tsx index 57835ec75e0ca367305803a651558ada1d22b0bb..8616f12238123400f68dc0e30841271f3ed09ede 100644 --- a/apps/meteor/client/components/message/variants/thread/ThreadMessageContent.tsx +++ b/apps/meteor/client/components/message/variants/thread/ThreadMessageContent.tsx @@ -52,7 +52,7 @@ const ThreadMessageContent = ({ message }: ThreadMessageContentProps): ReactElem )} - {normalizedMessage.attachments && } + {normalizedMessage.attachments && } {oembedEnabled && !!normalizedMessage.urls?.length && } diff --git a/apps/meteor/client/contexts/AppsContext.tsx b/apps/meteor/client/contexts/AppsContext.tsx index 769609c733b091c89f85d77d595b9af5ba03aa44..b1732b6444b4394f66c11964697bfd02fa145ba0 100644 --- a/apps/meteor/client/contexts/AppsContext.tsx +++ b/apps/meteor/client/contexts/AppsContext.tsx @@ -5,9 +5,9 @@ import { AsyncStatePhase } from '../lib/asyncState'; import type { App } from '../views/marketplace/types'; export type AppsContextValue = { - installedApps: AsyncState<{ apps: App[] }>; - marketplaceApps: AsyncState<{ apps: App[] }>; - privateApps: AsyncState<{ apps: App[] }>; + installedApps: Omit, 'error'>; + marketplaceApps: Omit, 'error'>; + privateApps: Omit, 'error'>; reload: () => Promise; }; @@ -15,17 +15,14 @@ export const AppsContext = createContext({ installedApps: { phase: AsyncStatePhase.LOADING, value: undefined, - error: undefined, }, marketplaceApps: { phase: AsyncStatePhase.LOADING, value: undefined, - error: undefined, }, privateApps: { phase: AsyncStatePhase.LOADING, value: undefined, - error: undefined, }, reload: () => Promise.resolve(), }); diff --git a/apps/meteor/client/definitions/IOAuthProvider.ts b/apps/meteor/client/definitions/IOAuthProvider.ts new file mode 100644 index 0000000000000000000000000000000000000000..00bc3be2b0408fd5c75bc272c607f4a1eadc77f8 --- /dev/null +++ b/apps/meteor/client/definitions/IOAuthProvider.ts @@ -0,0 +1,9 @@ +import type { Meteor } from 'meteor/meteor'; + +export interface IOAuthProvider { + readonly name: string; + requestCredential( + options: Meteor.LoginWithExternalServiceOptions | undefined, + credentialRequestCompleteCallback: (credentialTokenOrError?: string | Error) => void, + ): void; +} diff --git a/apps/meteor/client/definitions/IRocketChatDesktop.ts b/apps/meteor/client/definitions/IRocketChatDesktop.ts new file mode 100644 index 0000000000000000000000000000000000000000..52bc172d719b1685c0b2114a5d8a661f98df6cdc --- /dev/null +++ b/apps/meteor/client/definitions/IRocketChatDesktop.ts @@ -0,0 +1,10 @@ +type OutlookEventsResponse = { status: 'success' | 'canceled' }; + +export interface IRocketChatDesktop { + openInternalVideoChatWindow?: (url: string, options: { providerName: string | undefined }) => void; + getOutlookEvents?: (date: Date) => Promise; + setOutlookExchangeUrl?: (url: string, userId: string) => Promise; + hasOutlookCredentials?: () => Promise; + clearOutlookCredentials?: () => void; + openDocumentViewer?: (url: string, format: string, options: any) => void; +} diff --git a/apps/meteor/client/definitions/global.d.ts b/apps/meteor/client/definitions/global.d.ts new file mode 100644 index 0000000000000000000000000000000000000000..8b20108e8e489c1b1f77ce739c847d5323f67a2e --- /dev/null +++ b/apps/meteor/client/definitions/global.d.ts @@ -0,0 +1,8 @@ +import type { IRocketChatDesktop } from './IRocketChatDesktop'; + +declare global { + // eslint-disable-next-line @typescript-eslint/naming-convention + interface Window { + RocketChatDesktop?: IRocketChatDesktop; + } +} diff --git a/apps/meteor/client/hooks/lists/useStreamUpdatesForMessageList.ts b/apps/meteor/client/hooks/lists/useStreamUpdatesForMessageList.ts index c9e287d573d294d0f4f2600d25aead8e65f8c96e..a28c4424d313c7015cf4c20ec978094897dce6c9 100644 --- a/apps/meteor/client/hooks/lists/useStreamUpdatesForMessageList.ts +++ b/apps/meteor/client/hooks/lists/useStreamUpdatesForMessageList.ts @@ -12,10 +12,18 @@ type NotifyRoomRidDeleteMessageBulkEvent = { ignoreDiscussion: boolean; ts: FieldExpression; users: string[]; + ids?: string[]; // message ids have priority over ts + showDeletedStatus?: boolean; }; const createDeleteCriteria = (params: NotifyRoomRidDeleteMessageBulkEvent): ((message: IMessage) => boolean) => { - const query: Query = { ts: params.ts }; + const query: Query = {}; + + if (params.ids) { + query._id = { $in: params.ids }; + } else { + query.ts = params.ts; + } if (params.excludePinned) { query.pinned = { $ne: true }; diff --git a/apps/meteor/client/hooks/roomActions/useOTRRoomAction.ts b/apps/meteor/client/hooks/roomActions/useOTRRoomAction.ts index b199631aa7d59a3a1bf6827501dfbe7b6510515b..1a50283c747551053d7424d8595f7a662a44802d 100644 --- a/apps/meteor/client/hooks/roomActions/useOTRRoomAction.ts +++ b/apps/meteor/client/hooks/roomActions/useOTRRoomAction.ts @@ -1,9 +1,8 @@ import { isRoomFederated } from '@rocket.chat/core-typings'; import { useSetting } from '@rocket.chat/ui-contexts'; -import { lazy, useEffect, useMemo } from 'react'; +import { lazy, useMemo } from 'react'; import { useTranslation } from 'react-i18next'; -import otr from '../../../app/otr/client/OTR'; import { useRoom } from '../../views/room/contexts/RoomContext'; import type { RoomToolboxActionConfig } from '../../views/room/contexts/RoomToolboxContext'; @@ -16,10 +15,6 @@ export const useOTRRoomAction = () => { const capable = !!global.crypto; const { t } = useTranslation(); - useEffect(() => { - otr.setEnabled(enabled && capable); - }, [enabled, capable]); - return useMemo((): RoomToolboxActionConfig | undefined => { if (!enabled || !capable) { return undefined; diff --git a/apps/meteor/client/hooks/roomActions/useOmnichannelExternalFrameRoomAction.ts b/apps/meteor/client/hooks/roomActions/useOmnichannelExternalFrameRoomAction.ts index 7f03b369b449d658ac18a1fb26b67056f7f74475..fe1e704f0b1ca5b5cf51da940640e6b68e57c7b9 100644 --- a/apps/meteor/client/hooks/roomActions/useOmnichannelExternalFrameRoomAction.ts +++ b/apps/meteor/client/hooks/roomActions/useOmnichannelExternalFrameRoomAction.ts @@ -3,7 +3,7 @@ import { lazy, useMemo } from 'react'; import type { RoomToolboxActionConfig } from '../../views/room/contexts/RoomToolboxContext'; -const ExternalFrameContainer = lazy(() => import('../../../app/livechat/client/externalFrame/ExternalFrameContainer')); +const ExternalFrameContainer = lazy(() => import('../../views/omnichannel/ExternalFrameContainer')); export const useOmnichannelExternalFrameRoomAction = () => { const enabled = useSetting('Omnichannel_External_Frame_Enabled', false); diff --git a/apps/meteor/client/hooks/roomActions/useThreadRoomAction.tsx b/apps/meteor/client/hooks/roomActions/useThreadRoomAction.tsx index dc72ef26c66bbd7f6232ce68ada366952a1196c8..92cc93e339fd5f0ae737c0cc94552b2c35026812 100644 --- a/apps/meteor/client/hooks/roomActions/useThreadRoomAction.tsx +++ b/apps/meteor/client/hooks/roomActions/useThreadRoomAction.tsx @@ -1,5 +1,5 @@ import type { BadgeProps } from '@rocket.chat/fuselage'; -import { HeaderToolboxAction, HeaderToolboxActionBadge } from '@rocket.chat/ui-client'; +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'; @@ -46,7 +46,7 @@ export const useThreadRoomAction = () => { tabComponent: Threads, order: 2, renderToolboxItem: ({ id, className, index, icon, title, toolbox: { tab }, action, disabled, tooltip }) => ( - { disabled={disabled} tooltip={tooltip} > - {!!unread && {unread}} - + {!!unread && {unread}} + ), }; }, [enabled, t, unread, variant]); diff --git a/apps/meteor/client/hooks/useAppActionButtons.ts b/apps/meteor/client/hooks/useAppActionButtons.ts index f4d37328d2a07ea740ebd811fa1c849732bc36c2..5ee20f7772bf100138e14b0fa8e3fa6a54bc68d8 100644 --- a/apps/meteor/client/hooks/useAppActionButtons.ts +++ b/apps/meteor/client/hooks/useAppActionButtons.ts @@ -1,6 +1,6 @@ import type { IUIActionButton, UIActionButtonContext } from '@rocket.chat/apps-engine/definition/ui'; import { useDebouncedCallback } from '@rocket.chat/fuselage-hooks'; -import { useEndpoint, useSingleStream, useToastMessageDispatch, useUserId } from '@rocket.chat/ui-contexts'; +import { useEndpoint, useStream, useToastMessageDispatch, useUserId } from '@rocket.chat/ui-contexts'; import type { UseQueryResult } from '@tanstack/react-query'; import { useQuery, useQueryClient } from '@tanstack/react-query'; import { useEffect, useMemo } from 'react'; @@ -19,7 +19,7 @@ const getIdForActionButton = ({ appId, actionId }: IUIActionButton): string => ` export const useAppActionButtons = (context?: TContext) => { const queryClient = useQueryClient(); - const apps = useSingleStream('apps'); + const apps = useStream('apps'); const uid = useUserId(); const getActionButtons = useEndpoint('GET', '/apps/actionButtons'); @@ -76,7 +76,7 @@ export const useMessageboxAppsActionButtons = () => { return applyButtonFilters(action); }) .map((action) => { - const item: MessageBoxAction = { + const item: Omit = { id: getIdForActionButton(action), label: Utilities.getI18nKeyForApp(action.labelI18n, action.appId), action: (params) => { diff --git a/apps/meteor/client/hooks/useAppSlashCommands.ts b/apps/meteor/client/hooks/useAppSlashCommands.ts index 665e76b0ef9e111c7c1716605fdc10c8ee425405..c49c629a2a0667cf7b46a1d042b257f8ee1d8e60 100644 --- a/apps/meteor/client/hooks/useAppSlashCommands.ts +++ b/apps/meteor/client/hooks/useAppSlashCommands.ts @@ -1,5 +1,5 @@ import { useDebouncedCallback } from '@rocket.chat/fuselage-hooks'; -import { useEndpoint, useSingleStream, useUserId } from '@rocket.chat/ui-contexts'; +import { useEndpoint, useStream, useUserId } from '@rocket.chat/ui-contexts'; import { useQuery, useQueryClient } from '@tanstack/react-query'; import { useEffect } from 'react'; @@ -8,7 +8,7 @@ import { slashCommands } from '../../app/utils/lib/slashCommand'; export const useAppSlashCommands = () => { const queryClient = useQueryClient(); - const apps = useSingleStream('apps'); + const apps = useStream('apps'); const uid = useUserId(); const invalidate = useDebouncedCallback( diff --git a/apps/meteor/client/hooks/useAvatarSuggestions.ts b/apps/meteor/client/hooks/useAvatarSuggestions.ts deleted file mode 100644 index 223cab8ca4b40a0baf2ddb657018c016012a012c..0000000000000000000000000000000000000000 --- a/apps/meteor/client/hooks/useAvatarSuggestions.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { useEndpoint } from '@rocket.chat/ui-contexts'; -import { useQuery } from '@tanstack/react-query'; - -export const useAvatarSuggestions = () => { - const getAvatarSuggestions = useEndpoint('GET', '/v1/users.getAvatarSuggestion'); - - return useQuery(['getAvatarSuggestion'], async () => getAvatarSuggestions()); -}; diff --git a/apps/meteor/client/hooks/useEndpointAction.ts b/apps/meteor/client/hooks/useEndpointAction.ts index 7cc1a68f102014d5d1183449ba1b1b9ec4017619..c7c371a04e1f0d009586fd3d5abc3d7d8119dced 100644 --- a/apps/meteor/client/hooks/useEndpointAction.ts +++ b/apps/meteor/client/hooks/useEndpointAction.ts @@ -1,7 +1,7 @@ -import type { Method, OperationParams, PathPattern, UrlParams } from '@rocket.chat/rest-typings'; +import type { Method, PathPattern, UrlParams } from '@rocket.chat/rest-typings'; import type { EndpointFunction } from '@rocket.chat/ui-contexts'; import { useToastMessageDispatch, useEndpoint } from '@rocket.chat/ui-contexts'; -import { useCallback } from 'react'; +import { useMutation } from '@tanstack/react-query'; type UseEndpointActionOptions = (undefined extends UrlParams ? { @@ -16,26 +16,21 @@ export function useEndpointAction = { keys: {} as UrlParams }, -): EndpointFunction { +) { const sendData = useEndpoint(method, pathPattern, options.keys as UrlParams); const dispatchToastMessage = useToastMessageDispatch(); - return useCallback( - async (params: OperationParams | undefined) => { - try { - const data = await sendData(params as OperationParams); - - if (options.successMessage) { - dispatchToastMessage({ type: 'success', message: options.successMessage }); - } - - return data; - } catch (error) { - dispatchToastMessage({ type: 'error', message: error }); - throw error; + const mutation = useMutation(sendData, { + onSuccess: () => { + if (options.successMessage) { + dispatchToastMessage({ type: 'success', message: options.successMessage }); } }, - [dispatchToastMessage, sendData, options.successMessage], - ); + onError: (error) => { + dispatchToastMessage({ type: 'error', message: error }); + }, + }); + + return mutation.mutateAsync as EndpointFunction; } diff --git a/apps/meteor/client/hooks/useFileInput.ts b/apps/meteor/client/hooks/useFileInput.ts new file mode 100644 index 0000000000000000000000000000000000000000..c9662b820d8f69e483a3bf193a8d03ca102c8b6a --- /dev/null +++ b/apps/meteor/client/hooks/useFileInput.ts @@ -0,0 +1,23 @@ +import { useRef, useEffect } from 'react'; +import type { AllHTMLAttributes } from 'react'; + +export const useFileInput = (props: AllHTMLAttributes) => { + const ref = useRef(); + + useEffect(() => { + const fileInput = document.createElement('input'); + fileInput.setAttribute('style', 'display: none;'); + Object.entries(props).forEach(([key, value]) => { + fileInput.setAttribute(key, value); + }); + document.body.appendChild(fileInput); + ref.current = fileInput; + + return (): void => { + ref.current = undefined; + fileInput.remove(); + }; + }, [props]); + + return ref; +}; diff --git a/apps/meteor/client/hooks/useForm.ts b/apps/meteor/client/hooks/useForm.ts deleted file mode 100644 index d84aca63fc0673e09c6ed0bc4a356676296897f1..0000000000000000000000000000000000000000 --- a/apps/meteor/client/hooks/useForm.ts +++ /dev/null @@ -1,195 +0,0 @@ -import { capitalize } from '@rocket.chat/string-helpers'; -import type { ChangeEvent } from 'react'; -import { useCallback, useReducer, useMemo } from 'react'; - -type Field = { - name: string; - currentValue: unknown; - initialValue: unknown; - changed: boolean; -}; - -type FormState> = { - fields: Field[]; - values: Values; - hasUnsavedChanges: boolean; -}; - -type FormAction> = { - (prevState: FormState): FormState; -}; - -const reduceForm = >(state: FormState, action: FormAction): FormState => - action(state); - -const initForm = >(initialValues: Values): FormState => { - const fields = []; - - for (const [fieldName, initialValue] of Object.entries(initialValues)) { - fields.push({ - name: fieldName, - currentValue: initialValue, - initialValue, - changed: false, - }); - } - - return { - fields, - values: { ...initialValues }, - hasUnsavedChanges: false, - }; -}; - -const valueChanged = - >(fieldName: string, newValue: unknown): FormAction => - (state: FormState): FormState => { - let { fields } = state; - const field = fields.find(({ name }) => name === fieldName); - - if (!field || field.currentValue === newValue) { - return state; - } - - const newField = { - ...field, - currentValue: newValue, - changed: JSON.stringify(newValue) !== JSON.stringify(field.initialValue), - }; - - fields = state.fields.map((field) => { - if (field.name === fieldName) { - return newField; - } - - return field; - }); - - return { - ...state, - fields, - values: { - ...state.values, - [newField.name]: newField.currentValue, - }, - hasUnsavedChanges: newField.changed || fields.some((field) => field.changed), - }; - }; - -const formCommitted = - >(): FormAction => - (state: FormState): FormState => ({ - ...state, - fields: state.fields.map((field) => ({ - ...field, - initialValue: field.currentValue, - changed: false, - })), - hasUnsavedChanges: false, - }); - -const formReset = - >(): FormAction => - (state: FormState): FormState => ({ - ...state, - fields: state.fields.map((field) => ({ - ...field, - currentValue: field.initialValue, - changed: false, - })), - values: state.fields.reduce( - (values, field) => ({ - ...values, - [field.name]: field.initialValue, - }), - {} as Values, - ), - hasUnsavedChanges: false, - }); - -const isChangeEvent = (x: any): x is ChangeEvent => - (typeof x === 'object' || typeof x === 'function') && typeof x?.currentTarget !== 'undefined'; - -const getValue = (eventOrValue: ChangeEvent | unknown): unknown => { - if (!isChangeEvent(eventOrValue)) { - return eventOrValue; - } - - const target = eventOrValue.currentTarget; - - if (target instanceof HTMLTextAreaElement) { - return target.value; - } - - if (target instanceof HTMLSelectElement) { - return target.value; - } - - if (!(target instanceof HTMLInputElement)) { - return undefined; - } - - if (target.type === 'checkbox' || target.type === 'radio') { - return target.checked; - } - - return target.value; -}; - -/** - * @deprecated prefer react-hook-form's `useForm` - */ -export const useForm = < - Reducer extends ( - state: FormState>, - action: FormAction>, - ) => FormState>, ->( - initialValues: Parameters[0]['values'], - onChange: (...args: unknown[]) => void = (): void => undefined, -): { - values: Parameters[0]['values']; - handlers: Record void>; - hasUnsavedChanges: boolean; - commit: () => void; - reset: () => void; -} => { - const [state, dispatch] = useReducer(reduceForm, initialValues, initForm); - - const commit = useCallback(() => { - dispatch(formCommitted()); - }, []); - - const reset = useCallback(() => { - dispatch(formReset()); - }, []); - - const handlers = useMemo void>>( - () => - state.fields.reduce( - (handlers, { name, initialValue }) => ({ - ...handlers, - [`handle${capitalize(name)}`]: (eventOrValue: ChangeEvent | unknown): void => { - const newValue = getValue(eventOrValue); - dispatch(valueChanged(name, newValue)); - onChange({ - initialValue, - value: newValue, - key: name, - values: state.values, - }); - }, - }), - {}, - ), - [onChange, state.fields, state.values], - ); - - return { - handlers, - values: state.values, - hasUnsavedChanges: state.hasUnsavedChanges, - commit, - reset, - }; -}; diff --git a/apps/meteor/client/hooks/useLicense.ts b/apps/meteor/client/hooks/useLicense.ts index 192aa089251aae459b31ad79e91c532ed96bdcac..4ec8d29ec49c489a52f1ffabcd059c01982f3fa6 100644 --- a/apps/meteor/client/hooks/useLicense.ts +++ b/apps/meteor/client/hooks/useLicense.ts @@ -1,6 +1,6 @@ import type { Serialized } from '@rocket.chat/core-typings'; import type { OperationResult } from '@rocket.chat/rest-typings'; -import { useEndpoint, useSingleStream, useUserId } from '@rocket.chat/ui-contexts'; +import { useEndpoint, useStream, useUserId } from '@rocket.chat/ui-contexts'; import type { QueryClient, UseQueryResult } from '@tanstack/react-query'; import { useQuery, useQueryClient } from '@tanstack/react-query'; import { useEffect } from 'react'; @@ -36,7 +36,7 @@ export const useLicenseBase = ({ const invalidateQueries = useInvalidateLicense(); - const notify = useSingleStream('notify-all'); + const notify = useStream('notify-all'); useEffect(() => notify('license', () => invalidateQueries()), [notify, invalidateQueries]); diff --git a/apps/meteor/client/hooks/useTranslationsForApps.ts b/apps/meteor/client/hooks/useTranslationsForApps.ts index 9cc4fd8c09916c8cedd0fd6746188ccb96aa7710..56e29c24a1937f80896bea2dcf06d9f439cb00a7 100644 --- a/apps/meteor/client/hooks/useTranslationsForApps.ts +++ b/apps/meteor/client/hooks/useTranslationsForApps.ts @@ -1,5 +1,5 @@ import { normalizeLanguage } from '@rocket.chat/tools'; -import { useEndpoint, useSingleStream, useUserId } from '@rocket.chat/ui-contexts'; +import { useEndpoint, useStream, useUserId } from '@rocket.chat/ui-contexts'; import { useQuery, useQueryClient } from '@tanstack/react-query'; import { useEffect } from 'react'; import { useTranslation } from 'react-i18next'; @@ -32,7 +32,7 @@ export const useTranslationsForApps = () => { }, [i18n, data, isSuccess]); const queryClient = useQueryClient(); - const subscribeToApps = useSingleStream('apps'); + const subscribeToApps = useStream('apps'); const uid = useUserId(); useEffect(() => { diff --git a/apps/meteor/client/hooks/webdav/useWebDAVAccountIntegrationsQuery.ts b/apps/meteor/client/hooks/webdav/useWebDAVAccountIntegrationsQuery.ts new file mode 100644 index 0000000000000000000000000000000000000000..171490b33d18ba3154f74ce12724092743222c8f --- /dev/null +++ b/apps/meteor/client/hooks/webdav/useWebDAVAccountIntegrationsQuery.ts @@ -0,0 +1,55 @@ +import type { IWebdavAccountIntegration } from '@rocket.chat/core-typings'; +import { useUserId, useEndpoint, useStream } from '@rocket.chat/ui-contexts'; +import type { UseQueryOptions } from '@tanstack/react-query'; +import { useQuery, useQueryClient } from '@tanstack/react-query'; +import { useEffect, useMemo } from 'react'; + +type UseWebDAVAccountIntegrationsQueryOptions = Omit< + UseQueryOptions, + 'queryKey' | 'queryFn' +>; + +export const useWebDAVAccountIntegrationsQuery = ({ enabled = true, ...options }: UseWebDAVAccountIntegrationsQueryOptions = {}) => { + const uid = useUserId(); + + const queryKey = useMemo(() => ['webdav', 'account-integrations'] as const, []); + + const getMyAccounts = useEndpoint('GET', '/v1/webdav.getMyAccounts'); + + const integrationsQuery = useQuery({ + queryKey, + queryFn: async (): Promise => { + const { accounts } = await getMyAccounts(); + return accounts; + }, + enabled: !!uid && enabled, + staleTime: Infinity, + ...options, + }); + + const queryClient = useQueryClient(); + + const subscribeToNotifyUser = useStream('notify-user'); + + useEffect(() => { + if (!uid || !enabled) { + return; + } + + return subscribeToNotifyUser(`${uid}/webdav`, ({ type, account }) => { + switch (type) { + case 'changed': + queryClient.invalidateQueries(queryKey); + break; + + case 'removed': + queryClient.setQueryData(queryKey, (old = []) => { + return old.filter((oldAccount) => oldAccount._id !== account._id); + }); + break; + } + }); + }, [enabled, queryClient, queryKey, uid, subscribeToNotifyUser]); + + return integrationsQuery; +}; diff --git a/apps/meteor/client/importPackages.ts b/apps/meteor/client/importPackages.ts index fc9ce52e99935b7ae263c6d11119472c77ceeed8..ffc81c35a953211eb7ebbbefe365aebbab4efaf5 100644 --- a/apps/meteor/client/importPackages.ts +++ b/apps/meteor/client/importPackages.ts @@ -1,11 +1,7 @@ import '../app/cors/client'; -import '../app/2fa/client'; import '../app/apple/client'; import '../app/authorization/client'; import '../app/autotranslate/client'; -import '../app/cas/client'; -import '../app/crowd/client'; -import '../app/custom-oauth/client/custom_oauth_client'; import '../app/custom-sounds/client'; import '../app/dolphin/client'; import '../app/drupal/client'; @@ -19,6 +15,7 @@ import '../app/iframe-login/client'; import '../app/lib/client'; import '../app/message-mark-as-unread/client'; import '../app/nextcloud/client'; +import '../app/notifications/client'; import '../app/otr/client'; import '../app/slackbridge/client'; import '../app/slashcommands-archiveroom/client'; @@ -33,18 +30,14 @@ import '../app/slashcommands-open/client'; import '../app/slashcommands-topic/client'; import '../app/slashcommands-unarchiveroom/client'; import '../app/tokenpass/client'; -import '../app/webdav/client'; import '../app/webrtc/client'; import '../app/wordpress/client'; -import '../app/meteor-accounts-saml/client'; import '../app/e2e/client'; import '../app/discussion/client'; import '../app/threads/client'; -import '../app/user-status/client'; import '../app/utils/client'; import '../app/settings/client'; import '../app/models/client'; -import '../app/notifications/client'; import '../app/ui-utils/client'; import '../app/ui-cached-collection/client'; import '../app/reactions/client'; diff --git a/apps/meteor/client/lib/2fa/overrideLoginMethod.ts b/apps/meteor/client/lib/2fa/overrideLoginMethod.ts index fcda6907cbcf7f583be4ebc0d8c29de361296abe..7cf01ba3370c955767f63fa823714243257a1c9a 100644 --- a/apps/meteor/client/lib/2fa/overrideLoginMethod.ts +++ b/apps/meteor/client/lib/2fa/overrideLoginMethod.ts @@ -1,46 +1,105 @@ -import { t } from '../../../app/utils/lib/i18n'; -import { dispatchToastMessage } from '../toast'; -import { process2faReturn } from './process2faReturn'; -import { isTotpInvalidError, isTotpRequiredError } from './utils'; - -type LoginCallback = { - (error: unknown): void; - (error: unknown, result: unknown): void; -}; +import { isTotpInvalidError, isTotpMaxAttemptsError, isTotpRequiredError } from './utils'; -type LoginMethod = (...args: [...args: A, cb: LoginCallback]) => void; +type LoginError = globalThis.Error | Meteor.Error | Meteor.TypedError; -type LoginMethodWithTotp = (...args: [...args: A, code: string, cb: LoginCallback]) => void; +export type LoginCallback = (error: LoginError | undefined, result?: unknown) => void; -export const overrideLoginMethod = ( - loginMethod: LoginMethod, - loginArgs: A, - callback: LoginCallback, - loginMethodTOTP: LoginMethodWithTotp, - emailOrUsername: string, -): void => { - loginMethod.call(null, ...loginArgs, async (error: unknown, result?: unknown) => { +export const overrideLoginMethod = ( + loginMethod: (...args: [...args: TArgs, cb: LoginCallback]) => void, + loginArgs: TArgs, + callback: LoginCallback | undefined, + loginMethodTOTP: (...args: [...args: TArgs, code: string, cb: LoginCallback]) => void, +) => { + loginMethod(...loginArgs, async (error: LoginError | undefined, result?: unknown) => { if (!isTotpRequiredError(error)) { - callback(error); + callback?.(error); return; } + const { process2faReturn } = await import('./process2faReturn'); + await process2faReturn({ error, result, - emailOrUsername, + emailOrUsername: typeof loginArgs[0] === 'string' ? loginArgs[0] : undefined, originalCallback: callback, onCode: (code: string) => { - loginMethodTOTP?.call(null, ...loginArgs, code, (error: unknown) => { + loginMethodTOTP(...loginArgs, code, (error: LoginError | undefined, result?: unknown) => { + if (!error) { + callback?.(undefined, result); + return; + } + if (isTotpInvalidError(error)) { - dispatchToastMessage({ type: 'error', message: t('Invalid_two_factor_code') }); - callback(null); + callback?.(error); return; } - callback(error); + Promise.all([import('../../../app/utils/lib/i18n'), import('../toast')]).then(([{ t }, { dispatchToastMessage }]) => { + if (isTotpMaxAttemptsError(error)) { + dispatchToastMessage({ + type: 'error', + message: t('totp-max-attempts'), + }); + callback?.(undefined); + return; + } + + dispatchToastMessage({ type: 'error', message: t('Invalid_two_factor_code') }); + callback?.(undefined); + }); }); }, }); }); }; + +export const handleLogin = Promise>( + login: TLoginFunction, + loginWithTOTP: (...args: [...args: Parameters, code: string]) => ReturnType, +) => { + return (...args: [...loginArgs: Parameters, callback?: LoginCallback]) => { + const loginArgs = args.slice(0, -1) as Parameters; + const callback = args.slice(-1)[0] as LoginCallback | undefined; + + return login(...loginArgs) + .catch(async (error: LoginError | undefined) => { + if (!isTotpRequiredError(error)) { + return Promise.reject(error); + } + + const { process2faAsyncReturn } = await import('./process2faReturn'); + return process2faAsyncReturn({ + emailOrUsername: typeof loginArgs[0] === 'string' ? loginArgs[0] : undefined, + error, + onCode: (code: string) => loginWithTOTP(...loginArgs, code), + }); + }) + .then((result: unknown) => callback?.(undefined, result)) + .catch((error: LoginError | undefined) => { + if (!isTotpInvalidError(error)) { + callback?.(error); + return; + } + + Promise.all([import('../../../app/utils/lib/i18n'), import('../toast')]).then(([{ t }, { dispatchToastMessage }]) => { + dispatchToastMessage({ type: 'error', message: t('Invalid_two_factor_code') }); + callback?.(undefined); + }); + }); + }; +}; + +export const callLoginMethod = (options: Omit) => + new Promise((resolve, reject) => { + Accounts.callLoginMethod({ + ...options, + userCallback: (error) => { + if (error) { + reject(error); + } else { + resolve(); + } + }, + }); + }); diff --git a/apps/meteor/client/lib/2fa/process2faReturn.ts b/apps/meteor/client/lib/2fa/process2faReturn.ts index 95f7f1dcb361825bed6b072f8228ef0877f9456c..57a8d98b05b0af2a0001058084d445cf90503583 100644 --- a/apps/meteor/client/lib/2fa/process2faReturn.ts +++ b/apps/meteor/client/lib/2fa/process2faReturn.ts @@ -3,6 +3,7 @@ import { Meteor } from 'meteor/meteor'; import { lazy } from 'react'; import { imperativeModal } from '../imperativeModal'; +import type { LoginCallback } from './overrideLoginMethod'; import { isTotpInvalidError, isTotpRequiredError } from './utils'; const TwoFactorModal = lazy(() => import('../../components/TwoFactorModal')); @@ -35,6 +36,23 @@ function assertModalProps(props: { } } +const getProps = ( + method: 'totp' | 'email' | 'password', + emailOrUsername?: { username: string } | { email: string } | { id: string } | string, +) => { + switch (method) { + case 'totp': + return { method }; + case 'email': + return { + method, + emailOrUsername: typeof emailOrUsername === 'string' ? emailOrUsername : Meteor.user()?.username, + }; + case 'password': + return { method }; + } +}; + export async function process2faReturn({ error, result, @@ -42,23 +60,19 @@ export async function process2faReturn({ onCode, emailOrUsername, }: { - error: unknown; + error: globalThis.Error | Meteor.Error | Meteor.TypedError | undefined; result: unknown; - originalCallback: { - (error: unknown): void; - (error: unknown, result: unknown): void; - }; + originalCallback: LoginCallback | undefined; onCode: (code: string, method: string) => void; - emailOrUsername: string | null | undefined; + emailOrUsername: { username: string } | { email: string } | { id: string } | string | null | undefined; }): Promise { if (!(isTotpRequiredError(error) || isTotpInvalidError(error)) || !hasRequiredTwoFactorMethod(error)) { - originalCallback(error, result); + originalCallback?.(error, result); return; } const props = { - method: error.details.method, - emailOrUsername: emailOrUsername || error.details.emailOrUsername || Meteor.user()?.username, + ...getProps(error.details.method, emailOrUsername || error.details.emailOrUsername), // eslint-disable-next-line no-nested-ternary invalidAttempt: isTotpInvalidError(error), }; @@ -69,7 +83,7 @@ export async function process2faReturn({ onCode(code, props.method); } catch (error) { process2faReturn({ - error, + error: error as globalThis.Error | Meteor.Error | Meteor.TypedError | undefined, result, originalCallback, onCode, diff --git a/apps/meteor/client/lib/2fa/utils.ts b/apps/meteor/client/lib/2fa/utils.ts index 4abd3f436e60d27b409d04b679cb63ee7a16ee5b..ab2234f2e589a244965945057cf0eebb0dd39320 100644 --- a/apps/meteor/client/lib/2fa/utils.ts +++ b/apps/meteor/client/lib/2fa/utils.ts @@ -1,6 +1,3 @@ -import { Accounts } from 'meteor/accounts-base'; -import { Meteor } from 'meteor/meteor'; - export const isTotpRequiredError = ( error: unknown, ): error is Meteor.Error & ({ error: 'totp-required' } | { errorType: 'totp-required' }) => @@ -12,22 +9,8 @@ export const isTotpInvalidError = (error: unknown): error is Meteor.Error & ({ e (error as { error?: unknown } | undefined)?.error === 'totp-invalid' || (error as { errorType?: unknown } | undefined)?.errorType === 'totp-invalid'; -const isLoginCancelledError = (error: unknown): error is Meteor.Error => - error instanceof Meteor.Error && error.error === Accounts.LoginCancelledError.numericError; - -export const reportError = (error: T, callback?: (error?: T) => void): void => { - if (callback) { - callback(error); - return; - } - - throw error; -}; - -export const convertError = (error: T): Accounts.LoginCancelledError | T => { - if (isLoginCancelledError(error)) { - return new Accounts.LoginCancelledError(error.reason); - } - - return error; -}; +export const isTotpMaxAttemptsError = ( + error: unknown, +): error is Meteor.Error & ({ error: 'totp-max-attempts' } | { errorType: 'totp-max-attempts' }) => + (error as { error?: unknown } | undefined)?.error === 'totp-max-attempts' || + (error as { errorType?: unknown } | undefined)?.errorType === 'totp-max-attempts'; diff --git a/apps/meteor/client/lib/VideoConfManager.ts b/apps/meteor/client/lib/VideoConfManager.ts index 42aa92a7be0e4f0a1b867af85561caeeb0dc8518..7ae1c04db6df503eb3d2554d605ba1ae425e4688 100644 --- a/apps/meteor/client/lib/VideoConfManager.ts +++ b/apps/meteor/client/lib/VideoConfManager.ts @@ -3,7 +3,6 @@ import { Emitter } from '@rocket.chat/emitter'; import { Meteor } from 'meteor/meteor'; import { Tracker } from 'meteor/tracker'; -import { Notifications } from '../../app/notifications/client'; import { sdk } from '../../app/utils/client/lib/SDKClient'; import { getConfig } from './utils/getConfig'; @@ -507,14 +506,14 @@ export const VideoConfManager = new (class VideoConfManager extends Emitter { - return Notifications.notifyUser(uid, 'video-conference', { action, params }); + return sdk.publish('notify-user', [`${uid}/video-conference`, { action, params }]); } private async connectUser(userId: string): Promise { debug && console.log(`[VideoConf] connecting user ${userId}`); this.userId = userId; - const { stop, ready } = Notifications.onUser('video-conference', (data) => this.onVideoConfNotification(data)); + const { stop, ready } = sdk.stream('notify-user', [`${userId}/video-conference`], (data) => this.onVideoConfNotification(data)); await ready(); diff --git a/apps/meteor/client/lib/chats/ChatAPI.ts b/apps/meteor/client/lib/chats/ChatAPI.ts index 36f40f404a4f7981a2bc3d36d396cc19ee8a0299..d2364cfcd72caf45609a72a280e6fb073af08c24 100644 --- a/apps/meteor/client/lib/chats/ChatAPI.ts +++ b/apps/meteor/client/lib/chats/ChatAPI.ts @@ -127,8 +127,8 @@ export type ChatAPI = { | undefined; readonly userCard: { - open(username: string): (event: UIEvent) => void; - close(): void; + openUserCard(event: UIEvent, username: string): void; + closeUserCard(): void; }; readonly emojiPicker: { diff --git a/apps/meteor/client/lib/chats/flows/uploadFiles.ts b/apps/meteor/client/lib/chats/flows/uploadFiles.ts index 1411ad5a004ed69a2e461729c9b137c8502329e6..82572aa2dbf5c895d8cabceae1ab66f7113a49cb 100644 --- a/apps/meteor/client/lib/chats/flows/uploadFiles.ts +++ b/apps/meteor/client/lib/chats/flows/uploadFiles.ts @@ -46,7 +46,7 @@ export const uploadFiles = async (chat: ChatAPI, files: readonly File[], resetFi imperativeModal.close(); uploadNextFile(); }, - invalidContentType: Boolean(file.type && !fileUploadIsValidContentType(file.type)), + invalidContentType: !(file.type && fileUploadIsValidContentType(file.type)), }, }); }; diff --git a/apps/meteor/client/lib/loginServices.ts b/apps/meteor/client/lib/loginServices.ts new file mode 100644 index 0000000000000000000000000000000000000000..ad5ee926ccc787e4fda3711c92ddd236105d13f8 --- /dev/null +++ b/apps/meteor/client/lib/loginServices.ts @@ -0,0 +1,147 @@ +import type { LoginServiceConfiguration } from '@rocket.chat/core-typings'; +import { Emitter } from '@rocket.chat/emitter'; +import { capitalize } from '@rocket.chat/string-helpers'; +import type { LoginService } from '@rocket.chat/ui-contexts'; + +import { sdk } from '../../app/utils/client/lib/SDKClient'; + +type LoginServicesEvents = { + changed: undefined; + loaded: LoginServiceConfiguration[]; +}; + +type LoadState = 'loaded' | 'loading' | 'error' | 'none'; + +const maxRetries = 3; +const timeout = 10000; + +class LoginServices extends Emitter { + private retries = 0; + + private services: LoginServiceConfiguration[] = []; + + private serviceButtons: LoginService[] = []; + + private state: LoadState = 'none'; + + private config: Record> = { + 'apple': { title: 'Apple', icon: 'apple' }, + 'facebook': { title: 'Facebook', icon: 'facebook' }, + 'twitter': { title: 'Twitter', icon: 'twitter' }, + 'google': { title: 'Google', icon: 'google' }, + 'github': { title: 'Github', icon: 'github' }, + 'github_enterprise': { title: 'Github Enterprise', icon: 'github' }, + 'gitlab': { title: 'Gitlab', icon: 'gitlab' }, + 'dolphin': { title: 'Dolphin', icon: 'dophin' }, + 'drupal': { title: 'Drupal', icon: 'drupal' }, + 'nextcloud': { title: 'Nextcloud', icon: 'nextcloud' }, + 'tokenpass': { title: 'Tokenpass', icon: 'tokenpass' }, + 'meteor-developer': { title: 'Meteor', icon: 'meteor' }, + 'wordpress': { title: 'WordPress', icon: 'wordpress' }, + 'linkedin': { title: 'Linkedin', icon: 'linkedin' }, + }; + + private setServices(state: LoadState, services: LoginServiceConfiguration[]) { + this.services = services; + this.state = state; + + this.generateServiceButtons(); + + if (state === 'loaded') { + this.retries = 0; + this.emit('loaded', services); + } + } + + private generateServiceButtons(): void { + const filtered = this.services.filter((config) => !('showButton' in config) || config.showButton !== false) || []; + const sorted = filtered.sort(({ service: service1 }, { service: service2 }) => service1.localeCompare(service2)); + this.serviceButtons = sorted.map((service) => { + // Remove the appId attribute if present + const { appId: _, ...serviceData } = { + ...service, + appId: undefined, + }; + + // Get the hardcoded title and icon, or fallback to capitalizing the service name + const serviceConfig = this.config[service.service] || { + title: capitalize(service.service), + }; + + return { + ...serviceData, + ...serviceConfig, + }; + }); + + this.emit('changed'); + } + + public getLoginService = LoginServiceConfiguration>(serviceName: string): T | undefined { + if (!this.ready) { + return; + } + + return this.services.find(({ service }) => service === serviceName) as T | undefined; + } + + public async loadLoginService = LoginServiceConfiguration>( + serviceName: string, + ): Promise { + if (this.ready) { + return this.getLoginService(serviceName); + } + + return new Promise((resolve, reject) => { + this.onLoad(() => resolve(this.getLoginService(serviceName))); + + setTimeout(() => reject(new Error('LoadLoginService timeout')), timeout); + }); + } + + public get ready() { + return this.state === 'loaded'; + } + + public getLoginServiceButtons(): LoginService[] { + if (!this.ready) { + if (this.state === 'none') { + void this.loadServices(); + } + } + + return this.serviceButtons; + } + + public onLoad(callback: (services: LoginServiceConfiguration[]) => void) { + if (this.ready) { + return callback(this.services); + } + + void this.loadServices(); + this.once('loaded', callback); + } + + public async loadServices(): Promise { + if (this.state === 'error') { + if (this.retries >= maxRetries) { + return; + } + this.retries++; + } else if (this.state !== 'none') { + return; + } + + try { + this.state = 'loading'; + const { configurations } = await sdk.rest.get('/v1/service.configurations'); + + this.setServices('loaded', configurations); + } catch (e) { + this.setServices('error', []); + throw e; + } + } +} + +export const loginServices = new LoginServices(); diff --git a/apps/meteor/client/lib/openCASLoginPopup.ts b/apps/meteor/client/lib/openCASLoginPopup.ts new file mode 100644 index 0000000000000000000000000000000000000000..d82a48599e4bdc1bd9a34d368d1922ef0a0fe8d5 --- /dev/null +++ b/apps/meteor/client/lib/openCASLoginPopup.ts @@ -0,0 +1,62 @@ +import { Meteor } from 'meteor/meteor'; + +import { settings } from '../../app/settings/client'; + +const openCenteredPopup = (url: string, width: number, height: number) => { + const screenX = window.screenX ?? window.screenLeft; + const screenY = window.screenY ?? window.screenTop; + const outerWidth = window.outerWidth ?? document.body.clientWidth; + const outerHeight = window.outerHeight ?? document.body.clientHeight - 22; + // XXX what is the 22? Probably the height of the title bar. + // Use `outerWidth - width` and `outerHeight - height` for help in + // positioning the popup centered relative to the current window + const left = screenX + (outerWidth - width) / 2; + const top = screenY + (outerHeight - height) / 2; + const features = `width=${width},height=${height},left=${left},top=${top},scrollbars=yes`; + + const newwindow = window.open(url, 'Login', features); + + if (!newwindow) { + throw new Error('Could not open popup'); + } + + newwindow.focus(); + + return newwindow; +}; + +const getPopupUrl = (credentialToken: string): string => { + const loginUrl = settings.get('CAS_login_url'); + + if (!loginUrl) { + throw new Error('CAS_login_url not set'); + } + + const appUrl = Meteor.absoluteUrl().replace(/\/$/, '') + __meteor_runtime_config__.ROOT_URL_PATH_PREFIX; + const serviceUrl = `${appUrl}/_cas/${credentialToken}`; + const url = new URL(loginUrl); + url.searchParams.set('service', serviceUrl); + + return url.href; +}; + +const waitForPopupClose = (popup: Window) => { + return new Promise((resolve) => { + const checkPopupOpen = setInterval(() => { + if (popup.closed || popup.closed === undefined) { + clearInterval(checkPopupOpen); + resolve(); + } + }, 100); + }); +}; + +export const openCASLoginPopup = async (credentialToken: string) => { + const popupWidth = settings.get('CAS_popup_width') || 800; + const popupHeight = settings.get('CAS_popup_height') || 600; + + const popupUrl = getPopupUrl(credentialToken); + const popup = openCenteredPopup(popupUrl, popupWidth, popupHeight); + + await waitForPopupClose(popup); +}; diff --git a/apps/meteor/client/lib/parseMessageTextToAstMarkdown.ts b/apps/meteor/client/lib/parseMessageTextToAstMarkdown.ts index 15a88c847fcec5c610d22d9fa2e9e1cbefdbcbef..41cbfed9575060dd0817ad57b55602c0f59f116e 100644 --- a/apps/meteor/client/lib/parseMessageTextToAstMarkdown.ts +++ b/apps/meteor/client/lib/parseMessageTextToAstMarkdown.ts @@ -3,6 +3,7 @@ import { isFileAttachment, isE2EEMessage, isOTRMessage, + isOTRAckMessage, isQuoteAttachment, isTranslatedAttachment, isTranslatedMessage, @@ -46,7 +47,7 @@ export const parseMessageTextToAstMarkdown = < return { ...msg, md: - isE2EEMessage(message) || isOTRMessage(message) || translated + isE2EEMessage(message) || isOTRMessage(message) || isOTRAckMessage(message) || translated ? textToMessageToken(text, parseOptions) : msg.md ?? textToMessageToken(text, parseOptions), ...(msg.attachments && { diff --git a/apps/meteor/client/lib/portals/portalsSubscription.ts b/apps/meteor/client/lib/portals/portalsSubscription.ts index 24295d624936a97c91f6865116ed6ab50685dbfa..513393eb983a9029063ecc0a426127b510fef7c4 100644 --- a/apps/meteor/client/lib/portals/portalsSubscription.ts +++ b/apps/meteor/client/lib/portals/portalsSubscription.ts @@ -1,9 +1,9 @@ import { Emitter } from '@rocket.chat/emitter'; import { Random } from '@rocket.chat/random'; -import type { ReactElement } from 'react'; +import type { ReactPortal } from 'react'; type SubscribedPortal = { - portal: ReactElement; + portal: ReactPortal; key: string; }; @@ -11,7 +11,7 @@ type PortalsSubscription = { subscribe: (callback: () => void) => () => void; getSnapshot: () => SubscribedPortal[]; has: (key: unknown) => boolean; - set: (key: unknown, portal: ReactElement) => void; + set: (key: unknown, portal: ReactPortal) => void; delete: (key: unknown) => void; }; @@ -43,7 +43,7 @@ export const unregisterPortal = (key: unknown): void => { portalsSubscription.delete(key); }; -export const registerPortal = (key: unknown, portal: ReactElement): (() => void) => { +export const registerPortal = (key: unknown, portal: ReactPortal): (() => void) => { portalsSubscription.set(key, portal); return (): void => { unregisterPortal(key); diff --git a/apps/meteor/client/lib/presence.ts b/apps/meteor/client/lib/presence.ts index 8cb367bf1e52600321a483e9121262f91d41a3ad..dbaddcbe405b515960f1bd8dea7b7796cabda682 100644 --- a/apps/meteor/client/lib/presence.ts +++ b/apps/meteor/client/lib/presence.ts @@ -6,8 +6,6 @@ import { Meteor } from 'meteor/meteor'; import { sdk } from '../../app/utils/client/lib/SDKClient'; -export const STATUS_MAP = [UserStatus.OFFLINE, UserStatus.ONLINE, UserStatus.AWAY, UserStatus.BUSY, UserStatus.DISABLED]; - type InternalEvents = { remove: IUser['_id']; reset: undefined; diff --git a/apps/meteor/client/lib/settings/PrivateSettingsCachedCollection.ts b/apps/meteor/client/lib/settings/PrivateSettingsCachedCollection.ts index 560d604534ed10956a78f9957455880a4c0f0808..b0276e753922eed783eb10539ca0edc31f6e8137 100644 --- a/apps/meteor/client/lib/settings/PrivateSettingsCachedCollection.ts +++ b/apps/meteor/client/lib/settings/PrivateSettingsCachedCollection.ts @@ -1,18 +1,18 @@ import type { ISetting } from '@rocket.chat/core-typings'; -import { Notifications } from '../../../app/notifications/client'; import { CachedCollection } from '../../../app/ui-cached-collection/client'; +import { sdk } from '../../../app/utils/client/lib/SDKClient'; class PrivateSettingsCachedCollection extends CachedCollection { constructor() { super({ name: 'private-settings', - eventType: 'onLogged', + eventType: 'notify-logged', }); } async setupListener(): Promise { - Notifications.onLogged(this.eventName as 'private-settings-changed', async (t: string, { _id, ...record }: { _id: string }) => { + 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.sync(); diff --git a/apps/meteor/client/lib/settings/PublicSettingsCachedCollection.ts b/apps/meteor/client/lib/settings/PublicSettingsCachedCollection.ts index 7eab4b1dc7a9299b9cf1b0abedc7fce38b566243..c01523252f853176262c6d37e25083f1f41dddc8 100644 --- a/apps/meteor/client/lib/settings/PublicSettingsCachedCollection.ts +++ b/apps/meteor/client/lib/settings/PublicSettingsCachedCollection.ts @@ -6,7 +6,7 @@ class PublicSettingsCachedCollection extends CachedCollection { constructor() { super({ name: 'public-settings', - eventType: 'onAll', + eventType: 'notify-all', userRelated: false, }); } diff --git a/apps/meteor/client/lib/userData.ts b/apps/meteor/client/lib/userData.ts index 9e67f4034b1d2e4c146080bca2eee8a84fd33f7d..ee90f493a30ca339c56b24742d6a31626e6ffecc 100644 --- a/apps/meteor/client/lib/userData.ts +++ b/apps/meteor/client/lib/userData.ts @@ -2,7 +2,6 @@ import type { ILivechatAgent, IUser, Serialized } from '@rocket.chat/core-typing import { ReactiveVar } from 'meteor/reactive-var'; import { Users } from '../../app/models/client'; -import { Notifications } from '../../app/notifications/client'; import { sdk } from '../../app/utils/client/lib/SDKClient'; export const isSyncReady = new ReactiveVar(false); @@ -60,7 +59,7 @@ export const synchronizeUserData = async (uid: IUser['_id']): Promise { + const result = sdk.stream('notify-user', [`${uid}/userData`], (data) => { switch (data.type) { case 'inserted': // eslint-disable-next-line @typescript-eslint/no-unused-vars @@ -122,7 +121,7 @@ export const synchronizeUserData = async (uid: IUser['_id']): Promise ({ expire: new Date(expire), ...data })) || [], + ...(emailCode ? { ...emailCode, expire: new Date(emailCode.expire) } : {}), ...(email2fa ? { email2fa: { ...email2fa, changedAt: new Date(email2fa.changedAt) } } : {}), ...(email?.verificationTokens && { email: { diff --git a/apps/meteor/client/lib/userStatuses.ts b/apps/meteor/client/lib/userStatuses.ts new file mode 100644 index 0000000000000000000000000000000000000000..631c1d1ea044219ff3793f5cfb786b88040a30b1 --- /dev/null +++ b/apps/meteor/client/lib/userStatuses.ts @@ -0,0 +1,90 @@ +import { UserStatus } from '@rocket.chat/core-typings'; +import type { ICustomUserStatus } from '@rocket.chat/core-typings'; + +import { sdk } from '../../app/utils/client/lib/SDKClient'; + +export type UserStatusDescriptor = { + id: string; + name: string; + statusType: UserStatus; + localizeName: boolean; +}; + +export class UserStatuses implements Iterable { + public invisibleAllowed = true; + + private store: Map = new Map( + [UserStatus.ONLINE, UserStatus.AWAY, UserStatus.BUSY, UserStatus.OFFLINE].map((status) => [ + status, + { + id: status, + name: status, + statusType: status, + localizeName: true, + }, + ]), + ); + + public delete(id: string): void { + this.store.delete(id); + } + + public put(customUserStatus: UserStatusDescriptor): void { + this.store.set(customUserStatus.id, customUserStatus); + } + + public createFromCustom(customUserStatus: ICustomUserStatus): UserStatusDescriptor { + if (!this.isValidType(customUserStatus.statusType)) { + throw new Error('Invalid user status type'); + } + + return { + name: customUserStatus.name, + id: customUserStatus._id, + statusType: customUserStatus.statusType as UserStatus, + localizeName: false, + }; + } + + public isValidType(type: string): type is UserStatus { + return (Object.values(UserStatus) as string[]).includes(type); + } + + public *[Symbol.iterator]() { + for (const value of this.store.values()) { + if (this.invisibleAllowed || value.statusType !== UserStatus.OFFLINE) { + yield value; + } + } + } + + public async sync() { + const result = await sdk.call('listCustomUserStatus'); + if (!result) { + return; + } + + for (const customStatus of result) { + this.put(this.createFromCustom(customStatus)); + } + } + + public watch(cb?: () => void) { + const updateSubscription = sdk.stream('notify-logged', ['updateCustomUserStatus'], (data) => { + this.put(this.createFromCustom(data.userStatusData)); + cb?.(); + }); + + const deleteSubscription = sdk.stream('notify-logged', ['deleteCustomUserStatus'], (data) => { + this.delete(data.userStatusData._id); + cb?.(); + }); + + return () => { + updateSubscription.stop(); + deleteSubscription.stop(); + }; + } +} + +export const userStatuses = new UserStatuses(); diff --git a/apps/meteor/client/lib/utils/applyCustomTranslations.ts b/apps/meteor/client/lib/utils/applyCustomTranslations.ts deleted file mode 100644 index f629ed1aaaceeca71cd345f268e77f96092b3163..0000000000000000000000000000000000000000 --- a/apps/meteor/client/lib/utils/applyCustomTranslations.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { settings } from '../../../app/settings/client'; -import { i18n } from '../../../app/utils/lib/i18n'; - -const parseToJSON = (customTranslations: string) => { - try { - return JSON.parse(customTranslations); - } catch (e) { - return false; - } -}; - -export const applyCustomTranslations = (): void => { - const customTranslations: string | undefined = settings.get('Custom_Translations'); - - if (!customTranslations || !parseToJSON(customTranslations)) { - return; - } - - try { - const parsedCustomTranslations: Record = JSON.parse(customTranslations); - - for (const [lang, translations] of Object.entries(parsedCustomTranslations)) { - i18n.addResourceBundle(lang, 'core', translations); - } - } catch (e) { - console.error('Invalid setting Custom_Translations', e); - } -}; diff --git a/apps/meteor/client/lib/utils/getUidDirectMessage.ts b/apps/meteor/client/lib/utils/getUidDirectMessage.ts index cb5ef83c825f501e19883d957b3fb50d4b345bf5..761b849f78839d234fe11c8066b39e82058ed1c6 100644 --- a/apps/meteor/client/lib/utils/getUidDirectMessage.ts +++ b/apps/meteor/client/lib/utils/getUidDirectMessage.ts @@ -3,12 +3,12 @@ import { Meteor } from 'meteor/meteor'; import { ChatRoom } from '../../../app/models/client'; -export const getUidDirectMessage = (rid: IRoom['_id'], userId: IUser['_id'] | null = Meteor.userId()): string | undefined => { - const room = ChatRoom.findOne({ _id: rid }, { fields: { uids: 1 } }); +export const getUidDirectMessage = (rid: IRoom['_id'], uid: IUser['_id'] | null = Meteor.userId()): string | undefined => { + const room = ChatRoom.findOne({ _id: rid }, { fields: { t: 1, uids: 1 } }); - if (!room?.uids || room.uids.length > 2) { + if (!room || room.t !== 'd' || !room.uids || room.uids.length > 2) { return undefined; } - return room.uids.filter((uid) => uid !== userId)[0]; + return room.uids.filter((_uid) => _uid !== uid)[0]; }; diff --git a/apps/meteor/client/lib/utils/window.RocketChatDesktop.d.ts b/apps/meteor/client/lib/utils/window.RocketChatDesktop.d.ts deleted file mode 100644 index a7f625fc07058702648819cf6aed44ab73c5a9f5..0000000000000000000000000000000000000000 --- a/apps/meteor/client/lib/utils/window.RocketChatDesktop.d.ts +++ /dev/null @@ -1,14 +0,0 @@ -type OutlookEventsResponse = { status: 'success' | 'canceled' }; - -// eslint-disable-next-line @typescript-eslint/naming-convention -interface Window { - RocketChatDesktop: - | { - openInternalVideoChatWindow?: (url: string, options: { providerName: string | undefined }) => void; - getOutlookEvents?: (date: Date) => Promise; - setOutlookExchangeUrl?: (url: string, userId: string) => Promise; - hasOutlookCredentials?: () => Promise; - clearOutlookCredentials?: () => void; - } - | undefined; -} diff --git a/apps/meteor/client/lib/wrapRequestCredentialFn.ts b/apps/meteor/client/lib/wrapRequestCredentialFn.ts new file mode 100644 index 0000000000000000000000000000000000000000..12102187de30f8c2691d6f9bb89441f952e8adf1 --- /dev/null +++ b/apps/meteor/client/lib/wrapRequestCredentialFn.ts @@ -0,0 +1,52 @@ +import type { OAuthConfiguration } from '@rocket.chat/core-typings'; +import { Accounts } from 'meteor/accounts-base'; +import type { Meteor } from 'meteor/meteor'; +import { OAuth } from 'meteor/oauth'; + +import { loginServices } from './loginServices'; + +type RequestCredentialOptions = Meteor.LoginWithExternalServiceOptions; +type RequestCredentialCallback = (credentialTokenOrError?: string | Error) => void; + +type RequestCredentialConfig> = { + config: T; + loginStyle: string; + options: RequestCredentialOptions; + credentialRequestCompleteCallback?: RequestCredentialCallback; +}; + +export function wrapRequestCredentialFn>( + serviceName: string, + fn: (params: RequestCredentialConfig) => void, +) { + const wrapped = async ( + options: RequestCredentialOptions, + credentialRequestCompleteCallback?: RequestCredentialCallback, + ): Promise => { + const config = await loginServices.loadLoginService(serviceName); + if (!config) { + credentialRequestCompleteCallback?.(new Accounts.ConfigError()); + return; + } + + const loginStyle = OAuth._loginStyle(serviceName, config, options); + fn({ + config, + loginStyle, + options, + credentialRequestCompleteCallback, + }); + }; + + return ( + options?: RequestCredentialOptions | RequestCredentialCallback, + credentialRequestCompleteCallback?: RequestCredentialCallback, + ) => { + if (!credentialRequestCompleteCallback && typeof options === 'function') { + void wrapped({}, options); + return; + } + + void wrapped(options as RequestCredentialOptions, credentialRequestCompleteCallback); + }; +} diff --git a/apps/meteor/client/main.ts b/apps/meteor/client/main.ts index 3345159807874daaa732919e416ae8447076e9f9..0a35c44a10be95d7e84454402b14d506a8d653ed 100644 --- a/apps/meteor/client/main.ts +++ b/apps/meteor/client/main.ts @@ -1,3 +1,5 @@ +import './startup/accounts'; + import { FlowRouter } from 'meteor/kadira:flow-router'; FlowRouter.wait(); @@ -7,7 +9,7 @@ FlowRouter.notFound = { }; import('./polyfills') - .then(() => Promise.all([import('./lib/meteorCallWrapper'), import('../lib/oauthRedirectUriClient')])) + .then(() => import('./meteorOverrides')) .then(() => import('../ee/client/ecdh')) .then(() => import('./importPackages')) .then(() => Promise.all([import('./methods'), import('./startup')])) diff --git a/apps/meteor/client/lib/meteorCallWrapper.ts b/apps/meteor/client/meteorOverrides/ddpOverREST.ts similarity index 63% rename from apps/meteor/client/lib/meteorCallWrapper.ts rename to apps/meteor/client/meteorOverrides/ddpOverREST.ts index b5a2f8785a6907093be0da86583ecacb6f30c71c..9bd2021ec027853c1590451c1c2507373931d697 100644 --- a/apps/meteor/client/lib/meteorCallWrapper.ts +++ b/apps/meteor/client/meteorOverrides/ddpOverREST.ts @@ -6,7 +6,11 @@ import { sdk } from '../../app/utils/client/lib/SDKClient'; const bypassMethods: string[] = ['setUserStatus', 'logout']; -function shouldBypass({ method, params }: Meteor.IDDPMessage): boolean { +const shouldBypass = ({ msg, method, params }: Meteor.IDDPMessage): boolean => { + if (msg !== 'method') { + return true; + } + if (method === 'login' && params[0]?.resume) { return true; } @@ -20,14 +24,12 @@ function shouldBypass({ method, params }: Meteor.IDDPMessage): boolean { } return false; -} +}; -function wrapMeteorDDPCalls(): void { - const { _send } = Meteor.connection; - - Meteor.connection._send = function _DDPSendOverREST(message): void { - if (message.msg !== 'method' || shouldBypass(message)) { - return _send.call(Meteor.connection, message); +const withDDPOverREST = (_send: (this: Meteor.IMeteorConnection, message: Meteor.IDDPMessage) => void) => { + return function _sendOverREST(this: Meteor.IMeteorConnection, message: Meteor.IDDPMessage): void { + if (shouldBypass(message)) { + return _send.call(this, message); } const endpoint = Tracker.nonreactive(() => (!Meteor.userId() ? 'method.callAnon' : 'method.call')); @@ -36,19 +38,20 @@ function wrapMeteorDDPCalls(): void { message: DDPCommon.stringifyDDP({ ...message }), }; - const processResult = (_message: any): void => { + const processResult = (_message: string): void => { // Prevent error on reconnections and method retry. // On those cases the API will be called 2 times but // the handler will be deleted after the first execution. - if (!Meteor.connection._methodInvokers[message.id]) { + if (!this._methodInvokers[message.id]) { return; } - Meteor.connection._livedata_data({ + this._livedata_data({ msg: 'updated', methods: [message.id], }); - Meteor.connection.onMessage(_message); + this.onMessage(_message); }; + const method = encodeURIComponent(message.method.replace(/\//g, ':')); sdk.rest @@ -56,7 +59,7 @@ function wrapMeteorDDPCalls(): void { .then(({ message: _message }) => { processResult(_message); if (message.method === 'login') { - const parsedMessage = DDPCommon.parseDDP(_message as any) as { result?: { token?: string } }; + const parsedMessage = DDPCommon.parseDDP(_message) as { result?: { token?: string } }; if (parsedMessage.result?.token) { Meteor.loginWithToken(parsedMessage.result.token); } @@ -66,6 +69,8 @@ function wrapMeteorDDPCalls(): void { console.error(error); }); }; -} +}; -window.USE_REST_FOR_DDP_CALLS && wrapMeteorDDPCalls(); +if (window.USE_REST_FOR_DDP_CALLS) { + Meteor.connection._send = withDDPOverREST(Meteor.connection._send); +} diff --git a/apps/meteor/client/meteorOverrides/index.ts b/apps/meteor/client/meteorOverrides/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..9a1b0eb1f7be05b531d732b9bd59a5a62b2c068b --- /dev/null +++ b/apps/meteor/client/meteorOverrides/index.ts @@ -0,0 +1,16 @@ +import './ddpOverREST'; +import './totpOnCall'; +import './oauthRedirectUri'; +import './userAndUsers'; +import './login/cas'; +import './login/crowd'; +import './login/facebook'; +import './login/github'; +import './login/google'; +import './login/ldap'; +import './login/linkedin'; +import './login/meteorDeveloperAccount'; +import './login/oauth'; +import './login/password'; +import './login/saml'; +import './login/twitter'; diff --git a/apps/meteor/client/meteorOverrides/login/cas.ts b/apps/meteor/client/meteorOverrides/login/cas.ts new file mode 100644 index 0000000000000000000000000000000000000000..93a9f1d5b23655df38f23f7cab23fbdd8f15fa9e --- /dev/null +++ b/apps/meteor/client/meteorOverrides/login/cas.ts @@ -0,0 +1,20 @@ +import { Random } from '@rocket.chat/random'; +import { Meteor } from 'meteor/meteor'; + +import { callLoginMethod } from '../../lib/2fa/overrideLoginMethod'; + +declare module 'meteor/meteor' { + // eslint-disable-next-line @typescript-eslint/no-namespace + namespace Meteor { + function loginWithCas(_?: unknown, callback?: (err?: any) => void): void; + } +} + +Meteor.loginWithCas = (_, callback) => { + const credentialToken = Random.id(); + import('../../lib/openCASLoginPopup') + .then(({ openCASLoginPopup }) => openCASLoginPopup(credentialToken)) + .then(() => callLoginMethod({ methodArguments: [{ cas: { credentialToken } }] })) + .then(() => callback?.()) + .catch(callback); +}; diff --git a/apps/meteor/client/meteorOverrides/login/crowd.ts b/apps/meteor/client/meteorOverrides/login/crowd.ts new file mode 100644 index 0000000000000000000000000000000000000000..9b1d4b83d402572c232c7c6590dfcba3fa3039a3 --- /dev/null +++ b/apps/meteor/client/meteorOverrides/login/crowd.ts @@ -0,0 +1,49 @@ +import { Meteor } from 'meteor/meteor'; + +import { callLoginMethod, handleLogin, type LoginCallback } from '../../lib/2fa/overrideLoginMethod'; + +declare module 'meteor/meteor' { + // eslint-disable-next-line @typescript-eslint/no-namespace + namespace Meteor { + function loginWithCrowd( + userDescriptor: { username: string } | { email: string } | { id: string } | string, + password: string, + callback?: LoginCallback, + ): void; + } +} + +const loginWithCrowd = (userDescriptor: { username: string } | { email: string } | { id: string } | string, password: string) => { + const loginRequest = { + crowd: true, + username: userDescriptor, + crowdPassword: password, + }; + + return callLoginMethod({ methodArguments: [loginRequest] }); +}; + +const loginWithCrowdAndTOTP = ( + userDescriptor: { username: string } | { email: string } | { id: string } | string, + password: string, + code: string, +) => { + const loginRequest = { + crowd: true, + username: userDescriptor, + crowdPassword: password, + }; + + return callLoginMethod({ + methodArguments: [ + { + totp: { + login: loginRequest, + code, + }, + }, + ], + }); +}; + +Meteor.loginWithCrowd = handleLogin(loginWithCrowd, loginWithCrowdAndTOTP); diff --git a/apps/meteor/client/meteorOverrides/login/facebook.ts b/apps/meteor/client/meteorOverrides/login/facebook.ts new file mode 100644 index 0000000000000000000000000000000000000000..72a91775818e86289eab5a24c46da4425f259e4d --- /dev/null +++ b/apps/meteor/client/meteorOverrides/login/facebook.ts @@ -0,0 +1,56 @@ +import type { FacebookOAuthConfiguration } from '@rocket.chat/core-typings'; +import { Random } from '@rocket.chat/random'; +import { Facebook } from 'meteor/facebook-oauth'; +import { Meteor } from 'meteor/meteor'; +import { OAuth } from 'meteor/oauth'; + +import { overrideLoginMethod } from '../../lib/2fa/overrideLoginMethod'; +import { wrapRequestCredentialFn } from '../../lib/wrapRequestCredentialFn'; +import { createOAuthTotpLoginMethod } from './oauth'; + +const { loginWithFacebook } = Meteor; +const loginWithFacebookAndTOTP = createOAuthTotpLoginMethod(Facebook); +Meteor.loginWithFacebook = (options, callback) => { + overrideLoginMethod(loginWithFacebook, [options], callback, loginWithFacebookAndTOTP); +}; + +Facebook.requestCredential = wrapRequestCredentialFn( + 'facebook', + ({ config, loginStyle, options: requestOptions, credentialRequestCompleteCallback }) => { + const options = requestOptions as Meteor.LoginWithExternalServiceOptions & { + absoluteUrlOptions?: Record; + params?: Record; + auth_type?: string; + }; + + const credentialToken = Random.secret(); + const mobile = /Android|webOS|iPhone|iPad|iPod|BlackBerry|Windows Phone/i.test(navigator.userAgent); + const display = mobile ? 'touch' : 'popup'; + + const scope = options?.requestPermissions ? options.requestPermissions.join(',') : 'email'; + + const API_VERSION = Meteor.settings?.public?.packages?.['facebook-oauth']?.apiVersion || '17.0'; + + const loginUrlParameters: Record = { + client_id: config.appId, + redirect_uri: OAuth._redirectUri('facebook', config, options.params, options.absoluteUrlOptions), + display, + scope, + state: OAuth._stateParam(loginStyle, credentialToken, options?.redirectUrl), + // Handle authentication type (e.g. for force login you need auth_type: "reauthenticate") + ...(options.auth_type && { auth_type: options.auth_type }), + }; + + const loginUrl = `https://www.facebook.com/v${API_VERSION}/dialog/oauth?${Object.keys(loginUrlParameters) + .map((param) => `${encodeURIComponent(param)}=${encodeURIComponent(loginUrlParameters[param])}`) + .join('&')}`; + + OAuth.launchLogin({ + loginService: 'facebook', + loginStyle, + loginUrl, + credentialRequestCompleteCallback, + credentialToken, + }); + }, +); diff --git a/apps/meteor/client/meteorOverrides/login/github.ts b/apps/meteor/client/meteorOverrides/login/github.ts new file mode 100644 index 0000000000000000000000000000000000000000..2a1aa3903317f7c73381af1ebf3574ee38ad2500 --- /dev/null +++ b/apps/meteor/client/meteorOverrides/login/github.ts @@ -0,0 +1,42 @@ +import { Random } from '@rocket.chat/random'; +import { Accounts } from 'meteor/accounts-base'; +import { Github } from 'meteor/github-oauth'; +import { Meteor } from 'meteor/meteor'; +import { OAuth } from 'meteor/oauth'; + +import { overrideLoginMethod } from '../../lib/2fa/overrideLoginMethod'; +import { wrapRequestCredentialFn } from '../../lib/wrapRequestCredentialFn'; +import { createOAuthTotpLoginMethod } from './oauth'; + +const { loginWithGithub } = Meteor; +const loginWithGithubAndTOTP = createOAuthTotpLoginMethod(Github); +Meteor.loginWithGithub = (options, callback) => { + overrideLoginMethod(loginWithGithub, [options], callback, loginWithGithubAndTOTP); +}; + +Github.requestCredential = wrapRequestCredentialFn('github', ({ config, loginStyle, options, credentialRequestCompleteCallback }) => { + const credentialToken = Random.secret(); + const scope = options?.requestPermissions || ['user:email']; + const flatScope = scope.map(encodeURIComponent).join('+'); + + let allowSignup = ''; + if (Accounts._options?.forbidClientAccountCreation) { + allowSignup = '&allow_signup=false'; + } + + const loginUrl = + `https://github.com/login/oauth/authorize` + + `?client_id=${config.clientId}` + + `&scope=${flatScope}` + + `&redirect_uri=${OAuth._redirectUri('github', config)}` + + `&state=${OAuth._stateParam(loginStyle, credentialToken, options.redirectUrl)}${allowSignup}`; + + OAuth.launchLogin({ + loginService: 'github', + loginStyle, + loginUrl, + credentialRequestCompleteCallback, + credentialToken, + popupOptions: { width: 900, height: 450 }, + }); +}); diff --git a/apps/meteor/client/meteorOverrides/login/google.ts b/apps/meteor/client/meteorOverrides/login/google.ts new file mode 100644 index 0000000000000000000000000000000000000000..2742cade15d2ccf4598b86b3f1f9c80fd9af0cc9 --- /dev/null +++ b/apps/meteor/client/meteorOverrides/login/google.ts @@ -0,0 +1,126 @@ +import { Random } from '@rocket.chat/random'; +import { Accounts } from 'meteor/accounts-base'; +import { Google } from 'meteor/google-oauth'; +import { Meteor } from 'meteor/meteor'; +import { OAuth } from 'meteor/oauth'; + +import { overrideLoginMethod, type LoginCallback } from '../../lib/2fa/overrideLoginMethod'; +import { wrapRequestCredentialFn } from '../../lib/wrapRequestCredentialFn'; +import { createOAuthTotpLoginMethod } from './oauth'; + +declare module 'meteor/accounts-base' { + // eslint-disable-next-line @typescript-eslint/no-namespace + namespace Accounts { + export const _options: { + restrictCreationByEmailDomain?: string | (() => string); + forbidClientAccountCreation?: boolean | undefined; + }; + } +} + +declare module 'meteor/meteor' { + // eslint-disable-next-line @typescript-eslint/no-namespace + namespace Meteor { + function loginWithGoogle( + options?: + | Meteor.LoginWithExternalServiceOptions & { + loginUrlParameters?: { + include_granted_scopes?: boolean; + hd?: string; + }; + }, + callback?: LoginCallback, + ): void; + } +} + +const { loginWithGoogle } = Meteor; + +const innerLoginWithGoogleAndTOTP = createOAuthTotpLoginMethod(Google); + +const loginWithGoogleAndTOTP = ( + options: + | (Meteor.LoginWithExternalServiceOptions & { + loginUrlParameters?: { + include_granted_scopes?: boolean; + hd?: string; + }; + }) + | undefined, + code: string, + callback?: LoginCallback, +) => { + if (Meteor.isCordova && Google.signIn) { + // After 20 April 2017, Google OAuth login will no longer work from + // a WebView, so Cordova apps must use Google Sign-In instead. + // https://github.com/meteor/meteor/issues/8253 + Google.signIn(options, callback); + return; + } // Use Google's domain-specific login page if we want to restrict creation to + + // a particular email domain. (Don't use it if restrictCreationByEmailDomain + // is a function.) Note that all this does is change Google's UI --- + // accounts-base/accounts_server.js still checks server-side that the server + // has the proper email address after the OAuth conversation. + if (typeof Accounts._options.restrictCreationByEmailDomain === 'string') { + options = Object.assign({}, options || {}); + options.loginUrlParameters = Object.assign({}, options.loginUrlParameters || {}); + options.loginUrlParameters.hd = Accounts._options.restrictCreationByEmailDomain; + } + + innerLoginWithGoogleAndTOTP(options, code, callback); +}; + +Meteor.loginWithGoogle = (options, callback) => { + overrideLoginMethod(loginWithGoogle, [options], callback, loginWithGoogleAndTOTP); +}; + +Google.requestCredential = wrapRequestCredentialFn( + 'google', + ({ config, loginStyle, options: requestOptions, credentialRequestCompleteCallback }) => { + const credentialToken = Random.secret(); + const options = requestOptions as Meteor.LoginWithExternalServiceOptions & { + loginUrlParameters?: { + include_granted_scopes?: boolean; + hd?: string; + }; + prompt?: string; + }; + + const scope = ['email', ...(options.requestPermissions || ['profile'])].join(' '); + + const loginUrlParameters: Record = { + ...options.loginUrlParameters, + ...(options.requestOfflineToken !== undefined && { + access_type: options.requestOfflineToken ? 'offline' : 'online', + }), + ...((options.prompt || options.forceApprovalPrompt) && { prompt: options.prompt || 'consent' }), + ...(options.loginHint && { login_hint: options.loginHint }), + response_type: 'code', + client_id: config.clientId, + scope, + redirect_uri: OAuth._redirectUri('google', config), + state: OAuth._stateParam(loginStyle, credentialToken, options.redirectUrl), + }; + + Object.assign(loginUrlParameters, { + response_type: 'code', + client_id: config.clientId, + scope, + redirect_uri: OAuth._redirectUri('google', config), + state: OAuth._stateParam(loginStyle, credentialToken, options.redirectUrl), + }); + const loginUrl = `https://accounts.google.com/o/oauth2/auth?${Object.keys(loginUrlParameters) + .map((param) => `${encodeURIComponent(param)}=${encodeURIComponent(loginUrlParameters[param])}`) + .join('&')}`; + + OAuth.launchLogin({ + loginService: 'google', + loginStyle, + loginUrl, + credentialRequestCompleteCallback, + credentialToken, + popupOptions: { height: 600 }, + }); + }, +); diff --git a/apps/meteor/client/meteorOverrides/login/ldap.ts b/apps/meteor/client/meteorOverrides/login/ldap.ts new file mode 100644 index 0000000000000000000000000000000000000000..77a16ce3675d46b72d2fef85a3b516cc54978362 --- /dev/null +++ b/apps/meteor/client/meteorOverrides/login/ldap.ts @@ -0,0 +1,52 @@ +import { Meteor } from 'meteor/meteor'; + +import { callLoginMethod, handleLogin, type LoginCallback } from '../../lib/2fa/overrideLoginMethod'; + +declare module 'meteor/meteor' { + // eslint-disable-next-line @typescript-eslint/no-namespace + namespace Meteor { + function loginWithLDAP( + username: string | { username: string } | { email: string } | { id: string }, + ldapPass: string, + callback?: LoginCallback, + ): void; + } +} + +const loginWithLDAP = (username: string | { username: string } | { email: string } | { id: string }, ldapPass: string) => + callLoginMethod({ + methodArguments: [ + { + ldap: true, + username, + ldapPass, + ldapOptions: {}, + }, + ], + }); + +const loginWithLDAPAndTOTP = ( + username: string | { username: string } | { email: string } | { id: string }, + ldapPass: string, + code: string, +) => { + const loginRequest = { + ldap: true, + username, + ldapPass, + ldapOptions: {}, + }; + + return callLoginMethod({ + methodArguments: [ + { + totp: { + login: loginRequest, + code, + }, + }, + ], + }); +}; + +Meteor.loginWithLDAP = handleLogin(loginWithLDAP, loginWithLDAPAndTOTP); diff --git a/apps/meteor/client/meteorOverrides/login/linkedin.ts b/apps/meteor/client/meteorOverrides/login/linkedin.ts new file mode 100644 index 0000000000000000000000000000000000000000..a10b8182feec367284e0a5687a23910b2024adf5 --- /dev/null +++ b/apps/meteor/client/meteorOverrides/login/linkedin.ts @@ -0,0 +1,48 @@ +import type { LinkedinOAuthConfiguration } from '@rocket.chat/core-typings'; +import { Random } from '@rocket.chat/random'; +import { Meteor } from 'meteor/meteor'; +import { OAuth } from 'meteor/oauth'; +import { Linkedin } from 'meteor/pauli:linkedin-oauth'; + +import type { LoginCallback } from '../../lib/2fa/overrideLoginMethod'; +import { overrideLoginMethod } from '../../lib/2fa/overrideLoginMethod'; +import { wrapRequestCredentialFn } from '../../lib/wrapRequestCredentialFn'; +import { createOAuthTotpLoginMethod } from './oauth'; + +declare module 'meteor/meteor' { + // eslint-disable-next-line @typescript-eslint/no-namespace + namespace Meteor { + function loginWithLinkedin(options?: Meteor.LoginWithExternalServiceOptions, callback?: LoginCallback): void; + } +} +const { loginWithLinkedin } = Meteor; +const loginWithLinkedinAndTOTP = createOAuthTotpLoginMethod(Linkedin); +Meteor.loginWithLinkedin = (options, callback) => { + overrideLoginMethod(loginWithLinkedin, [options], callback, loginWithLinkedinAndTOTP); +}; + +Linkedin.requestCredential = wrapRequestCredentialFn( + 'linkedin', + ({ options, credentialRequestCompleteCallback, config, loginStyle }) => { + const credentialToken = Random.secret(); + + const { requestPermissions } = options; + const scope = (requestPermissions || ['openid', 'email', 'profile']).join('+'); + + const loginUrl = `https://www.linkedin.com/uas/oauth2/authorization?response_type=code&client_id=${ + config.clientId + }&redirect_uri=${OAuth._redirectUri('linkedin', config)}&state=${OAuth._stateParam(loginStyle, credentialToken)}&scope=${scope}`; + + OAuth.launchLogin({ + credentialRequestCompleteCallback, + credentialToken, + loginService: 'linkedin', + loginStyle, + loginUrl, + popupOptions: { + width: 390, + height: 628, + }, + }); + }, +); diff --git a/apps/meteor/client/meteorOverrides/login/meteorDeveloperAccount.ts b/apps/meteor/client/meteorOverrides/login/meteorDeveloperAccount.ts new file mode 100644 index 0000000000000000000000000000000000000000..56823fee6b6a033fd37311dfaa9c81065acbaab8 --- /dev/null +++ b/apps/meteor/client/meteorOverrides/login/meteorDeveloperAccount.ts @@ -0,0 +1,43 @@ +import { Meteor } from 'meteor/meteor'; +import { MeteorDeveloperAccounts } from 'meteor/meteor-developer-oauth'; +import { OAuth } from 'meteor/oauth'; + +import { overrideLoginMethod } from '../../lib/2fa/overrideLoginMethod'; +import { wrapRequestCredentialFn } from '../../lib/wrapRequestCredentialFn'; +import { createOAuthTotpLoginMethod } from './oauth'; + +const { loginWithMeteorDeveloperAccount } = Meteor; +const loginWithMeteorDeveloperAccountAndTOTP = createOAuthTotpLoginMethod(MeteorDeveloperAccounts); +Meteor.loginWithMeteorDeveloperAccount = (options, callback) => { + overrideLoginMethod(loginWithMeteorDeveloperAccount, [options], callback, loginWithMeteorDeveloperAccountAndTOTP); +}; + +MeteorDeveloperAccounts.requestCredential = wrapRequestCredentialFn( + 'meteor-developer', + ({ config, loginStyle, options: requestOptions, credentialRequestCompleteCallback }) => { + const options = requestOptions as Record; + + const credentialToken = Random.secret(); + + let loginUrl = + `${MeteorDeveloperAccounts._server}/oauth2/authorize?` + + `state=${OAuth._stateParam(loginStyle, credentialToken, options.redirectUrl)}` + + `&response_type=code&` + + `client_id=${config.clientId}${options.details ? `&details=${options.details}` : ''}`; + + if (options.loginHint) { + loginUrl += `&user_email=${encodeURIComponent(options.loginHint)}`; + } + + loginUrl += `&redirect_uri=${OAuth._redirectUri('meteor-developer', config)}`; + + OAuth.launchLogin({ + loginService: 'meteor-developer', + loginStyle, + loginUrl, + credentialRequestCompleteCallback, + credentialToken, + popupOptions: { width: 497, height: 749 }, + }); + }, +); diff --git a/apps/meteor/client/meteorOverrides/login/oauth.ts b/apps/meteor/client/meteorOverrides/login/oauth.ts new file mode 100644 index 0000000000000000000000000000000000000000..a3f9d72c9cbf85f6b896542f6fd4c6662f3f7755 --- /dev/null +++ b/apps/meteor/client/meteorOverrides/login/oauth.ts @@ -0,0 +1,127 @@ +import { Accounts } from 'meteor/accounts-base'; +import { Meteor } from 'meteor/meteor'; +import { OAuth } from 'meteor/oauth'; + +import type { IOAuthProvider } from '../../definitions/IOAuthProvider'; +import type { LoginCallback } from '../../lib/2fa/overrideLoginMethod'; + +const isLoginCancelledError = (error: unknown): error is Meteor.Error => + error instanceof Meteor.Error && error.error === Accounts.LoginCancelledError.numericError; + +export const convertError = (error: T): Accounts.LoginCancelledError | T => { + if (isLoginCancelledError(error)) { + return new Accounts.LoginCancelledError(error.reason); + } + + return error; +}; + +let lastCredentialToken: string | null = null; +let lastCredentialSecret: string | null | undefined = null; + +const meteorOAuthRetrieveCredentialSecret = OAuth._retrieveCredentialSecret; +OAuth._retrieveCredentialSecret = (credentialToken: string): string | null => { + let secret = meteorOAuthRetrieveCredentialSecret.call(OAuth, credentialToken); + if (!secret) { + const localStorageKey = `${OAuth._storageTokenPrefix}${credentialToken}`; + secret = localStorage.getItem(localStorageKey); + localStorage.removeItem(localStorageKey); + } + + return secret; +}; + +const tryLoginAfterPopupClosed = ( + credentialToken: string, + callback?: (error?: globalThis.Error | Meteor.Error | Meteor.TypedError) => void, + totpCode?: string, + credentialSecret?: string | null, +) => { + credentialSecret = credentialSecret || OAuth._retrieveCredentialSecret(credentialToken) || null; + const methodArgument = { + oauth: { + credentialToken, + credentialSecret, + }, + ...(typeof totpCode === 'string' && + !!totpCode && { + totp: { + code: totpCode, + }, + }), + }; + + lastCredentialToken = credentialToken; + lastCredentialSecret = credentialSecret; + + if (typeof totpCode === 'string' && !!totpCode) { + methodArgument.totp = { + code: totpCode, + }; + } + + Accounts.callLoginMethod({ + methodArguments: [methodArgument], + userCallback: (err) => { + callback?.(convertError(err)); + }, + }); +}; + +const credentialRequestCompleteHandler = + (callback?: (error?: globalThis.Error | Meteor.Error | Meteor.TypedError) => void, totpCode?: string) => + (credentialTokenOrError?: string | globalThis.Error | Meteor.Error | Meteor.TypedError) => { + if (!credentialTokenOrError) { + callback?.(new Meteor.Error('No credential token passed')); + return; + } + + if (credentialTokenOrError instanceof Error) { + callback?.(credentialTokenOrError); + return; + } + + tryLoginAfterPopupClosed(credentialTokenOrError, callback, totpCode); + }; + +export const createOAuthTotpLoginMethod = + (provider: IOAuthProvider) => (options: Meteor.LoginWithExternalServiceOptions | undefined, code: string, callback?: LoginCallback) => { + if (lastCredentialToken && lastCredentialSecret) { + tryLoginAfterPopupClosed(lastCredentialToken, callback, code, lastCredentialSecret); + } else { + const credentialRequestCompleteCallback = credentialRequestCompleteHandler(callback, code); + provider.requestCredential(options, credentialRequestCompleteCallback); + } + + lastCredentialToken = null; + lastCredentialSecret = null; + }; + +Accounts.oauth.credentialRequestCompleteHandler = credentialRequestCompleteHandler; + +Accounts.onPageLoadLogin(async (loginAttempt: any) => { + if (loginAttempt?.error?.error !== 'totp-required') { + return; + } + + const { methodArguments } = loginAttempt; + if (!methodArguments?.length) { + return; + } + + const oAuthArgs = methodArguments.find((arg: any) => arg.oauth); + const { credentialToken, credentialSecret } = oAuthArgs.oauth; + const cb = loginAttempt.userCallback; + + const { process2faReturn } = await import('../../lib/2fa/process2faReturn'); + + await process2faReturn({ + error: loginAttempt.error, + originalCallback: cb, + onCode: (code) => { + tryLoginAfterPopupClosed(credentialToken, cb, code, credentialSecret); + }, + emailOrUsername: undefined, + result: undefined, + }); +}); diff --git a/apps/meteor/client/meteorOverrides/login/password.ts b/apps/meteor/client/meteorOverrides/login/password.ts new file mode 100644 index 0000000000000000000000000000000000000000..f1c6e32f2282b0ff0477c6291aba6f5d72916eb7 --- /dev/null +++ b/apps/meteor/client/meteorOverrides/login/password.ts @@ -0,0 +1,67 @@ +import { Accounts } from 'meteor/accounts-base'; +import { Meteor } from 'meteor/meteor'; + +import { overrideLoginMethod, type LoginCallback } from '../../lib/2fa/overrideLoginMethod'; + +declare module 'meteor/meteor' { + // eslint-disable-next-line @typescript-eslint/no-namespace + namespace Meteor { + function loginWithPassword( + userDescriptor: { username: string } | { email: string } | { id: string } | string, + password: string, + callback?: LoginCallback, + ): void; + } +} + +const loginWithPasswordAndTOTP = ( + userDescriptor: { username: string } | { email: string } | { id: string } | string, + password: string, + code: string, + callback?: LoginCallback, +) => { + if (typeof userDescriptor === 'string') { + if (userDescriptor.indexOf('@') === -1) { + userDescriptor = { username: userDescriptor }; + } else { + userDescriptor = { email: userDescriptor }; + } + } + + Accounts.callLoginMethod({ + methodArguments: [ + { + totp: { + login: { + user: userDescriptor, + password: Accounts._hashPassword(password), + }, + code, + }, + }, + ], + userCallback(error) { + if (!error) { + callback?.(undefined); + return; + } + + if (callback) { + callback(error); + return; + } + + throw error; + }, + }); +}; + +const { loginWithPassword } = Meteor; + +Meteor.loginWithPassword = ( + userDescriptor: { username: string } | { email: string } | { id: string } | string, + password: string, + callback?: LoginCallback, +) => { + overrideLoginMethod(loginWithPassword, [userDescriptor, password], callback, loginWithPasswordAndTOTP); +}; diff --git a/apps/meteor/client/meteorOverrides/login/saml.ts b/apps/meteor/client/meteorOverrides/login/saml.ts new file mode 100644 index 0000000000000000000000000000000000000000..f3ff7a8d45dd5d5b2f2ae3c1f6f3b279e5cead7d --- /dev/null +++ b/apps/meteor/client/meteorOverrides/login/saml.ts @@ -0,0 +1,114 @@ +import { Random } from '@rocket.chat/random'; +import { Accounts } from 'meteor/accounts-base'; +import { Meteor } from 'meteor/meteor'; + +import { settings } from '../../../app/settings/client'; +import { type LoginCallback, callLoginMethod, handleLogin } from '../../lib/2fa/overrideLoginMethod'; + +declare module 'meteor/meteor' { + // eslint-disable-next-line @typescript-eslint/no-namespace + namespace Meteor { + function loginWithSamlToken(credentialToken: string, callback?: LoginCallback): void; + + function loginWithSaml(options: { provider: string; credentialToken?: string }): void; + } +} + +declare module 'meteor/accounts-base' { + // eslint-disable-next-line @typescript-eslint/no-namespace + namespace Accounts { + export let saml: { + credentialToken?: string; + credentialSecret?: string; + }; + } +} + +declare module 'meteor/service-configuration' { + // eslint-disable-next-line @typescript-eslint/naming-convention + interface Configuration { + logoutBehaviour?: 'SAML' | 'Local'; + idpSLORedirectURL?: string; + } +} + +if (!Accounts.saml) { + Accounts.saml = {}; +} + +const { logout } = Meteor; + +Meteor.logout = async function (...args) { + const standardLogout = () => logout.apply(Meteor, args); + + if (!settings.get('SAML_Custom_Default')) { + return standardLogout(); + } + + if (settings.get('SAML_Custom_Default_logout_behaviour') === 'Local') { + console.info('SAML session not terminated, only the Rocket.Chat session is going to be killed'); + return standardLogout(); + } + + const provider = settings.get('SAML_Custom_Default_provider'); + + if (provider && settings.get('SAML_Custom_Default_idp_slo_redirect_url')) { + console.info('SAML session terminated via SLO'); + + const { sdk } = await import('../../../app/utils/client/lib/SDKClient'); + sdk + .call('samlLogout', provider) + .then((result) => { + if (!result) { + logout.apply(Meteor); + return; + } + + // Remove the userId from the client to prevent calls to the server while the logout is processed. + // If the logout fails, the userId will be reloaded on the resume call + Meteor._localStorage.removeItem(Accounts.USER_ID_KEY); + + // A nasty bounce: 'result' has the SAML LogoutRequest but we need a proper 302 to redirected from the server. + window.location.replace(Meteor.absoluteUrl(`_saml/sloRedirect/${provider}/?redirect=${encodeURIComponent(result)}`)); + }) + .catch(() => logout.apply(Meteor)); + return; + } + + return standardLogout(); +}; + +Meteor.loginWithSaml = (options) => { + options = options || {}; + const credentialToken = `id-${Random.id()}`; + options.credentialToken = credentialToken; + + window.location.href = `_saml/authorize/${options.provider}/${options.credentialToken}`; +}; + +const loginWithSamlToken = (credentialToken: string) => + callLoginMethod({ + methodArguments: [ + { + saml: true, + credentialToken, + }, + ], + }); + +const loginWithSamlTokenAndTOTP = (credentialToken: string, code: string) => + callLoginMethod({ + methodArguments: [ + { + totp: { + login: { + saml: true, + credentialToken, + }, + code, + }, + }, + ], + }); + +Meteor.loginWithSamlToken = handleLogin(loginWithSamlToken, loginWithSamlTokenAndTOTP); diff --git a/apps/meteor/client/meteorOverrides/login/twitter.ts b/apps/meteor/client/meteorOverrides/login/twitter.ts new file mode 100644 index 0000000000000000000000000000000000000000..e19ce234e5e91b68eade71d2033ce7da023abbbe --- /dev/null +++ b/apps/meteor/client/meteorOverrides/login/twitter.ts @@ -0,0 +1,56 @@ +import type { TwitterOAuthConfiguration } from '@rocket.chat/core-typings'; +import { Random } from '@rocket.chat/random'; +import { Meteor } from 'meteor/meteor'; +import { OAuth } from 'meteor/oauth'; +import { Twitter } from 'meteor/twitter-oauth'; + +import { overrideLoginMethod } from '../../lib/2fa/overrideLoginMethod'; +import { wrapRequestCredentialFn } from '../../lib/wrapRequestCredentialFn'; +import { createOAuthTotpLoginMethod } from './oauth'; + +const { loginWithTwitter } = Meteor; +const loginWithTwitterAndTOTP = createOAuthTotpLoginMethod(Twitter); +Meteor.loginWithTwitter = (options, callback) => { + overrideLoginMethod(loginWithTwitter, [options], callback, loginWithTwitterAndTOTP); +}; + +Twitter.requestCredential = wrapRequestCredentialFn( + 'twitter', + ({ loginStyle, options: requestOptions, credentialRequestCompleteCallback }) => { + const options = requestOptions as Record; + const credentialToken = Random.secret(); + + let loginPath = `_oauth/twitter/?requestTokenAndRedirect=true&state=${OAuth._stateParam( + loginStyle, + credentialToken, + options?.redirectUrl, + )}`; + + if (Meteor.isCordova) { + loginPath += '&cordova=true'; + if (/Android/i.test(navigator.userAgent)) { + loginPath += '&android=true'; + } + } + + // Support additional, permitted parameters + if (options) { + const hasOwn = Object.prototype.hasOwnProperty; + Twitter.validParamsAuthenticate.forEach((param: string) => { + if (hasOwn.call(options, param)) { + loginPath += `&${param}=${encodeURIComponent(options[param])}`; + } + }); + } + + const loginUrl = Meteor.absoluteUrl(loginPath); + + OAuth.launchLogin({ + loginService: 'twitter', + loginStyle, + loginUrl, + credentialRequestCompleteCallback, + credentialToken, + }); + }, +); diff --git a/apps/meteor/lib/oauthRedirectUriClient.ts b/apps/meteor/client/meteorOverrides/oauthRedirectUri.ts similarity index 80% rename from apps/meteor/lib/oauthRedirectUriClient.ts rename to apps/meteor/client/meteorOverrides/oauthRedirectUri.ts index cb5581210432be7ffbdb771460cb4b6e34a953d4..23f53acfe1d7778acc3c662c58e0390d9d924492 100644 --- a/apps/meteor/lib/oauthRedirectUriClient.ts +++ b/apps/meteor/client/meteorOverrides/oauthRedirectUri.ts @@ -1,5 +1,12 @@ import { OAuth } from 'meteor/oauth'; +declare module 'meteor/oauth' { + // eslint-disable-next-line @typescript-eslint/no-namespace + namespace OAuth { + function _redirectUri(serviceName: string, config: any, params: any, absoluteUrlOptions: any): string; + } +} + const { _redirectUri } = OAuth; OAuth._redirectUri = (serviceName: string, config: any, params: unknown, absoluteUrlOptions: unknown): string => { diff --git a/apps/meteor/client/meteorOverrides/totpOnCall.ts b/apps/meteor/client/meteorOverrides/totpOnCall.ts new file mode 100644 index 0000000000000000000000000000000000000000..247b3897842fe94e19020ee5453c269649954055 --- /dev/null +++ b/apps/meteor/client/meteorOverrides/totpOnCall.ts @@ -0,0 +1,63 @@ +import { Meteor } from 'meteor/meteor'; + +import { t } from '../../app/utils/lib/i18n'; +import type { LoginCallback } from '../lib/2fa/overrideLoginMethod'; +import { process2faReturn, process2faAsyncReturn } from '../lib/2fa/process2faReturn'; +import { isTotpInvalidError } from '../lib/2fa/utils'; + +const withSyncTOTP = (call: (name: string, ...args: any[]) => any) => { + const callWithTotp = + (methodName: string, args: unknown[], callback: LoginCallback) => + (twoFactorCode: string, twoFactorMethod: string): unknown => + call( + methodName, + ...args, + { twoFactorCode, twoFactorMethod }, + (error: globalThis.Error | Meteor.Error | Meteor.TypedError | undefined, result: unknown): void => { + if (isTotpInvalidError(error)) { + callback(new Error(twoFactorMethod === 'password' ? t('Invalid_password') : t('Invalid_two_factor_code'))); + return; + } + + callback(error, result); + }, + ); + + const callWithoutTotp = (methodName: string, args: unknown[], callback: LoginCallback) => (): unknown => + call( + methodName, + ...args, + async (error: globalThis.Error | Meteor.Error | Meteor.TypedError | undefined, result: unknown): Promise => { + await process2faReturn({ + error, + result, + onCode: callWithTotp(methodName, args, callback), + originalCallback: callback, + emailOrUsername: undefined, + }); + }, + ); + + return function (methodName: string, ...args: unknown[]): unknown { + const callback = args.length > 0 && typeof args[args.length - 1] === 'function' ? (args.pop() as LoginCallback) : (): void => undefined; + + return callWithoutTotp(methodName, args, callback)(); + }; +}; + +const withAsyncTOTP = (callAsync: (name: string, ...args: any[]) => Promise) => { + return async function callAsyncWithTOTP(methodName: string, ...args: unknown[]): Promise { + try { + return await callAsync(methodName, ...args); + } catch (error: unknown) { + return process2faAsyncReturn({ + error, + onCode: (twoFactorCode, twoFactorMethod) => Meteor.callAsync(methodName, ...args, { twoFactorCode, twoFactorMethod }), + emailOrUsername: undefined, + }); + } + }; +}; + +Meteor.call = withSyncTOTP(Meteor.call); +Meteor.callAsync = withAsyncTOTP(Meteor.callAsync); diff --git a/apps/meteor/client/meteorOverrides/userAndUsers.ts b/apps/meteor/client/meteorOverrides/userAndUsers.ts new file mode 100644 index 0000000000000000000000000000000000000000..84bd85ff38d2f6f5923d8bb3788fdc23b7035729 --- /dev/null +++ b/apps/meteor/client/meteorOverrides/userAndUsers.ts @@ -0,0 +1,14 @@ +import { Users } from '../../app/models/client/models/Users'; + +Meteor.users = Users as typeof Meteor.users; + +// overwrite Meteor.users collection so records on it don't get erased whenever the client reconnects to websocket +Meteor.user = function user(): Meteor.User | null { + const uid = Meteor.userId(); + + if (!uid) { + return null; + } + + return (Users.findOne({ _id: uid }) ?? null) as Meteor.User | null; +}; diff --git a/apps/meteor/client/methods/updateMessage.ts b/apps/meteor/client/methods/updateMessage.ts index 27489d281f4edca782fbf0dbbffb2456e794b386..deb2878072c5281b393d979c8b795738bd5c1055 100644 --- a/apps/meteor/client/methods/updateMessage.ts +++ b/apps/meteor/client/methods/updateMessage.ts @@ -8,7 +8,6 @@ import { hasAtLeastOnePermission, hasPermission } from '../../app/authorization/ import { ChatMessage } from '../../app/models/client'; import { settings } from '../../app/settings/client'; import { t } from '../../app/utils/lib/i18n'; -import { callbacks } from '../../lib/callbacks'; import { dispatchToastMessage } from '../lib/toast'; Meteor.methods({ @@ -74,7 +73,6 @@ Meteor.methods({ username: me.username, }; - message = (await callbacks.run('beforeSaveMessage', message)) as IEditedMessage; const messageObject: Partial = { editedAt: message.editedAt, editedBy: message.editedBy, diff --git a/apps/meteor/client/providers/AppsProvider.tsx b/apps/meteor/client/providers/AppsProvider.tsx index bb35c01c4ae29e9030031e66a9f27c3fb285a1fa..61baaefc4a492a66626db5c4170631a846641500 100644 --- a/apps/meteor/client/providers/AppsProvider.tsx +++ b/apps/meteor/client/providers/AppsProvider.tsx @@ -1,5 +1,5 @@ import { useDebouncedCallback } from '@rocket.chat/fuselage-hooks'; -import { usePermission, useSingleStream } from '@rocket.chat/ui-contexts'; +import { usePermission, useStream } from '@rocket.chat/ui-contexts'; import { useQuery, useQueryClient } from '@tanstack/react-query'; import type { FC } from 'react'; import React, { useEffect } from 'react'; @@ -8,12 +8,26 @@ import { AppClientOrchestratorInstance } from '../../ee/client/apps/orchestrator import { AppsContext } from '../contexts/AppsContext'; import { useIsEnterprise } from '../hooks/useIsEnterprise'; import { useInvalidateLicense } from '../hooks/useLicense'; +import type { AsyncState } from '../lib/asyncState'; import { AsyncStatePhase } from '../lib/asyncState'; import { useInvalidateAppsCountQueryCallback } from '../views/marketplace/hooks/useAppsCountQuery'; import type { App } from '../views/marketplace/types'; const sortByName = (apps: App[]): App[] => apps.sort((a, b) => (a.name.toLowerCase() > b.name.toLowerCase() ? 1 : -1)); +const getAppState = ( + loading: boolean, + apps: App[] | undefined, +): Omit< + AsyncState<{ + apps: App[]; + }>, + 'error' +> => ({ + phase: loading ? AsyncStatePhase.LOADING : AsyncStatePhase.RESOLVED, + value: { apps: apps || [] }, +}); + const AppsProvider: FC = ({ children }) => { const isAdminUser = usePermission('manage-apps'); @@ -25,7 +39,7 @@ const AppsProvider: FC = ({ children }) => { const invalidateAppsCountQuery = useInvalidateAppsCountQueryCallback(); const invalidateLicenseQuery = useInvalidateLicense(); - const stream = useSingleStream('apps'); + const stream = useStream('apps'); const invalidate = useDebouncedCallback( () => { @@ -109,7 +123,11 @@ const AppsProvider: FC = ({ children }) => { }; if (installedApp) { - installedApps.push(record); + if (installedApp.private) { + privateApps.push(record); + } else { + installedApps.push(record); + } } marketplaceApps.push(record); @@ -129,13 +147,16 @@ const AppsProvider: FC = ({ children }) => { }, ); + const [marketplaceAppsData, installedAppsData, privateAppsData] = store.data || []; + const { isLoading } = store; + return ( { await Promise.all([queryClient.invalidateQueries(['marketplace'])]); }, diff --git a/apps/meteor/client/providers/AuthenticationProvider/AuthenticationProvider.tsx b/apps/meteor/client/providers/AuthenticationProvider/AuthenticationProvider.tsx new file mode 100644 index 0000000000000000000000000000000000000000..c76f06bcd3b4ef97a28608e9f0231283231cf2c3 --- /dev/null +++ b/apps/meteor/client/providers/AuthenticationProvider/AuthenticationProvider.tsx @@ -0,0 +1,86 @@ +import type { LoginServiceConfiguration } from '@rocket.chat/core-typings'; +import { capitalize } from '@rocket.chat/string-helpers'; +import { AuthenticationContext, useSetting } from '@rocket.chat/ui-contexts'; +import { Meteor } from 'meteor/meteor'; +import type { ContextType, ReactElement, ReactNode } from 'react'; +import React, { useMemo } from 'react'; + +import { loginServices } from '../../lib/loginServices'; +import { useLDAPAndCrowdCollisionWarning } from './hooks/useLDAPAndCrowdCollisionWarning'; + +export type LoginMethods = keyof typeof Meteor extends infer T ? (T extends `loginWith${string}` ? T : never) : never; + +type AuthenticationProviderProps = { + children: ReactNode; +}; + +const AuthenticationProvider = ({ children }: AuthenticationProviderProps): ReactElement => { + const isLdapEnabled = useSetting('LDAP_Enable'); + const isCrowdEnabled = useSetting('CROWD_Enable'); + + const loginMethod: LoginMethods = (isLdapEnabled && 'loginWithLDAP') || (isCrowdEnabled && 'loginWithCrowd') || 'loginWithPassword'; + + useLDAPAndCrowdCollisionWarning(); + + const contextValue = useMemo( + (): ContextType => ({ + loginWithToken: (token: string): Promise => + new Promise((resolve, reject) => + Meteor.loginWithToken(token, (err) => { + if (err) { + return reject(err); + } + resolve(undefined); + }), + ), + loginWithPassword: (user: string | { username: string } | { email: string } | { id: string }, password: string): Promise => + new Promise((resolve, reject) => { + Meteor[loginMethod](user, password, (error) => { + if (error) { + reject(error); + return; + } + + resolve(); + }); + }), + loginWithService: (serviceConfig: T): (() => Promise) => { + const loginMethods: Record = { + 'meteor-developer': 'MeteorDeveloperAccount', + }; + + const { service: serviceName } = serviceConfig; + const clientConfig = ('clientConfig' in serviceConfig && serviceConfig.clientConfig) || {}; + + const loginWithService = `loginWith${loginMethods[serviceName] || capitalize(String(serviceName || ''))}`; + + const method: (config: unknown, cb: (error: any) => void) => Promise = (Meteor as any)[loginWithService] as any; + + if (!method) { + return () => Promise.reject(new Error('Login method not found')); + } + + return () => + new Promise((resolve, reject) => { + method(clientConfig, (error: any): void => { + if (!error) { + resolve(true); + return; + } + reject(error); + }); + }); + }, + + queryLoginServices: { + getCurrentValue: () => loginServices.getLoginServiceButtons(), + subscribe: (onStoreChange: () => void) => loginServices.on('changed', onStoreChange), + }, + }), + [loginMethod], + ); + + return ; +}; + +export default AuthenticationProvider; diff --git a/apps/meteor/client/providers/UserProvider/hooks/useLDAPAndCrowdCollisionWarning.tsx b/apps/meteor/client/providers/AuthenticationProvider/hooks/useLDAPAndCrowdCollisionWarning.tsx similarity index 93% rename from apps/meteor/client/providers/UserProvider/hooks/useLDAPAndCrowdCollisionWarning.tsx rename to apps/meteor/client/providers/AuthenticationProvider/hooks/useLDAPAndCrowdCollisionWarning.tsx index fbcabc2825fbe02db2a462436154448be7900440..afb0eea54fda7c75811153edb0c05a5dd9d2d81c 100644 --- a/apps/meteor/client/providers/UserProvider/hooks/useLDAPAndCrowdCollisionWarning.tsx +++ b/apps/meteor/client/providers/AuthenticationProvider/hooks/useLDAPAndCrowdCollisionWarning.tsx @@ -2,7 +2,7 @@ import { useSetting } from '@rocket.chat/ui-contexts'; import { Meteor } from 'meteor/meteor'; import { useEffect } from 'react'; -import type { LoginMethods } from '../UserProvider'; +import type { LoginMethods } from '../AuthenticationProvider'; export function useLDAPAndCrowdCollisionWarning() { const isLdapEnabled = useSetting('LDAP_Enable'); diff --git a/apps/meteor/client/providers/EmojiPickerProvider.tsx b/apps/meteor/client/providers/EmojiPickerProvider/EmojiPickerProvider.tsx similarity index 92% rename from apps/meteor/client/providers/EmojiPickerProvider.tsx rename to apps/meteor/client/providers/EmojiPickerProvider/EmojiPickerProvider.tsx index f7d14b317d54a7039b16b83e2c25218b3e090288..35cb32077a849fc41de406c72d569081555fe07a 100644 --- a/apps/meteor/client/providers/EmojiPickerProvider.tsx +++ b/apps/meteor/client/providers/EmojiPickerProvider/EmojiPickerProvider.tsx @@ -2,10 +2,11 @@ import { useDebouncedState, useLocalStorage } from '@rocket.chat/fuselage-hooks' import type { ReactNode, ReactElement, ContextType } from 'react'; import React, { useState, useCallback, useMemo, useEffect } from 'react'; -import type { EmojiByCategory } from '../../app/emoji/client'; -import { emoji, getFrequentEmoji, updateRecent, createEmojiList, createPickerEmojis, CUSTOM_CATEGORY } from '../../app/emoji/client'; -import { EmojiPickerContext } from '../contexts/EmojiPickerContext'; -import EmojiPicker from '../views/composer/EmojiPicker/EmojiPicker'; +import type { EmojiByCategory } from '../../../app/emoji/client'; +import { emoji, getFrequentEmoji, updateRecent, createEmojiList, createPickerEmojis, CUSTOM_CATEGORY } from '../../../app/emoji/client'; +import { EmojiPickerContext } from '../../contexts/EmojiPickerContext'; +import EmojiPicker from '../../views/composer/EmojiPicker/EmojiPicker'; +import { useUpdateCustomEmoji } from './useUpdateCustomEmoji'; const DEFAULT_ITEMS_LIMIT = 90; @@ -23,6 +24,8 @@ const EmojiPickerProvider = ({ children }: { children: ReactNode }): ReactElemen getFrequentEmoji(frequentEmojis.map(([emoji]) => emoji)), ); + useUpdateCustomEmoji(); + const addFrequentEmojis = useCallback( (emoji: string) => { const empty: [string, number][] = frequentEmojis.some(([emojiName]) => emojiName === emoji) ? [] : [[emoji, 0]]; diff --git a/apps/meteor/client/providers/EmojiPickerProvider/index.ts b/apps/meteor/client/providers/EmojiPickerProvider/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..4437d6b749d43ee677c4c35706f602a0e96ef7d7 --- /dev/null +++ b/apps/meteor/client/providers/EmojiPickerProvider/index.ts @@ -0,0 +1 @@ +export { default } from './EmojiPickerProvider'; diff --git a/apps/meteor/client/providers/EmojiPickerProvider/useUpdateCustomEmoji.ts b/apps/meteor/client/providers/EmojiPickerProvider/useUpdateCustomEmoji.ts new file mode 100644 index 0000000000000000000000000000000000000000..a0a0946006dbb27667d180185f930c5afd0f4453 --- /dev/null +++ b/apps/meteor/client/providers/EmojiPickerProvider/useUpdateCustomEmoji.ts @@ -0,0 +1,17 @@ +import { useStream } from '@rocket.chat/ui-contexts'; +import { useEffect } from 'react'; + +import { updateEmojiCustom, deleteEmojiCustom } from '../../../app/emoji-custom/client/lib/emojiCustom'; + +export const useUpdateCustomEmoji = () => { + const notify = useStream('notify-logged'); + useEffect(() => { + const unsubUpdate = notify('updateEmojiCustom', (data) => updateEmojiCustom(data.emojiData)); + const unsubDelete = notify('deleteEmojiCustom', (data) => deleteEmojiCustom(data.emojiData)); + + return () => { + unsubUpdate(); + unsubDelete(); + }; + }, [notify]); +}; diff --git a/apps/meteor/client/providers/ImageGalleryProvider.tsx b/apps/meteor/client/providers/ImageGalleryProvider.tsx index 6d14e28c53ce14d177d2b9c5590a543e380614be..1cd07f29882cdbd3ef9789c82fa498bef5eead7b 100644 --- a/apps/meteor/client/providers/ImageGalleryProvider.tsx +++ b/apps/meteor/client/providers/ImageGalleryProvider.tsx @@ -1,7 +1,8 @@ import React, { type ReactNode, useEffect, useState } from 'react'; -import ImageGallery from '../components/ImageGallery/ImageGallery'; +import { ImageGallery } from '../components/ImageGallery'; import { ImageGalleryContext } from '../contexts/ImageGalleryContext'; +import ImageGalleryData from '../views/room/ImageGallery/ImageGalleryData'; type ImageGalleryProviderProps = { children: ReactNode; @@ -9,34 +10,39 @@ type ImageGalleryProviderProps = { const ImageGalleryProvider = ({ children }: ImageGalleryProviderProps) => { const [imageId, setImageId] = useState(); + const [quotedImageUrl, setQuotedImageUrl] = useState(); useEffect(() => { - document.addEventListener('click', (event: Event) => { + const handleImageClick = (event: Event) => { const target = event?.target as HTMLElement | null; + + if (target?.closest('.rcx-attachment__details')) { + return setQuotedImageUrl(target.dataset.id); + } if (target?.classList.contains('gallery-item')) { - return setImageId(target.dataset.id || target?.parentElement?.parentElement?.dataset.id); + const id = target.closest('.gallery-item-container')?.getAttribute('data-id') || undefined; + return setImageId(target.dataset.id || id); } - if (target?.classList.contains('gallery-item-container')) { return setImageId(target.dataset.id); } - if ( - target?.classList.contains('gallery-item') && - target?.parentElement?.parentElement?.classList.contains('gallery-item-container') - ) { - return setImageId(target.dataset.id || target?.parentElement?.parentElement?.dataset.id); + if (target?.classList.contains('rcx-avatar__element') && target.closest('.gallery-item')) { + const avatarTarget = target.closest('.gallery-item-container')?.getAttribute('data-id') || undefined; + return setImageId(avatarTarget); } + }; + document.addEventListener('click', handleImageClick); - if (target?.classList.contains('rcx-avatar__element') && target?.parentElement?.classList.contains('gallery-item')) { - return setImageId(target.dataset.id || target?.parentElement?.parentElement?.dataset.id); - } - }); + return () => document.removeEventListener('click', handleImageClick); }, []); return ( setImageId(undefined) }}> {children} - {!!imageId && } + {!!quotedImageUrl && ( + setQuotedImageUrl(undefined)} /> + )} + {!!imageId && } ); }; diff --git a/apps/meteor/client/providers/LayoutProvider.tsx b/apps/meteor/client/providers/LayoutProvider.tsx index 5cc113e172c520246c8bc49e53fd69c1fc1724eb..a4f8fa84f9ff422d9404cc867b04a72454423fb5 100644 --- a/apps/meteor/client/providers/LayoutProvider.tsx +++ b/apps/meteor/client/providers/LayoutProvider.tsx @@ -3,10 +3,18 @@ import { LayoutContext, useRouter, useSetting } from '@rocket.chat/ui-contexts'; import type { FC } from 'react'; import React, { useMemo, useState, useEffect } from 'react'; +const hiddenActionsDefaultValue = { + roomToolbox: [], + messageToolbox: [], + composerToolbox: [], + userToolbox: [], +}; + const LayoutProvider: FC = ({ children }) => { const showTopNavbarEmbeddedLayout = Boolean(useSetting('UI_Show_top_navbar_embedded_layout')); const [isCollapsed, setIsCollapsed] = useState(false); const breakpoints = useBreakpoints(); // ["xs", "sm", "md", "lg", "xl", xxl"] + const [hiddenActions, setHiddenActions] = useState(hiddenActionsDefaultValue); const router = useRouter(); // Once the layout is embedded, it can't be changed @@ -18,6 +26,18 @@ const LayoutProvider: FC = ({ children }) => { setIsCollapsed(isMobile); }, [isMobile]); + useEffect(() => { + const eventHandler = (event: MessageEvent) => { + if (event.data?.event !== 'overrideUi') { + return; + } + + setHiddenActions({ ...hiddenActionsDefaultValue, ...event.data.hideActions }); + }; + window.addEventListener('message', eventHandler); + return () => window.removeEventListener('message', eventHandler); + }, []); + return ( { contextualBarExpanded: breakpoints.includes('sm'), // eslint-disable-next-line no-nested-ternary contextualBarPosition: breakpoints.includes('sm') ? (breakpoints.includes('lg') ? 'relative' : 'absolute') : 'fixed', + hiddenActions, }), - [isMobile, isEmbedded, showTopNavbarEmbeddedLayout, isCollapsed, breakpoints, router], + [isMobile, isEmbedded, showTopNavbarEmbeddedLayout, isCollapsed, breakpoints, router, hiddenActions], )} /> ); diff --git a/apps/meteor/client/providers/MeteorProvider.tsx b/apps/meteor/client/providers/MeteorProvider.tsx index aa12af9055210f203bbb2fdf52947780941ec6e3..9ae22651f1f36f4232810df0bbf74a6f0ed02369 100644 --- a/apps/meteor/client/providers/MeteorProvider.tsx +++ b/apps/meteor/client/providers/MeteorProvider.tsx @@ -3,6 +3,7 @@ import React from 'react'; import { OmnichannelRoomIconProvider } from '../components/RoomIcon/OmnichannelRoomIcon/provider/OmnichannelRoomIconProvider'; import ActionManagerProvider from './ActionManagerProvider'; +import AuthenticationProvider from './AuthenticationProvider/AuthenticationProvider'; import AuthorizationProvider from './AuthorizationProvider'; import AvatarUrlProvider from './AvatarUrlProvider'; import { CallProvider } from './CallProvider'; @@ -36,27 +37,29 @@ const MeteorProvider: FC = ({ children }) => ( - - - - - - - - - - - {children} - - - - - - - - - - + + + + + + + + + + + + {children} + + + + + + + + + + + diff --git a/apps/meteor/client/providers/OmnichannelProvider.tsx b/apps/meteor/client/providers/OmnichannelProvider.tsx index fcf57a399905f233fea5b67dbad9e3fd1692796f..47ccb3d39c880a8f7fd4a32b6968213673fd494d 100644 --- a/apps/meteor/client/providers/OmnichannelProvider.tsx +++ b/apps/meteor/client/providers/OmnichannelProvider.tsx @@ -13,7 +13,6 @@ import React, { useState, useEffect, useMemo, useCallback, memo, useRef } from ' import { LivechatInquiry } from '../../app/livechat/client/collections/LivechatInquiry'; import { initializeLivechatInquiryStream } from '../../app/livechat/client/lib/stream/queueManager'; import { getOmniChatSortQuery } from '../../app/livechat/lib/inquiries'; -import { Notifications } from '../../app/notifications/client'; import { KonchatNotification } from '../../app/ui/client/lib/KonchatNotification'; import { useHasLicenseModule } from '../../ee/client/hooks/useHasLicenseModule'; import { ClientLogger } from '../../lib/ClientLogger'; @@ -41,7 +40,7 @@ const OmnichannelProvider: FC = ({ children }) => { const omniChannelEnabled = useSetting('Livechat_enabled') as boolean; const omnichannelRouting = useSetting('Livechat_Routing_Method'); const showOmnichannelQueueLink = useSetting('Livechat_show_queue_list_link') as boolean; - const omnichannelPoolMaxIncoming = useSetting('Livechat_guest_pool_max_number_incoming_livechats_displayed') as number; + const omnichannelPoolMaxIncoming = useSetting('Livechat_guest_pool_max_number_incoming_livechats_displayed') ?? 0; const omnichannelSortingMechanism = useSetting('Omnichannel_sorting_mechanism') as OmnichannelSortingMechanismSettingType; const loggerRef = useRef(new ClientLogger('OmnichannelProvider')); @@ -110,6 +109,7 @@ const OmnichannelProvider: FC = ({ children }) => { const manuallySelected = enabled && canViewOmnichannelQueue && !!routeConfig && routeConfig.showQueue && !routeConfig.autoAssignAgent && agentAvailable; + const streamNotifyUser = useStream('notify-user'); useEffect(() => { if (!manuallySelected) { return; @@ -120,8 +120,11 @@ const OmnichannelProvider: FC = ({ children }) => { }; initializeLivechatInquiryStream(user?._id); - return Notifications.onUser('departmentAgentData', handleDepartmentAgentData).stop; - }, [manuallySelected, user?._id]); + if (!user?._id) { + return; + } + return streamNotifyUser(`${user._id}/departmentAgentData`, handleDepartmentAgentData); + }, [manuallySelected, streamNotifyUser, user?._id]); const queue = useReactiveValue( useCallback(() => { @@ -130,16 +133,13 @@ const OmnichannelProvider: FC = ({ children }) => { } return LivechatInquiry.find( - { - status: 'queued', - $or: [{ defaultAgent: { $exists: false } }, { 'defaultAgent.agentId': user?._id }], - }, + { status: 'queued' }, { sort: getOmniChatSortQuery(omnichannelSortingMechanism), limit: omnichannelPoolMaxIncoming, }, ).fetch(); - }, [manuallySelected, omnichannelPoolMaxIncoming, omnichannelSortingMechanism, user?._id]), + }, [manuallySelected, omnichannelPoolMaxIncoming, omnichannelSortingMechanism]), ); queue?.map(({ rid }) => { diff --git a/apps/meteor/client/providers/ServerProvider.tsx b/apps/meteor/client/providers/ServerProvider.tsx index 8ff767b209338b0c7b301e0386127b0fb809399d..8eb5e2e37b6bf218ff21e91c5e67939201555299 100644 --- a/apps/meteor/client/providers/ServerProvider.tsx +++ b/apps/meteor/client/providers/ServerProvider.tsx @@ -1,5 +1,4 @@ import type { Serialized } from '@rocket.chat/core-typings'; -import { Emitter } from '@rocket.chat/emitter'; import type { Method, PathFor, OperationParams, OperationResult, UrlParams, PathPattern } from '@rocket.chat/rest-typings'; import type { ServerMethodName, @@ -59,58 +58,16 @@ const callEndpoint = ( const uploadToEndpoint = (endpoint: PathFor<'POST'>, formData: any): Promise => sdk.rest.post(endpoint as any, formData); -const getStream = >( - streamName: N, - _options?: { - retransmit?: boolean | undefined; - retransmitToSelf?: boolean | undefined; - }, -): ((eventName: K, callback: (...args: StreamerCallbackArgs) => void) => () => void) => { - return (eventName, callback): (() => void) => { - return sdk.stream(streamName, [eventName], callback as (...args: any[]) => void).stop; - }; -}; - -const ee = new Emitter>(); - -const events = new Map void>(); - -const getSingleStream = >( - streamName: N, - _options?: { - retransmit?: boolean | undefined; - retransmitToSelf?: boolean | undefined; - }, -): ((eventName: K, callback: (...args: StreamerCallbackArgs) => void) => () => void) => { - const stream = getStream(streamName); - return (eventName, callback): (() => void) => { - ee.on(`${streamName}/${eventName}`, callback); - - const handler = (...args: any[]): void => { - ee.emit(`${streamName}/${eventName}`, ...args); - }; - - const stop = (): void => { - // If someone is still listening, don't unsubscribe - ee.off(`${streamName}/${eventName}`, callback); - - if (ee.has(`${streamName}/${eventName}`)) { - return; - } - - const unsubscribe = events.get(`${streamName}/${eventName}`); - if (unsubscribe) { - unsubscribe(); - events.delete(`${streamName}/${eventName}`); - } - }; - - if (!events.has(`${streamName}/${eventName}`)) { - events.set(`${streamName}/${eventName}`, stream(eventName, handler)); - } - return stop; - }; -}; +const getStream = + ( + streamName: N, + _options?: { + retransmit?: boolean | undefined; + retransmitToSelf?: boolean | undefined; + }, + ) => + >(eventName: K, callback: (...args: StreamerCallbackArgs) => void): (() => void) => + sdk.stream(streamName, [eventName], callback).stop; const contextValue = { info, @@ -119,7 +76,6 @@ const contextValue = { callEndpoint, uploadToEndpoint, getStream, - getSingleStream, }; const ServerProvider: FC = ({ children }) => ; diff --git a/apps/meteor/client/providers/TranslationProvider.tsx b/apps/meteor/client/providers/TranslationProvider.tsx index f9fdf299a5d602668e95ea146d9c1c15c95113d8..7f98c374f949fadce6a8a2730040b10ebf79c970 100644 --- a/apps/meteor/client/providers/TranslationProvider.tsx +++ b/apps/meteor/client/providers/TranslationProvider.tsx @@ -1,8 +1,8 @@ -import { useLocalStorage, useMutableCallback } from '@rocket.chat/fuselage-hooks'; +import { useLocalStorage } from '@rocket.chat/fuselage-hooks'; import languages from '@rocket.chat/i18n/dist/languages'; import en from '@rocket.chat/i18n/src/locales/en.i18n.json'; import { normalizeLanguage } from '@rocket.chat/tools'; -import type { TranslationKey, TranslationContextValue } from '@rocket.chat/ui-contexts'; +import type { TranslationContextValue } from '@rocket.chat/ui-contexts'; import { useMethod, useSetting, TranslationContext } from '@rocket.chat/ui-contexts'; import type i18next from 'i18next'; import I18NextHttpBackend from 'i18next-http-backend'; @@ -14,99 +14,73 @@ import { I18nextProvider, initReactI18next, useTranslation } from 'react-i18next import { CachedCollectionManager } from '../../app/ui-cached-collection/client'; import { getURL } from '../../app/utils/client'; -import { i18n, addSprinfToI18n } from '../../app/utils/lib/i18n'; +import { + i18n, + addSprinfToI18n, + extractTranslationKeys, + applyCustomTranslations, + availableTranslationNamespaces, + defaultTranslationNamespace, + extractTranslationNamespaces, +} from '../../app/utils/lib/i18n'; import { AppClientOrchestratorInstance } from '../../ee/client/apps/orchestrator'; -import { applyCustomTranslations } from '../lib/utils/applyCustomTranslations'; import { isRTLScriptLanguage } from '../lib/utils/isRTLScriptLanguage'; i18n.use(I18NextHttpBackend).use(initReactI18next).use(sprintf); -type TranslationNamespace = Extract extends `${infer T}.${string}` - ? T extends Lowercase - ? T - : never - : never; - -const namespacesDefault = ['core', 'onboarding', 'registration', 'cloud'] as TranslationNamespace[]; - -const parseToJSON = (customTranslations: string): Record> | false => { - try { - return JSON.parse(customTranslations); - } catch (e) { - return false; - } -}; - -const localeCache = new Map>(); - -const useI18next = (lng: string): typeof i18next => { +const useCustomTranslations = (i18n: typeof i18next) => { const customTranslations = useSetting('Custom_Translations'); - const parsedCustomTranslations = useMemo(() => { + const parsedCustomTranslations = useMemo((): Record> | undefined => { if (!customTranslations || typeof customTranslations !== 'string') { - return; + return undefined; } - return parseToJSON(customTranslations); + try { + return JSON.parse(customTranslations); + } catch (e) { + console.error(e); + return undefined; + } }, [customTranslations]); - const extractKeys = useMutableCallback( - (source: Record, lngs?: string | string[], namespaces: string | string[] = []): { [key: string]: any } => { - const result: { [key: string]: any } = {}; - - for (const [key, value] of Object.entries(source)) { - const [prefix] = key.split('.'); - - if (prefix && Array.isArray(namespaces) ? namespaces.includes(prefix) : prefix === namespaces) { - result[key.slice(prefix.length + 1)] = value; - continue; - } - - if (Array.isArray(namespaces) ? namespaces.includes('core') : namespaces === 'core') { - result[key] = value; - } - } + useEffect(() => { + if (!parsedCustomTranslations) { + return; + } - if (lngs && parsedCustomTranslations) { - for (const language of Array.isArray(lngs) ? lngs : [lngs]) { - if (!parsedCustomTranslations[language]) { - continue; - } + applyCustomTranslations(i18n, parsedCustomTranslations); - for (const [key, value] of Object.entries(parsedCustomTranslations[language])) { - const prefix = (Array.isArray(namespaces) ? namespaces : [namespaces]).find((namespace) => key.startsWith(`${namespace}.`)); + const handleLanguageChanged = (): void => { + applyCustomTranslations(i18n, parsedCustomTranslations); + }; - if (prefix) { - result[key.slice(prefix.length + 1)] = value; - continue; - } + i18n.on('languageChanged', handleLanguageChanged); - if (Array.isArray(namespaces) ? namespaces.includes('core') : namespaces === 'core') { - result[key] = value; - } - } - } - } + return () => { + i18n.off('languageChanged', handleLanguageChanged); + }; + }, [i18n, parsedCustomTranslations]); +}; - return result; - }, - ); +const localeCache = new Map>(); +const useI18next = (lng: string): typeof i18next => { if (!i18n.isInitialized) { i18n.init({ lng, fallbackLng: 'en', - ns: namespacesDefault, + ns: availableTranslationNamespaces, + defaultNS: defaultTranslationNamespace, nsSeparator: '.', resources: { - en: extractKeys(en), + en: extractTranslationNamespaces(en), }, partialBundledLanguages: true, - defaultNS: 'core', backend: { loadPath: 'i18n/{{lng}}.json', - parse: (data: string, lngs?: string | string[], namespaces: string | string[] = []) => - extractKeys(JSON.parse(data), lngs, namespaces), + parse: (data: string, _lngs?: string | string[], namespaces: string | string[] = []) => + extractTranslationKeys(JSON.parse(data), namespaces), request: (_options, url, _payload, callback) => { const params = url.split('/'); @@ -137,47 +111,12 @@ const useI18next = (lng: string): typeof i18next => { } useEffect(() => { - if (i18n.language !== lng) { - i18n.changeLanguage(lng); - } + i18n.changeLanguage(lng); }, [lng]); - useEffect(() => { - if (!parsedCustomTranslations) { - return; - } - - for (const [ln, translations] of Object.entries(parsedCustomTranslations)) { - if (!translations) { - continue; - } - const namespaces = Object.entries(translations).reduce((acc, [key, value]): Record> => { - const namespace = key.split('.')[0]; - - if (namespacesDefault.includes(namespace as unknown as TranslationNamespace)) { - acc[namespace] = acc[namespace] ?? {}; - acc[namespace][key] = value; - acc[namespace][key.slice(namespace.length + 1)] = value; - return acc; - } - acc.project = acc.project ?? {}; - acc.project[key] = value; - return acc; - }, {} as Record>); - - for (const [namespace, translations] of Object.entries(namespaces)) { - i18n.addResourceBundle(ln, namespace, translations); - } - } - }, [parsedCustomTranslations]); - return i18n; }; -type TranslationProviderProps = { - children: ReactNode; -}; - const useAutoLanguage = () => { const serverLanguage = useSetting('Language'); const browserLanguage = normalizeLanguage(window.navigator.userLanguage ?? window.navigator.language); @@ -206,11 +145,17 @@ const getLanguageName = (code: string, lng: string): string => { } }; +type TranslationProviderProps = { + children: ReactNode; +}; + const TranslationProvider = ({ children }: TranslationProviderProps): ReactElement => { const loadLocale = useMethod('loadLocale'); const language = useAutoLanguage(); const i18nextInstance = useI18next(language); + useCustomTranslations(i18nextInstance); + const availableLanguages = useMemo( () => [ { @@ -290,8 +235,8 @@ const TranslationProviderInner = ({ () => ({ language: i18n.language, languages: availableLanguages, - loadLanguage: async (language: string): Promise => { - i18n.changeLanguage(language).then(() => applyCustomTranslations()); + loadLanguage: async (language: string) => { + i18n.changeLanguage(language); }, translate: Object.assign(addSprinfToI18n(t), { has: ((key, options) => key && i18n.exists(key, options)) as TranslationContextValue['translate']['has'], diff --git a/apps/meteor/client/providers/UserProvider/UserProvider.tsx b/apps/meteor/client/providers/UserProvider/UserProvider.tsx index 09f631ffa6a64510015891ba7e3cac51d0810c14..62ed7070737d7e5db5b6cdb03aabeaf65d421a82 100644 --- a/apps/meteor/client/providers/UserProvider/UserProvider.tsx +++ b/apps/meteor/client/providers/UserProvider/UserProvider.tsx @@ -1,7 +1,7 @@ import type { IRoom, ISubscription, IUser } from '@rocket.chat/core-typings'; import { useLocalStorage } from '@rocket.chat/fuselage-hooks'; -import type { LoginService, SubscriptionWithRoom } from '@rocket.chat/ui-contexts'; -import { UserContext, useEndpoint, useSetting } from '@rocket.chat/ui-contexts'; +import type { SubscriptionWithRoom } from '@rocket.chat/ui-contexts'; +import { UserContext, useEndpoint } from '@rocket.chat/ui-contexts'; import { Meteor } from 'meteor/meteor'; import type { ContextType, ReactElement, ReactNode } from 'react'; import React, { useEffect, useMemo } from 'react'; @@ -13,32 +13,15 @@ import { afterLogoutCleanUpCallback } from '../../../lib/callbacks/afterLogoutCl import { useReactiveValue } from '../../hooks/useReactiveValue'; import { createReactiveSubscriptionFactory } from '../../lib/createReactiveSubscriptionFactory'; import { useCreateFontStyleElement } from '../../views/account/accessibility/hooks/useCreateFontStyleElement'; +import { useClearRemovedRoomsHistory } from './hooks/useClearRemovedRoomsHistory'; +import { useDeleteUser } from './hooks/useDeleteUser'; import { useEmailVerificationWarning } from './hooks/useEmailVerificationWarning'; -import { useLDAPAndCrowdCollisionWarning } from './hooks/useLDAPAndCrowdCollisionWarning'; +import { useUpdateAvatar } from './hooks/useUpdateAvatar'; const getUserId = (): string | null => Meteor.userId(); const getUser = (): IUser | null => Meteor.user() as IUser | null; -const capitalize = (str: string): string => str.charAt(0).toUpperCase() + str.slice(1); - -const config: Record> = { - 'apple': { title: 'Apple', icon: 'apple' }, - 'facebook': { title: 'Facebook', icon: 'facebook' }, - 'twitter': { title: 'Twitter', icon: 'twitter' }, - 'google': { title: 'Google', icon: 'google' }, - 'github': { title: 'Github', icon: 'github' }, - 'github_enterprise': { title: 'Github Enterprise', icon: 'github' }, - 'gitlab': { title: 'Gitlab', icon: 'gitlab' }, - 'dolphin': { title: 'Dolphin', icon: 'dophin' }, - 'drupal': { title: 'Drupal', icon: 'drupal' }, - 'nextcloud': { title: 'Nextcloud', icon: 'nextcloud' }, - 'tokenpass': { title: 'Tokenpass', icon: 'tokenpass' }, - 'meteor-developer': { title: 'Meteor', icon: 'meteor' }, - 'wordpress': { title: 'WordPress', icon: 'wordpress' }, - 'linkedin': { title: 'Linkedin', icon: 'linkedin' }, -}; - const logout = (): Promise => new Promise((resolve, reject) => { const user = getUser(); @@ -53,16 +36,11 @@ const logout = (): Promise => }); }); -export type LoginMethods = keyof typeof Meteor; - type UserProviderProps = { children: ReactNode; }; const UserProvider = ({ children }: UserProviderProps): ReactElement => { - const isLdapEnabled = useSetting('LDAP_Enable'); - const isCrowdEnabled = useSetting('CROWD_Enable'); - const userId = useReactiveValue(getUserId); const user = useReactiveValue(getUser); const [userLanguage, setUserLanguage] = useLocalStorage('userLanguage', ''); @@ -73,10 +51,11 @@ const UserProvider = ({ children }: UserProviderProps): ReactElement => { const createFontStyleElement = useCreateFontStyleElement(); createFontStyleElement(user?.settings?.preferences?.fontSize); - const loginMethod: LoginMethods = (isLdapEnabled && 'loginWithLDAP') || (isCrowdEnabled && 'loginWithCrowd') || 'loginWithPassword'; - - useLDAPAndCrowdCollisionWarning(); useEmailVerificationWarning(user ?? undefined); + useClearRemovedRoomsHistory(userId); + + useDeleteUser(); + useUpdateAvatar(); const contextValue = useMemo( (): ContextType => ({ @@ -96,75 +75,9 @@ const UserProvider = ({ children }: UserProviderProps): ReactElement => { return ChatRoom.find(query, options).fetch(); }), - loginWithToken: (token: string): Promise => - new Promise((resolve, reject) => - Meteor.loginWithToken(token, (err) => { - if (err) { - return reject(err); - } - resolve(undefined); - }), - ), - loginWithPassword: (user: string | { username: string } | { email: string } | { id: string }, password: string): Promise => - new Promise((resolve, reject) => { - Meteor[loginMethod](user, password, (error: Error | Meteor.Error | Meteor.TypedError | undefined) => { - if (error) { - reject(error); - return; - } - - resolve(); - }); - }), logout, - loginWithService: ({ service, clientConfig = {} }: T): (() => Promise) => { - const loginMethods = { - 'meteor-developer': 'MeteorDeveloperAccount', - }; - - const loginWithService = `loginWith${(loginMethods as any)[service] || capitalize(String(service || ''))}`; - - const method: (config: unknown, cb: (error: any) => void) => Promise = (Meteor as any)[loginWithService] as any; - - if (!method) { - return () => Promise.reject(new Error('Login method not found')); - } - - return () => - new Promise((resolve, reject) => { - method(clientConfig, (error: any): void => { - if (!error) { - resolve(true); - return; - } - reject(error); - }); - }); - }, - queryAllServices: createReactiveSubscriptionFactory(() => - ServiceConfiguration.configurations - .find( - { - showButton: { $ne: false }, - }, - { - sort: { - service: 1, - }, - }, - ) - .fetch() - .map( - ({ appId: _, ...service }) => - ({ - title: capitalize(String((service as any).service || '')), - ...service, - ...(config[(service as any).service] ?? {}), - } as any), - ), - ), }), - [userId, user, loginMethod], + [userId, user], ); useEffect(() => { diff --git a/apps/meteor/client/providers/UserProvider/hooks/useClearRemovedRoomsHistory.ts b/apps/meteor/client/providers/UserProvider/hooks/useClearRemovedRoomsHistory.ts new file mode 100644 index 0000000000000000000000000000000000000000..f48b55986756a63b4dbb67b7094913824104fed6 --- /dev/null +++ b/apps/meteor/client/providers/UserProvider/hooks/useClearRemovedRoomsHistory.ts @@ -0,0 +1,19 @@ +import { useStream } from '@rocket.chat/ui-contexts'; +import { useEffect } from 'react'; + +import { RoomHistoryManager } from '../../../../app/ui-utils/client'; + +export const useClearRemovedRoomsHistory = (userId: string | null) => { + const subscribeToNotifyUser = useStream('notify-user'); + useEffect(() => { + if (!userId) { + return; + } + + return subscribeToNotifyUser(`${userId}/subscriptions-changed`, (event, data) => { + if (data.t !== 'l' && event === 'removed' && data.rid) { + RoomHistoryManager.clear(data.rid); + } + }); + }, [userId, subscribeToNotifyUser]); +}; diff --git a/apps/meteor/client/providers/UserProvider/hooks/useDeleteUser.ts b/apps/meteor/client/providers/UserProvider/hooks/useDeleteUser.ts new file mode 100644 index 0000000000000000000000000000000000000000..e86fe9951a2642f5c38633c5bbe4bd9b6a01cae3 --- /dev/null +++ b/apps/meteor/client/providers/UserProvider/hooks/useDeleteUser.ts @@ -0,0 +1,32 @@ +import { useStream } from '@rocket.chat/ui-contexts'; +import { useEffect } from 'react'; + +import { ChatMessage } from '../../../../app/models/client'; + +export const useDeleteUser = () => { + const notify = useStream('notify-logged'); + + useEffect(() => { + return notify('Users:Deleted', ({ userId, messageErasureType, replaceByUser }) => { + if (messageErasureType === 'Unlink' && replaceByUser) { + return ChatMessage.update( + { + 'u._id': userId, + }, + { + $set: { + 'alias': replaceByUser.alias, + 'u._id': replaceByUser._id, + 'u.username': replaceByUser.username, + 'u.name': undefined, + }, + }, + { multi: true }, + ); + } + ChatMessage.remove({ + 'u._id': userId, + }); + }); + }, [notify]); +}; diff --git a/apps/meteor/client/providers/UserProvider/hooks/useUpdateAvatar.ts b/apps/meteor/client/providers/UserProvider/hooks/useUpdateAvatar.ts new file mode 100644 index 0000000000000000000000000000000000000000..292880e23da8c3d6fca0eef5299fe3a4bc2e55fd --- /dev/null +++ b/apps/meteor/client/providers/UserProvider/hooks/useUpdateAvatar.ts @@ -0,0 +1,15 @@ +import { useStream } from '@rocket.chat/ui-contexts'; +import { Meteor } from 'meteor/meteor'; +import { useEffect } from 'react'; + +export const useUpdateAvatar = () => { + const notify = useStream('notify-logged'); + useEffect(() => { + return notify('updateAvatar', (data) => { + if ('username' in data) { + const { username, etag } = data; + username && Meteor.users.update({ username }, { $set: { avatarETag: etag } }); + } + }); + }, [notify]); +}; diff --git a/apps/meteor/client/sidebar/Item/Condensed.stories.tsx b/apps/meteor/client/sidebar/Item/Condensed.stories.tsx index 61e392e77e2ef445b4d80dab1d3b97d25ba01094..f63893a30a81b6afd333636d8a8033154b4aa80e 100644 --- a/apps/meteor/client/sidebar/Item/Condensed.stories.tsx +++ b/apps/meteor/client/sidebar/Item/Condensed.stories.tsx @@ -1,10 +1,10 @@ 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 UserAvatar from '../../components/avatar/UserAvatar'; import Condensed from './Condensed'; export default { diff --git a/apps/meteor/client/sidebar/Item/Extended.stories.tsx b/apps/meteor/client/sidebar/Item/Extended.stories.tsx index 7e2b79ad1d6f758066edd30e1a7a974d849abbf9..a6392eae5d61cd78d920d8fe3ca1be2cf08775af 100644 --- a/apps/meteor/client/sidebar/Item/Extended.stories.tsx +++ b/apps/meteor/client/sidebar/Item/Extended.stories.tsx @@ -1,10 +1,10 @@ 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 UserAvatar from '../../components/avatar/UserAvatar'; import Extended from './Extended'; export default { diff --git a/apps/meteor/client/sidebar/Item/Medium.stories.tsx b/apps/meteor/client/sidebar/Item/Medium.stories.tsx index 823244047c517d3d1ee339d570b3c63725bb2668..0c03cf33c50001b9bc80791977d2a61f81e99012 100644 --- a/apps/meteor/client/sidebar/Item/Medium.stories.tsx +++ b/apps/meteor/client/sidebar/Item/Medium.stories.tsx @@ -1,10 +1,10 @@ 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 UserAvatar from '../../components/avatar/UserAvatar'; import Medium from './Medium'; export default { diff --git a/apps/meteor/client/sidebar/Sidebar.stories.tsx b/apps/meteor/client/sidebar/Sidebar.stories.tsx index f147ed86b4e403e2481ef01554496cb8b1f18f7d..d8c5788bae863dd38cfa5f8031cd2cfdc5e6bd3a 100644 --- a/apps/meteor/client/sidebar/Sidebar.stories.tsx +++ b/apps/meteor/client/sidebar/Sidebar.stories.tsx @@ -1,5 +1,5 @@ import type { ISetting } from '@rocket.chat/core-typings'; -import type { LoginService, SubscriptionWithRoom } from '@rocket.chat/ui-contexts'; +import type { SubscriptionWithRoom } from '@rocket.chat/ui-contexts'; import { UserContext, SettingsContext } from '@rocket.chat/ui-contexts'; import type { Meta, Story } from '@storybook/react'; import type { ObjectId } from 'mongodb'; @@ -98,10 +98,6 @@ const userContextValue: ContextType = { querySubscription: () => [() => () => undefined, () => undefined], queryRoom: () => [() => () => undefined, () => undefined], - queryAllServices: () => [() => (): void => undefined, (): LoginService[] => []], - loginWithService: () => () => Promise.reject('loginWithService not implemented'), - loginWithPassword: async () => Promise.reject('loginWithPassword not implemented'), - loginWithToken: async () => Promise.reject('loginWithToken not implemented'), logout: () => Promise.resolve(), }; diff --git a/apps/meteor/client/sidebar/footer/voip/index.tsx b/apps/meteor/client/sidebar/footer/voip/index.tsx index 70ad626044a422f1ab8f9f855560061edf5a958b..9ae7d91c7c2b82c1f7573bc5c4e40749b94d111b 100644 --- a/apps/meteor/client/sidebar/footer/voip/index.tsx +++ b/apps/meteor/client/sidebar/footer/voip/index.tsx @@ -1,7 +1,7 @@ import type { VoIpCallerInfo } from '@rocket.chat/core-typings'; import { useEndpoint, useTranslation } from '@rocket.chat/ui-contexts'; import type { ReactElement } from 'react'; -import React, { useCallback, useMemo, useState } from 'react'; +import React, { useCallback, useState } from 'react'; import { useVoipFooterMenu } from '../../../../ee/client/hooks/useVoipFooterMenu'; import { @@ -62,18 +62,6 @@ export const VoipFooter = (): ReactElement | null => { return subtitles[state] || ''; }; - const getCallsInQueueText = useMemo((): string => { - if (queueCounter === 0) { - return t('Calls_in_queue_empty'); - } - - if (queueCounter === 1) { - return t('Calls_in_queue', { calls: queueCounter }); - } - - return t('Calls_in_queue_plural', { calls: queueCounter }); - }, [queueCounter, t]); - if (!('caller' in callerInfo)) { return ; } @@ -91,7 +79,7 @@ export const VoipFooter = (): ReactElement | null => { togglePause={togglePause} createRoom={createRoom} openRoom={openRoom} - callsInQueue={getCallsInQueueText} + callsInQueue={t('Calls_in_queue', { count: queueCounter })} dispatchEvent={dispatchEvent} openedRoomInfo={openedRoomInfo} isEnterprise={isEnterprise} diff --git a/apps/meteor/client/sidebar/header/CreateChannel/CreateChannelModal.tsx b/apps/meteor/client/sidebar/header/CreateChannel/CreateChannelModal.tsx index f693648d0deb704e17db6ea231db31ca840353ee..2708e6e0a1e3f5a85cf76847756305358e3d3ba6 100644 --- a/apps/meteor/client/sidebar/header/CreateChannel/CreateChannelModal.tsx +++ b/apps/meteor/client/sidebar/header/CreateChannel/CreateChannelModal.tsx @@ -240,13 +240,8 @@ const CreateChannelModal = ({ teamId = '', onClose }: CreateChannelModalProps): {t('Channel_what_is_this_channel_about')} - - - {t('Private')} - - {isPrivate ? t('Only_invited_users_can_acess_this_channel') : t('Everyone_can_access_this_channel')} - - + + {t('Private')} )} /> - + + + {isPrivate ? t('Only_invited_users_can_acess_this_channel') : t('Everyone_can_access_this_channel')} + - - - {t('Federation_Matrix_Federated')} - {t(getFederationHintKey(federatedModule, federationEnabled))} - + + {t('Federation_Matrix_Federated')} )} /> - + + {t(getFederationHintKey(federatedModule, federationEnabled))} - - - {t('Read_only')} - - {readOnly ? t('Only_authorized_users_can_write_new_messages') : t('All_users_in_the_channel_can_write_new_messages')} - - + + {t('Read_only')} )} /> - + + + {readOnly ? t('Only_authorized_users_can_write_new_messages') : t('All_users_in_the_channel_can_write_new_messages')} + - - - {t('Encrypted')} - - {isPrivate ? t('Encrypted_channel_Description') : t('Encrypted_not_available')} - - + + {t('Encrypted')} )} /> - + + + {isPrivate ? t('Encrypted_channel_Description') : t('Encrypted_not_available')} + - - - {t('Broadcast')} - {t('Broadcast_channel_Description')} - + + {t('Broadcast')} )} /> - + + {t('Broadcast_channel_Description')} {t('Add_members')} diff --git a/apps/meteor/client/sidebar/header/CreateDirectMessage.tsx b/apps/meteor/client/sidebar/header/CreateDirectMessage.tsx index 853de76b665a28bc7bb135f4b93899386ae80146..626d1202a8e037a264123e7118c5c4dac0034cef 100644 --- a/apps/meteor/client/sidebar/header/CreateDirectMessage.tsx +++ b/apps/meteor/client/sidebar/header/CreateDirectMessage.tsx @@ -1,7 +1,7 @@ import type { IUser } from '@rocket.chat/core-typings'; import { Box, Modal, Button, FieldGroup, Field, FieldRow, FieldLabel, FieldError } from '@rocket.chat/fuselage'; import { useUniqueId } from '@rocket.chat/fuselage-hooks'; -import { useTranslation, useEndpoint, useToastMessageDispatch } from '@rocket.chat/ui-contexts'; +import { useTranslation, useEndpoint, useToastMessageDispatch, useSetting } from '@rocket.chat/ui-contexts'; import { useMutation } from '@tanstack/react-query'; import React, { memo } from 'react'; import { useForm, Controller } from 'react-hook-form'; @@ -11,6 +11,7 @@ import { goToRoomById } from '../../lib/utils/goToRoomById'; const CreateDirectMessage = ({ onClose }: { onClose: () => void }) => { const t = useTranslation(); + const directMaxUsers = useSetting('DirectMesssage_maxUsers') || 1; const membersFieldId = useUniqueId(); const dispatchToastMessage = useToastMessageDispatch(); @@ -55,7 +56,13 @@ const CreateDirectMessage = ({ onClose }: { onClose: () => void }) => { + users.length + 1 > directMaxUsers + ? t('error-direct-message-max-user-exceeded', { maxUsers: directMaxUsers }) + : undefined, + }} control={control} render={({ field: { name, onChange, value, onBlur } }) => ( void }): ReactElement => - - - {t('Teams_New_Private_Label')} - - {isPrivate ? t('Teams_New_Private_Description_Enabled') : t('Teams_New_Private_Description_Disabled')} - - + + {t('Teams_New_Private_Label')} void }): ReactElement => )} /> - + + + {isPrivate ? t('Teams_New_Private_Description_Enabled') : t('Teams_New_Private_Description_Disabled')} + - - - {t('Teams_New_Read_only_Label')} - - {readOnly ? t('Only_authorized_users_can_write_new_messages') : t('Teams_New_Read_only_Description')} - - + + {t('Teams_New_Read_only_Label')} void }): ReactElement => /> )} /> - + + + {readOnly ? t('Only_authorized_users_can_write_new_messages') : t('Teams_New_Read_only_Description')} + - - - {t('Teams_New_Encrypted_Label')} - - {isPrivate ? t('Teams_New_Encrypted_Description_Enabled') : t('Teams_New_Encrypted_Description_Disabled')} - - + + {t('Teams_New_Encrypted_Label')} void }): ReactElement => /> )} /> - + + + {isPrivate ? t('Teams_New_Encrypted_Description_Enabled') : t('Teams_New_Encrypted_Description_Disabled')} + - - - {t('Teams_New_Broadcast_Label')} - {t('Teams_New_Broadcast_Description')} - + + {t('Teams_New_Broadcast_Label')} void }): ReactElement => )} /> - + + {t('Teams_New_Broadcast_Description')} diff --git a/apps/meteor/client/sidebar/header/EditStatusModal.tsx b/apps/meteor/client/sidebar/header/EditStatusModal.tsx index 18ee86a19c27dbb7cd4f67cec2df8e8126233dff..ba250d92707bd06c53870c780b13d07dd34948c3 100644 --- a/apps/meteor/client/sidebar/header/EditStatusModal.tsx +++ b/apps/meteor/client/sidebar/header/EditStatusModal.tsx @@ -1,6 +1,6 @@ import type { IUser } from '@rocket.chat/core-typings'; import { Field, TextInput, FieldGroup, Modal, Button, Box, FieldLabel, FieldRow, FieldError, FieldHint } from '@rocket.chat/fuselage'; -import { useMutableCallback } from '@rocket.chat/fuselage-hooks'; +import { useLocalStorage, useMutableCallback } 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'; @@ -17,9 +17,11 @@ type EditStatusModalProps = { 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(userStatusText); + const [statusText, setStatusText] = useState(initialStatusText); const [statusType, setStatusType] = useState(userStatus); const [statusTextError, setStatusTextError] = useState(); @@ -40,6 +42,7 @@ const EditStatusModal = ({ onClose, userStatus, userStatusText }: EditStatusModa 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 }); diff --git a/apps/meteor/client/sidebar/header/HeaderUnstable.tsx b/apps/meteor/client/sidebar/header/HeaderUnstable.tsx index 319d3fb25e16a3746aec6ae5664e0e7f6a972586..c732b646ccc63c1b08939731304a7b8be76dd2c3 100644 --- a/apps/meteor/client/sidebar/header/HeaderUnstable.tsx +++ b/apps/meteor/client/sidebar/header/HeaderUnstable.tsx @@ -15,7 +15,7 @@ const HeaderUnstable = (): ReactElement => { return ( - + {uid && ( <> diff --git a/apps/meteor/client/sidebar/header/UserAvatarWithStatus.tsx b/apps/meteor/client/sidebar/header/UserAvatarWithStatus.tsx index f962125f681cfb1a22faf8d24ea5b8db7854b2bd..6fa08f5e025369c9c2d1c633a1e2cf0af4abefe7 100644 --- a/apps/meteor/client/sidebar/header/UserAvatarWithStatus.tsx +++ b/apps/meteor/client/sidebar/header/UserAvatarWithStatus.tsx @@ -1,10 +1,10 @@ import { css } from '@rocket.chat/css-in-js'; import { Box } from '@rocket.chat/fuselage'; +import { UserAvatar } from '@rocket.chat/ui-avatar'; import { useSetting, useUser, useTranslation } from '@rocket.chat/ui-contexts'; import React from 'react'; import { UserStatus } from '../../components/UserStatus'; -import UserAvatar from '../../components/avatar/UserAvatar'; const anon = { _id: '', diff --git a/apps/meteor/client/sidebar/header/UserMenuHeader.tsx b/apps/meteor/client/sidebar/header/UserMenuHeader.tsx index 515d4153576d75387e7b6e91f5770794054fde2c..511b2c2a1e45b34bdae8fa54722e9f6f22ffcc92 100644 --- a/apps/meteor/client/sidebar/header/UserMenuHeader.tsx +++ b/apps/meteor/client/sidebar/header/UserMenuHeader.tsx @@ -1,11 +1,11 @@ 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 UserAvatar from '../../components/avatar/UserAvatar'; import { useUserDisplayName } from '../../hooks/useUserDisplayName'; const UserMenuHeader = ({ user }: { user: IUser }) => { diff --git a/apps/meteor/client/sidebar/header/hooks/useStatusItems.tsx b/apps/meteor/client/sidebar/header/hooks/useStatusItems.tsx index 217c5c53f5732ce5fe38d50fcdf9a6446b571cac..026f7c80400eda182b881c9feea9d740f6cc958c 100644 --- a/apps/meteor/client/sidebar/header/hooks/useStatusItems.tsx +++ b/apps/meteor/client/sidebar/header/hooks/useStatusItems.tsx @@ -1,77 +1,87 @@ -import type { IUser, ValueOf } from '@rocket.chat/core-typings'; -import { UserStatus as UserStatusEnum } from '@rocket.chat/core-typings'; import { Box } from '@rocket.chat/fuselage'; -import type { TranslationKey } from '@rocket.chat/ui-contexts'; -import { useEndpoint, useSetting, useTranslation } from '@rocket.chat/ui-contexts'; -import React from 'react'; +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 { userStatus } from '../../../../app/user-status/client'; 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'; -const isDefaultStatus = (id: string): boolean => (Object.values(UserStatusEnum) as string[]).includes(id); -const isDefaultStatusName = (_name: string, id: string): _name is UserStatusEnum => isDefaultStatus(id); -const translateStatusName = (t: ReturnType, status: (typeof userStatus.list)['']): string => { - if (isDefaultStatusName(status.name, status.id)) { - return t(status.name as TranslationKey); - } +export const useStatusItems = (): GenericMenuItemProps[] => { + // We should lift this up to somewhere else if we want to use it in other places - return status.name; -}; + userStatuses.invisibleAllowed = useSetting('Accounts_AllowInvisibleStatusOption', true); -export const useStatusItems = (user: IUser): GenericMenuItemProps[] => { - const t = useTranslation(); - const presenceDisabled = useSetting('Presence_broadcast_disabled'); - const setStatus = useEndpoint('POST', '/v1/users.setStatus'); + const queryClient = useQueryClient(); - const setStatusAction = (status: (typeof userStatus.list)['']): void => { - setStatus({ status: status.statusType, message: !isDefaultStatus(status.id) ? status.name : '' }); - void callbacks.run('userStatusManuallySet', status); - }; + useEffect( + () => + userStatuses.watch(() => { + queryClient.setQueryData(['user-statuses'], Array.from(userStatuses)); + }), + [queryClient], + ); - const filterInvisibleStatus = !useSetting('Accounts_AllowInvisibleStatusOption') - ? (status: ValueOf<(typeof userStatus)['list']>): boolean => status.name !== 'invisible' - : (): boolean => true; + const { t } = useTranslation(); - const handleCustomStatus = useCustomStatusModalHandler(); + 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 handleStatusDisabledModal = useStatusDisabledModal(); + const presenceDisabled = useSetting('Presence_broadcast_disabled', false); - const presenceDisabledItem = { - id: 'presence-disabled', - content: ( - - - {t('User_status_disabled')} - - - {t('Learn_more')} - - - ), - }; + 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 statusItems = Object.values(userStatus.list) - .filter(filterInvisibleStatus) - .map((status) => { - const name = status.localizeName ? translateStatusName(t, status) : status.name; - const modifier = status.statusType || user?.status; - return { - id: status.id, - status: , - content: , - onClick: () => setStatusAction(status), - disabled: presenceDisabled, - }; - }); + const handleStatusDisabledModal = useStatusDisabledModal(); + const handleCustomStatus = useCustomStatusModalHandler(); return [ - ...(presenceDisabled ? [presenceDisabledItem] : []), - ...statusItems, + ...(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/sidebar/header/hooks/useUserMenu.tsx b/apps/meteor/client/sidebar/header/hooks/useUserMenu.tsx index 111065b0ac46ef39e1b95b09a12e8f4a2b99b498..de43d30306e96d30399722237e6fdfdf56de700b 100644 --- a/apps/meteor/client/sidebar/header/hooks/useUserMenu.tsx +++ b/apps/meteor/client/sidebar/header/hooks/useUserMenu.tsx @@ -11,7 +11,7 @@ import { useStatusItems } from './useStatusItems'; export const useUserMenu = (user: IUser) => { const t = useTranslation(); - const statusItems = useStatusItems(user); + const statusItems = useStatusItems(); const accountItems = useAccountItems(); const logout = useLogout(); diff --git a/apps/meteor/client/sidebar/hooks/useAvatarTemplate.tsx b/apps/meteor/client/sidebar/hooks/useAvatarTemplate.tsx index 5bb49da37418f731336c53d3dcad46d533c9ed34..9fd1023a32e726b15bd3152fb1140b281954ef19 100644 --- a/apps/meteor/client/sidebar/hooks/useAvatarTemplate.tsx +++ b/apps/meteor/client/sidebar/hooks/useAvatarTemplate.tsx @@ -1,10 +1,9 @@ import type { IRoom } from '@rocket.chat/core-typings'; +import { RoomAvatar } from '@rocket.chat/ui-avatar'; import { useUserPreference } from '@rocket.chat/ui-contexts'; import type { ComponentType } from 'react'; import React, { useMemo } from 'react'; -import RoomAvatar from '../../components/avatar/RoomAvatar'; - export const useAvatarTemplate = ( sidebarViewMode?: 'extended' | 'medium' | 'condensed', sidebarDisplayAvatar?: boolean, diff --git a/apps/meteor/client/sidebar/search/SearchList.tsx b/apps/meteor/client/sidebar/search/SearchList.tsx index e63be149cc7d2597c607371fa130c361db488afe..82d6d12c62138983a5fe29c30318d03de53e6cb5 100644 --- a/apps/meteor/client/sidebar/search/SearchList.tsx +++ b/apps/meteor/client/sidebar/search/SearchList.tsx @@ -255,14 +255,14 @@ const SearchList = forwardRef(function SearchList({ onClose }: SearchListProps, }); const resetCursor = useMutableCallback(() => { - itemIndexRef.current = 0; - listRef.current?.scrollToIndex({ index: itemIndexRef.current }); - - selectedElement.current = boxRef.current?.querySelector('a.rcx-sidebar-item'); - - if (selectedElement.current) { - toggleSelectionState(selectedElement.current, undefined, cursorRef?.current || undefined); - } + setTimeout(() => { + itemIndexRef.current = 0; + listRef.current?.scrollToIndex({ index: itemIndexRef.current }); + selectedElement.current = boxRef.current?.querySelector('a.rcx-sidebar-item'); + if (selectedElement.current) { + toggleSelectionState(selectedElement.current, undefined, cursorRef?.current || undefined); + } + }, 0); }); usePreventDefault(boxRef); @@ -303,9 +303,12 @@ const SearchList = forwardRef(function SearchList({ onClose }: SearchListProps, listRef.current?.scrollToIndex({ index: itemIndexRef.current }); selectedElement.current = currentElement; }, - Enter: () => { - if (selectedElement.current) { + Enter: (event) => { + event.preventDefault(); + if (selectedElement.current && items.length > 0) { selectedElement.current.click(); + } else { + onClose(); } }, }); diff --git a/apps/meteor/client/startup/UserDeleted.ts b/apps/meteor/client/startup/UserDeleted.ts deleted file mode 100644 index bbaeb6bc02296c1c596ccee7a8881426c40cdbf0..0000000000000000000000000000000000000000 --- a/apps/meteor/client/startup/UserDeleted.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { Meteor } from 'meteor/meteor'; - -import { ChatMessage } from '../../app/models/client'; -import { Notifications } from '../../app/notifications/client'; - -Meteor.startup(() => { - Notifications.onLogged('Users:Deleted', ({ userId, messageErasureType, replaceByUser }) => { - if (messageErasureType === 'Unlink' && replaceByUser) { - return ChatMessage.update( - { - 'u._id': userId, - }, - { - $set: { - 'alias': replaceByUser.alias, - 'u._id': replaceByUser._id, - 'u.username': replaceByUser.username, - 'u.name': undefined, - }, - }, - { multi: true }, - ); - } - ChatMessage.remove({ - 'u._id': userId, - }); - }); -}); diff --git a/apps/meteor/client/startup/accounts.ts b/apps/meteor/client/startup/accounts.ts index ed99ea8238beb19269c4e802eb3e6a35cbacd911..3be110bc0a09ae6143dfd70b8f1215a79cb0b309 100644 --- a/apps/meteor/client/startup/accounts.ts +++ b/apps/meteor/client/startup/accounts.ts @@ -1,18 +1,26 @@ import { Accounts } from 'meteor/accounts-base'; import { Meteor } from 'meteor/meteor'; +import { Tracker } from 'meteor/tracker'; +import { mainReady } from '../../app/ui-utils/client'; import { sdk } from '../../app/utils/client/lib/SDKClient'; import { t } from '../../app/utils/lib/i18n'; import { dispatchToastMessage } from '../lib/toast'; Accounts.onEmailVerificationLink((token: string) => { Accounts.verifyEmail(token, (error) => { - if (error instanceof Meteor.Error && error.error === 'verify-email') { - dispatchToastMessage({ type: 'success', message: t('Email_verified') }); - void sdk.call('afterVerifyEmail'); - return; - } - - dispatchToastMessage({ type: 'error', message: error }); + Tracker.autorun(() => { + if (mainReady.get()) { + if (error) { + dispatchToastMessage({ type: 'error', message: error }); + throw new Meteor.Error('verify-email', 'E-mail not verified'); + } else { + Tracker.nonreactive(() => { + void sdk.call('afterVerifyEmail'); + }); + dispatchToastMessage({ type: 'success', message: t('Email_verified') }); + } + } + }); }); }); diff --git a/apps/meteor/client/startup/actionButtons/pinMessage.ts b/apps/meteor/client/startup/actionButtons/pinMessage.tsx similarity index 66% rename from apps/meteor/client/startup/actionButtons/pinMessage.ts rename to apps/meteor/client/startup/actionButtons/pinMessage.tsx index 970eb28349c0cc218170bc35a042380e817c5f7f..b383b4a3c6484d23d386a00eac347644635547dc 100644 --- a/apps/meteor/client/startup/actionButtons/pinMessage.ts +++ b/apps/meteor/client/startup/actionButtons/pinMessage.tsx @@ -4,10 +4,12 @@ import { hasAtLeastOnePermission } from '../../../app/authorization/client'; import { settings } from '../../../app/settings/client'; import { MessageAction } from '../../../app/ui-utils/client'; import { sdk } from '../../../app/utils/client/lib/SDKClient'; +import { imperativeModal } from '../../lib/imperativeModal'; import { queryClient } from '../../lib/queryClient'; import { roomCoordinator } from '../../lib/rooms/roomCoordinator'; import { dispatchToastMessage } from '../../lib/toast'; import { messageArgs } from '../../lib/utils/messageArgs'; +import PinMessageModal from '../../views/room/modals/PinMessageModal'; Meteor.startup(() => { MessageAction.addButton({ @@ -18,13 +20,25 @@ Meteor.startup(() => { context: ['pinned', 'message', 'message-mobile', 'threads', 'direct', 'videoconf', 'videoconf-threads'], async action(_, props) { const { message = messageArgs(this).msg } = props; - message.pinned = true; - try { - await sdk.call('pinMessage', message); - queryClient.invalidateQueries(['rooms', message.rid, 'pinned-messages']); - } catch (error) { - dispatchToastMessage({ type: 'error', message: error }); - } + const onConfirm = async () => { + message.pinned = true; + try { + await sdk.call('pinMessage', message); + queryClient.invalidateQueries(['rooms', message.rid, 'pinned-messages']); + } catch (error) { + dispatchToastMessage({ type: 'error', message: error }); + } + imperativeModal.close(); + }; + + imperativeModal.open({ + component: PinMessageModal, + props: { + message, + onConfirm, + onCancel: () => imperativeModal.close(), + }, + }); }, condition({ message, subscription, room }) { if (!settings.get('Message_AllowPinning') || message.pinned || !subscription) { diff --git a/apps/meteor/client/startup/customOAuth.ts b/apps/meteor/client/startup/customOAuth.ts index 5b0e3dfb426158acca8e7ed5da06742b3eb87067..5796814444cfcac7ecb8be591160e31451370dd3 100644 --- a/apps/meteor/client/startup/customOAuth.ts +++ b/apps/meteor/client/startup/customOAuth.ts @@ -1,25 +1,20 @@ import { Meteor } from 'meteor/meteor'; -import { ServiceConfiguration } from 'meteor/service-configuration'; -import { CustomOAuth } from '../../app/custom-oauth/client/custom_oauth_client'; +import { CustomOAuth } from '../../app/custom-oauth/client/CustomOAuth'; +import { loginServices } from '../lib/loginServices'; Meteor.startup(() => { - ServiceConfiguration.configurations - .find({ - custom: true, - }) - .observe({ - async added(record) { - const { isOauthCustomConfiguration } = await import('@rocket.chat/rest-typings'); - if (!isOauthCustomConfiguration(record)) { - return; - } + loginServices.onLoad((services) => { + for (const service of services) { + if (!('custom' in service && service.custom)) { + continue; + } - new CustomOAuth(record.service, { - serverURL: record.serverURL, - authorizePath: record.authorizePath, - scope: record.scope, - }); - }, - }); + new CustomOAuth(service.service, { + serverURL: service.serverURL, + authorizePath: service.authorizePath, + scope: service.scope, + }); + } + }); }); diff --git a/apps/meteor/client/startup/e2e.ts b/apps/meteor/client/startup/e2e.ts index ddfbada67c2ae632036f8d04c14663cec00636eb..f9cf156f8d8b39b35a36917ab86b0fac663de9bc 100644 --- a/apps/meteor/client/startup/e2e.ts +++ b/apps/meteor/client/startup/e2e.ts @@ -4,8 +4,8 @@ import { Tracker } from 'meteor/tracker'; import { e2e } from '../../app/e2e/client/rocketchat.e2e'; import { Subscriptions, ChatRoom } from '../../app/models/client'; -import { Notifications } from '../../app/notifications/client'; import { settings } from '../../app/settings/client'; +import { sdk } from '../../app/utils/client/lib/SDKClient'; import { onClientBeforeSendMessage } from '../lib/onClientBeforeSendMessage'; import { onClientMessageReceived } from '../lib/onClientMessageReceived'; import { isLayoutEmbedded } from '../lib/utils/isLayoutEmbedded'; @@ -38,23 +38,25 @@ Meteor.startup(() => { let observable: Meteor.LiveQueryHandle | null = null; let offClientMessageReceived: undefined | (() => void); let offClientBeforeSendMessage: undefined | (() => void); + let unsubNotifyUser: undefined | (() => void); Tracker.autorun(() => { if (!e2e.isReady()) { offClientMessageReceived?.(); - Notifications.unUser('e2ekeyRequest'); + unsubNotifyUser?.(); + unsubNotifyUser = undefined; observable?.stop(); offClientBeforeSendMessage?.(); return; } - Notifications.onUser('e2ekeyRequest', async (roomId, keyId): Promise => { + unsubNotifyUser = sdk.stream('notify-user', [`${Meteor.userId()}/e2ekeyRequest`], async (roomId, keyId): Promise => { const e2eRoom = await e2e.getInstanceByRoomId(roomId); if (!e2eRoom) { return; } e2eRoom.provideKeyToUser(keyId); - }); + }).stop; observable = Subscriptions.find().observe({ changed: async (sub: ISubscription) => { diff --git a/apps/meteor/client/startup/forceLogout.ts b/apps/meteor/client/startup/forceLogout.ts index 9226229ae4182a733e00a24a108a134abf5f37f3..f882354062cdaa966136e9535381cc3da6acde31 100644 --- a/apps/meteor/client/startup/forceLogout.ts +++ b/apps/meteor/client/startup/forceLogout.ts @@ -2,7 +2,7 @@ import { Meteor } from 'meteor/meteor'; import { Session } from 'meteor/session'; import { Tracker } from 'meteor/tracker'; -import { Notifications } from '../../app/notifications/client'; +import { sdk } from '../../app/utils/client/lib/SDKClient'; Meteor.startup(() => { Tracker.autorun(() => { @@ -12,7 +12,7 @@ Meteor.startup(() => { return; } Session.set('force_logout', false); - Notifications.onUser('force_logout', () => { + sdk.stream('notify-user', [`${userId}/force_logout`], () => { Session.set('force_logout', true); }); }); diff --git a/apps/meteor/client/startup/iframeCommands.ts b/apps/meteor/client/startup/iframeCommands.ts index cb946ba44176cc459a739fcbca68abc2ed44d344..f0db83ccdcbffa77122ffe0907d8aa433da40323 100644 --- a/apps/meteor/client/startup/iframeCommands.ts +++ b/apps/meteor/client/startup/iframeCommands.ts @@ -2,7 +2,6 @@ import type { UserStatus, IUser } from '@rocket.chat/core-typings'; import { escapeRegExp } from '@rocket.chat/string-helpers'; import type { LocationPathname } from '@rocket.chat/ui-contexts'; import { Meteor } from 'meteor/meteor'; -import { ServiceConfiguration } from 'meteor/service-configuration'; import { settings } from '../../app/settings/client'; import { AccountBox } from '../../app/ui-utils/client/lib/AccountBox'; @@ -10,6 +9,7 @@ import { sdk } from '../../app/utils/client/lib/SDKClient'; import { afterLogoutCleanUpCallback } from '../../lib/callbacks/afterLogoutCleanUpCallback'; import { capitalize, ltrim, rtrim } from '../../lib/utils/stringUtils'; import { baseURI } from '../lib/baseURI'; +import { loginServices } from '../lib/loginServices'; import { router } from '../providers/RouterProvider'; const commands = { @@ -55,7 +55,7 @@ const commands = { } if (typeof data.service === 'string' && window.ServiceConfiguration) { - const customOauth = ServiceConfiguration.configurations.findOne({ service: data.service }); + const customOauth = loginServices.getLoginService(data.service); if (customOauth) { const customLoginWith = (Meteor as any)[`loginWith${capitalize(customOauth.service, true)}`]; diff --git a/apps/meteor/client/startup/incomingMessages.ts b/apps/meteor/client/startup/incomingMessages.ts index 88e83b9cf5e85eb8727ffbbcf7eef8154b4835af..e9659cc24724470f85e52a1e83904354f66bb907 100644 --- a/apps/meteor/client/startup/incomingMessages.ts +++ b/apps/meteor/client/startup/incomingMessages.ts @@ -2,8 +2,8 @@ import type { IMessage } from '@rocket.chat/core-typings'; import { Meteor } from 'meteor/meteor'; import { ChatMessage } from '../../app/models/client'; -import { Notifications } from '../../app/notifications/client'; import { CachedCollectionManager } from '../../app/ui-cached-collection/client'; +import { sdk } from '../../app/utils/client/lib/SDKClient'; Meteor.startup(() => { Tracker.autorun(() => { @@ -11,7 +11,9 @@ Meteor.startup(() => { return; } - Notifications.onUser('message', (msg: IMessage) => { + // Only event I found triggers this is from ephemeral messages + // Other types of messages come from another stream + sdk.stream('notify-user', [`${Meteor.userId()}/message`], (msg: IMessage) => { msg.u = msg.u || { username: 'rocket.cat' }; msg.private = true; @@ -20,7 +22,7 @@ Meteor.startup(() => { }); CachedCollectionManager.onLogin(() => { - Notifications.onUser('subscriptions-changed', (_action, sub) => { + sdk.stream('notify-user', [`${Meteor.userId()}/subscriptions-changed`], (_action, sub) => { ChatMessage.update( { rid: sub.rid, diff --git a/apps/meteor/client/startup/index.ts b/apps/meteor/client/startup/index.ts index 8d85beb18df80e274df06f7f7174aad85e054b8a..bf6814617e4afd61fa5eca619b5a02c19fac54e5 100644 --- a/apps/meteor/client/startup/index.ts +++ b/apps/meteor/client/startup/index.ts @@ -1,6 +1,5 @@ import '../lib/rooms/roomTypes'; import './absoluteUrl'; -import './accounts'; import './actionButtons'; import './afterLogoutCleanUp'; import './appRoot'; @@ -11,13 +10,11 @@ import './e2e'; import './forceLogout'; import './iframeCommands'; import './incomingMessages'; -import './ldap'; import './loadMissedMessages'; import './loginViaQuery'; import './messageObserve'; import './messageTypes'; import './notifications'; -import './oauth'; import './otr'; import './reloadRoomAfterLogin'; import './roles'; @@ -28,6 +25,5 @@ import './slashCommands'; import './startup'; import './streamMessage'; import './unread'; -import './UserDeleted'; import './userRoles'; import './userStatusManuallySet'; diff --git a/apps/meteor/client/startup/ldap.ts b/apps/meteor/client/startup/ldap.ts deleted file mode 100644 index 13f6048bb2eb2663e863d433c331fe8194fbffb8..0000000000000000000000000000000000000000 --- a/apps/meteor/client/startup/ldap.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { Accounts } from 'meteor/accounts-base'; -import { Meteor } from 'meteor/meteor'; - -(Meteor as any).loginWithLDAP = function (username: string, password: string, callback?: (err?: any) => void): void { - Accounts.callLoginMethod({ - methodArguments: [ - { - ldap: true, - username, - ldapPass: password, - ldapOptions: {}, - }, - ], - userCallback: callback, - }); -}; diff --git a/apps/meteor/client/startup/notifications/index.ts b/apps/meteor/client/startup/notifications/index.ts index e94aaacdd7837baed947e49ff35827b1bf61f482..866dae4d52f3abfb4264adb3d112aab4f9f74e94 100644 --- a/apps/meteor/client/startup/notifications/index.ts +++ b/apps/meteor/client/startup/notifications/index.ts @@ -1,4 +1,2 @@ import './konchatNotifications'; import './notification'; -import './updateAvatar'; -import './usersNameChanged'; diff --git a/apps/meteor/client/startup/notifications/konchatNotifications.ts b/apps/meteor/client/startup/notifications/konchatNotifications.ts index 723901b7f70b7e4ebb5d75a2299ea167d3e52be6..cd16d4264479dcce13b7b15a73b5396fe93d6b1a 100644 --- a/apps/meteor/client/startup/notifications/konchatNotifications.ts +++ b/apps/meteor/client/startup/notifications/konchatNotifications.ts @@ -4,10 +4,10 @@ import { Tracker } from 'meteor/tracker'; import { lazy } from 'react'; import { CachedChatSubscription } from '../../../app/models/client'; -import { Notifications } from '../../../app/notifications/client'; import { settings } from '../../../app/settings/client'; import { KonchatNotification } from '../../../app/ui/client/lib/KonchatNotification'; import { getUserPreference } from '../../../app/utils/client'; +import { sdk } from '../../../app/utils/client/lib/SDKClient'; import { RoomManager } from '../../lib/RoomManager'; import { imperativeModal } from '../../lib/imperativeModal'; import { fireGlobalEvent } from '../../lib/utils/fireGlobalEvent'; @@ -71,17 +71,18 @@ Meteor.startup(() => { }; Tracker.autorun(() => { if (!Meteor.userId() || !settings.get('Outlook_Calendar_Enabled')) { - return Notifications.unUser('calendar'); + sdk.stop('notify-user', `${Meteor.userId()}/calendar`); } - Notifications.onUser('calendar', notifyUserCalendar); + sdk.stream('notify-user', [`${Meteor.userId()}/calendar`], notifyUserCalendar); }); Tracker.autorun(() => { if (!Meteor.userId()) { return; } - Notifications.onUser('notification', (notification) => { + + sdk.stream('notify-user', [`${Meteor.userId()}/notification`], (notification) => { const openedRoomId = ['channel', 'group', 'direct'].includes(router.getRouteName()!) ? RoomManager.opened : undefined; // This logic is duplicated in /client/startup/unread.coffee. @@ -111,7 +112,7 @@ Meteor.startup(() => { void notifyNewRoom(sub); }); - Notifications.onUser('subscriptions-changed', (action, sub) => { + sdk.stream('notify-user', [`${Meteor.userId()}/subscriptions-changed`], (action, sub) => { if (action === 'removed') { return; } diff --git a/apps/meteor/client/startup/notifications/updateAvatar.ts b/apps/meteor/client/startup/notifications/updateAvatar.ts deleted file mode 100644 index b26e184aca205d2212b1af4072a278cee617abc9..0000000000000000000000000000000000000000 --- a/apps/meteor/client/startup/notifications/updateAvatar.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { Meteor } from 'meteor/meteor'; - -import { Notifications } from '../../../app/notifications/client'; - -Meteor.startup(() => { - Notifications.onLogged('updateAvatar', (data) => { - if ('username' in data) { - const { username, etag } = data; - username && Meteor.users.update({ username }, { $set: { avatarETag: etag } }); - } - }); -}); diff --git a/apps/meteor/client/startup/notifications/usersNameChanged.ts b/apps/meteor/client/startup/notifications/usersNameChanged.ts deleted file mode 100644 index a1dacf9a1945cbe3e1ac9ea7b8d1403811be62de..0000000000000000000000000000000000000000 --- a/apps/meteor/client/startup/notifications/usersNameChanged.ts +++ /dev/null @@ -1,69 +0,0 @@ -import type { IUser } from '@rocket.chat/core-typings'; -import { Meteor } from 'meteor/meteor'; - -import { Messages, Subscriptions } from '../../../app/models/client'; -import { Notifications } from '../../../app/notifications/client'; - -type UsersNameChangedEvent = Partial; - -Meteor.startup(() => { - Notifications.onLogged('Users:NameChanged', ({ _id, name, username }: UsersNameChangedEvent) => { - Messages.update( - { - 'u._id': _id, - }, - { - $set: { - 'u.username': username, - 'u.name': name, - }, - }, - { - multi: true, - }, - ); - - Messages.update( - { - 'editedBy._id': _id, - }, - { - $set: { - 'editedBy.username': username, - }, - }, - { - multi: true, - }, - ); - - Messages.update( - { - mentions: { - $elemMatch: { _id }, - }, - }, - { - $set: { - 'mentions.$.username': username, - 'mentions.$.name': name, - }, - }, - { - multi: true, - }, - ); - - Subscriptions.update( - { - name: username, - t: 'd', - }, - { - $set: { - fname: name, - }, - }, - ); - }); -}); diff --git a/apps/meteor/client/startup/oauth.ts b/apps/meteor/client/startup/oauth.ts deleted file mode 100644 index 23f5ec8246b46768c93e3f3bda341178e6542347..0000000000000000000000000000000000000000 --- a/apps/meteor/client/startup/oauth.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { Meteor } from 'meteor/meteor'; -import { OAuth } from 'meteor/oauth'; - -// OAuth._retrieveCredentialSecret is a meteor method modified to also check the global localStorage -// This was necessary because of the "Forget User Session on Window Close" setting. -// The setting changes Meteor._localStorage to use the browser's session storage instead, but that doesn't happen on the Oauth's popup code. - -Meteor.startup(() => { - const meteorOAuthRetrieveCredentialSecret = OAuth._retrieveCredentialSecret; - OAuth._retrieveCredentialSecret = (credentialToken: string): string | null => { - let secret = meteorOAuthRetrieveCredentialSecret.call(OAuth, credentialToken); - if (!secret) { - const localStorageKey = `${OAuth._storageTokenPrefix}${credentialToken}`; - secret = localStorage.getItem(localStorageKey); - localStorage.removeItem(localStorageKey); - } - - return secret; - }; -}); diff --git a/apps/meteor/client/startup/otr.ts b/apps/meteor/client/startup/otr.ts index d0603a8fdfd22e6912e98f3f64371b85ac91da32..084f4311c499f226cba7e6b377e46361f9f85c68 100644 --- a/apps/meteor/client/startup/otr.ts +++ b/apps/meteor/client/startup/otr.ts @@ -1,8 +1,7 @@ -import type { IMessage, AtLeast } from '@rocket.chat/core-typings'; +import { isOTRMessage } from '@rocket.chat/core-typings'; import { Meteor } from 'meteor/meteor'; import { Tracker } from 'meteor/tracker'; -import { Notifications } from '../../app/notifications/client'; import OTR from '../../app/otr/client/OTR'; import { OtrRoomState } from '../../app/otr/lib/OtrRoomState'; import { sdk } from '../../app/utils/client/lib/SDKClient'; @@ -12,45 +11,57 @@ import { onClientMessageReceived } from '../lib/onClientMessageReceived'; Meteor.startup(() => { Tracker.autorun(() => { - if (Meteor.userId()) { - Notifications.onUser('otr', (type, data) => { - if (!data.roomId || !data.userId || data.userId === Meteor.userId()) { - return; - } - const instanceByRoomId = OTR.getInstanceByRoomId(data.roomId); - - if (!instanceByRoomId) { - return; - } + const uid = Meteor.userId(); - instanceByRoomId.onUserStream(type, data); - }); + if (!uid) { + return; } + + sdk.stream('notify-user', [`${uid}/otr`], (type, data) => { + if (!data.roomId || !data.userId || data.userId === uid) { + return; + } + + const otrRoom = OTR.getInstanceByRoomId(uid, data.roomId); + otrRoom?.onUserStream(type, data); + }); }); - onClientBeforeSendMessage.use(async (message: AtLeast) => { - const instanceByRoomId = OTR.getInstanceByRoomId(message.rid); + onClientBeforeSendMessage.use(async (message) => { + const uid = Meteor.userId(); + + if (!uid) { + return message; + } - if (message.rid && instanceByRoomId && instanceByRoomId.getState() === OtrRoomState.ESTABLISHED) { - const msg = await instanceByRoomId.encrypt(message); + const otrRoom = OTR.getInstanceByRoomId(uid, message.rid); + + if (otrRoom && otrRoom.getState() === OtrRoomState.ESTABLISHED) { + const msg = await otrRoom.encrypt(message); return { ...message, msg, t: 'otr' }; } return message; }); - onClientMessageReceived.use(async (message: IMessage & { notification?: boolean }) => { - const instanceByRoomId = OTR.getInstanceByRoomId(message.rid); + onClientMessageReceived.use(async (message) => { + const uid = Meteor.userId(); - if (message.rid && instanceByRoomId && instanceByRoomId.getState() === OtrRoomState.ESTABLISHED) { - if (message?.notification) { - message.msg = t('Encrypted_message'); - return message; - } - if (message.t !== 'otr') { - return message; - } + if (!uid) { + return message; + } - const decrypted = await instanceByRoomId.decrypt(message.msg); + if (!isOTRMessage(message)) { + return message; + } + + if ('notification' in message) { + return { ...message, msg: t('Encrypted_message') }; + } + + const otrRoom = OTR.getInstanceByRoomId(uid, message.rid); + + if (otrRoom && otrRoom.getState() === OtrRoomState.ESTABLISHED) { + const decrypted = await otrRoom.decrypt(message.msg); if (typeof decrypted === 'string') { return { ...message, msg: decrypted }; } @@ -59,13 +70,16 @@ Meteor.startup(() => { if (ts) message.ts = ts; if (message.otrAck) { - const otrAck = await instanceByRoomId.decrypt(message.otrAck); + const otrAck = await otrRoom.decrypt(message.otrAck); if (typeof otrAck === 'string') { return { ...message, msg: otrAck }; } - if (ack === otrAck.text) message.t = 'otr-ack'; + + if (ack === otrAck.text) { + return { ...message, _id, t: 'otr-ack', msg }; + } } else if (userId !== Meteor.userId()) { - const encryptedAck = await instanceByRoomId.encryptText(ack); + const encryptedAck = await otrRoom.encryptText(ack); void sdk.call('updateOTRAck', { message, ack: encryptedAck }); } diff --git a/apps/meteor/client/startup/userRoles.ts b/apps/meteor/client/startup/userRoles.ts index a311148a6563922a1f8ace03acf1c5dfb50fa4c1..77ba6978d48505a297cfc5aba5c1fde797c3f8e0 100644 --- a/apps/meteor/client/startup/userRoles.ts +++ b/apps/meteor/client/startup/userRoles.ts @@ -2,7 +2,6 @@ import { Meteor } from 'meteor/meteor'; import { Tracker } from 'meteor/tracker'; import { UserRoles, ChatMessage } from '../../app/models/client'; -import { Notifications } from '../../app/notifications/client'; import { sdk } from '../../app/utils/client/lib/SDKClient'; import { dispatchToastMessage } from '../lib/toast'; @@ -20,7 +19,7 @@ Meteor.startup(() => { dispatchToastMessage({ type: 'error', message: error }); }); - Notifications.onLogged('roles-change', (role) => { + sdk.stream('notify-logged', ['roles-change'], (role) => { if (role.type === 'added') { if (!role.scope) { if (!role.u) { diff --git a/apps/meteor/client/views/account/accessibility/AccessibilityPage.tsx b/apps/meteor/client/views/account/accessibility/AccessibilityPage.tsx index bda1f0c6c5bbeb5f4d0d9a10cb91133ff49fc1ae..9bb4e57317cf408f2a891c5b413c83f40e4558e3 100644 --- a/apps/meteor/client/views/account/accessibility/AccessibilityPage.tsx +++ b/apps/meteor/client/views/account/accessibility/AccessibilityPage.tsx @@ -16,6 +16,7 @@ import { ToggleSwitch, } from '@rocket.chat/fuselage'; import { useUniqueId } from '@rocket.chat/fuselage-hooks'; +import { ExternalLink } from '@rocket.chat/ui-client'; import { useTranslation, useToastMessageDispatch, useEndpoint, useSetting } from '@rocket.chat/ui-contexts'; import { useMutation } from '@tanstack/react-query'; import React, { useMemo } from 'react'; @@ -52,6 +53,7 @@ const AccessibilityPage = () => { const clockModeId = useUniqueId(); const hideUsernamesId = useUniqueId(); const hideRolesId = useUniqueId(); + const linkListId = useUniqueId(); const { formState: { isDirty, dirtyFields, isSubmitting }, @@ -88,27 +90,41 @@ const AccessibilityPage = () => { - {t('Accessibility_activation')} + + {t('Accessibility_activation')} + +

{t('Learn_more_about_accessibility')}

+
    +
  • + {t('Accessibility_statement')} +
  • +
  • + {t('Glossary_of_simplified_terms')} +
  • +
  • + + {t('Accessibility_feature_documentation')} + +
  • +
{themes.map(({ id, title, description }, index) => { return ( - + {t(title)} - - ( - onChange(id)} checked={value === id} /> - )} - /> - - + ( + onChange(id)} checked={value === id} /> + )} + /> + {t(description)} @@ -134,18 +150,16 @@ const AccessibilityPage = () => { {t('Adjustable_font_size_description')} - + {t('Mentions_with_@_symbol')} - - ( - - )} - /> - - + ( + + )} + /> + { - + {t('Show_usernames')} + ( + onChange(!(e.target as HTMLInputElement).checked)} + /> + )} + /> + + {t('Show_or_hide_the_username_of_message_authors')} + + {displayRolesEnabled && ( + + {t('Show_roles')} ( onChange(!(e.target as HTMLInputElement).checked)} @@ -184,28 +216,6 @@ const AccessibilityPage = () => { )} /> -
- {t('Show_or_hide_the_username_of_message_authors')} -
- {displayRolesEnabled && ( - - - {t('Show_roles')} - - ( - onChange(!(e.target as HTMLInputElement).checked)} - /> - )} - /> - - {t('Show_or_hide_the_user_roles_of_message_authors')} )} diff --git a/apps/meteor/client/views/account/featurePreview/AccountFeaturePreviewPage.tsx b/apps/meteor/client/views/account/featurePreview/AccountFeaturePreviewPage.tsx index c271d0a2fee8ef497fdb67bcc4e874e9a534e30f..c8cd7138e5a6fbf1e1ca16bf73efaa0e3e2c23b7 100644 --- a/apps/meteor/client/views/account/featurePreview/AccountFeaturePreviewPage.tsx +++ b/apps/meteor/client/views/account/featurePreview/AccountFeaturePreviewPage.tsx @@ -106,12 +106,10 @@ const AccountFeaturePreviewPage = () => { {features.map((feature) => ( - + {t(feature.i18n)} - - - - + + {feature.description && {t(feature.description)}} {feature.imageUrl && } diff --git a/apps/meteor/client/views/account/integrations/AccountIntegrationsPage.tsx b/apps/meteor/client/views/account/integrations/AccountIntegrationsPage.tsx index 312cc89b65a00919c7d5e28b7aeb639674f0c9b8..2c11b7a384cd5bbc6beba57db0a1d905a759b208 100644 --- a/apps/meteor/client/views/account/integrations/AccountIntegrationsPage.tsx +++ b/apps/meteor/client/views/account/integrations/AccountIntegrationsPage.tsx @@ -1,49 +1,54 @@ -import type { IWebdavAccountIntegration } from '@rocket.chat/core-typings'; import type { SelectOption } from '@rocket.chat/fuselage'; import { SelectLegacy, Box, Button, Field, FieldLabel, FieldRow } from '@rocket.chat/fuselage'; -import { useMutableCallback } from '@rocket.chat/fuselage-hooks'; -import { useEndpoint, useToastMessageDispatch, useTranslation } from '@rocket.chat/ui-contexts'; -import type { ReactElement } from 'react'; +import { useEffectEvent } from '@rocket.chat/fuselage-hooks'; +import { useToastMessageDispatch, useTranslation } from '@rocket.chat/ui-contexts'; import React, { useMemo } from 'react'; import { useForm, Controller } from 'react-hook-form'; -import { WebdavAccounts } from '../../../../app/models/client'; import { Page, PageHeader, PageScrollableContentWithShadow } from '../../../components/Page'; -import { useReactiveValue } from '../../../hooks/useReactiveValue'; +import { useWebDAVAccountIntegrationsQuery } from '../../../hooks/webdav/useWebDAVAccountIntegrationsQuery'; import { getWebdavServerName } from '../../../lib/getWebdavServerName'; +import { useRemoveWebDAVAccountIntegrationMutation } from './hooks/useRemoveWebDAVAccountIntegrationMutation'; -const getWebdavAccounts = (): IWebdavAccountIntegration[] => WebdavAccounts.find().fetch(); +const AccountIntegrationsPage = () => { + const { data: webdavAccountIntegrations } = useWebDAVAccountIntegrationsQuery(); -const AccountIntegrationsPage = (): ReactElement => { - const t = useTranslation(); - const { handleSubmit, control } = useForm(); - const dispatchToastMessage = useToastMessageDispatch(); - const accounts = useReactiveValue(getWebdavAccounts); - const removeWebdavAccount = useEndpoint('POST', '/v1/webdav.removeWebdavAccount'); + const { handleSubmit, control } = useForm<{ accountSelected: string }>(); + + const options: SelectOption[] = useMemo( + () => webdavAccountIntegrations?.map(({ _id, ...current }) => [_id, getWebdavServerName(current)]) ?? [], + [webdavAccountIntegrations], + ); - const options: SelectOption[] = useMemo(() => accounts?.map(({ _id, ...current }) => [_id, getWebdavServerName(current)]), [accounts]); + const dispatchToastMessage = useToastMessageDispatch(); + const t = useTranslation(); - const handleClickRemove = useMutableCallback(({ accountSelected }) => { - try { - removeWebdavAccount({ accountId: accountSelected }); + const removeMutation = useRemoveWebDAVAccountIntegrationMutation({ + onSuccess: () => { dispatchToastMessage({ type: 'success', message: t('Webdav_account_removed') }); - } catch (error) { - dispatchToastMessage({ type: 'error', message: error as Error }); - } + }, + onError: (error) => { + dispatchToastMessage({ type: 'error', message: error }); + }, + }); + + const handleSubmitForm = useEffectEvent(({ accountSelected }) => { + removeMutation.mutate({ accountSelected }); }); return ( - + {t('WebDAV_Accounts')} ( + rules={{ required: true }} + render={({ field: { onChange, value, name, ref } }) => ( { /> )} /> - diff --git a/apps/meteor/client/views/account/integrations/AccountIntegrationsRoute.tsx b/apps/meteor/client/views/account/integrations/AccountIntegrationsRoute.tsx index ba16360b3d36645f4336362047c830e8163cb4b8..00121c68834590ffa9f32370ea5809e9d944d203 100644 --- a/apps/meteor/client/views/account/integrations/AccountIntegrationsRoute.tsx +++ b/apps/meteor/client/views/account/integrations/AccountIntegrationsRoute.tsx @@ -6,7 +6,7 @@ import NotAuthorizedPage from '../../notAuthorized/NotAuthorizedPage'; import AccountIntegrationsPage from './AccountIntegrationsPage'; const AccountIntegrationsRoute = (): ReactElement => { - const webdavEnabled = useSetting('Webdav_Integration_Enabled'); + const webdavEnabled = useSetting('Webdav_Integration_Enabled', false); if (!webdavEnabled) { return ; diff --git a/apps/meteor/client/views/account/integrations/hooks/useRemoveWebDAVAccountIntegrationMutation.tsx b/apps/meteor/client/views/account/integrations/hooks/useRemoveWebDAVAccountIntegrationMutation.tsx new file mode 100644 index 0000000000000000000000000000000000000000..d0eb6b6f2a12705e3f32000d4fd4ee6f6c4c62b3 --- /dev/null +++ b/apps/meteor/client/views/account/integrations/hooks/useRemoveWebDAVAccountIntegrationMutation.tsx @@ -0,0 +1,16 @@ +import { useEndpoint } from '@rocket.chat/ui-contexts'; +import type { UseMutationOptions } from '@tanstack/react-query'; +import { useMutation } from '@tanstack/react-query'; + +type UseRemoveWebDAVAccountIntegrationMutationOptions = Omit, 'mutationFn'>; + +export const useRemoveWebDAVAccountIntegrationMutation = (options?: UseRemoveWebDAVAccountIntegrationMutationOptions) => { + const removeWebdavAccount = useEndpoint('POST', '/v1/webdav.removeWebdavAccount'); + + return useMutation({ + mutationFn: async ({ accountSelected }: { accountSelected: string }) => { + await removeWebdavAccount({ accountId: accountSelected }); + }, + ...options, + }); +}; diff --git a/apps/meteor/client/views/account/omnichannel/PreferencesConversationTranscript.tsx b/apps/meteor/client/views/account/omnichannel/PreferencesConversationTranscript.tsx index 8b11367775dd6421f8a7debe79f89b7d5e0ee1b3..8cf34b6a56d4080c42f69fccba5ddeb53f2489e3 100644 --- a/apps/meteor/client/views/account/omnichannel/PreferencesConversationTranscript.tsx +++ b/apps/meteor/client/views/account/omnichannel/PreferencesConversationTranscript.tsx @@ -23,7 +23,7 @@ const PreferencesConversationTranscript = () => { - + {t('Omnichannel_transcript_pdf')} @@ -33,14 +33,12 @@ const PreferencesConversationTranscript = () => { - - - - + + {t('Accounts_Default_User_Preferences_omnichannelTranscriptPDF_Description')} - + {t('Omnichannel_transcript_email')} @@ -51,14 +49,8 @@ const PreferencesConversationTranscript = () => { )} - - - - + + {t('Accounts_Default_User_Preferences_omnichannelTranscriptEmail_Description')} diff --git a/apps/meteor/client/views/account/omnichannel/PreferencesGeneral.tsx b/apps/meteor/client/views/account/omnichannel/PreferencesGeneral.tsx index 67c06bd2c5b25587c378a6605d11fd671e97d482..2f58ed305be4941a6700a222ed5acb6a984a637b 100644 --- a/apps/meteor/client/views/account/omnichannel/PreferencesGeneral.tsx +++ b/apps/meteor/client/views/account/omnichannel/PreferencesGeneral.tsx @@ -1,4 +1,4 @@ -import { Box, Field, FieldGroup, FieldHint, FieldLabel, FieldRow, ToggleSwitch } from '@rocket.chat/fuselage'; +import { Field, FieldGroup, FieldHint, FieldLabel, FieldRow, ToggleSwitch } from '@rocket.chat/fuselage'; import { useUniqueId } from '@rocket.chat/fuselage-hooks'; import { useTranslation } from '@rocket.chat/ui-contexts'; import type { ReactElement } from 'react'; @@ -13,12 +13,10 @@ export const PreferencesGeneral = (): ReactElement => { return ( - + {t('Omnichannel_hide_conversation_after_closing')} - - - - + + {t('Omnichannel_hide_conversation_after_closing_description')} diff --git a/apps/meteor/client/views/account/preferences/PreferencesMessagesSection.tsx b/apps/meteor/client/views/account/preferences/PreferencesMessagesSection.tsx index 3edfabf23ac5d82fc98ec7110c4911d50d52cefc..b8b4366cf4ff636c86856a383fdc7f3f98dc1455 100644 --- a/apps/meteor/client/views/account/preferences/PreferencesMessagesSection.tsx +++ b/apps/meteor/client/views/account/preferences/PreferencesMessagesSection.tsx @@ -1,5 +1,5 @@ import type { SelectOption } from '@rocket.chat/fuselage'; -import { FieldRow, FieldLink, FieldHint, FieldLabel, Accordion, Field, Select, FieldGroup, ToggleSwitch, Box } from '@rocket.chat/fuselage'; +import { FieldRow, FieldLink, FieldHint, FieldLabel, Accordion, Field, Select, FieldGroup, ToggleSwitch } from '@rocket.chat/fuselage'; import { useUniqueId } from '@rocket.chat/fuselage-hooks'; import { useTranslation } from '@rocket.chat/ui-contexts'; import React, { useMemo } from 'react'; @@ -43,38 +43,34 @@ const PreferencesMessagesSection = () => { - + {t('Unread_Tray_Icon_Alert')} - - ( - - )} - /> - - + ( + + )} + /> + - + {t('Always_show_thread_replies_in_main_channel')} - - ( - - )} - /> - - + ( + + )} + /> + {t('Accounts_Default_User_Preferences_showThreadsInMainChannel_Description')} @@ -105,74 +101,64 @@ const PreferencesMessagesSection = () => { {t('Go_to_accessibility_and_appearance')} - + {t('Use_Emojis')} - - ( - - )} - /> - - + ( + + )} + /> + - + {t('Convert_Ascii_Emojis')} - - ( - - )} - /> - - + ( + + )} + /> + - + {t('Auto_Load_Images')} - - ( - - )} - /> - - + ( + + )} + /> + - + {t('Save_Mobile_Bandwidth')} - - ( - - )} - /> - - + ( + + )} + /> + - + {t('Collapse_Embedded_Media_By_Default')} - - ( - - )} - /> - - + ( + + )} + /> + {t('Hide_usernames')} @@ -183,32 +169,28 @@ const PreferencesMessagesSection = () => { {t('Go_to_accessibility_and_appearance')} - + {t('Hide_flextab')} - - ( - - )} - /> - - + ( + + )} + /> + - + {t('Display_avatars')} - - ( - - )} - /> - - + ( + + )} + /> + {t('Enter_Behaviour')} diff --git a/apps/meteor/client/views/account/preferences/PreferencesMyDataSection.tsx b/apps/meteor/client/views/account/preferences/PreferencesMyDataSection.tsx index 0b82d7441323046c1ec58ddc8393e8f068b8ea3a..5fd4540b5e5708343f163ba38bd3d511e9f796cf 100644 --- a/apps/meteor/client/views/account/preferences/PreferencesMyDataSection.tsx +++ b/apps/meteor/client/views/account/preferences/PreferencesMyDataSection.tsx @@ -1,4 +1,4 @@ -import { Accordion, Field, FieldGroup, FieldRow, ButtonGroup, Button, Box } from '@rocket.chat/fuselage'; +import { Accordion, ButtonGroup, Button, Box } from '@rocket.chat/fuselage'; import { useSetModal, useToastMessageDispatch, useMethod, useTranslation } from '@rocket.chat/ui-contexts'; import React, { useCallback } from 'react'; @@ -75,20 +75,14 @@ const PreferencesMyDataSection = () => { return ( - - - - - - - - - - + + + + ); }; diff --git a/apps/meteor/client/views/account/preferences/PreferencesNotificationsSection.tsx b/apps/meteor/client/views/account/preferences/PreferencesNotificationsSection.tsx index 1dfcdf917c2d59fd22b10fd211e749f981d67365..ddac7fda145b73efaaa4b66e56bbc72aa9827a72 100644 --- a/apps/meteor/client/views/account/preferences/PreferencesNotificationsSection.tsx +++ b/apps/meteor/client/views/account/preferences/PreferencesNotificationsSection.tsx @@ -1,6 +1,6 @@ import type { INotificationDesktop } from '@rocket.chat/core-typings'; import type { SelectOption } from '@rocket.chat/fuselage'; -import { Accordion, Field, FieldLabel, FieldRow, FieldHint, Select, FieldGroup, ToggleSwitch, Button, Box } from '@rocket.chat/fuselage'; +import { Accordion, Field, FieldLabel, FieldRow, FieldHint, Select, FieldGroup, ToggleSwitch, Button } from '@rocket.chat/fuselage'; import { useUniqueId } from '@rocket.chat/fuselage-hooks'; import type { TranslationKey } from '@rocket.chat/ui-contexts'; import { useUserPreference, useSetting, useTranslation } from '@rocket.chat/ui-contexts'; @@ -113,24 +113,22 @@ const PreferencesNotificationsSection = () => { - + {t('Notification_RequireInteraction')} - - ( - - )} - /> - - + ( + + )} + /> + {t('Only_works_with_chrome_version_greater_50')} @@ -182,57 +180,51 @@ const PreferencesNotificationsSection = () => { {showNewLoginEmailPreference && ( - + {t('Receive_Login_Detection_Emails')} - - ( - - )} - /> - - + ( + + )} + /> + {t('Receive_Login_Detection_Emails_Description')} )} {showCalendarPreference && ( - + {t('Notify_Calendar_Events')} - - ( - - )} - /> - - + ( + + )} + /> + )} {showMobileRinging && ( - + {t('VideoConf_Mobile_Ringing')} - - ( - - )} - /> - - + ( + + )} + /> + )} diff --git a/apps/meteor/client/views/account/preferences/PreferencesSoundSection.tsx b/apps/meteor/client/views/account/preferences/PreferencesSoundSection.tsx index 223d67fd7f95a59f6a055f08a93d490ccc17e7e5..8306e2941b00568524376b63134cdd1228bbd1b3 100644 --- a/apps/meteor/client/views/account/preferences/PreferencesSoundSection.tsx +++ b/apps/meteor/client/views/account/preferences/PreferencesSoundSection.tsx @@ -62,9 +62,9 @@ const PreferencesSoundSection = () => { /> - - {t('Mute_Focused_Conversations')} + + {t('Mute_Focused_Conversations')} { - + {t('Enable_Auto_Away')} - - ( - - )} - /> - - + ( + + )} + /> + {t('Idle_Time_Limit')} diff --git a/apps/meteor/client/views/account/preferences/useAccountPreferencesValues.ts b/apps/meteor/client/views/account/preferences/useAccountPreferencesValues.ts index b99a2a6c1ebee62ca3309523f95a680aeb0bd8d3..a85ef275638e499e601359ded48cf41473cc7883 100644 --- a/apps/meteor/client/views/account/preferences/useAccountPreferencesValues.ts +++ b/apps/meteor/client/views/account/preferences/useAccountPreferencesValues.ts @@ -39,7 +39,7 @@ export type AccountPreferencesData = { }; export const useAccountPreferencesValues = (): AccountPreferencesData => { - const language = useUserPreference('language'); + const language = useUserPreference('language') || ''; const userDontAskAgainList = useUserPreference<{ action: string; label: string }[]>('dontAskAgainList') || []; const dontAskAgainList = userDontAskAgainList.map(({ action }) => action); const enableAutoAway = useUserPreference('enableAutoAway'); diff --git a/apps/meteor/client/views/account/profile/AccountProfilePage.tsx b/apps/meteor/client/views/account/profile/AccountProfilePage.tsx index c6d675a203ac8afb5644039a2d9fda515eaed216..df1710b075095bce8064730492aaf8eeaaddd919 100644 --- a/apps/meteor/client/views/account/profile/AccountProfilePage.tsx +++ b/apps/meteor/client/views/account/profile/AccountProfilePage.tsx @@ -119,16 +119,18 @@ const AccountProfilePage = (): ReactElement => { - - - {allowDeleteOwnAccount && ( - - )} - + {allowDeleteOwnAccount && ( + + )} + + diff --git a/apps/meteor/client/views/account/security/TwoFactorTOTP.tsx b/apps/meteor/client/views/account/security/TwoFactorTOTP.tsx index 30cc87861838318a3123693b067edea91c313090..a00a64ee991babb97e25480cde2b4acbb2492bab 100644 --- a/apps/meteor/client/views/account/security/TwoFactorTOTP.tsx +++ b/apps/meteor/client/views/account/security/TwoFactorTOTP.tsx @@ -1,6 +1,6 @@ import { Box, Button, TextInput, Margins } from '@rocket.chat/fuselage'; import { useSafely } from '@rocket.chat/fuselage-hooks'; -import { useSetModal, useToastMessageDispatch, useUser, useMethod, useTranslation, useEndpoint } from '@rocket.chat/ui-contexts'; +import { useSetModal, useToastMessageDispatch, useUser, useMethod, useTranslation } from '@rocket.chat/ui-contexts'; import type { ReactElement, ComponentProps } from 'react'; import React, { useState, useCallback, useEffect } from 'react'; import { useForm } from 'react-hook-form'; @@ -16,7 +16,6 @@ const TwoFactorTOTP = (props: ComponentProps): ReactElement => { const user = useUser(); const setModal = useSetModal(); - const logoutOtherSessions = useEndpoint('POST', '/v1/users.logoutOtherClients'); const enableTotpFn = useMethod('2fa:enable'); const disableTotpFn = useMethod('2fa:disable'); const verifyCodeFn = useMethod('2fa:validateTempToken'); @@ -86,13 +85,12 @@ const TwoFactorTOTP = (props: ComponentProps): ReactElement => { return dispatchToastMessage({ type: 'error', message: t('Invalid_two_factor_code') }); } - logoutOtherSessions(); setModal(); } catch (error) { dispatchToastMessage({ type: 'error', message: error }); } }, - [closeModal, dispatchToastMessage, logoutOtherSessions, setModal, t, verifyCodeFn], + [closeModal, dispatchToastMessage, setModal, t, verifyCodeFn], ); const handleRegenerateCodes = useCallback(() => { @@ -115,7 +113,7 @@ const TwoFactorTOTP = (props: ComponentProps): ReactElement => { return ( - {t('Two-factor_authentication')} + {t('Two-factor_authentication_via_TOTP')} {!totpEnabled && !registeringTotp && ( <> {t('Two-factor_authentication_is_currently_disabled')} diff --git a/apps/meteor/client/views/account/tokens/AccountTokensTable/AddToken.tsx b/apps/meteor/client/views/account/tokens/AccountTokensTable/AddToken.tsx index 97d2a8163cab886d7aa4cfbd8a01e94d8e581a84..71fd2933f431e3efba9c332a7182e91b12529b3e 100644 --- a/apps/meteor/client/views/account/tokens/AccountTokensTable/AddToken.tsx +++ b/apps/meteor/client/views/account/tokens/AccountTokensTable/AddToken.tsx @@ -1,33 +1,41 @@ -import { Box, TextInput, Button, Field, FieldGroup, FieldLabel, FieldRow, Margins, CheckBox } from '@rocket.chat/fuselage'; -import { useUniqueId } from '@rocket.chat/fuselage-hooks'; +import type { SelectOption } from '@rocket.chat/fuselage'; +import { Box, TextInput, Button, Margins, Select } from '@rocket.chat/fuselage'; import { useSetModal, useToastMessageDispatch, useUserId, useMethod, useTranslation } from '@rocket.chat/ui-contexts'; import type { ReactElement } from 'react'; import React, { useCallback, useMemo, useEffect } from 'react'; -import { useForm } from 'react-hook-form'; +import { Controller, useForm } from 'react-hook-form'; import GenericModal from '../../../../components/GenericModal'; -const AddToken = ({ reload, ...props }: { reload: () => void }): ReactElement => { +const AddToken = ({ reload }: { reload: () => void }): ReactElement => { const t = useTranslation(); const userId = useUserId(); + const setModal = useSetModal(); const createTokenFn = useMethod('personalAccessTokens:generateToken'); const dispatchToastMessage = useToastMessageDispatch(); - const bypassTwoFactorCheckboxId = useUniqueId(); - const setModal = useSetModal(); - const initialValues = useMemo(() => ({ name: '', bypassTwoFactor: false }), []); + const initialValues = useMemo(() => ({ name: '', bypassTwoFactor: 'require' }), []); const { register, resetField, handleSubmit, - formState: { isDirty, isSubmitted, submitCount }, + control, + formState: { isSubmitted, submitCount }, } = useForm({ defaultValues: initialValues }); + const twoFactorAuthOptions: SelectOption[] = useMemo( + () => [ + ['require', t('Require_Two_Factor_Authentication')], + ['bypass', t('Ignore_Two_Factor_Authentication')], + ], + [t], + ); + const handleAddToken = useCallback( - async ({ name: tokenName, bypassTwoFactor }: typeof initialValues) => { + async ({ name: tokenName, bypassTwoFactor }) => { try { - const token = await createTokenFn({ tokenName, bypassTwoFactor }); + const token = await createTokenFn({ tokenName, bypassTwoFactor: bypassTwoFactor === 'bypass' }); setModal( setModal(null)} onClose={() => setModal(null)}> @@ -54,22 +62,23 @@ const AddToken = ({ reload, ...props }: { reload: () => void }): ReactElement => }, [isSubmitted, submitCount, reload, resetField]); return ( - - - - - - - - - - - {t('Ignore_Two_Factor_Authentication')} - - - + + + + + + ); }; diff --git a/apps/meteor/client/views/admin/moderation/helpers/ModerationFilter.tsx b/apps/meteor/client/views/admin/moderation/helpers/ModerationFilter.tsx new file mode 100644 index 0000000000000000000000000000000000000000..7d72d9d4c3926535031cd20217e7e1f0985a28fe --- /dev/null +++ b/apps/meteor/client/views/admin/moderation/helpers/ModerationFilter.tsx @@ -0,0 +1,24 @@ +import { useTranslation } from '@rocket.chat/ui-contexts'; +import React, { useCallback } from 'react'; + +import FilterByText from '../../../../components/FilterByText'; +import DateRangePicker from './DateRangePicker'; + +type ModerationFilterProps = { + setText: (text: string) => void; + setDateRange: (dateRange: { start: string; end: string }) => void; +}; + +const ModerationFilter = ({ setText, setDateRange }: ModerationFilterProps) => { + const t = useTranslation(); + + const handleChange = useCallback(({ text }): void => setText(text), [setText]); + + return ( + + + + ); +}; + +export default ModerationFilter; diff --git a/apps/meteor/client/views/admin/moderation/helpers/ReportReason.tsx b/apps/meteor/client/views/admin/moderation/helpers/ReportReason.tsx index bd4af0143b8dbdf8313ee7413765d85b69c5f522..3daa0370b0300e1bb21f4b5c1665f513a7952584 100644 --- a/apps/meteor/client/views/admin/moderation/helpers/ReportReason.tsx +++ b/apps/meteor/client/views/admin/moderation/helpers/ReportReason.tsx @@ -8,7 +8,7 @@ const ReportReason = ({ ind, uinfo, msg, ts }: { ind: number; uinfo: string | un return ( Report #{ind} - + {msg} diff --git a/apps/meteor/client/views/admin/moderation/helpers/UserColumn.tsx b/apps/meteor/client/views/admin/moderation/helpers/UserColumn.tsx new file mode 100644 index 0000000000000000000000000000000000000000..baaddc1cb6c287b0dcb2000e6ca883cf93013bd4 --- /dev/null +++ b/apps/meteor/client/views/admin/moderation/helpers/UserColumn.tsx @@ -0,0 +1,43 @@ +import { Box } from '@rocket.chat/fuselage'; +import { UserAvatar } from '@rocket.chat/ui-avatar'; +import type { ComponentProps } from 'react'; +import React from 'react'; + +type UserColumnProps = { + name?: string; + username?: string; + isDesktopOrLarger?: boolean; + isProfile?: boolean; + size: ComponentProps['size']; + fontSize?: string; +}; + +const UserColumn = ({ name, username, fontSize, size }: UserColumnProps) => { + return ( + + {username && ( + + + + )} + + + + {name && username ? ( + <> + {name}{' '} + + (@{username}) + + + ) : ( + name || username + )}{' '} + + + + + ); +}; + +export default UserColumn; diff --git a/apps/meteor/client/views/admin/moderation/hooks/useDeactivateUserAction.tsx b/apps/meteor/client/views/admin/moderation/hooks/useDeactivateUserAction.tsx index c875408d423e0c40a128f48056cee0f7dd6439e5..2ac0eeffe131a5a4eabde34448c189d135ec7e3e 100644 --- a/apps/meteor/client/views/admin/moderation/hooks/useDeactivateUserAction.tsx +++ b/apps/meteor/client/views/admin/moderation/hooks/useDeactivateUserAction.tsx @@ -1,19 +1,24 @@ -import { useEndpoint, useRoute, useSetModal, useToastMessageDispatch, useTranslation } from '@rocket.chat/ui-contexts'; +import { useEndpoint, useRouteParameter, useRouter, useSetModal, useToastMessageDispatch } from '@rocket.chat/ui-contexts'; import { useMutation, useQueryClient } from '@tanstack/react-query'; import React from 'react'; +import { useTranslation } from 'react-i18next'; import type { GenericMenuItemProps } from '../../../../components/GenericMenu/GenericMenuItem'; import GenericModal from '../../../../components/GenericModal'; -const useDeactivateUserAction = (userId: string): GenericMenuItemProps => { - const t = useTranslation(); +const useDeactivateUserAction = (userId: string, isUserReport?: boolean): GenericMenuItemProps => { + const { t } = useTranslation(); const setModal = useSetModal(); const dispatchToastMessage = useToastMessageDispatch(); const queryClient = useQueryClient(); const deactiveUser = useEndpoint('POST', '/v1/users.setActiveStatus'); const deleteMessages = useEndpoint('POST', '/v1/moderation.user.deleteReportedMessages'); - const moderationRoute = useRoute('moderation-console'); + const dismissUserReports = useEndpoint('POST', '/v1/moderation.dismissUserReports'); + + const router = useRouter(); + + const tab = useRouteParameter('tab'); const handleDeactivateUser = useMutation({ mutationFn: deactiveUser, @@ -35,12 +40,23 @@ const useDeactivateUserAction = (userId: string): GenericMenuItemProps => { }, }); + const handleDismissUserReports = useMutation({ + mutationFn: dismissUserReports, + onError: (error) => { + dispatchToastMessage({ type: 'error', message: error }); + }, + onSuccess: () => { + dispatchToastMessage({ type: 'success', message: t('Moderation_Reports_dismissed') }); + }, + }); + const onDeactivateUser = async () => { setModal(); - await handleDeleteMessages.mutateAsync({ userId }); + !isUserReport && (await handleDeleteMessages.mutateAsync({ userId })); await handleDeactivateUser.mutateAsync({ userId, activeStatus: false, confirmRelinquish: true }); - queryClient.invalidateQueries({ queryKey: ['moderation.reports'] }); - moderationRoute.push({}); + await handleDismissUserReports.mutateAsync({ userId }); + queryClient.invalidateQueries({ queryKey: ['moderation'] }); + router.navigate(`/admin/moderation/${tab}`); }; const confirmDeactivateUser = (): void => { diff --git a/apps/meteor/client/views/admin/moderation/hooks/useDeleteMessage.tsx b/apps/meteor/client/views/admin/moderation/hooks/useDeleteMessage.tsx index bf54eaf89e198b75440439625758f90a70c2fc65..21d7c3b86c731052b8560556a6c69d512fa32d77 100644 --- a/apps/meteor/client/views/admin/moderation/hooks/useDeleteMessage.tsx +++ b/apps/meteor/client/views/admin/moderation/hooks/useDeleteMessage.tsx @@ -33,7 +33,7 @@ const useDeleteMessage = (mid: string, rid: string, onChange: () => void) => { }, onSettled: () => { onChange(); - queryClient.invalidateQueries({ queryKey: ['moderation.reports'] }); + queryClient.invalidateQueries({ queryKey: ['moderation', 'msgReports'] }); setModal(); }, }); diff --git a/apps/meteor/client/views/admin/moderation/hooks/useDeleteMessagesAction.tsx b/apps/meteor/client/views/admin/moderation/hooks/useDeleteMessagesAction.tsx index cebe8d33908d8d00271f8c06430a1797cf2fb66a..4d5d5690492d12e3c169c6cc877098ea274ee8f7 100644 --- a/apps/meteor/client/views/admin/moderation/hooks/useDeleteMessagesAction.tsx +++ b/apps/meteor/client/views/admin/moderation/hooks/useDeleteMessagesAction.tsx @@ -1,4 +1,4 @@ -import { useEndpoint, useRoute, useSetModal, useToastMessageDispatch, useTranslation } from '@rocket.chat/ui-contexts'; +import { useEndpoint, useRouteParameter, useRouter, useSetModal, useToastMessageDispatch, useTranslation } from '@rocket.chat/ui-contexts'; import { useMutation, useQueryClient } from '@tanstack/react-query'; import React from 'react'; @@ -10,7 +10,8 @@ const useDeleteMessagesAction = (userId: string): GenericMenuItemProps => { const deleteMessages = useEndpoint('POST', '/v1/moderation.user.deleteReportedMessages'); const dispatchToastMessage = useToastMessageDispatch(); const setModal = useSetModal(); - const moderationRoute = useRoute('moderation-console'); + const router = useRouter(); + const tab = useRouteParameter('tab'); const queryClient = useQueryClient(); const handleDeleteMessages = useMutation({ @@ -25,9 +26,9 @@ const useDeleteMessagesAction = (userId: string): GenericMenuItemProps => { const onDeleteAll = async () => { await handleDeleteMessages.mutateAsync({ userId }); - queryClient.invalidateQueries({ queryKey: ['moderation.reports'] }); + queryClient.invalidateQueries({ queryKey: ['moderation', 'msgReports', 'fetchAll'] }); setModal(); - moderationRoute.push({}); + router.navigate(`/admin/moderation/${tab}`); }; const confirmDeletMessages = (): void => { diff --git a/apps/meteor/client/views/admin/moderation/hooks/useDismissMessageAction.tsx b/apps/meteor/client/views/admin/moderation/hooks/useDismissMessageAction.tsx index 824255e57db170bd3932f1f32a9175708119c4c1..6a7f6867ea74e581e2052223d23876a16a43656f 100644 --- a/apps/meteor/client/views/admin/moderation/hooks/useDismissMessageAction.tsx +++ b/apps/meteor/client/views/admin/moderation/hooks/useDismissMessageAction.tsx @@ -24,7 +24,7 @@ export const useDismissMessageAction = (msgId: string): { action: () => void } = const onDismissMessage = async () => { await handleDismissMessage.mutateAsync({ msgId }); - queryClient.invalidateQueries({ queryKey: ['moderation.userMessages'] }); + queryClient.invalidateQueries({ queryKey: ['moderation', 'msgReports'] }); setModal(); }; diff --git a/apps/meteor/client/views/admin/moderation/hooks/useDismissUserAction.tsx b/apps/meteor/client/views/admin/moderation/hooks/useDismissUserAction.tsx index 560c980df91eacfcc5384c58c9f88f3aa1fcfd5e..09a30358d73746c2035ffb401e52bf87a8d65cc0 100644 --- a/apps/meteor/client/views/admin/moderation/hooks/useDismissUserAction.tsx +++ b/apps/meteor/client/views/admin/moderation/hooks/useDismissUserAction.tsx @@ -1,18 +1,24 @@ -import { useEndpoint, useRouter, useSetModal, useToastMessageDispatch, useTranslation } from '@rocket.chat/ui-contexts'; +import { useEndpoint, useRouter, useSetModal, useToastMessageDispatch, useRouteParameter } from '@rocket.chat/ui-contexts'; import { useMutation, useQueryClient } from '@tanstack/react-query'; import React from 'react'; +import { useTranslation } from 'react-i18next'; import type { GenericMenuItemProps } from '../../../../components/GenericMenu/GenericMenuItem'; import GenericModal from '../../../../components/GenericModal'; -const useDismissUserAction = (userId: string): GenericMenuItemProps => { - const t = useTranslation(); +const useDismissUserAction = (userId: string, isUserReport?: boolean): GenericMenuItemProps => { + const { t } = useTranslation(); const setModal = useSetModal(); const dispatchToastMessage = useToastMessageDispatch(); const moderationRoute = useRouter(); + const tab = useRouteParameter('tab'); const queryClient = useQueryClient(); - const dismissUser = useEndpoint('POST', '/v1/moderation.dismissReports'); + const dismissMsgReports = useEndpoint('POST', '/v1/moderation.dismissReports'); + + const dismissUserReports = useEndpoint('POST', '/v1/moderation.dismissUserReports'); + + const dismissUser = isUserReport ? dismissUserReports : dismissMsgReports; const handleDismissUser = useMutation({ mutationFn: dismissUser, @@ -20,15 +26,15 @@ const useDismissUserAction = (userId: string): GenericMenuItemProps => { dispatchToastMessage({ type: 'error', message: error }); }, onSuccess: () => { - dispatchToastMessage({ type: 'success', message: t('Moderation_Reports_dismissed_plural') }); + dispatchToastMessage({ type: 'success', message: t('Moderation_Reports_all_dismissed') }); }, }); const onDismissUser = async () => { await handleDismissUser.mutateAsync({ userId }); - queryClient.invalidateQueries({ queryKey: ['moderation.reports'] }); + queryClient.invalidateQueries({ queryKey: ['moderation', 'userReports'] }); setModal(); - moderationRoute.navigate('/admin/moderation', { replace: true }); + moderationRoute.navigate(`/admin/moderation/${tab}`, { replace: true }); }; const confirmDismissUser = (): void => { diff --git a/apps/meteor/client/views/admin/moderation/hooks/useResetAvatarAction.tsx b/apps/meteor/client/views/admin/moderation/hooks/useResetAvatarAction.tsx index f4abf806c91393a45f178797222f3b3995d9f38f..377aad97b186c3f20e2bcd6d8ae7fd943fc1446b 100644 --- a/apps/meteor/client/views/admin/moderation/hooks/useResetAvatarAction.tsx +++ b/apps/meteor/client/views/admin/moderation/hooks/useResetAvatarAction.tsx @@ -25,8 +25,8 @@ const useResetAvatarAction = (userId: string): GenericMenuItemProps => { const onResetAvatar = async () => { setModal(); - handleResetAvatar.mutateAsync({ userId }); - queryClient.invalidateQueries({ queryKey: ['moderation.reports'] }); + await handleResetAvatar.mutateAsync({ userId }); + queryClient.invalidateQueries({ queryKey: ['moderation'] }); }; const confirmResetAvatar = (): void => { diff --git a/apps/meteor/client/views/admin/oauthApps/EditOauthApp.tsx b/apps/meteor/client/views/admin/oauthApps/EditOauthApp.tsx index a5759fde6cc7e59c0be3b8a513903be84c29ac6f..19bef201492f200459d1d966acc8019202a6283a 100644 --- a/apps/meteor/client/views/admin/oauthApps/EditOauthApp.tsx +++ b/apps/meteor/client/views/admin/oauthApps/EditOauthApp.tsx @@ -101,15 +101,15 @@ const EditOauthApp = ({ onChange, data, ...props }: EditOauthAppProps): ReactEle - - {t('Active')} + + {t('Active')} } /> - + {t('Application_Name')} @@ -153,7 +153,7 @@ const EditOauthApp = ({ onChange, data, ...props }: EditOauthAppProps): ReactEle - + diff --git a/apps/meteor/client/views/admin/oauthApps/OAuthAddApp.tsx b/apps/meteor/client/views/admin/oauthApps/OAuthAddApp.tsx index ccb4205b581969863b031c8e4626cf8402e098d7..9a66e46e6b3984c98ee9e312bd0126fdee945078 100644 --- a/apps/meteor/client/views/admin/oauthApps/OAuthAddApp.tsx +++ b/apps/meteor/client/views/admin/oauthApps/OAuthAddApp.tsx @@ -56,15 +56,15 @@ const OAuthAddApp = (): ReactElement => { - - {t('Active')} + + {t('Active')} } /> - + {t('Application_Name')} @@ -84,7 +84,7 @@ const OAuthAddApp = (): ReactElement => { - + - )} + router.navigate('/admin/third-party-login') : undefined}> {!context && ( - + + + )} diff --git a/apps/meteor/client/views/admin/permissions/PermissionsTable/PermissionsTable.tsx b/apps/meteor/client/views/admin/permissions/PermissionsTable/PermissionsTable.tsx index 1d688da630a95f429882ebb232484c071909a372..583f9d2372527a09b88f885f200c971c3b0b0c5a 100644 --- a/apps/meteor/client/views/admin/permissions/PermissionsTable/PermissionsTable.tsx +++ b/apps/meteor/client/views/admin/permissions/PermissionsTable/PermissionsTable.tsx @@ -1,4 +1,5 @@ -import { Margins, Tabs, Button, Pagination } from '@rocket.chat/fuselage'; +import { css } from '@rocket.chat/css-in-js'; +import { Margins, Tabs, Button, Pagination, Palette } from '@rocket.chat/fuselage'; import { useMutableCallback } from '@rocket.chat/fuselage-hooks'; import { useRoute, usePermission, useMethod, useTranslation, useSetModal } from '@rocket.chat/ui-contexts'; import type { ReactElement } from 'react'; @@ -55,6 +56,26 @@ const PermissionsTable = ({ isEnterprise }: { isEnterprise: boolean }): ReactEle }); }); + const fixedColumnStyle = css` + tr > th { + &:first-child { + position: sticky; + left: 0; + background-color: ${Palette.surface['surface-light']}; + z-index: 12; + } + } + tr > td { + &:first-child { + position: sticky; + left: 0; + box-shadow: -1px 0 0 ${Palette.stroke['stroke-light']} inset; + background-color: ${Palette.surface['surface-light']}; + z-index: 11; + } + } + `; + return ( @@ -89,7 +110,7 @@ const PermissionsTable = ({ isEnterprise }: { isEnterprise: boolean }): ReactEle {permissions?.length === 0 && } {permissions?.length > 0 && ( <> - + {t('Name')} {roleList?.map(({ _id, name, description }) => ( diff --git a/apps/meteor/client/views/admin/permissions/PermissionsTable/RoleHeader.tsx b/apps/meteor/client/views/admin/permissions/PermissionsTable/RoleHeader.tsx index 69f99d67f114f755b2123284bdb8ebf2da697c9e..006514ce6b3137114ad775675777bc4fe283e652 100644 --- a/apps/meteor/client/views/admin/permissions/PermissionsTable/RoleHeader.tsx +++ b/apps/meteor/client/views/admin/permissions/PermissionsTable/RoleHeader.tsx @@ -25,7 +25,7 @@ const RoleHeader = ({ _id, name, description }: RoleHeaderProps): ReactElement = return ( - diff --git a/apps/meteor/client/views/admin/permissions/RoleForm.tsx b/apps/meteor/client/views/admin/permissions/RoleForm.tsx index e1517d7721feadef1bc37458078cd0e5ec7e21d0..2a063ce7d05a808581ca25754a36b386c797b8df 100644 --- a/apps/meteor/client/views/admin/permissions/RoleForm.tsx +++ b/apps/meteor/client/views/admin/permissions/RoleForm.tsx @@ -1,5 +1,5 @@ import type { SelectOption } from '@rocket.chat/fuselage'; -import { Box, Field, FieldLabel, FieldRow, FieldError, FieldHint, TextInput, Select, ToggleSwitch } from '@rocket.chat/fuselage'; +import { Field, FieldLabel, FieldRow, FieldError, FieldHint, TextInput, Select, ToggleSwitch } from '@rocket.chat/fuselage'; import { useTranslation } from '@rocket.chat/ui-contexts'; import type { ReactElement } from 'react'; import React, { useMemo } from 'react'; @@ -57,16 +57,14 @@ const RoleForm = ({ className, editing = false, isProtected = false, isDisabled - + {t('Users must use Two Factor Authentication')} - - } - /> - - + } + /> + ); diff --git a/apps/meteor/client/views/admin/permissions/UsersInRole/UsersInRolePage.tsx b/apps/meteor/client/views/admin/permissions/UsersInRole/UsersInRolePage.tsx index 478a0b445e3e5d8a5371f6621f68d88735887a7a..effb72691f93c114905df81fdd8bb9e4b2a2c2dc 100644 --- a/apps/meteor/client/views/admin/permissions/UsersInRole/UsersInRolePage.tsx +++ b/apps/meteor/client/views/admin/permissions/UsersInRole/UsersInRolePage.tsx @@ -93,11 +93,10 @@ const UsersInRolePage = ({ role }: { role: IRole }): ReactElement => { )} /> - - - + + diff --git a/apps/meteor/client/views/admin/permissions/UsersInRole/UsersInRoleTable/UsersInRoleTableRow.tsx b/apps/meteor/client/views/admin/permissions/UsersInRole/UsersInRoleTable/UsersInRoleTableRow.tsx index 1fbce402a21b52d798b7aa3ed6d13c81422e0774..ea4b999d694ebf4c46adee5dac9cd999d64de9c1 100644 --- a/apps/meteor/client/views/admin/permissions/UsersInRole/UsersInRoleTable/UsersInRoleTableRow.tsx +++ b/apps/meteor/client/views/admin/permissions/UsersInRole/UsersInRoleTable/UsersInRoleTableRow.tsx @@ -1,12 +1,12 @@ import type { IUserInRole } from '@rocket.chat/core-typings'; import { Box, Button, Icon } from '@rocket.chat/fuselage'; import { useMutableCallback } from '@rocket.chat/fuselage-hooks'; +import { UserAvatar } from '@rocket.chat/ui-avatar'; import type { ReactElement } from 'react'; import React, { memo } from 'react'; import { getUserEmailAddress } from '../../../../../../lib/getUserEmailAddress'; import { GenericTableRow, GenericTableCell } from '../../../../../components/GenericTable'; -import UserAvatar from '../../../../../components/avatar/UserAvatar'; type UsersInRoleTableRowProps = { user: IUserInRole; diff --git a/apps/meteor/client/views/admin/rooms/EditRoom.tsx b/apps/meteor/client/views/admin/rooms/EditRoom.tsx index 0f824d71f5c717937acfe3cac5b72889f209b1dc..18edd8cc815aa656a74e7e06e691ae0064213500 100644 --- a/apps/meteor/client/views/admin/rooms/EditRoom.tsx +++ b/apps/meteor/client/views/admin/rooms/EditRoom.tsx @@ -13,8 +13,8 @@ import { TextAreaInput, FieldError, } from '@rocket.chat/fuselage'; -import { useMutableCallback, useUniqueId } from '@rocket.chat/fuselage-hooks'; -import { useEndpoint, useToastMessageDispatch, useTranslation } from '@rocket.chat/ui-contexts'; +import { useEffectEvent, useUniqueId } from '@rocket.chat/fuselage-hooks'; +import { useEndpoint, useRouter, useToastMessageDispatch, useTranslation } from '@rocket.chat/ui-contexts'; import React from 'react'; import { useForm, Controller } from 'react-hook-form'; @@ -64,6 +64,7 @@ const getInitialValues = (room: Pick): EditRoomFormV const EditRoom = ({ room, onChange, onDelete }: EditRoomProps) => { const t = useTranslation(); + const router = useRouter(); const dispatchToastMessage = useToastMessageDispatch(); const { @@ -72,10 +73,18 @@ const EditRoom = ({ room, onChange, onDelete }: EditRoomProps) => { reset, handleSubmit, formState: { isDirty, errors, dirtyFields }, - } = useForm({ defaultValues: getInitialValues(room) }); + } = useForm({ values: getInitialValues(room) }); - const { canViewName, canViewTopic, canViewAnnouncement, canViewArchived, canViewDescription, canViewType, canViewReadOnly } = - useEditAdminRoomPermissions(room); + const { + canViewName, + canViewTopic, + canViewAnnouncement, + canViewArchived, + canViewDescription, + canViewType, + canViewReadOnly, + canViewReactWhenReadOnly, + } = useEditAdminRoomPermissions(room); const { roomType, readOnly, archived } = watch(); @@ -87,13 +96,14 @@ const EditRoom = ({ room, onChange, onDelete }: EditRoomProps) => { const handleArchive = useArchiveRoom(room); - const handleUpdateRoomData = useMutableCallback(async ({ isDefault, roomName, favorite, ...formData }) => { + const handleUpdateRoomData = useEffectEvent(async ({ isDefault, favorite, ...formData }) => { const data = getDirtyFields(formData, dirtyFields); + delete data.archived; + delete data.favorite; try { await saveAction({ rid: room._id, - roomName: roomType === 'd' ? undefined : roomName, default: isDefault, favorite: { defaultValue: isDefault, favorite }, ...data, @@ -101,15 +111,17 @@ const EditRoom = ({ room, onChange, onDelete }: EditRoomProps) => { dispatchToastMessage({ type: 'success', message: t('Room_updated_successfully') }); onChange(); + router.navigate('/admin/rooms'); } catch (error) { dispatchToastMessage({ type: 'error', message: error }); } }); - const handleSave = useMutableCallback(async (data) => { - await Promise.all([isDirty && handleUpdateRoomData(data), changeArchiving && handleArchive()].filter(Boolean)); - }); + const handleSave = useEffectEvent((data) => + Promise.all([isDirty && handleUpdateRoomData(data), changeArchiving && handleArchive()].filter(Boolean)), + ); + const formId = useUniqueId(); const roomNameField = useUniqueId(); const ownerField = useUniqueId(); const roomDescription = useUniqueId(); @@ -125,7 +137,7 @@ const EditRoom = ({ room, onChange, onDelete }: EditRoomProps) => { return ( <> - + {room.t !== 'd' && ( { )} {canViewType && ( - + {t('Private')} - - ( - onChange(value === 'p' ? 'c' : 'p')} - aria-describedby={`${roomTypeField}-hint`} - /> - )} - /> - - + ( + onChange(value === 'p' ? 'c' : 'p')} + aria-describedby={`${roomTypeField}-hint`} + /> + )} + /> + {t('Just_invited_people_can_access_this_channel')} )} {canViewReadOnly && ( - + {t('Read_only')} - - ( - - )} - /> - - + ( + + )} + /> + {t('Only_authorized_users_can_write_new_messages')} )} - {readOnly && ( + {canViewReactWhenReadOnly && readOnly && ( - + {t('React_when_read_only')} - - ( - - )} - /> - - + ( + + )} + /> + {t('React_when_read_only_changed_successfully')} )} {canViewArchived && ( - + {t('Room_archivation_state_true')} - - ( - - )} - /> - - + ( + + )} + /> + )} )} - + {t('Default')} - - ( - - )} - /> - - + ( + + )} + /> + - + {t('Favorite')} - - ( - - )} - /> - - + ( + + )} + /> + - + {t('Featured')} - - ( - - )} - /> - - + ( + + )} + /> + @@ -349,15 +347,17 @@ const EditRoom = ({ room, onChange, onDelete }: EditRoomProps) => { - - - - + + + + + ); diff --git a/apps/meteor/client/views/admin/rooms/RoomRow.tsx b/apps/meteor/client/views/admin/rooms/RoomRow.tsx index 2c0dbb8c31a6c2d0761283aaa26123d60e82f474..73a30e647764c82cf6c75bb2750a506337903159 100644 --- a/apps/meteor/client/views/admin/rooms/RoomRow.tsx +++ b/apps/meteor/client/views/admin/rooms/RoomRow.tsx @@ -2,11 +2,11 @@ import { isDiscussion } from '@rocket.chat/core-typings'; import type { IRoom, RoomAdminFieldsType } from '@rocket.chat/core-typings'; import { Box, Icon } from '@rocket.chat/fuselage'; import { useMediaQuery } from '@rocket.chat/fuselage-hooks'; +import { RoomAvatar } from '@rocket.chat/ui-avatar'; import { useRouter, useTranslation } from '@rocket.chat/ui-contexts'; import React, { useCallback } from 'react'; import { GenericTableCell, GenericTableRow } from '../../../components/GenericTable'; -import RoomAvatar from '../../../components/avatar/RoomAvatar'; import { roomCoordinator } from '../../../lib/rooms/roomCoordinator'; const roomTypeI18nMap = { diff --git a/apps/meteor/client/views/admin/rooms/useEditAdminRoomPermissions.ts b/apps/meteor/client/views/admin/rooms/useEditAdminRoomPermissions.ts index a250015098a1e9efc5b4d98d3d8397c15926c8dd..2f7d6cb7e0b959fd440c5692e73af4705b1196b4 100644 --- a/apps/meteor/client/views/admin/rooms/useEditAdminRoomPermissions.ts +++ b/apps/meteor/client/views/admin/rooms/useEditAdminRoomPermissions.ts @@ -5,20 +5,37 @@ import { RoomSettingsEnum } from '../../../../definition/IRoomTypeConfig'; import { roomCoordinator } from '../../../lib/rooms/roomCoordinator'; export const useEditAdminRoomPermissions = (room: Pick) => { - const [canViewName, canViewTopic, canViewAnnouncement, canViewArchived, canViewDescription, canViewType, canViewReadOnly] = - useMemo(() => { - const isAllowed = roomCoordinator.getRoomDirectives(room.t).allowRoomSettingChange; - return [ - isAllowed?.(room, RoomSettingsEnum.NAME), - isAllowed?.(room, RoomSettingsEnum.TOPIC), - isAllowed?.(room, RoomSettingsEnum.ANNOUNCEMENT), - isAllowed?.(room, RoomSettingsEnum.ARCHIVE_OR_UNARCHIVE), - isAllowed?.(room, RoomSettingsEnum.DESCRIPTION), - isAllowed?.(room, RoomSettingsEnum.TYPE), - isAllowed?.(room, RoomSettingsEnum.READ_ONLY), - isAllowed?.(room, RoomSettingsEnum.REACT_WHEN_READ_ONLY), - ]; - }, [room]); + const [ + canViewName, + canViewTopic, + canViewAnnouncement, + canViewArchived, + canViewDescription, + canViewType, + canViewReadOnly, + canViewReactWhenReadOnly, + ] = useMemo(() => { + const isAllowed = roomCoordinator.getRoomDirectives(room.t).allowRoomSettingChange; + return [ + isAllowed?.(room, RoomSettingsEnum.NAME), + isAllowed?.(room, RoomSettingsEnum.TOPIC), + isAllowed?.(room, RoomSettingsEnum.ANNOUNCEMENT), + isAllowed?.(room, RoomSettingsEnum.ARCHIVE_OR_UNARCHIVE), + isAllowed?.(room, RoomSettingsEnum.DESCRIPTION), + isAllowed?.(room, RoomSettingsEnum.TYPE), + isAllowed?.(room, RoomSettingsEnum.READ_ONLY), + isAllowed?.(room, RoomSettingsEnum.REACT_WHEN_READ_ONLY), + ]; + }, [room]); - return { canViewName, canViewTopic, canViewAnnouncement, canViewArchived, canViewDescription, canViewType, canViewReadOnly }; + return { + canViewName, + canViewTopic, + canViewAnnouncement, + canViewArchived, + canViewDescription, + canViewType, + canViewReadOnly, + canViewReactWhenReadOnly, + }; }; diff --git a/apps/meteor/client/views/admin/routes.tsx b/apps/meteor/client/views/admin/routes.tsx index afb40b1686c8f9b496333468d85c61e6b3a7c080..db09a6d436d6dbf1bfc3fcdabcb537108b8498cd 100644 --- a/apps/meteor/client/views/admin/routes.tsx +++ b/apps/meteor/client/views/admin/routes.tsx @@ -97,8 +97,8 @@ declare module '@rocket.chat/ui-contexts' { pattern: '/admin/engagement/:tab?'; }; 'moderation-console': { - pathname: `/admin/moderation${`/${string}` | ''}${`/${string}` | ''}`; - pattern: '/admin/moderation/:context?/:id?'; + pathname: `/admin/moderation${`/${string}` | ''}${`/${string}` | ''}${`/${string}` | ''}`; + pattern: '/admin/moderation/:tab?/:context?/:id?'; }; 'subscription': { pathname: `/admin/subscription`; @@ -218,7 +218,7 @@ registerAdminRoute('/settings/:group?', { component: lazy(() => import('./settings/SettingsRoute')), }); -registerAdminRoute('/moderation/:context?/:id?', { +registerAdminRoute('/moderation/:tab?/:context?/:id?', { name: 'moderation-console', component: lazy(() => import('./moderation/ModerationConsoleRoute')), }); diff --git a/apps/meteor/client/views/admin/settings/MemoizedSetting.tsx b/apps/meteor/client/views/admin/settings/MemoizedSetting.tsx index a7433afc66c25d72601ac622b4f120013f7085f3..5534e4cef7b397428176fbc81412cafc7dbf8fb5 100644 --- a/apps/meteor/client/views/admin/settings/MemoizedSetting.tsx +++ b/apps/meteor/client/views/admin/settings/MemoizedSetting.tsx @@ -59,6 +59,7 @@ type MemoizedSettingProps = { sectionChanged?: boolean; hasResetButton?: boolean; disabled?: boolean; + required?: boolean; showUpgradeButton?: ReactNode; actionText?: string; }; @@ -92,8 +93,8 @@ const MemoizedSetting = ({ editor={editor} onChangeValue={onChangeValue} onChangeEditor={onChangeEditor} - {...inputProps} disabled={disabled} + {...inputProps} /> {hint && type !== 'code' && {hint}} {callout && ( @@ -101,8 +102,8 @@ const MemoizedSetting = ({ {callout} )} + {showUpgradeButton} - {showUpgradeButton} ); }; diff --git a/apps/meteor/client/views/admin/settings/ResetSettingButton.tsx b/apps/meteor/client/views/admin/settings/ResetSettingButton.tsx index 90616464c0e167f4e385825b6d7eeba67e8e1248..c4cf47c109fcbfcaf9b8f46e7d0f8df38bdc8e82 100644 --- a/apps/meteor/client/views/admin/settings/ResetSettingButton.tsx +++ b/apps/meteor/client/views/admin/settings/ResetSettingButton.tsx @@ -7,7 +7,7 @@ import React from 'react'; function ResetSettingButton(props: ComponentProps): ReactElement { const t = useTranslation(); - return ; + return ; } export default ResetSettingButton; diff --git a/apps/meteor/client/views/admin/settings/Setting.tsx b/apps/meteor/client/views/admin/settings/Setting.tsx index eb0413d53d84f3b52ebaa3db04be9ba5106e0b03..d9076e5fb4f621f7c855fe14819627299b4bfce0 100644 --- a/apps/meteor/client/views/admin/settings/Setting.tsx +++ b/apps/meteor/client/views/admin/settings/Setting.tsx @@ -2,7 +2,6 @@ import type { ISettingColor, SettingEditor, SettingValue } from '@rocket.chat/co import { isSettingColor, isSetting } from '@rocket.chat/core-typings'; import { Button } from '@rocket.chat/fuselage'; import { useDebouncedCallback } from '@rocket.chat/fuselage-hooks'; -import { ExternalLink } from '@rocket.chat/ui-client'; import { useSettingStructure, useTranslation } from '@rocket.chat/ui-contexts'; import type { ReactElement } from 'react'; import React, { useEffect, useMemo, useState, useCallback } from 'react'; @@ -113,9 +112,9 @@ function Setting({ className = undefined, settingId, sectionChanged }: SettingPr const showUpgradeButton = useMemo( () => shouldDisableEnterprise ? ( - - - + ) : undefined, [shouldDisableEnterprise, t], ); diff --git a/apps/meteor/client/views/admin/settings/SettingsGroupCard.tsx b/apps/meteor/client/views/admin/settings/SettingsGroupCard.tsx index 26331379a9fd9a5a66680869a8c5afe6e718e551..52e5fae49ed784c549f9e446041d2ed911ae139e 100644 --- a/apps/meteor/client/views/admin/settings/SettingsGroupCard.tsx +++ b/apps/meteor/client/views/admin/settings/SettingsGroupCard.tsx @@ -1,7 +1,7 @@ import type { ISetting } from '@rocket.chat/core-typings'; import { css } from '@rocket.chat/css-in-js'; -import { Button, Box } from '@rocket.chat/fuselage'; -import { Card, CardBody, CardTitle, CardFooter } from '@rocket.chat/ui-client'; +import { Button, Box, Card, CardTitle, CardBody, CardControls } from '@rocket.chat/fuselage'; +import { useUniqueId } from '@rocket.chat/fuselage-hooks'; import type { TranslationKey } from '@rocket.chat/ui-contexts'; import { useRouter, useTranslation } from '@rocket.chat/ui-contexts'; import type { ReactElement } from 'react'; @@ -22,30 +22,32 @@ type SettingsGroupCardProps = { description?: TranslationKey; }; -const SettingsGroupCard = ({ id, title, description }: SettingsGroupCardProps): ReactElement => { +const SettingsGroupCard = ({ id, title, description, ...props }: SettingsGroupCardProps): ReactElement => { const t = useTranslation(); const router = useRouter(); + const cardId = useUniqueId(); + const descriptionId = useUniqueId(); return ( - - {t(title)} - - + + {t(title)} + + {description && t.has(description) && } - + - + ); }; diff --git a/apps/meteor/client/views/admin/settings/SettingsPage.tsx b/apps/meteor/client/views/admin/settings/SettingsPage.tsx index 5180b89b25fb8dadcc4cdc6cdb1c411010893724..f82e16ce637c19a6fc401cc6cc9d20ad9d2fadf1 100644 --- a/apps/meteor/client/views/admin/settings/SettingsPage.tsx +++ b/apps/meteor/client/views/admin/settings/SettingsPage.tsx @@ -1,4 +1,4 @@ -import { Icon, SearchInput, Skeleton, Grid } from '@rocket.chat/fuselage'; +import { Icon, SearchInput, Skeleton, CardGrid } from '@rocket.chat/fuselage'; import { useDebouncedValue } from '@rocket.chat/fuselage-hooks'; import type { TranslationKey } from '@rocket.chat/ui-contexts'; import { useIsSettingsContextLoading, useTranslation } from '@rocket.chat/ui-contexts'; @@ -25,21 +25,31 @@ const SettingsPage = (): ReactElement => { } /> - + + {isLoadingGroups && } - + {!isLoadingGroups && !!groups.length && groups.map((group) => ( - - - + ))} - + + {!isLoadingGroups && !groups.length && } diff --git a/apps/meteor/client/views/admin/settings/groups/voip/VoipExtensionsPage.tsx b/apps/meteor/client/views/admin/settings/groups/voip/VoipExtensionsPage.tsx index 857fd034c91e6e4e83448dacf8adb7054eba22a3..7494a249818cbd42663d29dee5365d714eac7325 100644 --- a/apps/meteor/client/views/admin/settings/groups/voip/VoipExtensionsPage.tsx +++ b/apps/meteor/client/views/admin/settings/groups/voip/VoipExtensionsPage.tsx @@ -1,4 +1,5 @@ import { Box, Chip, Button, Pagination } from '@rocket.chat/fuselage'; +import { UserAvatar } from '@rocket.chat/ui-avatar'; import { useSetModal, useTranslation, useEndpoint } from '@rocket.chat/ui-contexts'; import { useQuery } from '@tanstack/react-query'; import React, { useMemo } from 'react'; @@ -15,7 +16,6 @@ import { } from '../../../../../components/GenericTable'; import { usePagination } from '../../../../../components/GenericTable/hooks/usePagination'; import { PageContent } from '../../../../../components/Page'; -import UserAvatar from '../../../../../components/avatar/UserAvatar'; import AssignAgentButton from './AssignAgentButton'; import AssignAgentModal from './AssignAgentModal'; import RemoveAgentButton from './RemoveAgentButton'; diff --git a/apps/meteor/client/views/admin/settings/inputs/ActionSettingInput.stories.tsx b/apps/meteor/client/views/admin/settings/inputs/ActionSettingInput.stories.tsx index da0e68443dc3452cb5a799552938e8f6a326b48a..5c8a468eed804e90af1acfa072fe0d1922c9fcf1 100644 --- a/apps/meteor/client/views/admin/settings/inputs/ActionSettingInput.stories.tsx +++ b/apps/meteor/client/views/admin/settings/inputs/ActionSettingInput.stories.tsx @@ -1,9 +1,8 @@ import { Field } from '@rocket.chat/fuselage'; -import type { ServerMethods } from '@rocket.chat/ui-contexts'; +import type { ServerMethods, TranslationKey } from '@rocket.chat/ui-contexts'; import type { ComponentMeta, ComponentStory } from '@storybook/react'; import React from 'react'; -import type keys from '../../../../../packages/rocketchat-i18n/i18n/en.i18n.json'; import ActionSettingInput from './ActionSettingInput'; export default { @@ -17,14 +16,14 @@ const Template: ComponentStory = (args) => => { try { - const data: { message: TranslationKey; params: string[] } = await actionMethod(); + const data: { message: TranslationKey; params?: string[] } = await actionMethod(); - dispatchToastMessage({ type: 'success', message: t(data.message, ...data.params) }); + const params = data.params || []; + dispatchToastMessage({ type: 'success', message: t(data.message, ...params) }); } catch (error) { dispatchToastMessage({ type: 'error', message: error }); } diff --git a/apps/meteor/client/views/admin/settings/inputs/AssetSettingInput.tsx b/apps/meteor/client/views/admin/settings/inputs/AssetSettingInput.tsx index f3122a23295b3c5f779895a31ee79e77f7f39f7c..226215b97e5e4e35711ae81082b8daf7ce3a0dac 100644 --- a/apps/meteor/client/views/admin/settings/inputs/AssetSettingInput.tsx +++ b/apps/meteor/client/views/admin/settings/inputs/AssetSettingInput.tsx @@ -1,4 +1,4 @@ -import { Button, FieldLabel, FieldRow, Icon } from '@rocket.chat/fuselage'; +import { Button, Field, FieldLabel, FieldRow, Icon } from '@rocket.chat/fuselage'; import { Random } from '@rocket.chat/random'; import { useToastMessageDispatch, useEndpoint, useTranslation, useUpload } from '@rocket.chat/ui-contexts'; import type { ChangeEventHandler, DragEvent, ReactElement, SyntheticEvent } from 'react'; @@ -11,10 +11,11 @@ type AssetSettingInputProps = { label: string; value?: { url: string }; asset?: any; + required?: boolean; fileConstraints?: { extensions: string[] }; }; -function AssetSettingInput({ _id, label, value, asset, fileConstraints }: AssetSettingInputProps): ReactElement { +function AssetSettingInput({ _id, label, value, asset, required, fileConstraints }: AssetSettingInputProps): ReactElement { const t = useTranslation(); const dispatchToastMessage = useToastMessageDispatch(); @@ -57,8 +58,8 @@ function AssetSettingInput({ _id, label, value, asset, fileConstraints }: AssetS }; return ( - <> - + + {label} @@ -94,7 +95,7 @@ function AssetSettingInput({ _id, label, value, asset, fileConstraints }: AssetS - + ); } diff --git a/apps/meteor/client/views/admin/settings/inputs/BooleanSettingInput.tsx b/apps/meteor/client/views/admin/settings/inputs/BooleanSettingInput.tsx index 308e781e8e4696006a55cbab437ab77fa7a9ccd9..7dc45f6a5392e23ffe743714c2a324a97173f491 100644 --- a/apps/meteor/client/views/admin/settings/inputs/BooleanSettingInput.tsx +++ b/apps/meteor/client/views/admin/settings/inputs/BooleanSettingInput.tsx @@ -1,4 +1,4 @@ -import { FieldLabel, FieldRow, ToggleSwitch } from '@rocket.chat/fuselage'; +import { Box, Field, FieldLabel, FieldRow, ToggleSwitch } from '@rocket.chat/fuselage'; import type { ReactElement, SyntheticEvent } from 'react'; import React from 'react'; @@ -9,6 +9,7 @@ type BooleanSettingInputProps = { label: string; disabled?: boolean; readonly?: boolean; + required?: boolean; value: boolean; hasResetButton: boolean; onChangeValue: (value: boolean) => void; @@ -19,6 +20,7 @@ function BooleanSettingInput({ label, disabled, readonly, + required, value, hasResetButton, onChangeValue, @@ -30,20 +32,23 @@ function BooleanSettingInput({ }; return ( - - - - {label} - - {hasResetButton && } - + + + + {label} + + + {hasResetButton && } + + + + ); } diff --git a/apps/meteor/client/views/admin/settings/inputs/CodeMirror/CodeMirrorBox.tsx b/apps/meteor/client/views/admin/settings/inputs/CodeMirror/CodeMirrorBox.tsx index a633bbd533c4cbd984b36f5580a167da81534bd4..730b22b6b58c09df3a07e318c65860e61bf384c3 100644 --- a/apps/meteor/client/views/admin/settings/inputs/CodeMirror/CodeMirrorBox.tsx +++ b/apps/meteor/client/views/admin/settings/inputs/CodeMirror/CodeMirrorBox.tsx @@ -40,11 +40,13 @@ const CodeMirrorBox = ({ label, children }: { label: string; children: ReactElem )} {children} - - - + + + + + ); }; diff --git a/apps/meteor/client/views/admin/settings/inputs/CodeSettingInput.tsx b/apps/meteor/client/views/admin/settings/inputs/CodeSettingInput.tsx index 85698b66e2b75ed44c82eaf3906fb5beb8f15445..0424e862bd086a9c171d0fafae476d2aa24142da 100644 --- a/apps/meteor/client/views/admin/settings/inputs/CodeSettingInput.tsx +++ b/apps/meteor/client/views/admin/settings/inputs/CodeSettingInput.tsx @@ -1,4 +1,4 @@ -import { Box, FieldLabel, FieldHint, Flex } from '@rocket.chat/fuselage'; +import { FieldLabel, FieldHint, FieldRow, Field } from '@rocket.chat/fuselage'; import type { ReactElement } from 'react'; import React from 'react'; @@ -16,6 +16,7 @@ type CodeSettingInputProps = { readonly: boolean; autocomplete: boolean; disabled: boolean; + required?: boolean; hasResetButton: boolean; onChangeValue: (value: string) => void; onResetButtonClick: () => void; @@ -31,6 +32,7 @@ function CodeSettingInput({ readonly, autocomplete, disabled, + required, hasResetButton, onChangeValue, onResetButtonClick, @@ -40,16 +42,14 @@ function CodeSettingInput({ }; return ( - <> - - - - {label} - - {hasResetButton && } - - {hint && {hint}} - + + + + {label} + + {hasResetButton && } + + {hint && {hint}} - + ); } diff --git a/apps/meteor/client/views/admin/settings/inputs/ColorSettingInput.tsx b/apps/meteor/client/views/admin/settings/inputs/ColorSettingInput.tsx index 2b2e463346be920b336b4864a64e2524caadfdc3..a4f61b81154970eaa7a3ccb66393a63146bf439a 100644 --- a/apps/meteor/client/views/admin/settings/inputs/ColorSettingInput.tsx +++ b/apps/meteor/client/views/admin/settings/inputs/ColorSettingInput.tsx @@ -1,4 +1,4 @@ -import { Box, FieldLabel, FieldRow, FieldHint, Flex, InputBox, Margins, TextInput, Select } from '@rocket.chat/fuselage'; +import { FieldLabel, FieldRow, FieldHint, Flex, InputBox, Margins, TextInput, Select, Field } from '@rocket.chat/fuselage'; import type { TranslationKey } from '@rocket.chat/ui-contexts'; import { useTranslation } from '@rocket.chat/ui-contexts'; import type { ReactElement } from 'react'; @@ -16,6 +16,7 @@ type ColorSettingInputProps = { readonly?: boolean; autocomplete?: boolean; disabled?: boolean; + required?: boolean; hasResetButton?: boolean; onChangeValue?: (value: string) => void; onChangeEditor?: (value: string) => void; @@ -31,6 +32,7 @@ function ColorSettingInput({ readonly, autocomplete, disabled, + required, hasResetButton, onChangeValue, onChangeEditor, @@ -53,15 +55,13 @@ function ColorSettingInput({ ); return ( - <> - - - - {label} - - {hasResetButton && } - - + + + + {label} + + {hasResetButton && } + @@ -107,7 +107,7 @@ function ColorSettingInput({ Variable name: {_id.replace(/theme-color-/, '@')} - + ); } diff --git a/apps/meteor/client/views/admin/settings/inputs/FontSettingInput.tsx b/apps/meteor/client/views/admin/settings/inputs/FontSettingInput.tsx index 35b255b4e75661f0536fda0c0d0285d0b40f4ca5..1c1c45913bc13a6c9e19141eb63313d9589793fb 100644 --- a/apps/meteor/client/views/admin/settings/inputs/FontSettingInput.tsx +++ b/apps/meteor/client/views/admin/settings/inputs/FontSettingInput.tsx @@ -1,4 +1,4 @@ -import { Box, FieldLabel, FieldRow, Flex, TextInput } from '@rocket.chat/fuselage'; +import { Field, FieldLabel, FieldRow, TextInput } from '@rocket.chat/fuselage'; import type { FormEventHandler, ReactElement } from 'react'; import React from 'react'; @@ -12,6 +12,7 @@ type FontSettingInputProps = { readonly?: boolean; autocomplete?: boolean; disabled?: boolean; + required?: boolean; hasResetButton?: boolean; onChangeValue?: (value: string) => void; onResetButtonClick?: () => void; @@ -24,6 +25,7 @@ function FontSettingInput({ readonly, autocomplete, disabled, + required, hasResetButton, onChangeValue, onResetButtonClick, @@ -33,15 +35,13 @@ function FontSettingInput({ }; return ( - <> - - - - {label} - - {hasResetButton && } - - + + + + {label} + + {hasResetButton && } + - + ); } diff --git a/apps/meteor/client/views/admin/settings/inputs/GenericSettingInput.tsx b/apps/meteor/client/views/admin/settings/inputs/GenericSettingInput.tsx index 32425a57c698b3041a46d4e9dd781788079488e1..daabea2b1d0ebdb108662aab651c51fbc8a9e0f5 100644 --- a/apps/meteor/client/views/admin/settings/inputs/GenericSettingInput.tsx +++ b/apps/meteor/client/views/admin/settings/inputs/GenericSettingInput.tsx @@ -1,4 +1,4 @@ -import { Box, FieldLabel, FieldRow, Flex, TextInput } from '@rocket.chat/fuselage'; +import { Field, FieldLabel, FieldRow, TextInput } from '@rocket.chat/fuselage'; import type { FormEventHandler, ReactElement } from 'react'; import React from 'react'; @@ -12,6 +12,7 @@ type GenericSettingInputProps = { readonly?: boolean; autocomplete?: boolean; disabled?: boolean; + required?: boolean; hasResetButton?: boolean; onChangeValue?: (value: string) => void; onResetButtonClick?: () => void; @@ -24,6 +25,7 @@ function GenericSettingInput({ readonly, autocomplete, disabled, + required, hasResetButton, onChangeValue, onResetButtonClick, @@ -33,15 +35,13 @@ function GenericSettingInput({ }; return ( - <> - - - - {label} - - {hasResetButton && } - - + + + + {label} + + {hasResetButton && } + - + ); } diff --git a/apps/meteor/client/views/admin/settings/inputs/IntSettingInput.tsx b/apps/meteor/client/views/admin/settings/inputs/IntSettingInput.tsx index cd5abd54f481bfd4ada361875cd6046e5d55d562..16100f128466f0b8ef0185cca196120c40bce048 100644 --- a/apps/meteor/client/views/admin/settings/inputs/IntSettingInput.tsx +++ b/apps/meteor/client/views/admin/settings/inputs/IntSettingInput.tsx @@ -1,4 +1,4 @@ -import { Box, FieldLabel, FieldRow, Flex, InputBox } from '@rocket.chat/fuselage'; +import { Field, FieldLabel, FieldRow, InputBox } from '@rocket.chat/fuselage'; import type { FormEventHandler, ReactElement } from 'react'; import React from 'react'; @@ -12,6 +12,7 @@ type IntSettingInputProps = { readonly?: boolean; autocomplete?: boolean; disabled?: boolean; + required?: boolean; hasResetButton?: boolean; onChangeValue?: (value: string | number) => void; onResetButtonClick?: () => void; @@ -25,6 +26,7 @@ function IntSettingInput({ readonly, autocomplete, disabled, + required, onChangeValue, hasResetButton, onResetButtonClick, @@ -34,15 +36,13 @@ function IntSettingInput({ }; return ( - <> - - - - {label} - - {hasResetButton && } - - + + + + {label} + + {hasResetButton && } + - + ); } diff --git a/apps/meteor/client/views/admin/settings/inputs/LanguageSettingInput.tsx b/apps/meteor/client/views/admin/settings/inputs/LanguageSettingInput.tsx index 8bfe977aaf396abe71f1c6443fcbffa22c3aa54f..f91642a5eed9933ab009a9a43aeceeeae1ffc096 100644 --- a/apps/meteor/client/views/admin/settings/inputs/LanguageSettingInput.tsx +++ b/apps/meteor/client/views/admin/settings/inputs/LanguageSettingInput.tsx @@ -1,4 +1,4 @@ -import { Box, FieldLabel, FieldRow, Flex, Select } from '@rocket.chat/fuselage'; +import { Field, FieldLabel, FieldRow, Select } from '@rocket.chat/fuselage'; import { useLanguages } from '@rocket.chat/ui-contexts'; import type { ReactElement } from 'react'; import React from 'react'; @@ -13,6 +13,7 @@ type LanguageSettingInputProps = { readonly?: boolean; autocomplete?: boolean; disabled?: boolean; + required?: boolean; hasResetButton?: boolean; onChangeValue?: (value: string | number) => void; onResetButtonClick?: () => void; @@ -26,6 +27,7 @@ function LanguageSettingInput({ readonly, autocomplete, disabled, + required, hasResetButton, onChangeValue, onResetButtonClick, @@ -37,15 +39,13 @@ function LanguageSettingInput({ }; return ( - <> - - - - {label} - - {hasResetButton && } - - + + + + {label} + + {hasResetButton && } + [key, label])} /> - + ); } diff --git a/apps/meteor/client/views/admin/settings/inputs/MultiSelectSettingInput.stories.tsx b/apps/meteor/client/views/admin/settings/inputs/MultiSelectSettingInput.stories.tsx index 3b4eb15262790317f14104a9e79b16efa05bb508..e667c9728ed38aa98c573ec1db827b80b837f42b 100644 --- a/apps/meteor/client/views/admin/settings/inputs/MultiSelectSettingInput.stories.tsx +++ b/apps/meteor/client/views/admin/settings/inputs/MultiSelectSettingInput.stories.tsx @@ -1,8 +1,8 @@ import { Field } from '@rocket.chat/fuselage'; +import type { TranslationKey } from '@rocket.chat/ui-contexts'; import type { ComponentMeta, ComponentStory } from '@storybook/react'; import React from 'react'; -import type keys from '../../../../../packages/rocketchat-i18n/i18n/en.i18n.json'; import type { valuesOption } from './MultiSelectSettingInput'; import MultiSelectSettingInput from './MultiSelectSettingInput'; @@ -20,9 +20,9 @@ export default { const Template: ComponentStory = (args) => ; const options: valuesOption[] = [ - { key: '1', i18nLabel: '1' as keyof typeof keys }, - { key: '2', i18nLabel: '2' as keyof typeof keys }, - { key: '3', i18nLabel: '3' as keyof typeof keys }, + { key: '1', i18nLabel: '1' as TranslationKey }, + { key: '2', i18nLabel: '2' as TranslationKey }, + { key: '3', i18nLabel: '3' as TranslationKey }, ]; export const Default = Template.bind({}); diff --git a/apps/meteor/client/views/admin/settings/inputs/MultiSelectSettingInput.tsx b/apps/meteor/client/views/admin/settings/inputs/MultiSelectSettingInput.tsx index ff2f0f7d18188d5320d0040b69081159c82c2891..c0d12ee401cf8fa44b703ce2c2746ab31789616a 100644 --- a/apps/meteor/client/views/admin/settings/inputs/MultiSelectSettingInput.tsx +++ b/apps/meteor/client/views/admin/settings/inputs/MultiSelectSettingInput.tsx @@ -1,4 +1,4 @@ -import { FieldLabel, Flex, Box, MultiSelectFiltered, MultiSelect } from '@rocket.chat/fuselage'; +import { FieldLabel, MultiSelectFiltered, MultiSelect, Field, FieldRow } from '@rocket.chat/fuselage'; import type { TranslationKey } from '@rocket.chat/ui-contexts'; import { useTranslation } from '@rocket.chat/ui-contexts'; import type { ReactElement } from 'react'; @@ -16,6 +16,7 @@ type MultiSelectSettingInputProps = { readonly?: boolean; autocomplete?: boolean; disabled?: boolean; + required?: boolean; hasResetButton?: boolean; onChangeValue?: (value: string[]) => void; onResetButtonClick?: () => void; @@ -28,6 +29,7 @@ function MultiSelectSettingInput({ placeholder, readonly, disabled, + required, values = [], hasResetButton, onChangeValue, @@ -42,28 +44,28 @@ function MultiSelectSettingInput({ }; const Component = autocomplete ? MultiSelectFiltered : MultiSelect; return ( - <> - - - - {label} - - {hasResetButton && } - - - [key, t(i18nLabel)])} - /> - + + + + {label} + + {hasResetButton && } + + + [key, t(i18nLabel)])} + /> + + ); } diff --git a/apps/meteor/client/views/admin/settings/inputs/PasswordSettingInput.tsx b/apps/meteor/client/views/admin/settings/inputs/PasswordSettingInput.tsx index b7d2c1d48d471fa41d5202aaf4f08df3538a9b8f..249fb7e8c90d6a673b85d4e34f5f39781c21a9b0 100644 --- a/apps/meteor/client/views/admin/settings/inputs/PasswordSettingInput.tsx +++ b/apps/meteor/client/views/admin/settings/inputs/PasswordSettingInput.tsx @@ -1,4 +1,4 @@ -import { Box, FieldLabel, FieldRow, Flex, PasswordInput } from '@rocket.chat/fuselage'; +import { Field, FieldLabel, FieldRow, PasswordInput } from '@rocket.chat/fuselage'; import type { EventHandler, ReactElement, SyntheticEvent } from 'react'; import React from 'react'; @@ -12,6 +12,7 @@ type PasswordSettingInputProps = { readonly?: boolean; autocomplete?: boolean; disabled?: boolean; + required?: boolean; hasResetButton?: boolean; onChangeValue?: (value: string) => void; onResetButtonClick?: () => void; @@ -25,6 +26,7 @@ function PasswordSettingInput({ readonly, autocomplete, disabled, + required, hasResetButton, onChangeValue, onResetButtonClick, @@ -34,15 +36,13 @@ function PasswordSettingInput({ }; return ( - <> - - - - {label} - - {hasResetButton && } - - + + + + {label} + + {hasResetButton && } + - + ); } diff --git a/apps/meteor/client/views/admin/settings/inputs/RelativeUrlSettingInput.tsx b/apps/meteor/client/views/admin/settings/inputs/RelativeUrlSettingInput.tsx index b94581706757efec5482dc7e07e000ebd39215d3..681232e41802c6705544e1561abb460d70684e45 100644 --- a/apps/meteor/client/views/admin/settings/inputs/RelativeUrlSettingInput.tsx +++ b/apps/meteor/client/views/admin/settings/inputs/RelativeUrlSettingInput.tsx @@ -1,4 +1,4 @@ -import { Box, FieldLabel, Flex, UrlInput } from '@rocket.chat/fuselage'; +import { Field, FieldLabel, FieldRow, UrlInput } from '@rocket.chat/fuselage'; import { useAbsoluteUrl } from '@rocket.chat/ui-contexts'; import type { EventHandler, ReactElement, SyntheticEvent } from 'react'; import React from 'react'; @@ -13,6 +13,7 @@ type RelativeUrlSettingInputProps = { readonly?: boolean; autocomplete?: boolean; disabled?: boolean; + required?: boolean; hasResetButton?: boolean; onChangeValue?: (value: string) => void; onResetButtonClick?: () => void; @@ -26,6 +27,7 @@ function RelativeUrlSettingInput({ readonly, autocomplete, disabled, + required, hasResetButton, onChangeValue, onResetButtonClick, @@ -37,15 +39,13 @@ function RelativeUrlSettingInput({ }; return ( - <> - - - - {label} - - {hasResetButton && } - - + + + + {label} + + {hasResetButton && } + - + ); } diff --git a/apps/meteor/client/views/admin/settings/inputs/RoomPickSettingInput.tsx b/apps/meteor/client/views/admin/settings/inputs/RoomPickSettingInput.tsx index 15423742ff91fd66bec91b217423488e06f44e05..fab66ff7a066438d69479482921214412f898fca 100644 --- a/apps/meteor/client/views/admin/settings/inputs/RoomPickSettingInput.tsx +++ b/apps/meteor/client/views/admin/settings/inputs/RoomPickSettingInput.tsx @@ -1,5 +1,5 @@ import type { SettingValueRoomPick } from '@rocket.chat/core-typings'; -import { Box, FieldLabel, FieldRow, Flex } from '@rocket.chat/fuselage'; +import { Field, FieldLabel, FieldRow } from '@rocket.chat/fuselage'; import type { ReactElement } from 'react'; import React from 'react'; @@ -13,6 +13,7 @@ type RoomPickSettingInputProps = { placeholder?: string; readonly?: boolean; disabled?: boolean; + required?: boolean; hasResetButton?: boolean; onChangeValue: (value: SettingValueRoomPick) => void; onResetButtonClick?: () => void; @@ -25,6 +26,7 @@ function RoomPickSettingInput({ placeholder, readonly, disabled, + required, hasResetButton, onChangeValue, onResetButtonClick, @@ -39,15 +41,13 @@ function RoomPickSettingInput({ }; return ( - <> - - - - {label} - - {hasResetButton && } - - + + + + {label} + + {hasResetButton && } + - + ); } diff --git a/apps/meteor/client/views/admin/settings/inputs/SelectSettingInput.stories.tsx b/apps/meteor/client/views/admin/settings/inputs/SelectSettingInput.stories.tsx index e08f376be0f76106d24b0db6ca25ef2c226baff0..fc8798351f9e207e51be10859728da0eb2f0a067 100644 --- a/apps/meteor/client/views/admin/settings/inputs/SelectSettingInput.stories.tsx +++ b/apps/meteor/client/views/admin/settings/inputs/SelectSettingInput.stories.tsx @@ -1,8 +1,8 @@ import { Field } from '@rocket.chat/fuselage'; +import type { TranslationKey } from '@rocket.chat/ui-contexts'; import type { ComponentMeta, ComponentStory } from '@storybook/react'; import React from 'react'; -import type keys from '../../../../../packages/rocketchat-i18n/i18n/en.i18n.json'; import SelectSettingInput from './SelectSettingInput'; export default { @@ -24,9 +24,9 @@ Default.args = { label: 'Label', placeholder: 'Placeholder', values: [ - { key: '1', i18nLabel: '1' as keyof typeof keys }, - { key: '2', i18nLabel: '2' as keyof typeof keys }, - { key: '3', i18nLabel: '3' as keyof typeof keys }, + { key: '1', i18nLabel: '1' as TranslationKey }, + { key: '2', i18nLabel: '2' as TranslationKey }, + { key: '3', i18nLabel: '3' as TranslationKey }, ], }; @@ -36,9 +36,9 @@ Disabled.args = { label: 'Label', placeholder: 'Placeholder', values: [ - { key: '1', i18nLabel: '1' as keyof typeof keys }, - { key: '2', i18nLabel: '2' as keyof typeof keys }, - { key: '3', i18nLabel: '3' as keyof typeof keys }, + { key: '1', i18nLabel: '1' as TranslationKey }, + { key: '2', i18nLabel: '2' as TranslationKey }, + { key: '3', i18nLabel: '3' as TranslationKey }, ], disabled: true, }; @@ -50,9 +50,9 @@ WithValue.args = { placeholder: 'Placeholder', value: '2', values: [ - { key: '1', i18nLabel: '1' as keyof typeof keys }, - { key: '2', i18nLabel: '2' as keyof typeof keys }, - { key: '3', i18nLabel: '3' as keyof typeof keys }, + { key: '1', i18nLabel: '1' as TranslationKey }, + { key: '2', i18nLabel: '2' as TranslationKey }, + { key: '3', i18nLabel: '3' as TranslationKey }, ], }; @@ -62,9 +62,9 @@ WithResetButton.args = { label: 'Label', placeholder: 'Placeholder', values: [ - { key: '1', i18nLabel: '1' as keyof typeof keys }, - { key: '2', i18nLabel: '2' as keyof typeof keys }, - { key: '3', i18nLabel: '3' as keyof typeof keys }, + { key: '1', i18nLabel: '1' as TranslationKey }, + { key: '2', i18nLabel: '2' as TranslationKey }, + { key: '3', i18nLabel: '3' as TranslationKey }, ], hasResetButton: true, }; diff --git a/apps/meteor/client/views/admin/settings/inputs/SelectSettingInput.tsx b/apps/meteor/client/views/admin/settings/inputs/SelectSettingInput.tsx index a6fa88f7ffe760d7307fcb5afc10e74298fad4a3..f12104f07d0c2b975c7f094c04ee1cbdd930a314 100644 --- a/apps/meteor/client/views/admin/settings/inputs/SelectSettingInput.tsx +++ b/apps/meteor/client/views/admin/settings/inputs/SelectSettingInput.tsx @@ -1,4 +1,4 @@ -import { Box, FieldLabel, FieldRow, Flex, Select } from '@rocket.chat/fuselage'; +import { Field, FieldLabel, FieldRow, Select } from '@rocket.chat/fuselage'; import type { TranslationKey } from '@rocket.chat/ui-contexts'; import { useTranslation } from '@rocket.chat/ui-contexts'; import type { ReactElement } from 'react'; @@ -15,6 +15,7 @@ type SelectSettingInputProps = { readonly?: boolean; autocomplete?: boolean; disabled?: boolean; + required?: boolean; hasResetButton?: boolean; onChangeValue?: (value: string) => void; onResetButtonClick?: () => void; @@ -28,6 +29,7 @@ function SelectSettingInput({ readonly, autocomplete, disabled, + required, values = [], hasResetButton, onChangeValue, @@ -40,15 +42,13 @@ function SelectSettingInput({ }; return ( - <> - - - - {label} - - {hasResetButton && } - - + + + + {label} + + {hasResetButton && } + [key, key])} /> - + ); } diff --git a/apps/meteor/client/views/admin/settings/inputs/StringSettingInput.tsx b/apps/meteor/client/views/admin/settings/inputs/StringSettingInput.tsx index 3d0ba78a127a7d4551c62f4916736047e09048ee..1803f54a36316e75706b628659c4007d3c69342e 100644 --- a/apps/meteor/client/views/admin/settings/inputs/StringSettingInput.tsx +++ b/apps/meteor/client/views/admin/settings/inputs/StringSettingInput.tsx @@ -1,4 +1,4 @@ -import { Box, FieldLabel, FieldRow, Flex, TextAreaInput, TextInput } from '@rocket.chat/fuselage'; +import { Field, FieldLabel, FieldRow, TextAreaInput, TextInput } from '@rocket.chat/fuselage'; import type { EventHandler, ReactElement, SyntheticEvent } from 'react'; import React from 'react'; @@ -15,6 +15,7 @@ type StringSettingInputProps = { error?: string; autocomplete?: boolean; disabled?: boolean; + required?: boolean; hasResetButton?: boolean; onChangeValue?: (value: string) => void; onResetButtonClick?: () => void; @@ -25,6 +26,7 @@ function StringSettingInput({ label, name, disabled, + required, multiline, placeholder, readonly, @@ -40,15 +42,13 @@ function StringSettingInput({ }; return ( - <> - - - - {label} - - {hasResetButton && } - - + + + + {label} + + {hasResetButton && } + {multiline ? ( )} - + ); } diff --git a/apps/meteor/client/views/admin/subscription/SubscriptionPage.tsx b/apps/meteor/client/views/admin/subscription/SubscriptionPage.tsx index 98c5054a8f268e3ac67a57c9d55a649b43cfb4ff..93792b517dcff67e9d7cbbeae9e22947fc2e4da2 100644 --- a/apps/meteor/client/views/admin/subscription/SubscriptionPage.tsx +++ b/apps/meteor/client/views/admin/subscription/SubscriptionPage.tsx @@ -176,7 +176,7 @@ const SubscriptionPage = () => { )} - {Boolean(licensesData?.trial || licensesData?.license?.information.cancellable) && ( + {Boolean(licensesData?.license?.information.cancellable) && ( diff --git a/apps/meteor/client/views/admin/subscription/components/FeatureUsageCard.tsx b/apps/meteor/client/views/admin/subscription/components/FeatureUsageCard.tsx index bcde39d701fdd4c1bc43671ad5cbd8cf66e2ff73..3e736062bd7d45499aa0d619700dbaa16543b08d 100644 --- a/apps/meteor/client/views/admin/subscription/components/FeatureUsageCard.tsx +++ b/apps/meteor/client/views/admin/subscription/components/FeatureUsageCard.tsx @@ -1,4 +1,4 @@ -import { Card, CardBody, CardColSection, CardFooter, CardTitle } from '@rocket.chat/ui-client'; +import { Box, Card, CardBody, CardControls, CardTitle } from '@rocket.chat/fuselage'; import type { ReactElement, ReactNode } from 'react'; import React, { memo } from 'react'; @@ -18,16 +18,16 @@ export type CardProps = { const FeatureUsageCard = ({ children, card }: FeatureUsageCardProps): ReactElement => { const { title, infoText, upgradeButton } = card; return ( - + {title} {infoText && } - - + + {children} - + - {upgradeButton && {upgradeButton}} + {upgradeButton && {upgradeButton}} ); }; diff --git a/apps/meteor/client/views/admin/subscription/components/UpgradeToGetMore.tsx b/apps/meteor/client/views/admin/subscription/components/UpgradeToGetMore.tsx index 05f83c787b4edeaa9e2bccad3baa0aaddb72a4a1..450c58994f62c0dfb1a7b879edb16a6d0355f5a5 100644 --- a/apps/meteor/client/views/admin/subscription/components/UpgradeToGetMore.tsx +++ b/apps/meteor/client/views/admin/subscription/components/UpgradeToGetMore.tsx @@ -1,8 +1,8 @@ -import { Box, States, StatesIcon, StatesTitle, StatesSubtitle, Grid, Button, ButtonGroup } from '@rocket.chat/fuselage'; -import { Card, CardBody, CardTitle, FramedIcon } from '@rocket.chat/ui-client'; +import { Box, States, StatesIcon, StatesTitle, StatesSubtitle, Button, ButtonGroup, CardGrid } from '@rocket.chat/fuselage'; import React, { memo } from 'react'; import { useTranslation } from 'react-i18next'; +import { GenericCard } from '../../../../components/GenericCard'; import { useExternalLink } from '../../../../hooks/useExternalLink'; import { PRICING_LINK } from '../utils/links'; @@ -43,39 +43,34 @@ const UpgradeToGetMore = ({ activeModules, children }: UpgradeToGetMoreProps) => } return ( - + {t('UpgradeToGetMore_Headline')} {t('UpgradeToGetMore_Subtitle')} - - {upgradeModules.map(({ title, body }, index) => ( - - - - - - - {title} - - - - - - {body} - - - - - ))} - - - - {children} - + + {upgradeModules.map((card, index) => { + return ; + })} + + + + + {children} + + ); }; diff --git a/apps/meteor/client/views/admin/subscription/components/cards/ActiveSessionsCard.tsx b/apps/meteor/client/views/admin/subscription/components/cards/ActiveSessionsCard.tsx index fbd850f262bae0f610449eee6cd3887fa2d60449..e42ae7f6b74498395d06b0210022ff26118e9f53 100644 --- a/apps/meteor/client/views/admin/subscription/components/cards/ActiveSessionsCard.tsx +++ b/apps/meteor/client/views/admin/subscription/components/cards/ActiveSessionsCard.tsx @@ -55,13 +55,11 @@ const ActiveSessionsCard = (): ReactElement => { }), }} > - - + + {used} / {total} - - {available} {t('ActiveSessions_available')} - + {available} {t('ActiveSessions_available')} ); diff --git a/apps/meteor/client/views/admin/subscription/components/cards/ActiveSessionsPeakCard.tsx b/apps/meteor/client/views/admin/subscription/components/cards/ActiveSessionsPeakCard.tsx index f62ec2ad164c0a7b144f1be8fb406c12ad38430b..02ac3eeeb536888a31951c03ca539f8a645125a2 100644 --- a/apps/meteor/client/views/admin/subscription/components/cards/ActiveSessionsPeakCard.tsx +++ b/apps/meteor/client/views/admin/subscription/components/cards/ActiveSessionsPeakCard.tsx @@ -33,20 +33,22 @@ const ActiveSessionsPeakCard = (): ReactElement => { }), }; + if (isLoading || maxMonthlyPeakConnections === undefined) { + return ( + + + + ); + } + return ( - {!isLoading && maxMonthlyPeakConnections !== undefined ? ( - - - {used} / {total} - - - {formatDate(new Date())} - + + + {used} / {total} - ) : ( - - )} + {formatDate(new Date())} + ); }; diff --git a/apps/meteor/client/views/admin/subscription/components/cards/AppsUsageCard.tsx b/apps/meteor/client/views/admin/subscription/components/cards/AppsUsageCard.tsx index 0043e12d02d194836ea3129d1380c5c2f8652896..dbd402ef2c7f23e6570dfc4691874559d3986cee 100644 --- a/apps/meteor/client/views/admin/subscription/components/cards/AppsUsageCard.tsx +++ b/apps/meteor/client/views/admin/subscription/components/cards/AppsUsageCard.tsx @@ -35,37 +35,36 @@ const AppsUsageCard = ({ privateAppsLimit, marketplaceAppsLimit }: AppsUsageCard }), }; + if (!privateAppsLimit || !marketplaceAppsLimit) { + return ( + + + + ); + } + return ( - {privateAppsLimit && marketplaceAppsLimit ? ( - - - - {t('Marketplace_apps')} - = 80 ? 'font-danger' : 'status-font-on-success'}> - {marketplaceAppsEnabled} / {marketplaceAppsLimitCount} - - - - = 80 ? 'danger' : 'success'} - /> + + +
{t('Marketplace_apps')}
+ = 80 ? 'font-danger' : 'status-font-on-success'}> + {marketplaceAppsEnabled} / {marketplaceAppsLimitCount} - - - {t('Private_apps')} - = 80 ? 'font-danger' : 'status-font-on-success'}> - {privateAppsEnabled} / {privateAppsLimitCount} - - + - = 80 ? 'danger' : 'success'} /> + = 80 ? 'danger' : 'success'} /> +
+ + +
{t('Private_apps')}
+ = 80 ? 'font-danger' : 'status-font-on-success'}> + {privateAppsEnabled} / {privateAppsLimitCount}
- ) : ( - - )} + + = 80 ? 'danger' : 'success'} /> +
); }; diff --git a/apps/meteor/client/views/admin/subscription/components/cards/FeaturesCard.tsx b/apps/meteor/client/views/admin/subscription/components/cards/FeaturesCard.tsx index 3a2318b70d6d1c0e8dedf7a67373d85ed311d583..8af99de7d38cc1f4197bfc7cc315d09ee205e79e 100644 --- a/apps/meteor/client/views/admin/subscription/components/cards/FeaturesCard.tsx +++ b/apps/meteor/client/views/admin/subscription/components/cards/FeaturesCard.tsx @@ -1,16 +1,15 @@ -import { Box } from '@rocket.chat/fuselage'; +import { Box, Card, CardBody, CardControls, CardTitle, FramedIcon } from '@rocket.chat/fuselage'; import { useMediaQuery } from '@rocket.chat/fuselage-hooks'; -import { CardCol, CardColSection, CardFooter, FramedIcon } from '@rocket.chat/ui-client'; import type { ReactElement } from 'react'; import React from 'react'; import { useTranslation } from 'react-i18next'; import { PRICING_LINK } from '../../utils/links'; -import FeatureUsageCard from '../FeatureUsageCard'; import InfoTextIconModal from '../InfoTextIconModal'; type FeatureSet = { - type: 'neutral' | 'success'; + success?: boolean; + neutral?: boolean; title: string; infoText?: string; }; @@ -20,83 +19,73 @@ type FeaturesCardProps = { isEnterprise: boolean; }; +const getFeatureSet = (modules: string[], isEnterprise: boolean): FeatureSet[] => { + const featureSet: FeatureSet[] = [ + { + success: isEnterprise, + title: 'Premium_and_unlimited_apps', + }, + { + success: isEnterprise, + title: 'Premium_omnichannel_capabilities', + }, + { + success: isEnterprise, + title: 'Unlimited_push_notifications', + }, + { + success: modules.includes('videoconference-enterprise'), + title: 'Video_call_manager', + }, + { + success: modules.includes('hide-watermark'), + title: 'Remove_RocketChat_Watermark', + infoText: 'Remove_RocketChat_Watermark_InfoText', + }, + { + success: modules.includes('scalability'), + title: 'High_scalabaility', + }, + { + success: modules.includes('custom-roles'), + title: 'Custom_roles', + }, + { + success: modules.includes('auditing'), + title: 'Message_audit', + }, + ]; + + // eslint-disable-next-line no-nested-ternary + return featureSet.sort(({ success: a }, { success: b }) => (a === b ? 0 : a ? -1 : 1)); +}; + const FeaturesCard = ({ activeModules, isEnterprise }: FeaturesCardProps): ReactElement => { const { t } = useTranslation(); const isSmall = useMediaQuery('(min-width: 1180px)'); - const getFeatureSet = (modules: string[], isEnterprise: boolean): FeatureSet[] => { - const featureSet: FeatureSet[] = [ - { - type: isEnterprise ? 'success' : 'neutral', - title: 'Premium_and_unlimited_apps', - }, - { - type: isEnterprise ? 'success' : 'neutral', - title: 'Premium_omnichannel_capabilities', - }, - { - type: isEnterprise ? 'success' : 'neutral', - title: 'Unlimited_push_notifications', - }, - { - type: modules.includes('videoconference-enterprise') ? 'success' : 'neutral', - title: 'Video_call_manager', - }, - { - type: modules.includes('hide-watermark') ? 'success' : 'neutral', - title: 'Remove_RocketChat_Watermark', - infoText: 'Remove_RocketChat_Watermark_InfoText', - }, - { - type: modules.includes('scalability') ? 'success' : 'neutral', - title: 'High_scalabaility', - }, - { - type: modules.includes('custom-roles') ? 'success' : 'neutral', - title: 'Custom_roles', - }, - { - type: modules.includes('auditing') ? 'success' : 'neutral', - title: 'Message_audit', - }, - ]; - - const sortedFeatureSet = featureSet.sort((a, b) => { - if (a.type === 'success' && b.type !== 'success') { - return -1; - } - if (a.type !== 'success' && b.type === 'success') { - return 1; - } - return featureSet.indexOf(a) - featureSet.indexOf(b); - }); - - return sortedFeatureSet; - }; - return ( - - - - - {getFeatureSet(activeModules, isEnterprise).map(({ type, title, infoText }, index) => ( - - - - {t(title)} - - {infoText && } + + {!isEnterprise ? t('Unlock_premium_capabilities') : t('Includes')} + + + {getFeatureSet(activeModules, isEnterprise).map(({ success, title, infoText }, index) => ( + + + + {t(title)} - ))} - - - - - {t('Compare_plans')} - - - - + {infoText && } +
+ ))} +
+
+ +
+ {t('Compare_plans')} + + + ); }; diff --git a/apps/meteor/client/views/admin/subscription/components/cards/PlanCard.tsx b/apps/meteor/client/views/admin/subscription/components/cards/PlanCard.tsx index 066a4fc1f160eedee26798cd32da7da55d2fbd85..5ccc5aeecd18a632934ba8d7c317395e14a0d3a3 100644 --- a/apps/meteor/client/views/admin/subscription/components/cards/PlanCard.tsx +++ b/apps/meteor/client/views/admin/subscription/components/cards/PlanCard.tsx @@ -17,12 +17,11 @@ type PlanCardProps = { const PlanCard = ({ licenseInformation, licenseLimits }: PlanCardProps): ReactElement => { const isTrial = licenseInformation.trial; - switch (true) { - case isTrial: - return ; - default: - return ; - } + return isTrial ? ( + + ) : ( + + ); }; export default PlanCard; diff --git a/apps/meteor/client/views/admin/subscription/components/cards/PlanCard/PlanCardBase.tsx b/apps/meteor/client/views/admin/subscription/components/cards/PlanCard/PlanCardBase.tsx deleted file mode 100644 index 1f6cefdb76469b72f1489ac02e3f32d60c15d296..0000000000000000000000000000000000000000 --- a/apps/meteor/client/views/admin/subscription/components/cards/PlanCard/PlanCardBase.tsx +++ /dev/null @@ -1,22 +0,0 @@ -import { Box, Icon, Palette } from '@rocket.chat/fuselage'; -import { Card, CardBody, CardColSection, CardTitle } from '@rocket.chat/ui-client'; -import type { ReactElement, ReactNode } from 'react'; -import React from 'react'; - -const PlanCardBase = ({ name, children }: { name: string; children: ReactNode }): ReactElement => { - return ( - - - - {name} - - - - {children} - - - - ); -}; - -export default PlanCardBase; diff --git a/apps/meteor/client/views/admin/subscription/components/cards/PlanCard/PlanCardCommunity.tsx b/apps/meteor/client/views/admin/subscription/components/cards/PlanCard/PlanCardCommunity.tsx index 6a8d00ad8b4d960f290d28efefa24c578cf60166..3ce0ee07cabd50fbe6f3fe6016e1d7b96ea6d6dc 100644 --- a/apps/meteor/client/views/admin/subscription/components/cards/PlanCard/PlanCardCommunity.tsx +++ b/apps/meteor/client/views/admin/subscription/components/cards/PlanCard/PlanCardCommunity.tsx @@ -1,22 +1,25 @@ -import { Box, Icon } from '@rocket.chat/fuselage'; +import { Card, CardBody, CardRow, Icon } from '@rocket.chat/fuselage'; import type { ReactElement } from 'react'; import React from 'react'; import { useTranslation } from 'react-i18next'; -import PlanCardBase from './PlanCardBase'; +import PlanCardHeader from './PlanCardHeader'; const PlanCardCommunity = (): ReactElement => { const { t } = useTranslation(); return ( - - - {t('free_per_month_user')} - - - {t('Self_managed_hosting')} - - + + + + + {t('free_per_month_user')} + + + {t('Self_managed_hosting')} + + + ); }; diff --git a/apps/meteor/client/views/admin/subscription/components/cards/PlanCard/PlanCardHeader.tsx b/apps/meteor/client/views/admin/subscription/components/cards/PlanCard/PlanCardHeader.tsx new file mode 100644 index 0000000000000000000000000000000000000000..c550cfd2069f17253240eaf05394abbfa8b29e5d --- /dev/null +++ b/apps/meteor/client/views/admin/subscription/components/cards/PlanCard/PlanCardHeader.tsx @@ -0,0 +1,14 @@ +import { CardTitle, Icon, Palette, CardHeader } from '@rocket.chat/fuselage'; +import type { ReactElement } from 'react'; +import React from 'react'; + +const PlanCardHeader = ({ name }: { name: string }): ReactElement => { + return ( + + + {name} + + ); +}; + +export default PlanCardHeader; diff --git a/apps/meteor/client/views/admin/subscription/components/cards/PlanCard/PlanCardPremium.tsx b/apps/meteor/client/views/admin/subscription/components/cards/PlanCard/PlanCardPremium.tsx index 056aab081785fe0fcc1fc51d36c73640053b18a8..ea96adf32a389dd9ac3cbc838eb5ecbaf90afd09 100644 --- a/apps/meteor/client/views/admin/subscription/components/cards/PlanCard/PlanCardPremium.tsx +++ b/apps/meteor/client/views/admin/subscription/components/cards/PlanCard/PlanCardPremium.tsx @@ -1,5 +1,5 @@ import type { ILicenseV3 } from '@rocket.chat/core-typings'; -import { Box, Icon, Skeleton } from '@rocket.chat/fuselage'; +import { Box, Card, CardBody, Icon, Skeleton } from '@rocket.chat/fuselage'; import { ExternalLink } from '@rocket.chat/ui-client'; import type { ReactElement } from 'react'; import React from 'react'; @@ -9,7 +9,7 @@ import { useFormatDate } from '../../../../../../hooks/useFormatDate'; import { useIsSelfHosted } from '../../../../../../hooks/useIsSelfHosted'; import { useLicenseName } from '../../../../../../hooks/useLicense'; import { CONTACT_SALES_LINK } from '../../../utils/links'; -import PlanCardBase from './PlanCardBase'; +import PlanCardHeader from './PlanCardHeader'; type LicenseLimits = { activeUsers: { max: number; value?: number }; @@ -31,35 +31,38 @@ const PlanCardPremium = ({ licenseInformation, licenseLimits }: PlanCardProps): const { visualExpiration } = licenseInformation; return ( - - {licenseLimits?.activeUsers.max === Infinity && ( - - - {t('Unlimited_seats')} - - )} - {visualExpiration && ( - - - - {isAutoRenew ? ( - t('Renews_DATE', { date: formatDate(visualExpiration) }) - ) : ( - - Contact sales to check plan renew date. - - )} + + + + {licenseLimits?.activeUsers.max === Infinity && ( + + + {t('Unlimited_seats')} - - )} - {!isLoading ? ( - - {isSelfHosted ? t('Self_managed_hosting') : t('Cloud_hosting')} - - ) : ( - - )} - + )} + {visualExpiration && ( + + + + {isAutoRenew ? ( + t('Renews_DATE', { date: formatDate(visualExpiration || '') }) + ) : ( + + Contact sales to check plan renew date. + + )} + + + )} + {!isLoading ? ( + + {isSelfHosted ? t('Self_managed_hosting') : t('Cloud_hosting')} + + ) : ( + + )} + + ); }; diff --git a/apps/meteor/client/views/admin/subscription/components/cards/PlanCard/PlanCardTrial.tsx b/apps/meteor/client/views/admin/subscription/components/cards/PlanCard/PlanCardTrial.tsx index 95ce21e679187c184218bb0caefa5ae7cc2ad506..eb6a020894ec1a0d50e5cf916ba23056728fcd00 100644 --- a/apps/meteor/client/views/admin/subscription/components/cards/PlanCard/PlanCardTrial.tsx +++ b/apps/meteor/client/views/admin/subscription/components/cards/PlanCard/PlanCardTrial.tsx @@ -1,5 +1,5 @@ import type { ILicenseV3 } from '@rocket.chat/core-typings'; -import { Box, Tag } from '@rocket.chat/fuselage'; +import { Box, Card, CardBody, CardControls, CardRow, Tag } from '@rocket.chat/fuselage'; import { ExternalLink } from '@rocket.chat/ui-client'; import differenceInDays from 'date-fns/differenceInDays'; import type { ReactElement } from 'react'; @@ -9,7 +9,7 @@ import { Trans, useTranslation } from 'react-i18next'; import { useLicenseName } from '../../../../../../hooks/useLicense'; import { DOWNGRADE_LINK, TRIAL_LINK } from '../../../utils/links'; import UpgradeButton from '../../UpgradeButton'; -import PlanCardBase from './PlanCardBase'; +import PlanCardHeader from './PlanCardHeader'; type PlanCardProps = { licenseInformation: ILicenseV3['information']; @@ -23,37 +23,46 @@ const PlanCardTrial = ({ licenseInformation }: PlanCardProps): ReactElement => { const { visualExpiration } = licenseInformation; return ( - - + + + {visualExpiration && ( - - {t('Trial_active')}{' '} + + + {t('Trial_active')} + {t('n_days_left', { n: differenceInDays(new Date(visualExpiration), new Date()) })} - + )} - - {isSalesAssisted ? ( - - Contact sales to finish your purchase and avoid - downgrade consequences. - - ) : ( - - Finish your purchase to avoid downgrade consequences. - - )} - - + + + + {isSalesAssisted ? ( + + Contact sales to finish your purchase and avoid + downgrade consequences. + + ) : ( + + Finish your purchase to avoid downgrade consequences. + + )} + + + + Why has a trial been applied to this workspace? - + + + {isSalesAssisted ? t('Finish_purchase') : t('Contact_sales')} - - + + ); }; diff --git a/apps/meteor/client/views/admin/users/AdminUserForm.tsx b/apps/meteor/client/views/admin/users/AdminUserForm.tsx index 1912150b4a487801d765c9f5b41dedeeb544ecea..a8575470509034bbec0b2bbd133bc09c910f25de 100644 --- a/apps/meteor/client/views/admin/users/AdminUserForm.tsx +++ b/apps/meteor/client/views/admin/users/AdminUserForm.tsx @@ -263,16 +263,14 @@ const UserForm = ({ userData, onReload, ...props }: AdminUserFormProps) => { )} - + {t('Verified')} - - } - /> - - + } + /> + {t('StatusMessage')} @@ -370,45 +368,41 @@ const UserForm = ({ userData, onReload, ...props }: AdminUserFormProps) => { )} - + {t('Require_password_change')} - - ( - - )} - /> - - + ( + + )} + /> + - + {t('Set_random_password_and_send_by_email')} - - ( - - )} - /> - - + ( + + )} + /> + {!isSmtpEnabled && ( { {errors?.roles && {errors.roles.message}} - + {t('Join_default_channels')} - - ( - - )} - /> - - + ( + + )} + /> + - + {t('Send_welcome_email')} - - ( - - )} - /> - - + ( + + )} + /> + {!isSmtpEnabled && ( void; }; +// TODO: Replace menu const AdminUserInfoActions = ({ username, userId, @@ -131,7 +132,7 @@ const AdminUserInfoActions = ({ }, [actionsDefinition, menu]); return ( - + {actions} ); diff --git a/apps/meteor/client/views/admin/users/AdminUserInfoWithData.tsx b/apps/meteor/client/views/admin/users/AdminUserInfoWithData.tsx index d0580b90c6428960bf6d2c172f45d23bf0eec05b..f9b8bb01e129059dd3025bf12b881efb925fae19 100644 --- a/apps/meteor/client/views/admin/users/AdminUserInfoWithData.tsx +++ b/apps/meteor/client/views/admin/users/AdminUserInfoWithData.tsx @@ -10,7 +10,7 @@ import React, { useMemo } from 'react'; import { getUserEmailAddress } from '../../../../lib/getUserEmailAddress'; import { ContextualbarContent } from '../../../components/Contextualbar'; import { FormSkeleton } from '../../../components/Skeleton'; -import UserCard from '../../../components/UserCard'; +import { UserCardRole } from '../../../components/UserCard'; import UserInfo from '../../../components/UserInfo'; import { UserStatus } from '../../../components/UserStatus'; import { getUserEmailVerified } from '../../../lib/utils/getUserEmailVerified'; @@ -76,7 +76,7 @@ const AdminUserInfoWithData = ({ uid, onReload }: AdminUserInfoWithDataProps): R name, username, lastLogin, - roles: getRoles(roles).map((role, index) => {role}), + roles: getRoles(roles).map((role, index) => {role}), bio, canViewAllInfo, phone, diff --git a/apps/meteor/client/views/admin/users/UsersTable/UsersTableRow.tsx b/apps/meteor/client/views/admin/users/UsersTable/UsersTableRow.tsx index 2572b7446c42ed14df63062f956b8d283eb4f63d..4010b118e57d6e41947e6503013c963dd1602768 100644 --- a/apps/meteor/client/views/admin/users/UsersTable/UsersTableRow.tsx +++ b/apps/meteor/client/views/admin/users/UsersTable/UsersTableRow.tsx @@ -1,6 +1,7 @@ import type { IRole, IUser } from '@rocket.chat/core-typings'; import { Box } from '@rocket.chat/fuselage'; import { capitalize } from '@rocket.chat/string-helpers'; +import { UserAvatar } from '@rocket.chat/ui-avatar'; import type { TranslationKey } from '@rocket.chat/ui-contexts'; import { useTranslation } from '@rocket.chat/ui-contexts'; import type { ReactElement } from 'react'; @@ -8,7 +9,6 @@ import React from 'react'; import { Roles } from '../../../../../app/models/client'; import { GenericTableRow, GenericTableCell } from '../../../../components/GenericTable'; -import UserAvatar from '../../../../components/avatar/UserAvatar'; type UsersTableRowProps = { user: Pick; diff --git a/apps/meteor/client/views/admin/workspace/DeploymentCard/DeploymentCard.tsx b/apps/meteor/client/views/admin/workspace/DeploymentCard/DeploymentCard.tsx index 2201e7c9c34c58c7c5898a3b06c56187f2892208..0b48d0c8cc8827843bfa6195e0e0baee24191022 100644 --- a/apps/meteor/client/views/admin/workspace/DeploymentCard/DeploymentCard.tsx +++ b/apps/meteor/client/views/admin/workspace/DeploymentCard/DeploymentCard.tsx @@ -1,13 +1,13 @@ import type { IWorkspaceInfo, IStats } from '@rocket.chat/core-typings'; -import { ButtonGroup, Button } from '@rocket.chat/fuselage'; +import { Button, Card, CardControls } from '@rocket.chat/fuselage'; import { useMutableCallback } from '@rocket.chat/fuselage-hooks'; import type { IInstance } from '@rocket.chat/rest-typings'; -import { Card, CardBody, CardCol, CardColSection, CardColTitle, CardFooter } from '@rocket.chat/ui-client'; import { useSetModal, useTranslation } from '@rocket.chat/ui-contexts'; import type { ReactElement } from 'react'; import React, { memo } from 'react'; import { useFormatDateAndTime } from '../../../../hooks/useFormatDateAndTime'; +import WorkspaceCardSection from '../components/WorkspaceCardSection'; import InstancesModal from './components/InstancesModal'; type DeploymentCardProps = { @@ -28,59 +28,41 @@ const DeploymentCard = ({ serverInfo: { info }, statistics, instances }: Deploym }); return ( - - - - {t('Deployment')} - - {t('Version')} - {statistics.version} - - - {t('Deployment_ID')} - {statistics.uniqueId} - - {appsEngineVersion && ( - - {t('Apps_Engine_Version')} - {appsEngineVersion} - - )} - - {t('Node_version')} - {statistics.process.nodeVersion} - - - {t('DB_Migration')} - {`${statistics.migration.version} (${formatDateAndTime(statistics.migration.lockedAt)})`} - - - {t('MongoDB')} - {`${statistics.mongoVersion} / ${statistics.mongoStorageEngine} ${ - !statistics.msEnabled ? `(oplog ${statistics.oplogEnabled ? t('Enabled') : t('Disabled')})` : '' - }`} - - - {t('Commit_details')} + + + + + + {appsEngineVersion && } + + + + {t('github_HEAD')}: ({commit.hash ? commit.hash.slice(0, 9) : ''})
{t('Branch')}: {commit.branch}
{commit.subject} -
- - {t('PID')} - {statistics.process.pid} - -
-
+ + } + /> + {!!instances.length && ( - - - - - + + + )}
); diff --git a/apps/meteor/client/views/admin/workspace/MessagesRoomsCard/MessagesRoomsCard.tsx b/apps/meteor/client/views/admin/workspace/MessagesRoomsCard/MessagesRoomsCard.tsx index 7f6538b07b5bba37ca24da476b61398178557923..4b3ffd819f810c672c0043d865afb210da59fb04 100644 --- a/apps/meteor/client/views/admin/workspace/MessagesRoomsCard/MessagesRoomsCard.tsx +++ b/apps/meteor/client/views/admin/workspace/MessagesRoomsCard/MessagesRoomsCard.tsx @@ -1,9 +1,12 @@ import type { IStats } from '@rocket.chat/core-typings'; -import { TextSeparator, Card, CardBody, CardCol, CardColSection, CardColTitle, CardIcon } from '@rocket.chat/ui-client'; +import { Card } from '@rocket.chat/fuselage'; import { useTranslation } from '@rocket.chat/ui-contexts'; import type { ReactElement } from 'react'; import React, { memo } from 'react'; +import WorkspaceCardSection from '../components/WorkspaceCardSection'; +import WorkspaceCardTextSeparator from '../components/WorkspaceCardTextSeparator'; + type MessagesRoomsCardProps = { statistics: IStats; }; @@ -12,110 +15,46 @@ const MessagesRoomsCard = ({ statistics }: MessagesRoomsCardProps): ReactElement const t = useTranslation(); return ( - - - - - {t('Total_rooms')} - - - {t('Channels')} - - } - value={statistics.totalChannels} - /> - - - {t('Private_Groups')} - - } - value={statistics.totalPrivateGroups} - /> - - - {t('Direct_Messages')} - - } - value={statistics.totalDirect} - /> - - - {t('Discussions')} - - } - value={statistics.totalDiscussions} - /> - - - {t('Omnichannel')} - - } - value={statistics.totalLivechat} - /> - - + + + + + + + + + + } + /> - - {t('Messages')} - - - {t('Stats_Total_Messages_Channel')} - - } - value={statistics.totalChannelMessages} - /> - - - {t('Stats_Total_Messages_PrivateGroup')} - - } + + + - - - {t('Stats_Total_Messages_Direct')} - - } - value={statistics.totalDirectMessages} - /> - - - {t('Stats_Total_Messages_Discussions')} - - } + + - - - {t('Stats_Total_Messages_Livechat')} - - } + - - - - + + + } + /> ); }; diff --git a/apps/meteor/client/views/admin/workspace/UsersUploadsCard/UsersUploadsCard.tsx b/apps/meteor/client/views/admin/workspace/UsersUploadsCard/UsersUploadsCard.tsx index eaf1079d5d700b98daf839f1bcb43060ce1d067c..3298f38b0f114d015521ba69f921e7dd82b4f807 100644 --- a/apps/meteor/client/views/admin/workspace/UsersUploadsCard/UsersUploadsCard.tsx +++ b/apps/meteor/client/views/admin/workspace/UsersUploadsCard/UsersUploadsCard.tsx @@ -1,14 +1,14 @@ import type { IStats } from '@rocket.chat/core-typings'; -import { ButtonGroup, Button } from '@rocket.chat/fuselage'; +import { Button, Card, CardBody, CardControls } from '@rocket.chat/fuselage'; import { useMutableCallback } from '@rocket.chat/fuselage-hooks'; -import { TextSeparator, Card, CardBody, CardCol, CardColSection, CardColTitle, CardFooter, CardIcon } from '@rocket.chat/ui-client'; import { useRouter, useTranslation } from '@rocket.chat/ui-contexts'; import type { ReactElement } from 'react'; import React, { memo } from 'react'; import { useHasLicenseModule } from '../../../../../ee/client/hooks/useHasLicenseModule'; -import { UserStatus } from '../../../../components/UserStatus'; import { useFormatMemorySize } from '../../../../hooks/useFormatMemorySize'; +import WorkspaceCardSection from '../components/WorkspaceCardSection'; +import WorkspaceCardTextSeparator from '../components/WorkspaceCardTextSeparator'; type UsersUploadsCardProps = { statistics: IStats; @@ -27,81 +27,50 @@ const UsersUploadsCard = ({ statistics }: UsersUploadsCardProps): ReactElement = const canViewEngagement = useHasLicenseModule('engagement-dashboard'); return ( - - - - - {t('Users')} - - - - - {t('Online')} - - } - value={statistics.onlineUsers} - /> - - - - - {t('Busy')} - - } - value={statistics.busyUsers} - /> - - - - - {t('Away')} - - } - value={statistics.awayUsers} - /> - - - - - {t('Offline')} - - } - value={statistics.offlineUsers} - /> - - + + + + + + + + + + } + /> - - {t('Types')} - - - - - - + + + + + + + + } + /> - - {t('Uploads')} - - - - + + + + + } + /> - - - - - + + + + ); }; diff --git a/apps/meteor/client/views/admin/workspace/VersionCard/VersionCard.tsx b/apps/meteor/client/views/admin/workspace/VersionCard/VersionCard.tsx index 68f83f4eb505abef6420689641c37a54f6c0a3d9..1f4ba9a75d34a3946c8ff320c886c5b5b2d5be02 100644 --- a/apps/meteor/client/views/admin/workspace/VersionCard/VersionCard.tsx +++ b/apps/meteor/client/views/admin/workspace/VersionCard/VersionCard.tsx @@ -1,8 +1,8 @@ import type { IWorkspaceInfo } from '@rocket.chat/core-typings'; -import { Box, Icon } from '@rocket.chat/fuselage'; +import { Box, Card, CardBody, CardCol, CardControls, CardHeader, CardTitle, Icon } from '@rocket.chat/fuselage'; import { useMediaQuery } from '@rocket.chat/fuselage-hooks'; import type { SupportedVersions } from '@rocket.chat/server-cloud-communication'; -import { Card, CardBody, CardCol, CardColSection, CardColTitle, CardFooter, ExternalLink } from '@rocket.chat/ui-client'; +import { ExternalLink } from '@rocket.chat/ui-client'; import type { LocationPathname } from '@rocket.chat/ui-contexts'; import { useModal, useMediaUrl } from '@rocket.chat/ui-contexts'; import type { ReactElement, ReactNode } from 'react'; @@ -15,7 +15,7 @@ import { useRegistrationStatus } from '../../../../hooks/useRegistrationStatus'; import { isOverLicenseLimits } from '../../../../lib/utils/isOverLicenseLimits'; import VersionCardActionButton from './components/VersionCardActionButton'; import type { VersionActionItem } from './components/VersionCardActionItem'; -import VersionCardActionItemList from './components/VersionCardActionItemList'; +import VersionCardActionItem from './components/VersionCardActionItem'; import { VersionCardSkeleton } from './components/VersionCardSkeleton'; import { VersionTag } from './components/VersionTag'; import { getVersionStatus } from './getVersionStatus'; @@ -113,17 +113,15 @@ const VersionCard = ({ serverInfo }: VersionCardProps): ReactElement => { [ isOverLimits ? { - type: 'danger', + danger: true, icon: 'warning', label: t('Plan_limits_reached'), } : { - type: 'neutral', icon: 'check', label: t('Operating_withing_plan_limits'), }, (isAirgapped || !versions) && { - type: 'neutral', icon: 'warning', label: ( @@ -136,7 +134,6 @@ const VersionCard = ({ serverInfo }: VersionCardProps): ReactElement => { versionStatus?.label !== 'outdated' && versionStatus?.expiration && { - type: 'neutral', icon: 'check', label: ( @@ -147,7 +144,7 @@ const VersionCard = ({ serverInfo }: VersionCardProps): ReactElement => { ), }, versionStatus?.label === 'outdated' && { - type: 'danger', + danger: true, icon: 'warning', label: ( @@ -158,54 +155,47 @@ const VersionCard = ({ serverInfo }: VersionCardProps): ReactElement => { }, isRegistered ? { - type: 'neutral', icon: 'check', label: t('Workspace_registered'), } : { - type: 'danger', + danger: true, icon: 'warning', label: t('Workspace_not_registered'), }, ].filter(Boolean) as VersionActionItem[] - ).sort((a) => (a.type === 'danger' ? -1 : 1)); + ).sort((a) => (a.danger ? -1 : 1)); }, [isOverLimits, t, isAirgapped, versions, versionStatus?.label, versionStatus?.expiration, formatDate, isRegistered]); + if (isLoading && !licenseData) { + return ( + + ; + + ); + } + return ( - - {!isLoading && licenseData ? ( - <> - - - - - {t('Version_version', { version: serverVersion })} - - {!isAirgapped && versions && } - - - - - - - {licenseName.data} - - - {actionItems.length > 0 && ( - - - - )} - - - {actionButton && ( - - - - )} - - ) : ( - + + + + {t('Version_version', { version: serverVersion })} + {!isAirgapped && versions && } + + + + {licenseName.data} + + + + + {actionItems.length > 0 && actionItems.map((item, index) => )} + + + {actionButton && ( + + + )} ); diff --git a/apps/meteor/client/views/admin/workspace/VersionCard/components/VersionCardActionItem.tsx b/apps/meteor/client/views/admin/workspace/VersionCard/components/VersionCardActionItem.tsx index b382aee92c336b07b7c706950a0ac4a1ac8da0a3..afbbd63fddcdab629c7f7a08900d974ec68ac074 100644 --- a/apps/meteor/client/views/admin/workspace/VersionCard/components/VersionCardActionItem.tsx +++ b/apps/meteor/client/views/admin/workspace/VersionCard/components/VersionCardActionItem.tsx @@ -1,30 +1,21 @@ -import { Box } from '@rocket.chat/fuselage'; +import { Box, FramedIcon } from '@rocket.chat/fuselage'; import type { Keys } from '@rocket.chat/icons'; -import { FramedIcon } from '@rocket.chat/ui-client'; import type { ReactElement, ReactNode } from 'react'; import React from 'react'; export type VersionActionItem = { - type: 'danger' | 'neutral'; + danger?: boolean; icon: Keys; label: ReactNode; }; -type VersionCardActionItemProps = { - actionItem: VersionActionItem; -}; +type VersionCardActionItemProps = VersionActionItem; -const VersionCardActionItem = ({ actionItem }: VersionCardActionItemProps): ReactElement => { +const VersionCardActionItem = ({ icon, label, danger }: VersionCardActionItemProps): ReactElement => { return ( - - - {actionItem.label} + + + {label} ); }; diff --git a/apps/meteor/client/views/admin/workspace/VersionCard/components/VersionCardActionItemList.tsx b/apps/meteor/client/views/admin/workspace/VersionCard/components/VersionCardActionItemList.tsx deleted file mode 100644 index 9fdd1416dadc0e694d984d85bbe629ccf55d7f2b..0000000000000000000000000000000000000000 --- a/apps/meteor/client/views/admin/workspace/VersionCard/components/VersionCardActionItemList.tsx +++ /dev/null @@ -1,20 +0,0 @@ -import React from 'react'; - -import type { VersionActionItem } from './VersionCardActionItem'; -import VersionCardActionItem from './VersionCardActionItem'; - -type VersionCardActionItemListProps = { - actionItems: VersionActionItem[]; -}; - -const VersionCardActionItemList = ({ actionItems }: VersionCardActionItemListProps) => { - return ( - <> - {actionItems.map((item, index) => ( - - ))} - - ); -}; - -export default VersionCardActionItemList; diff --git a/apps/meteor/client/views/admin/workspace/VersionCard/modals/RegisterWorkspaceSetupModal/RegisterWorkspaceSetupStepOneModal.tsx b/apps/meteor/client/views/admin/workspace/VersionCard/modals/RegisterWorkspaceSetupModal/RegisterWorkspaceSetupStepOneModal.tsx index 41e8f300d898b4af7f2a7543118cc63fbbeb9f9b..68d66657fad611d85829647133025ed995ba0875 100644 --- a/apps/meteor/client/views/admin/workspace/VersionCard/modals/RegisterWorkspaceSetupModal/RegisterWorkspaceSetupStepOneModal.tsx +++ b/apps/meteor/client/views/admin/workspace/VersionCard/modals/RegisterWorkspaceSetupModal/RegisterWorkspaceSetupStepOneModal.tsx @@ -1,4 +1,5 @@ import { Modal, Box, Field, FieldLabel, FieldRow, TextInput, CheckBox, ButtonGroup, Button } from '@rocket.chat/fuselage'; +import { useUniqueId } from '@rocket.chat/fuselage-hooks'; import { ExternalLink } from '@rocket.chat/ui-client'; import { useEndpoint, useSetModal, useToastMessageDispatch, useTranslation } from '@rocket.chat/ui-contexts'; import React from 'react'; @@ -51,6 +52,9 @@ const RegisterWorkspaceSetupStepOneModal = ({ } }; + const emailField = useUniqueId(); + const termsField = useUniqueId(); + return ( @@ -66,9 +70,10 @@ const RegisterWorkspaceSetupStepOneModal = ({ {t('RegisterWorkspace_Setup_Subtitle')} - {t('RegisterWorkspace_Setup_Label')} + {t('RegisterWorkspace_Setup_Label')} { setEmail((e.target as HTMLInputElement).value); }} @@ -85,16 +90,17 @@ const RegisterWorkspaceSetupStepOneModal = ({ {t('RegisterWorkspace_Setup_No_Account_Subtitle')} - - setTerms(!terms)} /> - - - I agree with Terms and Conditions - and - Privacy Policy - - - + + + + + I agree with Terms and Conditions and{' '} + Privacy Policy + + + setTerms(!terms)} /> + + diff --git a/apps/meteor/client/views/admin/workspace/WorkspacePage.tsx b/apps/meteor/client/views/admin/workspace/WorkspacePage.tsx index 604aaf2dbb9fd0cd5d2b5f5c99ed0a3f341ff39a..d45c01153318b76065dbb9d38060be2228d21993 100644 --- a/apps/meteor/client/views/admin/workspace/WorkspacePage.tsx +++ b/apps/meteor/client/views/admin/workspace/WorkspacePage.tsx @@ -1,5 +1,5 @@ import type { IWorkspaceInfo, IStats } from '@rocket.chat/core-typings'; -import { Box, Button, ButtonGroup, Callout, Grid } from '@rocket.chat/fuselage'; +import { Box, Button, ButtonGroup, Callout, CardGrid } from '@rocket.chat/fuselage'; import type { IInstance } from '@rocket.chat/rest-typings'; import { useTranslation } from '@rocket.chat/ui-contexts'; import React, { memo } from 'react'; @@ -78,21 +78,14 @@ const WorkspacePage = ({ )} - - - - - - - - - - - - - - - + + + + + + + + diff --git a/apps/meteor/client/views/admin/workspace/WorkspaceRoute.tsx b/apps/meteor/client/views/admin/workspace/WorkspaceRoute.tsx index ed10912965d36199d5c9b3c663fdad4a609f556e..7cadf4b7ce1a671f314d2e56e5c84d794416e77b 100644 --- a/apps/meteor/client/views/admin/workspace/WorkspaceRoute.tsx +++ b/apps/meteor/client/views/admin/workspace/WorkspaceRoute.tsx @@ -30,6 +30,10 @@ const WorkspaceRoute = (): ReactElement => { statisticsQuery.refetch(); }; + const handleClickDownloadInfo = (): void => { + downloadJsonAs(statisticsQuery.data, 'statistics'); + }; + if (serverInfoQuery.isError || instancesQuery.isError || statisticsQuery.isError) { return ( @@ -47,10 +51,6 @@ const WorkspaceRoute = (): ReactElement => { ); } - const handleClickDownloadInfo = (): void => { - downloadJsonAs(statisticsQuery.data, 'statistics'); - }; - return ( { + return ( + + {title} + {body && body} + + ); +}; + +export default WorkspaceCardSection; diff --git a/apps/meteor/client/views/admin/workspace/components/WorkspaceCardTextSeparator.tsx b/apps/meteor/client/views/admin/workspace/components/WorkspaceCardTextSeparator.tsx new file mode 100644 index 0000000000000000000000000000000000000000..607189d28365477ea9a11c208aafd5d820483697 --- /dev/null +++ b/apps/meteor/client/views/admin/workspace/components/WorkspaceCardTextSeparator.tsx @@ -0,0 +1,30 @@ +import { Box, Icon, StatusBullet } from '@rocket.chat/fuselage'; +import type { Keys } from '@rocket.chat/icons'; +import { TextSeparator } from '@rocket.chat/ui-client'; +import type { ComponentProps, ReactNode } from 'react'; +import React from 'react'; + +type WorkspaceCardTextSeparatorProps = { + label: ReactNode; + icon?: Keys; + status?: ComponentProps['status']; + value: ReactNode; +}; +const WorkspaceCardTextSeparator = ({ icon, label, value, status }: WorkspaceCardTextSeparatorProps) => ( + + {icon && } + {status && ( + + + + )} + {label && label} + + } + value={value} + /> +); + +export default WorkspaceCardTextSeparator; diff --git a/apps/meteor/client/views/cloud/CloudAnnouncementsRegion.tsx b/apps/meteor/client/views/cloud/CloudAnnouncementsRegion.tsx index 7352b54e757f0b15e980baa7e5d2e00dec81737e..2f353a4f9701be83c066c2955354536a77f0585c 100644 --- a/apps/meteor/client/views/cloud/CloudAnnouncementsRegion.tsx +++ b/apps/meteor/client/views/cloud/CloudAnnouncementsRegion.tsx @@ -16,7 +16,7 @@ const CloudAnnouncementsRegion = () => { select: (data) => data.banners, enabled: !!uid, staleTime: 0, - refetchInterval: 1000 * 60 * 5, + refetchInterval: 1000 * 60 * 60 * 24, }); const subscribeToNotifyLoggedIn = useStream('notify-logged'); diff --git a/apps/meteor/client/views/composer/EmojiPicker/EmojiElement.tsx b/apps/meteor/client/views/composer/EmojiPicker/EmojiElement.tsx index 9577e3def78263772c16f767013908c10d3c44fc..2440a940d995afbfdbe4c5a23e7f8a841f0e41ea 100644 --- a/apps/meteor/client/views/composer/EmojiPicker/EmojiElement.tsx +++ b/apps/meteor/client/views/composer/EmojiPicker/EmojiElement.tsx @@ -30,7 +30,6 @@ const EmojiElement = ({ emoji, image, onClick, small = false, ...props }: EmojiE return ( ); }; diff --git a/apps/meteor/client/views/composer/EmojiPicker/EmojiPicker.tsx b/apps/meteor/client/views/composer/EmojiPicker/EmojiPicker.tsx index 971005e3fa8e4f733fed4850470f5d3a7a05d79c..054ed258e11e14238e9ce7f206aef0cb94a2389b 100644 --- a/apps/meteor/client/views/composer/EmojiPicker/EmojiPicker.tsx +++ b/apps/meteor/client/views/composer/EmojiPicker/EmojiPicker.tsx @@ -195,7 +195,7 @@ const EmojiPicker = ({ reference, onClose, onPickEmoji }: EmojiPickerProps) => { aria-label={t('Search')} /> - + {emojiCategories.map((category, index) => ( { {t('Some_ideas_to_get_you_started')} - - {canAddUsers && ( - - - - )} - {canCreateChannel && ( - - - - )} - + + + {canAddUsers && } + {canCreateChannel && } - - - - - - - - - {(isAdmin || (isCustomContentVisible && !isCustomContentBodyEmpty)) && ( - - - - )} + {(isAdmin || (isCustomContentVisible && !isCustomContentBodyEmpty)) && } + + ); diff --git a/apps/meteor/client/views/home/cards/AddUsersCard.tsx b/apps/meteor/client/views/home/cards/AddUsersCard.tsx index 551552a182d4b2c90c869df1399e5b3ef3b763c8..33a096fbfe5d8f42633009aa5c37b1f0ddd9941a 100644 --- a/apps/meteor/client/views/home/cards/AddUsersCard.tsx +++ b/apps/meteor/client/views/home/cards/AddUsersCard.tsx @@ -1,29 +1,27 @@ -import { Button } from '@rocket.chat/fuselage'; -import { Card, CardBody, CardFooter, CardFooterWrapper, CardTitle } from '@rocket.chat/ui-client'; -import { useTranslation, useRoute } from '@rocket.chat/ui-contexts'; -import type { ReactElement } from 'react'; +import type { Card } from '@rocket.chat/fuselage'; +import { useTranslation, useRouter } from '@rocket.chat/ui-contexts'; +import type { ComponentProps, ReactElement } from 'react'; import React from 'react'; -const AddUsersCard = (): ReactElement => { +import { GenericCard, GenericCardButton } from '../../../components/GenericCard'; + +const AddUsersCard = (props: Omit, 'type'>): ReactElement => { const t = useTranslation(); - const adminUsersRoute = useRoute('admin-users'); + const router = useRouter(); const handleOpenUsersRoute = (): void => { - adminUsersRoute.push({}); + router.navigate('/admin/users'); }; return ( - - {t('Add_users')} - {t('Invite_and_add_members_to_this_workspace_to_start_communicating')} - - - - - - + ]} + data-qa-id='homepage-add-users-card' + width='x340' + {...props} + /> ); }; diff --git a/apps/meteor/client/views/home/cards/CreateChannelsCard.tsx b/apps/meteor/client/views/home/cards/CreateChannelsCard.tsx index 9e84a8ff373dd819f9bbd1877304e6d05acfd2da..a0edf15ba3acdd6cb11fbdedd15e19b5231cb8dd 100644 --- a/apps/meteor/client/views/home/cards/CreateChannelsCard.tsx +++ b/apps/meteor/client/views/home/cards/CreateChannelsCard.tsx @@ -1,27 +1,26 @@ -import { Button } from '@rocket.chat/fuselage'; -import { Card, CardBody, CardFooter, CardFooterWrapper, CardTitle } from '@rocket.chat/ui-client'; +import type { Card } from '@rocket.chat/fuselage'; import { useTranslation, useSetModal } from '@rocket.chat/ui-contexts'; -import type { ReactElement } from 'react'; +import type { ComponentProps, ReactElement } from 'react'; import React from 'react'; +import { GenericCard, GenericCardButton } from '../../../components/GenericCard'; import CreateChannelWithData from '../../../sidebar/header/CreateChannel'; -const CreateChannelsCard = (): ReactElement => { +const CreateChannelsCard = (props: Omit, 'type'>): ReactElement => { const t = useTranslation(); const setModal = useSetModal(); const openCreateChannelModal = (): void => setModal( setModal(null)} />); return ( - - {t('Create_channels')} - {t('Create_a_public_channel_that_new_workspace_members_can_join')} - - - - - - + ]} + data-qa-id='homepage-create-channels-card' + width='x340' + {...props} + /> ); }; diff --git a/apps/meteor/client/views/home/cards/CustomContentCard.tsx b/apps/meteor/client/views/home/cards/CustomContentCard.tsx index 7683f1e1c30007309a9beffc981deaf7295828c5..af1bf899dd8431f38abe6e90e102b78c774aeac8 100644 --- a/apps/meteor/client/views/home/cards/CustomContentCard.tsx +++ b/apps/meteor/client/views/home/cards/CustomContentCard.tsx @@ -1,15 +1,15 @@ -import { Box, Button, Icon, Tag } from '@rocket.chat/fuselage'; -import { Card, CardFooter, CardFooterWrapper } from '@rocket.chat/ui-client'; -import { useRole, useSettingSetValue, useSetting, useToastMessageDispatch, useTranslation, useRoute } from '@rocket.chat/ui-contexts'; -import type { ReactElement } from 'react'; +import { Box, Button, Card, CardBody, CardControls, CardHeader, Icon, Tag } from '@rocket.chat/fuselage'; +import { useRole, useSettingSetValue, useSetting, useToastMessageDispatch, useTranslation, useRouter } from '@rocket.chat/ui-contexts'; +import type { ComponentProps, ReactElement } from 'react'; import React from 'react'; import { useIsEnterprise } from '../../../hooks/useIsEnterprise'; import CustomHomepageContent from '../CustomHomePageContent'; -const CustomContentCard = (): ReactElement | null => { +const CustomContentCard = (props: Omit, 'type'>): ReactElement | null => { const t = useTranslation(); const dispatchToastMessage = useToastMessageDispatch(); + const router = useRouter(); const { data } = useIsEnterprise(); const isAdmin = useRole('admin'); @@ -18,8 +18,6 @@ const CustomContentCard = (): ReactElement | null => { const isCustomContentVisible = useSetting('Layout_Home_Custom_Block_Visible'); const isCustomContentOnly = useSetting('Layout_Custom_Body_Only'); - const settingsRoute = useRoute('admin-settings'); - const setCustomContentVisible = useSettingSetValue('Layout_Home_Custom_Block_Visible'); const setCustomContentOnly = useSettingSetValue('Layout_Custom_Body_Only'); @@ -53,39 +51,37 @@ const CustomContentCard = (): ReactElement | null => { if (isAdmin) { return ( - - + + {willNotShowCustomContent ? t('Not_Visible_To_Workspace') : t('Visible_To_Workspace')} - - {isCustomContentBodyEmpty ? t('Homepage_Custom_Content_Default_Message') : } - - - - - - - + + {isCustomContentBodyEmpty ? t('Homepage_Custom_Content_Default_Message') : } + + + + + ); } diff --git a/apps/meteor/client/views/home/cards/DesktopAppsCard.tsx b/apps/meteor/client/views/home/cards/DesktopAppsCard.tsx index 5a3c7834b3b44b77878b45e4db44632985fe9982..bd3b46c6adbd71bd2e8f01270c6c9995b016e3c7 100644 --- a/apps/meteor/client/views/home/cards/DesktopAppsCard.tsx +++ b/apps/meteor/client/views/home/cards/DesktopAppsCard.tsx @@ -1,31 +1,32 @@ -import { Button } from '@rocket.chat/fuselage'; -import { Card, CardBody, CardFooter, CardFooterWrapper, CardTitle } from '@rocket.chat/ui-client'; +import type { Card } from '@rocket.chat/fuselage'; import { useTranslation } from '@rocket.chat/ui-contexts'; -import type { ReactElement } from 'react'; +import type { ComponentProps, ReactElement } from 'react'; import React from 'react'; +import { GenericCard, GenericCardButton } from '../../../components/GenericCard'; import { useExternalLink } from '../../../hooks/useExternalLink'; const WINDOWS_APP_URL = 'https://go.rocket.chat/i/hp-desktop-app-windows'; const LINUX_APP_URL = 'https://go.rocket.chat/i/hp-desktop-app-linux'; const MAC_APP_URL = 'https://go.rocket.chat/i/hp-desktop-app-mac'; -const DesktopAppsCard = (): ReactElement => { +const DesktopAppsCard = (props: Omit, 'type'>): ReactElement => { const t = useTranslation(); const handleOpenLink = useExternalLink(); return ( - - {t('Desktop_apps')} - {t('Install_rocket_chat_on_your_preferred_desktop_platform')} - - - - - - - - + handleOpenLink(WINDOWS_APP_URL)} children={t('Platform_Windows')} role='link' />, + handleOpenLink(LINUX_APP_URL)} children={t('Platform_Linux')} role='link' />, + handleOpenLink(MAC_APP_URL)} children={t('Platform_Mac')} role='link' />, + ]} + width='x340' + data-qa-id='homepage-desktop-apps-card' + {...props} + /> ); }; diff --git a/apps/meteor/client/views/home/cards/DocumentationCard.tsx b/apps/meteor/client/views/home/cards/DocumentationCard.tsx index 2ae8c06180936a513ad736dc0abeca34f9eba808..fc50e0aefa3e2700d5397c7ade84861bb11fd5eb 100644 --- a/apps/meteor/client/views/home/cards/DocumentationCard.tsx +++ b/apps/meteor/client/views/home/cards/DocumentationCard.tsx @@ -1,27 +1,26 @@ -import { Button } from '@rocket.chat/fuselage'; -import { Card, CardBody, CardFooter, CardFooterWrapper, CardTitle } from '@rocket.chat/ui-client'; +import type { Card } from '@rocket.chat/fuselage'; import { useTranslation } from '@rocket.chat/ui-contexts'; -import type { ReactEle