diff --git a/.changeset/brave-brooms-invent.md b/.changeset/brave-brooms-invent.md new file mode 100644 index 000000000000..35d32b485944 --- /dev/null +++ b/.changeset/brave-brooms-invent.md @@ -0,0 +1,5 @@ +--- +"@rocket.chat/meteor": patch +--- + +Fixes a problem that caused visitor creation to fail when GDPR setting was enabled and visitor was created via Apps Engine or the deprecated `livechat:registerGuest` method. diff --git a/.changeset/bump-patch-1727212585363.md b/.changeset/bump-patch-1727212585363.md new file mode 100644 index 000000000000..e1eaa7980afb --- /dev/null +++ b/.changeset/bump-patch-1727212585363.md @@ -0,0 +1,5 @@ +--- +'@rocket.chat/meteor': patch +--- + +Bump @rocket.chat/meteor version. diff --git a/.changeset/cyan-ladybugs-thank.md b/.changeset/cyan-ladybugs-thank.md new file mode 100644 index 000000000000..377a014fcb72 --- /dev/null +++ b/.changeset/cyan-ladybugs-thank.md @@ -0,0 +1,5 @@ +--- +"@rocket.chat/meteor": patch +--- + +Fixed error during sendmessage client stub diff --git a/.changeset/dirty-stingrays-beg.md b/.changeset/dirty-stingrays-beg.md new file mode 100644 index 000000000000..cf5e3a4ca839 --- /dev/null +++ b/.changeset/dirty-stingrays-beg.md @@ -0,0 +1,7 @@ +--- +"@rocket.chat/meteor": minor +"@rocket.chat/model-typings": minor +"@rocket.chat/rest-typings": minor +--- + +Added support for specifying a unit on departments' creation and update diff --git a/.changeset/dry-taxis-cry.md b/.changeset/dry-taxis-cry.md new file mode 100644 index 000000000000..ae8244087d9e --- /dev/null +++ b/.changeset/dry-taxis-cry.md @@ -0,0 +1,5 @@ +--- +'@rocket.chat/meteor': patch +--- + +Fixes a race condition that causes livechat conversations to get stuck in the agent's sidebar panel after being forwarded. diff --git a/.changeset/five-coats-rhyme.md b/.changeset/five-coats-rhyme.md new file mode 100644 index 000000000000..c5359e3c978a --- /dev/null +++ b/.changeset/five-coats-rhyme.md @@ -0,0 +1,5 @@ +--- +'@rocket.chat/fuselage-ui-kit': patch +--- + +Fixed an error that incorrectly showed conference calls as not answered after they ended diff --git a/.changeset/great-humans-live.md b/.changeset/great-humans-live.md new file mode 100644 index 000000000000..1d97d9da23ae --- /dev/null +++ b/.changeset/great-humans-live.md @@ -0,0 +1,5 @@ +--- +"@rocket.chat/meteor": patch +--- + +Fixed a Federation callback not awaiting db call diff --git a/.changeset/hot-balloons-travel.md b/.changeset/hot-balloons-travel.md new file mode 100644 index 000000000000..d6154babc49d --- /dev/null +++ b/.changeset/hot-balloons-travel.md @@ -0,0 +1,5 @@ +--- +"@rocket.chat/meteor": patch +--- + +Fixed issue where when you marked a room as unread and you were part of it, sometimes it would mark it as read right after diff --git a/.changeset/kind-llamas-grin.md b/.changeset/kind-llamas-grin.md new file mode 100644 index 000000000000..fd349e82d7f9 --- /dev/null +++ b/.changeset/kind-llamas-grin.md @@ -0,0 +1,5 @@ +--- +"@rocket.chat/meteor": patch +--- + +Changed the contextualbar behavior based on chat size instead the viewport diff --git a/.changeset/late-hats-carry.md b/.changeset/late-hats-carry.md new file mode 100644 index 000000000000..ec24c7cd5376 --- /dev/null +++ b/.changeset/late-hats-carry.md @@ -0,0 +1,6 @@ +--- +"@rocket.chat/meteor": minor +"@rocket.chat/i18n": minor +--- + +Improves the accessibility of the report user modal by adding an appropriate label, description, and ARIA attributes. diff --git a/.changeset/late-planes-sniff.md b/.changeset/late-planes-sniff.md new file mode 100644 index 000000000000..d702a938da78 --- /dev/null +++ b/.changeset/late-planes-sniff.md @@ -0,0 +1,7 @@ +--- +"@rocket.chat/meteor": minor +"@rocket.chat/core-typings": patch +"@rocket.chat/i18n": patch +--- + +Added a new setting to enable mentions in end to end encrypted channels diff --git a/.changeset/little-bottles-peel.md b/.changeset/little-bottles-peel.md new file mode 100644 index 000000000000..eacb88108a0f --- /dev/null +++ b/.changeset/little-bottles-peel.md @@ -0,0 +1,5 @@ +--- +'@rocket.chat/meteor': patch +--- + +Security Hotfix (https://docs.rocket.chat/docs/security-fixes-and-updates) diff --git a/.changeset/many-rules-shout.md b/.changeset/many-rules-shout.md new file mode 100644 index 000000000000..eacb88108a0f --- /dev/null +++ b/.changeset/many-rules-shout.md @@ -0,0 +1,5 @@ +--- +'@rocket.chat/meteor': patch +--- + +Security Hotfix (https://docs.rocket.chat/docs/security-fixes-and-updates) diff --git a/.changeset/pre.json b/.changeset/pre.json new file mode 100644 index 000000000000..7c415a6b0dde --- /dev/null +++ b/.changeset/pre.json @@ -0,0 +1,103 @@ +{ + "mode": "pre", + "tag": "rc", + "initialVersions": { + "@rocket.chat/meteor": "6.13.0-develop", + "rocketchat-services": "1.3.3", + "@rocket.chat/uikit-playground": "0.4.0", + "@rocket.chat/account-service": "0.4.6", + "@rocket.chat/authorization-service": "0.4.6", + "@rocket.chat/ddp-streamer": "0.3.6", + "@rocket.chat/omnichannel-transcript": "0.4.6", + "@rocket.chat/presence-service": "0.4.6", + "@rocket.chat/queue-worker": "0.4.6", + "@rocket.chat/stream-hub-service": "0.4.6", + "@rocket.chat/license": "0.2.6", + "@rocket.chat/omnichannel-services": "0.3.3", + "@rocket.chat/pdf-worker": "0.2.3", + "@rocket.chat/presence": "0.2.6", + "@rocket.chat/ui-theming": "0.2.1", + "@rocket.chat/account-utils": "0.0.2", + "@rocket.chat/agenda": "0.1.0", + "@rocket.chat/api-client": "0.2.6", + "@rocket.chat/apps": "0.1.6", + "@rocket.chat/base64": "1.0.13", + "@rocket.chat/cas-validate": "0.0.2", + "@rocket.chat/core-services": "0.6.0", + "@rocket.chat/core-typings": "6.13.0-develop", + "@rocket.chat/cron": "0.1.6", + "@rocket.chat/ddp-client": "0.3.6", + "@rocket.chat/eslint-config": "0.7.0", + "@rocket.chat/favicon": "0.0.2", + "@rocket.chat/fuselage-ui-kit": "10.0.0", + "@rocket.chat/gazzodown": "10.0.0", + "@rocket.chat/i18n": "0.7.0", + "@rocket.chat/instance-status": "0.1.6", + "@rocket.chat/jest-presets": "0.0.1", + "@rocket.chat/jwt": "0.1.1", + "@rocket.chat/livechat": "1.19.3", + "@rocket.chat/log-format": "0.0.2", + "@rocket.chat/logger": "0.0.2", + "@rocket.chat/message-parser": "0.31.29", + "@rocket.chat/mock-providers": "0.1.2", + "@rocket.chat/model-typings": "0.7.0", + "@rocket.chat/models": "0.2.3", + "@rocket.chat/poplib": "0.0.2", + "@rocket.chat/password-policies": "0.0.2", + "@rocket.chat/patch-injection": "0.0.1", + "@rocket.chat/peggy-loader": "0.31.25", + "@rocket.chat/random": "1.2.2", + "@rocket.chat/release-action": "2.2.3", + "@rocket.chat/release-changelog": "0.1.0", + "@rocket.chat/rest-typings": "6.13.0-develop", + "@rocket.chat/server-cloud-communication": "0.0.2", + "@rocket.chat/server-fetch": "0.0.3", + "@rocket.chat/sha256": "1.0.10", + "@rocket.chat/tools": "0.2.2", + "@rocket.chat/ui-avatar": "6.0.0", + "@rocket.chat/ui-client": "10.0.0", + "@rocket.chat/ui-composer": "0.2.1", + "@rocket.chat/ui-contexts": "10.0.0", + "@rocket.chat/ui-kit": "0.36.1", + "@rocket.chat/ui-video-conf": "10.0.0", + "@rocket.chat/web-ui-registration": "10.0.0" + }, + "changesets": [ + "brave-brooms-invent", + "brown-singers-appear", + "bump-patch-1727212585363", + "cyan-ladybugs-thank", + "dirty-stingrays-beg", + "five-coats-rhyme", + "four-cherries-kneel", + "great-humans-live", + "healthy-rivers-nail", + "heavy-snails-help", + "hot-balloons-travel", + "khaki-cameras-glow", + "kind-llamas-grin", + "late-planes-sniff", + "many-balloons-scream", + "many-rules-shout", + "mighty-drinks-hide", + "nasty-tools-enjoy", + "pink-swans-teach", + "quick-rings-wave", + "quiet-cherries-punch", + "rich-toes-bow", + "rotten-rabbits-brush", + "short-drinks-itch", + "sixty-spoons-own", + "small-crabs-travel", + "soft-mirrors-remember", + "spicy-rocks-burn", + "strong-grapes-brake", + "stupid-pigs-share", + "sweet-nails-grin", + "tame-mayflies-press", + "tiny-geckos-kiss", + "wet-hats-walk", + "wise-avocados-taste", + "witty-lemons-type" + ] +} diff --git a/.changeset/quick-rings-wave.md b/.changeset/quick-rings-wave.md new file mode 100644 index 000000000000..0ea22897ff45 --- /dev/null +++ b/.changeset/quick-rings-wave.md @@ -0,0 +1,7 @@ +--- +"@rocket.chat/meteor": minor +"@rocket.chat/i18n": minor +"@rocket.chat/ui-client": minor +--- + +Added new Admin Feature Preview management view, this will allow the workspace admins to both enable feature previewing in the workspace as well as define which feature previews are enabled by default for the users in the workspace. diff --git a/.changeset/rotten-rabbits-brush.md b/.changeset/rotten-rabbits-brush.md new file mode 100644 index 000000000000..916f4cc8034a --- /dev/null +++ b/.changeset/rotten-rabbits-brush.md @@ -0,0 +1,5 @@ +--- +'@rocket.chat/meteor': patch +--- + +Resolves the issue where outgoing integrations failed to trigger after the version 6.12.0 upgrade by correcting the parameter order from the `afterSaveMessage` callback to listener functions. This ensures the correct room information is passed, restoring the functionality of outgoing webhooks, IRC bridge, Autotranslate, and Engagement Dashboard. diff --git a/.changeset/small-crabs-travel.md b/.changeset/small-crabs-travel.md new file mode 100644 index 000000000000..201494a5b70f --- /dev/null +++ b/.changeset/small-crabs-travel.md @@ -0,0 +1,5 @@ +--- +'@rocket.chat/meteor': patch +--- + +Fixed avatar blob image setting in setUserAvatar method by correcting service handling logic. diff --git a/.changeset/soft-mirrors-remember.md b/.changeset/soft-mirrors-remember.md new file mode 100644 index 000000000000..78b005ee6b6e --- /dev/null +++ b/.changeset/soft-mirrors-remember.md @@ -0,0 +1,8 @@ +--- +"@rocket.chat/meteor": minor +"@rocket.chat/core-services": minor +"@rocket.chat/model-typings": minor +"@rocket.chat/rest-typings": minor +--- + +New `teams.listChildren` endpoint that allows users listing rooms & discussions from teams. Only the discussions from the team's main room are returned. diff --git a/.changeset/spicy-rocks-burn.md b/.changeset/spicy-rocks-burn.md new file mode 100644 index 000000000000..6468dbbec241 --- /dev/null +++ b/.changeset/spicy-rocks-burn.md @@ -0,0 +1,5 @@ +--- +"@rocket.chat/meteor": patch +--- + +Fixed `LivechatSessionTaken` webhook event being called without the `agent` param, which represents the agent serving the room. diff --git a/.changeset/strong-grapes-brake.md b/.changeset/strong-grapes-brake.md new file mode 100644 index 000000000000..c867600a8cd2 --- /dev/null +++ b/.changeset/strong-grapes-brake.md @@ -0,0 +1,9 @@ +--- +'@rocket.chat/meteor': patch +--- + +Fixed remaining direct references to external user avatar URLs + +Fixed local avatars having priority over external provider + +It mainly corrects the behavior of E2E encryption messages and desktop notifications. diff --git a/.changeset/sweet-nails-grin.md b/.changeset/sweet-nails-grin.md new file mode 100644 index 000000000000..de240bfc0e3f --- /dev/null +++ b/.changeset/sweet-nails-grin.md @@ -0,0 +1,5 @@ +--- +"@rocket.chat/meteor": patch +--- + +Fixed inconsistency between the markdown parser from the composer and the rest of the application when using bold and italics in a text. diff --git a/.changeset/witty-apples-pretend.md b/.changeset/witty-apples-pretend.md new file mode 100644 index 000000000000..cb4dd696983d --- /dev/null +++ b/.changeset/witty-apples-pretend.md @@ -0,0 +1,5 @@ +--- +"@rocket.chat/meteor": patch +--- + +Fixes async E2EE key exchange in development environments where change streams are no longer used diff --git a/.changeset/witty-lemons-type.md b/.changeset/witty-lemons-type.md new file mode 100644 index 000000000000..a007cbe6260e --- /dev/null +++ b/.changeset/witty-lemons-type.md @@ -0,0 +1,10 @@ +--- +'@rocket.chat/core-services': minor +'@rocket.chat/model-typings': minor +'@rocket.chat/core-typings': minor +'@rocket.chat/rest-typings': minor +'@rocket.chat/ui-client': minor +'@rocket.chat/meteor': minor +--- + +Implemented new feature preview for Sidepanel diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index a834776aeff5..f66c5d29de5b 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -1,4 +1,5 @@ /packages/* @RocketChat/Architecture +/packages/apps-engine/ @RocketChat/apps /packages/core-typings/ @RocketChat/Architecture /packages/rest-typings/ @RocketChat/Architecture @RocketChat/backend /packages/ui-contexts/ @RocketChat/frontend diff --git a/.github/actions/build-docker-image/action.yml b/.github/actions/build-docker-image/action.yml index 378f6bdb01b9..02a05d9605a7 100644 --- a/.github/actions/build-docker-image/action.yml +++ b/.github/actions/build-docker-image/action.yml @@ -12,6 +12,9 @@ inputs: required: false password: required: false + deno-version: + required: true + type: string outputs: image-name: @@ -59,7 +62,7 @@ runs: fi; echo "Build ${{ inputs.release }} Docker image" - docker build -t $IMAGE_NAME . + docker build --build-arg DENO_VERSION=${{ inputs.deno-version }} -t $IMAGE_NAME . echo "image-name-base=${IMAGE_NAME_BASE}" >> $GITHUB_OUTPUT echo "image-name=${IMAGE_NAME}" >> $GITHUB_OUTPUT diff --git a/.github/actions/build-docker/action.yml b/.github/actions/build-docker/action.yml index 5af39b924057..ae84e376a0d9 100644 --- a/.github/actions/build-docker/action.yml +++ b/.github/actions/build-docker/action.yml @@ -9,6 +9,10 @@ inputs: required: true description: 'Node version' type: string + deno-version: + required: true + description: 'Deno version' + type: string platform: required: false description: 'Platform' @@ -66,6 +70,7 @@ runs: if: inputs.setup == 'true' with: node-version: ${{ inputs.node-version }} + deno-version: ${{ inputs.deno-version }} cache-modules: true install: true NPM_TOKEN: ${{ inputs.NPM_TOKEN }} @@ -79,6 +84,8 @@ runs: run: | args=(rocketchat ${{ inputs.build-containers }}) + export DENO_VERSION="${{ inputs.deno-version }}" + docker compose -f docker-compose-ci.yml build "${args[@]}" - name: Publish Docker images to GitHub Container Registry diff --git a/.github/actions/meteor-build/action.yml b/.github/actions/meteor-build/action.yml index 525595146700..bfd4ae7f5c20 100644 --- a/.github/actions/meteor-build/action.yml +++ b/.github/actions/meteor-build/action.yml @@ -16,6 +16,10 @@ inputs: NPM_TOKEN: required: false description: 'NPM token' + deno-version: + required: true + description: 'Deno version' + type: string runs: using: composite @@ -30,6 +34,7 @@ runs: uses: ./.github/actions/setup-node with: node-version: ${{ inputs.node-version }} + deno-version: ${{ inputs.deno-version }} cache-modules: true install: true NPM_TOKEN: ${{ inputs.NPM_TOKEN }} @@ -133,3 +138,4 @@ runs: name: build path: /tmp/Rocket.Chat.tar.gz overwrite: true + include-hidden-files: true diff --git a/.github/actions/setup-node/action.yml b/.github/actions/setup-node/action.yml index 1035e2835792..120797d2ba3c 100644 --- a/.github/actions/setup-node/action.yml +++ b/.github/actions/setup-node/action.yml @@ -11,10 +11,11 @@ inputs: install: required: false description: 'Install dependencies' - deno-dir: - required: false - description: 'Deno directory' - default: ~/.deno-cache + type: boolean + deno-version: + required: true + description: 'Deno version' + type: string NPM_TOKEN: required: false description: 'NPM token' @@ -28,21 +29,20 @@ runs: using: composite steps: - - run: echo 'DENO_DIR=${{ inputs.deno-dir }}' >> $GITHUB_ENV - shell: bash - - - name: Cache Node Modules + - name: Cache Node Modules & Deno if: inputs.cache-modules id: cache-node-modules uses: actions/cache@v3 with: + # We need to cache node_modules for all workspaces with "hoistingLimits" defined path: | .turbo/cache node_modules - ${{ env.DENO_DIR }} apps/meteor/node_modules apps/meteor/ee/server/services/node_modules - key: node-modules-${{ hashFiles('yarn.lock') }} + packages/apps-engine/node_modules + packages/apps-engine/.deno-cache + key: node-modules-${{ hashFiles('yarn.lock') }}-deno-${{ hashFiles('packages/apps-engine/deno-runtime/deno.lock') }} # # Could use this command to list all paths to save: # find . -name 'node_modules' -prune | grep -v "/\.meteor/" | grep -v "/meteor/packages/" @@ -54,6 +54,11 @@ runs: node-version: ${{ inputs.node-version }} cache: 'yarn' + - name: Use Deno ${{ inputs.deno-version }} + uses: denoland/setup-deno@v1 + with: + deno-version: ${{ inputs.deno-version }} + - name: yarn login shell: bash if: inputs.NPM_TOKEN diff --git a/.github/workflows/ci-code-check.yml b/.github/workflows/ci-code-check.yml index af50b3230ba7..41facad89a03 100644 --- a/.github/workflows/ci-code-check.yml +++ b/.github/workflows/ci-code-check.yml @@ -6,6 +6,9 @@ on: node-version: required: true type: string + deno-version: + required: true + type: string env: TOOL_NODE_FLAGS: ${{ vars.TOOL_NODE_FLAGS }} @@ -33,6 +36,7 @@ jobs: uses: ./.github/actions/setup-node with: node-version: ${{ inputs.node-version }} + deno-version: ${{ inputs.deno-version }} cache-modules: true install: true NPM_TOKEN: ${{ secrets.NPM_TOKEN }} diff --git a/.github/workflows/ci-deploy-gh-pages-preview.yml b/.github/workflows/ci-deploy-gh-pages-preview.yml index 17f247ddad94..8a0905a174bb 100644 --- a/.github/workflows/ci-deploy-gh-pages-preview.yml +++ b/.github/workflows/ci-deploy-gh-pages-preview.yml @@ -23,6 +23,7 @@ jobs: if: github.event.action != 'closed' with: node-version: 14.21.3 + deno-version: 1.37.1 cache-modules: true install: true diff --git a/.github/workflows/ci-deploy-gh-pages.yml b/.github/workflows/ci-deploy-gh-pages.yml index b381e05ae5d8..0aab8022c7e6 100644 --- a/.github/workflows/ci-deploy-gh-pages.yml +++ b/.github/workflows/ci-deploy-gh-pages.yml @@ -18,6 +18,7 @@ jobs: uses: ./.github/actions/setup-node with: node-version: 14.21.3 + deno-version: 1.37.1 cache-modules: true install: true diff --git a/.github/workflows/ci-test-e2e.yml b/.github/workflows/ci-test-e2e.yml index e6c02b7b6417..f219f39c0614 100644 --- a/.github/workflows/ci-test-e2e.yml +++ b/.github/workflows/ci-test-e2e.yml @@ -6,6 +6,9 @@ on: node-version: required: true type: string + deno-version: + required: true + type: string lowercase-repo: required: true type: string @@ -128,6 +131,7 @@ jobs: uses: ./.github/actions/setup-node with: node-version: ${{ inputs.node-version }} + deno-version: ${{ inputs.deno-version }} cache-modules: true install: true NPM_TOKEN: ${{ secrets.NPM_TOKEN }} @@ -291,8 +295,9 @@ jobs: if: inputs.type == 'ui' && always() uses: actions/upload-artifact@v4 with: - name: playwright-test-trace-${{ matrix.mongodb-version }}-${{ matrix.shard }} + name: playwright-test-trace-${{ inputs.release }}-${{ matrix.mongodb-version }}-${{ matrix.shard }} path: ./apps/meteor/tests/e2e/.playwright* + include-hidden-files: true - name: Show server logs if E2E test failed if: failure() @@ -326,6 +331,7 @@ jobs: with: name: e2e-api-ee-coverage-${{ matrix.mongodb-version }}-${{ matrix.shard }} path: /tmp/coverage + include-hidden-files: true - name: Store e2e-ee-coverage if: inputs.type == 'ui' && inputs.release == 'ee' @@ -333,3 +339,4 @@ jobs: with: name: e2e-ee-coverage-${{ matrix.mongodb-version }}-${{ matrix.shard }} path: ./apps/meteor/coverage* + include-hidden-files: true diff --git a/.github/workflows/ci-test-unit.yml b/.github/workflows/ci-test-unit.yml index 840808ff5e31..883212d0cf3d 100644 --- a/.github/workflows/ci-test-unit.yml +++ b/.github/workflows/ci-test-unit.yml @@ -6,6 +6,9 @@ on: node-version: required: true type: string + deno-version: + required: true + type: string enterprise-license: required: false type: string @@ -37,6 +40,7 @@ jobs: uses: ./.github/actions/setup-node with: node-version: ${{ inputs.node-version }} + deno-version: ${{ inputs.deno-version }} cache-modules: true install: true NPM_TOKEN: ${{ secrets.NPM_TOKEN }} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 40260f71d21f..6b6fa426ca96 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -31,6 +31,7 @@ jobs: rc-dockerfile-alpine: '${{ github.workspace }}/apps/meteor/.docker/Dockerfile.alpine' rc-docker-tag-alpine: '${{ steps.docker.outputs.gh-docker-tag }}.alpine' node-version: ${{ steps.var.outputs.node-version }} + deno-version: ${{ steps.var.outputs.deno-version }} # this is 100% intentional, secrets are not available for forks, so ee-tests will always fail # to avoid this, we are using a dummy license, expiring at 2025-06-31 enterprise-license: X/XumwIkgwQuld0alWKt37lVA90XjKOrfiMvMZ0/RtqsMtrdL9GoAk+4jXnaY1b2ePoG7XSzGhuxEDxFKIWJK3hIKGNTvrd980LgH5sM5+1T4P42ivSpd8UZi0bwjJkCFLIu9RozzYwslGG0IehMxe0S6VjcO0UYlUJtbMCBHuR2WmTAmO6YVU3ln+pZCbrPFaTPSS1RovhKaNCNkZwIx/CLWW8UTXUuFV/ML4PbKKVoa5nvvJwPeatgL7UCnlSD90lfCiiuikpzj/Y/JLkIL6velFbwNxsrxg9iRJ2k0sKheMMSmlTiGzSvZUm+na5WQq91aKGncih+DmaEZA7QGrjp4eoA0dqTk6OmItsy0fHmQhvZIOKNMeO7vNQiLbaSV6rqibrzu7WPpeIvsvL57T1h37USoCSB6+jDqkzdfoqIpz8BxTiJDj1d8xGPJFVrgxoqQqkj9qIP/gCaEz5DF39QFv5sovk4yK2O8fEQYod2d14V9yECYl4szZPMk1IBfCAC2w7czWGHHFonhL+CQGT403y5wmDmnsnjlCqMKF72odqfTPTI8XnCvJDriPMWohnQEAGtTTyciAhNokx/mjAVJ4NeZPcsbm4BjhvJvnjxx/BhYhBBTNWPaCSZzocfrGUj9Z+ZA7BEz+xAFQyGDx3xRzqIXfT0G7w8fvgYJMU= @@ -39,6 +40,7 @@ jobs: with: sparse-checkout: | package.json + .tool-versions sparse-checkout-cone-mode: false ref: ${{ github.ref }} @@ -53,6 +55,10 @@ jobs: echo "NODE_VERSION: ${NODE_VERSION}" echo "node-version=${NODE_VERSION}" >> $GITHUB_OUTPUT + DENO_VERSION=$(awk '$1=="deno"{ print $2 }' .tool-versions) + echo "DENO_VERSION: ${DENO_VERSION}" + echo "deno-version=${DENO_VERSION}" >> $GITHUB_OUTPUT + - id: by-tag run: | if echo "$GITHUB_REF_NAME" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+$' ; then @@ -150,6 +156,7 @@ jobs: uses: ./.github/actions/setup-node with: node-version: ${{ needs.release-versions.outputs.node-version }} + deno-version: ${{ needs.release-versions.outputs.deno-version }} cache-modules: true install: true NPM_TOKEN: ${{ secrets.NPM_TOKEN }} @@ -194,6 +201,7 @@ jobs: - uses: ./.github/actions/meteor-build with: node-version: ${{ needs.release-versions.outputs.node-version }} + deno-version: ${{ needs.release-versions.outputs.deno-version }} coverage: ${{ github.event_name != 'release' }} build-prod: @@ -224,6 +232,7 @@ jobs: - uses: ./.github/actions/meteor-build with: node-version: ${{ needs.release-versions.outputs.node-version }} + deno-version: ${{ needs.release-versions.outputs.deno-version }} coverage: ${{ github.event_name != 'release' }} build-gh-docker-coverage: @@ -252,6 +261,7 @@ jobs: CR_USER: ${{ secrets.CR_USER }} CR_PAT: ${{ secrets.CR_PAT }} node-version: ${{ needs.release-versions.outputs.node-version }} + deno-version: ${{ needs.release-versions.outputs.deno-version }} platform: ${{ matrix.platform }} build-containers: ${{ matrix.platform == 'alpine' && 'authorization-service account-service ddp-streamer-service presence-service stream-hub-service queue-worker-service omnichannel-transcript-service' || '' }} NPM_TOKEN: ${{ secrets.NPM_TOKEN }} @@ -280,6 +290,7 @@ jobs: CR_USER: ${{ secrets.CR_USER }} CR_PAT: ${{ secrets.CR_PAT }} node-version: ${{ needs.release-versions.outputs.node-version }} + deno-version: ${{ needs.release-versions.outputs.deno-version }} platform: ${{ matrix.platform }} build-containers: ${{ matrix.platform == 'alpine' && 'authorization-service account-service ddp-streamer-service presence-service stream-hub-service queue-worker-service omnichannel-transcript-service' || '' }} NPM_TOKEN: ${{ secrets.NPM_TOKEN }} @@ -300,6 +311,7 @@ jobs: uses: ./.github/workflows/ci-code-check.yml with: node-version: ${{ needs.release-versions.outputs.node-version }} + deno-version: ${{ needs.release-versions.outputs.deno-version }} test-unit: name: 🔨 Test Unit @@ -308,6 +320,7 @@ jobs: uses: ./.github/workflows/ci-test-unit.yml with: node-version: ${{ needs.release-versions.outputs.node-version }} + deno-version: ${{ needs.release-versions.outputs.deno-version }} enterprise-license: ${{ needs.release-versions.outputs.enterprise-license }} secrets: CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} @@ -321,6 +334,7 @@ jobs: type: api release: ce node-version: ${{ needs.release-versions.outputs.node-version }} + deno-version: ${{ needs.release-versions.outputs.deno-version }} lowercase-repo: ${{ needs.release-versions.outputs.lowercase-repo }} rc-dockerfile: ${{ needs.release-versions.outputs.rc-dockerfile }} rc-docker-tag: ${{ needs.release-versions.outputs.rc-docker-tag }} @@ -344,6 +358,7 @@ jobs: shard: '[1, 2, 3, 4]' total-shard: 4 node-version: ${{ needs.release-versions.outputs.node-version }} + deno-version: ${{ needs.release-versions.outputs.deno-version }} lowercase-repo: ${{ needs.release-versions.outputs.lowercase-repo }} rc-dockerfile: ${{ needs.release-versions.outputs.rc-dockerfile }} rc-docker-tag: ${{ needs.release-versions.outputs.rc-docker-tag }} @@ -371,6 +386,7 @@ jobs: enterprise-license: ${{ needs.release-versions.outputs.enterprise-license }} mongodb-version: "['4.4']" node-version: ${{ needs.release-versions.outputs.node-version }} + deno-version: ${{ needs.release-versions.outputs.deno-version }} lowercase-repo: ${{ needs.release-versions.outputs.lowercase-repo }} rc-dockerfile: ${{ needs.release-versions.outputs.rc-dockerfile }} rc-docker-tag: ${{ needs.release-versions.outputs.rc-docker-tag }} @@ -395,6 +411,7 @@ jobs: total-shard: 5 mongodb-version: "['4.4']" node-version: ${{ needs.release-versions.outputs.node-version }} + deno-version: ${{ needs.release-versions.outputs.deno-version }} lowercase-repo: ${{ needs.release-versions.outputs.lowercase-repo }} rc-dockerfile: ${{ needs.release-versions.outputs.rc-dockerfile }} rc-docker-tag: ${{ needs.release-versions.outputs.rc-docker-tag }} @@ -425,6 +442,7 @@ jobs: total-shard: 5 mongodb-version: "['6.0']" node-version: ${{ needs.release-versions.outputs.node-version }} + deno-version: ${{ needs.release-versions.outputs.deno-version }} lowercase-repo: ${{ needs.release-versions.outputs.lowercase-repo }} rc-dockerfile: ${{ needs.release-versions.outputs.rc-dockerfile }} rc-docker-tag: ${{ needs.release-versions.outputs.rc-docker-tag }} @@ -564,6 +582,7 @@ jobs: username: ${{ secrets.CR_USER }} password: ${{ secrets.CR_PAT }} NPM_TOKEN: ${{ secrets.NPM_TOKEN }} + deno-version: ${{ needs.release-versions.outputs.deno-version }} docker-image-publish: name: 🚀 Publish Docker Image (main) diff --git a/.github/workflows/new-release.yml b/.github/workflows/new-release.yml index b2eae5d90b92..70e9eb354a06 100644 --- a/.github/workflows/new-release.yml +++ b/.github/workflows/new-release.yml @@ -35,6 +35,7 @@ jobs: uses: ./.github/actions/setup-node with: node-version: 14.21.3 + deno-version: 1.37.1 cache-modules: true install: true NPM_TOKEN: ${{ secrets.NPM_TOKEN }} diff --git a/.github/workflows/pr-update-description.yml b/.github/workflows/pr-update-description.yml index 084f2a383480..26ffffc6c86f 100644 --- a/.github/workflows/pr-update-description.yml +++ b/.github/workflows/pr-update-description.yml @@ -22,6 +22,7 @@ jobs: uses: ./.github/actions/setup-node with: node-version: 14.21.3 + deno-version: 1.37.1 cache-modules: true install: true NPM_TOKEN: ${{ secrets.NPM_TOKEN }} diff --git a/.github/workflows/publish-release.yml b/.github/workflows/publish-release.yml index 3f2067ac7ec3..fe049f6a8369 100644 --- a/.github/workflows/publish-release.yml +++ b/.github/workflows/publish-release.yml @@ -25,6 +25,7 @@ jobs: uses: ./.github/actions/setup-node with: node-version: 14.21.3 + deno-version: 1.37.1 cache-modules: true install: true NPM_TOKEN: ${{ secrets.NPM_TOKEN }} diff --git a/.github/workflows/release-candidate.yml b/.github/workflows/release-candidate.yml new file mode 100644 index 000000000000..8c9048710dd2 --- /dev/null +++ b/.github/workflows/release-candidate.yml @@ -0,0 +1,36 @@ +name: Release candidate cut +on: + schedule: + - cron: '28 0 20 * *' # run at minute 28 to avoid the chance of delay due to high load on GH +jobs: + new-release: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ github.ref_name }} + fetch-depth: 0 + token: ${{ secrets.CI_PAT }} + + - name: Setup NodeJS + uses: ./.github/actions/setup-node + with: + node-version: 14.21.3 + deno-version: 1.37.1 + cache-modules: true + install: true + NPM_TOKEN: ${{ secrets.NPM_TOKEN }} + + - uses: rharkor/caching-for-turbo@v1.5 + + - name: Build packages + run: yarn build + + - name: 'Start release candidate' + uses: ./packages/release-action + with: + action: next + base-ref: ${{ github.ref_name }} + env: + NPM_TOKEN: ${{ secrets.NPM_TOKEN }} + GITHUB_TOKEN: ${{ secrets.CI_PAT }} diff --git a/.github/workflows/vulnerabilities-jira-integration.yml b/.github/workflows/vulnerabilities-jira-integration.yml deleted file mode 100644 index 2daeb533937d..000000000000 --- a/.github/workflows/vulnerabilities-jira-integration.yml +++ /dev/null @@ -1,22 +0,0 @@ -name: Github vulnerabilities and jira board integration - -on: - schedule: - - cron: '0 1 * * *' - -jobs: - IntegrateSecurityVulnerabilities: - runs-on: ubuntu-latest - steps: - - name: "Github vulnerabilities and jira board integration" - uses: RocketChat/github-vulnerabilities-jira-integration@v0.3 - env: - JIRA_URL: https://rocketchat.atlassian.net/ - JIRA_TOKEN: ${{ secrets.JIRA_TOKEN }} - GITHUB_TOKEN: ${{ secrets._GITHUB_TOKEN }} - JIRA_EMAIL: security-team-accounts@rocket.chat - JIRA_PROJECT_ID: GJIT - UID_CUSTOMFIELD_ID: customfield_10059 - JIRA_COMPLETE_PHASE_ID: 31 - JIRA_START_PHASE_ID: 11 - diff --git a/.gitignore b/.gitignore index dbad2c29a22c..8ca2d018f92e 100644 --- a/.gitignore +++ b/.gitignore @@ -29,6 +29,7 @@ yarn-error.log* .env.test.local .env.production.local +storybook-static # turbo .turbo @@ -55,3 +56,5 @@ yarn-error.log* data/ registration.yaml + +storybook-static diff --git a/.tool-versions b/.tool-versions new file mode 100644 index 000000000000..bc89cc40f83e --- /dev/null +++ b/.tool-versions @@ -0,0 +1 @@ +deno 1.37.1 diff --git a/.yarn/patches/moleculer-npm-0.14.34-440e26767d.patch b/.yarn/patches/moleculer-npm-0.14.34-440e26767d.patch new file mode 100644 index 000000000000..005ab83cfb48 --- /dev/null +++ b/.yarn/patches/moleculer-npm-0.14.34-440e26767d.patch @@ -0,0 +1,13 @@ +diff --git a/index.d.ts b/index.d.ts +index f64eda7889619f2a1e7cbd5bb5e3533fc8aaa182..3d815a3b7aaf426235aa92422129079aa5826908 100644 +--- a/index.d.ts ++++ b/index.d.ts +@@ -725,7 +725,7 @@ declare namespace Moleculer { + this: T + ) => void | Promise; + +- interface ServiceSchema { ++ interface ServiceSchema> { + name: string; + version?: string | number; + settings?: S; diff --git a/.yarn/patches/react-i18next-npm-15.0.1-0812bb73aa.patch b/.yarn/patches/react-i18next-npm-15.0.1-0812bb73aa.patch new file mode 100644 index 000000000000..cf5a292c0253 --- /dev/null +++ b/.yarn/patches/react-i18next-npm-15.0.1-0812bb73aa.patch @@ -0,0 +1,99 @@ +diff --git a/dist/amd/react-i18next.js b/dist/amd/react-i18next.js +index 115ef3cc362e5ce38e875f9b35dfd1fe687cfd6c..2ba1b4d54295eeff49c8c650f214d7875d9219a5 100644 +--- a/dist/amd/react-i18next.js ++++ b/dist/amd/react-i18next.js +@@ -495,7 +495,7 @@ define(['exports', 'react'], (function (exports, react) { 'use strict'; + } + addUsedNamespaces(namespaces) { + namespaces.forEach(ns => { +- this.usedNamespaces[ns] ??= true; ++ this.usedNamespaces[ns] = this.usedNamespaces[ns] ?? true; + }); + } + getUsedNamespaces() { +diff --git a/dist/amd/react-i18next.min.js b/dist/amd/react-i18next.min.js +index c54c9114869feb14df7855be24237f85d64fe7e4..0cc2b09d6db9bfc6f94dffff4b9e0f3fd9108510 100644 +--- a/dist/amd/react-i18next.min.js ++++ b/dist/amd/react-i18next.min.js +@@ -1 +1 @@ +-define(["exports","react"],(function(e,n){"use strict";function t(e){return e&&e.__esModule&&Object.prototype.hasOwnProperty.call(e,"default")?e.default:e}var s=t({area:!0,base:!0,br:!0,col:!0,embed:!0,hr:!0,img:!0,input:!0,link:!0,meta:!0,param:!0,source:!0,track:!0,wbr:!0}),a=/\s([^'"/\s><]+?)[\s/>]|([^\s=]+)=\s?(".*?"|'.*?')/g;function i(e){var n={type:"tag",name:"",voidElement:!1,attrs:{},children:[]},t=e.match(/<\/?([^\s]+?)[/\s>]/);if(t&&(n.name=t[1],(s[t[1]]||"/"===e.charAt(e.length-2))&&(n.voidElement=!0),n.name.startsWith("!--"))){var i=e.indexOf("--\x3e");return{type:"comment",comment:-1!==i?e.slice(4,i):""}}for(var r=new RegExp(a),o=null;null!==(o=r.exec(e));)if(o[0].trim())if(o[1]){var l=o[1].trim(),c=[l,""];l.indexOf("=")>-1&&(c=l.split("=")),n.attrs[c[0]]=c[1],r.lastIndex--}else o[2]&&(n.attrs[o[2]]=o[3].trim().substring(1,o[3].length-1));return n}var r=/<[a-zA-Z0-9\-\!\/](?:"[^"]*"|'[^']*'|[^'">])*>/g,o=/^\s*$/,l=Object.create(null);var c=function(e,n){n||(n={}),n.components||(n.components=l);var t,s=[],a=[],c=-1,u=!1;if(0!==e.indexOf("<")){var p=e.indexOf("<");s.push({type:"text",content:-1===p?e:e.substring(0,p)})}return e.replace(r,(function(r,l){if(u){if(r!=="")return;u=!1}var p,d="/"!==r.charAt(1),f=r.startsWith("\x3c!--"),h=l+r.length,m=e.charAt(h);if(f){var g=i(r);return c<0?(s.push(g),s):((p=a[c]).children.push(g),s)}if(d&&(c++,"tag"===(t=i(r)).type&&n.components[t.name]&&(t.type="component",u=!0),t.voidElement||u||!m||"<"===m||t.children.push({type:"text",content:e.slice(h,e.indexOf("<",h))}),0===c&&s.push(t),(p=a[c-1])&&p.children.push(t),a[c]=t),(!d||t.voidElement)&&(c>-1&&(t.voidElement||t.name===r.slice(2,-1))&&(c--,t=-1===c?s:a[c]),!u&&"<"!==m&&m)){p=-1===c?s:a[c].children;var y=e.indexOf("<",h),v=e.slice(h,-1===y?void 0:y);o.test(v)&&(v=" "),(y>-1&&c+p.length>=0||" "!==v)&&p.push({type:"text",content:v})}})),s};const u=function(){if(console?.warn){for(var e=arguments.length,n=new Array(e),t=0;t()=>{if(e.isInitialized)n();else{const t=()=>{setTimeout((()=>{e.off("initialized",t)}),0),n()};e.on("initialized",t)}},h=(e,n,t)=>{e.loadNamespaces(n,f(e,t))},m=(e,n,t,s)=>{y(t)&&(t=[t]),t.forEach((n=>{e.options.ns.indexOf(n)<0&&e.options.ns.push(n)})),e.loadLanguages(n,f(e,s))},g=e=>e.displayName||e.name||(y(e)&&e.length>0?e:"Unknown"),y=e=>"string"==typeof e,v=e=>"object"==typeof e&&null!==e,x=/&(?:amp|#38|lt|#60|gt|#62|apos|#39|quot|#34|nbsp|#160|copy|#169|reg|#174|hellip|#8230|#x2F|#47);/g,b={"&":"&","&":"&","<":"<","<":"<",">":">",">":">","'":"'","'":"'",""":'"',""":'"'," ":" "," ":" ","©":"©","©":"©","®":"®","®":"®","…":"…","…":"…","/":"/","/":"/"},E=e=>b[e];let O={bindI18n:"languageChanged",bindI18nStore:"",transEmptyNodeValue:"",transSupportBasicHtmlNodes:!0,transWrapTextNodes:"",transKeepBasicHtmlNodesFor:["br","strong","i","p"],useSuspense:!0,unescape:e=>e.replace(x,E)};const N=function(){let e=arguments.length>0&&void 0!==arguments[0]?arguments[0]:{};O={...O,...e}},$=()=>O;let w;const I=e=>{w=e},k=()=>w,S=(e,n)=>{if(!e)return!1;const t=e.props?.children??e.children;return n?t.length>0:!!t},j=e=>{if(!e)return[];const n=e.props?.children??e.children;return e.props?.i18nIsDynamicList?R(n):n},R=e=>Array.isArray(e)?e:[e],C=(e,t)=>{if(!e)return"";let s="";const a=R(e),i=t?.transSupportBasicHtmlNodes?t.transKeepBasicHtmlNodesFor??[]:[];return a.forEach(((e,a)=>{if(y(e))s+=`${e}`;else if(n.isValidElement(e)){const{props:n,type:r}=e,o=Object.keys(n).length,l=i.indexOf(r)>-1,c=n.children;if(c||!l||o)if(!c&&(!l||o)||n.i18nIsDynamicList)s+=`<${a}>`;else if(l&&1===o&&y(c))s+=`<${r}>${c}`;else{const e=C(c,t);s+=`<${a}>${e}`}else s+=`<${r}/>`}else if(null===e)u("Trans: the passed in value is invalid - seems you passed in a null child.");else if(v(e)){const{format:n,...t}=e,a=Object.keys(t);if(1===a.length){const e=n?`${a[0]}, ${n}`:a[0];s+=`{{${e}}}`}else u("react-i18next: the passed in object contained more than one variable - the object should look like {{ value, format }} where format is optional.",e)}else u("Trans: the passed in value is invalid - seems you passed in a variable like {number} - please pass in variables for interpolation as full objects like {{number}}.",e)})),s},T=(e,t,s,a,i,r)=>{if(""===t)return[];const o=a.transKeepBasicHtmlNodesFor||[],l=t&&new RegExp(o.map((e=>`<${e}`)).join("|")).test(t);if(!e&&!l&&!r)return[t];const u={},p=e=>{R(e).forEach((e=>{y(e)||(S(e)?p(j(e)):v(e)&&!n.isValidElement(e)&&Object.assign(u,e))}))};p(e);const d=c(`<0>${t}`),f={...u,...i},h=(e,t,s)=>{const a=j(e),i=g(a,t.children,s);return(e=>Array.isArray(e)&&e.every(n.isValidElement))(a)&&0===i.length||e.props?.i18nIsDynamicList?a:i},m=(e,t,s,a,i)=>{e.dummy?(e.children=t,s.push(n.cloneElement(e,{key:a},i?void 0:t))):s.push(...n.Children.map([e],(e=>{const s={...e.props};return delete s.i18nIsDynamicList,n.createElement(e.type,{...s,key:a,ref:e.ref},i?null:t)})))},g=(t,i,c)=>{const u=R(t);return R(i).reduce(((t,i,p)=>{const d=i.children?.[0]?.content&&s.services.interpolator.interpolate(i.children[0].content,f,s.language);if("tag"===i.type){let r=u[parseInt(i.name,10)];1!==c.length||r||(r=c[0][i.name]),r||(r={});const x=0!==Object.keys(i.attrs).length?((e,n)=>{const t={...n};return t.props=Object.assign(e.props,n.props),t})({props:i.attrs},r):r,b=n.isValidElement(x),E=b&&S(i,!0)&&!i.voidElement,O=l&&v(x)&&x.dummy&&!b,N=v(e)&&Object.hasOwnProperty.call(e,i.name);if(y(x)){const e=s.services.interpolator.interpolate(x,f,s.language);t.push(e)}else if(S(x)||E){const e=h(x,i,c);m(x,e,t,p)}else if(O){const e=g(u,i.children,c);m(x,e,t,p)}else if(Number.isNaN(parseFloat(i.name)))if(N){const e=h(x,i,c);m(x,e,t,p,i.voidElement)}else if(a.transSupportBasicHtmlNodes&&o.indexOf(i.name)>-1)if(i.voidElement)t.push(n.createElement(i.name,{key:`${i.name}-${p}`}));else{const e=g(u,i.children,c);t.push(n.createElement(i.name,{key:`${i.name}-${p}`},e))}else if(i.voidElement)t.push(`<${i.name} />`);else{const e=g(u,i.children,c);t.push(`<${i.name}>${e}`)}else if(v(x)&&!b){const e=i.children[0]?d:null;e&&t.push(e)}else m(x,d,t,p,1!==i.children.length||!d)}else if("text"===i.type){const e=a.transWrapTextNodes,o=r?a.unescape(s.services.interpolator.interpolate(i.content,f,s.language)):s.services.interpolator.interpolate(i.content,f,s.language);e?t.push(n.createElement(e,{key:`${i.name}-${p}`},o)):t.push(o)}return t}),[])},x=g([{dummy:!0,children:e||[]}],d,R(e||[]));return j(x[0])};function P(e){let{children:t,count:s,parent:a,i18nKey:i,context:r,tOptions:o={},values:l,defaults:c,components:u,ns:p,i18n:f,t:h,shouldUnescape:m,...g}=e;const v=f||k();if(!v)return d("You will need to pass in an i18next instance by using i18nextReactModule"),t;const x=h||v.t.bind(v)||(e=>e),b={...$(),...v.options?.react};let E=p||x.ns||v.options?.defaultNS;E=y(E)?[E]:E||["translation"];const O=C(t,b),N=c||O||b.transEmptyNodeValue||i,{hashTransKey:w}=b,I=i||(w?w(O||N):O||N);v.options?.interpolation?.defaultVariables&&(l=l&&Object.keys(l).length>0?{...l,...v.options.interpolation.defaultVariables}:{...v.options.interpolation.defaultVariables});const S=l||void 0!==s||!t?o.interpolation:{interpolation:{...o.interpolation,prefix:"#$?",suffix:"?$#"}},j={...o,context:r||o.context,count:s,...l,...S,defaultValue:N,ns:E},R=I?x(I,j):N;u&&Object.keys(u).forEach((e=>{const t=u[e];"function"==typeof t.type||!t.props||!t.props.children||R.indexOf(`${e}/>`)<0&&R.indexOf(`${e} />`)<0||(u[e]=n.createElement((function(){return n.createElement(n.Fragment,null,t)})))}));const P=T(u||t,R,v,b,j,m),L=a??b.defaultTransParent;return L?n.createElement(L,g,P):P}const L={type:"3rdParty",init(e){N(e.options.react),I(e)}},A=n.createContext();class V{constructor(){this.usedNamespaces={}}addUsedNamespaces(e){e.forEach((e=>{this.usedNamespaces[e]??=!0}))}getUsedNamespaces(){return Object.keys(this.usedNamespaces)}}const z=e=>async n=>({...await(e.getInitialProps?.(n))??{},...F()}),F=()=>{const e=k(),n=e.reportNamespaces?.getUsedNamespaces()??[],t={},s={};return e.languages.forEach((t=>{s[t]={},n.forEach((n=>{s[t][n]=e.getResourceBundle(t,n)||{}}))})),t.initialI18nStore=s,t.initialLanguage=e.language,t};const U=(e,n,t,s)=>e.getFixedT(n,t,s),B=function(e){let t=arguments.length>1&&void 0!==arguments[1]?arguments[1]:{};const{i18n:s}=t,{i18n:a,defaultNS:i}=n.useContext(A)||{},r=s||a||k();if(r&&!r.reportNamespaces&&(r.reportNamespaces=new V),!r){d("You will need to pass in an i18next instance by using initReactI18next");const e=(e,n)=>y(n)?n:v(n)&&y(n.defaultValue)?n.defaultValue:Array.isArray(e)?e[e.length-1]:e,n=[e,{},!1];return n.t=e,n.i18n={},n.ready=!1,n}r.options.react?.wait&&d("It seems you are still using the old wait option, you may migrate to the new useSuspense behaviour.");const o={...$(),...r.options.react,...t},{useSuspense:l,keyPrefix:c}=o;let u=e||i||r.options?.defaultNS;u=y(u)?[u]:u||["translation"],r.reportNamespaces.addUsedNamespaces?.(u);const p=(r.isInitialized||r.initializedStoreOnce)&&u.every((e=>function(e,n){let t=arguments.length>2&&void 0!==arguments[2]?arguments[2]:{};return n.languages&&n.languages.length?n.hasLoadedNamespace(e,{lng:t.lng,precheck:(n,s)=>{if(t.bindI18n?.indexOf("languageChanging")>-1&&n.services.backendConnector.backend&&n.isLanguageChangingTo&&!s(n.isLanguageChangingTo,e))return!1}}):(d("i18n.languages were undefined or empty",n.languages),!0)}(e,r,o))),f=((e,t,s,a)=>n.useCallback(U(e,t,s,a),[e,t,s,a]))(r,t.lng||null,"fallback"===o.nsMode?u:u[0],c),g=()=>f,x=()=>U(r,t.lng||null,"fallback"===o.nsMode?u:u[0],c),[b,E]=n.useState(g);let O=u.join();t.lng&&(O=`${t.lng}${O}`);const N=((e,t)=>{const s=n.useRef();return n.useEffect((()=>{s.current=e}),[e,t]),s.current})(O),w=n.useRef(!0);n.useEffect((()=>{const{bindI18n:e,bindI18nStore:n}=o;w.current=!0,p||l||(t.lng?m(r,t.lng,u,(()=>{w.current&&E(x)})):h(r,u,(()=>{w.current&&E(x)}))),p&&N&&N!==O&&w.current&&E(x);const s=()=>{w.current&&E(x)};return e&&r?.on(e,s),n&&r?.store.on(n,s),()=>{w.current=!1,r&&e?.split(" ").forEach((e=>r.off(e,s))),n&&r&&n.split(" ").forEach((e=>r.store.off(e,s)))}}),[r,O]),n.useEffect((()=>{w.current&&p&&E(g)}),[r,c,p]);const I=[b,r,p];if(I.t=b,I.i18n=r,I.ready=p,p)return I;if(!p&&!l)return I;throw new Promise((e=>{t.lng?m(r,t.lng,u,(()=>e())):h(r,u,(()=>e()))}))};const D=function(e,t){let s=arguments.length>2&&void 0!==arguments[2]?arguments[2]:{};const{i18n:a}=s,{i18n:i}=n.useContext(A)||{},r=a||i||k();r.options?.isClone||(e&&!r.initializedStoreOnce&&(r.services.resourceStore.data=e,r.options.ns=Object.values(e).reduce(((e,n)=>(Object.keys(n).forEach((n=>{e.indexOf(n)<0&&e.push(n)})),e)),r.options.ns),r.initializedStoreOnce=!0,r.isInitialized=!0),t&&!r.initializedLanguageOnce&&(r.changeLanguage(t),r.initializedLanguageOnce=!0))};e.I18nContext=A,e.I18nextProvider=function(e){let{i18n:t,defaultNS:s,children:a}=e;const i=n.useMemo((()=>({i18n:t,defaultNS:s})),[t,s]);return n.createElement(A.Provider,{value:i},a)},e.Trans=function(e){let{children:t,count:s,parent:a,i18nKey:i,context:r,tOptions:o={},values:l,defaults:c,components:u,ns:p,i18n:d,t:f,shouldUnescape:h,...m}=e;const{i18n:g,defaultNS:y}=n.useContext(A)||{},v=d||g||k(),x=f||v?.t.bind(v);return P({children:t,count:s,parent:a,i18nKey:i,context:r,tOptions:o,values:l,defaults:c,components:u,ns:p||x?.ns||y||v?.options?.defaultNS,i18n:v,t:f,shouldUnescape:h,...m})},e.TransWithoutContext=P,e.Translation=e=>{let{ns:n,children:t,...s}=e;const[a,i,r]=B(n,s);return t(a,{i18n:i,lng:i.language},r)},e.composeInitialProps=z,e.date=()=>"",e.getDefaults=$,e.getI18n=k,e.getInitialProps=F,e.initReactI18next=L,e.number=()=>"",e.plural=()=>"",e.select=()=>"",e.selectOrdinal=()=>"",e.setDefaults=N,e.setI18n=I,e.time=()=>"",e.useSSR=D,e.useTranslation=B,e.withSSR=()=>function(e){function t(t){let{initialI18nStore:s,initialLanguage:a,...i}=t;return D(s,a),n.createElement(e,{...i})}return t.getInitialProps=z(e),t.displayName=`withI18nextSSR(${g(e)})`,t.WrappedComponent=e,t},e.withTranslation=function(e){let t=arguments.length>1&&void 0!==arguments[1]?arguments[1]:{};return function(s){function a(a){let{forwardedRef:i,...r}=a;const[o,l,c]=B(e,{...r,keyPrefix:t.keyPrefix}),u={...r,t:o,i18n:l,tReady:c};return t.withRef&&i?u.ref=i:!t.withRef&&i&&(u.forwardedRef=i),n.createElement(s,u)}a.displayName=`withI18nextTranslation(${g(s)})`,a.WrappedComponent=s;return t.withRef?n.forwardRef(((e,t)=>n.createElement(a,Object.assign({},e,{forwardedRef:t})))):a}}})); ++define(["exports","react"],(function(e,n){"use strict";function t(e){return e&&e.__esModule&&Object.prototype.hasOwnProperty.call(e,"default")?e.default:e}var s=t({area:!0,base:!0,br:!0,col:!0,embed:!0,hr:!0,img:!0,input:!0,link:!0,meta:!0,param:!0,source:!0,track:!0,wbr:!0}),a=/\s([^'"/\s><]+?)[\s/>]|([^\s=]+)=\s?(".*?"|'.*?')/g;function i(e){var n={type:"tag",name:"",voidElement:!1,attrs:{},children:[]},t=e.match(/<\/?([^\s]+?)[/\s>]/);if(t&&(n.name=t[1],(s[t[1]]||"/"===e.charAt(e.length-2))&&(n.voidElement=!0),n.name.startsWith("!--"))){var i=e.indexOf("--\x3e");return{type:"comment",comment:-1!==i?e.slice(4,i):""}}for(var r=new RegExp(a),o=null;null!==(o=r.exec(e));)if(o[0].trim())if(o[1]){var l=o[1].trim(),c=[l,""];l.indexOf("=")>-1&&(c=l.split("=")),n.attrs[c[0]]=c[1],r.lastIndex--}else o[2]&&(n.attrs[o[2]]=o[3].trim().substring(1,o[3].length-1));return n}var r=/<[a-zA-Z0-9\-\!\/](?:"[^"]*"|'[^']*'|[^'">])*>/g,o=/^\s*$/,l=Object.create(null);var c=function(e,n){n||(n={}),n.components||(n.components=l);var t,s=[],a=[],c=-1,u=!1;if(0!==e.indexOf("<")){var p=e.indexOf("<");s.push({type:"text",content:-1===p?e:e.substring(0,p)})}return e.replace(r,(function(r,l){if(u){if(r!=="")return;u=!1}var p,d="/"!==r.charAt(1),f=r.startsWith("\x3c!--"),h=l+r.length,m=e.charAt(h);if(f){var g=i(r);return c<0?(s.push(g),s):((p=a[c]).children.push(g),s)}if(d&&(c++,"tag"===(t=i(r)).type&&n.components[t.name]&&(t.type="component",u=!0),t.voidElement||u||!m||"<"===m||t.children.push({type:"text",content:e.slice(h,e.indexOf("<",h))}),0===c&&s.push(t),(p=a[c-1])&&p.children.push(t),a[c]=t),(!d||t.voidElement)&&(c>-1&&(t.voidElement||t.name===r.slice(2,-1))&&(c--,t=-1===c?s:a[c]),!u&&"<"!==m&&m)){p=-1===c?s:a[c].children;var y=e.indexOf("<",h),v=e.slice(h,-1===y?void 0:y);o.test(v)&&(v=" "),(y>-1&&c+p.length>=0||" "!==v)&&p.push({type:"text",content:v})}})),s};const u=function(){if(console?.warn){for(var e=arguments.length,n=new Array(e),t=0;t()=>{if(e.isInitialized)n();else{const t=()=>{setTimeout((()=>{e.off("initialized",t)}),0),n()};e.on("initialized",t)}},h=(e,n,t)=>{e.loadNamespaces(n,f(e,t))},m=(e,n,t,s)=>{y(t)&&(t=[t]),t.forEach((n=>{e.options.ns.indexOf(n)<0&&e.options.ns.push(n)})),e.loadLanguages(n,f(e,s))},g=e=>e.displayName||e.name||(y(e)&&e.length>0?e:"Unknown"),y=e=>"string"==typeof e,v=e=>"object"==typeof e&&null!==e,x=/&(?:amp|#38|lt|#60|gt|#62|apos|#39|quot|#34|nbsp|#160|copy|#169|reg|#174|hellip|#8230|#x2F|#47);/g,b={"&":"&","&":"&","<":"<","<":"<",">":">",">":">","'":"'","'":"'",""":'"',""":'"'," ":" "," ":" ","©":"©","©":"©","®":"®","®":"®","…":"…","…":"…","/":"/","/":"/"},E=e=>b[e];let O={bindI18n:"languageChanged",bindI18nStore:"",transEmptyNodeValue:"",transSupportBasicHtmlNodes:!0,transWrapTextNodes:"",transKeepBasicHtmlNodesFor:["br","strong","i","p"],useSuspense:!0,unescape:e=>e.replace(x,E)};const N=function(){let e=arguments.length>0&&void 0!==arguments[0]?arguments[0]:{};O={...O,...e}},$=()=>O;let w;const I=e=>{w=e},k=()=>w,S=(e,n)=>{if(!e)return!1;const t=e.props?.children??e.children;return n?t.length>0:!!t},j=e=>{if(!e)return[];const n=e.props?.children??e.children;return e.props?.i18nIsDynamicList?R(n):n},R=e=>Array.isArray(e)?e:[e],C=(e,t)=>{if(!e)return"";let s="";const a=R(e),i=t?.transSupportBasicHtmlNodes?t.transKeepBasicHtmlNodesFor??[]:[];return a.forEach(((e,a)=>{if(y(e))s+=`${e}`;else if(n.isValidElement(e)){const{props:n,type:r}=e,o=Object.keys(n).length,l=i.indexOf(r)>-1,c=n.children;if(c||!l||o)if(!c&&(!l||o)||n.i18nIsDynamicList)s+=`<${a}>`;else if(l&&1===o&&y(c))s+=`<${r}>${c}`;else{const e=C(c,t);s+=`<${a}>${e}`}else s+=`<${r}/>`}else if(null===e)u("Trans: the passed in value is invalid - seems you passed in a null child.");else if(v(e)){const{format:n,...t}=e,a=Object.keys(t);if(1===a.length){const e=n?`${a[0]}, ${n}`:a[0];s+=`{{${e}}}`}else u("react-i18next: the passed in object contained more than one variable - the object should look like {{ value, format }} where format is optional.",e)}else u("Trans: the passed in value is invalid - seems you passed in a variable like {number} - please pass in variables for interpolation as full objects like {{number}}.",e)})),s},T=(e,t,s,a,i,r)=>{if(""===t)return[];const o=a.transKeepBasicHtmlNodesFor||[],l=t&&new RegExp(o.map((e=>`<${e}`)).join("|")).test(t);if(!e&&!l&&!r)return[t];const u={},p=e=>{R(e).forEach((e=>{y(e)||(S(e)?p(j(e)):v(e)&&!n.isValidElement(e)&&Object.assign(u,e))}))};p(e);const d=c(`<0>${t}`),f={...u,...i},h=(e,t,s)=>{const a=j(e),i=g(a,t.children,s);return(e=>Array.isArray(e)&&e.every(n.isValidElement))(a)&&0===i.length||e.props?.i18nIsDynamicList?a:i},m=(e,t,s,a,i)=>{e.dummy?(e.children=t,s.push(n.cloneElement(e,{key:a},i?void 0:t))):s.push(...n.Children.map([e],(e=>{const s={...e.props};return delete s.i18nIsDynamicList,n.createElement(e.type,{...s,key:a,ref:e.ref},i?null:t)})))},g=(t,i,c)=>{const u=R(t);return R(i).reduce(((t,i,p)=>{const d=i.children?.[0]?.content&&s.services.interpolator.interpolate(i.children[0].content,f,s.language);if("tag"===i.type){let r=u[parseInt(i.name,10)];1!==c.length||r||(r=c[0][i.name]),r||(r={});const x=0!==Object.keys(i.attrs).length?((e,n)=>{const t={...n};return t.props=Object.assign(e.props,n.props),t})({props:i.attrs},r):r,b=n.isValidElement(x),E=b&&S(i,!0)&&!i.voidElement,O=l&&v(x)&&x.dummy&&!b,N=v(e)&&Object.hasOwnProperty.call(e,i.name);if(y(x)){const e=s.services.interpolator.interpolate(x,f,s.language);t.push(e)}else if(S(x)||E){const e=h(x,i,c);m(x,e,t,p)}else if(O){const e=g(u,i.children,c);m(x,e,t,p)}else if(Number.isNaN(parseFloat(i.name)))if(N){const e=h(x,i,c);m(x,e,t,p,i.voidElement)}else if(a.transSupportBasicHtmlNodes&&o.indexOf(i.name)>-1)if(i.voidElement)t.push(n.createElement(i.name,{key:`${i.name}-${p}`}));else{const e=g(u,i.children,c);t.push(n.createElement(i.name,{key:`${i.name}-${p}`},e))}else if(i.voidElement)t.push(`<${i.name} />`);else{const e=g(u,i.children,c);t.push(`<${i.name}>${e}`)}else if(v(x)&&!b){const e=i.children[0]?d:null;e&&t.push(e)}else m(x,d,t,p,1!==i.children.length||!d)}else if("text"===i.type){const e=a.transWrapTextNodes,o=r?a.unescape(s.services.interpolator.interpolate(i.content,f,s.language)):s.services.interpolator.interpolate(i.content,f,s.language);e?t.push(n.createElement(e,{key:`${i.name}-${p}`},o)):t.push(o)}return t}),[])},x=g([{dummy:!0,children:e||[]}],d,R(e||[]));return j(x[0])};function P(e){let{children:t,count:s,parent:a,i18nKey:i,context:r,tOptions:o={},values:l,defaults:c,components:u,ns:p,i18n:f,t:h,shouldUnescape:m,...g}=e;const v=f||k();if(!v)return d("You will need to pass in an i18next instance by using i18nextReactModule"),t;const x=h||v.t.bind(v)||(e=>e),b={...$(),...v.options?.react};let E=p||x.ns||v.options?.defaultNS;E=y(E)?[E]:E||["translation"];const O=C(t,b),N=c||O||b.transEmptyNodeValue||i,{hashTransKey:w}=b,I=i||(w?w(O||N):O||N);v.options?.interpolation?.defaultVariables&&(l=l&&Object.keys(l).length>0?{...l,...v.options.interpolation.defaultVariables}:{...v.options.interpolation.defaultVariables});const S=l||void 0!==s||!t?o.interpolation:{interpolation:{...o.interpolation,prefix:"#$?",suffix:"?$#"}},j={...o,context:r||o.context,count:s,...l,...S,defaultValue:N,ns:E},R=I?x(I,j):N;u&&Object.keys(u).forEach((e=>{const t=u[e];"function"==typeof t.type||!t.props||!t.props.children||R.indexOf(`${e}/>`)<0&&R.indexOf(`${e} />`)<0||(u[e]=n.createElement((function(){return n.createElement(n.Fragment,null,t)})))}));const P=T(u||t,R,v,b,j,m),L=a??b.defaultTransParent;return L?n.createElement(L,g,P):P}const L={type:"3rdParty",init(e){N(e.options.react),I(e)}},A=n.createContext();class V{constructor(){this.usedNamespaces={}}addUsedNamespaces(e){e.forEach((e=>{this.usedNamespaces[e]=this.usedNamespaces[e]??!0}))}getUsedNamespaces(){return Object.keys(this.usedNamespaces)}}const z=e=>async n=>({...await(e.getInitialProps?.(n))??{},...F()}),F=()=>{const e=k(),n=e.reportNamespaces?.getUsedNamespaces()??[],t={},s={};return e.languages.forEach((t=>{s[t]={},n.forEach((n=>{s[t][n]=e.getResourceBundle(t,n)||{}}))})),t.initialI18nStore=s,t.initialLanguage=e.language,t};const U=(e,n,t,s)=>e.getFixedT(n,t,s),B=function(e){let t=arguments.length>1&&void 0!==arguments[1]?arguments[1]:{};const{i18n:s}=t,{i18n:a,defaultNS:i}=n.useContext(A)||{},r=s||a||k();if(r&&!r.reportNamespaces&&(r.reportNamespaces=new V),!r){d("You will need to pass in an i18next instance by using initReactI18next");const e=(e,n)=>y(n)?n:v(n)&&y(n.defaultValue)?n.defaultValue:Array.isArray(e)?e[e.length-1]:e,n=[e,{},!1];return n.t=e,n.i18n={},n.ready=!1,n}r.options.react?.wait&&d("It seems you are still using the old wait option, you may migrate to the new useSuspense behaviour.");const o={...$(),...r.options.react,...t},{useSuspense:l,keyPrefix:c}=o;let u=e||i||r.options?.defaultNS;u=y(u)?[u]:u||["translation"],r.reportNamespaces.addUsedNamespaces?.(u);const p=(r.isInitialized||r.initializedStoreOnce)&&u.every((e=>function(e,n){let t=arguments.length>2&&void 0!==arguments[2]?arguments[2]:{};return n.languages&&n.languages.length?n.hasLoadedNamespace(e,{lng:t.lng,precheck:(n,s)=>{if(t.bindI18n?.indexOf("languageChanging")>-1&&n.services.backendConnector.backend&&n.isLanguageChangingTo&&!s(n.isLanguageChangingTo,e))return!1}}):(d("i18n.languages were undefined or empty",n.languages),!0)}(e,r,o))),f=((e,t,s,a)=>n.useCallback(U(e,t,s,a),[e,t,s,a]))(r,t.lng||null,"fallback"===o.nsMode?u:u[0],c),g=()=>f,x=()=>U(r,t.lng||null,"fallback"===o.nsMode?u:u[0],c),[b,E]=n.useState(g);let O=u.join();t.lng&&(O=`${t.lng}${O}`);const N=((e,t)=>{const s=n.useRef();return n.useEffect((()=>{s.current=e}),[e,t]),s.current})(O),w=n.useRef(!0);n.useEffect((()=>{const{bindI18n:e,bindI18nStore:n}=o;w.current=!0,p||l||(t.lng?m(r,t.lng,u,(()=>{w.current&&E(x)})):h(r,u,(()=>{w.current&&E(x)}))),p&&N&&N!==O&&w.current&&E(x);const s=()=>{w.current&&E(x)};return e&&r?.on(e,s),n&&r?.store.on(n,s),()=>{w.current=!1,r&&e?.split(" ").forEach((e=>r.off(e,s))),n&&r&&n.split(" ").forEach((e=>r.store.off(e,s)))}}),[r,O]),n.useEffect((()=>{w.current&&p&&E(g)}),[r,c,p]);const I=[b,r,p];if(I.t=b,I.i18n=r,I.ready=p,p)return I;if(!p&&!l)return I;throw new Promise((e=>{t.lng?m(r,t.lng,u,(()=>e())):h(r,u,(()=>e()))}))};const D=function(e,t){let s=arguments.length>2&&void 0!==arguments[2]?arguments[2]:{};const{i18n:a}=s,{i18n:i}=n.useContext(A)||{},r=a||i||k();r.options?.isClone||(e&&!r.initializedStoreOnce&&(r.services.resourceStore.data=e,r.options.ns=Object.values(e).reduce(((e,n)=>(Object.keys(n).forEach((n=>{e.indexOf(n)<0&&e.push(n)})),e)),r.options.ns),r.initializedStoreOnce=!0,r.isInitialized=!0),t&&!r.initializedLanguageOnce&&(r.changeLanguage(t),r.initializedLanguageOnce=!0))};e.I18nContext=A,e.I18nextProvider=function(e){let{i18n:t,defaultNS:s,children:a}=e;const i=n.useMemo((()=>({i18n:t,defaultNS:s})),[t,s]);return n.createElement(A.Provider,{value:i},a)},e.Trans=function(e){let{children:t,count:s,parent:a,i18nKey:i,context:r,tOptions:o={},values:l,defaults:c,components:u,ns:p,i18n:d,t:f,shouldUnescape:h,...m}=e;const{i18n:g,defaultNS:y}=n.useContext(A)||{},v=d||g||k(),x=f||v?.t.bind(v);return P({children:t,count:s,parent:a,i18nKey:i,context:r,tOptions:o,values:l,defaults:c,components:u,ns:p||x?.ns||y||v?.options?.defaultNS,i18n:v,t:f,shouldUnescape:h,...m})},e.TransWithoutContext=P,e.Translation=e=>{let{ns:n,children:t,...s}=e;const[a,i,r]=B(n,s);return t(a,{i18n:i,lng:i.language},r)},e.composeInitialProps=z,e.date=()=>"",e.getDefaults=$,e.getI18n=k,e.getInitialProps=F,e.initReactI18next=L,e.number=()=>"",e.plural=()=>"",e.select=()=>"",e.selectOrdinal=()=>"",e.setDefaults=N,e.setI18n=I,e.time=()=>"",e.useSSR=D,e.useTranslation=B,e.withSSR=()=>function(e){function t(t){let{initialI18nStore:s,initialLanguage:a,...i}=t;return D(s,a),n.createElement(e,{...i})}return t.getInitialProps=z(e),t.displayName=`withI18nextSSR(${g(e)})`,t.WrappedComponent=e,t},e.withTranslation=function(e){let t=arguments.length>1&&void 0!==arguments[1]?arguments[1]:{};return function(s){function a(a){let{forwardedRef:i,...r}=a;const[o,l,c]=B(e,{...r,keyPrefix:t.keyPrefix}),u={...r,t:o,i18n:l,tReady:c};return t.withRef&&i?u.ref=i:!t.withRef&&i&&(u.forwardedRef=i),n.createElement(s,u)}a.displayName=`withI18nextTranslation(${g(s)})`,a.WrappedComponent=s;return t.withRef?n.forwardRef(((e,t)=>n.createElement(a,Object.assign({},e,{forwardedRef:t})))):a}}})); +diff --git a/dist/commonjs/context.js b/dist/commonjs/context.js +index 5c4506e4d3424e4ffd167fd5bb696dabd67b662b..dcc11b798da72771953acce3c1368f006c3c1478 100644 +--- a/dist/commonjs/context.js ++++ b/dist/commonjs/context.js +@@ -46,7 +46,7 @@ class ReportNamespaces { + } + addUsedNamespaces(namespaces) { + namespaces.forEach(ns => { +- this.usedNamespaces[ns] ??= true; ++ this.usedNamespaces[ns] = this.usedNamespaces[ns] ?? true; + }); + } + getUsedNamespaces() { +diff --git a/dist/es/context.js b/dist/es/context.js +index 89afe45d6cf480079598dd183521b32f28f77f06..a953078f44008ee751f51257536a31ceba2fd672 100644 +--- a/dist/es/context.js ++++ b/dist/es/context.js +@@ -10,7 +10,7 @@ export class ReportNamespaces { + } + addUsedNamespaces(namespaces) { + namespaces.forEach(ns => { +- this.usedNamespaces[ns] ??= true; ++ this.usedNamespaces[ns] = this.usedNamespaces[ns] ?? true; + }); + } + getUsedNamespaces() { +diff --git a/dist/umd/react-i18next.js b/dist/umd/react-i18next.js +index 3723bd7c0f5762bdb09e3226ac86ff255cbf9859..9178ae9f7cdb776f51a64ff6c79eed1d18fbd836 100644 +--- a/dist/umd/react-i18next.js ++++ b/dist/umd/react-i18next.js +@@ -499,7 +499,7 @@ + } + addUsedNamespaces(namespaces) { + namespaces.forEach(ns => { +- this.usedNamespaces[ns] ??= true; ++ this.usedNamespaces[ns] = this.usedNamespaces[ns] ?? true; + }); + } + getUsedNamespaces() { +diff --git a/dist/umd/react-i18next.min.js b/dist/umd/react-i18next.min.js +index 2eef624040aab6b4b9ba4699bf7e4777842bf0a2..69e17753d545df9dc26aa3411b477a4dff5e8361 100644 +--- a/dist/umd/react-i18next.min.js ++++ b/dist/umd/react-i18next.min.js +@@ -1 +1 @@ +-!function(e,n){"object"==typeof exports&&"undefined"!=typeof module?n(exports,require("react")):"function"==typeof define&&define.amd?define(["exports","react"],n):n((e="undefined"!=typeof globalThis?globalThis:e||self).ReactI18next={},e.React)}(this,(function(e,n){"use strict";function t(e){return e&&e.__esModule&&Object.prototype.hasOwnProperty.call(e,"default")?e.default:e}var s=t({area:!0,base:!0,br:!0,col:!0,embed:!0,hr:!0,img:!0,input:!0,link:!0,meta:!0,param:!0,source:!0,track:!0,wbr:!0}),i=/\s([^'"/\s><]+?)[\s/>]|([^\s=]+)=\s?(".*?"|'.*?')/g;function a(e){var n={type:"tag",name:"",voidElement:!1,attrs:{},children:[]},t=e.match(/<\/?([^\s]+?)[/\s>]/);if(t&&(n.name=t[1],(s[t[1]]||"/"===e.charAt(e.length-2))&&(n.voidElement=!0),n.name.startsWith("!--"))){var a=e.indexOf("--\x3e");return{type:"comment",comment:-1!==a?e.slice(4,a):""}}for(var r=new RegExp(i),o=null;null!==(o=r.exec(e));)if(o[0].trim())if(o[1]){var l=o[1].trim(),c=[l,""];l.indexOf("=")>-1&&(c=l.split("=")),n.attrs[c[0]]=c[1],r.lastIndex--}else o[2]&&(n.attrs[o[2]]=o[3].trim().substring(1,o[3].length-1));return n}var r=/<[a-zA-Z0-9\-\!\/](?:"[^"]*"|'[^']*'|[^'">])*>/g,o=/^\s*$/,l=Object.create(null);var c=function(e,n){n||(n={}),n.components||(n.components=l);var t,s=[],i=[],c=-1,u=!1;if(0!==e.indexOf("<")){var p=e.indexOf("<");s.push({type:"text",content:-1===p?e:e.substring(0,p)})}return e.replace(r,(function(r,l){if(u){if(r!=="")return;u=!1}var p,d="/"!==r.charAt(1),f=r.startsWith("\x3c!--"),h=l+r.length,m=e.charAt(h);if(f){var g=a(r);return c<0?(s.push(g),s):((p=i[c]).children.push(g),s)}if(d&&(c++,"tag"===(t=a(r)).type&&n.components[t.name]&&(t.type="component",u=!0),t.voidElement||u||!m||"<"===m||t.children.push({type:"text",content:e.slice(h,e.indexOf("<",h))}),0===c&&s.push(t),(p=i[c-1])&&p.children.push(t),i[c]=t),(!d||t.voidElement)&&(c>-1&&(t.voidElement||t.name===r.slice(2,-1))&&(c--,t=-1===c?s:i[c]),!u&&"<"!==m&&m)){p=-1===c?s:i[c].children;var y=e.indexOf("<",h),x=e.slice(h,-1===y?void 0:y);o.test(x)&&(x=" "),(y>-1&&c+p.length>=0||" "!==x)&&p.push({type:"text",content:x})}})),s};const u=function(){if(console?.warn){for(var e=arguments.length,n=new Array(e),t=0;t()=>{if(e.isInitialized)n();else{const t=()=>{setTimeout((()=>{e.off("initialized",t)}),0),n()};e.on("initialized",t)}},h=(e,n,t)=>{e.loadNamespaces(n,f(e,t))},m=(e,n,t,s)=>{y(t)&&(t=[t]),t.forEach((n=>{e.options.ns.indexOf(n)<0&&e.options.ns.push(n)})),e.loadLanguages(n,f(e,s))},g=e=>e.displayName||e.name||(y(e)&&e.length>0?e:"Unknown"),y=e=>"string"==typeof e,x=e=>"object"==typeof e&&null!==e,b=/&(?:amp|#38|lt|#60|gt|#62|apos|#39|quot|#34|nbsp|#160|copy|#169|reg|#174|hellip|#8230|#x2F|#47);/g,v={"&":"&","&":"&","<":"<","<":"<",">":">",">":">","'":"'","'":"'",""":'"',""":'"'," ":" "," ":" ","©":"©","©":"©","®":"®","®":"®","…":"…","…":"…","/":"/","/":"/"},E=e=>v[e];let O={bindI18n:"languageChanged",bindI18nStore:"",transEmptyNodeValue:"",transSupportBasicHtmlNodes:!0,transWrapTextNodes:"",transKeepBasicHtmlNodesFor:["br","strong","i","p"],useSuspense:!0,unescape:e=>e.replace(b,E)};const N=function(){let e=arguments.length>0&&void 0!==arguments[0]?arguments[0]:{};O={...O,...e}},$=()=>O;let w;const I=e=>{w=e},k=()=>w,S=(e,n)=>{if(!e)return!1;const t=e.props?.children??e.children;return n?t.length>0:!!t},j=e=>{if(!e)return[];const n=e.props?.children??e.children;return e.props?.i18nIsDynamicList?R(n):n},R=e=>Array.isArray(e)?e:[e],T=(e,t)=>{if(!e)return"";let s="";const i=R(e),a=t?.transSupportBasicHtmlNodes?t.transKeepBasicHtmlNodesFor??[]:[];return i.forEach(((e,i)=>{if(y(e))s+=`${e}`;else if(n.isValidElement(e)){const{props:n,type:r}=e,o=Object.keys(n).length,l=a.indexOf(r)>-1,c=n.children;if(c||!l||o)if(!c&&(!l||o)||n.i18nIsDynamicList)s+=`<${i}>`;else if(l&&1===o&&y(c))s+=`<${r}>${c}`;else{const e=T(c,t);s+=`<${i}>${e}`}else s+=`<${r}/>`}else if(null===e)u("Trans: the passed in value is invalid - seems you passed in a null child.");else if(x(e)){const{format:n,...t}=e,i=Object.keys(t);if(1===i.length){const e=n?`${i[0]}, ${n}`:i[0];s+=`{{${e}}}`}else u("react-i18next: the passed in object contained more than one variable - the object should look like {{ value, format }} where format is optional.",e)}else u("Trans: the passed in value is invalid - seems you passed in a variable like {number} - please pass in variables for interpolation as full objects like {{number}}.",e)})),s},C=(e,t,s,i,a,r)=>{if(""===t)return[];const o=i.transKeepBasicHtmlNodesFor||[],l=t&&new RegExp(o.map((e=>`<${e}`)).join("|")).test(t);if(!e&&!l&&!r)return[t];const u={},p=e=>{R(e).forEach((e=>{y(e)||(S(e)?p(j(e)):x(e)&&!n.isValidElement(e)&&Object.assign(u,e))}))};p(e);const d=c(`<0>${t}`),f={...u,...a},h=(e,t,s)=>{const i=j(e),a=g(i,t.children,s);return(e=>Array.isArray(e)&&e.every(n.isValidElement))(i)&&0===a.length||e.props?.i18nIsDynamicList?i:a},m=(e,t,s,i,a)=>{e.dummy?(e.children=t,s.push(n.cloneElement(e,{key:i},a?void 0:t))):s.push(...n.Children.map([e],(e=>{const s={...e.props};return delete s.i18nIsDynamicList,n.createElement(e.type,{...s,key:i,ref:e.ref},a?null:t)})))},g=(t,a,c)=>{const u=R(t);return R(a).reduce(((t,a,p)=>{const d=a.children?.[0]?.content&&s.services.interpolator.interpolate(a.children[0].content,f,s.language);if("tag"===a.type){let r=u[parseInt(a.name,10)];1!==c.length||r||(r=c[0][a.name]),r||(r={});const b=0!==Object.keys(a.attrs).length?((e,n)=>{const t={...n};return t.props=Object.assign(e.props,n.props),t})({props:a.attrs},r):r,v=n.isValidElement(b),E=v&&S(a,!0)&&!a.voidElement,O=l&&x(b)&&b.dummy&&!v,N=x(e)&&Object.hasOwnProperty.call(e,a.name);if(y(b)){const e=s.services.interpolator.interpolate(b,f,s.language);t.push(e)}else if(S(b)||E){const e=h(b,a,c);m(b,e,t,p)}else if(O){const e=g(u,a.children,c);m(b,e,t,p)}else if(Number.isNaN(parseFloat(a.name)))if(N){const e=h(b,a,c);m(b,e,t,p,a.voidElement)}else if(i.transSupportBasicHtmlNodes&&o.indexOf(a.name)>-1)if(a.voidElement)t.push(n.createElement(a.name,{key:`${a.name}-${p}`}));else{const e=g(u,a.children,c);t.push(n.createElement(a.name,{key:`${a.name}-${p}`},e))}else if(a.voidElement)t.push(`<${a.name} />`);else{const e=g(u,a.children,c);t.push(`<${a.name}>${e}`)}else if(x(b)&&!v){const e=a.children[0]?d:null;e&&t.push(e)}else m(b,d,t,p,1!==a.children.length||!d)}else if("text"===a.type){const e=i.transWrapTextNodes,o=r?i.unescape(s.services.interpolator.interpolate(a.content,f,s.language)):s.services.interpolator.interpolate(a.content,f,s.language);e?t.push(n.createElement(e,{key:`${a.name}-${p}`},o)):t.push(o)}return t}),[])},b=g([{dummy:!0,children:e||[]}],d,R(e||[]));return j(b[0])};function P(e){let{children:t,count:s,parent:i,i18nKey:a,context:r,tOptions:o={},values:l,defaults:c,components:u,ns:p,i18n:f,t:h,shouldUnescape:m,...g}=e;const x=f||k();if(!x)return d("You will need to pass in an i18next instance by using i18nextReactModule"),t;const b=h||x.t.bind(x)||(e=>e),v={...$(),...x.options?.react};let E=p||b.ns||x.options?.defaultNS;E=y(E)?[E]:E||["translation"];const O=T(t,v),N=c||O||v.transEmptyNodeValue||a,{hashTransKey:w}=v,I=a||(w?w(O||N):O||N);x.options?.interpolation?.defaultVariables&&(l=l&&Object.keys(l).length>0?{...l,...x.options.interpolation.defaultVariables}:{...x.options.interpolation.defaultVariables});const S=l||void 0!==s||!t?o.interpolation:{interpolation:{...o.interpolation,prefix:"#$?",suffix:"?$#"}},j={...o,context:r||o.context,count:s,...l,...S,defaultValue:N,ns:E},R=I?b(I,j):N;u&&Object.keys(u).forEach((e=>{const t=u[e];"function"==typeof t.type||!t.props||!t.props.children||R.indexOf(`${e}/>`)<0&&R.indexOf(`${e} />`)<0||(u[e]=n.createElement((function(){return n.createElement(n.Fragment,null,t)})))}));const P=C(u||t,R,x,v,j,m),L=i??v.defaultTransParent;return L?n.createElement(L,g,P):P}const L={type:"3rdParty",init(e){N(e.options.react),I(e)}},A=n.createContext();class V{constructor(){this.usedNamespaces={}}addUsedNamespaces(e){e.forEach((e=>{this.usedNamespaces[e]??=!0}))}getUsedNamespaces(){return Object.keys(this.usedNamespaces)}}const z=e=>async n=>({...await(e.getInitialProps?.(n))??{},...F()}),F=()=>{const e=k(),n=e.reportNamespaces?.getUsedNamespaces()??[],t={},s={};return e.languages.forEach((t=>{s[t]={},n.forEach((n=>{s[t][n]=e.getResourceBundle(t,n)||{}}))})),t.initialI18nStore=s,t.initialLanguage=e.language,t};const U=(e,n,t,s)=>e.getFixedT(n,t,s),B=function(e){let t=arguments.length>1&&void 0!==arguments[1]?arguments[1]:{};const{i18n:s}=t,{i18n:i,defaultNS:a}=n.useContext(A)||{},r=s||i||k();if(r&&!r.reportNamespaces&&(r.reportNamespaces=new V),!r){d("You will need to pass in an i18next instance by using initReactI18next");const e=(e,n)=>y(n)?n:x(n)&&y(n.defaultValue)?n.defaultValue:Array.isArray(e)?e[e.length-1]:e,n=[e,{},!1];return n.t=e,n.i18n={},n.ready=!1,n}r.options.react?.wait&&d("It seems you are still using the old wait option, you may migrate to the new useSuspense behaviour.");const o={...$(),...r.options.react,...t},{useSuspense:l,keyPrefix:c}=o;let u=e||a||r.options?.defaultNS;u=y(u)?[u]:u||["translation"],r.reportNamespaces.addUsedNamespaces?.(u);const p=(r.isInitialized||r.initializedStoreOnce)&&u.every((e=>function(e,n){let t=arguments.length>2&&void 0!==arguments[2]?arguments[2]:{};return n.languages&&n.languages.length?n.hasLoadedNamespace(e,{lng:t.lng,precheck:(n,s)=>{if(t.bindI18n?.indexOf("languageChanging")>-1&&n.services.backendConnector.backend&&n.isLanguageChangingTo&&!s(n.isLanguageChangingTo,e))return!1}}):(d("i18n.languages were undefined or empty",n.languages),!0)}(e,r,o))),f=((e,t,s,i)=>n.useCallback(U(e,t,s,i),[e,t,s,i]))(r,t.lng||null,"fallback"===o.nsMode?u:u[0],c),g=()=>f,b=()=>U(r,t.lng||null,"fallback"===o.nsMode?u:u[0],c),[v,E]=n.useState(g);let O=u.join();t.lng&&(O=`${t.lng}${O}`);const N=((e,t)=>{const s=n.useRef();return n.useEffect((()=>{s.current=e}),[e,t]),s.current})(O),w=n.useRef(!0);n.useEffect((()=>{const{bindI18n:e,bindI18nStore:n}=o;w.current=!0,p||l||(t.lng?m(r,t.lng,u,(()=>{w.current&&E(b)})):h(r,u,(()=>{w.current&&E(b)}))),p&&N&&N!==O&&w.current&&E(b);const s=()=>{w.current&&E(b)};return e&&r?.on(e,s),n&&r?.store.on(n,s),()=>{w.current=!1,r&&e?.split(" ").forEach((e=>r.off(e,s))),n&&r&&n.split(" ").forEach((e=>r.store.off(e,s)))}}),[r,O]),n.useEffect((()=>{w.current&&p&&E(g)}),[r,c,p]);const I=[v,r,p];if(I.t=v,I.i18n=r,I.ready=p,p)return I;if(!p&&!l)return I;throw new Promise((e=>{t.lng?m(r,t.lng,u,(()=>e())):h(r,u,(()=>e()))}))};const D=function(e,t){let s=arguments.length>2&&void 0!==arguments[2]?arguments[2]:{};const{i18n:i}=s,{i18n:a}=n.useContext(A)||{},r=i||a||k();r.options?.isClone||(e&&!r.initializedStoreOnce&&(r.services.resourceStore.data=e,r.options.ns=Object.values(e).reduce(((e,n)=>(Object.keys(n).forEach((n=>{e.indexOf(n)<0&&e.push(n)})),e)),r.options.ns),r.initializedStoreOnce=!0,r.isInitialized=!0),t&&!r.initializedLanguageOnce&&(r.changeLanguage(t),r.initializedLanguageOnce=!0))};e.I18nContext=A,e.I18nextProvider=function(e){let{i18n:t,defaultNS:s,children:i}=e;const a=n.useMemo((()=>({i18n:t,defaultNS:s})),[t,s]);return n.createElement(A.Provider,{value:a},i)},e.Trans=function(e){let{children:t,count:s,parent:i,i18nKey:a,context:r,tOptions:o={},values:l,defaults:c,components:u,ns:p,i18n:d,t:f,shouldUnescape:h,...m}=e;const{i18n:g,defaultNS:y}=n.useContext(A)||{},x=d||g||k(),b=f||x?.t.bind(x);return P({children:t,count:s,parent:i,i18nKey:a,context:r,tOptions:o,values:l,defaults:c,components:u,ns:p||b?.ns||y||x?.options?.defaultNS,i18n:x,t:f,shouldUnescape:h,...m})},e.TransWithoutContext=P,e.Translation=e=>{let{ns:n,children:t,...s}=e;const[i,a,r]=B(n,s);return t(i,{i18n:a,lng:a.language},r)},e.composeInitialProps=z,e.date=()=>"",e.getDefaults=$,e.getI18n=k,e.getInitialProps=F,e.initReactI18next=L,e.number=()=>"",e.plural=()=>"",e.select=()=>"",e.selectOrdinal=()=>"",e.setDefaults=N,e.setI18n=I,e.time=()=>"",e.useSSR=D,e.useTranslation=B,e.withSSR=()=>function(e){function t(t){let{initialI18nStore:s,initialLanguage:i,...a}=t;return D(s,i),n.createElement(e,{...a})}return t.getInitialProps=z(e),t.displayName=`withI18nextSSR(${g(e)})`,t.WrappedComponent=e,t},e.withTranslation=function(e){let t=arguments.length>1&&void 0!==arguments[1]?arguments[1]:{};return function(s){function i(i){let{forwardedRef:a,...r}=i;const[o,l,c]=B(e,{...r,keyPrefix:t.keyPrefix}),u={...r,t:o,i18n:l,tReady:c};return t.withRef&&a?u.ref=a:!t.withRef&&a&&(u.forwardedRef=a),n.createElement(s,u)}i.displayName=`withI18nextTranslation(${g(s)})`,i.WrappedComponent=s;return t.withRef?n.forwardRef(((e,t)=>n.createElement(i,Object.assign({},e,{forwardedRef:t})))):i}}})); ++!function(e,n){"object"==typeof exports&&"undefined"!=typeof module?n(exports,require("react")):"function"==typeof define&&define.amd?define(["exports","react"],n):n((e="undefined"!=typeof globalThis?globalThis:e||self).ReactI18next={},e.React)}(this,(function(e,n){"use strict";function t(e){return e&&e.__esModule&&Object.prototype.hasOwnProperty.call(e,"default")?e.default:e}var s=t({area:!0,base:!0,br:!0,col:!0,embed:!0,hr:!0,img:!0,input:!0,link:!0,meta:!0,param:!0,source:!0,track:!0,wbr:!0}),i=/\s([^'"/\s><]+?)[\s/>]|([^\s=]+)=\s?(".*?"|'.*?')/g;function a(e){var n={type:"tag",name:"",voidElement:!1,attrs:{},children:[]},t=e.match(/<\/?([^\s]+?)[/\s>]/);if(t&&(n.name=t[1],(s[t[1]]||"/"===e.charAt(e.length-2))&&(n.voidElement=!0),n.name.startsWith("!--"))){var a=e.indexOf("--\x3e");return{type:"comment",comment:-1!==a?e.slice(4,a):""}}for(var r=new RegExp(i),o=null;null!==(o=r.exec(e));)if(o[0].trim())if(o[1]){var l=o[1].trim(),c=[l,""];l.indexOf("=")>-1&&(c=l.split("=")),n.attrs[c[0]]=c[1],r.lastIndex--}else o[2]&&(n.attrs[o[2]]=o[3].trim().substring(1,o[3].length-1));return n}var r=/<[a-zA-Z0-9\-\!\/](?:"[^"]*"|'[^']*'|[^'">])*>/g,o=/^\s*$/,l=Object.create(null);var c=function(e,n){n||(n={}),n.components||(n.components=l);var t,s=[],i=[],c=-1,u=!1;if(0!==e.indexOf("<")){var p=e.indexOf("<");s.push({type:"text",content:-1===p?e:e.substring(0,p)})}return e.replace(r,(function(r,l){if(u){if(r!=="")return;u=!1}var p,d="/"!==r.charAt(1),f=r.startsWith("\x3c!--"),h=l+r.length,m=e.charAt(h);if(f){var g=a(r);return c<0?(s.push(g),s):((p=i[c]).children.push(g),s)}if(d&&(c++,"tag"===(t=a(r)).type&&n.components[t.name]&&(t.type="component",u=!0),t.voidElement||u||!m||"<"===m||t.children.push({type:"text",content:e.slice(h,e.indexOf("<",h))}),0===c&&s.push(t),(p=i[c-1])&&p.children.push(t),i[c]=t),(!d||t.voidElement)&&(c>-1&&(t.voidElement||t.name===r.slice(2,-1))&&(c--,t=-1===c?s:i[c]),!u&&"<"!==m&&m)){p=-1===c?s:i[c].children;var y=e.indexOf("<",h),x=e.slice(h,-1===y?void 0:y);o.test(x)&&(x=" "),(y>-1&&c+p.length>=0||" "!==x)&&p.push({type:"text",content:x})}})),s};const u=function(){if(console?.warn){for(var e=arguments.length,n=new Array(e),t=0;t()=>{if(e.isInitialized)n();else{const t=()=>{setTimeout((()=>{e.off("initialized",t)}),0),n()};e.on("initialized",t)}},h=(e,n,t)=>{e.loadNamespaces(n,f(e,t))},m=(e,n,t,s)=>{y(t)&&(t=[t]),t.forEach((n=>{e.options.ns.indexOf(n)<0&&e.options.ns.push(n)})),e.loadLanguages(n,f(e,s))},g=e=>e.displayName||e.name||(y(e)&&e.length>0?e:"Unknown"),y=e=>"string"==typeof e,x=e=>"object"==typeof e&&null!==e,b=/&(?:amp|#38|lt|#60|gt|#62|apos|#39|quot|#34|nbsp|#160|copy|#169|reg|#174|hellip|#8230|#x2F|#47);/g,v={"&":"&","&":"&","<":"<","<":"<",">":">",">":">","'":"'","'":"'",""":'"',""":'"'," ":" "," ":" ","©":"©","©":"©","®":"®","®":"®","…":"…","…":"…","/":"/","/":"/"},E=e=>v[e];let O={bindI18n:"languageChanged",bindI18nStore:"",transEmptyNodeValue:"",transSupportBasicHtmlNodes:!0,transWrapTextNodes:"",transKeepBasicHtmlNodesFor:["br","strong","i","p"],useSuspense:!0,unescape:e=>e.replace(b,E)};const N=function(){let e=arguments.length>0&&void 0!==arguments[0]?arguments[0]:{};O={...O,...e}},$=()=>O;let w;const I=e=>{w=e},k=()=>w,S=(e,n)=>{if(!e)return!1;const t=e.props?.children??e.children;return n?t.length>0:!!t},j=e=>{if(!e)return[];const n=e.props?.children??e.children;return e.props?.i18nIsDynamicList?R(n):n},R=e=>Array.isArray(e)?e:[e],T=(e,t)=>{if(!e)return"";let s="";const i=R(e),a=t?.transSupportBasicHtmlNodes?t.transKeepBasicHtmlNodesFor??[]:[];return i.forEach(((e,i)=>{if(y(e))s+=`${e}`;else if(n.isValidElement(e)){const{props:n,type:r}=e,o=Object.keys(n).length,l=a.indexOf(r)>-1,c=n.children;if(c||!l||o)if(!c&&(!l||o)||n.i18nIsDynamicList)s+=`<${i}>`;else if(l&&1===o&&y(c))s+=`<${r}>${c}`;else{const e=T(c,t);s+=`<${i}>${e}`}else s+=`<${r}/>`}else if(null===e)u("Trans: the passed in value is invalid - seems you passed in a null child.");else if(x(e)){const{format:n,...t}=e,i=Object.keys(t);if(1===i.length){const e=n?`${i[0]}, ${n}`:i[0];s+=`{{${e}}}`}else u("react-i18next: the passed in object contained more than one variable - the object should look like {{ value, format }} where format is optional.",e)}else u("Trans: the passed in value is invalid - seems you passed in a variable like {number} - please pass in variables for interpolation as full objects like {{number}}.",e)})),s},C=(e,t,s,i,a,r)=>{if(""===t)return[];const o=i.transKeepBasicHtmlNodesFor||[],l=t&&new RegExp(o.map((e=>`<${e}`)).join("|")).test(t);if(!e&&!l&&!r)return[t];const u={},p=e=>{R(e).forEach((e=>{y(e)||(S(e)?p(j(e)):x(e)&&!n.isValidElement(e)&&Object.assign(u,e))}))};p(e);const d=c(`<0>${t}`),f={...u,...a},h=(e,t,s)=>{const i=j(e),a=g(i,t.children,s);return(e=>Array.isArray(e)&&e.every(n.isValidElement))(i)&&0===a.length||e.props?.i18nIsDynamicList?i:a},m=(e,t,s,i,a)=>{e.dummy?(e.children=t,s.push(n.cloneElement(e,{key:i},a?void 0:t))):s.push(...n.Children.map([e],(e=>{const s={...e.props};return delete s.i18nIsDynamicList,n.createElement(e.type,{...s,key:i,ref:e.ref},a?null:t)})))},g=(t,a,c)=>{const u=R(t);return R(a).reduce(((t,a,p)=>{const d=a.children?.[0]?.content&&s.services.interpolator.interpolate(a.children[0].content,f,s.language);if("tag"===a.type){let r=u[parseInt(a.name,10)];1!==c.length||r||(r=c[0][a.name]),r||(r={});const b=0!==Object.keys(a.attrs).length?((e,n)=>{const t={...n};return t.props=Object.assign(e.props,n.props),t})({props:a.attrs},r):r,v=n.isValidElement(b),E=v&&S(a,!0)&&!a.voidElement,O=l&&x(b)&&b.dummy&&!v,N=x(e)&&Object.hasOwnProperty.call(e,a.name);if(y(b)){const e=s.services.interpolator.interpolate(b,f,s.language);t.push(e)}else if(S(b)||E){const e=h(b,a,c);m(b,e,t,p)}else if(O){const e=g(u,a.children,c);m(b,e,t,p)}else if(Number.isNaN(parseFloat(a.name)))if(N){const e=h(b,a,c);m(b,e,t,p,a.voidElement)}else if(i.transSupportBasicHtmlNodes&&o.indexOf(a.name)>-1)if(a.voidElement)t.push(n.createElement(a.name,{key:`${a.name}-${p}`}));else{const e=g(u,a.children,c);t.push(n.createElement(a.name,{key:`${a.name}-${p}`},e))}else if(a.voidElement)t.push(`<${a.name} />`);else{const e=g(u,a.children,c);t.push(`<${a.name}>${e}`)}else if(x(b)&&!v){const e=a.children[0]?d:null;e&&t.push(e)}else m(b,d,t,p,1!==a.children.length||!d)}else if("text"===a.type){const e=i.transWrapTextNodes,o=r?i.unescape(s.services.interpolator.interpolate(a.content,f,s.language)):s.services.interpolator.interpolate(a.content,f,s.language);e?t.push(n.createElement(e,{key:`${a.name}-${p}`},o)):t.push(o)}return t}),[])},b=g([{dummy:!0,children:e||[]}],d,R(e||[]));return j(b[0])};function P(e){let{children:t,count:s,parent:i,i18nKey:a,context:r,tOptions:o={},values:l,defaults:c,components:u,ns:p,i18n:f,t:h,shouldUnescape:m,...g}=e;const x=f||k();if(!x)return d("You will need to pass in an i18next instance by using i18nextReactModule"),t;const b=h||x.t.bind(x)||(e=>e),v={...$(),...x.options?.react};let E=p||b.ns||x.options?.defaultNS;E=y(E)?[E]:E||["translation"];const O=T(t,v),N=c||O||v.transEmptyNodeValue||a,{hashTransKey:w}=v,I=a||(w?w(O||N):O||N);x.options?.interpolation?.defaultVariables&&(l=l&&Object.keys(l).length>0?{...l,...x.options.interpolation.defaultVariables}:{...x.options.interpolation.defaultVariables});const S=l||void 0!==s||!t?o.interpolation:{interpolation:{...o.interpolation,prefix:"#$?",suffix:"?$#"}},j={...o,context:r||o.context,count:s,...l,...S,defaultValue:N,ns:E},R=I?b(I,j):N;u&&Object.keys(u).forEach((e=>{const t=u[e];"function"==typeof t.type||!t.props||!t.props.children||R.indexOf(`${e}/>`)<0&&R.indexOf(`${e} />`)<0||(u[e]=n.createElement((function(){return n.createElement(n.Fragment,null,t)})))}));const P=C(u||t,R,x,v,j,m),L=i??v.defaultTransParent;return L?n.createElement(L,g,P):P}const L={type:"3rdParty",init(e){N(e.options.react),I(e)}},A=n.createContext();class V{constructor(){this.usedNamespaces={}}addUsedNamespaces(e){e.forEach((e=>{this.usedNamespaces[e]=this.usedNamespaces[e]??!0}))}getUsedNamespaces(){return Object.keys(this.usedNamespaces)}}const z=e=>async n=>({...await(e.getInitialProps?.(n))??{},...F()}),F=()=>{const e=k(),n=e.reportNamespaces?.getUsedNamespaces()??[],t={},s={};return e.languages.forEach((t=>{s[t]={},n.forEach((n=>{s[t][n]=e.getResourceBundle(t,n)||{}}))})),t.initialI18nStore=s,t.initialLanguage=e.language,t};const U=(e,n,t,s)=>e.getFixedT(n,t,s),B=function(e){let t=arguments.length>1&&void 0!==arguments[1]?arguments[1]:{};const{i18n:s}=t,{i18n:i,defaultNS:a}=n.useContext(A)||{},r=s||i||k();if(r&&!r.reportNamespaces&&(r.reportNamespaces=new V),!r){d("You will need to pass in an i18next instance by using initReactI18next");const e=(e,n)=>y(n)?n:x(n)&&y(n.defaultValue)?n.defaultValue:Array.isArray(e)?e[e.length-1]:e,n=[e,{},!1];return n.t=e,n.i18n={},n.ready=!1,n}r.options.react?.wait&&d("It seems you are still using the old wait option, you may migrate to the new useSuspense behaviour.");const o={...$(),...r.options.react,...t},{useSuspense:l,keyPrefix:c}=o;let u=e||a||r.options?.defaultNS;u=y(u)?[u]:u||["translation"],r.reportNamespaces.addUsedNamespaces?.(u);const p=(r.isInitialized||r.initializedStoreOnce)&&u.every((e=>function(e,n){let t=arguments.length>2&&void 0!==arguments[2]?arguments[2]:{};return n.languages&&n.languages.length?n.hasLoadedNamespace(e,{lng:t.lng,precheck:(n,s)=>{if(t.bindI18n?.indexOf("languageChanging")>-1&&n.services.backendConnector.backend&&n.isLanguageChangingTo&&!s(n.isLanguageChangingTo,e))return!1}}):(d("i18n.languages were undefined or empty",n.languages),!0)}(e,r,o))),f=((e,t,s,i)=>n.useCallback(U(e,t,s,i),[e,t,s,i]))(r,t.lng||null,"fallback"===o.nsMode?u:u[0],c),g=()=>f,b=()=>U(r,t.lng||null,"fallback"===o.nsMode?u:u[0],c),[v,E]=n.useState(g);let O=u.join();t.lng&&(O=`${t.lng}${O}`);const N=((e,t)=>{const s=n.useRef();return n.useEffect((()=>{s.current=e}),[e,t]),s.current})(O),w=n.useRef(!0);n.useEffect((()=>{const{bindI18n:e,bindI18nStore:n}=o;w.current=!0,p||l||(t.lng?m(r,t.lng,u,(()=>{w.current&&E(b)})):h(r,u,(()=>{w.current&&E(b)}))),p&&N&&N!==O&&w.current&&E(b);const s=()=>{w.current&&E(b)};return e&&r?.on(e,s),n&&r?.store.on(n,s),()=>{w.current=!1,r&&e?.split(" ").forEach((e=>r.off(e,s))),n&&r&&n.split(" ").forEach((e=>r.store.off(e,s)))}}),[r,O]),n.useEffect((()=>{w.current&&p&&E(g)}),[r,c,p]);const I=[v,r,p];if(I.t=v,I.i18n=r,I.ready=p,p)return I;if(!p&&!l)return I;throw new Promise((e=>{t.lng?m(r,t.lng,u,(()=>e())):h(r,u,(()=>e()))}))};const D=function(e,t){let s=arguments.length>2&&void 0!==arguments[2]?arguments[2]:{};const{i18n:i}=s,{i18n:a}=n.useContext(A)||{},r=i||a||k();r.options?.isClone||(e&&!r.initializedStoreOnce&&(r.services.resourceStore.data=e,r.options.ns=Object.values(e).reduce(((e,n)=>(Object.keys(n).forEach((n=>{e.indexOf(n)<0&&e.push(n)})),e)),r.options.ns),r.initializedStoreOnce=!0,r.isInitialized=!0),t&&!r.initializedLanguageOnce&&(r.changeLanguage(t),r.initializedLanguageOnce=!0))};e.I18nContext=A,e.I18nextProvider=function(e){let{i18n:t,defaultNS:s,children:i}=e;const a=n.useMemo((()=>({i18n:t,defaultNS:s})),[t,s]);return n.createElement(A.Provider,{value:a},i)},e.Trans=function(e){let{children:t,count:s,parent:i,i18nKey:a,context:r,tOptions:o={},values:l,defaults:c,components:u,ns:p,i18n:d,t:f,shouldUnescape:h,...m}=e;const{i18n:g,defaultNS:y}=n.useContext(A)||{},x=d||g||k(),b=f||x?.t.bind(x);return P({children:t,count:s,parent:i,i18nKey:a,context:r,tOptions:o,values:l,defaults:c,components:u,ns:p||b?.ns||y||x?.options?.defaultNS,i18n:x,t:f,shouldUnescape:h,...m})},e.TransWithoutContext=P,e.Translation=e=>{let{ns:n,children:t,...s}=e;const[i,a,r]=B(n,s);return t(i,{i18n:a,lng:a.language},r)},e.composeInitialProps=z,e.date=()=>"",e.getDefaults=$,e.getI18n=k,e.getInitialProps=F,e.initReactI18next=L,e.number=()=>"",e.plural=()=>"",e.select=()=>"",e.selectOrdinal=()=>"",e.setDefaults=N,e.setI18n=I,e.time=()=>"",e.useSSR=D,e.useTranslation=B,e.withSSR=()=>function(e){function t(t){let{initialI18nStore:s,initialLanguage:i,...a}=t;return D(s,i),n.createElement(e,{...a})}return t.getInitialProps=z(e),t.displayName=`withI18nextSSR(${g(e)})`,t.WrappedComponent=e,t},e.withTranslation=function(e){let t=arguments.length>1&&void 0!==arguments[1]?arguments[1]:{};return function(s){function i(i){let{forwardedRef:a,...r}=i;const[o,l,c]=B(e,{...r,keyPrefix:t.keyPrefix}),u={...r,t:o,i18n:l,tReady:c};return t.withRef&&a?u.ref=a:!t.withRef&&a&&(u.forwardedRef=a),n.createElement(s,u)}i.displayName=`withI18nextTranslation(${g(s)})`,i.WrappedComponent=s;return t.withRef?n.forwardRef(((e,t)=>n.createElement(i,Object.assign({},e,{forwardedRef:t})))):i}}})); +diff --git a/react-i18next.js b/react-i18next.js +index 3723bd7c0f5762bdb09e3226ac86ff255cbf9859..9178ae9f7cdb776f51a64ff6c79eed1d18fbd836 100644 +--- a/react-i18next.js ++++ b/react-i18next.js +@@ -499,7 +499,7 @@ + } + addUsedNamespaces(namespaces) { + namespaces.forEach(ns => { +- this.usedNamespaces[ns] ??= true; ++ this.usedNamespaces[ns] = this.usedNamespaces[ns] ?? true; + }); + } + getUsedNamespaces() { +diff --git a/react-i18next.min.js b/react-i18next.min.js +index 2eef624040aab6b4b9ba4699bf7e4777842bf0a2..69e17753d545df9dc26aa3411b477a4dff5e8361 100644 +--- a/react-i18next.min.js ++++ b/react-i18next.min.js +@@ -1 +1 @@ +-!function(e,n){"object"==typeof exports&&"undefined"!=typeof module?n(exports,require("react")):"function"==typeof define&&define.amd?define(["exports","react"],n):n((e="undefined"!=typeof globalThis?globalThis:e||self).ReactI18next={},e.React)}(this,(function(e,n){"use strict";function t(e){return e&&e.__esModule&&Object.prototype.hasOwnProperty.call(e,"default")?e.default:e}var s=t({area:!0,base:!0,br:!0,col:!0,embed:!0,hr:!0,img:!0,input:!0,link:!0,meta:!0,param:!0,source:!0,track:!0,wbr:!0}),i=/\s([^'"/\s><]+?)[\s/>]|([^\s=]+)=\s?(".*?"|'.*?')/g;function a(e){var n={type:"tag",name:"",voidElement:!1,attrs:{},children:[]},t=e.match(/<\/?([^\s]+?)[/\s>]/);if(t&&(n.name=t[1],(s[t[1]]||"/"===e.charAt(e.length-2))&&(n.voidElement=!0),n.name.startsWith("!--"))){var a=e.indexOf("--\x3e");return{type:"comment",comment:-1!==a?e.slice(4,a):""}}for(var r=new RegExp(i),o=null;null!==(o=r.exec(e));)if(o[0].trim())if(o[1]){var l=o[1].trim(),c=[l,""];l.indexOf("=")>-1&&(c=l.split("=")),n.attrs[c[0]]=c[1],r.lastIndex--}else o[2]&&(n.attrs[o[2]]=o[3].trim().substring(1,o[3].length-1));return n}var r=/<[a-zA-Z0-9\-\!\/](?:"[^"]*"|'[^']*'|[^'">])*>/g,o=/^\s*$/,l=Object.create(null);var c=function(e,n){n||(n={}),n.components||(n.components=l);var t,s=[],i=[],c=-1,u=!1;if(0!==e.indexOf("<")){var p=e.indexOf("<");s.push({type:"text",content:-1===p?e:e.substring(0,p)})}return e.replace(r,(function(r,l){if(u){if(r!=="")return;u=!1}var p,d="/"!==r.charAt(1),f=r.startsWith("\x3c!--"),h=l+r.length,m=e.charAt(h);if(f){var g=a(r);return c<0?(s.push(g),s):((p=i[c]).children.push(g),s)}if(d&&(c++,"tag"===(t=a(r)).type&&n.components[t.name]&&(t.type="component",u=!0),t.voidElement||u||!m||"<"===m||t.children.push({type:"text",content:e.slice(h,e.indexOf("<",h))}),0===c&&s.push(t),(p=i[c-1])&&p.children.push(t),i[c]=t),(!d||t.voidElement)&&(c>-1&&(t.voidElement||t.name===r.slice(2,-1))&&(c--,t=-1===c?s:i[c]),!u&&"<"!==m&&m)){p=-1===c?s:i[c].children;var y=e.indexOf("<",h),x=e.slice(h,-1===y?void 0:y);o.test(x)&&(x=" "),(y>-1&&c+p.length>=0||" "!==x)&&p.push({type:"text",content:x})}})),s};const u=function(){if(console?.warn){for(var e=arguments.length,n=new Array(e),t=0;t()=>{if(e.isInitialized)n();else{const t=()=>{setTimeout((()=>{e.off("initialized",t)}),0),n()};e.on("initialized",t)}},h=(e,n,t)=>{e.loadNamespaces(n,f(e,t))},m=(e,n,t,s)=>{y(t)&&(t=[t]),t.forEach((n=>{e.options.ns.indexOf(n)<0&&e.options.ns.push(n)})),e.loadLanguages(n,f(e,s))},g=e=>e.displayName||e.name||(y(e)&&e.length>0?e:"Unknown"),y=e=>"string"==typeof e,x=e=>"object"==typeof e&&null!==e,b=/&(?:amp|#38|lt|#60|gt|#62|apos|#39|quot|#34|nbsp|#160|copy|#169|reg|#174|hellip|#8230|#x2F|#47);/g,v={"&":"&","&":"&","<":"<","<":"<",">":">",">":">","'":"'","'":"'",""":'"',""":'"'," ":" "," ":" ","©":"©","©":"©","®":"®","®":"®","…":"…","…":"…","/":"/","/":"/"},E=e=>v[e];let O={bindI18n:"languageChanged",bindI18nStore:"",transEmptyNodeValue:"",transSupportBasicHtmlNodes:!0,transWrapTextNodes:"",transKeepBasicHtmlNodesFor:["br","strong","i","p"],useSuspense:!0,unescape:e=>e.replace(b,E)};const N=function(){let e=arguments.length>0&&void 0!==arguments[0]?arguments[0]:{};O={...O,...e}},$=()=>O;let w;const I=e=>{w=e},k=()=>w,S=(e,n)=>{if(!e)return!1;const t=e.props?.children??e.children;return n?t.length>0:!!t},j=e=>{if(!e)return[];const n=e.props?.children??e.children;return e.props?.i18nIsDynamicList?R(n):n},R=e=>Array.isArray(e)?e:[e],T=(e,t)=>{if(!e)return"";let s="";const i=R(e),a=t?.transSupportBasicHtmlNodes?t.transKeepBasicHtmlNodesFor??[]:[];return i.forEach(((e,i)=>{if(y(e))s+=`${e}`;else if(n.isValidElement(e)){const{props:n,type:r}=e,o=Object.keys(n).length,l=a.indexOf(r)>-1,c=n.children;if(c||!l||o)if(!c&&(!l||o)||n.i18nIsDynamicList)s+=`<${i}>`;else if(l&&1===o&&y(c))s+=`<${r}>${c}`;else{const e=T(c,t);s+=`<${i}>${e}`}else s+=`<${r}/>`}else if(null===e)u("Trans: the passed in value is invalid - seems you passed in a null child.");else if(x(e)){const{format:n,...t}=e,i=Object.keys(t);if(1===i.length){const e=n?`${i[0]}, ${n}`:i[0];s+=`{{${e}}}`}else u("react-i18next: the passed in object contained more than one variable - the object should look like {{ value, format }} where format is optional.",e)}else u("Trans: the passed in value is invalid - seems you passed in a variable like {number} - please pass in variables for interpolation as full objects like {{number}}.",e)})),s},C=(e,t,s,i,a,r)=>{if(""===t)return[];const o=i.transKeepBasicHtmlNodesFor||[],l=t&&new RegExp(o.map((e=>`<${e}`)).join("|")).test(t);if(!e&&!l&&!r)return[t];const u={},p=e=>{R(e).forEach((e=>{y(e)||(S(e)?p(j(e)):x(e)&&!n.isValidElement(e)&&Object.assign(u,e))}))};p(e);const d=c(`<0>${t}`),f={...u,...a},h=(e,t,s)=>{const i=j(e),a=g(i,t.children,s);return(e=>Array.isArray(e)&&e.every(n.isValidElement))(i)&&0===a.length||e.props?.i18nIsDynamicList?i:a},m=(e,t,s,i,a)=>{e.dummy?(e.children=t,s.push(n.cloneElement(e,{key:i},a?void 0:t))):s.push(...n.Children.map([e],(e=>{const s={...e.props};return delete s.i18nIsDynamicList,n.createElement(e.type,{...s,key:i,ref:e.ref},a?null:t)})))},g=(t,a,c)=>{const u=R(t);return R(a).reduce(((t,a,p)=>{const d=a.children?.[0]?.content&&s.services.interpolator.interpolate(a.children[0].content,f,s.language);if("tag"===a.type){let r=u[parseInt(a.name,10)];1!==c.length||r||(r=c[0][a.name]),r||(r={});const b=0!==Object.keys(a.attrs).length?((e,n)=>{const t={...n};return t.props=Object.assign(e.props,n.props),t})({props:a.attrs},r):r,v=n.isValidElement(b),E=v&&S(a,!0)&&!a.voidElement,O=l&&x(b)&&b.dummy&&!v,N=x(e)&&Object.hasOwnProperty.call(e,a.name);if(y(b)){const e=s.services.interpolator.interpolate(b,f,s.language);t.push(e)}else if(S(b)||E){const e=h(b,a,c);m(b,e,t,p)}else if(O){const e=g(u,a.children,c);m(b,e,t,p)}else if(Number.isNaN(parseFloat(a.name)))if(N){const e=h(b,a,c);m(b,e,t,p,a.voidElement)}else if(i.transSupportBasicHtmlNodes&&o.indexOf(a.name)>-1)if(a.voidElement)t.push(n.createElement(a.name,{key:`${a.name}-${p}`}));else{const e=g(u,a.children,c);t.push(n.createElement(a.name,{key:`${a.name}-${p}`},e))}else if(a.voidElement)t.push(`<${a.name} />`);else{const e=g(u,a.children,c);t.push(`<${a.name}>${e}`)}else if(x(b)&&!v){const e=a.children[0]?d:null;e&&t.push(e)}else m(b,d,t,p,1!==a.children.length||!d)}else if("text"===a.type){const e=i.transWrapTextNodes,o=r?i.unescape(s.services.interpolator.interpolate(a.content,f,s.language)):s.services.interpolator.interpolate(a.content,f,s.language);e?t.push(n.createElement(e,{key:`${a.name}-${p}`},o)):t.push(o)}return t}),[])},b=g([{dummy:!0,children:e||[]}],d,R(e||[]));return j(b[0])};function P(e){let{children:t,count:s,parent:i,i18nKey:a,context:r,tOptions:o={},values:l,defaults:c,components:u,ns:p,i18n:f,t:h,shouldUnescape:m,...g}=e;const x=f||k();if(!x)return d("You will need to pass in an i18next instance by using i18nextReactModule"),t;const b=h||x.t.bind(x)||(e=>e),v={...$(),...x.options?.react};let E=p||b.ns||x.options?.defaultNS;E=y(E)?[E]:E||["translation"];const O=T(t,v),N=c||O||v.transEmptyNodeValue||a,{hashTransKey:w}=v,I=a||(w?w(O||N):O||N);x.options?.interpolation?.defaultVariables&&(l=l&&Object.keys(l).length>0?{...l,...x.options.interpolation.defaultVariables}:{...x.options.interpolation.defaultVariables});const S=l||void 0!==s||!t?o.interpolation:{interpolation:{...o.interpolation,prefix:"#$?",suffix:"?$#"}},j={...o,context:r||o.context,count:s,...l,...S,defaultValue:N,ns:E},R=I?b(I,j):N;u&&Object.keys(u).forEach((e=>{const t=u[e];"function"==typeof t.type||!t.props||!t.props.children||R.indexOf(`${e}/>`)<0&&R.indexOf(`${e} />`)<0||(u[e]=n.createElement((function(){return n.createElement(n.Fragment,null,t)})))}));const P=C(u||t,R,x,v,j,m),L=i??v.defaultTransParent;return L?n.createElement(L,g,P):P}const L={type:"3rdParty",init(e){N(e.options.react),I(e)}},A=n.createContext();class V{constructor(){this.usedNamespaces={}}addUsedNamespaces(e){e.forEach((e=>{this.usedNamespaces[e]??=!0}))}getUsedNamespaces(){return Object.keys(this.usedNamespaces)}}const z=e=>async n=>({...await(e.getInitialProps?.(n))??{},...F()}),F=()=>{const e=k(),n=e.reportNamespaces?.getUsedNamespaces()??[],t={},s={};return e.languages.forEach((t=>{s[t]={},n.forEach((n=>{s[t][n]=e.getResourceBundle(t,n)||{}}))})),t.initialI18nStore=s,t.initialLanguage=e.language,t};const U=(e,n,t,s)=>e.getFixedT(n,t,s),B=function(e){let t=arguments.length>1&&void 0!==arguments[1]?arguments[1]:{};const{i18n:s}=t,{i18n:i,defaultNS:a}=n.useContext(A)||{},r=s||i||k();if(r&&!r.reportNamespaces&&(r.reportNamespaces=new V),!r){d("You will need to pass in an i18next instance by using initReactI18next");const e=(e,n)=>y(n)?n:x(n)&&y(n.defaultValue)?n.defaultValue:Array.isArray(e)?e[e.length-1]:e,n=[e,{},!1];return n.t=e,n.i18n={},n.ready=!1,n}r.options.react?.wait&&d("It seems you are still using the old wait option, you may migrate to the new useSuspense behaviour.");const o={...$(),...r.options.react,...t},{useSuspense:l,keyPrefix:c}=o;let u=e||a||r.options?.defaultNS;u=y(u)?[u]:u||["translation"],r.reportNamespaces.addUsedNamespaces?.(u);const p=(r.isInitialized||r.initializedStoreOnce)&&u.every((e=>function(e,n){let t=arguments.length>2&&void 0!==arguments[2]?arguments[2]:{};return n.languages&&n.languages.length?n.hasLoadedNamespace(e,{lng:t.lng,precheck:(n,s)=>{if(t.bindI18n?.indexOf("languageChanging")>-1&&n.services.backendConnector.backend&&n.isLanguageChangingTo&&!s(n.isLanguageChangingTo,e))return!1}}):(d("i18n.languages were undefined or empty",n.languages),!0)}(e,r,o))),f=((e,t,s,i)=>n.useCallback(U(e,t,s,i),[e,t,s,i]))(r,t.lng||null,"fallback"===o.nsMode?u:u[0],c),g=()=>f,b=()=>U(r,t.lng||null,"fallback"===o.nsMode?u:u[0],c),[v,E]=n.useState(g);let O=u.join();t.lng&&(O=`${t.lng}${O}`);const N=((e,t)=>{const s=n.useRef();return n.useEffect((()=>{s.current=e}),[e,t]),s.current})(O),w=n.useRef(!0);n.useEffect((()=>{const{bindI18n:e,bindI18nStore:n}=o;w.current=!0,p||l||(t.lng?m(r,t.lng,u,(()=>{w.current&&E(b)})):h(r,u,(()=>{w.current&&E(b)}))),p&&N&&N!==O&&w.current&&E(b);const s=()=>{w.current&&E(b)};return e&&r?.on(e,s),n&&r?.store.on(n,s),()=>{w.current=!1,r&&e?.split(" ").forEach((e=>r.off(e,s))),n&&r&&n.split(" ").forEach((e=>r.store.off(e,s)))}}),[r,O]),n.useEffect((()=>{w.current&&p&&E(g)}),[r,c,p]);const I=[v,r,p];if(I.t=v,I.i18n=r,I.ready=p,p)return I;if(!p&&!l)return I;throw new Promise((e=>{t.lng?m(r,t.lng,u,(()=>e())):h(r,u,(()=>e()))}))};const D=function(e,t){let s=arguments.length>2&&void 0!==arguments[2]?arguments[2]:{};const{i18n:i}=s,{i18n:a}=n.useContext(A)||{},r=i||a||k();r.options?.isClone||(e&&!r.initializedStoreOnce&&(r.services.resourceStore.data=e,r.options.ns=Object.values(e).reduce(((e,n)=>(Object.keys(n).forEach((n=>{e.indexOf(n)<0&&e.push(n)})),e)),r.options.ns),r.initializedStoreOnce=!0,r.isInitialized=!0),t&&!r.initializedLanguageOnce&&(r.changeLanguage(t),r.initializedLanguageOnce=!0))};e.I18nContext=A,e.I18nextProvider=function(e){let{i18n:t,defaultNS:s,children:i}=e;const a=n.useMemo((()=>({i18n:t,defaultNS:s})),[t,s]);return n.createElement(A.Provider,{value:a},i)},e.Trans=function(e){let{children:t,count:s,parent:i,i18nKey:a,context:r,tOptions:o={},values:l,defaults:c,components:u,ns:p,i18n:d,t:f,shouldUnescape:h,...m}=e;const{i18n:g,defaultNS:y}=n.useContext(A)||{},x=d||g||k(),b=f||x?.t.bind(x);return P({children:t,count:s,parent:i,i18nKey:a,context:r,tOptions:o,values:l,defaults:c,components:u,ns:p||b?.ns||y||x?.options?.defaultNS,i18n:x,t:f,shouldUnescape:h,...m})},e.TransWithoutContext=P,e.Translation=e=>{let{ns:n,children:t,...s}=e;const[i,a,r]=B(n,s);return t(i,{i18n:a,lng:a.language},r)},e.composeInitialProps=z,e.date=()=>"",e.getDefaults=$,e.getI18n=k,e.getInitialProps=F,e.initReactI18next=L,e.number=()=>"",e.plural=()=>"",e.select=()=>"",e.selectOrdinal=()=>"",e.setDefaults=N,e.setI18n=I,e.time=()=>"",e.useSSR=D,e.useTranslation=B,e.withSSR=()=>function(e){function t(t){let{initialI18nStore:s,initialLanguage:i,...a}=t;return D(s,i),n.createElement(e,{...a})}return t.getInitialProps=z(e),t.displayName=`withI18nextSSR(${g(e)})`,t.WrappedComponent=e,t},e.withTranslation=function(e){let t=arguments.length>1&&void 0!==arguments[1]?arguments[1]:{};return function(s){function i(i){let{forwardedRef:a,...r}=i;const[o,l,c]=B(e,{...r,keyPrefix:t.keyPrefix}),u={...r,t:o,i18n:l,tReady:c};return t.withRef&&a?u.ref=a:!t.withRef&&a&&(u.forwardedRef=a),n.createElement(s,u)}i.displayName=`withI18nextTranslation(${g(s)})`,i.WrappedComponent=s;return t.withRef?n.forwardRef(((e,t)=>n.createElement(i,Object.assign({},e,{forwardedRef:t})))):i}}})); ++!function(e,n){"object"==typeof exports&&"undefined"!=typeof module?n(exports,require("react")):"function"==typeof define&&define.amd?define(["exports","react"],n):n((e="undefined"!=typeof globalThis?globalThis:e||self).ReactI18next={},e.React)}(this,(function(e,n){"use strict";function t(e){return e&&e.__esModule&&Object.prototype.hasOwnProperty.call(e,"default")?e.default:e}var s=t({area:!0,base:!0,br:!0,col:!0,embed:!0,hr:!0,img:!0,input:!0,link:!0,meta:!0,param:!0,source:!0,track:!0,wbr:!0}),i=/\s([^'"/\s><]+?)[\s/>]|([^\s=]+)=\s?(".*?"|'.*?')/g;function a(e){var n={type:"tag",name:"",voidElement:!1,attrs:{},children:[]},t=e.match(/<\/?([^\s]+?)[/\s>]/);if(t&&(n.name=t[1],(s[t[1]]||"/"===e.charAt(e.length-2))&&(n.voidElement=!0),n.name.startsWith("!--"))){var a=e.indexOf("--\x3e");return{type:"comment",comment:-1!==a?e.slice(4,a):""}}for(var r=new RegExp(i),o=null;null!==(o=r.exec(e));)if(o[0].trim())if(o[1]){var l=o[1].trim(),c=[l,""];l.indexOf("=")>-1&&(c=l.split("=")),n.attrs[c[0]]=c[1],r.lastIndex--}else o[2]&&(n.attrs[o[2]]=o[3].trim().substring(1,o[3].length-1));return n}var r=/<[a-zA-Z0-9\-\!\/](?:"[^"]*"|'[^']*'|[^'">])*>/g,o=/^\s*$/,l=Object.create(null);var c=function(e,n){n||(n={}),n.components||(n.components=l);var t,s=[],i=[],c=-1,u=!1;if(0!==e.indexOf("<")){var p=e.indexOf("<");s.push({type:"text",content:-1===p?e:e.substring(0,p)})}return e.replace(r,(function(r,l){if(u){if(r!=="")return;u=!1}var p,d="/"!==r.charAt(1),f=r.startsWith("\x3c!--"),h=l+r.length,m=e.charAt(h);if(f){var g=a(r);return c<0?(s.push(g),s):((p=i[c]).children.push(g),s)}if(d&&(c++,"tag"===(t=a(r)).type&&n.components[t.name]&&(t.type="component",u=!0),t.voidElement||u||!m||"<"===m||t.children.push({type:"text",content:e.slice(h,e.indexOf("<",h))}),0===c&&s.push(t),(p=i[c-1])&&p.children.push(t),i[c]=t),(!d||t.voidElement)&&(c>-1&&(t.voidElement||t.name===r.slice(2,-1))&&(c--,t=-1===c?s:i[c]),!u&&"<"!==m&&m)){p=-1===c?s:i[c].children;var y=e.indexOf("<",h),x=e.slice(h,-1===y?void 0:y);o.test(x)&&(x=" "),(y>-1&&c+p.length>=0||" "!==x)&&p.push({type:"text",content:x})}})),s};const u=function(){if(console?.warn){for(var e=arguments.length,n=new Array(e),t=0;t()=>{if(e.isInitialized)n();else{const t=()=>{setTimeout((()=>{e.off("initialized",t)}),0),n()};e.on("initialized",t)}},h=(e,n,t)=>{e.loadNamespaces(n,f(e,t))},m=(e,n,t,s)=>{y(t)&&(t=[t]),t.forEach((n=>{e.options.ns.indexOf(n)<0&&e.options.ns.push(n)})),e.loadLanguages(n,f(e,s))},g=e=>e.displayName||e.name||(y(e)&&e.length>0?e:"Unknown"),y=e=>"string"==typeof e,x=e=>"object"==typeof e&&null!==e,b=/&(?:amp|#38|lt|#60|gt|#62|apos|#39|quot|#34|nbsp|#160|copy|#169|reg|#174|hellip|#8230|#x2F|#47);/g,v={"&":"&","&":"&","<":"<","<":"<",">":">",">":">","'":"'","'":"'",""":'"',""":'"'," ":" "," ":" ","©":"©","©":"©","®":"®","®":"®","…":"…","…":"…","/":"/","/":"/"},E=e=>v[e];let O={bindI18n:"languageChanged",bindI18nStore:"",transEmptyNodeValue:"",transSupportBasicHtmlNodes:!0,transWrapTextNodes:"",transKeepBasicHtmlNodesFor:["br","strong","i","p"],useSuspense:!0,unescape:e=>e.replace(b,E)};const N=function(){let e=arguments.length>0&&void 0!==arguments[0]?arguments[0]:{};O={...O,...e}},$=()=>O;let w;const I=e=>{w=e},k=()=>w,S=(e,n)=>{if(!e)return!1;const t=e.props?.children??e.children;return n?t.length>0:!!t},j=e=>{if(!e)return[];const n=e.props?.children??e.children;return e.props?.i18nIsDynamicList?R(n):n},R=e=>Array.isArray(e)?e:[e],T=(e,t)=>{if(!e)return"";let s="";const i=R(e),a=t?.transSupportBasicHtmlNodes?t.transKeepBasicHtmlNodesFor??[]:[];return i.forEach(((e,i)=>{if(y(e))s+=`${e}`;else if(n.isValidElement(e)){const{props:n,type:r}=e,o=Object.keys(n).length,l=a.indexOf(r)>-1,c=n.children;if(c||!l||o)if(!c&&(!l||o)||n.i18nIsDynamicList)s+=`<${i}>`;else if(l&&1===o&&y(c))s+=`<${r}>${c}`;else{const e=T(c,t);s+=`<${i}>${e}`}else s+=`<${r}/>`}else if(null===e)u("Trans: the passed in value is invalid - seems you passed in a null child.");else if(x(e)){const{format:n,...t}=e,i=Object.keys(t);if(1===i.length){const e=n?`${i[0]}, ${n}`:i[0];s+=`{{${e}}}`}else u("react-i18next: the passed in object contained more than one variable - the object should look like {{ value, format }} where format is optional.",e)}else u("Trans: the passed in value is invalid - seems you passed in a variable like {number} - please pass in variables for interpolation as full objects like {{number}}.",e)})),s},C=(e,t,s,i,a,r)=>{if(""===t)return[];const o=i.transKeepBasicHtmlNodesFor||[],l=t&&new RegExp(o.map((e=>`<${e}`)).join("|")).test(t);if(!e&&!l&&!r)return[t];const u={},p=e=>{R(e).forEach((e=>{y(e)||(S(e)?p(j(e)):x(e)&&!n.isValidElement(e)&&Object.assign(u,e))}))};p(e);const d=c(`<0>${t}`),f={...u,...a},h=(e,t,s)=>{const i=j(e),a=g(i,t.children,s);return(e=>Array.isArray(e)&&e.every(n.isValidElement))(i)&&0===a.length||e.props?.i18nIsDynamicList?i:a},m=(e,t,s,i,a)=>{e.dummy?(e.children=t,s.push(n.cloneElement(e,{key:i},a?void 0:t))):s.push(...n.Children.map([e],(e=>{const s={...e.props};return delete s.i18nIsDynamicList,n.createElement(e.type,{...s,key:i,ref:e.ref},a?null:t)})))},g=(t,a,c)=>{const u=R(t);return R(a).reduce(((t,a,p)=>{const d=a.children?.[0]?.content&&s.services.interpolator.interpolate(a.children[0].content,f,s.language);if("tag"===a.type){let r=u[parseInt(a.name,10)];1!==c.length||r||(r=c[0][a.name]),r||(r={});const b=0!==Object.keys(a.attrs).length?((e,n)=>{const t={...n};return t.props=Object.assign(e.props,n.props),t})({props:a.attrs},r):r,v=n.isValidElement(b),E=v&&S(a,!0)&&!a.voidElement,O=l&&x(b)&&b.dummy&&!v,N=x(e)&&Object.hasOwnProperty.call(e,a.name);if(y(b)){const e=s.services.interpolator.interpolate(b,f,s.language);t.push(e)}else if(S(b)||E){const e=h(b,a,c);m(b,e,t,p)}else if(O){const e=g(u,a.children,c);m(b,e,t,p)}else if(Number.isNaN(parseFloat(a.name)))if(N){const e=h(b,a,c);m(b,e,t,p,a.voidElement)}else if(i.transSupportBasicHtmlNodes&&o.indexOf(a.name)>-1)if(a.voidElement)t.push(n.createElement(a.name,{key:`${a.name}-${p}`}));else{const e=g(u,a.children,c);t.push(n.createElement(a.name,{key:`${a.name}-${p}`},e))}else if(a.voidElement)t.push(`<${a.name} />`);else{const e=g(u,a.children,c);t.push(`<${a.name}>${e}`)}else if(x(b)&&!v){const e=a.children[0]?d:null;e&&t.push(e)}else m(b,d,t,p,1!==a.children.length||!d)}else if("text"===a.type){const e=i.transWrapTextNodes,o=r?i.unescape(s.services.interpolator.interpolate(a.content,f,s.language)):s.services.interpolator.interpolate(a.content,f,s.language);e?t.push(n.createElement(e,{key:`${a.name}-${p}`},o)):t.push(o)}return t}),[])},b=g([{dummy:!0,children:e||[]}],d,R(e||[]));return j(b[0])};function P(e){let{children:t,count:s,parent:i,i18nKey:a,context:r,tOptions:o={},values:l,defaults:c,components:u,ns:p,i18n:f,t:h,shouldUnescape:m,...g}=e;const x=f||k();if(!x)return d("You will need to pass in an i18next instance by using i18nextReactModule"),t;const b=h||x.t.bind(x)||(e=>e),v={...$(),...x.options?.react};let E=p||b.ns||x.options?.defaultNS;E=y(E)?[E]:E||["translation"];const O=T(t,v),N=c||O||v.transEmptyNodeValue||a,{hashTransKey:w}=v,I=a||(w?w(O||N):O||N);x.options?.interpolation?.defaultVariables&&(l=l&&Object.keys(l).length>0?{...l,...x.options.interpolation.defaultVariables}:{...x.options.interpolation.defaultVariables});const S=l||void 0!==s||!t?o.interpolation:{interpolation:{...o.interpolation,prefix:"#$?",suffix:"?$#"}},j={...o,context:r||o.context,count:s,...l,...S,defaultValue:N,ns:E},R=I?b(I,j):N;u&&Object.keys(u).forEach((e=>{const t=u[e];"function"==typeof t.type||!t.props||!t.props.children||R.indexOf(`${e}/>`)<0&&R.indexOf(`${e} />`)<0||(u[e]=n.createElement((function(){return n.createElement(n.Fragment,null,t)})))}));const P=C(u||t,R,x,v,j,m),L=i??v.defaultTransParent;return L?n.createElement(L,g,P):P}const L={type:"3rdParty",init(e){N(e.options.react),I(e)}},A=n.createContext();class V{constructor(){this.usedNamespaces={}}addUsedNamespaces(e){e.forEach((e=>{this.usedNamespaces[e]=this.usedNamespaces[e]??!0}))}getUsedNamespaces(){return Object.keys(this.usedNamespaces)}}const z=e=>async n=>({...await(e.getInitialProps?.(n))??{},...F()}),F=()=>{const e=k(),n=e.reportNamespaces?.getUsedNamespaces()??[],t={},s={};return e.languages.forEach((t=>{s[t]={},n.forEach((n=>{s[t][n]=e.getResourceBundle(t,n)||{}}))})),t.initialI18nStore=s,t.initialLanguage=e.language,t};const U=(e,n,t,s)=>e.getFixedT(n,t,s),B=function(e){let t=arguments.length>1&&void 0!==arguments[1]?arguments[1]:{};const{i18n:s}=t,{i18n:i,defaultNS:a}=n.useContext(A)||{},r=s||i||k();if(r&&!r.reportNamespaces&&(r.reportNamespaces=new V),!r){d("You will need to pass in an i18next instance by using initReactI18next");const e=(e,n)=>y(n)?n:x(n)&&y(n.defaultValue)?n.defaultValue:Array.isArray(e)?e[e.length-1]:e,n=[e,{},!1];return n.t=e,n.i18n={},n.ready=!1,n}r.options.react?.wait&&d("It seems you are still using the old wait option, you may migrate to the new useSuspense behaviour.");const o={...$(),...r.options.react,...t},{useSuspense:l,keyPrefix:c}=o;let u=e||a||r.options?.defaultNS;u=y(u)?[u]:u||["translation"],r.reportNamespaces.addUsedNamespaces?.(u);const p=(r.isInitialized||r.initializedStoreOnce)&&u.every((e=>function(e,n){let t=arguments.length>2&&void 0!==arguments[2]?arguments[2]:{};return n.languages&&n.languages.length?n.hasLoadedNamespace(e,{lng:t.lng,precheck:(n,s)=>{if(t.bindI18n?.indexOf("languageChanging")>-1&&n.services.backendConnector.backend&&n.isLanguageChangingTo&&!s(n.isLanguageChangingTo,e))return!1}}):(d("i18n.languages were undefined or empty",n.languages),!0)}(e,r,o))),f=((e,t,s,i)=>n.useCallback(U(e,t,s,i),[e,t,s,i]))(r,t.lng||null,"fallback"===o.nsMode?u:u[0],c),g=()=>f,b=()=>U(r,t.lng||null,"fallback"===o.nsMode?u:u[0],c),[v,E]=n.useState(g);let O=u.join();t.lng&&(O=`${t.lng}${O}`);const N=((e,t)=>{const s=n.useRef();return n.useEffect((()=>{s.current=e}),[e,t]),s.current})(O),w=n.useRef(!0);n.useEffect((()=>{const{bindI18n:e,bindI18nStore:n}=o;w.current=!0,p||l||(t.lng?m(r,t.lng,u,(()=>{w.current&&E(b)})):h(r,u,(()=>{w.current&&E(b)}))),p&&N&&N!==O&&w.current&&E(b);const s=()=>{w.current&&E(b)};return e&&r?.on(e,s),n&&r?.store.on(n,s),()=>{w.current=!1,r&&e?.split(" ").forEach((e=>r.off(e,s))),n&&r&&n.split(" ").forEach((e=>r.store.off(e,s)))}}),[r,O]),n.useEffect((()=>{w.current&&p&&E(g)}),[r,c,p]);const I=[v,r,p];if(I.t=v,I.i18n=r,I.ready=p,p)return I;if(!p&&!l)return I;throw new Promise((e=>{t.lng?m(r,t.lng,u,(()=>e())):h(r,u,(()=>e()))}))};const D=function(e,t){let s=arguments.length>2&&void 0!==arguments[2]?arguments[2]:{};const{i18n:i}=s,{i18n:a}=n.useContext(A)||{},r=i||a||k();r.options?.isClone||(e&&!r.initializedStoreOnce&&(r.services.resourceStore.data=e,r.options.ns=Object.values(e).reduce(((e,n)=>(Object.keys(n).forEach((n=>{e.indexOf(n)<0&&e.push(n)})),e)),r.options.ns),r.initializedStoreOnce=!0,r.isInitialized=!0),t&&!r.initializedLanguageOnce&&(r.changeLanguage(t),r.initializedLanguageOnce=!0))};e.I18nContext=A,e.I18nextProvider=function(e){let{i18n:t,defaultNS:s,children:i}=e;const a=n.useMemo((()=>({i18n:t,defaultNS:s})),[t,s]);return n.createElement(A.Provider,{value:a},i)},e.Trans=function(e){let{children:t,count:s,parent:i,i18nKey:a,context:r,tOptions:o={},values:l,defaults:c,components:u,ns:p,i18n:d,t:f,shouldUnescape:h,...m}=e;const{i18n:g,defaultNS:y}=n.useContext(A)||{},x=d||g||k(),b=f||x?.t.bind(x);return P({children:t,count:s,parent:i,i18nKey:a,context:r,tOptions:o,values:l,defaults:c,components:u,ns:p||b?.ns||y||x?.options?.defaultNS,i18n:x,t:f,shouldUnescape:h,...m})},e.TransWithoutContext=P,e.Translation=e=>{let{ns:n,children:t,...s}=e;const[i,a,r]=B(n,s);return t(i,{i18n:a,lng:a.language},r)},e.composeInitialProps=z,e.date=()=>"",e.getDefaults=$,e.getI18n=k,e.getInitialProps=F,e.initReactI18next=L,e.number=()=>"",e.plural=()=>"",e.select=()=>"",e.selectOrdinal=()=>"",e.setDefaults=N,e.setI18n=I,e.time=()=>"",e.useSSR=D,e.useTranslation=B,e.withSSR=()=>function(e){function t(t){let{initialI18nStore:s,initialLanguage:i,...a}=t;return D(s,i),n.createElement(e,{...a})}return t.getInitialProps=z(e),t.displayName=`withI18nextSSR(${g(e)})`,t.WrappedComponent=e,t},e.withTranslation=function(e){let t=arguments.length>1&&void 0!==arguments[1]?arguments[1]:{};return function(s){function i(i){let{forwardedRef:a,...r}=i;const[o,l,c]=B(e,{...r,keyPrefix:t.keyPrefix}),u={...r,t:o,i18n:l,tReady:c};return t.withRef&&a?u.ref=a:!t.withRef&&a&&(u.forwardedRef=a),n.createElement(s,u)}i.displayName=`withI18nextTranslation(${g(s)})`,i.WrappedComponent=s;return t.withRef?n.forwardRef(((e,t)=>n.createElement(i,Object.assign({},e,{forwardedRef:t})))):i}}})); +diff --git a/src/context.js b/src/context.js +index 167af9c50f47e34f7473df03bb2bb1e369725934..9b9a5570b0b765c9d7809f912dba2db759ebb68d 100644 +--- a/src/context.js ++++ b/src/context.js +@@ -14,7 +14,7 @@ export class ReportNamespaces { + + addUsedNamespaces(namespaces) { + namespaces.forEach((ns) => { +- this.usedNamespaces[ns] ??= true; ++ this.usedNamespaces[ns] = this.usedNamespaces[ns] ?? true; + }); + } + diff --git a/README.md b/README.md index 6461ad602516..564ca75d2b11 100644 --- a/README.md +++ b/README.md @@ -52,6 +52,7 @@ You can follow these instructions to setup a dev environment: - Install **Node 14.x (LTS)** either [manually](https://nodejs.org/dist/latest-v14.x/) or using a tool like [nvm](https://github.com/creationix/nvm) or [volta](https://volta.sh/) (recommended) - Install **Meteor** ([version here](apps/meteor/.meteor/release)): https://docs.meteor.com/about/install.html - Install **yarn**: https://yarnpkg.com/getting-started/install +- Install **Deno 1.x**: https://docs.deno.com/runtime/fundamentals/installation/ - Clone this repo: `git clone https://github.com/RocketChat/Rocket.Chat.git` - Run `yarn` to install dependencies diff --git a/_templates/service/new/package.json.ejs.t b/_templates/service/new/package.json.ejs.t index 2c74278d1ced..0aa1bd69e995 100644 --- a/_templates/service/new/package.json.ejs.t +++ b/_templates/service/new/package.json.ejs.t @@ -20,6 +20,7 @@ to: ee/apps/<%= name %>/package.json "dependencies": { "@rocket.chat/core-services": "workspace:^", "@rocket.chat/core-typings": "workspace:^", + "@rocket.chat/network-broker": "workspace:^", "@rocket.chat/emitter": "next", "@rocket.chat/model-typings": "workspace:^", "@rocket.chat/models": "workspace:^", diff --git a/_templates/service/new/service.ejs.t b/_templates/service/new/service.ejs.t index 54080d94cf08..699539365259 100644 --- a/_templates/service/new/service.ejs.t +++ b/_templates/service/new/service.ejs.t @@ -1,12 +1,10 @@ --- to: ee/apps/<%= name %>/src/service.ts --- -import type { Document } from 'mongodb'; +import { api, getConnection, getTrashCollection } from '@rocket.chat/core-services'; +import { broker } from '@rocket.chat/network-broker'; import polka from 'polka'; -import { api } from '@rocket.chat/core-services'; -import { broker } from '../../../../apps/meteor/ee/server/startup/broker'; -import { Collections, getCollection, getConnection } from '../../../../apps/meteor/ee/server/services/mongo'; import { registerServiceModels } from '../../../../apps/meteor/ee/server/lib/registerServiceModels'; const PORT = process.env.PORT || <%= h.random() %>; @@ -14,9 +12,7 @@ const PORT = process.env.PORT || <%= h.random() %>; (async () => { const db = await getConnection(); - const trash = await getCollection(Collections.Trash); - - registerServiceModels(db, trash); + registerServiceModels(db, await getTrashCollection()); api.setBroker(broker); diff --git a/apps/meteor/.docker/Dockerfile b/apps/meteor/.docker/Dockerfile index 1e9ed3f5e592..75df2cb90678 100644 --- a/apps/meteor/.docker/Dockerfile +++ b/apps/meteor/.docker/Dockerfile @@ -1,3 +1,7 @@ +ARG DENO_VERSION="1.37.1" + +FROM denoland/deno:bin-${DENO_VERSION} as deno + FROM node:14.21.3-bullseye-slim LABEL maintainer="buildmaster@rocket.chat" @@ -10,6 +14,8 @@ RUN groupadd -g 65533 -r rocketchat \ && apt-get update \ && apt-get install -y --no-install-recommends fontconfig +COPY --from=deno /deno /bin/deno + # --chown requires Docker 17.12 and works only on Linux ADD --chown=rocketchat:rocketchat . /app @@ -20,8 +26,7 @@ ENV DEPLOY_METHOD=docker \ HOME=/tmp \ PORT=3000 \ ROOT_URL=http://localhost:3000 \ - Accounts_AvatarStorePath=/app/uploads \ - DENO_DIR=/usr/share/deno + Accounts_AvatarStorePath=/app/uploads RUN aptMark="$(apt-mark showmanual)" \ && apt-get install -y --no-install-recommends g++ make python3 ca-certificates \ @@ -29,8 +34,6 @@ RUN aptMark="$(apt-mark showmanual)" \ && npm install \ && cd npm/node_modules/isolated-vm \ && npm install \ - && cd /app/bundle/programs/server/npm/node_modules/@rocket.chat/apps-engine/deno-runtime \ - && ../../../deno-bin/bin/deno cache main.ts \ && apt-mark auto '.*' > /dev/null \ && apt-mark manual $aptMark > /dev/null \ && find /usr/local -type f -executable -exec ldd '{}' ';' \ diff --git a/apps/meteor/.docker/Dockerfile.alpine b/apps/meteor/.docker/Dockerfile.alpine index feebf76a03e7..aaa2d2552ab3 100644 --- a/apps/meteor/.docker/Dockerfile.alpine +++ b/apps/meteor/.docker/Dockerfile.alpine @@ -1,7 +1,17 @@ +ARG DENO_VERSION="1.37.1" + +FROM denoland/deno:bin-${DENO_VERSION} as deno + FROM node:14.21.3-alpine3.16 +LABEL maintainer="buildmaster@rocket.chat" + ENV LANG=C.UTF-8 +## Alpine 3.16 does not have a deno package, but newer versions have it +## So as soon as we can update the Alpine version, we can replace the following +## GLIBC installation part by an `apk add deno` + # Installing glibc deps required by Deno # This replaces libc6-compat # Copied from https://github.com/Docker-Hub-frolvlad/docker-alpine-glibc, which denoland/deno:alpine-1.37.1 uses @@ -11,7 +21,7 @@ RUN ALPINE_GLIBC_BASE_URL="https://github.com/sgerrand/alpine-pkg-glibc/releases ALPINE_GLIBC_BASE_PACKAGE_FILENAME="glibc-$ALPINE_GLIBC_PACKAGE_VERSION.apk" && \ ALPINE_GLIBC_BIN_PACKAGE_FILENAME="glibc-bin-$ALPINE_GLIBC_PACKAGE_VERSION.apk" && \ ALPINE_GLIBC_I18N_PACKAGE_FILENAME="glibc-i18n-$ALPINE_GLIBC_PACKAGE_VERSION.apk" && \ - apk add --no-cache --virtual=.build-dependencies wget ca-certificates && \ + apk add --no-cache --virtual=.build-dependencies wget ca-certificates ttf-dejavu && \ echo \ "-----BEGIN PUBLIC KEY-----\ MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEApZ2u1KJKUu/fW4A25y9m\ @@ -44,12 +54,11 @@ RUN ALPINE_GLIBC_BASE_URL="https://github.com/sgerrand/alpine-pkg-glibc/releases rm \ "$ALPINE_GLIBC_BASE_PACKAGE_FILENAME" \ "$ALPINE_GLIBC_BIN_PACKAGE_FILENAME" \ - "$ALPINE_GLIBC_I18N_PACKAGE_FILENAME" && \ - apk add --no-cache ttf-dejavu + "$ALPINE_GLIBC_I18N_PACKAGE_FILENAME" -ADD . /app +COPY --from=deno /deno /bin/deno -LABEL maintainer="buildmaster@rocket.chat" +ADD . /app # needs a mongo instance - defaults to container linking with alias 'mongo' ENV DEPLOY_METHOD=docker \ @@ -58,13 +67,12 @@ ENV DEPLOY_METHOD=docker \ HOME=/tmp \ PORT=3000 \ ROOT_URL=http://localhost:3000 \ - Accounts_AvatarStorePath=/app/uploads \ - DENO_DIR=/usr/share/deno + Accounts_AvatarStorePath=/app/uploads RUN set -x \ && apk add --no-cache --virtual .fetch-deps python3 make g++ \ && cd /app/bundle/programs/server \ - && npm install --production \ + && npm install --omit=dev --unsafe-perm \ # Start hack for sharp... && rm -rf npm/node_modules/sharp \ && npm install sharp@0.32.6 \ @@ -75,9 +83,6 @@ RUN set -x \ && npm install isolated-vm@4.4.2 \ && mv node_modules/isolated-vm npm/node_modules/isolated-vm \ # End hack for isolated-vm - # Cache Deno dependencies for Apps-Engine - && cd npm/node_modules/@rocket.chat/apps-engine/deno-runtime \ - && /app/bundle/programs/server/npm/node_modules/deno-bin/bin/deno cache main.ts \ && cd /app/bundle/programs/server/npm \ && npm rebuild bcrypt --build-from-source \ && npm cache clear --force \ diff --git a/apps/meteor/CHANGELOG.md b/apps/meteor/CHANGELOG.md index 97a246abaca6..c31645ca4ea4 100644 --- a/apps/meteor/CHANGELOG.md +++ b/apps/meteor/CHANGELOG.md @@ -1,5 +1,186 @@ # @rocket.chat/meteor +## 6.13.0-rc.1 + +### Minor Changes + +- ([#33212](https://github.com/RocketChat/Rocket.Chat/pull/33212)) Added new Admin Feature Preview management view, this will allow the workspace admins to both enable feature previewing in the workspace as well as define which feature previews are enabled by default for the users in the workspace. + +### Patch Changes + +- ([#33339](https://github.com/RocketChat/Rocket.Chat/pull/33339)) Fixes a problem that caused visitor creation to fail when GDPR setting was enabled and visitor was created via Apps Engine or the deprecated `livechat:registerGuest` method. + +- Bump @rocket.chat/meteor version. + +-
Updated dependencies [2f9eea03d2]: + + - @rocket.chat/i18n@0.8.0-rc.1 + - @rocket.chat/ui-client@11.0.0-rc.1 + - @rocket.chat/ui-contexts@11.0.0-rc.1 + - @rocket.chat/web-ui-registration@11.0.0-rc.1 + - @rocket.chat/gazzodown@11.0.0-rc.1 + - @rocket.chat/fuselage-ui-kit@11.0.0-rc.1 + - @rocket.chat/ui-theming@0.3.0-rc.0 + - @rocket.chat/ui-avatar@7.0.0-rc.1 + - @rocket.chat/ui-video-conf@11.0.0-rc.1 + - @rocket.chat/core-typings@6.13.0-rc.1 + - @rocket.chat/rest-typings@6.13.0-rc.1 + - @rocket.chat/license@0.2.8-rc.1 + - @rocket.chat/omnichannel-services@0.3.5-rc.1 + - @rocket.chat/pdf-worker@0.2.5-rc.1 + - @rocket.chat/presence@0.2.8-rc.1 + - @rocket.chat/api-client@0.2.8-rc.1 + - @rocket.chat/apps@0.1.8-rc.1 + - @rocket.chat/core-services@0.7.0-rc.1 + - @rocket.chat/cron@0.1.8-rc.1 + - @rocket.chat/model-typings@0.8.0-rc.1 + - @rocket.chat/server-cloud-communication@0.0.2 + - @rocket.chat/models@0.3.0-rc.1 + - @rocket.chat/instance-status@0.1.8-rc.1 +
+ +## 6.13.0-rc.0 + +### Minor Changes + +- ([#33156](https://github.com/RocketChat/Rocket.Chat/pull/33156)) added `sidepanelNavigation` to feature preview list + +- ([#32682](https://github.com/RocketChat/Rocket.Chat/pull/32682)) Added support for specifying a unit on departments' creation and update + +- ([#33139](https://github.com/RocketChat/Rocket.Chat/pull/33139)) Added new setting `Allow visitors to finish conversations` that allows admins to decide if omnichannel visitors can close a conversation or not. This doesn't affect agent's capabilities of room closing, neither apps using the livechat bridge to close rooms. + However, if currently your integration relies on `livechat/room.close` endpoint for closing conversations, it's advised to use the authenticated version `livechat/room.closeByUser` of it before turning off this setting. +- ([#32729](https://github.com/RocketChat/Rocket.Chat/pull/32729)) Implemented "omnichannel/contacts.update" endpoint to update contacts + +- ([#32510](https://github.com/RocketChat/Rocket.Chat/pull/32510)) Added a new setting to enable mentions in end to end encrypted channels + +- ([#32821](https://github.com/RocketChat/Rocket.Chat/pull/32821)) Replaced new `SidebarV2` components under feature preview + +- ([#33011](https://github.com/RocketChat/Rocket.Chat/pull/33011)) Return `parent` and `team` information when calling `rooms.info` endpoint + +- ([#32693](https://github.com/RocketChat/Rocket.Chat/pull/32693)) Introduced "create contacts" endpoint to omnichannel + +- ([#33177](https://github.com/RocketChat/Rocket.Chat/pull/33177)) New `teams.listChildren` endpoint that allows users listing rooms & discussions from teams. Only the discussions from the team's main room are returned. + +- ([#33114](https://github.com/RocketChat/Rocket.Chat/pull/33114)) Wraps some room settings in an accordion advanced settings section in room edit contextual bar to improve organization + +- ([#33160](https://github.com/RocketChat/Rocket.Chat/pull/33160)) Implemented sending email via apps + +- ([#32945](https://github.com/RocketChat/Rocket.Chat/pull/32945)) Added a new setting which allows workspace admins to disable email two factor authentication for SSO (OAuth) users. If enabled, SSO users won't be asked for email two factor authentication. + +- ([#33225](https://github.com/RocketChat/Rocket.Chat/pull/33225)) Implemented new feature preview for Sidepanel + +### Patch Changes + +- ([#33317](https://github.com/RocketChat/Rocket.Chat/pull/33317)) Fixed error during sendmessage client stub + +- ([#33211](https://github.com/RocketChat/Rocket.Chat/pull/33211)) Allow to use the token from `room.v` when requesting transcript instead of visitor token. Visitors may change their tokens at any time, rendering old conversations impossible to access for them (or for APIs depending on token) as the visitor token won't match the `room.v` token. + +- ([#33298](https://github.com/RocketChat/Rocket.Chat/pull/33298)) Fixed a Federation callback not awaiting db call + +- ([#32939](https://github.com/RocketChat/Rocket.Chat/pull/32939)) Fixed issue where when you marked a room as unread and you were part of it, sometimes it would mark it as read right after + +- ([#33197](https://github.com/RocketChat/Rocket.Chat/pull/33197)) Fixes an issue where the retention policy warning keep displaying even if the retention is disabled inside the room + +- ([#33321](https://github.com/RocketChat/Rocket.Chat/pull/33321)) Changed the contextualbar behavior based on chat size instead the viewport + +- ([#33246](https://github.com/RocketChat/Rocket.Chat/pull/33246)) Security Hotfix (https://docs.rocket.chat/docs/security-fixes-and-updates) + +- ([#32999](https://github.com/RocketChat/Rocket.Chat/pull/32999)) Fixes multiple selection for MultiStaticSelectElement in UiKit + +- ([#33155](https://github.com/RocketChat/Rocket.Chat/pull/33155)) Fixed a code issue on NPS service. It was passing `startAt` as the expiration date when creating a banner. + +- ([#33237](https://github.com/RocketChat/Rocket.Chat/pull/33237)) fixed retention policy max age settings not being respected after upgrade + +- ([#33216](https://github.com/RocketChat/Rocket.Chat/pull/33216)) Prevented uiInteraction to subscribe multiple times + +- ([#33295](https://github.com/RocketChat/Rocket.Chat/pull/33295)) Resolves the issue where outgoing integrations failed to trigger after the version 6.12.0 upgrade by correcting the parameter order from the `afterSaveMessage` callback to listener functions. This ensures the correct room information is passed, restoring the functionality of outgoing webhooks, IRC bridge, Autotranslate, and Engagement Dashboard. + +- ([#33193](https://github.com/RocketChat/Rocket.Chat/pull/33193)) Fixed avatar blob image setting in setUserAvatar method by correcting service handling logic. + +- ([#33209](https://github.com/RocketChat/Rocket.Chat/pull/33209)) Fixed `LivechatSessionTaken` webhook event being called without the `agent` param, which represents the agent serving the room. + +- ([#33296](https://github.com/RocketChat/Rocket.Chat/pull/33296)) Fixed remaining direct references to external user avatar URLs + + Fixed local avatars having priority over external provider + + It mainly corrects the behavior of E2E encryption messages and desktop notifications. + +- ([#33157](https://github.com/RocketChat/Rocket.Chat/pull/33157) by [@csuadev](https://github.com/csuadev)) Fixed inconsistency between the markdown parser from the composer and the rest of the application when using bold and italics in a text. + +- ([#33181](https://github.com/RocketChat/Rocket.Chat/pull/33181)) Fixed issue that caused an infinite loading state when uploading a private app to Rocket.Chat + +- ([#33158](https://github.com/RocketChat/Rocket.Chat/pull/33158)) Fixes an issue where multi-step modals were closing unexpectedly + +-
Updated dependencies [bb94c9c67a, 9a38c8e13f, 599762739a, 7c14fd1a80, 9eaefdc892, 274f4f5881, cd0d50016e, 78e6ba4820, 532f08819e, 79c16d315a, 927710d778, 3a161c4310, 0f21fa01a3, 12d6307998]: + + - @rocket.chat/ui-client@11.0.0-rc.0 + - @rocket.chat/i18n@0.8.0-rc.0 + - @rocket.chat/model-typings@0.8.0-rc.0 + - @rocket.chat/rest-typings@6.13.0-rc.0 + - @rocket.chat/fuselage-ui-kit@11.0.0-rc.0 + - @rocket.chat/core-typings@6.13.0-rc.0 + - @rocket.chat/ui-theming@0.3.0-rc.0 + - @rocket.chat/ui-video-conf@11.0.0-rc.0 + - @rocket.chat/ui-composer@0.3.0-rc.0 + - @rocket.chat/gazzodown@11.0.0-rc.0 + - @rocket.chat/ui-avatar@7.0.0-rc.0 + - @rocket.chat/core-services@0.7.0-rc.0 + - @rocket.chat/message-parser@0.31.30-rc.0 + - @rocket.chat/models@0.3.0-rc.0 + - @rocket.chat/web-ui-registration@11.0.0-rc.0 + - @rocket.chat/ui-contexts@11.0.0-rc.0 + - @rocket.chat/omnichannel-services@0.3.4-rc.0 + - @rocket.chat/apps@0.1.7-rc.0 + - @rocket.chat/presence@0.2.7-rc.0 + - @rocket.chat/api-client@0.2.7-rc.0 + - @rocket.chat/license@0.2.7-rc.0 + - @rocket.chat/pdf-worker@0.2.4-rc.0 + - @rocket.chat/cron@0.1.7-rc.0 + - @rocket.chat/instance-status@0.1.7-rc.0 + - @rocket.chat/server-cloud-communication@0.0.2 +
+ +## 6.12.1 + +### Patch Changes + +- Bump @rocket.chat/meteor version. + +- Bump @rocket.chat/meteor version. + +- ([#33242](https://github.com/RocketChat/Rocket.Chat/pull/33242) by [@dionisio-bot](https://github.com/dionisio-bot)) Allow to use the token from `room.v` when requesting transcript instead of visitor token. Visitors may change their tokens at any time, rendering old conversations impossible to access for them (or for APIs depending on token) as the visitor token won't match the `room.v` token. + +- ([#33268](https://github.com/RocketChat/Rocket.Chat/pull/33268) by [@dionisio-bot](https://github.com/dionisio-bot)) Security Hotfix (https://docs.rocket.chat/docs/security-fixes-and-updates) + +- ([#33265](https://github.com/RocketChat/Rocket.Chat/pull/33265) by [@dionisio-bot](https://github.com/dionisio-bot)) fixed retention policy max age settings not being respected after upgrade + +-
Updated dependencies [3cbb9f6252]: + + - @rocket.chat/message-parser@0.31.30 + - @rocket.chat/core-services@0.6.1 + - @rocket.chat/core-typings@6.12.1 + - @rocket.chat/gazzodown@10.0.1 + - @rocket.chat/rest-typings@6.12.1 + - @rocket.chat/omnichannel-services@0.3.4 + - @rocket.chat/presence@0.2.7 + - @rocket.chat/license@0.2.7 + - @rocket.chat/pdf-worker@0.2.4 + - @rocket.chat/api-client@0.2.7 + - @rocket.chat/apps@0.1.7 + - @rocket.chat/cron@0.1.7 + - @rocket.chat/fuselage-ui-kit@10.0.1 + - @rocket.chat/model-typings@0.7.1 + - @rocket.chat/ui-contexts@10.0.1 + - @rocket.chat/server-cloud-communication@0.0.2 + - @rocket.chat/models@0.2.4 + - @rocket.chat/ui-theming@0.2.1 + - @rocket.chat/ui-avatar@6.0.1 + - @rocket.chat/ui-client@10.0.1 + - @rocket.chat/ui-video-conf@10.0.1 + - @rocket.chat/web-ui-registration@10.0.1 + - @rocket.chat/instance-status@0.1.7 +
+ ## 6.12.0 ### Minor Changes diff --git a/apps/meteor/app/2fa/server/code/EmailCheck.ts b/apps/meteor/app/2fa/server/code/EmailCheck.ts index d947c1b30c2e..5baf218a62bb 100644 --- a/apps/meteor/app/2fa/server/code/EmailCheck.ts +++ b/apps/meteor/app/2fa/server/code/EmailCheck.ts @@ -38,7 +38,7 @@ export class EmailCheck implements ICodeCheck { private async send2FAEmail(address: string, random: string, user: IUser): Promise { const language = user.language || settings.get('Language') || 'en'; - const t = (s: string): string => i18n.t(s, { lng: language }); + const t = i18n.getFixedT(language); await Mailer.send({ to: address, diff --git a/apps/meteor/app/2fa/server/functions/resetTOTP.ts b/apps/meteor/app/2fa/server/functions/resetTOTP.ts index 3be8ec7c8060..84426cd4f88d 100644 --- a/apps/meteor/app/2fa/server/functions/resetTOTP.ts +++ b/apps/meteor/app/2fa/server/functions/resetTOTP.ts @@ -22,7 +22,7 @@ const sendResetNotification = async function (uid: string): Promise { return; } - const t = (s: string): string => i18n.t(s, { lng: language }); + const t = i18n.getFixedT(language); const text = ` ${t('Your_TOTP_has_been_reset')} diff --git a/apps/meteor/app/api/server/v1/chat.ts b/apps/meteor/app/api/server/v1/chat.ts index c3a4f0ec1042..eaeb27c0d317 100644 --- a/apps/meteor/app/api/server/v1/chat.ts +++ b/apps/meteor/app/api/server/v1/chat.ts @@ -383,7 +383,7 @@ API.v1.addRoute( throw new Meteor.Error('error-emoji-param-not-provided', 'The required "emoji" param is missing.'); } - await executeSetReaction(this.userId, emoji, msg._id, this.bodyParams.shouldReact); + await executeSetReaction(this.userId, emoji, msg, this.bodyParams.shouldReact); return API.v1.success(); }, diff --git a/apps/meteor/app/api/server/v1/rooms.ts b/apps/meteor/app/api/server/v1/rooms.ts index f3e3e05ee9d8..71a2acf25c1b 100644 --- a/apps/meteor/app/api/server/v1/rooms.ts +++ b/apps/meteor/app/api/server/v1/rooms.ts @@ -41,7 +41,7 @@ import { findRoomsAvailableForTeams, } from '../lib/rooms'; -async function findRoomByIdOrName({ +export async function findRoomByIdOrName({ params, checkedArchived = true, }: { @@ -366,7 +366,12 @@ API.v1.addRoute( { authRequired: true, validateParams: isRoomsCleanHistoryProps }, { async post() { - const { _id } = await findRoomByIdOrName({ params: this.bodyParams }); + const room = await findRoomByIdOrName({ params: this.bodyParams }); + const { _id } = room; + + if (!room || !(await canAccessRoomAsync(room, { _id: this.userId }))) { + return API.v1.failure('User does not have access to the room [error-not-allowed]', 'error-not-allowed'); + } const { latest, @@ -421,7 +426,7 @@ API.v1.addRoute( const discussionParent = room.prid && (await Rooms.findOneById>(room.prid, { - projection: { name: 1, fname: 1, t: 1, prid: 1, u: 1 }, + projection: { name: 1, fname: 1, t: 1, prid: 1, u: 1, sidepanel: 1 }, })); const { team, parentRoom } = await Team.getRoomInfo(room); const parent = discussionParent || parentRoom; diff --git a/apps/meteor/app/api/server/v1/teams.ts b/apps/meteor/app/api/server/v1/teams.ts index f64f8c820575..acb6cba2bac7 100644 --- a/apps/meteor/app/api/server/v1/teams.ts +++ b/apps/meteor/app/api/server/v1/teams.ts @@ -11,6 +11,7 @@ import { isTeamsDeleteProps, isTeamsLeaveProps, isTeamsUpdateProps, + isTeamsListChildrenProps, } from '@rocket.chat/rest-typings'; import { escapeRegExp } from '@rocket.chat/string-helpers'; import { Match, check } from 'meteor/check'; @@ -375,6 +376,44 @@ API.v1.addRoute( }, ); +const getTeamByIdOrNameOrParentRoom = async ( + params: { teamId: string } | { teamName: string } | { roomId: string }, +): Promise | null> => { + if ('teamId' in params && params.teamId) { + return Team.getOneById(params.teamId, { projection: { type: 1, roomId: 1 } }); + } + if ('teamName' in params && params.teamName) { + return Team.getOneByName(params.teamName, { projection: { type: 1, roomId: 1 } }); + } + if ('roomId' in params && params.roomId) { + return Team.getOneByRoomId(params.roomId, { projection: { type: 1, roomId: 1 } }); + } + return null; +}; + +// This should accept a teamId, filter (search by name on rooms collection) and sort/pagination +// should return a list of rooms/discussions from the team. the discussions will only be returned from the main room +API.v1.addRoute( + 'teams.listChildren', + { authRequired: true, validateParams: isTeamsListChildrenProps }, + { + async get() { + const { offset, count } = await getPaginationItems(this.queryParams); + const { sort } = await this.parseJsonQuery(); + const { filter, type } = this.queryParams; + + const team = await getTeamByIdOrNameOrParentRoom(this.queryParams); + if (!team) { + return API.v1.notFound(); + } + + const data = await Team.listChildren(this.userId, team, filter, type, sort, offset, count); + + return API.v1.success({ ...data, offset, count }); + }, + }, +); + API.v1.addRoute( 'teams.members', { authRequired: true }, diff --git a/apps/meteor/app/authorization/server/constant/permissions.ts b/apps/meteor/app/authorization/server/constant/permissions.ts index e5e8f7fb05dd..46d40713bad1 100644 --- a/apps/meteor/app/authorization/server/constant/permissions.ts +++ b/apps/meteor/app/authorization/server/constant/permissions.ts @@ -101,6 +101,10 @@ export const permissions = [ _id: 'update-livechat-contact', roles: ['livechat-manager', 'livechat-monitor', 'livechat-agent', 'admin'], }, + { + _id: 'view-livechat-contact', + roles: ['livechat-manager', 'livechat-monitor', 'livechat-agent', 'admin'], + }, { _id: 'view-livechat-manager', roles: ['livechat-manager', 'livechat-monitor', 'admin'] }, { _id: 'view-omnichannel-contact-center', diff --git a/apps/meteor/app/authorization/server/functions/canDeleteMessage.ts b/apps/meteor/app/authorization/server/functions/canDeleteMessage.ts index 7cd953a52bb2..fea37fd1c2a5 100644 --- a/apps/meteor/app/authorization/server/functions/canDeleteMessage.ts +++ b/apps/meteor/app/authorization/server/functions/canDeleteMessage.ts @@ -1,7 +1,8 @@ -import type { IUser } from '@rocket.chat/core-typings'; +import type { IUser, IRoom } from '@rocket.chat/core-typings'; import { Rooms } from '@rocket.chat/models'; import { getValue } from '../../../settings/server/raw'; +import { canAccessRoomAsync } from './canAccessRoom'; import { hasPermissionAsync } from './hasPermission'; const elapsedTime = (ts: Date): number => { @@ -13,6 +14,25 @@ export const canDeleteMessageAsync = async ( uid: string, { u, rid, ts }: { u: Pick; rid: string; ts: Date }, ): Promise => { + const room = await Rooms.findOneById>(rid, { + projection: { + _id: 1, + ro: 1, + unmuted: 1, + t: 1, + teamId: 1, + prid: 1, + }, + }); + + if (!room) { + return false; + } + + if (!(await canAccessRoomAsync(room, { _id: uid }))) { + return false; + } + const forceDelete = await hasPermissionAsync(uid, 'force-delete-message', rid); if (forceDelete) { @@ -45,12 +65,6 @@ export const canDeleteMessageAsync = async ( } } - const room = await Rooms.findOneById(rid, { projection: { ro: 1, unmuted: 1 } }); - - if (!room) { - return false; - } - if (room.ro === true && !(await hasPermissionAsync(uid, 'post-readonly', rid))) { // Unless the user was manually unmuted if (u.username && !(room.unmuted || []).includes(u.username)) { diff --git a/apps/meteor/app/autotranslate/server/autotranslate.ts b/apps/meteor/app/autotranslate/server/autotranslate.ts index f3c6d9e55fdb..23e1b189a792 100644 --- a/apps/meteor/app/autotranslate/server/autotranslate.ts +++ b/apps/meteor/app/autotranslate/server/autotranslate.ts @@ -113,7 +113,12 @@ export class TranslationProviderRegistry { return; } - callbacks.add('afterSaveMessage', provider.translateMessage.bind(provider), callbacks.priority.MEDIUM, 'autotranslate'); + callbacks.add( + 'afterSaveMessage', + (message, { room }) => provider.translateMessage(message, { room }), + callbacks.priority.MEDIUM, + 'autotranslate', + ); } } diff --git a/apps/meteor/app/e2e/client/rocketchat.e2e.ts b/apps/meteor/app/e2e/client/rocketchat.e2e.ts index bbd6f208f35a..50224cb89dbb 100644 --- a/apps/meteor/app/e2e/client/rocketchat.e2e.ts +++ b/apps/meteor/app/e2e/client/rocketchat.e2e.ts @@ -6,6 +6,7 @@ import { isE2EEMessage } from '@rocket.chat/core-typings'; import { Emitter } from '@rocket.chat/emitter'; import EJSON from 'ejson'; import _ from 'lodash'; +import { Accounts } from 'meteor/accounts-base'; import { Meteor } from 'meteor/meteor'; import { Tracker } from 'meteor/tracker'; @@ -308,8 +309,8 @@ class E2E extends Emitter { getKeysFromLocalStorage(): KeyPair { return { - public_key: Meteor._localStorage.getItem('public_key'), - private_key: Meteor._localStorage.getItem('private_key'), + public_key: Accounts.storageLocation.getItem('public_key'), + private_key: Accounts.storageLocation.getItem('private_key'), }; } @@ -332,7 +333,7 @@ class E2E extends Emitter { imperativeModal.close(); }, onConfirm: () => { - Meteor._localStorage.removeItem('e2e.randomPassword'); + Accounts.storageLocation.removeItem('e2e.randomPassword'); this.setState(E2EEState.READY); dispatchToastMessage({ type: 'success', message: t('End_To_End_Encryption_Enabled') }); this.closeAlert(); @@ -394,7 +395,7 @@ class E2E extends Emitter { await this.persistKeys(this.getKeysFromLocalStorage(), await this.createRandomPassword()); } - const randomPassword = Meteor._localStorage.getItem('e2e.randomPassword'); + const randomPassword = Accounts.storageLocation.getItem('e2e.randomPassword'); if (randomPassword) { this.setState(E2EEState.SAVE_PASSWORD); this.openAlert({ @@ -412,8 +413,8 @@ class E2E extends Emitter { this.log('-> Stop Client'); this.closeAlert(); - Meteor._localStorage.removeItem('public_key'); - Meteor._localStorage.removeItem('private_key'); + Accounts.storageLocation.removeItem('public_key'); + Accounts.storageLocation.removeItem('private_key'); this.instancesByRoomId = {}; this.privateKey = undefined; this.started = false; @@ -425,8 +426,8 @@ class E2E extends Emitter { async changePassword(newPassword: string): Promise { await this.persistKeys(this.getKeysFromLocalStorage(), newPassword, { force: true }); - if (Meteor._localStorage.getItem('e2e.randomPassword')) { - Meteor._localStorage.setItem('e2e.randomPassword', newPassword); + if (Accounts.storageLocation.getItem('e2e.randomPassword')) { + Accounts.storageLocation.setItem('e2e.randomPassword', newPassword); } } @@ -447,12 +448,12 @@ class E2E extends Emitter { } async loadKeys({ public_key, private_key }: { public_key: string; private_key: string }): Promise { - Meteor._localStorage.setItem('public_key', public_key); + Accounts.storageLocation.setItem('public_key', public_key); try { this.privateKey = await importRSAKey(EJSON.parse(private_key), ['decrypt']); - Meteor._localStorage.setItem('private_key', private_key); + Accounts.storageLocation.setItem('private_key', private_key); } catch (error) { this.setState(E2EEState.ERROR); return this.error('Error importing private key: ', error); @@ -474,7 +475,7 @@ class E2E extends Emitter { try { const publicKey = await exportJWKKey(key.publicKey); - Meteor._localStorage.setItem('public_key', JSON.stringify(publicKey)); + Accounts.storageLocation.setItem('public_key', JSON.stringify(publicKey)); } catch (error) { this.setState(E2EEState.ERROR); return this.error('Error exporting public key: ', error); @@ -483,7 +484,7 @@ class E2E extends Emitter { try { const privateKey = await exportJWKKey(key.privateKey); - Meteor._localStorage.setItem('private_key', JSON.stringify(privateKey)); + Accounts.storageLocation.setItem('private_key', JSON.stringify(privateKey)); } catch (error) { this.setState(E2EEState.ERROR); return this.error('Error exporting private key: ', error); @@ -498,7 +499,7 @@ class E2E extends Emitter { async createRandomPassword(): Promise { const randomPassword = await generateMnemonicPhrase(5); - Meteor._localStorage.setItem('e2e.randomPassword', randomPassword); + Accounts.storageLocation.setItem('e2e.randomPassword', randomPassword); return randomPassword; } diff --git a/apps/meteor/app/e2e/server/functions/handleSuggestedGroupKey.ts b/apps/meteor/app/e2e/server/functions/handleSuggestedGroupKey.ts index 22eccf03f407..e498d282d704 100644 --- a/apps/meteor/app/e2e/server/functions/handleSuggestedGroupKey.ts +++ b/apps/meteor/app/e2e/server/functions/handleSuggestedGroupKey.ts @@ -1,7 +1,7 @@ import { Rooms, Subscriptions } from '@rocket.chat/models'; import { Meteor } from 'meteor/meteor'; -import { notifyOnSubscriptionChangedById } from '../../../lib/server/lib/notifyListener'; +import { notifyOnSubscriptionChangedById, notifyOnRoomChangedById } from '../../../lib/server/lib/notifyListener'; export async function handleSuggestedGroupKey( handle: 'accept' | 'reject', @@ -25,11 +25,17 @@ export async function handleSuggestedGroupKey( if (handle === 'accept') { await Subscriptions.setGroupE2EKey(sub._id, suggestedKey); - await Rooms.removeUsersFromE2EEQueueByRoomId(sub.rid, [userId]); + const { modifiedCount } = await Rooms.removeUsersFromE2EEQueueByRoomId(sub.rid, [userId]); + if (modifiedCount) { + void notifyOnRoomChangedById(sub.rid); + } } if (handle === 'reject') { - await Rooms.addUserIdToE2EEQueueByRoomIds([sub.rid], userId); + const { modifiedCount } = await Rooms.addUserIdToE2EEQueueByRoomIds([sub.rid], userId); + if (modifiedCount) { + void notifyOnRoomChangedById(sub.rid); + } } const { modifiedCount } = await Subscriptions.unsetGroupE2ESuggestedKey(sub._id); diff --git a/apps/meteor/app/e2e/server/methods/setUserPublicAndPrivateKeys.ts b/apps/meteor/app/e2e/server/methods/setUserPublicAndPrivateKeys.ts index 6ef35a063a28..45a00886af1e 100644 --- a/apps/meteor/app/e2e/server/methods/setUserPublicAndPrivateKeys.ts +++ b/apps/meteor/app/e2e/server/methods/setUserPublicAndPrivateKeys.ts @@ -2,6 +2,8 @@ import type { ServerMethods } from '@rocket.chat/ddp-client'; import { Rooms, Users } from '@rocket.chat/models'; import { Meteor } from 'meteor/meteor'; +import { notifyOnRoomChangedById } from '../../../lib/server/lib/notifyListener'; + declare module '@rocket.chat/ddp-client' { // eslint-disable-next-line @typescript-eslint/naming-convention interface ServerMethods { @@ -36,5 +38,7 @@ Meteor.methods({ const subscribedRoomIds = await Rooms.getSubscribedRoomIdsWithoutE2EKeys(userId); await Rooms.addUserIdToE2EEQueueByRoomIds(subscribedRoomIds, userId); + + void notifyOnRoomChangedById(subscribedRoomIds); }, }); diff --git a/apps/meteor/app/error-handler/server/lib/RocketChat.ErrorHandler.ts b/apps/meteor/app/error-handler/server/lib/RocketChat.ErrorHandler.ts index 264443a1378b..984561fe13cd 100644 --- a/apps/meteor/app/error-handler/server/lib/RocketChat.ErrorHandler.ts +++ b/apps/meteor/app/error-handler/server/lib/RocketChat.ErrorHandler.ts @@ -3,14 +3,13 @@ import { Meteor } from 'meteor/meteor'; import { throttledCounter } from '../../../../lib/utils/throttledCounter'; import { sendMessage } from '../../../lib/server/functions/sendMessage'; -import { notifyOnSettingChanged } from '../../../lib/server/lib/notifyListener'; import { settings } from '../../../settings/server'; const incException = throttledCounter((counter) => { Settings.incrementValueById('Uncaught_Exceptions_Count', counter, { returnDocument: 'after' }) .then(({ value }) => { if (value) { - void notifyOnSettingChanged(value); + settings.set(value); } }) .catch(console.error); @@ -118,5 +117,12 @@ process.on('unhandledRejection', (error) => { process.on('uncaughtException', async (error) => { incException(); + + console.error('=== UnCaughtException ==='); + console.error(error); + console.error('-------------------------'); + console.error('Errors like this can cause oplog processing errors.'); + console.error('==========================='); + void errorHandler.trackError(error.message, error.stack); }); diff --git a/apps/meteor/app/federation/server/hooks/afterUnsetReaction.js b/apps/meteor/app/federation/server/hooks/afterUnsetReaction.js index 51181d88ab9e..995146b290bf 100644 --- a/apps/meteor/app/federation/server/hooks/afterUnsetReaction.js +++ b/apps/meteor/app/federation/server/hooks/afterUnsetReaction.js @@ -6,7 +6,7 @@ import { getFederationDomain } from '../lib/getFederationDomain'; import { clientLogger } from '../lib/logger'; async function afterUnsetReaction(message, { user, reaction }) { - const room = Rooms.findOneById(message.rid, { fields: { federation: 1 } }); + const room = await Rooms.findOneById(message.rid, { projection: { federation: 1 } }); // If there are not federated users on this room, ignore it if (!hasExternalDomain(room)) { diff --git a/apps/meteor/app/integrations/server/triggers.ts b/apps/meteor/app/integrations/server/triggers.ts index cdf8acda6a21..64b95827645f 100644 --- a/apps/meteor/app/integrations/server/triggers.ts +++ b/apps/meteor/app/integrations/server/triggers.ts @@ -8,7 +8,12 @@ const callbackHandler = function _callbackHandler(eventType: string) { }; }; -callbacks.add('afterSaveMessage', callbackHandler('sendMessage'), callbacks.priority.LOW, 'integrations-sendMessage'); +callbacks.add( + 'afterSaveMessage', + (message, { room }) => callbackHandler('sendMessage')(message, room), + callbacks.priority.LOW, + 'integrations-sendMessage', +); callbacks.add('afterCreateChannel', callbackHandler('roomCreated'), callbacks.priority.LOW, 'integrations-roomCreated'); callbacks.add('afterCreatePrivateGroup', callbackHandler('roomCreated'), callbacks.priority.LOW, 'integrations-roomCreated'); callbacks.add('afterCreateUser', callbackHandler('userCreated'), callbacks.priority.LOW, 'integrations-userCreated'); diff --git a/apps/meteor/app/irc/server/irc-bridge/index.js b/apps/meteor/app/irc/server/irc-bridge/index.js index 09b7a3568362..bc5b4f0bc33f 100644 --- a/apps/meteor/app/irc/server/irc-bridge/index.js +++ b/apps/meteor/app/irc/server/irc-bridge/index.js @@ -209,7 +209,7 @@ class Bridge { // Chatting callbacks.add( 'afterSaveMessage', - this.onMessageReceived.bind(this, 'local', 'onSaveMessage'), + (message, { room }) => this.onMessageReceived('local', 'onSaveMessage', message, room), callbacks.priority.LOW, 'irc-on-save-message', ); diff --git a/apps/meteor/app/lib/client/methods/sendMessage.ts b/apps/meteor/app/lib/client/methods/sendMessage.ts index bdaca587493a..19220f901458 100644 --- a/apps/meteor/app/lib/client/methods/sendMessage.ts +++ b/apps/meteor/app/lib/client/methods/sendMessage.ts @@ -43,7 +43,7 @@ Meteor.methods({ await onClientMessageReceived(message as IMessage).then((message) => { ChatMessage.insert(message); - return callbacks.run('afterSaveMessage', message, room); + return callbacks.run('afterSaveMessage', message, { room }); }); }, }); diff --git a/apps/meteor/app/lib/server/functions/addUserToRoom.ts b/apps/meteor/app/lib/server/functions/addUserToRoom.ts index e6ca7b2a8b4d..2e74bf66cf4a 100644 --- a/apps/meteor/app/lib/server/functions/addUserToRoom.ts +++ b/apps/meteor/app/lib/server/functions/addUserToRoom.ts @@ -97,8 +97,6 @@ export const addUserToRoom = async function ( void notifyOnSubscriptionChangedById(insertedId, 'inserted'); } - void notifyOnRoomChangedById(rid); - if (!userToBeAdded.username) { throw new Meteor.Error('error-invalid-user', 'Cannot add an user to a room without a username'); } @@ -147,5 +145,6 @@ export const addUserToRoom = async function ( await Rooms.addUserIdToE2EEQueueByRoomIds([room._id], userToBeAdded._id); } + void notifyOnRoomChangedById(rid); return true; }; diff --git a/apps/meteor/app/lib/server/functions/isTheLastMessage.ts b/apps/meteor/app/lib/server/functions/isTheLastMessage.ts index f8e5be94002c..f1f1fb4c1497 100644 --- a/apps/meteor/app/lib/server/functions/isTheLastMessage.ts +++ b/apps/meteor/app/lib/server/functions/isTheLastMessage.ts @@ -1,7 +1,7 @@ -import type { IMessage, IRoom } from '@rocket.chat/core-typings'; +import type { IMessage, IRoom, AtLeast } from '@rocket.chat/core-typings'; import { settings } from '../../../settings/server'; // eslint-disable-next-line @typescript-eslint/explicit-function-return-type -export const isTheLastMessage = (room: IRoom, message: Pick) => +export const isTheLastMessage = (room: AtLeast, message: Pick) => settings.get('Store_Last_Message') && (!room.lastMessage || room.lastMessage._id === message._id); diff --git a/apps/meteor/app/lib/server/functions/setUsername.ts b/apps/meteor/app/lib/server/functions/setUsername.ts index 5b2b1923da75..c4d2c47c6d9d 100644 --- a/apps/meteor/app/lib/server/functions/setUsername.ts +++ b/apps/meteor/app/lib/server/functions/setUsername.ts @@ -102,23 +102,22 @@ export const _setUsername = async function (userId: string, u: string, fullUser: // Set new username* await Users.setUsername(user._id, username); user.username = username; + if (!previousUsername && settings.get('Accounts_SetDefaultAvatar') === true) { - // eslint-disable-next-line @typescript-eslint/ban-types - const avatarSuggestions = (await getAvatarSuggestionForUser(user)) as {}; - let gravatar; - for await (const service of Object.keys(avatarSuggestions)) { - const avatarData = avatarSuggestions[+service as keyof typeof avatarSuggestions]; + const avatarSuggestions = await getAvatarSuggestionForUser(user); + let avatarData; + let serviceName = 'gravatar'; + + for (const service of Object.keys(avatarSuggestions)) { + avatarData = avatarSuggestions[service]; if (service !== 'gravatar') { - // eslint-disable-next-line dot-notation - await setUserAvatar(user, avatarData['blob'], avatarData['contentType'], service); - gravatar = null; + serviceName = service; break; } - gravatar = avatarData; } - if (gravatar != null) { - // eslint-disable-next-line dot-notation - await setUserAvatar(user, gravatar['blob'], gravatar['contentType'], 'gravatar'); + + if (avatarData) { + await setUserAvatar(user, avatarData.blob, avatarData.contentType, serviceName); } } diff --git a/apps/meteor/app/lib/server/methods/addUsersToRoom.ts b/apps/meteor/app/lib/server/methods/addUsersToRoom.ts index 73fbf6e51a04..119071e8ece0 100644 --- a/apps/meteor/app/lib/server/methods/addUsersToRoom.ts +++ b/apps/meteor/app/lib/server/methods/addUsersToRoom.ts @@ -98,14 +98,11 @@ export const addUsersToRoomMethod = async (userId: string, data: { rid: string; return; } void api.broadcast('notify.ephemeralMessage', userId, data.rid, { - msg: i18n.t( - 'Username_is_already_in_here', - { - postProcess: 'sprintf', - sprintf: [newUser.username], - }, - user?.language, - ), + msg: i18n.t('Username_is_already_in_here', { + postProcess: 'sprintf', + sprintf: [newUser.username], + lng: user?.language, + }), }); } }), diff --git a/apps/meteor/app/lib/server/methods/cleanRoomHistory.ts b/apps/meteor/app/lib/server/methods/cleanRoomHistory.ts index d6136eee9131..c804128d27bd 100644 --- a/apps/meteor/app/lib/server/methods/cleanRoomHistory.ts +++ b/apps/meteor/app/lib/server/methods/cleanRoomHistory.ts @@ -2,6 +2,8 @@ import type { ServerMethods } from '@rocket.chat/ddp-client'; import { Match, check } from 'meteor/check'; import { Meteor } from 'meteor/meteor'; +import { findRoomByIdOrName } from '../../../api/server/v1/rooms'; +import { canAccessRoomAsync } from '../../../authorization/server'; import { hasPermissionAsync } from '../../../authorization/server/functions/hasPermission'; import { cleanRoomHistory } from '../functions/cleanRoomHistory'; @@ -56,6 +58,12 @@ Meteor.methods({ throw new Meteor.Error('error-not-allowed', 'Not allowed', { method: 'cleanRoomHistory' }); } + const room = await findRoomByIdOrName({ params: { roomId } }); + + if (!room || !(await canAccessRoomAsync(room, { _id: userId }))) { + throw new Meteor.Error('error-not-allowed', 'Not allowed', { method: 'cleanRoomHistory' }); + } + return cleanRoomHistory({ rid: roomId, latest, diff --git a/apps/meteor/app/lib/server/methods/sendMessage.ts b/apps/meteor/app/lib/server/methods/sendMessage.ts index 86faeee556ec..bce67a7b81d2 100644 --- a/apps/meteor/app/lib/server/methods/sendMessage.ts +++ b/apps/meteor/app/lib/server/methods/sendMessage.ts @@ -2,6 +2,7 @@ import { api } from '@rocket.chat/core-services'; import type { AtLeast, IMessage, IUser } from '@rocket.chat/core-typings'; import type { ServerMethods } from '@rocket.chat/ddp-client'; import { Messages, Users } from '@rocket.chat/models'; +import type { TOptions } from 'i18next'; import { check } from 'meteor/check'; import { Meteor } from 'meteor/meteor'; import moment from 'moment'; @@ -99,9 +100,9 @@ export async function executeSendMessage(uid: IUser['_id'], message: AtLeast void; @@ -9,7 +9,7 @@ type PlaceChatOnHoldModalProps = { }; const PlaceChatOnHoldModal = ({ onCancel, onOnHoldChat, confirm = onOnHoldChat, ...props }: PlaceChatOnHoldModalProps) => { - const t = useTranslation(); + const { t } = useTranslation(); return ( diff --git a/apps/meteor/app/livechat/imports/server/rest/departments.ts b/apps/meteor/app/livechat/imports/server/rest/departments.ts index 252a83855700..e56feeac2fa3 100644 --- a/apps/meteor/app/livechat/imports/server/rest/departments.ts +++ b/apps/meteor/app/livechat/imports/server/rest/departments.ts @@ -57,10 +57,18 @@ API.v1.addRoute( check(this.bodyParams, { department: Object, agents: Match.Maybe(Array), + departmentUnit: Match.Maybe({ _id: Match.Optional(String) }), }); const agents = this.bodyParams.agents ? { upsert: this.bodyParams.agents } : {}; - const department = await LivechatTs.saveDepartment(null, this.bodyParams.department as ILivechatDepartment, agents); + const { departmentUnit } = this.bodyParams; + const department = await LivechatTs.saveDepartment( + this.userId, + null, + this.bodyParams.department as ILivechatDepartment, + agents, + departmentUnit || {}, + ); if (department) { return API.v1.success({ @@ -112,17 +120,18 @@ API.v1.addRoute( check(this.bodyParams, { department: Object, agents: Match.Maybe(Array), + departmentUnit: Match.Maybe({ _id: Match.Optional(String) }), }); const { _id } = this.urlParams; - const { department, agents } = this.bodyParams; + const { department, agents, departmentUnit } = this.bodyParams; if (!permissionToSave) { throw new Error('error-not-allowed'); } const agentParam = permissionToAddAgents && agents ? { upsert: agents } : {}; - await LivechatTs.saveDepartment(_id, department, agentParam); + await LivechatTs.saveDepartment(this.userId, _id, department, agentParam, departmentUnit || {}); return API.v1.success({ department: await LivechatDepartment.findOneById(_id), diff --git a/apps/meteor/app/livechat/server/api/v1/contact.ts b/apps/meteor/app/livechat/server/api/v1/contact.ts index 7e9457d2f185..ec0559d5f191 100644 --- a/apps/meteor/app/livechat/server/api/v1/contact.ts +++ b/apps/meteor/app/livechat/server/api/v1/contact.ts @@ -1,11 +1,15 @@ -import { LivechatCustomField, LivechatVisitors } from '@rocket.chat/models'; -import { isPOSTOmnichannelContactsProps, isPOSTUpdateOmnichannelContactsProps } from '@rocket.chat/rest-typings'; +import { LivechatContacts, LivechatCustomField, LivechatVisitors } from '@rocket.chat/models'; +import { + isPOSTOmnichannelContactsProps, + isPOSTUpdateOmnichannelContactsProps, + isGETOmnichannelContactsProps, +} from '@rocket.chat/rest-typings'; import { escapeRegExp } from '@rocket.chat/string-helpers'; import { Match, check } from 'meteor/check'; import { Meteor } from 'meteor/meteor'; import { API } from '../../../../api/server'; -import { Contacts, createContact, updateContact } from '../../lib/Contacts'; +import { Contacts, createContact, updateContact, isSingleContactEnabled } from '../../lib/Contacts'; API.v1.addRoute( 'omnichannel/contact', @@ -92,8 +96,8 @@ API.v1.addRoute( { authRequired: true, permissionsRequired: ['create-livechat-contact'], validateParams: isPOSTOmnichannelContactsProps }, { async post() { - if (process.env.TEST_MODE?.toUpperCase() !== 'TRUE') { - throw new Meteor.Error('error-not-allowed', 'This endpoint is only allowed in test mode'); + if (!isSingleContactEnabled()) { + return API.v1.unauthorized(); } const contactId = await createContact({ ...this.bodyParams, unknown: false }); @@ -101,13 +105,14 @@ API.v1.addRoute( }, }, ); + API.v1.addRoute( 'omnichannel/contacts.update', { authRequired: true, permissionsRequired: ['update-livechat-contact'], validateParams: isPOSTUpdateOmnichannelContactsProps }, { async post() { - if (process.env.TEST_MODE?.toUpperCase() !== 'TRUE') { - throw new Meteor.Error('error-not-allowed', 'This endpoint is only allowed in test mode'); + if (!isSingleContactEnabled()) { + return API.v1.unauthorized(); } const contact = await updateContact({ ...this.bodyParams }); @@ -116,3 +121,18 @@ API.v1.addRoute( }, }, ); + +API.v1.addRoute( + 'omnichannel/contacts.get', + { authRequired: true, permissionsRequired: ['view-livechat-contact'], validateParams: isGETOmnichannelContactsProps }, + { + async get() { + if (!isSingleContactEnabled()) { + return API.v1.unauthorized(); + } + const contact = await LivechatContacts.findOneById(this.queryParams.contactId); + + return API.v1.success({ contact }); + }, + }, +); diff --git a/apps/meteor/app/livechat/server/api/v1/room.ts b/apps/meteor/app/livechat/server/api/v1/room.ts index 7aacfacb4476..404f576ea513 100644 --- a/apps/meteor/app/livechat/server/api/v1/room.ts +++ b/apps/meteor/app/livechat/server/api/v1/room.ts @@ -159,7 +159,7 @@ API.v1.addRoute( const visitorEmail = visitor.visitorEmails?.[0]?.address; const language = servingAgent.language || rcSettings.get('Language') || 'en'; - const t = (s: string): string => i18n.t(s, { lng: language }); + const t = i18n.getFixedT(language); const subject = t('Transcript_of_your_livechat_conversation'); options.emailTranscript = { diff --git a/apps/meteor/app/livechat/server/lib/Contacts.ts b/apps/meteor/app/livechat/server/lib/Contacts.ts index f6f812ce8af8..e9be40aa942b 100644 --- a/apps/meteor/app/livechat/server/lib/Contacts.ts +++ b/apps/meteor/app/livechat/server/lib/Contacts.ts @@ -1,4 +1,5 @@ import type { + AtLeast, ILivechatContact, ILivechatContactChannel, ILivechatCustomField, @@ -113,41 +114,8 @@ export const Contacts = { } } - const allowedCF = LivechatCustomField.findByScope>( - 'visitor', - { - projection: { _id: 1, label: 1, regexp: 1, required: 1 }, - }, - false, - ); - - const livechatData: Record = {}; - - for await (const cf of allowedCF) { - if (!customFields.hasOwnProperty(cf._id)) { - if (cf.required) { - throw new Error(i18n.t('error-invalid-custom-field-value', { field: cf.label })); - } - continue; - } - const cfValue: string = trim(customFields[cf._id]); - - if (!cfValue || typeof cfValue !== 'string') { - if (cf.required) { - throw new Error(i18n.t('error-invalid-custom-field-value', { field: cf.label })); - } - continue; - } - - if (cf.regexp) { - const regex = new RegExp(cf.regexp); - if (!regex.test(cfValue)) { - throw new Error(i18n.t('error-invalid-custom-field-value', { field: cf.label })); - } - } - - livechatData[cf._id] = cfValue; - } + const allowedCF = await getAllowedCustomFields(); + const livechatData: Record = validateCustomFields(allowedCF, customFields, { ignoreAdditionalFields: true }); const fieldsToRemove = { // if field is explicitely set to empty string, remove @@ -202,15 +170,20 @@ export const Contacts = { }, }; +export function isSingleContactEnabled(): boolean { + // The Single Contact feature is not yet available in production, but can already be partially used in test environments. + return process.env.TEST_MODE?.toUpperCase() === 'TRUE'; +} + export async function createContact(params: CreateContactParams): Promise { - const { name, emails, phones, customFields = {}, contactManager, channels, unknown } = params; + const { name, emails, phones, customFields: receivedCustomFields = {}, contactManager, channels, unknown } = params; if (contactManager) { await validateContactManager(contactManager); } const allowedCustomFields = await getAllowedCustomFields(); - validateCustomFields(allowedCustomFields, customFields); + const customFields = validateCustomFields(allowedCustomFields, receivedCustomFields); const { insertedId } = await LivechatContacts.insertOne({ name, @@ -226,7 +199,7 @@ export async function createContact(params: CreateContactParams): Promise { - const { contactId, name, emails, phones, customFields, contactManager, channels } = params; + const { contactId, name, emails, phones, customFields: receivedCustomFields, contactManager, channels } = params; const contact = await LivechatContacts.findOneById>(contactId, { projection: { _id: 1 } }); @@ -238,17 +211,21 @@ export async function updateContact(params: UpdateContactParams): Promise { +async function getAllowedCustomFields(): Promise[]> { return LivechatCustomField.findByScope( 'visitor', { @@ -258,7 +235,13 @@ async function getAllowedCustomFields(): Promise { ).toArray(); } -export function validateCustomFields(allowedCustomFields: ILivechatCustomField[], customFields: Record) { +export function validateCustomFields( + allowedCustomFields: AtLeast[], + customFields: Record, + options?: { ignoreAdditionalFields?: boolean }, +): Record { + const validValues: Record = {}; + for (const cf of allowedCustomFields) { if (!customFields.hasOwnProperty(cf._id)) { if (cf.required) { @@ -281,14 +264,20 @@ export function validateCustomFields(allowedCustomFields: ILivechatCustomField[] throw new Error(i18n.t('error-invalid-custom-field-value', { field: cf.label })); } } + + validValues[cf._id] = cfValue; } - const allowedCustomFieldIds = new Set(allowedCustomFields.map((cf) => cf._id)); - for (const key in customFields) { - if (!allowedCustomFieldIds.has(key)) { - throw new Error(i18n.t('error-custom-field-not-allowed', { key })); + if (!options?.ignoreAdditionalFields) { + const allowedCustomFieldIds = new Set(allowedCustomFields.map((cf) => cf._id)); + for (const key in customFields) { + if (!allowedCustomFieldIds.has(key)) { + throw new Error(i18n.t('error-custom-field-not-allowed', { key })); + } } } + + return validValues; } export async function validateContactManager(contactManagerUserId: string) { diff --git a/apps/meteor/app/livechat/server/lib/Helper.ts b/apps/meteor/app/livechat/server/lib/Helper.ts index 17f21d8d7b04..017eb516a487 100644 --- a/apps/meteor/app/livechat/server/lib/Helper.ts +++ b/apps/meteor/app/livechat/server/lib/Helper.ts @@ -439,8 +439,8 @@ export const dispatchInquiryQueued = async (inquiry: ILivechatInquiryRecord, age hasMentionToHere: false, message: { _id: '', u: v, msg: '' }, // we should use server's language for this type of messages instead of user's - notificationMessage: i18n.t('User_started_a_new_conversation', { username: notificationUserName }, language), - room: Object.assign(room, { name: i18n.t('New_chat_in_queue', {}, language) }), + notificationMessage: i18n.t('User_started_a_new_conversation', { username: notificationUserName, lng: language }), + room: Object.assign(room, { name: i18n.t('New_chat_in_queue', { lng: language }) }), mentionIds: [], }); } diff --git a/apps/meteor/app/livechat/server/lib/LivechatTyped.ts b/apps/meteor/app/livechat/server/lib/LivechatTyped.ts index 89d125033977..44ee46f04418 100644 --- a/apps/meteor/app/livechat/server/lib/LivechatTyped.ts +++ b/apps/meteor/app/livechat/server/lib/LivechatTyped.ts @@ -71,7 +71,7 @@ import * as Mailer from '../../../mailer/server/api'; import { metrics } from '../../../metrics/server'; import { settings } from '../../../settings/server'; import { businessHourManager } from '../business-hour'; -import { createContact } from './Contacts'; +import { createContact, isSingleContactEnabled } from './Contacts'; import { parseAgentCustomFields, updateDepartmentAgents, validateEmail, normalizeTransferredByData } from './Helper'; import { QueueManager } from './QueueManager'; import { RoutingManager } from './RoutingManager'; @@ -589,6 +589,10 @@ class LivechatClass { } } + isValidObject(obj: unknown): obj is Record { + return typeof obj === 'object' && obj !== null; + } + async registerGuest({ id, token, @@ -654,10 +658,10 @@ class LivechatClass { visitorDataToUpdate.status = status; visitorDataToUpdate.ts = new Date(); - if (settings.get('Livechat_Allow_collect_and_store_HTTP_header_informations')) { + if (settings.get('Livechat_Allow_collect_and_store_HTTP_header_informations') && Livechat.isValidObject(connectionData)) { Livechat.logger.debug(`Saving connection data for visitor ${token}`); const { httpHeaders, clientAddress } = connectionData; - if (httpHeaders) { + if (Livechat.isValidObject(httpHeaders)) { visitorDataToUpdate.userAgent = httpHeaders['user-agent']; visitorDataToUpdate.ip = httpHeaders['x-real-ip'] || httpHeaders['x-forwarded-for'] || clientAddress; visitorDataToUpdate.host = httpHeaders?.host; @@ -665,7 +669,7 @@ class LivechatClass { } } - if (process.env.TEST_MODE?.toUpperCase() === 'TRUE') { + if (isSingleContactEnabled()) { const contactId = await createContact({ name: name ?? (visitorDataToUpdate.username as string), emails: email ? [email] : [], @@ -1789,18 +1793,37 @@ class LivechatClass { * @param {string|null} _id - The department id * @param {Partial} departmentData * @param {{upsert?: { agentId: string; count?: number; order?: number; }[], remove?: { agentId: string; count?: number; order?: number; }}} [departmentAgents] - The department agents + * @param {{_id?: string}} [departmentUnit] - The department's unit id */ async saveDepartment( + userId: string, _id: string | null, departmentData: LivechatDepartmentDTO, departmentAgents?: { upsert?: { agentId: string; count?: number; order?: number }[]; remove?: { agentId: string; count?: number; order?: number }; }, + departmentUnit?: { _id?: string }, ) { check(_id, Match.Maybe(String)); + if (departmentUnit?._id !== undefined && typeof departmentUnit._id !== 'string') { + throw new Meteor.Error('error-invalid-department-unit', 'Invalid department unit id provided', { + method: 'livechat:saveDepartment', + }); + } + + const department = _id + ? await LivechatDepartment.findOneById(_id, { projection: { _id: 1, archived: 1, enabled: 1, parentId: 1 } }) + : null; - const department = _id ? await LivechatDepartment.findOneById(_id, { projection: { _id: 1, archived: 1, enabled: 1 } }) : null; + if (departmentUnit && !departmentUnit._id && department && department.parentId) { + const isLastDepartmentInUnit = (await LivechatDepartment.countDepartmentsInUnit(department.parentId)) === 1; + if (isLastDepartmentInUnit) { + throw new Meteor.Error('error-unit-cant-be-empty', "The last department in a unit can't be removed", { + method: 'livechat:saveDepartment', + }); + } + } if (!department && !(await isDepartmentCreationAvailable())) { throw new Meteor.Error('error-max-departments-number-reached', 'Maximum number of departments reached', { @@ -1887,6 +1910,10 @@ class LivechatClass { await callbacks.run('livechat.afterDepartmentDisabled', departmentDB); } + if (departmentUnit) { + await callbacks.run('livechat.manageDepartmentUnit', { userId, departmentId: departmentDB._id, unitId: departmentUnit._id }); + } + return departmentDB; } } diff --git a/apps/meteor/app/livechat/server/lib/QueueManager.ts b/apps/meteor/app/livechat/server/lib/QueueManager.ts index e1ea79d84163..c6728d470870 100644 --- a/apps/meteor/app/livechat/server/lib/QueueManager.ts +++ b/apps/meteor/app/livechat/server/lib/QueueManager.ts @@ -371,8 +371,8 @@ export class QueueManager { hasMentionToHere: false, message: { _id: '', u: v, msg: '' }, // we should use server's language for this type of messages instead of user's - notificationMessage: i18n.t('User_started_a_new_conversation', { username: notificationUserName }, language), - room: { ...room, name: i18n.t('New_chat_in_queue', {}, language) }, + notificationMessage: i18n.t('User_started_a_new_conversation', { username: notificationUserName, lng: language }), + room: { ...room, name: i18n.t('New_chat_in_queue', { lng: language }) }, mentionIds: [], }); } diff --git a/apps/meteor/app/livechat/server/lib/RoutingManager.ts b/apps/meteor/app/livechat/server/lib/RoutingManager.ts index f4a2288305e5..28e5c72efc16 100644 --- a/apps/meteor/app/livechat/server/lib/RoutingManager.ts +++ b/apps/meteor/app/livechat/server/lib/RoutingManager.ts @@ -265,11 +265,20 @@ export const RoutingManager: Routing = { logger.info(`Inquiry ${inquiry._id} taken by agent ${agent.agentId}`); + // assignAgent changes the room data to add the agent serving the conversation. afterTakeInquiry expects room object to be updated + const inq = await this.assignAgent(inquiry as InquiryWithAgentInfo, room, agent); + const roomAfterUpdate = await LivechatRooms.findOneById(rid); + + if (!roomAfterUpdate) { + // This should never happen + throw new Error('error-room-not-found'); + } + callbacks.runAsync( 'livechat.afterTakeInquiry', { - inquiry: await this.assignAgent(inquiry as InquiryWithAgentInfo, room, agent), - room, + inquiry: inq, + room: roomAfterUpdate, }, agent, ); @@ -282,7 +291,7 @@ export const RoutingManager: Routing = { queuedAt: undefined, }); - return LivechatRooms.findOneById(rid); + return roomAfterUpdate; }, async transferRoom(room, guest, transferData) { diff --git a/apps/meteor/app/livechat/server/methods/saveDepartment.ts b/apps/meteor/app/livechat/server/methods/saveDepartment.ts index b4833523ab3f..659f85f49945 100644 --- a/apps/meteor/app/livechat/server/methods/saveDepartment.ts +++ b/apps/meteor/app/livechat/server/methods/saveDepartment.ts @@ -30,12 +30,13 @@ declare module '@rocket.chat/ddp-client' { order?: number | undefined; }[] | undefined, + departmentUnit?: { _id?: string }, ) => ILivechatDepartment; } } Meteor.methods({ - async 'livechat:saveDepartment'(_id, departmentData, departmentAgents) { + async 'livechat:saveDepartment'(_id, departmentData, departmentAgents, departmentUnit) { const uid = Meteor.userId(); if (!uid || !(await hasPermissionAsync(uid, 'manage-livechat-departments'))) { throw new Meteor.Error('error-not-allowed', 'Not allowed', { @@ -43,6 +44,6 @@ Meteor.methods({ }); } - return Livechat.saveDepartment(_id, departmentData, { upsert: departmentAgents }); + return Livechat.saveDepartment(uid, _id, departmentData, { upsert: departmentAgents }, departmentUnit); }, }); diff --git a/apps/meteor/app/mentions/server/Mentions.ts b/apps/meteor/app/mentions/server/Mentions.ts index 9eda56fea21c..779af2087932 100644 --- a/apps/meteor/app/mentions/server/Mentions.ts +++ b/apps/meteor/app/mentions/server/Mentions.ts @@ -2,7 +2,7 @@ * 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 { isE2EEMessage, type IMessage, type IRoom, type IUser } from '@rocket.chat/core-typings'; import { type MentionsParserArgs, MentionsParser } from '../lib/MentionsParser'; @@ -43,8 +43,13 @@ export class MentionsServer extends MentionsParser { }); } - async getUsersByMentions({ msg, rid, u: sender }: Pick): Promise { - const mentions = this.getUserMentions(msg); + async getUsersByMentions(message: IMessage): Promise { + const { msg, rid, u: sender, e2eMentions }: Pick = message; + + const mentions = + isE2EEMessage(message) && e2eMentions?.e2eUserMentions && e2eMentions?.e2eUserMentions.length > 0 + ? e2eMentions?.e2eUserMentions + : this.getUserMentions(msg); const mentionsAll: { _id: string; username: string }[] = []; const userMentions = []; @@ -67,8 +72,13 @@ export class MentionsServer extends MentionsParser { return [...mentionsAll, ...(userMentions.length ? await this.getUsers(userMentions) : [])]; } - async getChannelbyMentions({ msg }: Pick) { - const channels = this.getChannelMentions(msg); + async getChannelbyMentions(message: IMessage) { + const { msg, e2eMentions }: Pick = message; + + const channels = + isE2EEMessage(message) && e2eMentions?.e2eChannelMentions && e2eMentions?.e2eChannelMentions.length > 0 + ? e2eMentions?.e2eChannelMentions + : this.getChannelMentions(msg); return this.getChannels(channels.map((c) => c.trim().substr(1))); } diff --git a/apps/meteor/app/message-mark-as-unread/client/actionButton.ts b/apps/meteor/app/message-mark-as-unread/client/actionButton.ts index fc4a9d80c43c..e1e35d216029 100644 --- a/apps/meteor/app/message-mark-as-unread/client/actionButton.ts +++ b/apps/meteor/app/message-mark-as-unread/client/actionButton.ts @@ -19,7 +19,6 @@ Meteor.startup(() => { const { message = messageArgs(this).msg } = props; try { - await sdk.call('unreadMessages', message); const subscription = ChatSubscription.findOne({ rid: message.rid, }); @@ -27,8 +26,9 @@ Meteor.startup(() => { if (subscription == null) { return; } + router.navigate('/home'); await LegacyRoomManager.close(subscription.t + subscription.name); - return router.navigate('/home'); + await sdk.call('unreadMessages', message); } catch (error) { dispatchToastMessage({ type: 'error', message: error }); } diff --git a/apps/meteor/app/otr/server/methods/updateOTRAck.ts b/apps/meteor/app/otr/server/methods/updateOTRAck.ts index 4fbd182e9d27..64e5e97fa4e5 100644 --- a/apps/meteor/app/otr/server/methods/updateOTRAck.ts +++ b/apps/meteor/app/otr/server/methods/updateOTRAck.ts @@ -1,8 +1,12 @@ import { api } from '@rocket.chat/core-services'; import type { IOTRMessage } from '@rocket.chat/core-typings'; import type { ServerMethods } from '@rocket.chat/ddp-client'; +import { Rooms } from '@rocket.chat/models'; +import { check } from 'meteor/check'; import { Meteor } from 'meteor/meteor'; +import { canAccessRoomAsync } from '../../../authorization/server/functions/canAccessRoom'; + declare module '@rocket.chat/ddp-client' { // eslint-disable-next-line @typescript-eslint/naming-convention interface ServerMethods { @@ -11,10 +15,40 @@ declare module '@rocket.chat/ddp-client' { } Meteor.methods({ - updateOTRAck({ message, ack }) { - if (!Meteor.userId()) { + async updateOTRAck({ message, ack }) { + const uid = Meteor.userId(); + if (!uid) { throw new Meteor.Error('error-invalid-user', 'Invalid user', { method: 'updateOTRAck' }); } + + check(ack, String); + check(message, { + _id: String, + rid: String, + msg: String, + t: String, + ts: Date, + u: { + _id: String, + username: String, + name: String, + }, + }); + + if (message?.t !== 'otr') { + throw new Meteor.Error('error-invalid-message', 'Invalid message type', { method: 'updateOTRAck' }); + } + + const room = await Rooms.findOneByIdAndType(message.rid, 'd', { projection: { t: 1, _id: 1, uids: 1 } }); + + if (!room) { + throw new Meteor.Error('error-invalid-room', 'Invalid room', { method: 'updateOTRAck' }); + } + + if (!(await canAccessRoomAsync(room, { _id: uid })) || (room.uids && (!message.u._id || !room.uids.includes(message.u._id)))) { + throw new Meteor.Error('error-invalid-user', 'Invalid user, not in room', { method: 'updateOTRAck' }); + } + const acknowledgeMessage: IOTRMessage = { ...message, otrAck: ack }; void api.broadcast('otrAckUpdate', { roomId: message.rid, acknowledgeMessage }); }, diff --git a/apps/meteor/app/reactions/client/methods/setReaction.ts b/apps/meteor/app/reactions/client/methods/setReaction.ts index ed15cda9ab8e..1744d49c0ceb 100644 --- a/apps/meteor/app/reactions/client/methods/setReaction.ts +++ b/apps/meteor/app/reactions/client/methods/setReaction.ts @@ -3,7 +3,6 @@ import type { ServerMethods } from '@rocket.chat/ddp-client'; import { Meteor } from 'meteor/meteor'; import { roomCoordinator } from '../../../../client/lib/rooms/roomCoordinator'; -import { callbacks } from '../../../../lib/callbacks'; import { emoji } from '../../../emoji/client'; import { Messages, ChatRoom, Subscriptions } from '../../../models/client'; @@ -55,10 +54,8 @@ Meteor.methods({ if (!message.reactions || typeof message.reactions !== 'object' || Object.keys(message.reactions).length === 0) { delete message.reactions; Messages.update({ _id: messageId }, { $unset: { reactions: 1 } }); - await callbacks.run('unsetReaction', messageId, reaction); } else { Messages.update({ _id: messageId }, { $set: { reactions: message.reactions } }); - await callbacks.run('setReaction', messageId, reaction); } } else { if (!message.reactions) { @@ -72,7 +69,6 @@ Meteor.methods({ message.reactions[reaction].usernames.push(user.username); Messages.update({ _id: messageId }, { $set: { reactions: message.reactions } }); - await callbacks.run('setReaction', messageId, reaction); } }, }); diff --git a/apps/meteor/app/reactions/server/setReaction.ts b/apps/meteor/app/reactions/server/setReaction.ts index d513c8dda6a5..be6e5aed4a54 100644 --- a/apps/meteor/app/reactions/server/setReaction.ts +++ b/apps/meteor/app/reactions/server/setReaction.ts @@ -4,7 +4,6 @@ import type { IMessage, IRoom, IUser } from '@rocket.chat/core-typings'; import type { ServerMethods } from '@rocket.chat/ddp-client'; import { Messages, EmojiCustom, Rooms, Users } from '@rocket.chat/models'; import { Meteor } from 'meteor/meteor'; -import _ from 'underscore'; import { callbacks } from '../../../lib/callbacks'; import { i18n } from '../../../server/lib/i18n'; @@ -12,26 +11,39 @@ import { canAccessRoomAsync } from '../../authorization/server'; import { hasPermissionAsync } from '../../authorization/server/functions/hasPermission'; import { emoji } from '../../emoji/server'; import { isTheLastMessage } from '../../lib/server/functions/isTheLastMessage'; -import { notifyOnRoomChangedById, notifyOnMessageChange } from '../../lib/server/lib/notifyListener'; +import { notifyOnMessageChange } from '../../lib/server/lib/notifyListener'; -const removeUserReaction = (message: IMessage, reaction: string, username: string) => { +export const removeUserReaction = (message: IMessage, reaction: string, username: string) => { if (!message.reactions) { return message; } - message.reactions[reaction].usernames.splice(message.reactions[reaction].usernames.indexOf(username), 1); - if (message.reactions[reaction].usernames.length === 0) { + const idx = message.reactions[reaction].usernames.indexOf(username); + + // user not found in reaction array + if (idx === -1) { + return message; + } + + message.reactions[reaction].usernames.splice(idx, 1); + if (!message.reactions[reaction].usernames.length) { delete message.reactions[reaction]; } return message; }; -async function setReaction(room: IRoom, user: IUser, message: IMessage, reaction: string, shouldReact?: boolean) { - reaction = `:${reaction.replace(/:/g, '')}:`; +export async function setReaction( + room: Pick, + user: IUser, + message: IMessage, + reaction: string, + userAlreadyReacted?: boolean, +) { + await Message.beforeReacted(message, room); - if (!emoji.list[reaction] && (await EmojiCustom.findByNameOrAlias(reaction, {}).count()) === 0) { - throw new Meteor.Error('error-not-allowed', 'Invalid emoji provided.', { - method: 'setReaction', + if (Array.isArray(room.muted) && room.muted.includes(user.username as string)) { + throw new Meteor.Error('error-not-allowed', i18n.t('You_have_been_muted', { lng: user.language }), { + rid: room._id, }); } @@ -42,51 +54,23 @@ async function setReaction(room: IRoom, user: IUser, message: IMessage, reaction } } - if (Array.isArray(room.muted) && room.muted.indexOf(user.username as string) !== -1) { - throw new Meteor.Error('error-not-allowed', i18n.t('You_have_been_muted', { lng: user.language }), { - rid: room._id, - }); - } - - // if (!('reactions' in message)) { - // return; - // } - - await Message.beforeReacted(message, room); - - const userAlreadyReacted = - message.reactions && - Boolean(message.reactions[reaction]) && - message.reactions[reaction].usernames.indexOf(user.username as string) !== -1; - // When shouldReact was not informed, toggle the reaction. - if (shouldReact === undefined) { - shouldReact = !userAlreadyReacted; - } - - if (userAlreadyReacted === shouldReact) { - return; - } - let isReacted; - if (userAlreadyReacted) { const oldMessage = JSON.parse(JSON.stringify(message)); removeUserReaction(message, reaction, user.username as string); - if (_.isEmpty(message.reactions)) { + if (Object.keys(message.reactions || {}).length === 0) { delete message.reactions; + await Messages.unsetReactions(message._id); if (isTheLastMessage(room, message)) { await Rooms.unsetReactionsInLastMessage(room._id); - void notifyOnRoomChangedById(room._id); } - await Messages.unsetReactions(message._id); } else { await Messages.setReactions(message._id, message.reactions); if (isTheLastMessage(room, message)) { await Rooms.setReactionsInLastMessage(room._id, message.reactions); } } - await callbacks.run('unsetReaction', message._id, reaction); - await callbacks.run('afterUnsetReaction', message, { user, reaction, shouldReact, oldMessage }); + void callbacks.run('afterUnsetReaction', message, { user, reaction, shouldReact: false, oldMessage }); isReacted = false; } else { @@ -102,34 +86,61 @@ async function setReaction(room: IRoom, user: IUser, message: IMessage, reaction await Messages.setReactions(message._id, message.reactions); if (isTheLastMessage(room, message)) { await Rooms.setReactionsInLastMessage(room._id, message.reactions); - void notifyOnRoomChangedById(room._id); } - await callbacks.run('setReaction', message._id, reaction); - await callbacks.run('afterSetReaction', message, { user, reaction, shouldReact }); + + void callbacks.run('afterSetReaction', message, { user, reaction, shouldReact: true }); isReacted = true; } - await Apps.self?.triggerEvent(AppEvents.IPostMessageReacted, message, user, reaction, isReacted); + void Apps.self?.triggerEvent(AppEvents.IPostMessageReacted, message, user, reaction, isReacted); void notifyOnMessageChange({ id: message._id, }); } -export async function executeSetReaction(userId: string, reaction: string, messageId: IMessage['_id'], shouldReact?: boolean) { - const user = await Users.findOneById(userId); +export async function executeSetReaction( + userId: string, + reaction: string, + messageParam: IMessage['_id'] | IMessage, + shouldReact?: boolean, +) { + // Check if the emoji is valid before proceeding + const reactionWithoutColons = reaction.replace(/:/g, ''); + reaction = `:${reactionWithoutColons}:`; + + if (!emoji.list[reaction] && (await EmojiCustom.countByNameOrAlias(reactionWithoutColons)) === 0) { + throw new Meteor.Error('error-not-allowed', 'Invalid emoji provided.', { + method: 'setReaction', + }); + } + const user = await Users.findOneById(userId); if (!user) { throw new Meteor.Error('error-invalid-user', 'Invalid user', { method: 'setReaction' }); } - const message = await Messages.findOneById(messageId); + const message = typeof messageParam === 'string' ? await Messages.findOneById(messageParam) : messageParam; if (!message) { throw new Meteor.Error('error-not-allowed', 'Not allowed', { method: 'setReaction' }); } - const room = await Rooms.findOneById(message.rid); + const userAlreadyReacted = + message.reactions && Boolean(message.reactions[reaction]) && message.reactions[reaction].usernames.includes(user.username as string); + + // When shouldReact was not informed, toggle the reaction. + if (shouldReact === undefined) { + shouldReact = !userAlreadyReacted; + } + + if (userAlreadyReacted === shouldReact) { + return; + } + + const room = await Rooms.findOneById< + Pick + >(message.rid, { projection: { _id: 1, ro: 1, muted: 1, reactWhenReadOnly: 1, lastMessage: 1, t: 1, prid: 1, federated: 1 } }); if (!room) { throw new Meteor.Error('error-not-allowed', 'Not allowed', { method: 'setReaction' }); } @@ -138,7 +149,7 @@ export async function executeSetReaction(userId: string, reaction: string, messa throw new Meteor.Error('not-authorized', 'Not Authorized', { method: 'setReaction' }); } - return setReaction(room, user, message, reaction, shouldReact); + return setReaction(room, user, message, reaction, userAlreadyReacted); } declare module '@rocket.chat/ddp-client' { diff --git a/apps/meteor/app/slackbridge/server/RocketAdapter.js b/apps/meteor/app/slackbridge/server/RocketAdapter.js index f76c33fa1f81..8ba2a76dcbc2 100644 --- a/apps/meteor/app/slackbridge/server/RocketAdapter.js +++ b/apps/meteor/app/slackbridge/server/RocketAdapter.js @@ -45,16 +45,16 @@ export default class RocketAdapter { rocketLogger.debug('Register for events'); callbacks.add('afterSaveMessage', this.onMessage.bind(this), callbacks.priority.LOW, 'SlackBridge_Out'); callbacks.add('afterDeleteMessage', this.onMessageDelete.bind(this), callbacks.priority.LOW, 'SlackBridge_Delete'); - callbacks.add('setReaction', this.onSetReaction.bind(this), callbacks.priority.LOW, 'SlackBridge_SetReaction'); - callbacks.add('unsetReaction', this.onUnSetReaction.bind(this), callbacks.priority.LOW, 'SlackBridge_UnSetReaction'); + callbacks.add('afterSetReaction', this.onSetReaction.bind(this), callbacks.priority.LOW, 'SlackBridge_SetReaction'); + callbacks.add('afterUnsetReaction', this.onUnSetReaction.bind(this), callbacks.priority.LOW, 'SlackBridge_UnSetReaction'); } unregisterForEvents() { rocketLogger.debug('Unregister for events'); callbacks.remove('afterSaveMessage', 'SlackBridge_Out'); callbacks.remove('afterDeleteMessage', 'SlackBridge_Delete'); - callbacks.remove('setReaction', 'SlackBridge_SetReaction'); - callbacks.remove('unsetReaction', 'SlackBridge_UnSetReaction'); + callbacks.remove('afterSetReaction', 'SlackBridge_SetReaction'); + callbacks.remove('afterUnsetReaction', 'SlackBridge_UnSetReaction'); } async onMessageDelete(rocketMessageDeleted) { @@ -72,7 +72,7 @@ export default class RocketAdapter { } } - async onSetReaction(rocketMsgID, reaction) { + async onSetReaction(rocketMsg, { reaction }) { try { if (!this.slackBridge.isReactionsEnabled) { return; @@ -80,12 +80,11 @@ export default class RocketAdapter { rocketLogger.debug('onRocketSetReaction'); - if (rocketMsgID && reaction) { - if (this.slackBridge.reactionsMap.delete(`set${rocketMsgID}${reaction}`)) { + if (rocketMsg._id && reaction) { + if (this.slackBridge.reactionsMap.delete(`set${rocketMsg._id}${reaction}`)) { // This was a Slack reaction, we don't need to tell Slack about it return; } - const rocketMsg = await Messages.findOneById(rocketMsgID); if (rocketMsg) { for await (const slack of this.slackAdapters) { const slackChannel = slack.getSlackChannel(rocketMsg.rid); @@ -101,7 +100,7 @@ export default class RocketAdapter { } } - async onUnSetReaction(rocketMsgID, reaction) { + async onUnSetReaction(rocketMsg, { reaction }) { try { if (!this.slackBridge.isReactionsEnabled) { return; @@ -109,13 +108,12 @@ export default class RocketAdapter { rocketLogger.debug('onRocketUnSetReaction'); - if (rocketMsgID && reaction) { - if (this.slackBridge.reactionsMap.delete(`unset${rocketMsgID}${reaction}`)) { + if (rocketMsg._id && reaction) { + if (this.slackBridge.reactionsMap.delete(`unset${rocketMsg._id}${reaction}`)) { // This was a Slack unset reaction, we don't need to tell Slack about it return; } - const rocketMsg = await Messages.findOneById(rocketMsgID); if (rocketMsg) { for await (const slack of this.slackAdapters) { const slackChannel = slack.getSlackChannel(rocketMsg.rid); diff --git a/apps/meteor/app/ui-master/server/scripts.ts b/apps/meteor/app/ui-master/server/scripts.ts index 9edadb021d32..3e84a6e39c90 100644 --- a/apps/meteor/app/ui-master/server/scripts.ts +++ b/apps/meteor/app/ui-master/server/scripts.ts @@ -45,6 +45,7 @@ window.addEventListener('load', function() { }); window.localStorage.clear(); Meteor._localStorage = window.sessionStorage; + Accounts.config({ clientStorage: 'session' }); } }); ` diff --git a/apps/meteor/app/ui-message/client/messageBox/AddLinkComposerActionModal.tsx b/apps/meteor/app/ui-message/client/messageBox/AddLinkComposerActionModal.tsx index 420bf93df66d..21e502c90001 100644 --- a/apps/meteor/app/ui-message/client/messageBox/AddLinkComposerActionModal.tsx +++ b/apps/meteor/app/ui-message/client/messageBox/AddLinkComposerActionModal.tsx @@ -1,8 +1,8 @@ import { Field, FieldGroup, TextInput, FieldLabel, FieldRow, Box } from '@rocket.chat/fuselage'; import { useUniqueId } from '@rocket.chat/fuselage-hooks'; -import { useTranslation } from '@rocket.chat/ui-contexts'; import React, { useEffect } from 'react'; import { useForm, Controller } from 'react-hook-form'; +import { useTranslation } from 'react-i18next'; import GenericModal from '../../../../client/components/GenericModal'; @@ -13,7 +13,7 @@ type AddLinkComposerActionModalProps = { }; const AddLinkComposerActionModal = ({ selectedText, onClose, onConfirm }: AddLinkComposerActionModalProps) => { - const t = useTranslation(); + const { t } = useTranslation(); const textField = useUniqueId(); const urlField = useUniqueId(); diff --git a/apps/meteor/app/ui-message/client/messageBox/createComposerAPI.ts b/apps/meteor/app/ui-message/client/messageBox/createComposerAPI.ts index a926f8540d27..741f7959fa90 100644 --- a/apps/meteor/app/ui-message/client/messageBox/createComposerAPI.ts +++ b/apps/meteor/app/ui-message/client/messageBox/createComposerAPI.ts @@ -1,6 +1,6 @@ import type { IMessage } from '@rocket.chat/core-typings'; import { Emitter } from '@rocket.chat/emitter'; -import { Meteor } from 'meteor/meteor'; +import { Accounts } from 'meteor/accounts-base'; import type { ComposerAPI } from '../../../../client/lib/chats/ChatAPI'; import { withDebouncing } from '../../../../lib/utils/highOrderFunctions'; @@ -31,11 +31,11 @@ export const createComposerAPI = (input: HTMLTextAreaElement, storageID: string) const persist = withDebouncing({ wait: 300 })(() => { if (input.value) { - Meteor._localStorage.setItem(storageID, input.value); + Accounts.storageLocation.setItem(storageID, input.value); return; } - Meteor._localStorage.removeItem(storageID); + Accounts.storageLocation.removeItem(storageID); }); const notifyQuotedMessagesUpdate = (): void => { @@ -262,7 +262,7 @@ export const createComposerAPI = (input: HTMLTextAreaElement, storageID: string) const insertNewLine = (): void => insertText('\n'); - setText(Meteor._localStorage.getItem(storageID) ?? '', { + setText(Accounts.storageLocation.getItem(storageID) ?? '', { skipFocus: true, }); diff --git a/apps/meteor/app/ui-utils/server/Message.ts b/apps/meteor/app/ui-utils/server/Message.ts index 06ae59238b42..21d8886c70bc 100644 --- a/apps/meteor/app/ui-utils/server/Message.ts +++ b/apps/meteor/app/ui-utils/server/Message.ts @@ -1,6 +1,6 @@ import type { IMessage } from '@rocket.chat/core-typings'; import { escapeHTML } from '@rocket.chat/string-helpers'; -import { Meteor } from 'meteor/meteor'; +import { Accounts } from 'meteor/accounts-base'; import { trim } from '../../../lib/utils/stringUtils'; import { i18n } from '../../../server/lib/i18n'; @@ -17,7 +17,7 @@ export const Message = { } if (messageType.message) { if (!language) { - language = Meteor._localStorage.getItem('userLanguage') || 'en'; + language = Accounts.storageLocation.getItem('userLanguage') || 'en'; } const data = (typeof messageType.data === 'function' && messageType.data(msg)) || {}; return i18n.t(messageType.message, { ...data, lng: language }); diff --git a/apps/meteor/app/utils/client/getUserAvatarURL.ts b/apps/meteor/app/utils/client/getUserAvatarURL.ts index 1a825a44fc27..d5fdd2b2d427 100644 --- a/apps/meteor/app/utils/client/getUserAvatarURL.ts +++ b/apps/meteor/app/utils/client/getUserAvatarURL.ts @@ -1,11 +1,6 @@ -import { settings } from '../../settings/client'; import { getAvatarURL } from './getAvatarURL'; export const getUserAvatarURL = function (username: string, cache = ''): string | undefined { - const externalSource = (settings.get('Accounts_AvatarExternalProviderUrl') || '').trim().replace(/\/$/, ''); - if (externalSource !== '') { - return externalSource.replace('{username}', username); - } if (username == null) { return; } diff --git a/apps/meteor/app/utils/client/lib/RestApiClient.ts b/apps/meteor/app/utils/client/lib/RestApiClient.ts index c5e12250b441..53c95ee3e4fa 100644 --- a/apps/meteor/app/utils/client/lib/RestApiClient.ts +++ b/apps/meteor/app/utils/client/lib/RestApiClient.ts @@ -1,6 +1,5 @@ import { RestClient } from '@rocket.chat/api-client'; import { Accounts } from 'meteor/accounts-base'; -import { Meteor } from 'meteor/meteor'; import { invokeTwoFactorModal } from '../../../../client/lib/2fa/process2faReturn'; import { baseURI } from '../../../../client/lib/baseURI'; @@ -12,7 +11,10 @@ class RestApiClient extends RestClient { 'X-Auth-Token': string; } | undefined { - const [uid, token] = [Meteor._localStorage.getItem(Accounts.USER_ID_KEY), Meteor._localStorage.getItem(Accounts.LOGIN_TOKEN_KEY)]; + const [uid, token] = [ + Accounts.storageLocation.getItem(Accounts.USER_ID_KEY), + Accounts.storageLocation.getItem(Accounts.LOGIN_TOKEN_KEY), + ]; if (!uid || !token) { return; diff --git a/apps/meteor/app/utils/lib/i18n.ts b/apps/meteor/app/utils/lib/i18n.ts index b69fe6b30513..737b98666d0a 100644 --- a/apps/meteor/app/utils/lib/i18n.ts +++ b/apps/meteor/app/utils/lib/i18n.ts @@ -1,4 +1,5 @@ import type { RocketchatI18nKeys } from '@rocket.chat/i18n'; +import type { TOptions } from 'i18next'; import i18next from 'i18next'; import sprintf from 'i18next-sprintf-postprocessor'; @@ -13,7 +14,7 @@ export const addSprinfToI18n = function (t: (typeof i18n)['t']) { } if (isObject(replaces[0]) && !Array.isArray(replaces[0])) { - return t(key, replaces[0]); + return t(key, replaces[0] as TOptions); } return t(key, { diff --git a/apps/meteor/app/utils/rocketchat.info b/apps/meteor/app/utils/rocketchat.info index cb3ad01b882a..de3eb50fa5d1 100644 --- a/apps/meteor/app/utils/rocketchat.info +++ b/apps/meteor/app/utils/rocketchat.info @@ -1,3 +1,3 @@ { - "version": "6.13.0-develop" + "version": "6.14.0-develop" } diff --git a/apps/meteor/app/utils/server/getUserAvatarURL.ts b/apps/meteor/app/utils/server/getUserAvatarURL.ts index b83efea1d842..d5fdd2b2d427 100644 --- a/apps/meteor/app/utils/server/getUserAvatarURL.ts +++ b/apps/meteor/app/utils/server/getUserAvatarURL.ts @@ -1,11 +1,6 @@ -import { settings } from '../../settings/server'; import { getAvatarURL } from './getAvatarURL'; export const getUserAvatarURL = function (username: string, cache = ''): string | undefined { - const externalSource = (settings.get('Accounts_AvatarExternalProviderUrl') || '').trim().replace(/\/$/, ''); - if (externalSource !== '') { - return externalSource.replace('{username}', username); - } if (username == null) { return; } diff --git a/apps/meteor/client/NavBarV2/NavBarOmnichannelToolbar/NavBarItemOmniChannelCallDialPad.tsx b/apps/meteor/client/NavBarV2/NavBarOmnichannelToolbar/NavBarItemOmniChannelCallDialPad.tsx index af9b907df12e..2693060578ed 100644 --- a/apps/meteor/client/NavBarV2/NavBarOmnichannelToolbar/NavBarItemOmniChannelCallDialPad.tsx +++ b/apps/meteor/client/NavBarV2/NavBarOmnichannelToolbar/NavBarItemOmniChannelCallDialPad.tsx @@ -1,7 +1,7 @@ import { NavBarItem } from '@rocket.chat/fuselage'; -import { useTranslation } from '@rocket.chat/ui-contexts'; import type { ComponentPropsWithoutRef } from 'react'; import React from 'react'; +import { useTranslation } from 'react-i18next'; import { useVoipOutboundStates } from '../../contexts/CallContext'; import { useDialModal } from '../../hooks/useDialModal'; @@ -9,7 +9,7 @@ import { useDialModal } from '../../hooks/useDialModal'; type NavBarItemOmniChannelCallDialPadProps = ComponentPropsWithoutRef; const NavBarItemOmniChannelCallDialPad = (props: NavBarItemOmniChannelCallDialPadProps) => { - const t = useTranslation(); + const { t } = useTranslation(); const { openDialModal } = useDialModal(); diff --git a/apps/meteor/client/NavBarV2/NavBarOmnichannelToolbar/NavBarItemOmnichannelCallToggleError.tsx b/apps/meteor/client/NavBarV2/NavBarOmnichannelToolbar/NavBarItemOmnichannelCallToggleError.tsx index cf4e7ec240b4..7f2b6adc8691 100644 --- a/apps/meteor/client/NavBarV2/NavBarOmnichannelToolbar/NavBarItemOmnichannelCallToggleError.tsx +++ b/apps/meteor/client/NavBarV2/NavBarOmnichannelToolbar/NavBarItemOmnichannelCallToggleError.tsx @@ -1,12 +1,12 @@ import { NavBarItem } from '@rocket.chat/fuselage'; -import { useTranslation } from '@rocket.chat/ui-contexts'; import type { ComponentPropsWithoutRef } from 'react'; import React from 'react'; +import { useTranslation } from 'react-i18next'; type NavBarItemOmnichannelCallToggleErrorProps = ComponentPropsWithoutRef; const NavBarItemOmnichannelCallToggleError = (props: NavBarItemOmnichannelCallToggleErrorProps) => { - const t = useTranslation(); + const { t } = useTranslation(); return ; }; diff --git a/apps/meteor/client/NavBarV2/NavBarOmnichannelToolbar/NavBarItemOmnichannelCallToggleLoading.tsx b/apps/meteor/client/NavBarV2/NavBarOmnichannelToolbar/NavBarItemOmnichannelCallToggleLoading.tsx index c4b53acefabb..149500050402 100644 --- a/apps/meteor/client/NavBarV2/NavBarOmnichannelToolbar/NavBarItemOmnichannelCallToggleLoading.tsx +++ b/apps/meteor/client/NavBarV2/NavBarOmnichannelToolbar/NavBarItemOmnichannelCallToggleLoading.tsx @@ -1,12 +1,12 @@ import { NavBarItem } from '@rocket.chat/fuselage'; -import { useTranslation } from '@rocket.chat/ui-contexts'; import type { ComponentPropsWithoutRef } from 'react'; import React from 'react'; +import { useTranslation } from 'react-i18next'; type NavBarItemOmnichannelCallToggleLoadingProps = ComponentPropsWithoutRef; const NavBarItemOmnichannelCallToggleLoading = (props: NavBarItemOmnichannelCallToggleLoadingProps) => { - const t = useTranslation(); + const { t } = useTranslation(); return ; }; diff --git a/apps/meteor/client/NavBarV2/NavBarOmnichannelToolbar/NavBarItemOmnichannelCallToggleReady.tsx b/apps/meteor/client/NavBarV2/NavBarOmnichannelToolbar/NavBarItemOmnichannelCallToggleReady.tsx index 8b51fc6c5b57..82f1c28350cd 100644 --- a/apps/meteor/client/NavBarV2/NavBarOmnichannelToolbar/NavBarItemOmnichannelCallToggleReady.tsx +++ b/apps/meteor/client/NavBarV2/NavBarOmnichannelToolbar/NavBarItemOmnichannelCallToggleReady.tsx @@ -1,14 +1,14 @@ import { NavBarItem } from '@rocket.chat/fuselage'; -import { useTranslation } from '@rocket.chat/ui-contexts'; import type { ComponentPropsWithoutRef } from 'react'; import React, { useCallback } from 'react'; +import { useTranslation } from 'react-i18next'; import { useCallerInfo, useCallRegisterClient, useCallUnregisterClient, useVoipNetworkStatus } from '../../contexts/CallContext'; type NavBarItemOmnichannelCallToggleReadyProps = ComponentPropsWithoutRef; const NavBarItemOmnichannelCallToggleReady = (props: NavBarItemOmnichannelCallToggleReadyProps) => { - const t = useTranslation(); + const { t } = useTranslation(); const caller = useCallerInfo(); const unregister = useCallUnregisterClient(); diff --git a/apps/meteor/client/NavBarV2/NavBarPagesToolbar/NavBarItemAuditMenu.tsx b/apps/meteor/client/NavBarV2/NavBarPagesToolbar/NavBarItemAuditMenu.tsx index 07936f6f4276..7c8a50338e7d 100644 --- a/apps/meteor/client/NavBarV2/NavBarPagesToolbar/NavBarItemAuditMenu.tsx +++ b/apps/meteor/client/NavBarV2/NavBarPagesToolbar/NavBarItemAuditMenu.tsx @@ -1,9 +1,9 @@ import { NavBarItem } from '@rocket.chat/fuselage'; +import { GenericMenu } from '@rocket.chat/ui-client'; import { useCurrentRoutePath, useTranslation } from '@rocket.chat/ui-contexts'; import type { HTMLAttributes } from 'react'; import React from 'react'; -import GenericMenu from '../../components/GenericMenu/GenericMenu'; import { useAuditMenu } from './hooks/useAuditMenu'; type NavBarItemAuditMenuProps = Omit, 'is'>; diff --git a/apps/meteor/client/NavBarV2/NavBarPagesToolbar/NavBarItemMarketPlaceMenu.tsx b/apps/meteor/client/NavBarV2/NavBarPagesToolbar/NavBarItemMarketPlaceMenu.tsx index 4a2bbc916b57..85687bb12a2e 100644 --- a/apps/meteor/client/NavBarV2/NavBarPagesToolbar/NavBarItemMarketPlaceMenu.tsx +++ b/apps/meteor/client/NavBarV2/NavBarPagesToolbar/NavBarItemMarketPlaceMenu.tsx @@ -1,9 +1,9 @@ import { NavBarItem } from '@rocket.chat/fuselage'; +import { GenericMenu } from '@rocket.chat/ui-client'; import { useCurrentRoutePath, useTranslation } from '@rocket.chat/ui-contexts'; import type { HTMLAttributes } from 'react'; import React from 'react'; -import GenericMenu from '../../components/GenericMenu/GenericMenu'; import { useMarketPlaceMenu } from './hooks/useMarketPlaceMenu'; type NavBarItemMarketPlaceMenuProps = Omit, 'is'>; diff --git a/apps/meteor/client/NavBarV2/NavBarPagesToolbar/hooks/useAuditMenu.tsx b/apps/meteor/client/NavBarV2/NavBarPagesToolbar/hooks/useAuditMenu.tsx index 88a2a5de31aa..97c8d7299497 100644 --- a/apps/meteor/client/NavBarV2/NavBarPagesToolbar/hooks/useAuditMenu.tsx +++ b/apps/meteor/client/NavBarV2/NavBarPagesToolbar/hooks/useAuditMenu.tsx @@ -1,6 +1,6 @@ +import type { GenericMenuItemProps } from '@rocket.chat/ui-client'; import { usePermission, useRouter, useTranslation } from '@rocket.chat/ui-contexts'; -import type { GenericMenuItemProps } from '../../../components/GenericMenu/GenericMenuItem'; import { useHasLicenseModule } from '../../../hooks/useHasLicenseModule'; export const useAuditMenu = () => { diff --git a/apps/meteor/client/NavBarV2/NavBarPagesToolbar/hooks/useMarketPlaceMenu.tsx b/apps/meteor/client/NavBarV2/NavBarPagesToolbar/hooks/useMarketPlaceMenu.tsx index fd704ffafe1f..034ab0367e81 100644 --- a/apps/meteor/client/NavBarV2/NavBarPagesToolbar/hooks/useMarketPlaceMenu.tsx +++ b/apps/meteor/client/NavBarV2/NavBarPagesToolbar/hooks/useMarketPlaceMenu.tsx @@ -1,8 +1,8 @@ import { Badge, Skeleton } from '@rocket.chat/fuselage'; +import type { GenericMenuItemProps } from '@rocket.chat/ui-client'; import { useTranslation, usePermission, useRouter } from '@rocket.chat/ui-contexts'; import React from 'react'; -import type { GenericMenuItemProps } from '../../../components/GenericMenu/GenericMenuItem'; import { useUserDropdownAppsActionButtons } from '../../../hooks/useAppActionButtons'; import { useAppRequestStats } from '../../../views/marketplace/hooks/useAppRequestStats'; diff --git a/apps/meteor/client/NavBarV2/NavBarSettingsToolbar/NavBarItemAdministrationMenu.tsx b/apps/meteor/client/NavBarV2/NavBarSettingsToolbar/NavBarItemAdministrationMenu.tsx index 045b36425512..8236eec030e8 100644 --- a/apps/meteor/client/NavBarV2/NavBarSettingsToolbar/NavBarItemAdministrationMenu.tsx +++ b/apps/meteor/client/NavBarV2/NavBarSettingsToolbar/NavBarItemAdministrationMenu.tsx @@ -1,9 +1,9 @@ import { NavBarItem } from '@rocket.chat/fuselage'; +import { GenericMenu } from '@rocket.chat/ui-client'; import { useCurrentRoutePath, useTranslation } from '@rocket.chat/ui-contexts'; import type { HTMLAttributes } from 'react'; import React from 'react'; -import GenericMenu from '../../components/GenericMenu/GenericMenu'; import { useAdministrationMenu } from './hooks/useAdministrationMenu'; type NavBarItemAdministrationMenuProps = Omit, 'is'>; diff --git a/apps/meteor/client/NavBarV2/NavBarSettingsToolbar/UserMenu/UserMenu.tsx b/apps/meteor/client/NavBarV2/NavBarSettingsToolbar/UserMenu/UserMenu.tsx index 531ff8a74b66..149ad0ea585e 100644 --- a/apps/meteor/client/NavBarV2/NavBarSettingsToolbar/UserMenu/UserMenu.tsx +++ b/apps/meteor/client/NavBarV2/NavBarSettingsToolbar/UserMenu/UserMenu.tsx @@ -1,18 +1,17 @@ import type { IUser } from '@rocket.chat/core-typings'; -import { useTranslation } from '@rocket.chat/ui-contexts'; +import { GenericMenu, useHandleMenuAction } from '@rocket.chat/ui-client'; +import type { GenericMenuItemProps } from '@rocket.chat/ui-client'; import type { ComponentProps } from 'react'; import React, { memo, useState } from 'react'; +import { useTranslation } from 'react-i18next'; -import GenericMenu from '../../../components/GenericMenu/GenericMenu'; -import type { GenericMenuItemProps } from '../../../components/GenericMenu/GenericMenuItem'; -import { useHandleMenuAction } from '../../../components/GenericMenu/hooks/useHandleMenuAction'; import UserMenuButton from './UserMenuButton'; import { useUserMenu } from './hooks/useUserMenu'; type UserMenuProps = { user: IUser } & Omit, 'sections' | 'items' | 'title'>; const UserMenu = function UserMenu({ user, ...props }: UserMenuProps) { - const t = useTranslation(); + const { t } = useTranslation(); const [isOpen, setIsOpen] = useState(false); const sections = useUserMenu(user); diff --git a/apps/meteor/client/NavBarV2/NavBarSettingsToolbar/UserMenu/hooks/useAccountItems.tsx b/apps/meteor/client/NavBarV2/NavBarSettingsToolbar/UserMenu/hooks/useAccountItems.tsx index bf1b7e55f244..e54e2b72d675 100644 --- a/apps/meteor/client/NavBarV2/NavBarSettingsToolbar/UserMenu/hooks/useAccountItems.tsx +++ b/apps/meteor/client/NavBarV2/NavBarSettingsToolbar/UserMenu/hooks/useAccountItems.tsx @@ -1,16 +1,15 @@ import { Badge } from '@rocket.chat/fuselage'; import { useEffectEvent } from '@rocket.chat/fuselage-hooks'; -import { defaultFeaturesPreview, useFeaturePreviewList } from '@rocket.chat/ui-client'; +import type { GenericMenuItemProps } from '@rocket.chat/ui-client'; +import { defaultFeaturesPreview, usePreferenceFeaturePreviewList } from '@rocket.chat/ui-client'; import { useRouter, useTranslation } from '@rocket.chat/ui-contexts'; import React from 'react'; -import type { GenericMenuItemProps } from '../../../../components/GenericMenu/GenericMenuItem'; - export const useAccountItems = (): GenericMenuItemProps[] => { const t = useTranslation(); const router = useRouter(); - const { unseenFeatures, featurePreviewEnabled } = useFeaturePreviewList(); + const { unseenFeatures, featurePreviewEnabled } = usePreferenceFeaturePreviewList(); const handleMyAccount = useEffectEvent(() => { router.navigate('/account'); diff --git a/apps/meteor/client/NavBarV2/NavBarSettingsToolbar/UserMenu/hooks/useStatusItems.tsx b/apps/meteor/client/NavBarV2/NavBarSettingsToolbar/UserMenu/hooks/useStatusItems.tsx index 2957d22c5e32..1c9cf09e4610 100644 --- a/apps/meteor/client/NavBarV2/NavBarSettingsToolbar/UserMenu/hooks/useStatusItems.tsx +++ b/apps/meteor/client/NavBarV2/NavBarSettingsToolbar/UserMenu/hooks/useStatusItems.tsx @@ -1,11 +1,11 @@ import { Box } from '@rocket.chat/fuselage'; +import type { GenericMenuItemProps } from '@rocket.chat/ui-client'; import { useEndpoint, useSetting } from '@rocket.chat/ui-contexts'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import React, { useEffect } from 'react'; import { useTranslation } from 'react-i18next'; import { callbacks } from '../../../../../lib/callbacks'; -import type { GenericMenuItemProps } from '../../../../components/GenericMenu/GenericMenuItem'; import MarkdownText from '../../../../components/MarkdownText'; import { UserStatus } from '../../../../components/UserStatus'; import { userStatuses } from '../../../../lib/userStatuses'; diff --git a/apps/meteor/client/NavBarV2/NavBarSettingsToolbar/UserMenu/hooks/useUserMenu.tsx b/apps/meteor/client/NavBarV2/NavBarSettingsToolbar/UserMenu/hooks/useUserMenu.tsx index a969c853d797..85a481f3e257 100644 --- a/apps/meteor/client/NavBarV2/NavBarSettingsToolbar/UserMenu/hooks/useUserMenu.tsx +++ b/apps/meteor/client/NavBarV2/NavBarSettingsToolbar/UserMenu/hooks/useUserMenu.tsx @@ -1,9 +1,9 @@ import type { IUser } from '@rocket.chat/core-typings'; import { useEffectEvent } from '@rocket.chat/fuselage-hooks'; +import type { GenericMenuItemProps } from '@rocket.chat/ui-client'; import { useLogout, useTranslation } from '@rocket.chat/ui-contexts'; import React from 'react'; -import type { GenericMenuItemProps } from '../../../../components/GenericMenu/GenericMenuItem'; import UserMenuHeader from '../UserMenuHeader'; import { useAccountItems } from './useAccountItems'; import { useStatusItems } from './useStatusItems'; diff --git a/apps/meteor/client/NavBarV2/NavBarSettingsToolbar/hooks/useAdministrationMenu.tsx b/apps/meteor/client/NavBarV2/NavBarSettingsToolbar/hooks/useAdministrationMenu.tsx index 54d4818128ea..e3c4a358c7c1 100644 --- a/apps/meteor/client/NavBarV2/NavBarSettingsToolbar/hooks/useAdministrationMenu.tsx +++ b/apps/meteor/client/NavBarV2/NavBarSettingsToolbar/hooks/useAdministrationMenu.tsx @@ -1,7 +1,6 @@ +import type { GenericMenuItemProps } from '@rocket.chat/ui-client'; import { useAtLeastOnePermission, usePermission, useRouter, useTranslation } from '@rocket.chat/ui-contexts'; -import type { GenericMenuItemProps } from '../../../components/GenericMenu/GenericMenuItem'; - const ADMIN_PERMISSIONS = [ 'view-statistics', 'run-import', diff --git a/apps/meteor/client/apps/gameCenter/GameCenterContainer.tsx b/apps/meteor/client/apps/gameCenter/GameCenterContainer.tsx index f589dd21ed50..dbaea02ace4a 100644 --- a/apps/meteor/client/apps/gameCenter/GameCenterContainer.tsx +++ b/apps/meteor/client/apps/gameCenter/GameCenterContainer.tsx @@ -1,7 +1,7 @@ import { Avatar } from '@rocket.chat/fuselage'; -import { useTranslation } from '@rocket.chat/ui-contexts'; import type { ReactElement } from 'react'; import React from 'react'; +import { useTranslation } from 'react-i18next'; import { ContextualbarTitle, @@ -19,7 +19,7 @@ interface IGameCenterContainerProps { } const GameCenterContainer = ({ handleClose, handleBack, game }: IGameCenterContainerProps): ReactElement => { - const t = useTranslation(); + const { t } = useTranslation(); return ( <> diff --git a/apps/meteor/client/apps/gameCenter/GameCenterInvitePlayersModal.tsx b/apps/meteor/client/apps/gameCenter/GameCenterInvitePlayersModal.tsx index d0dcc6fad4fe..871e82f3ff56 100644 --- a/apps/meteor/client/apps/gameCenter/GameCenterInvitePlayersModal.tsx +++ b/apps/meteor/client/apps/gameCenter/GameCenterInvitePlayersModal.tsx @@ -1,8 +1,8 @@ import type { IUser } from '@rocket.chat/core-typings'; import { Box } from '@rocket.chat/fuselage'; -import { useTranslation } from '@rocket.chat/ui-contexts'; import type { ReactElement } from 'react'; import React, { useState } from 'react'; +import { useTranslation } from 'react-i18next'; import GenericModal from '../../components/GenericModal'; import UserAutoCompleteMultipleFederated from '../../components/UserAutoCompleteMultiple/UserAutoCompleteMultipleFederated'; @@ -19,7 +19,7 @@ interface IGameCenterInvitePlayersModalProps { } const GameCenterInvitePlayersModal = ({ game, onClose }: IGameCenterInvitePlayersModalProps): ReactElement => { - const t = useTranslation(); + const { t } = useTranslation(); const [users, setUsers] = useState>([]); const { name } = game; diff --git a/apps/meteor/client/components/ActionManagerBusyState.tsx b/apps/meteor/client/components/ActionManagerBusyState.tsx index 1399c045271f..932eb08ea502 100644 --- a/apps/meteor/client/components/ActionManagerBusyState.tsx +++ b/apps/meteor/client/components/ActionManagerBusyState.tsx @@ -1,12 +1,12 @@ import { css } from '@rocket.chat/css-in-js'; import { Box } from '@rocket.chat/fuselage'; -import { useTranslation } from '@rocket.chat/ui-contexts'; import React, { useEffect, useState } from 'react'; +import { useTranslation } from 'react-i18next'; import { useUiKitActionManager } from '../uikit/hooks/useUiKitActionManager'; const ActionManagerBusyState = () => { - const t = useTranslation(); + const { t } = useTranslation(); const actionManager = useUiKitActionManager(); const [busy, setBusy] = useState(false); diff --git a/apps/meteor/client/components/AutoCompleteDepartment.tsx b/apps/meteor/client/components/AutoCompleteDepartment.tsx index 6217f1d99610..0c50f2254aac 100644 --- a/apps/meteor/client/components/AutoCompleteDepartment.tsx +++ b/apps/meteor/client/components/AutoCompleteDepartment.tsx @@ -1,8 +1,8 @@ import { PaginatedSelectFiltered } from '@rocket.chat/fuselage'; import { useDebouncedValue } from '@rocket.chat/fuselage-hooks'; -import { useTranslation } from '@rocket.chat/ui-contexts'; import type { ComponentProps, ReactElement } from 'react'; import React, { memo, useMemo, useState } from 'react'; +import { useTranslation } from 'react-i18next'; import { useRecordList } from '../hooks/lists/useRecordList'; import { AsyncStatePhase } from '../hooks/useAsyncState'; @@ -28,7 +28,7 @@ const AutoCompleteDepartment = ({ showArchived = false, ...props }: AutoCompleteDepartmentProps): ReactElement | null => { - const t = useTranslation(); + const { t } = useTranslation(); const [departmentsFilter, setDepartmentsFilter] = useState(''); const debouncedDepartmentsFilter = useDebouncedValue(departmentsFilter, 500); diff --git a/apps/meteor/client/components/AutoCompleteDepartmentMultiple.tsx b/apps/meteor/client/components/AutoCompleteDepartmentMultiple.tsx index 99af9a1f6a2c..c15d480f3900 100644 --- a/apps/meteor/client/components/AutoCompleteDepartmentMultiple.tsx +++ b/apps/meteor/client/components/AutoCompleteDepartmentMultiple.tsx @@ -1,9 +1,9 @@ import { CheckOption, PaginatedMultiSelectFiltered } from '@rocket.chat/fuselage'; import type { PaginatedMultiSelectOption } from '@rocket.chat/fuselage'; import { useDebouncedValue } from '@rocket.chat/fuselage-hooks'; -import { useTranslation } from '@rocket.chat/ui-contexts'; import type { ComponentProps } from 'react'; import React, { memo, useMemo, useState } from 'react'; +import { useTranslation } from 'react-i18next'; import { useRecordList } from '../hooks/lists/useRecordList'; import { AsyncStatePhase } from '../hooks/useAsyncState'; @@ -24,7 +24,7 @@ const AutoCompleteDepartmentMultiple = ({ enabled = false, onChange = () => undefined, }: AutoCompleteDepartmentMultipleProps) => { - const t = useTranslation(); + const { t } = useTranslation(); const [departmentsFilter, setDepartmentsFilter] = useState(''); const debouncedDepartmentsFilter = useDebouncedValue(departmentsFilter, 500); diff --git a/apps/meteor/client/components/ConfirmOwnerChangeModal.tsx b/apps/meteor/client/components/ConfirmOwnerChangeModal.tsx index 349341baf003..77135fad6230 100644 --- a/apps/meteor/client/components/ConfirmOwnerChangeModal.tsx +++ b/apps/meteor/client/components/ConfirmOwnerChangeModal.tsx @@ -1,7 +1,7 @@ import { Box } from '@rocket.chat/fuselage'; -import { useTranslation } from '@rocket.chat/ui-contexts'; import type { ComponentPropsWithoutRef } from 'react'; import React from 'react'; +import { useTranslation } from 'react-i18next'; import GenericModal from './GenericModal'; import RawText from './RawText'; @@ -20,7 +20,7 @@ const ConfirmOwnerChangeModal = ({ onConfirm, onCancel, }: ConfirmOwnerChangeModalProps) => { - const t = useTranslation(); + const { t } = useTranslation(); let changeOwnerRooms = ''; if (shouldChangeOwner.length > 0) { diff --git a/apps/meteor/client/components/Contextualbar/ContextualbarBack.tsx b/apps/meteor/client/components/Contextualbar/ContextualbarBack.tsx index c8e17ab88d80..dcac448b1e92 100644 --- a/apps/meteor/client/components/Contextualbar/ContextualbarBack.tsx +++ b/apps/meteor/client/components/Contextualbar/ContextualbarBack.tsx @@ -1,13 +1,13 @@ -import { useTranslation } from '@rocket.chat/ui-contexts'; import type { ReactElement, ComponentProps } from 'react'; import React, { memo } from 'react'; +import { useTranslation } from 'react-i18next'; import ContextualbarAction from './ContextualbarAction'; type ContextualbarBackProps = Partial>; const ContextualbarBack = (props: ContextualbarBackProps): ReactElement => { - const t = useTranslation(); + const { t } = useTranslation(); return ; }; diff --git a/apps/meteor/client/components/Contextualbar/ContextualbarClose.tsx b/apps/meteor/client/components/Contextualbar/ContextualbarClose.tsx index 1670c9be5895..38db516476e3 100644 --- a/apps/meteor/client/components/Contextualbar/ContextualbarClose.tsx +++ b/apps/meteor/client/components/Contextualbar/ContextualbarClose.tsx @@ -1,13 +1,13 @@ -import { useTranslation } from '@rocket.chat/ui-contexts'; import type { ComponentProps, ReactElement } from 'react'; import React, { memo } from 'react'; +import { useTranslation } from 'react-i18next'; import ContextualbarAction from './ContextualbarAction'; type ContextualbarCloseProps = Partial>; const ContextualbarClose = (props: ContextualbarCloseProps): ReactElement => { - const t = useTranslation(); + const { t } = useTranslation(); return ; }; diff --git a/apps/meteor/client/components/Contextualbar/ContextualbarDialog.tsx b/apps/meteor/client/components/Contextualbar/ContextualbarDialog.tsx index 23def16a94a1..4e4640270087 100644 --- a/apps/meteor/client/components/Contextualbar/ContextualbarDialog.tsx +++ b/apps/meteor/client/components/Contextualbar/ContextualbarDialog.tsx @@ -18,7 +18,7 @@ type ContextualbarDialogProps = AriaDialogProps & ComponentProps { const ref = useRef(null); const { dialogProps } = useDialog({ 'aria-labelledby': 'contextualbarTitle', ...props }, ref); - const sizes = useLayoutSizes(); + const { contextualBar } = useLayoutSizes(); const position = useLayoutContextualBarPosition(); const { closeTab } = useRoomToolbox(); @@ -42,12 +42,12 @@ const ContextualbarDialog = (props: ContextualbarDialogProps) => { - + - + diff --git a/apps/meteor/client/components/CreateDiscussion/CreateDiscussion.tsx b/apps/meteor/client/components/CreateDiscussion/CreateDiscussion.tsx index cd39a187cd89..b1dcb9d8dfbc 100644 --- a/apps/meteor/client/components/CreateDiscussion/CreateDiscussion.tsx +++ b/apps/meteor/client/components/CreateDiscussion/CreateDiscussion.tsx @@ -120,7 +120,7 @@ const CreateDiscussion = ({ onClose, defaultParentRoom, parentMessageId, nameSug ( ( { + const disabled = !useSidePanelNavigationScreenSize(); + return ; +}; diff --git a/apps/meteor/client/components/FilterByText.tsx b/apps/meteor/client/components/FilterByText.tsx index 5c5a3d599e2f..25d8e225e3d8 100644 --- a/apps/meteor/client/components/FilterByText.tsx +++ b/apps/meteor/client/components/FilterByText.tsx @@ -1,8 +1,8 @@ import { Box, Icon, TextInput, Margins } from '@rocket.chat/fuselage'; import { useAutoFocus, useMergedRefs } from '@rocket.chat/fuselage-hooks'; -import { useTranslation } from '@rocket.chat/ui-contexts'; import type { ChangeEvent, FormEvent, HTMLAttributes } from 'react'; import React, { forwardRef, memo, useCallback, useState } from 'react'; +import { useTranslation } from 'react-i18next'; type FilterByTextProps = { onChange: (filter: string) => void; @@ -13,7 +13,7 @@ const FilterByText = forwardRef(function Fi { placeholder, onChange: setFilter, shouldAutoFocus = false, children, ...props }, ref, ) { - const t = useTranslation(); + const { t } = useTranslation(); const [text, setText] = useState(''); const autoFocusRef = useAutoFocus(shouldAutoFocus); const mergedRefs = useMergedRefs(ref, autoFocusRef); diff --git a/apps/meteor/client/components/FingerprintChangeModal.tsx b/apps/meteor/client/components/FingerprintChangeModal.tsx index db4c33654a92..a45a17db8ccc 100644 --- a/apps/meteor/client/components/FingerprintChangeModal.tsx +++ b/apps/meteor/client/components/FingerprintChangeModal.tsx @@ -1,7 +1,7 @@ import { Box } from '@rocket.chat/fuselage'; -import { useTranslation } from '@rocket.chat/ui-contexts'; import type { ReactElement } from 'react'; import React from 'react'; +import { useTranslation } from 'react-i18next'; import GenericModal from './GenericModal'; @@ -12,7 +12,7 @@ type FingerprintChangeModalProps = { }; const FingerprintChangeModal = ({ onConfirm, onCancel, onClose }: FingerprintChangeModalProps): ReactElement => { - const t = useTranslation(); + const { t } = useTranslation(); return ( { - const t = useTranslation(); + const { t } = useTranslation(); return ( { - const t = useTranslation(); + const { t } = useTranslation(); return ( diff --git a/apps/meteor/client/components/GenericModal/GenericModal.tsx b/apps/meteor/client/components/GenericModal/GenericModal.tsx index 5d025e05827d..d91e3c066007 100644 --- a/apps/meteor/client/components/GenericModal/GenericModal.tsx +++ b/apps/meteor/client/components/GenericModal/GenericModal.tsx @@ -1,9 +1,9 @@ import { Button, Modal } from '@rocket.chat/fuselage'; import { useEffectEvent, useUniqueId } from '@rocket.chat/fuselage-hooks'; import type { Keys as IconName } from '@rocket.chat/icons'; -import { useTranslation } from '@rocket.chat/ui-contexts'; import type { ComponentProps, ReactElement, ReactNode, ComponentPropsWithoutRef } from 'react'; import React, { useEffect, useRef } from 'react'; +import { useTranslation } from 'react-i18next'; import type { RequiredModalProps } from './withDoNotAskAgain'; import { withDoNotAskAgain } from './withDoNotAskAgain'; @@ -75,7 +75,7 @@ const GenericModal = ({ annotation, ...props }: GenericModalProps) => { - const t = useTranslation(); + const { t } = useTranslation(); const genericModalId = useUniqueId(); const dismissedRef = useRef(true); diff --git a/apps/meteor/client/components/GenericNoResults/GenericNoResults.tsx b/apps/meteor/client/components/GenericNoResults/GenericNoResults.tsx index 3fcfe2b0e0ac..d21023024fb3 100644 --- a/apps/meteor/client/components/GenericNoResults/GenericNoResults.tsx +++ b/apps/meteor/client/components/GenericNoResults/GenericNoResults.tsx @@ -1,7 +1,7 @@ import { Box, States, StatesIcon, StatesLink, StatesTitle, StatesSubtitle, StatesActions, StatesAction } from '@rocket.chat/fuselage'; import type { Keys as IconName } from '@rocket.chat/icons'; -import { useTranslation } from '@rocket.chat/ui-contexts'; import React from 'react'; +import { useTranslation } from 'react-i18next'; type LinkProps = { linkText: string; linkHref: string } | { linkText?: never; linkHref?: never }; type ButtonProps = { buttonTitle: string; buttonAction: () => void } | { buttonTitle?: never; buttonAction?: never }; @@ -23,7 +23,7 @@ const GenericNoResults = ({ linkHref, linkText, }: GenericNoResultsProps) => { - const t = useTranslation(); + const { t } = useTranslation(); return ( diff --git a/apps/meteor/client/components/GenericTable/hooks/useItemsPerPageLabel.ts b/apps/meteor/client/components/GenericTable/hooks/useItemsPerPageLabel.ts index 0a8a8deb8262..4c390041497e 100644 --- a/apps/meteor/client/components/GenericTable/hooks/useItemsPerPageLabel.ts +++ b/apps/meteor/client/components/GenericTable/hooks/useItemsPerPageLabel.ts @@ -1,7 +1,7 @@ -import { useTranslation } from '@rocket.chat/ui-contexts'; import { useCallback } from 'react'; +import { useTranslation } from 'react-i18next'; export const useItemsPerPageLabel = (): (() => string) => { - const t = useTranslation(); + const { t } = useTranslation(); return useCallback(() => t('Items_per_page:'), [t]); }; diff --git a/apps/meteor/client/components/GenericTable/hooks/useShowingResultsLabel.ts b/apps/meteor/client/components/GenericTable/hooks/useShowingResultsLabel.ts index c610340f28bd..8ff7d2ac18cf 100644 --- a/apps/meteor/client/components/GenericTable/hooks/useShowingResultsLabel.ts +++ b/apps/meteor/client/components/GenericTable/hooks/useShowingResultsLabel.ts @@ -1,15 +1,19 @@ import type { Pagination } from '@rocket.chat/fuselage'; -import { useTranslation } from '@rocket.chat/ui-contexts'; import type { ComponentProps } from 'react'; import { useCallback } from 'react'; +import { useTranslation } from 'react-i18next'; type Props['showingResultsLabel'] = ComponentProps['showingResultsLabel']> = T extends (...args: any[]) => any ? Parameters : never; export const useShowingResultsLabel = (): ((...params: Props) => string) => { - const t = useTranslation(); + const { t } = useTranslation(); return useCallback( - ({ count, current, itemsPerPage }) => t('Showing_results_of', current + 1, Math.min(current + itemsPerPage, count), count), + ({ count, current, itemsPerPage }) => + t('Showing_results_of', { + postProcess: 'sprintf', + sprintf: [current + 1, Math.min(current + itemsPerPage, count), count], + }), [t], ); }; diff --git a/apps/meteor/client/components/GenericUpsellModal/GenericUpsellModal.tsx b/apps/meteor/client/components/GenericUpsellModal/GenericUpsellModal.tsx index 3d68e3f4b6d3..e7ce515ac496 100644 --- a/apps/meteor/client/components/GenericUpsellModal/GenericUpsellModal.tsx +++ b/apps/meteor/client/components/GenericUpsellModal/GenericUpsellModal.tsx @@ -1,8 +1,8 @@ import { Box, Button, Modal } from '@rocket.chat/fuselage'; import type { Keys as IconName } from '@rocket.chat/icons'; -import { useTranslation } from '@rocket.chat/ui-contexts'; import type { ReactNode, ReactElement, ComponentProps } from 'react'; import React from 'react'; +import { useTranslation } from 'react-i18next'; type GenericUpsellModalProps = { children?: ReactNode; @@ -35,7 +35,7 @@ const GenericUpsellModal = ({ annotation, ...props }: GenericUpsellModalProps) => { - const t = useTranslation(); + const { t } = useTranslation(); return ( diff --git a/apps/meteor/client/components/ImageGallery/ImageGallery.tsx b/apps/meteor/client/components/ImageGallery/ImageGallery.tsx index 2cabfed460bd..6db61b2ec910 100644 --- a/apps/meteor/client/components/ImageGallery/ImageGallery.tsx +++ b/apps/meteor/client/components/ImageGallery/ImageGallery.tsx @@ -1,10 +1,10 @@ import type { IUpload } from '@rocket.chat/core-typings'; import { css } from '@rocket.chat/css-in-js'; import { Box, ButtonGroup, IconButton, Palette, Throbber } from '@rocket.chat/fuselage'; -import { useTranslation } from '@rocket.chat/ui-contexts'; import React, { useRef, useState } from 'react'; import { FocusScope } from 'react-aria'; import { createPortal } from 'react-dom'; +import { useTranslation } from 'react-i18next'; import { Keyboard, Navigation, Zoom, A11y } from 'swiper'; import type { SwiperRef } from 'swiper/react'; import { type SwiperClass, Swiper, SwiperSlide } from 'swiper/react'; @@ -108,7 +108,7 @@ const swiperStyle = css` `; export const ImageGallery = ({ images, onClose, loadMore }: { images: IUpload[]; onClose: () => void; loadMore?: () => void }) => { - const t = useTranslation(); + const { t } = useTranslation(); const swiperRef = useRef(null); const [, setSwiperInst] = useState(); const [zoomScale, setZoomScale] = useState(1); diff --git a/apps/meteor/client/components/ImageGallery/ImageGalleryError.tsx b/apps/meteor/client/components/ImageGallery/ImageGalleryError.tsx index 97d91de95f62..8dcc55a93a48 100644 --- a/apps/meteor/client/components/ImageGallery/ImageGalleryError.tsx +++ b/apps/meteor/client/components/ImageGallery/ImageGalleryError.tsx @@ -1,8 +1,8 @@ import { css } from '@rocket.chat/css-in-js'; import { IconButton, ModalBackdrop } from '@rocket.chat/fuselage'; -import { useTranslation } from '@rocket.chat/ui-contexts'; import React from 'react'; import { createPortal } from 'react-dom'; +import { useTranslation } from 'react-i18next'; import GenericError from '../GenericError/GenericError'; @@ -14,7 +14,7 @@ const closeButtonStyle = css` `; export const ImageGalleryError = ({ onClose }: { onClose: () => void }) => { - const t = useTranslation(); + const { t } = useTranslation(); return createPortal( diff --git a/apps/meteor/client/components/ImageGallery/ImageGalleryLoading.tsx b/apps/meteor/client/components/ImageGallery/ImageGalleryLoading.tsx index 1c057584bd1f..588605786664 100644 --- a/apps/meteor/client/components/ImageGallery/ImageGalleryLoading.tsx +++ b/apps/meteor/client/components/ImageGallery/ImageGalleryLoading.tsx @@ -1,8 +1,8 @@ import { css } from '@rocket.chat/css-in-js'; import { IconButton, ModalBackdrop, Throbber } from '@rocket.chat/fuselage'; -import { useTranslation } from '@rocket.chat/ui-contexts'; import React from 'react'; import { createPortal } from 'react-dom'; +import { useTranslation } from 'react-i18next'; const closeButtonStyle = css` position: absolute; @@ -12,7 +12,7 @@ const closeButtonStyle = css` `; export const ImageGalleryLoading = ({ onClose }: { onClose: () => void }) => { - const t = useTranslation(); + const { t } = useTranslation(); return createPortal( diff --git a/apps/meteor/client/components/InfoPanel/RetentionPolicyCallout.tsx b/apps/meteor/client/components/InfoPanel/RetentionPolicyCallout.tsx index cbefeb2c72c1..6f94be4ebc90 100644 --- a/apps/meteor/client/components/InfoPanel/RetentionPolicyCallout.tsx +++ b/apps/meteor/client/components/InfoPanel/RetentionPolicyCallout.tsx @@ -1,14 +1,14 @@ import type { IRoom } from '@rocket.chat/core-typings'; import { Callout } from '@rocket.chat/fuselage'; -import { useTranslation } from '@rocket.chat/ui-contexts'; import React from 'react'; +import { useTranslation } from 'react-i18next'; import { usePruneWarningMessage } from '../../hooks/usePruneWarningMessage'; import { withErrorBoundary } from '../withErrorBoundary'; const RetentionPolicyCallout = ({ room }: { room: IRoom }) => { const message = usePruneWarningMessage(room); - const t = useTranslation(); + const { t } = useTranslation(); return ( diff --git a/apps/meteor/client/components/LocalTime.tsx b/apps/meteor/client/components/LocalTime.tsx index 100ba2ec6a62..498b2da9711e 100644 --- a/apps/meteor/client/components/LocalTime.tsx +++ b/apps/meteor/client/components/LocalTime.tsx @@ -1,6 +1,6 @@ -import { useTranslation } from '@rocket.chat/ui-contexts'; import type { ReactElement } from 'react'; import React, { memo } from 'react'; +import { useTranslation } from 'react-i18next'; import { useUTCClock } from '../hooks/useUTCClock'; @@ -10,7 +10,7 @@ type LocalTimeProps = { const LocalTime = ({ utcOffset }: LocalTimeProps): ReactElement => { const time = useUTCClock(utcOffset); - const t = useTranslation(); + const { t } = useTranslation(); return <>{t('Local_Time_time', { time })}; }; diff --git a/apps/meteor/client/components/MarkdownText.spec.tsx b/apps/meteor/client/components/MarkdownText.spec.tsx new file mode 100644 index 000000000000..86ebadad8463 --- /dev/null +++ b/apps/meteor/client/components/MarkdownText.spec.tsx @@ -0,0 +1,92 @@ +import { mockAppRoot } from '@rocket.chat/mock-providers'; +import { render } from '@testing-library/react'; +import React from 'react'; + +import MarkdownText from './MarkdownText'; + +import '@testing-library/jest-dom'; + +const normalizeHtml = (html: any) => { + return html.replace(/\s+/g, ' ').trim(); +}; + +const markdownText = ` + # Heading 1 + **Paragraph text**: *Bold with one asterisk* **Bold with two asterisks** Lorem ipsum dolor sit amet, consectetur adipiscing elit. + ## Heading 2 + _Italic Text_: _Italic with one underscore_ __Italic with two underscores__ Lorem ipsum dolor sit amet, consectetur adipiscing elit. + ### Heading 3 + Lists, Links and elements + **Unordered List** + - List Item 1 + - List Item 2 + - List Item 3 + - List Item 4 + **Ordered List** + 1. List Item 1 + 2. List Item 2 + 3. List Item 3 + 4. List Item 4 + **Links:** + [Rocket.Chat](rocket.chat) + gabriel.engel@rocket.chat + +55991999999 + \`Inline code\` + \`\`\`typescript + const test = 'this is code' + \`\`\` +`; + +it('should render html elements as expected using default parser', async () => { + const { container } = render(, { + wrapper: mockAppRoot().build(), + legacyRoot: true, + }); + + const normalizedHtml = normalizeHtml(container.innerHTML); + + expect(normalizedHtml).toContain('

Heading 1

'); + expect(normalizedHtml).toContain( + 'Paragraph text: Bold with one asterisk Bold with two asterisks Lorem ipsum dolor sit amet', + ); + expect(normalizedHtml).toContain('

Heading 2

'); + expect(normalizedHtml).toContain( + 'Italic Text: Italic with one underscore Italic with two underscores Lorem ipsum dolor sit amet', + ); + expect(normalizedHtml).toContain('

Heading 3

'); + expect(normalizedHtml).toContain('
  • List Item 1
  • List Item 2
  • List Item 3
  • List Item 4'); + expect(normalizedHtml).toContain('
    1. List Item 1
    2. List Item 2
    3. List Item 3
    4. List Item 4'); + expect(normalizedHtml).toContain('Rocket.Chat'); + expect(normalizedHtml).toContain('gabriel.engel@rocket.chat'); + expect(normalizedHtml).toContain('+55991999999'); + expect(normalizedHtml).toContain('Inline code'); + expect(normalizedHtml).toContain('
      const test = \'this is code\' 
      '); +}); + +it('should render html elements as expected using inline parser', async () => { + const { container } = render(, { + wrapper: mockAppRoot().build(), + legacyRoot: true, + }); + + const normalizedHtml = normalizeHtml(container.innerHTML); + + expect(normalizedHtml).toContain('# Heading 1'); + expect(normalizedHtml).toContain( + 'Bold with one asterisk Bold with two asterisks Lorem ipsum dolor sit amet, consectetur adipiscing elit.', + ); + expect(normalizedHtml).toContain('## Heading 2'); + expect(normalizedHtml).toContain( + 'Italic Text: Italic with one underscore Italic with two underscores Lorem ipsum dolor sit amet', + ); + expect(normalizedHtml).toContain('### Heading 3'); + expect(normalizedHtml).toContain('Unordered List - List Item 1 - List Item 2 - List Item 3 - List Item 4'); + expect(normalizedHtml).toContain('Ordered List 1. List Item 1 2. List Item 2 3. List Item 3 4. List Item 4'); + expect(normalizedHtml).toContain(`Rocket.Chat`); + expect(normalizedHtml).toContain( + `gabriel.engel@rocket.chat`, + ); + expect(normalizedHtml).toContain('+55991999999'); + expect(normalizedHtml).toContain('Inline code'); + expect(normalizedHtml).toContain(`typescript const test = 'this is code'`); +}); diff --git a/apps/meteor/client/components/MarkdownText.tsx b/apps/meteor/client/components/MarkdownText.tsx index c9af942f6e1c..0b7d2efa780e 100644 --- a/apps/meteor/client/components/MarkdownText.tsx +++ b/apps/meteor/client/components/MarkdownText.tsx @@ -1,10 +1,10 @@ import { Box } from '@rocket.chat/fuselage'; import { isExternal, getBaseURI } from '@rocket.chat/ui-client'; -import { useTranslation } from '@rocket.chat/ui-contexts'; import dompurify from 'dompurify'; import { marked } from 'marked'; import type { ComponentProps } from 'react'; import React, { useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; import { renderMessageEmoji } from '../lib/utils/renderMessageEmoji'; @@ -20,12 +20,18 @@ const documentRenderer = new marked.Renderer(); const inlineRenderer = new marked.Renderer(); const inlineWithoutBreaks = new marked.Renderer(); -marked.Lexer.rules.gfm = { - ...marked.Lexer.rules.gfm, - strong: /^\*\*(?=\S)([\s\S]*?\S)\*\*(?!\*)|^\*(?=\S)([\s\S]*?\S)\*(?!\*)/, - em: /^__(?=\S)([\s\S]*?\S)__(?!_)|^_(?=\S)([\s\S]*?\S)_(?!_)/, +const walkTokens = (token: marked.Token) => { + const boldPattern = /^\*[^*]+\*$|^\*\*[^*]+\*\*$/; + const italicPattern = /^__(?=\S)([\s\S]*?\S)__(?!_)|^_(?=\S)([\s\S]*?\S)_(?!_)/; + if (boldPattern.test(token.raw)) { + token.type = 'strong'; + } else if (italicPattern.test(token.raw)) { + token.type = 'em'; + } }; +marked.use({ walkTokens }); + const linkMarked = (href: string | null, _title: string | null, text: string): string => `${text} `; const paragraphMarked = (text: string): string => text; @@ -89,7 +95,7 @@ const MarkdownText = ({ ...props }: MarkdownTextProps) => { const sanitizer = dompurify.sanitize; - const t = useTranslation(); + const { t } = useTranslation(); let markedOptions: marked.MarkedOptions; const schemes = 'http,https,notes,ftp,ftps,tel,mailto,sms,cid'; diff --git a/apps/meteor/client/components/Omnichannel/modals/CloseChatModal.tsx b/apps/meteor/client/components/Omnichannel/modals/CloseChatModal.tsx index 7c028fb5c876..489eb4a04602 100644 --- a/apps/meteor/client/components/Omnichannel/modals/CloseChatModal.tsx +++ b/apps/meteor/client/components/Omnichannel/modals/CloseChatModal.tsx @@ -84,11 +84,11 @@ const CloseChatModal = ({ const requestData = transcriptEmail && visitorEmail ? { email: visitorEmail, subject } : undefined; if (!comment?.trim() && commentRequired) { - setError('comment', { type: 'custom', message: t('The_field_is_required', t('Comment')) }); + setError('comment', { type: 'custom', message: t('Required_field', { field: t('Comment') }) }); } if (transcriptEmail && !subject) { - setError('subject', { type: 'custom', message: t('The_field_is_required', t('Subject')) }); + setError('subject', { type: 'custom', message: t('Required_field', { field: t('Subject') }) }); } if (!tags?.length && tagRequired) { @@ -154,12 +154,7 @@ const CloseChatModal = ({ @@ -206,12 +201,7 @@ const CloseChatModal = ({ diff --git a/apps/meteor/client/components/Omnichannel/modals/ReturnChatQueueModal.tsx b/apps/meteor/client/components/Omnichannel/modals/ReturnChatQueueModal.tsx index 04fcb29eed01..b19ccfee1769 100644 --- a/apps/meteor/client/components/Omnichannel/modals/ReturnChatQueueModal.tsx +++ b/apps/meteor/client/components/Omnichannel/modals/ReturnChatQueueModal.tsx @@ -1,6 +1,6 @@ import { Button, Modal } from '@rocket.chat/fuselage'; -import { useTranslation } from '@rocket.chat/ui-contexts'; import React from 'react'; +import { useTranslation } from 'react-i18next'; type ReturnChatQueueModalProps = { onMoveChat: () => void; @@ -8,7 +8,7 @@ type ReturnChatQueueModalProps = { }; const ReturnChatQueueModal = ({ onCancel, onMoveChat, ...props }: ReturnChatQueueModalProps) => { - const t = useTranslation(); + const { t } = useTranslation(); return ( diff --git a/apps/meteor/client/components/Omnichannel/modals/TranscriptModal.tsx b/apps/meteor/client/components/Omnichannel/modals/TranscriptModal.tsx index c06b6a190465..de13ee3aa9a4 100644 --- a/apps/meteor/client/components/Omnichannel/modals/TranscriptModal.tsx +++ b/apps/meteor/client/components/Omnichannel/modals/TranscriptModal.tsx @@ -1,8 +1,8 @@ import type { IOmnichannelRoom } from '@rocket.chat/core-typings'; import { Field, Button, TextInput, Modal, Box, FieldGroup, FieldLabel, FieldRow, FieldError } from '@rocket.chat/fuselage'; -import { useTranslation } from '@rocket.chat/ui-contexts'; import React, { useCallback, useEffect } from 'react'; import { useForm } from 'react-hook-form'; +import { useTranslation } from 'react-i18next'; type TranscriptModalProps = { email: string; @@ -14,7 +14,7 @@ type TranscriptModalProps = { }; const TranscriptModal = ({ email: emailDefault = '', room, onRequest, onSend, onCancel, onDiscard, ...props }: TranscriptModalProps) => { - const t = useTranslation(); + const { t } = useTranslation(); const { register, @@ -74,7 +74,7 @@ const TranscriptModal = ({ email: emailDefault = '', room, onRequest, onSend, on disabled={!!emailDefault || !!transcriptRequest} error={errors.email?.message} flexGrow={1} - {...register('email', { required: t('The_field_is_required', t('Email')) })} + {...register('email', { required: t('Required_field', { field: t('Email') }) })} /> {errors.email?.message} @@ -86,7 +86,7 @@ const TranscriptModal = ({ email: emailDefault = '', room, onRequest, onSend, on disabled={!!transcriptRequest} error={errors.subject?.message} flexGrow={1} - {...register('subject', { required: t('The_field_is_required', t('Subject')) })} + {...register('subject', { required: t('Required_field', { field: t('Subject') }) })} /> {errors.subject?.message} diff --git a/apps/meteor/client/components/Sidebar/Header.tsx b/apps/meteor/client/components/Sidebar/Header.tsx index e4bf5a5e7041..dcbf7f1cb505 100644 --- a/apps/meteor/client/components/Sidebar/Header.tsx +++ b/apps/meteor/client/components/Sidebar/Header.tsx @@ -1,7 +1,7 @@ import { Box, IconButton } from '@rocket.chat/fuselage'; -import { useTranslation } from '@rocket.chat/ui-contexts'; import type { ReactNode } from 'react'; import React from 'react'; +import { useTranslation } from 'react-i18next'; type HeaderProps = { children?: ReactNode; @@ -10,7 +10,7 @@ type HeaderProps = { }; const Header = ({ title, onClose, children, ...props }: HeaderProps) => { - const t = useTranslation(); + const { t } = useTranslation(); return ( diff --git a/apps/meteor/client/components/Sidebar/SidebarItemsAssembler.tsx b/apps/meteor/client/components/Sidebar/SidebarItemsAssembler.tsx index 45eb6094572a..b588e223d922 100644 --- a/apps/meteor/client/components/Sidebar/SidebarItemsAssembler.tsx +++ b/apps/meteor/client/components/Sidebar/SidebarItemsAssembler.tsx @@ -1,6 +1,6 @@ import { Divider } from '@rocket.chat/fuselage'; -import { useTranslation } from '@rocket.chat/ui-contexts'; import React, { Fragment, memo } from 'react'; +import { useTranslation } from 'react-i18next'; import type { SidebarItem } from '../../lib/createSidebarItems'; import { isSidebarItem } from '../../lib/createSidebarItems'; @@ -12,7 +12,7 @@ type SidebarItemsAssemblerProps = { }; const SidebarItemsAssembler = ({ items, currentPath }: SidebarItemsAssemblerProps) => { - const t = useTranslation(); + const { t, i18n } = useTranslation(); return ( <> @@ -25,7 +25,7 @@ const SidebarItemsAssembler = ({ items, currentPath }: SidebarItemsAssemblerProp icon={props.icon} label={t((props.i18nLabel || props.name) as Parameters[0])} currentPath={currentPath} - tag={props.tag && t.has(props.tag) ? t(props.tag) : props.tag} + tag={props.tag && i18n.exists(props.tag) ? t(props.tag) : props.tag} externalUrl={props.externalUrl} badge={props.badge} /> diff --git a/apps/meteor/client/components/SidebarToggler/SidebarTogglerButton.tsx b/apps/meteor/client/components/SidebarToggler/SidebarTogglerButton.tsx index cd0c25f03d86..1eefc50baabd 100644 --- a/apps/meteor/client/components/SidebarToggler/SidebarTogglerButton.tsx +++ b/apps/meteor/client/components/SidebarToggler/SidebarTogglerButton.tsx @@ -1,6 +1,6 @@ import { Box, IconButton } from '@rocket.chat/fuselage'; -import { useTranslation } from '@rocket.chat/ui-contexts'; import React from 'react'; +import { useTranslation } from 'react-i18next'; import SidebarTogglerBadge from './SidebarTogglerBadge'; @@ -10,7 +10,7 @@ type SideBarTogglerButtonProps = { }; const SideBarTogglerButton = ({ badge, onClick }: SideBarTogglerButtonProps) => { - const t = useTranslation(); + const { t } = useTranslation(); return ( diff --git a/apps/meteor/client/components/TextCopy.tsx b/apps/meteor/client/components/TextCopy.tsx index 467e954ddd65..f9e2ffc62284 100644 --- a/apps/meteor/client/components/TextCopy.tsx +++ b/apps/meteor/client/components/TextCopy.tsx @@ -1,7 +1,7 @@ import { Box, Button, Scrollable } from '@rocket.chat/fuselage'; -import { useTranslation } from '@rocket.chat/ui-contexts'; import type { ComponentProps, ReactElement } from 'react'; import React from 'react'; +import { useTranslation } from 'react-i18next'; import useClipboardWithToast from '../hooks/useClipboardWithToast'; @@ -17,7 +17,7 @@ type TextCopyProps = { } & ComponentProps; const TextCopy = ({ text, wrapper = defaultWrapperRenderer, ...props }: TextCopyProps): ReactElement => { - const t = useTranslation(); + const { t } = useTranslation(); const { copy } = useClipboardWithToast(text); diff --git a/apps/meteor/client/components/TwoFactorModal/TwoFactorPasswordModal.tsx b/apps/meteor/client/components/TwoFactorModal/TwoFactorPasswordModal.tsx index 4c91e274de68..4867d171aee8 100644 --- a/apps/meteor/client/components/TwoFactorModal/TwoFactorPasswordModal.tsx +++ b/apps/meteor/client/components/TwoFactorModal/TwoFactorPasswordModal.tsx @@ -1,8 +1,8 @@ import { Box, PasswordInput, FieldGroup, Field, FieldLabel, FieldRow, FieldError } from '@rocket.chat/fuselage'; import { useAutoFocus, useUniqueId } from '@rocket.chat/fuselage-hooks'; -import { useTranslation } from '@rocket.chat/ui-contexts'; import type { ReactElement, ChangeEvent, Ref, SyntheticEvent } from 'react'; import React, { useState } from 'react'; +import { useTranslation } from 'react-i18next'; import GenericModal from '../GenericModal'; import type { OnConfirm } from './TwoFactorModal'; @@ -15,7 +15,7 @@ type TwoFactorPasswordModalProps = { }; const TwoFactorPasswordModal = ({ onConfirm, onClose, invalidAttempt }: TwoFactorPasswordModalProps): ReactElement => { - const t = useTranslation(); + const { t } = useTranslation(); const [code, setCode] = useState(''); const ref = useAutoFocus(); diff --git a/apps/meteor/client/components/TwoFactorModal/TwoFactorTotpModal.tsx b/apps/meteor/client/components/TwoFactorModal/TwoFactorTotpModal.tsx index 6f36c9c8ce26..d6f167be8588 100644 --- a/apps/meteor/client/components/TwoFactorModal/TwoFactorTotpModal.tsx +++ b/apps/meteor/client/components/TwoFactorModal/TwoFactorTotpModal.tsx @@ -1,8 +1,8 @@ import { Box, TextInput, Field, FieldGroup, FieldLabel, FieldRow, FieldError } from '@rocket.chat/fuselage'; import { useAutoFocus, useUniqueId } from '@rocket.chat/fuselage-hooks'; -import { useTranslation } from '@rocket.chat/ui-contexts'; import type { ReactElement, ChangeEvent, SyntheticEvent } from 'react'; import React, { useState } from 'react'; +import { useTranslation } from 'react-i18next'; import GenericModal from '../GenericModal'; import type { OnConfirm } from './TwoFactorModal'; @@ -15,7 +15,7 @@ type TwoFactorTotpModalProps = { }; const TwoFactorTotpModal = ({ onConfirm, onClose, invalidAttempt }: TwoFactorTotpModalProps): ReactElement => { - const t = useTranslation(); + const { t } = useTranslation(); const [code, setCode] = useState(''); const ref = useAutoFocus(); diff --git a/apps/meteor/client/components/UrlChangeModal.tsx b/apps/meteor/client/components/UrlChangeModal.tsx index 13d0523c92aa..dbc152c7fbff 100644 --- a/apps/meteor/client/components/UrlChangeModal.tsx +++ b/apps/meteor/client/components/UrlChangeModal.tsx @@ -1,7 +1,7 @@ import { Box } from '@rocket.chat/fuselage'; -import { useTranslation } from '@rocket.chat/ui-contexts'; import type { ReactElement } from 'react'; import React from 'react'; +import { useTranslation } from 'react-i18next'; import GenericModal from './GenericModal'; @@ -13,14 +13,17 @@ type UrlChangeModalProps = { }; const UrlChangeModal = ({ onConfirm, siteUrl, currentUrl, onClose }: UrlChangeModalProps): ReactElement => { - const t = useTranslation(); + const { t } = useTranslation(); return (

      diff --git a/apps/meteor/client/components/UserCard/UserCard.tsx b/apps/meteor/client/components/UserCard/UserCard.tsx index bf143229cf65..98e1cce2ab78 100644 --- a/apps/meteor/client/components/UserCard/UserCard.tsx +++ b/apps/meteor/client/components/UserCard/UserCard.tsx @@ -1,9 +1,9 @@ import { css } from '@rocket.chat/css-in-js'; 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 } from 'react'; import React from 'react'; +import { useTranslation } from 'react-i18next'; import { useEmbeddedLayout } from '../../hooks/useEmbeddedLayout'; import MarkdownText from '../MarkdownText'; @@ -52,7 +52,7 @@ const UserCard = ({ nickname, ...props }: UserCardProps) => { - const t = useTranslation(); + const { t } = useTranslation(); const isLayoutEmbedded = useEmbeddedLayout(); return ( diff --git a/apps/meteor/client/components/UserInfo/UserInfo.tsx b/apps/meteor/client/components/UserInfo/UserInfo.tsx index a7f32f82f454..ac879d21738b 100644 --- a/apps/meteor/client/components/UserInfo/UserInfo.tsx +++ b/apps/meteor/client/components/UserInfo/UserInfo.tsx @@ -1,9 +1,9 @@ import type { IUser, Serialized } from '@rocket.chat/core-typings'; import { Box, Margins, Tag } from '@rocket.chat/fuselage'; import type { TranslationKey } from '@rocket.chat/ui-contexts'; -import { useTranslation } from '@rocket.chat/ui-contexts'; import type { ReactElement, ReactNode } from 'react'; import React, { memo } from 'react'; +import { useTranslation } from 'react-i18next'; import { useTimeAgo } from '../../hooks/useTimeAgo'; import { useUserCustomFields } from '../../hooks/useUserCustomFields'; @@ -72,7 +72,7 @@ const UserInfo = ({ reason, ...props }: UserInfoProps): ReactElement => { - const t = useTranslation(); + const { t } = useTranslation(); const timeAgo = useTimeAgo(); const userDisplayName = useUserDisplayName({ name, username }); const userCustomFields = useUserCustomFields(customFields); diff --git a/apps/meteor/client/components/WarningModal.tsx b/apps/meteor/client/components/WarningModal.tsx index 00ae92e1d3bd..db0697f78c0d 100644 --- a/apps/meteor/client/components/WarningModal.tsx +++ b/apps/meteor/client/components/WarningModal.tsx @@ -1,7 +1,7 @@ import { Button, Modal } from '@rocket.chat/fuselage'; -import { useTranslation } from '@rocket.chat/ui-contexts'; import type { ReactElement, ReactNode } from 'react'; import React from 'react'; +import { useTranslation } from 'react-i18next'; type WarningModalProps = { text: ReactNode; @@ -13,7 +13,7 @@ type WarningModalProps = { }; const WarningModal = ({ text, confirmText, close, cancel, cancelText, confirm, ...props }: WarningModalProps): ReactElement => { - const t = useTranslation(); + const { t } = useTranslation(); return ( diff --git a/apps/meteor/client/components/dashboards/PeriodSelector.tsx b/apps/meteor/client/components/dashboards/PeriodSelector.tsx index 6977e3334f4f..d441fbf2f19a 100644 --- a/apps/meteor/client/components/dashboards/PeriodSelector.tsx +++ b/apps/meteor/client/components/dashboards/PeriodSelector.tsx @@ -1,7 +1,7 @@ import { Select } from '@rocket.chat/fuselage'; -import { useTranslation } from '@rocket.chat/ui-contexts'; import type { ReactElement } from 'react'; import React, { useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; import type { Period } from './periods'; import { getPeriod } from './periods'; @@ -14,9 +14,9 @@ type PeriodSelectorProps = { }; const PeriodSelector = ({ periods, value, name, onChange }: PeriodSelectorProps): ReactElement => { - const t = useTranslation(); + const { t } = useTranslation(); - const options = useMemo<[string, string][]>(() => periods.map((period) => [period, t(...getPeriod(period).label)]), [periods, t]); + const options = useMemo<[string, string][]>(() => periods.map((period) => [period, t(getPeriod(period).label)]), [periods, t]); return ( ( ( { - const cdnAvatarUrl = String(useSetting('CDN_PREFIX') || ''); const contextValue = useMemo( () => ({ getUserPathAvatar: ((): ((uid: string, etag?: string) => string) => { - if (cdnAvatarUrl) { - return (uid: string, etag?: string): string => `${cdnAvatarUrl}/avatar/${uid}${etag ? `?etag=${etag}` : ''}`; - } return (uid: string, etag?: string): string => getURL(`/avatar/${uid}${etag ? `?etag=${etag}` : ''}`); })(), getRoomPathAvatar: ({ type, ...room }: any): string => roomCoordinator.getRoomDirectives(type || room.t).getAvatarPath({ username: room._id, ...room }) || '', }), - [cdnAvatarUrl], + [], ); return ; diff --git a/apps/meteor/client/providers/UserProvider/UserProvider.tsx b/apps/meteor/client/providers/UserProvider/UserProvider.tsx index 27bba21eae95..53761fbef4e7 100644 --- a/apps/meteor/client/providers/UserProvider/UserProvider.tsx +++ b/apps/meteor/client/providers/UserProvider/UserProvider.tsx @@ -19,8 +19,6 @@ import { useDeleteUser } from './hooks/useDeleteUser'; import { useEmailVerificationWarning } from './hooks/useEmailVerificationWarning'; import { useUpdateAvatar } from './hooks/useUpdateAvatar'; -const getUserId = (): string | null => Meteor.userId(); - const getUser = (): IUser | null => Meteor.user() as IUser | null; const logout = (): Promise => @@ -42,9 +40,9 @@ type UserProviderProps = { }; const UserProvider = ({ children }: UserProviderProps): ReactElement => { - const userId = useReactiveValue(getUserId); - const previousUserId = useRef(userId); const user = useReactiveValue(getUser); + const userId = user?._id ?? null; + const previousUserId = useRef(userId); const [userLanguage, setUserLanguage] = useLocalStorage('userLanguage', ''); const [preferedLanguage, setPreferedLanguage] = useLocalStorage('preferedLanguage', ''); diff --git a/apps/meteor/client/sidebar/RoomList/RoomListWrapper.tsx b/apps/meteor/client/sidebar/RoomList/RoomListWrapper.tsx index 6779fb8d6c82..791c2189ec6b 100644 --- a/apps/meteor/client/sidebar/RoomList/RoomListWrapper.tsx +++ b/apps/meteor/client/sidebar/RoomList/RoomListWrapper.tsx @@ -1,12 +1,12 @@ import { useMergedRefs } from '@rocket.chat/fuselage-hooks'; -import { useTranslation } from '@rocket.chat/ui-contexts'; import type { HTMLAttributes, Ref } from 'react'; import React, { forwardRef } from 'react'; +import { useTranslation } from 'react-i18next'; import { useSidebarListNavigation } from './useSidebarListNavigation'; const RoomListWrapper = forwardRef(function RoomListWrapper(props: HTMLAttributes, ref: Ref) { - const t = useTranslation(); + const { t } = useTranslation(); const { sidebarListRef } = useSidebarListNavigation(); const mergedRefs = useMergedRefs(ref, sidebarListRef); diff --git a/apps/meteor/client/sidebar/RoomList/SideBarItemTemplateWithData.tsx b/apps/meteor/client/sidebar/RoomList/SideBarItemTemplateWithData.tsx index cc7cdfbe7761..35d098151204 100644 --- a/apps/meteor/client/sidebar/RoomList/SideBarItemTemplateWithData.tsx +++ b/apps/meteor/client/sidebar/RoomList/SideBarItemTemplateWithData.tsx @@ -167,7 +167,7 @@ function SideBarItemTemplateWithData({ const badges = ( {showBadge && isUnread && ( - + {unread + tunread?.length} )} @@ -180,6 +180,7 @@ function SideBarItemTemplateWithData({ is='a' id={id} data-qa='sidebar-item' + data-unread={highlighted} unread={highlighted} selected={selected} href={href} @@ -205,7 +206,7 @@ function SideBarItemTemplateWithData({ threadUnread={threadUnread} rid={rid} unread={!!unread} - roomOpen={false} + roomOpen={selected} type={type} cl={cl} name={title} diff --git a/apps/meteor/client/sidebar/RoomMenu.tsx b/apps/meteor/client/sidebar/RoomMenu.tsx index 06b1352d2803..05f02220104b 100644 --- a/apps/meteor/client/sidebar/RoomMenu.tsx +++ b/apps/meteor/client/sidebar/RoomMenu.tsx @@ -13,6 +13,7 @@ import { useTranslation, useEndpoint, } from '@rocket.chat/ui-contexts'; +import { useQueryClient } from '@tanstack/react-query'; import type { ReactElement } from 'react'; import React, { memo, useMemo } from 'react'; @@ -100,6 +101,8 @@ const RoomMenu = ({ const isOmnichannelRoom = type === 'l'; const prioritiesMenu = useOmnichannelPrioritiesMenu(rid); + const queryClient = useQueryClient(); + const canLeave = ((): boolean => { if (type === 'c' && !canLeaveChannel) { return false; @@ -173,17 +176,22 @@ const RoomMenu = ({ const handleToggleRead = useMutableCallback(async () => { try { + queryClient.invalidateQueries(['sidebar/search/spotlight']); + if (isUnread) { await readMessages({ rid, readThreads: true }); return; } - await unreadMessages(undefined, rid); + if (subscription == null) { return; } + LegacyRoomManager.close(subscription.t + subscription.name); router.navigate('/home'); + + await unreadMessages(undefined, rid); } catch (error) { dispatchToastMessage({ type: 'error', message: error }); } diff --git a/apps/meteor/client/sidebar/footer/SidebarFooterWatermark.tsx b/apps/meteor/client/sidebar/footer/SidebarFooterWatermark.tsx index bf7736b5899a..1f66217d76e5 100644 --- a/apps/meteor/client/sidebar/footer/SidebarFooterWatermark.tsx +++ b/apps/meteor/client/sidebar/footer/SidebarFooterWatermark.tsx @@ -1,12 +1,12 @@ import { Box } from '@rocket.chat/fuselage'; -import { useTranslation } from '@rocket.chat/ui-contexts'; import type { ReactElement } from 'react'; import React from 'react'; +import { useTranslation } from 'react-i18next'; import { useLicense, useLicenseName } from '../../hooks/useLicense'; export const SidebarFooterWatermark = (): ReactElement | null => { - const t = useTranslation(); + const { t } = useTranslation(); const response = useLicense(); diff --git a/apps/meteor/client/sidebar/footer/voip/VoipFooter.tsx b/apps/meteor/client/sidebar/footer/voip/VoipFooter.tsx index 119476ecd89a..ba7bbd977e03 100644 --- a/apps/meteor/client/sidebar/footer/voip/VoipFooter.tsx +++ b/apps/meteor/client/sidebar/footer/voip/VoipFooter.tsx @@ -2,9 +2,9 @@ import type { IVoipRoom, ICallerInfo, VoIpCallerInfo } from '@rocket.chat/core-t import { VoipClientEvents } from '@rocket.chat/core-typings'; import { css } from '@rocket.chat/css-in-js'; import { Box, Button, ButtonGroup, SidebarFooter, Menu, IconButton } from '@rocket.chat/fuselage'; -import { useTranslation } from '@rocket.chat/ui-contexts'; import type { ReactElement, MouseEvent, ReactNode } from 'react'; import React from 'react'; +import { useTranslation } from 'react-i18next'; import type { CallActionsType } from '../../../contexts/CallContext'; import type { VoipFooterMenuOptions } from '../../../hooks/useVoipFooterMenu'; @@ -50,7 +50,7 @@ export const VoipFooter = ({ options, }: VoipFooterPropsType): ReactElement => { const contactLabel = useOmnichannelContactLabel(caller); - const t = useTranslation(); + const { t } = useTranslation(); const cssClickable = callerState === 'IN_CALL' || callerState === 'ON_HOLD' diff --git a/apps/meteor/client/sidebar/header/CreateChannel/CreateChannelModal.tsx b/apps/meteor/client/sidebar/header/CreateChannel/CreateChannelModal.tsx index 27ad314b2ab8..03e1122da773 100644 --- a/apps/meteor/client/sidebar/header/CreateChannel/CreateChannelModal.tsx +++ b/apps/meteor/client/sidebar/header/CreateChannel/CreateChannelModal.tsx @@ -222,7 +222,7 @@ const CreateChannelModal = ({ teamId = '', onClose, reload }: CreateChannelModal id={nameId} data-qa-type='channel-name-input' {...register('name', { - required: t('error-the-field-is-required', { field: t('Name') }), + required: t('Required_field', { field: t('Name') }), validate: (value) => validateChannelName(value), })} error={errors.name?.message} diff --git a/apps/meteor/client/sidebar/header/CreateTeam/CreateTeamModal.tsx b/apps/meteor/client/sidebar/header/CreateTeam/CreateTeamModal.tsx index ad1abdcbf399..3543234bca96 100644 --- a/apps/meteor/client/sidebar/header/CreateTeam/CreateTeamModal.tsx +++ b/apps/meteor/client/sidebar/header/CreateTeam/CreateTeamModal.tsx @@ -181,7 +181,7 @@ const CreateTeamModal = ({ onClose }: { onClose: () => void }): ReactElement => id={nameId} aria-invalid={errors.name ? 'true' : 'false'} {...register('name', { - required: t('error-the-field-is-required', { field: t('Name') }), + required: t('Required_field', { field: t('Name') }), validate: (value) => validateTeamName(value), })} addon={} diff --git a/apps/meteor/client/sidebar/header/MatrixFederationSearch/FederatedRoomListEmptyPlaceholder.tsx b/apps/meteor/client/sidebar/header/MatrixFederationSearch/FederatedRoomListEmptyPlaceholder.tsx index 8f0a26222679..43e6be7c583d 100644 --- a/apps/meteor/client/sidebar/header/MatrixFederationSearch/FederatedRoomListEmptyPlaceholder.tsx +++ b/apps/meteor/client/sidebar/header/MatrixFederationSearch/FederatedRoomListEmptyPlaceholder.tsx @@ -1,11 +1,11 @@ import { Box } from '@rocket.chat/fuselage'; -import { useTranslation } from '@rocket.chat/ui-contexts'; import React from 'react'; +import { useTranslation } from 'react-i18next'; import GenericNoResults from '../../../components/GenericNoResults'; const FederatedRoomListEmptyPlaceholder = () => { - const t = useTranslation(); + const { t } = useTranslation(); return ( diff --git a/apps/meteor/client/sidebar/header/MatrixFederationSearch/FederatedRoomListErrorBoundary.tsx b/apps/meteor/client/sidebar/header/MatrixFederationSearch/FederatedRoomListErrorBoundary.tsx index f3307436bc07..fd37aac816f4 100644 --- a/apps/meteor/client/sidebar/header/MatrixFederationSearch/FederatedRoomListErrorBoundary.tsx +++ b/apps/meteor/client/sidebar/header/MatrixFederationSearch/FederatedRoomListErrorBoundary.tsx @@ -1,9 +1,9 @@ import { States, StatesIcon, StatesTitle, StatesSubtitle, StatesActions, StatesAction, Icon } from '@rocket.chat/fuselage'; -import { useTranslation } from '@rocket.chat/ui-contexts'; import { QueryErrorResetBoundary } from '@tanstack/react-query'; import type { ReactElement, ReactNode } from 'react'; import React from 'react'; import { ErrorBoundary } from 'react-error-boundary'; +import { useTranslation } from 'react-i18next'; type FederatedRoomListErrorBoundaryProps = { children?: ReactNode; @@ -11,7 +11,7 @@ type FederatedRoomListErrorBoundaryProps = { }; const FederatedRoomListErrorBoundary = ({ children, resetKeys }: FederatedRoomListErrorBoundaryProps): ReactElement => { - const t = useTranslation(); + const { t } = useTranslation(); return ( {({ reset }): ReactElement => ( diff --git a/apps/meteor/client/sidebar/header/MatrixFederationSearch/FederatedRoomListItem.tsx b/apps/meteor/client/sidebar/header/MatrixFederationSearch/FederatedRoomListItem.tsx index 01cb22c2e1c5..c096c74c08a4 100644 --- a/apps/meteor/client/sidebar/header/MatrixFederationSearch/FederatedRoomListItem.tsx +++ b/apps/meteor/client/sidebar/header/MatrixFederationSearch/FederatedRoomListItem.tsx @@ -2,8 +2,8 @@ import { css } from '@rocket.chat/css-in-js'; import { Box, Button, Icon } from '@rocket.chat/fuselage'; import { useUniqueId } from '@rocket.chat/fuselage-hooks'; import type { IFederationPublicRooms } from '@rocket.chat/rest-typings'; -import { useTranslation } from '@rocket.chat/ui-contexts'; import React from 'react'; +import { useTranslation } from 'react-i18next'; type FederatedRoomListItemProps = IFederationPublicRooms & { disabled: boolean; @@ -23,7 +23,7 @@ const FederatedRoomListItem = ({ canJoin, disabled, }: FederatedRoomListItemProps) => { - const t = useTranslation(); + const { t } = useTranslation(); const nameId = useUniqueId(); return ( diff --git a/apps/meteor/client/sidebar/header/MatrixFederationSearch/MatrixFederationSearch.tsx b/apps/meteor/client/sidebar/header/MatrixFederationSearch/MatrixFederationSearch.tsx index 741eadf7bc7e..0108958e3e64 100644 --- a/apps/meteor/client/sidebar/header/MatrixFederationSearch/MatrixFederationSearch.tsx +++ b/apps/meteor/client/sidebar/header/MatrixFederationSearch/MatrixFederationSearch.tsx @@ -1,7 +1,7 @@ import { Modal, Skeleton } from '@rocket.chat/fuselage'; import { useUniqueId } from '@rocket.chat/fuselage-hooks'; -import { useTranslation } from '@rocket.chat/ui-contexts'; import React from 'react'; +import { useTranslation } from 'react-i18next'; import MatrixFederationSearchModalContent from './MatrixFederationSearchModalContent'; import { useMatrixServerList } from './useMatrixServerList'; @@ -12,7 +12,7 @@ type MatrixFederationSearchProps = { }; const MatrixFederationSearch = ({ onClose, defaultSelectedServer }: MatrixFederationSearchProps) => { - const t = useTranslation(); + const { t } = useTranslation(); const { data, isLoading } = useMatrixServerList(); const titleId = useUniqueId(); diff --git a/apps/meteor/client/sidebar/header/UserMenu.tsx b/apps/meteor/client/sidebar/header/UserMenu.tsx index 592fc19656e9..7d964c2122bc 100644 --- a/apps/meteor/client/sidebar/header/UserMenu.tsx +++ b/apps/meteor/client/sidebar/header/UserMenu.tsx @@ -1,17 +1,21 @@ import type { IUser } from '@rocket.chat/core-typings'; -import { FeaturePreview, FeaturePreviewOn, FeaturePreviewOff } from '@rocket.chat/ui-client'; -import { useTranslation } from '@rocket.chat/ui-contexts'; +import { + FeaturePreview, + FeaturePreviewOn, + FeaturePreviewOff, + GenericMenu, + useHandleMenuAction, + type GenericMenuItemProps, +} from '@rocket.chat/ui-client'; import React, { useState, memo } from 'react'; +import { useTranslation } from 'react-i18next'; -import GenericMenu from '../../components/GenericMenu/GenericMenu'; -import type { GenericMenuItemProps } from '../../components/GenericMenu/GenericMenuItem'; -import { useHandleMenuAction } from '../../components/GenericMenu/hooks/useHandleMenuAction'; import UserAvatarWithStatus from './UserAvatarWithStatus'; import UserAvatarWithStatusUnstable from './UserAvatarWithStatusUnstable'; import { useUserMenu } from './hooks/useUserMenu'; const UserMenu = ({ user }: { user: IUser }) => { - const t = useTranslation(); + const { t } = useTranslation(); const [isOpen, setIsOpen] = useState(false); const sections = useUserMenu(user); diff --git a/apps/meteor/client/sidebar/header/actions/Administration.tsx b/apps/meteor/client/sidebar/header/actions/Administration.tsx index fbed4afec4cb..14c9f2cc83c5 100644 --- a/apps/meteor/client/sidebar/header/actions/Administration.tsx +++ b/apps/meteor/client/sidebar/header/actions/Administration.tsx @@ -1,15 +1,15 @@ import { Sidebar } from '@rocket.chat/fuselage'; -import { useTranslation } from '@rocket.chat/ui-contexts'; +import { GenericMenu } from '@rocket.chat/ui-client'; import type { HTMLAttributes } from 'react'; import React from 'react'; +import { useTranslation } from 'react-i18next'; -import GenericMenu from '../../../components/GenericMenu/GenericMenu'; import { useAdministrationMenu } from './hooks/useAdministrationMenu'; type AdministrationProps = Omit, 'is'>; const Administration = (props: AdministrationProps) => { - const t = useTranslation(); + const { t } = useTranslation(); const sections = useAdministrationMenu(); diff --git a/apps/meteor/client/sidebar/header/actions/CreateRoom.tsx b/apps/meteor/client/sidebar/header/actions/CreateRoom.tsx index 478e7cce33e1..3a59b0adea43 100644 --- a/apps/meteor/client/sidebar/header/actions/CreateRoom.tsx +++ b/apps/meteor/client/sidebar/header/actions/CreateRoom.tsx @@ -1,15 +1,15 @@ import { Sidebar } from '@rocket.chat/fuselage'; -import { useTranslation } from '@rocket.chat/ui-contexts'; +import { GenericMenu } from '@rocket.chat/ui-client'; import type { HTMLAttributes } from 'react'; import React from 'react'; +import { useTranslation } from 'react-i18next'; -import GenericMenu from '../../../components/GenericMenu/GenericMenu'; import { useCreateRoom } from './hooks/useCreateRoomMenu'; type CreateRoomProps = Omit, 'is'>; const CreateRoom = (props: CreateRoomProps) => { - const t = useTranslation(); + const { t } = useTranslation(); const sections = useCreateRoom(); diff --git a/apps/meteor/client/sidebar/header/actions/Sort.tsx b/apps/meteor/client/sidebar/header/actions/Sort.tsx index e7f3b398e5f6..e4d1d66cf3af 100644 --- a/apps/meteor/client/sidebar/header/actions/Sort.tsx +++ b/apps/meteor/client/sidebar/header/actions/Sort.tsx @@ -1,15 +1,15 @@ import { Sidebar } from '@rocket.chat/fuselage'; -import { useTranslation } from '@rocket.chat/ui-contexts'; +import { GenericMenu } from '@rocket.chat/ui-client'; import type { HTMLAttributes } from 'react'; import React from 'react'; +import { useTranslation } from 'react-i18next'; -import GenericMenu from '../../../components/GenericMenu/GenericMenu'; import { useSortMenu } from './hooks/useSortMenu'; type SortProps = Omit, 'is'>; const Sort = (props: SortProps) => { - const t = useTranslation(); + const { t } = useTranslation(); const sections = useSortMenu(); diff --git a/apps/meteor/client/sidebar/header/actions/hooks/useAdministrationItems.tsx b/apps/meteor/client/sidebar/header/actions/hooks/useAdministrationItems.tsx index 5e802c2d1851..9c74bacd23ce 100644 --- a/apps/meteor/client/sidebar/header/actions/hooks/useAdministrationItems.tsx +++ b/apps/meteor/client/sidebar/header/actions/hooks/useAdministrationItems.tsx @@ -1,7 +1,6 @@ +import type { GenericMenuItemProps } from '@rocket.chat/ui-client'; import { useTranslation, useRoute, useRouter, useAtLeastOnePermission, usePermission } from '@rocket.chat/ui-contexts'; -import type { GenericMenuItemProps } from '../../../../components/GenericMenu/GenericMenuItem'; - const ADMIN_PERMISSIONS = [ 'view-statistics', 'run-import', diff --git a/apps/meteor/client/sidebar/header/actions/hooks/useAdministrationMenu.tsx b/apps/meteor/client/sidebar/header/actions/hooks/useAdministrationMenu.tsx index 5be021cf0b7e..23f71d67ebc5 100644 --- a/apps/meteor/client/sidebar/header/actions/hooks/useAdministrationMenu.tsx +++ b/apps/meteor/client/sidebar/header/actions/hooks/useAdministrationMenu.tsx @@ -1,12 +1,12 @@ -import { useTranslation } from '@rocket.chat/ui-contexts'; +import type { GenericMenuItemProps } from '@rocket.chat/ui-client'; +import { useTranslation } from 'react-i18next'; -import type { GenericMenuItemProps } from '../../../../components/GenericMenu/GenericMenuItem'; import { useAdministrationItems } from './useAdministrationItems'; import { useAppsItems } from './useAppsItems'; import { useAuditItems } from './useAuditItems'; export const useAdministrationMenu = () => { - const t = useTranslation(); + const { t } = useTranslation(); const administrationItems = useAdministrationItems(); const appItems = useAppsItems(); diff --git a/apps/meteor/client/sidebar/header/actions/hooks/useAppsItems.tsx b/apps/meteor/client/sidebar/header/actions/hooks/useAppsItems.tsx index 3717f33fb195..f7b9cea0d56a 100644 --- a/apps/meteor/client/sidebar/header/actions/hooks/useAppsItems.tsx +++ b/apps/meteor/client/sidebar/header/actions/hooks/useAppsItems.tsx @@ -1,8 +1,8 @@ import { Badge, Skeleton } from '@rocket.chat/fuselage'; +import type { GenericMenuItemProps } from '@rocket.chat/ui-client'; import { useTranslation, useRoute, usePermission } from '@rocket.chat/ui-contexts'; import React from 'react'; -import type { GenericMenuItemProps } from '../../../../components/GenericMenu/GenericMenuItem'; import { useUserDropdownAppsActionButtons } from '../../../../hooks/useAppActionButtons'; import { useAppRequestStats } from '../../../../views/marketplace/hooks/useAppRequestStats'; diff --git a/apps/meteor/client/sidebar/header/actions/hooks/useAuditItems.tsx b/apps/meteor/client/sidebar/header/actions/hooks/useAuditItems.tsx index 8e2640711347..cf53f1ffef03 100644 --- a/apps/meteor/client/sidebar/header/actions/hooks/useAuditItems.tsx +++ b/apps/meteor/client/sidebar/header/actions/hooks/useAuditItems.tsx @@ -1,6 +1,6 @@ +import type { GenericMenuItemProps } from '@rocket.chat/ui-client'; import { useTranslation, useRoute, usePermission } from '@rocket.chat/ui-contexts'; -import type { GenericMenuItemProps } from '../../../../components/GenericMenu/GenericMenuItem'; import { useHasLicenseModule } from '../../../../hooks/useHasLicenseModule'; /** diff --git a/apps/meteor/client/sidebar/header/actions/hooks/useCreateRoomItems.tsx b/apps/meteor/client/sidebar/header/actions/hooks/useCreateRoomItems.tsx index 7b2770a60ab5..1bc64d1ba8d1 100644 --- a/apps/meteor/client/sidebar/header/actions/hooks/useCreateRoomItems.tsx +++ b/apps/meteor/client/sidebar/header/actions/hooks/useCreateRoomItems.tsx @@ -1,7 +1,7 @@ +import type { GenericMenuItemProps } from '@rocket.chat/ui-client'; import { useTranslation, useSetting, useAtLeastOnePermission } from '@rocket.chat/ui-contexts'; import CreateDiscussion from '../../../../components/CreateDiscussion'; -import type { GenericMenuItemProps } from '../../../../components/GenericMenu/GenericMenuItem'; import CreateChannelWithData from '../../CreateChannel'; import CreateDirectMessage from '../../CreateDirectMessage'; import CreateTeam from '../../CreateTeam'; diff --git a/apps/meteor/client/sidebar/header/actions/hooks/useGroupingListItems.tsx b/apps/meteor/client/sidebar/header/actions/hooks/useGroupingListItems.tsx index 646b85c838be..5dcb53f2ad2f 100644 --- a/apps/meteor/client/sidebar/header/actions/hooks/useGroupingListItems.tsx +++ b/apps/meteor/client/sidebar/header/actions/hooks/useGroupingListItems.tsx @@ -1,9 +1,8 @@ import { CheckBox } from '@rocket.chat/fuselage'; +import type { GenericMenuItemProps } from '@rocket.chat/ui-client'; import { useEndpoint, useUserPreference, useTranslation } from '@rocket.chat/ui-contexts'; import React, { useCallback } from 'react'; -import type { GenericMenuItemProps } from '../../../../components/GenericMenu/GenericMenuItem'; - export const useGroupingListItems = (): GenericMenuItemProps[] => { const t = useTranslation(); diff --git a/apps/meteor/client/sidebar/header/actions/hooks/useMatrixFederationItems.tsx.tsx b/apps/meteor/client/sidebar/header/actions/hooks/useMatrixFederationItems.tsx.tsx index b3ac63f13773..cd0abc9bdfb2 100644 --- a/apps/meteor/client/sidebar/header/actions/hooks/useMatrixFederationItems.tsx.tsx +++ b/apps/meteor/client/sidebar/header/actions/hooks/useMatrixFederationItems.tsx.tsx @@ -1,6 +1,6 @@ -import { useTranslation } from '@rocket.chat/ui-contexts'; +import type { GenericMenuItemProps } from '@rocket.chat/ui-client'; +import { useTranslation } from 'react-i18next'; -import type { GenericMenuItemProps } from '../../../../components/GenericMenu/GenericMenuItem'; import MatrixFederationSearch from '../../MatrixFederationSearch'; import { useCreateRoomModal } from '../../hooks/useCreateRoomModal'; @@ -9,7 +9,7 @@ export const useMatrixFederationItems = ({ }: { isMatrixEnabled: string | number | boolean | null | undefined; }): GenericMenuItemProps[] => { - const t = useTranslation(); + const { t } = useTranslation(); const searchFederatedRooms = useCreateRoomModal(MatrixFederationSearch); diff --git a/apps/meteor/client/sidebar/header/actions/hooks/useSortMenu.tsx b/apps/meteor/client/sidebar/header/actions/hooks/useSortMenu.tsx index bea1d999997e..af6a73ce6eab 100644 --- a/apps/meteor/client/sidebar/header/actions/hooks/useSortMenu.tsx +++ b/apps/meteor/client/sidebar/header/actions/hooks/useSortMenu.tsx @@ -1,11 +1,11 @@ -import { useTranslation } from '@rocket.chat/ui-contexts'; +import { useTranslation } from 'react-i18next'; import { useGroupingListItems } from './useGroupingListItems'; import { useSortModeItems } from './useSortModeItems'; import { useViewModeItems } from './useViewModeItems'; export const useSortMenu = () => { - const t = useTranslation(); + const { t } = useTranslation(); const viewModeItems = useViewModeItems(); const sortModeItems = useSortModeItems(); diff --git a/apps/meteor/client/sidebar/header/actions/hooks/useSortModeItems.tsx b/apps/meteor/client/sidebar/header/actions/hooks/useSortModeItems.tsx index 56041ab4e571..0e2bf818a0bf 100644 --- a/apps/meteor/client/sidebar/header/actions/hooks/useSortModeItems.tsx +++ b/apps/meteor/client/sidebar/header/actions/hooks/useSortModeItems.tsx @@ -1,8 +1,8 @@ import { RadioButton } from '@rocket.chat/fuselage'; +import type { GenericMenuItemProps } from '@rocket.chat/ui-client'; import { useEndpoint, useUserPreference, useTranslation } from '@rocket.chat/ui-contexts'; import React, { useCallback } from 'react'; -import type { GenericMenuItemProps } from '../../../../components/GenericMenu/GenericMenuItem'; import { OmnichannelSortingDisclaimer, useOmnichannelSortingDisclaimer, diff --git a/apps/meteor/client/sidebar/header/actions/hooks/useViewModeItems.tsx b/apps/meteor/client/sidebar/header/actions/hooks/useViewModeItems.tsx index ca2855d09db5..a33230a15729 100644 --- a/apps/meteor/client/sidebar/header/actions/hooks/useViewModeItems.tsx +++ b/apps/meteor/client/sidebar/header/actions/hooks/useViewModeItems.tsx @@ -1,9 +1,8 @@ import { RadioButton, ToggleSwitch } from '@rocket.chat/fuselage'; +import type { GenericMenuItemProps } from '@rocket.chat/ui-client'; import { useEndpoint, useUserPreference, useTranslation } from '@rocket.chat/ui-contexts'; import React, { useCallback } from 'react'; -import type { GenericMenuItemProps } from '../../../../components/GenericMenu/GenericMenuItem'; - export const useViewModeItems = (): GenericMenuItemProps[] => { const t = useTranslation(); diff --git a/apps/meteor/client/sidebar/header/hooks/useAccountItems.tsx b/apps/meteor/client/sidebar/header/hooks/useAccountItems.tsx index 1f18d826843d..51ab7a198a67 100644 --- a/apps/meteor/client/sidebar/header/hooks/useAccountItems.tsx +++ b/apps/meteor/client/sidebar/header/hooks/useAccountItems.tsx @@ -1,16 +1,15 @@ import { Badge } from '@rocket.chat/fuselage'; import { useMutableCallback } from '@rocket.chat/fuselage-hooks'; -import { defaultFeaturesPreview, useFeaturePreviewList } from '@rocket.chat/ui-client'; +import type { GenericMenuItemProps } from '@rocket.chat/ui-client'; +import { defaultFeaturesPreview, usePreferenceFeaturePreviewList } from '@rocket.chat/ui-client'; import { useRouter, useTranslation } from '@rocket.chat/ui-contexts'; import React from 'react'; -import type { GenericMenuItemProps } from '../../../components/GenericMenu/GenericMenuItem'; - export const useAccountItems = (): GenericMenuItemProps[] => { const t = useTranslation(); const router = useRouter(); - const { unseenFeatures, featurePreviewEnabled } = useFeaturePreviewList(); + const { unseenFeatures, featurePreviewEnabled } = usePreferenceFeaturePreviewList(); const handleMyAccount = useMutableCallback(() => { router.navigate('/account'); diff --git a/apps/meteor/client/sidebar/header/hooks/useStatusItems.tsx b/apps/meteor/client/sidebar/header/hooks/useStatusItems.tsx index 026f7c80400e..dc3fa2a3b277 100644 --- a/apps/meteor/client/sidebar/header/hooks/useStatusItems.tsx +++ b/apps/meteor/client/sidebar/header/hooks/useStatusItems.tsx @@ -1,11 +1,11 @@ import { Box } from '@rocket.chat/fuselage'; +import type { GenericMenuItemProps } from '@rocket.chat/ui-client'; import { useEndpoint, useSetting } from '@rocket.chat/ui-contexts'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import React, { useEffect } from 'react'; import { useTranslation } from 'react-i18next'; import { callbacks } from '../../../../lib/callbacks'; -import type { GenericMenuItemProps } from '../../../components/GenericMenu/GenericMenuItem'; import MarkdownText from '../../../components/MarkdownText'; import { UserStatus } from '../../../components/UserStatus'; import { userStatuses } from '../../../lib/userStatuses'; diff --git a/apps/meteor/client/sidebar/header/hooks/useUserMenu.tsx b/apps/meteor/client/sidebar/header/hooks/useUserMenu.tsx index de43d30306e9..c0c6f94a4ed8 100644 --- a/apps/meteor/client/sidebar/header/hooks/useUserMenu.tsx +++ b/apps/meteor/client/sidebar/header/hooks/useUserMenu.tsx @@ -1,9 +1,9 @@ import type { IUser } from '@rocket.chat/core-typings'; import { useMutableCallback } from '@rocket.chat/fuselage-hooks'; +import type { GenericMenuItemProps } from '@rocket.chat/ui-client'; import { useLogout, useTranslation } from '@rocket.chat/ui-contexts'; import React from 'react'; -import type { GenericMenuItemProps } from '../../../components/GenericMenu/GenericMenuItem'; import UserMenuHeader from '../UserMenuHeader'; import { useAccountItems } from './useAccountItems'; import { useStatusItems } from './useStatusItems'; diff --git a/apps/meteor/client/sidebar/sections/OverMacLimitSection.tsx b/apps/meteor/client/sidebar/sections/OverMacLimitSection.tsx index 53cbd0340339..c2578d076f1a 100644 --- a/apps/meteor/client/sidebar/sections/OverMacLimitSection.tsx +++ b/apps/meteor/client/sidebar/sections/OverMacLimitSection.tsx @@ -1,10 +1,10 @@ import { Icon, SidebarBanner } from '@rocket.chat/fuselage'; -import { useTranslation } from '@rocket.chat/ui-contexts'; import type { ReactElement } from 'react'; import React from 'react'; +import { useTranslation } from 'react-i18next'; export const OverMacLimitSection = (): ReactElement => { - const t = useTranslation(); + const { t } = useTranslation(); const handleClick = () => { window.open('https://rocket.chat/pricing', '_blank'); diff --git a/apps/meteor/client/sidebar/sections/StatusDisabledSection.tsx b/apps/meteor/client/sidebar/sections/StatusDisabledSection.tsx index fb12c3e6fc3b..c8f56ffe5458 100644 --- a/apps/meteor/client/sidebar/sections/StatusDisabledSection.tsx +++ b/apps/meteor/client/sidebar/sections/StatusDisabledSection.tsx @@ -1,11 +1,11 @@ import { SidebarBanner } from '@rocket.chat/fuselage'; -import { useTranslation } from '@rocket.chat/ui-contexts'; import React from 'react'; +import { useTranslation } from 'react-i18next'; import { useStatusDisabledModal } from '../../views/admin/customUserStatus/hooks/useStatusDisabledModal'; const StatusDisabledSection = ({ onDismiss }: { onDismiss: () => void }) => { - const t = useTranslation(); + const { t } = useTranslation(); const handleStatusDisabledModal = useStatusDisabledModal(); return ( diff --git a/apps/meteor/client/sidebar/sections/actions/OmnichannelCallDialPad.tsx b/apps/meteor/client/sidebar/sections/actions/OmnichannelCallDialPad.tsx index 2cd263630da7..e0fa30ca2345 100644 --- a/apps/meteor/client/sidebar/sections/actions/OmnichannelCallDialPad.tsx +++ b/apps/meteor/client/sidebar/sections/actions/OmnichannelCallDialPad.tsx @@ -1,13 +1,13 @@ import { Sidebar } from '@rocket.chat/fuselage'; -import { useTranslation } from '@rocket.chat/ui-contexts'; import type { ReactElement } from 'react'; import React from 'react'; +import { useTranslation } from 'react-i18next'; import { useVoipOutboundStates } from '../../../contexts/CallContext'; import { useDialModal } from '../../../hooks/useDialModal'; export const OmniChannelCallDialPad = ({ ...props }): ReactElement => { - const t = useTranslation(); + const { t } = useTranslation(); const { openDialModal } = useDialModal(); diff --git a/apps/meteor/client/sidebar/sections/actions/OmnichannelCallToggleError.tsx b/apps/meteor/client/sidebar/sections/actions/OmnichannelCallToggleError.tsx index 77efcd5057e1..dd5186aeb479 100644 --- a/apps/meteor/client/sidebar/sections/actions/OmnichannelCallToggleError.tsx +++ b/apps/meteor/client/sidebar/sections/actions/OmnichannelCallToggleError.tsx @@ -1,9 +1,9 @@ import { Sidebar } from '@rocket.chat/fuselage'; -import { useTranslation } from '@rocket.chat/ui-contexts'; import type { ReactElement } from 'react'; import React from 'react'; +import { useTranslation } from 'react-i18next'; export const OmnichannelCallToggleError = ({ ...props }): ReactElement => { - const t = useTranslation(); + const { t } = useTranslation(); return ; }; diff --git a/apps/meteor/client/sidebar/sections/actions/OmnichannelCallToggleLoading.tsx b/apps/meteor/client/sidebar/sections/actions/OmnichannelCallToggleLoading.tsx index 673a3225482c..8318e9199900 100644 --- a/apps/meteor/client/sidebar/sections/actions/OmnichannelCallToggleLoading.tsx +++ b/apps/meteor/client/sidebar/sections/actions/OmnichannelCallToggleLoading.tsx @@ -1,9 +1,9 @@ import { Sidebar } from '@rocket.chat/fuselage'; -import { useTranslation } from '@rocket.chat/ui-contexts'; import type { ReactElement } from 'react'; import React from 'react'; +import { useTranslation } from 'react-i18next'; export const OmnichannelCallToggleLoading = ({ ...props }): ReactElement => { - const t = useTranslation(); + const { t } = useTranslation(); return ; }; diff --git a/apps/meteor/client/sidebar/sections/actions/OmnichannelCallToggleReady.tsx b/apps/meteor/client/sidebar/sections/actions/OmnichannelCallToggleReady.tsx index 497e0143bfaa..8205d0287768 100644 --- a/apps/meteor/client/sidebar/sections/actions/OmnichannelCallToggleReady.tsx +++ b/apps/meteor/client/sidebar/sections/actions/OmnichannelCallToggleReady.tsx @@ -1,12 +1,12 @@ import { Sidebar } from '@rocket.chat/fuselage'; -import { useTranslation } from '@rocket.chat/ui-contexts'; import type { ReactElement } from 'react'; import React, { useCallback } from 'react'; +import { useTranslation } from 'react-i18next'; import { useCallerInfo, useCallRegisterClient, useCallUnregisterClient, useVoipNetworkStatus } from '../../../contexts/CallContext'; export const OmnichannelCallToggleReady = ({ ...props }): ReactElement => { - const t = useTranslation(); + const { t } = useTranslation(); const caller = useCallerInfo(); const unregister = useCallUnregisterClient(); diff --git a/apps/meteor/client/sidebarv2/RoomList/RoomListWrapper.tsx b/apps/meteor/client/sidebarv2/RoomList/RoomListWrapper.tsx index b4d4b4a44c98..09e80f12765c 100644 --- a/apps/meteor/client/sidebarv2/RoomList/RoomListWrapper.tsx +++ b/apps/meteor/client/sidebarv2/RoomList/RoomListWrapper.tsx @@ -1,14 +1,14 @@ import { useMergedRefs } from '@rocket.chat/fuselage-hooks'; -import { useTranslation } from '@rocket.chat/ui-contexts'; import type { ForwardedRef, HTMLAttributes } from 'react'; import React, { forwardRef } from 'react'; +import { useTranslation } from 'react-i18next'; import { useSidebarListNavigation } from './useSidebarListNavigation'; type RoomListWrapperProps = HTMLAttributes; const RoomListWrapper = forwardRef(function RoomListWrapper(props: RoomListWrapperProps, ref: ForwardedRef) { - const t = useTranslation(); + const { t } = useTranslation(); const { sidebarListRef } = useSidebarListNavigation(); const mergedRefs = useMergedRefs(ref, sidebarListRef); diff --git a/apps/meteor/client/sidebarv2/RoomList/SidebarItemTemplateWithData.tsx b/apps/meteor/client/sidebarv2/RoomList/SidebarItemTemplateWithData.tsx index 51b8ce495af6..e1f66ba93b4e 100644 --- a/apps/meteor/client/sidebarv2/RoomList/SidebarItemTemplateWithData.tsx +++ b/apps/meteor/client/sidebarv2/RoomList/SidebarItemTemplateWithData.tsx @@ -180,6 +180,7 @@ const SidebarItemTemplateWithData = ({ is='a' id={id} data-qa='sidebar-item' + data-unread={highlighted} unread={highlighted} selected={selected} href={href} @@ -205,7 +206,7 @@ const SidebarItemTemplateWithData = ({ threadUnread={threadUnread} rid={rid} unread={!!unread} - roomOpen={false} + roomOpen={selected} type={type} cl={cl} name={title} diff --git a/apps/meteor/client/sidebarv2/RoomMenu.tsx b/apps/meteor/client/sidebarv2/RoomMenu.tsx index e88225df40ca..aac68b9ef79e 100644 --- a/apps/meteor/client/sidebarv2/RoomMenu.tsx +++ b/apps/meteor/client/sidebarv2/RoomMenu.tsx @@ -13,6 +13,7 @@ import { useTranslation, useEndpoint, } from '@rocket.chat/ui-contexts'; +import { useQueryClient } from '@tanstack/react-query'; import type { ReactElement } from 'react'; import React, { memo, useMemo } from 'react'; @@ -100,6 +101,8 @@ const RoomMenu = ({ const isOmnichannelRoom = type === 'l'; const prioritiesMenu = useOmnichannelPrioritiesMenu(rid); + const queryClient = useQueryClient(); + const canLeave = ((): boolean => { if (type === 'c' && !canLeaveChannel) { return false; @@ -173,17 +176,22 @@ const RoomMenu = ({ const handleToggleRead = useEffectEvent(async () => { try { + queryClient.invalidateQueries(['sidebar/search/spotlight']); + if (isUnread) { await readMessages({ rid, readThreads: true }); return; } - await unreadMessages(undefined, rid); + if (subscription == null) { return; } + LegacyRoomManager.close(subscription.t + subscription.name); router.navigate('/home'); + + await unreadMessages(undefined, rid); } catch (error) { dispatchToastMessage({ type: 'error', message: error }); } diff --git a/apps/meteor/client/sidebarv2/footer/SidebarFooterWatermark.tsx b/apps/meteor/client/sidebarv2/footer/SidebarFooterWatermark.tsx index bf7736b5899a..1f66217d76e5 100644 --- a/apps/meteor/client/sidebarv2/footer/SidebarFooterWatermark.tsx +++ b/apps/meteor/client/sidebarv2/footer/SidebarFooterWatermark.tsx @@ -1,12 +1,12 @@ import { Box } from '@rocket.chat/fuselage'; -import { useTranslation } from '@rocket.chat/ui-contexts'; import type { ReactElement } from 'react'; import React from 'react'; +import { useTranslation } from 'react-i18next'; import { useLicense, useLicenseName } from '../../hooks/useLicense'; export const SidebarFooterWatermark = (): ReactElement | null => { - const t = useTranslation(); + const { t } = useTranslation(); const response = useLicense(); diff --git a/apps/meteor/client/sidebarv2/footer/voip/VoipFooter.tsx b/apps/meteor/client/sidebarv2/footer/voip/VoipFooter.tsx index 6e67baefdec8..9344ba33c9ad 100644 --- a/apps/meteor/client/sidebarv2/footer/voip/VoipFooter.tsx +++ b/apps/meteor/client/sidebarv2/footer/voip/VoipFooter.tsx @@ -2,9 +2,9 @@ import type { IVoipRoom, ICallerInfo, VoIpCallerInfo } from '@rocket.chat/core-t import { VoipClientEvents } from '@rocket.chat/core-typings'; import { css } from '@rocket.chat/css-in-js'; import { Box, Button, ButtonGroup, SidebarFooter, Menu, IconButton } from '@rocket.chat/fuselage'; -import { useTranslation } from '@rocket.chat/ui-contexts'; import type { ReactElement, MouseEvent, ReactNode } from 'react'; import React from 'react'; +import { useTranslation } from 'react-i18next'; import type { CallActionsType } from '../../../contexts/CallContext'; import type { VoipFooterMenuOptions } from '../../../hooks/useVoipFooterMenu'; @@ -50,7 +50,7 @@ const VoipFooter = ({ options, }: VoipFooterProps): ReactElement => { const contactLabel = useOmnichannelContactLabel(caller); - const t = useTranslation(); + const { t } = useTranslation(); const cssClickable = callerState === 'IN_CALL' || callerState === 'ON_HOLD' diff --git a/apps/meteor/client/sidebarv2/header/CreateChannelModal.tsx b/apps/meteor/client/sidebarv2/header/CreateChannelModal.tsx index 38a1df5a5ec8..2ff468f4f72b 100644 --- a/apps/meteor/client/sidebarv2/header/CreateChannelModal.tsx +++ b/apps/meteor/client/sidebarv2/header/CreateChannelModal.tsx @@ -222,7 +222,7 @@ const CreateChannelModal = ({ teamId = '', onClose, reload }: CreateChannelModal id={nameId} data-qa-type='channel-name-input' {...register('name', { - required: t('error-the-field-is-required', { field: t('Name') }), + required: t('Required_field', { field: t('Name') }), validate: (value) => validateChannelName(value), })} error={errors.name?.message} diff --git a/apps/meteor/client/sidebarv2/header/CreateTeamModal.tsx b/apps/meteor/client/sidebarv2/header/CreateTeamModal.tsx index e907558ce278..9de721d8bbcd 100644 --- a/apps/meteor/client/sidebarv2/header/CreateTeamModal.tsx +++ b/apps/meteor/client/sidebarv2/header/CreateTeamModal.tsx @@ -1,3 +1,4 @@ +import type { SidepanelItem } from '@rocket.chat/core-typings'; import { Box, Button, @@ -16,6 +17,7 @@ import { AccordionItem, } from '@rocket.chat/fuselage'; import { useUniqueId } from '@rocket.chat/fuselage-hooks'; +import { FeaturePreview, FeaturePreviewOff, FeaturePreviewOn } from '@rocket.chat/ui-client'; import { useEndpoint, usePermission, @@ -40,6 +42,8 @@ type CreateTeamModalInputs = { encrypted: boolean; broadcast: boolean; members?: string[]; + showDiscussions?: boolean; + showChannels?: boolean; }; type CreateTeamModalProps = { onClose: () => void }; @@ -50,6 +54,7 @@ const CreateTeamModal = ({ onClose }: CreateTeamModalProps) => { const e2eEnabledForPrivateByDefault = useSetting('E2E_Enabled_Default_PrivateRooms'); const namesValidation = useSetting('UTF8_Channel_Names_Validation'); const allowSpecialNames = useSetting('UI_Allow_room_names_with_special_chars'); + const dispatchToastMessage = useToastMessageDispatch(); const canCreateTeam = usePermission('create-team'); const canSetReadOnly = usePermissionWithScopedRoles('set-readonly', ['owner']); @@ -94,6 +99,8 @@ const CreateTeamModal = ({ onClose }: CreateTeamModalProps) => { encrypted: (e2eEnabledForPrivateByDefault as boolean) ?? false, broadcast: false, members: [], + showChannels: true, + showDiscussions: true, }, }); @@ -123,7 +130,10 @@ const CreateTeamModal = ({ onClose }: CreateTeamModalProps) => { topic, broadcast, encrypted, + showChannels, + showDiscussions, }: CreateTeamModalInputs): Promise => { + const sidepanelItem = [showChannels && 'channels', showDiscussions && 'discussions'].filter(Boolean) as [SidepanelItem, SidepanelItem?]; const params = { name, members, @@ -136,6 +146,7 @@ const CreateTeamModal = ({ onClose }: CreateTeamModalProps) => { encrypted, }, }, + ...((showChannels || showDiscussions) && { sidepanel: { items: sidepanelItem } }), }; try { @@ -157,6 +168,8 @@ const CreateTeamModal = ({ onClose }: CreateTeamModalProps) => { const encryptedId = useUniqueId(); const broadcastId = useUniqueId(); const addMembersId = useUniqueId(); + const showChannelsId = useUniqueId(); + const showDiscussionsId = useUniqueId(); return ( { id={nameId} aria-invalid={errors.name ? 'true' : 'false'} {...register('name', { - required: t('error-the-field-is-required', { field: t('Name') }), + required: t('Required_field', { field: t('Name') }), validate: (value) => validateTeamName(value), })} addon={} @@ -236,6 +249,55 @@ const CreateTeamModal = ({ onClose }: CreateTeamModalProps) => { + + {null} + + + + {t('Navigation')} + + + + {t('Channels')} + ( + + )} + /> + + {t('Show_channels_description')} + + + + + {t('Discussions')} + ( + + )} + /> + + {t('Show_discussions_description')} + + + + {t('Security_and_permissions')} diff --git a/apps/meteor/client/sidebarv2/header/MatrixFederationSearch/FederatedRoomListEmptyPlaceholder.tsx b/apps/meteor/client/sidebarv2/header/MatrixFederationSearch/FederatedRoomListEmptyPlaceholder.tsx index 8f0a26222679..43e6be7c583d 100644 --- a/apps/meteor/client/sidebarv2/header/MatrixFederationSearch/FederatedRoomListEmptyPlaceholder.tsx +++ b/apps/meteor/client/sidebarv2/header/MatrixFederationSearch/FederatedRoomListEmptyPlaceholder.tsx @@ -1,11 +1,11 @@ import { Box } from '@rocket.chat/fuselage'; -import { useTranslation } from '@rocket.chat/ui-contexts'; import React from 'react'; +import { useTranslation } from 'react-i18next'; import GenericNoResults from '../../../components/GenericNoResults'; const FederatedRoomListEmptyPlaceholder = () => { - const t = useTranslation(); + const { t } = useTranslation(); return ( diff --git a/apps/meteor/client/sidebarv2/header/MatrixFederationSearch/FederatedRoomListErrorBoundary.tsx b/apps/meteor/client/sidebarv2/header/MatrixFederationSearch/FederatedRoomListErrorBoundary.tsx index 6931c98f219e..1608058528bc 100644 --- a/apps/meteor/client/sidebarv2/header/MatrixFederationSearch/FederatedRoomListErrorBoundary.tsx +++ b/apps/meteor/client/sidebarv2/header/MatrixFederationSearch/FederatedRoomListErrorBoundary.tsx @@ -1,9 +1,9 @@ import { States, StatesIcon, StatesTitle, StatesSubtitle, StatesActions, StatesAction, Icon } from '@rocket.chat/fuselage'; -import { useTranslation } from '@rocket.chat/ui-contexts'; import { QueryErrorResetBoundary } from '@tanstack/react-query'; import type { ReactNode } from 'react'; import React from 'react'; import { ErrorBoundary } from 'react-error-boundary'; +import { useTranslation } from 'react-i18next'; type FederatedRoomListErrorBoundaryProps = { children?: ReactNode; @@ -11,7 +11,7 @@ type FederatedRoomListErrorBoundaryProps = { }; const FederatedRoomListErrorBoundary = ({ children, resetKeys }: FederatedRoomListErrorBoundaryProps) => { - const t = useTranslation(); + const { t } = useTranslation(); return ( diff --git a/apps/meteor/client/sidebarv2/header/MatrixFederationSearch/FederatedRoomListItem.tsx b/apps/meteor/client/sidebarv2/header/MatrixFederationSearch/FederatedRoomListItem.tsx index dfaa79ed44de..002c8a5ac9da 100644 --- a/apps/meteor/client/sidebarv2/header/MatrixFederationSearch/FederatedRoomListItem.tsx +++ b/apps/meteor/client/sidebarv2/header/MatrixFederationSearch/FederatedRoomListItem.tsx @@ -1,8 +1,8 @@ import { css } from '@rocket.chat/css-in-js'; import { Box, Button, Icon } from '@rocket.chat/fuselage'; import type { IFederationPublicRooms } from '@rocket.chat/rest-typings'; -import { useTranslation } from '@rocket.chat/ui-contexts'; import React from 'react'; +import { useTranslation } from 'react-i18next'; type FederatedRoomListItemProps = IFederationPublicRooms & { disabled: boolean; @@ -22,7 +22,7 @@ const FederatedRoomListItem = ({ canJoin, disabled, }: FederatedRoomListItemProps) => { - const t = useTranslation(); + const { t } = useTranslation(); return ( diff --git a/apps/meteor/client/sidebarv2/header/MatrixFederationSearch/MatrixFederationSearch.tsx b/apps/meteor/client/sidebarv2/header/MatrixFederationSearch/MatrixFederationSearch.tsx index f3dc779d28c1..9ea119aabb18 100644 --- a/apps/meteor/client/sidebarv2/header/MatrixFederationSearch/MatrixFederationSearch.tsx +++ b/apps/meteor/client/sidebarv2/header/MatrixFederationSearch/MatrixFederationSearch.tsx @@ -1,6 +1,6 @@ import { Modal, Skeleton } from '@rocket.chat/fuselage'; -import { useTranslation } from '@rocket.chat/ui-contexts'; import React from 'react'; +import { useTranslation } from 'react-i18next'; import MatrixFederationSearchModalContent from './MatrixFederationSearchModalContent'; import { useMatrixServerList } from './useMatrixServerList'; @@ -11,7 +11,7 @@ type MatrixFederationSearchProps = { }; const MatrixFederationSearch = ({ onClose, defaultSelectedServer }: MatrixFederationSearchProps) => { - const t = useTranslation(); + const { t } = useTranslation(); const { data, isLoading } = useMatrixServerList(); return ( diff --git a/apps/meteor/client/sidebarv2/header/SearchSection.tsx b/apps/meteor/client/sidebarv2/header/SearchSection.tsx index 660b8ee19cd5..71b26b2e4053 100644 --- a/apps/meteor/client/sidebarv2/header/SearchSection.tsx +++ b/apps/meteor/client/sidebarv2/header/SearchSection.tsx @@ -22,6 +22,32 @@ const wrapperStyle = css` background-color: ${Palette.surface['surface-sidebar']}; `; +const mobileCheck = function () { + let check = false; + (function (a: string) { + if ( + /(android|bb\d+|meego).+mobile|avantgo|bada\/|blackberry|blazer|compal|elaine|fennec|hiptop|iemobile|ip(hone|od)|iris|kindle|lge |maemo|midp|mmp|mobile.+firefox|netfront|opera m(ob|in)i|palm( os)?|phone|p(ixi|re)\/|plucker|pocket|psp|series(4|6)0|symbian|treo|up\.(browser|link)|vodafone|wap|windows ce|xda|xiino/i.test( + a, + ) || + /1207|6310|6590|3gso|4thp|50[1-6]i|770s|802s|a wa|abac|ac(er|oo|s\-)|ai(ko|rn)|al(av|ca|co)|amoi|an(ex|ny|yw)|aptu|ar(ch|go)|as(te|us)|attw|au(di|\-m|r |s )|avan|be(ck|ll|nq)|bi(lb|rd)|bl(ac|az)|br(e|v)w|bumb|bw\-(n|u)|c55\/|capi|ccwa|cdm\-|cell|chtm|cldc|cmd\-|co(mp|nd)|craw|da(it|ll|ng)|dbte|dc\-s|devi|dica|dmob|do(c|p)o|ds(12|\-d)|el(49|ai)|em(l2|ul)|er(ic|k0)|esl8|ez([4-7]0|os|wa|ze)|fetc|fly(\-|_)|g1 u|g560|gene|gf\-5|g\-mo|go(\.w|od)|gr(ad|un)|haie|hcit|hd\-(m|p|t)|hei\-|hi(pt|ta)|hp( i|ip)|hs\-c|ht(c(\-| |_|a|g|p|s|t)|tp)|hu(aw|tc)|i\-(20|go|ma)|i230|iac( |\-|\/)|ibro|idea|ig01|ikom|im1k|inno|ipaq|iris|ja(t|v)a|jbro|jemu|jigs|kddi|keji|kgt( |\/)|klon|kpt |kwc\-|kyo(c|k)|le(no|xi)|lg( g|\/(k|l|u)|50|54|\-[a-w])|libw|lynx|m1\-w|m3ga|m50\/|ma(te|ui|xo)|mc(01|21|ca)|m\-cr|me(rc|ri)|mi(o8|oa|ts)|mmef|mo(01|02|bi|de|do|t(\-| |o|v)|zz)|mt(50|p1|v )|mwbp|mywa|n10[0-2]|n20[2-3]|n30(0|2)|n50(0|2|5)|n7(0(0|1)|10)|ne((c|m)\-|on|tf|wf|wg|wt)|nok(6|i)|nzph|o2im|op(ti|wv)|oran|owg1|p800|pan(a|d|t)|pdxg|pg(13|\-([1-8]|c))|phil|pire|pl(ay|uc)|pn\-2|po(ck|rt|se)|prox|psio|pt\-g|qa\-a|qc(07|12|21|32|60|\-[2-7]|i\-)|qtek|r380|r600|raks|rim9|ro(ve|zo)|s55\/|sa(ge|ma|mm|ms|ny|va)|sc(01|h\-|oo|p\-)|sdk\/|se(c(\-|0|1)|47|mc|nd|ri)|sgh\-|shar|sie(\-|m)|sk\-0|sl(45|id)|sm(al|ar|b3|it|t5)|so(ft|ny)|sp(01|h\-|v\-|v )|sy(01|mb)|t2(18|50)|t6(00|10|18)|ta(gt|lk)|tcl\-|tdg\-|tel(i|m)|tim\-|t\-mo|to(pl|sh)|ts(70|m\-|m3|m5)|tx\-9|up(\.b|g1|si)|utst|v400|v750|veri|vi(rg|te)|vk(40|5[0-3]|\-v)|vm40|voda|vulc|vx(52|53|60|61|70|80|81|83|85|98)|w3c(\-| )|webc|whit|wi(g |nc|nw)|wmlb|wonu|x700|yas\-|your|zeto|zte\-/i.test( + a.substr(0, 4), + ) + ) + check = true; + })(navigator.userAgent || navigator.vendor || window.opera || ''); + return check; +}; + +const shortcut = ((): string => { + if (navigator.userAgentData?.mobile || mobileCheck()) { + return ''; + } + if (window.navigator.platform.toLowerCase().includes('mac')) { + return '(\u2318+K)'; + } + return '(Ctrl+K)'; +})(); + const SearchSection = () => { const t = useTranslation(); const user = useUser(); @@ -68,11 +94,13 @@ const SearchSection = () => { }; }, [handleEscSearch, setFocus]); + const placeholder = [t('Search'), shortcut].filter(Boolean).join(' '); + return ( , 'is'>; const CreateRoom = (props: CreateRoomProps) => { - const t = useTranslation(); + const { t } = useTranslation(); const sections = useCreateRoom(); diff --git a/apps/meteor/client/sidebarv2/header/actions/Sort.tsx b/apps/meteor/client/sidebarv2/header/actions/Sort.tsx index 9956acf266b5..dcfa84dd71bc 100644 --- a/apps/meteor/client/sidebarv2/header/actions/Sort.tsx +++ b/apps/meteor/client/sidebarv2/header/actions/Sort.tsx @@ -1,15 +1,15 @@ import { SidebarV2Action } from '@rocket.chat/fuselage'; -import { useTranslation } from '@rocket.chat/ui-contexts'; +import { GenericMenu } from '@rocket.chat/ui-client'; import type { HTMLAttributes } from 'react'; import React from 'react'; +import { useTranslation } from 'react-i18next'; -import GenericMenu from '../../../components/GenericMenu/GenericMenu'; import { useSortMenu } from './hooks/useSortMenu'; type SortProps = Omit, 'is'>; const Sort = (props: SortProps) => { - const t = useTranslation(); + const { t } = useTranslation(); const sections = useSortMenu(); diff --git a/apps/meteor/client/sidebarv2/header/actions/hooks/useCreateRoomItems.tsx b/apps/meteor/client/sidebarv2/header/actions/hooks/useCreateRoomItems.tsx index 3935ad0039df..4d313975d6f5 100644 --- a/apps/meteor/client/sidebarv2/header/actions/hooks/useCreateRoomItems.tsx +++ b/apps/meteor/client/sidebarv2/header/actions/hooks/useCreateRoomItems.tsx @@ -1,7 +1,7 @@ +import type { GenericMenuItemProps } from '@rocket.chat/ui-client'; import { useTranslation, useSetting, useAtLeastOnePermission } from '@rocket.chat/ui-contexts'; import CreateDiscussion from '../../../../components/CreateDiscussion'; -import type { GenericMenuItemProps } from '../../../../components/GenericMenu/GenericMenuItem'; import CreateChannelModal from '../../CreateChannelModal'; import CreateDirectMessage from '../../CreateDirectMessage'; import CreateTeamModal from '../../CreateTeamModal'; diff --git a/apps/meteor/client/sidebarv2/header/actions/hooks/useGroupingListItems.tsx b/apps/meteor/client/sidebarv2/header/actions/hooks/useGroupingListItems.tsx index 646b85c838be..5dcb53f2ad2f 100644 --- a/apps/meteor/client/sidebarv2/header/actions/hooks/useGroupingListItems.tsx +++ b/apps/meteor/client/sidebarv2/header/actions/hooks/useGroupingListItems.tsx @@ -1,9 +1,8 @@ import { CheckBox } from '@rocket.chat/fuselage'; +import type { GenericMenuItemProps } from '@rocket.chat/ui-client'; import { useEndpoint, useUserPreference, useTranslation } from '@rocket.chat/ui-contexts'; import React, { useCallback } from 'react'; -import type { GenericMenuItemProps } from '../../../../components/GenericMenu/GenericMenuItem'; - export const useGroupingListItems = (): GenericMenuItemProps[] => { const t = useTranslation(); diff --git a/apps/meteor/client/sidebarv2/header/actions/hooks/useMatrixFederationItems.ts b/apps/meteor/client/sidebarv2/header/actions/hooks/useMatrixFederationItems.ts index b3ac63f13773..cd0abc9bdfb2 100644 --- a/apps/meteor/client/sidebarv2/header/actions/hooks/useMatrixFederationItems.ts +++ b/apps/meteor/client/sidebarv2/header/actions/hooks/useMatrixFederationItems.ts @@ -1,6 +1,6 @@ -import { useTranslation } from '@rocket.chat/ui-contexts'; +import type { GenericMenuItemProps } from '@rocket.chat/ui-client'; +import { useTranslation } from 'react-i18next'; -import type { GenericMenuItemProps } from '../../../../components/GenericMenu/GenericMenuItem'; import MatrixFederationSearch from '../../MatrixFederationSearch'; import { useCreateRoomModal } from '../../hooks/useCreateRoomModal'; @@ -9,7 +9,7 @@ export const useMatrixFederationItems = ({ }: { isMatrixEnabled: string | number | boolean | null | undefined; }): GenericMenuItemProps[] => { - const t = useTranslation(); + const { t } = useTranslation(); const searchFederatedRooms = useCreateRoomModal(MatrixFederationSearch); diff --git a/apps/meteor/client/sidebarv2/header/actions/hooks/useSortMenu.tsx b/apps/meteor/client/sidebarv2/header/actions/hooks/useSortMenu.tsx index bea1d999997e..af6a73ce6eab 100644 --- a/apps/meteor/client/sidebarv2/header/actions/hooks/useSortMenu.tsx +++ b/apps/meteor/client/sidebarv2/header/actions/hooks/useSortMenu.tsx @@ -1,11 +1,11 @@ -import { useTranslation } from '@rocket.chat/ui-contexts'; +import { useTranslation } from 'react-i18next'; import { useGroupingListItems } from './useGroupingListItems'; import { useSortModeItems } from './useSortModeItems'; import { useViewModeItems } from './useViewModeItems'; export const useSortMenu = () => { - const t = useTranslation(); + const { t } = useTranslation(); const viewModeItems = useViewModeItems(); const sortModeItems = useSortModeItems(); diff --git a/apps/meteor/client/sidebarv2/header/actions/hooks/useSortModeItems.tsx b/apps/meteor/client/sidebarv2/header/actions/hooks/useSortModeItems.tsx index 56041ab4e571..0e2bf818a0bf 100644 --- a/apps/meteor/client/sidebarv2/header/actions/hooks/useSortModeItems.tsx +++ b/apps/meteor/client/sidebarv2/header/actions/hooks/useSortModeItems.tsx @@ -1,8 +1,8 @@ import { RadioButton } from '@rocket.chat/fuselage'; +import type { GenericMenuItemProps } from '@rocket.chat/ui-client'; import { useEndpoint, useUserPreference, useTranslation } from '@rocket.chat/ui-contexts'; import React, { useCallback } from 'react'; -import type { GenericMenuItemProps } from '../../../../components/GenericMenu/GenericMenuItem'; import { OmnichannelSortingDisclaimer, useOmnichannelSortingDisclaimer, diff --git a/apps/meteor/client/sidebarv2/header/actions/hooks/useViewModeItems.tsx b/apps/meteor/client/sidebarv2/header/actions/hooks/useViewModeItems.tsx index ca2855d09db5..a33230a15729 100644 --- a/apps/meteor/client/sidebarv2/header/actions/hooks/useViewModeItems.tsx +++ b/apps/meteor/client/sidebarv2/header/actions/hooks/useViewModeItems.tsx @@ -1,9 +1,8 @@ import { RadioButton, ToggleSwitch } from '@rocket.chat/fuselage'; +import type { GenericMenuItemProps } from '@rocket.chat/ui-client'; import { useEndpoint, useUserPreference, useTranslation } from '@rocket.chat/ui-contexts'; import React, { useCallback } from 'react'; -import type { GenericMenuItemProps } from '../../../../components/GenericMenu/GenericMenuItem'; - export const useViewModeItems = (): GenericMenuItemProps[] => { const t = useTranslation(); diff --git a/apps/meteor/client/sidebarv2/sections/StatusDisabledSection.tsx b/apps/meteor/client/sidebarv2/sections/StatusDisabledSection.tsx index a0f3245e8835..9994a0e66e77 100644 --- a/apps/meteor/client/sidebarv2/sections/StatusDisabledSection.tsx +++ b/apps/meteor/client/sidebarv2/sections/StatusDisabledSection.tsx @@ -1,13 +1,13 @@ import { SidebarV2Banner } from '@rocket.chat/fuselage'; -import { useTranslation } from '@rocket.chat/ui-contexts'; import React from 'react'; +import { useTranslation } from 'react-i18next'; import { useStatusDisabledModal } from '../../views/admin/customUserStatus/hooks/useStatusDisabledModal'; type StatusDisabledSectionProps = { onDismiss: () => void }; const StatusDisabledSection = ({ onDismiss }: StatusDisabledSectionProps) => { - const t = useTranslation(); + const { t } = useTranslation(); const handleStatusDisabledModal = useStatusDisabledModal(); return ( diff --git a/apps/meteor/client/startup/accounts.ts b/apps/meteor/client/startup/accounts.ts index 88008a606656..50c033dc0596 100644 --- a/apps/meteor/client/startup/accounts.ts +++ b/apps/meteor/client/startup/accounts.ts @@ -2,7 +2,7 @@ import { Accounts } from 'meteor/accounts-base'; import { Meteor } from 'meteor/meteor'; import { Tracker } from 'meteor/tracker'; -import { settings } from '../../app/settings/client'; +// import { settings } from '../../app/settings/client'; import { mainReady } from '../../app/ui-utils/client'; import { sdk } from '../../app/utils/client/lib/SDKClient'; import { t } from '../../app/utils/lib/i18n'; @@ -25,17 +25,3 @@ Accounts.onEmailVerificationLink((token: string) => { }); }); }); - -Meteor.startup(() => { - Tracker.autorun((computation) => { - const forgetUserSessionOnWindowClose = settings.get('Accounts_ForgetUserSessionOnWindowClose'); - - if (forgetUserSessionOnWindowClose === undefined) { - return; - } - - computation.stop(); - - Accounts.config({ clientStorage: forgetUserSessionOnWindowClose ? 'session' : 'local' }); - }); -}); diff --git a/apps/meteor/client/startup/e2e.ts b/apps/meteor/client/startup/e2e.ts index de615e8f45de..e45b62563726 100644 --- a/apps/meteor/client/startup/e2e.ts +++ b/apps/meteor/client/startup/e2e.ts @@ -5,6 +5,7 @@ import { Tracker } from 'meteor/tracker'; import { E2EEState } from '../../app/e2e/client/E2EEState'; import { e2e } from '../../app/e2e/client/rocketchat.e2e'; +import { MentionsParser } from '../../app/mentions/lib/MentionsParser'; import { ChatRoom } from '../../app/models/client'; import { settings } from '../../app/settings/client'; import { onClientBeforeSendMessage } from '../lib/onClientBeforeSendMessage'; @@ -88,6 +89,27 @@ Meteor.startup(() => { return message; } + const mentionsEnabled = settings.get('E2E_Enabled_Mentions'); + + if (mentionsEnabled) { + const me = Meteor.user()?.username || ''; + const pattern = settings.get('UTF8_User_Names_Validation'); + const useRealName = settings.get('UI_Use_Real_Name'); + + const mentions = new MentionsParser({ + pattern: () => pattern, + useRealName: () => useRealName, + me: () => me, + }); + + const e2eMentions: IMessage['e2eMentions'] = { + e2eUserMentions: mentions.getUserMentions(message.msg), + e2eChannelMentions: mentions.getChannelMentions(message.msg), + }; + + message.e2eMentions = e2eMentions; + } + // Should encrypt this message. return e2eRoom.encryptMessage(message); }); diff --git a/apps/meteor/client/stories/contexts/TranslationContextMock.tsx b/apps/meteor/client/stories/contexts/TranslationContextMock.tsx index ac98cd15cd89..2ecb46e44ee7 100644 --- a/apps/meteor/client/stories/contexts/TranslationContextMock.tsx +++ b/apps/meteor/client/stories/contexts/TranslationContextMock.tsx @@ -1,5 +1,6 @@ import type { TranslationKey } from '@rocket.chat/ui-contexts'; import { TranslationContext } from '@rocket.chat/ui-contexts'; +import type { TOptions } from 'i18next'; import i18next from 'i18next'; import type { ContextType, ReactElement, ReactNode } from 'react'; import React, { useContext, useMemo } from 'react'; @@ -30,7 +31,7 @@ const TranslationContextMock = ({ children }: TranslationContextMockProps): Reac const translate = (key: string, ...replaces: unknown[]): string => { if (typeof replaces[0] === 'object' && replaces[0] !== null) { const [options] = replaces; - return i18next.t(key, options); + return i18next.t(key, options as TOptions); } if (replaces.length === 0) { diff --git a/apps/meteor/client/views/account/deviceManagement/DeviceManagementAccountPage.tsx b/apps/meteor/client/views/account/deviceManagement/DeviceManagementAccountPage.tsx index 30979e2a81d8..d1af15f59711 100644 --- a/apps/meteor/client/views/account/deviceManagement/DeviceManagementAccountPage.tsx +++ b/apps/meteor/client/views/account/deviceManagement/DeviceManagementAccountPage.tsx @@ -1,12 +1,12 @@ -import { useTranslation } from '@rocket.chat/ui-contexts'; import type { ReactElement } from 'react'; import React from 'react'; +import { useTranslation } from 'react-i18next'; import { Page, PageHeader, PageContent } from '../../../components/Page'; import DeviceManagementAccountTable from './DeviceManagementAccountTable'; const DeviceManagementAccountPage = (): ReactElement => { - const t = useTranslation(); + const { t } = useTranslation(); return ( diff --git a/apps/meteor/client/views/account/deviceManagement/DeviceManagementAccountTable/DeviceManagementAccountRow.tsx b/apps/meteor/client/views/account/deviceManagement/DeviceManagementAccountTable/DeviceManagementAccountRow.tsx index e2532d0c6623..76556a238bac 100644 --- a/apps/meteor/client/views/account/deviceManagement/DeviceManagementAccountTable/DeviceManagementAccountRow.tsx +++ b/apps/meteor/client/views/account/deviceManagement/DeviceManagementAccountTable/DeviceManagementAccountRow.tsx @@ -1,8 +1,8 @@ import { Box, Button } from '@rocket.chat/fuselage'; import { useMediaQuery } from '@rocket.chat/fuselage-hooks'; -import { useTranslation } from '@rocket.chat/ui-contexts'; import type { ReactElement } from 'react'; import React from 'react'; +import { useTranslation } from 'react-i18next'; import { GenericTableCell, GenericTableRow } from '../../../../components/GenericTable'; import DeviceIcon from '../../../../components/deviceManagement/DeviceIcon'; @@ -26,7 +26,7 @@ const DeviceManagementAccountRow = ({ loginAt, onReload, }: DevicesRowProps): ReactElement => { - const t = useTranslation(); + const { t } = useTranslation(); const formatDateAndTime = useFormatDateAndTime(); const mediaQuery = useMediaQuery('(min-width: 1024px)'); diff --git a/apps/meteor/client/views/account/deviceManagement/DeviceManagementAccountTable/DeviceManagementAccountTable.tsx b/apps/meteor/client/views/account/deviceManagement/DeviceManagementAccountTable/DeviceManagementAccountTable.tsx index 5039521fa622..411f9ad02e6f 100644 --- a/apps/meteor/client/views/account/deviceManagement/DeviceManagementAccountTable/DeviceManagementAccountTable.tsx +++ b/apps/meteor/client/views/account/deviceManagement/DeviceManagementAccountTable/DeviceManagementAccountTable.tsx @@ -1,7 +1,7 @@ import { useMediaQuery } from '@rocket.chat/fuselage-hooks'; -import { useTranslation } from '@rocket.chat/ui-contexts'; import type { ReactElement } from 'react'; import React, { useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; import { GenericTableHeaderCell } from '../../../../components/GenericTable'; import { usePagination } from '../../../../components/GenericTable/hooks/usePagination'; @@ -17,7 +17,7 @@ const sortMapping = { }; const DeviceManagementAccountTable = (): ReactElement => { - const t = useTranslation(); + const { t } = useTranslation(); const { current, itemsPerPage, setCurrent, setItemsPerPage, ...paginationProps } = usePagination(); const { sortBy, sortDirection, setSort } = useSort<'client' | 'os' | 'loginAt'>('loginAt'); diff --git a/apps/meteor/client/views/account/featurePreview/AccountFeaturePreviewPage.tsx b/apps/meteor/client/views/account/featurePreview/AccountFeaturePreviewPage.tsx index dd9ab6a90959..358d2394003b 100644 --- a/apps/meteor/client/views/account/featurePreview/AccountFeaturePreviewPage.tsx +++ b/apps/meteor/client/views/account/featurePreview/AccountFeaturePreviewPage.tsx @@ -1,4 +1,3 @@ -import { css } from '@rocket.chat/css-in-js'; import { ButtonGroup, Button, @@ -13,9 +12,10 @@ import { FieldLabel, FieldRow, FieldHint, + Callout, + Margins, } from '@rocket.chat/fuselage'; -import type { FeaturePreviewProps } from '@rocket.chat/ui-client'; -import { useFeaturePreviewList } from '@rocket.chat/ui-client'; +import { usePreferenceFeaturePreviewList } from '@rocket.chat/ui-client'; import type { TranslationKey } from '@rocket.chat/ui-contexts'; import { useToastMessageDispatch, useTranslation, useEndpoint } from '@rocket.chat/ui-contexts'; import type { ChangeEvent } from 'react'; @@ -23,26 +23,12 @@ import React, { useEffect, Fragment } from 'react'; import { useForm } from 'react-hook-form'; import { Page, PageHeader, PageScrollableContentWithShadow, PageFooter } from '../../../components/Page'; +import { useFeaturePreviewEnableQuery } from '../../../hooks/useFeaturePreviewEnableQuery'; -const handleEnableQuery = (features: FeaturePreviewProps[]) => { - return features.map((item) => { - if (item.enableQuery) { - const expected = item.enableQuery.value; - const received = features.find((el) => el.name === item.enableQuery?.name)?.value; - if (expected !== received) { - item.disabled = true; - item.value = false; - } else { - item.disabled = false; - } - } - return item; - }); -}; const AccountFeaturePreviewPage = () => { const t = useTranslation(); const dispatchToastMessage = useToastMessageDispatch(); - const { features, unseenFeatures } = useFeaturePreviewList(); + const { features, unseenFeatures } = usePreferenceFeaturePreviewList(); const setUserPreferences = useEndpoint('POST', '/v1/users.setPreferences'); @@ -85,12 +71,7 @@ const AccountFeaturePreviewPage = () => { setValue('featuresPreview', updated, { shouldDirty: true }); }; - const grouppedFeaturesPreview = Object.entries( - handleEnableQuery(featuresPreview).reduce((result, currentValue) => { - (result[currentValue.group] = result[currentValue.group] || []).push(currentValue); - return result; - }, {} as Record), - ); + const grouppedFeaturesPreview = useFeaturePreviewEnableQuery(featuresPreview); return ( @@ -105,14 +86,11 @@ const AccountFeaturePreviewPage = () => { )} {featuresPreview.length > 0 && ( <> - - {t('Feature_preview_page_description')} + + + {t('Feature_preview_page_description')} + {t('Feature_preview_page_callout')} + {grouppedFeaturesPreview?.map(([group, features], index) => ( diff --git a/apps/meteor/client/views/account/integrations/AccountIntegrationsPage.tsx b/apps/meteor/client/views/account/integrations/AccountIntegrationsPage.tsx index 2c11b7a384cd..ce9fad8591fa 100644 --- a/apps/meteor/client/views/account/integrations/AccountIntegrationsPage.tsx +++ b/apps/meteor/client/views/account/integrations/AccountIntegrationsPage.tsx @@ -1,6 +1,6 @@ import type { SelectOption } from '@rocket.chat/fuselage'; -import { SelectLegacy, Box, Button, Field, FieldLabel, FieldRow } from '@rocket.chat/fuselage'; -import { useEffectEvent } from '@rocket.chat/fuselage-hooks'; +import { SelectLegacy, Box, Button, Field, FieldLabel, FieldRow, FieldError } from '@rocket.chat/fuselage'; +import { useEffectEvent, useUniqueId } 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'; @@ -13,7 +13,11 @@ import { useRemoveWebDAVAccountIntegrationMutation } from './hooks/useRemoveWebD const AccountIntegrationsPage = () => { const { data: webdavAccountIntegrations } = useWebDAVAccountIntegrationsQuery(); - const { handleSubmit, control } = useForm<{ accountSelected: string }>(); + const { + handleSubmit, + control, + formState: { errors }, + } = useForm<{ accountSelected: string }>(); const options: SelectOption[] = useMemo( () => webdavAccountIntegrations?.map(({ _id, ...current }) => [_id, getWebdavServerName(current)]) ?? [], @@ -36,6 +40,8 @@ const AccountIntegrationsPage = () => { removeMutation.mutate({ accountSelected }); }); + const accountSelectedId = useUniqueId(); + return ( @@ -47,22 +53,18 @@ const AccountIntegrationsPage = () => { ( - - )} + rules={{ required: t('Required_field', { field: t('WebDAV_Accounts') }) }} + render={({ field }) => } /> + {errors?.accountSelected && ( + + {errors.accountSelected.message} + + )} diff --git a/apps/meteor/client/views/account/omnichannel/PreferencesGeneral.tsx b/apps/meteor/client/views/account/omnichannel/PreferencesGeneral.tsx index 2f58ed305be4..ca6872b0165a 100644 --- a/apps/meteor/client/views/account/omnichannel/PreferencesGeneral.tsx +++ b/apps/meteor/client/views/account/omnichannel/PreferencesGeneral.tsx @@ -1,12 +1,12 @@ 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'; import React from 'react'; import { useFormContext } from 'react-hook-form'; +import { useTranslation } from 'react-i18next'; export const PreferencesGeneral = (): ReactElement => { - const t = useTranslation(); + const { t } = useTranslation(); const { register } = useFormContext(); const omnichannelHideAfterClosing = useUniqueId(); diff --git a/apps/meteor/client/views/account/preferences/MyDataModal.tsx b/apps/meteor/client/views/account/preferences/MyDataModal.tsx index 8889a1a44a53..75b1e67b324e 100644 --- a/apps/meteor/client/views/account/preferences/MyDataModal.tsx +++ b/apps/meteor/client/views/account/preferences/MyDataModal.tsx @@ -1,7 +1,7 @@ import { Button, Box, Modal } from '@rocket.chat/fuselage'; -import { useTranslation } from '@rocket.chat/ui-contexts'; import type { ReactNode } from 'react'; import React from 'react'; +import { useTranslation } from 'react-i18next'; type MyDataModalProps = { onCancel: () => void; @@ -10,7 +10,7 @@ type MyDataModalProps = { }; const MyDataModal = ({ onCancel, title, text, ...props }: MyDataModalProps) => { - const t = useTranslation(); + const { t } = useTranslation(); return ( diff --git a/apps/meteor/client/views/account/preferences/PreferencesHighlightsSection.tsx b/apps/meteor/client/views/account/preferences/PreferencesHighlightsSection.tsx index 85bfc9072b12..e987f78266b6 100644 --- a/apps/meteor/client/views/account/preferences/PreferencesHighlightsSection.tsx +++ b/apps/meteor/client/views/account/preferences/PreferencesHighlightsSection.tsx @@ -1,11 +1,11 @@ import { Accordion, Field, FieldGroup, FieldLabel, FieldRow, FieldHint, TextAreaInput } from '@rocket.chat/fuselage'; import { useUniqueId } from '@rocket.chat/fuselage-hooks'; -import { useTranslation } from '@rocket.chat/ui-contexts'; import React from 'react'; import { useFormContext } from 'react-hook-form'; +import { useTranslation } from 'react-i18next'; const PreferencesHighlightsSection = () => { - const t = useTranslation(); + const { t } = useTranslation(); const { register } = useFormContext(); const highlightsId = useUniqueId(); diff --git a/apps/meteor/client/views/account/preferences/PreferencesMessagesSection.tsx b/apps/meteor/client/views/account/preferences/PreferencesMessagesSection.tsx index b8b4366cf4ff..c7e5bdf5aece 100644 --- a/apps/meteor/client/views/account/preferences/PreferencesMessagesSection.tsx +++ b/apps/meteor/client/views/account/preferences/PreferencesMessagesSection.tsx @@ -1,12 +1,12 @@ import type { SelectOption } 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'; import { Controller, useFormContext } from 'react-hook-form'; +import { useTranslation } from 'react-i18next'; const PreferencesMessagesSection = () => { - const t = useTranslation(); + const { t } = useTranslation(); const { control } = useFormContext(); const alsoSendThreadMessageToChannelOptions = useMemo( diff --git a/apps/meteor/client/views/account/preferences/PreferencesUserPresenceSection.tsx b/apps/meteor/client/views/account/preferences/PreferencesUserPresenceSection.tsx index 02a04d0b2c59..edfece178e52 100644 --- a/apps/meteor/client/views/account/preferences/PreferencesUserPresenceSection.tsx +++ b/apps/meteor/client/views/account/preferences/PreferencesUserPresenceSection.tsx @@ -1,11 +1,11 @@ import { Accordion, Field, FieldLabel, FieldRow, NumberInput, FieldGroup, ToggleSwitch } from '@rocket.chat/fuselage'; import { useUniqueId } from '@rocket.chat/fuselage-hooks'; -import { useTranslation } from '@rocket.chat/ui-contexts'; import React from 'react'; import { Controller, useFormContext } from 'react-hook-form'; +import { useTranslation } from 'react-i18next'; const PreferencesUserPresenceSection = () => { - const t = useTranslation(); + const { t } = useTranslation(); const { register, control } = useFormContext(); const enableAutoAwayId = useUniqueId(); diff --git a/apps/meteor/client/views/account/profile/AccountProfileForm.tsx b/apps/meteor/client/views/account/profile/AccountProfileForm.tsx index 0de462cd5bd3..95398b352049 100644 --- a/apps/meteor/client/views/account/profile/AccountProfileForm.tsx +++ b/apps/meteor/client/views/account/profile/AccountProfileForm.tsx @@ -166,7 +166,9 @@ const AccountProfileForm = (props: AllHTMLAttributes): ReactEle (requireName && name === '' ? t('error-the-field-is-required', { field: t('Name') }) : true) }} + rules={{ + required: requireName && t('Required_field', { field: t('Name') }), + }} render={({ field }) => ( ): ReactEle control={control} name='username' rules={{ - required: t('error-the-field-is-required', { field: t('Username') }), + required: t('Required_field', { field: t('Username') }), validate: (username) => validateUsername(username), }} render={({ field }) => ( @@ -305,7 +307,10 @@ const AccountProfileForm = (props: AllHTMLAttributes): ReactEle (validateEmail(email) ? undefined : t('error-invalid-email-address')) } }} + rules={{ + required: t('Required_field', { field: t('Email') }), + validate: { validateEmail: (email) => (validateEmail(email) ? undefined : t('error-invalid-email-address')) }, + }} render={({ field }) => ( { - const t = useTranslation(); + const { t } = useTranslation(); const [inputText, setInputText] = useState(''); const [inputError, setInputError] = useState(); diff --git a/apps/meteor/client/views/account/security/AccountSecurityPage.tsx b/apps/meteor/client/views/account/security/AccountSecurityPage.tsx index 06619f0618f5..81435536e981 100644 --- a/apps/meteor/client/views/account/security/AccountSecurityPage.tsx +++ b/apps/meteor/client/views/account/security/AccountSecurityPage.tsx @@ -23,7 +23,7 @@ const AccountSecurityPage = (): ReactElement => { const methods = useForm({ defaultValues: passwordDefaultValues, - mode: 'onBlur', + mode: 'all', }); const { reset, diff --git a/apps/meteor/client/views/account/security/BackupCodesModal.tsx b/apps/meteor/client/views/account/security/BackupCodesModal.tsx index eb97fb10cd7f..de3457902b7a 100644 --- a/apps/meteor/client/views/account/security/BackupCodesModal.tsx +++ b/apps/meteor/client/views/account/security/BackupCodesModal.tsx @@ -1,6 +1,6 @@ import { Box, Button, Modal } from '@rocket.chat/fuselage'; -import { useTranslation } from '@rocket.chat/ui-contexts'; import React, { useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; import TextCopy from '../../../components/TextCopy'; @@ -10,7 +10,7 @@ type BackupCodesModalProps = { }; const BackupCodesModal = ({ codes, onClose, ...props }: BackupCodesModalProps) => { - const t = useTranslation(); + const { t } = useTranslation(); const codesText = useMemo(() => codes.join(' '), [codes]); diff --git a/apps/meteor/client/views/account/security/ChangePassword.tsx b/apps/meteor/client/views/account/security/ChangePassword.tsx index c70d9e166175..e5cb61e34547 100644 --- a/apps/meteor/client/views/account/security/ChangePassword.tsx +++ b/apps/meteor/client/views/account/security/ChangePassword.tsx @@ -53,13 +53,13 @@ const ChangePassword = (props: AllHTMLAttributes) => { control={control} name='password' rules={{ + required: t('Required_field', { field: t('New_password') }), validate: () => (password?.length && !passwordIsValid ? t('Password_must_meet_the_complexity_requirements') : true), }} - render={({ field: { onChange, value } }) => ( + render={({ field }) => ( } @@ -84,12 +84,14 @@ const ChangePassword = (props: AllHTMLAttributes) => { (password !== confirmationPassword ? t('Passwords_do_not_match') : true) }} - render={({ field: { onChange, value } }) => ( + rules={{ + required: t('Required_field', { field: t('Confirm_password') }), + validate: (confirmationPassword) => (password !== confirmationPassword ? t('Passwords_do_not_match') : true), + }} + render={({ field }) => ( } diff --git a/apps/meteor/client/views/account/security/EndToEnd.tsx b/apps/meteor/client/views/account/security/EndToEnd.tsx index 72213f3202ba..b2fb982ebb9b 100644 --- a/apps/meteor/client/views/account/security/EndToEnd.tsx +++ b/apps/meteor/client/views/account/security/EndToEnd.tsx @@ -1,8 +1,9 @@ import { Box, Margins, PasswordInput, Field, FieldGroup, FieldLabel, FieldRow, FieldError, FieldHint, Button } from '@rocket.chat/fuselage'; +import { useUniqueId } from '@rocket.chat/fuselage-hooks'; import { useToastMessageDispatch, useMethod, useTranslation, useLogout } from '@rocket.chat/ui-contexts'; import type { ComponentProps, ReactElement } from 'react'; import React, { useCallback, useEffect } from 'react'; -import { useForm } from 'react-hook-form'; +import { Controller, useForm } from 'react-hook-form'; import { e2e } from '../../../../app/e2e/client/rocketchat.e2e'; @@ -17,17 +18,17 @@ const EndToEnd = (props: ComponentProps): ReactElement => { const resetE2eKey = useMethod('e2e.resetOwnE2EKey'); const { - register, handleSubmit, watch, resetField, formState: { errors, isValid }, + control, } = useForm({ defaultValues: { password: '', passwordConfirm: '', }, - mode: 'onChange', + mode: 'all', }); const { password } = watch(); @@ -64,37 +65,71 @@ const EndToEnd = (props: ComponentProps): ReactElement => { } }, [password, resetField]); + const passwordId = useUniqueId(); + const e2ePasswordExplanationId = useUniqueId(); + const passwordConfirmId = useUniqueId(); + return ( {t('E2E_Encryption_Password_Change')} - + - {t('New_encryption_password')} + {t('New_encryption_password')} - ( + + )} /> - {!keysExist && {t('EncryptionKey_Change_Disabled')}} + {!keysExist && {t('EncryptionKey_Change_Disabled')}} + {errors?.password && ( + + {errors.password.message} + + )} {hasTypedPassword && ( - {t('Confirm_new_encryption_password')} - (password !== value ? 'Your passwords do no match' : true), - })} - placeholder={t('Confirm_New_Password_Placeholder')} - aria-labelledby='Confirm_new_encryption_password' - /> - {errors.passwordConfirm && {errors.passwordConfirm.message}} + {t('Confirm_new_encryption_password')} + + (password !== value ? 'Your passwords do no match' : true), + }} + render={({ field }) => ( + + )} + /> + + {errors.passwordConfirm && ( + + {errors.passwordConfirm.message} + + )} )} diff --git a/apps/meteor/client/views/account/sidebarItems.tsx b/apps/meteor/client/views/account/sidebarItems.tsx index ca0376be329d..fa2ab8bd5e40 100644 --- a/apps/meteor/client/views/account/sidebarItems.tsx +++ b/apps/meteor/client/views/account/sidebarItems.tsx @@ -1,10 +1,9 @@ -import { defaultFeaturesPreview } from '@rocket.chat/ui-client'; +import { defaultFeaturesPreview, FeaturePreviewBadge } from '@rocket.chat/ui-client'; import React from 'react'; import { hasPermission, hasAtLeastOnePermission } from '../../../app/authorization/client'; import { settings } from '../../../app/settings/client'; import { createSidebarItems } from '../../lib/createSidebarItems'; -import AccountFeaturePreviewBadge from './featurePreview/AccountFeaturePreviewBadge'; export const { registerSidebarItem: registerAccountSidebarItem, @@ -54,7 +53,7 @@ export const { href: '/account/feature-preview', i18nLabel: 'Feature_preview', icon: 'flask', - badge: () => , + badge: () => , permissionGranted: () => settings.get('Accounts_AllowFeaturePreview') && defaultFeaturesPreview?.length > 0, }, { diff --git a/apps/meteor/client/views/account/tokens/AccountTokensPage.tsx b/apps/meteor/client/views/account/tokens/AccountTokensPage.tsx index 9885b0bd3b86..abf45ebedcb9 100644 --- a/apps/meteor/client/views/account/tokens/AccountTokensPage.tsx +++ b/apps/meteor/client/views/account/tokens/AccountTokensPage.tsx @@ -1,12 +1,12 @@ -import { useTranslation } from '@rocket.chat/ui-contexts'; import type { ReactElement } from 'react'; import React from 'react'; +import { useTranslation } from 'react-i18next'; import { Page, PageHeader, PageContent } from '../../../components/Page'; import AccountTokensTable from './AccountTokensTable'; const AccountTokensPage = (): ReactElement => { - const t = useTranslation(); + const { t } = useTranslation(); return ( diff --git a/apps/meteor/client/views/account/tokens/AccountTokensTable/AccountTokensRow.tsx b/apps/meteor/client/views/account/tokens/AccountTokensTable/AccountTokensRow.tsx index 5158d31d2470..e4e9db4a8fad 100644 --- a/apps/meteor/client/views/account/tokens/AccountTokensTable/AccountTokensRow.tsx +++ b/apps/meteor/client/views/account/tokens/AccountTokensTable/AccountTokensRow.tsx @@ -1,7 +1,7 @@ import type { IPersonalAccessToken, Serialized } from '@rocket.chat/core-typings'; import { ButtonGroup, IconButton } from '@rocket.chat/fuselage'; -import { useTranslation } from '@rocket.chat/ui-contexts'; import React, { useCallback } from 'react'; +import { useTranslation } from 'react-i18next'; import { GenericTableRow, GenericTableCell } from '../../../../components/GenericTable'; import { useFormatDateAndTime } from '../../../../hooks/useFormatDateAndTime'; @@ -13,7 +13,7 @@ type AccountTokensRowProps = { } & Serialized>; const AccountTokensRow = ({ bypassTwoFactor, createdAt, isMedium, lastTokenPart, name, onRegenerate, onRemove }: AccountTokensRowProps) => { - const t = useTranslation(); + const { t } = useTranslation(); const formatDateAndTime = useFormatDateAndTime(); const handleRegenerate = useCallback(() => onRegenerate(name), [name, onRegenerate]); const handleRemove = useCallback(() => onRemove(name), [name, onRemove]); diff --git a/apps/meteor/client/views/admin/customEmoji/AddCustomEmoji.tsx b/apps/meteor/client/views/admin/customEmoji/AddCustomEmoji.tsx index 31721cb4f7ab..f6f52507d33a 100644 --- a/apps/meteor/client/views/admin/customEmoji/AddCustomEmoji.tsx +++ b/apps/meteor/client/views/admin/customEmoji/AddCustomEmoji.tsx @@ -1,7 +1,7 @@ -import { Box, Button, ButtonGroup, Margins, TextInput, Field, FieldLabel, FieldRow, FieldError, Icon } from '@rocket.chat/fuselage'; -import { useTranslation } from '@rocket.chat/ui-contexts'; +import { Box, Button, ButtonGroup, Margins, TextInput, Field, FieldLabel, FieldRow, FieldError, IconButton } from '@rocket.chat/fuselage'; import type { ReactElement, ChangeEvent } from 'react'; import React, { useCallback, useState } from 'react'; +import { useTranslation } from 'react-i18next'; import { ContextualbarScrollableContent, ContextualbarFooter } from '../../../components/Contextualbar'; import { useEndpointUpload } from '../../../hooks/useEndpointUpload'; @@ -13,7 +13,7 @@ type AddCustomEmojiProps = { }; const AddCustomEmoji = ({ close, onChange, ...props }: AddCustomEmojiProps): ReactElement => { - const t = useTranslation(); + const { t } = useTranslation(); const [name, setName] = useState(''); const [aliases, setAliases] = useState(''); const [emojiFile, setEmojiFile] = useState(); @@ -82,7 +82,7 @@ const AddCustomEmoji = ({ close, onChange, ...props }: AddCustomEmojiProps): Rea - {errors.name && {t('error-the-field-is-required', { field: t('Name') })}} + {errors.name && {t('Required_field', { field: t('Name') })}} {t('Aliases')} @@ -94,12 +94,9 @@ const AddCustomEmoji = ({ close, onChange, ...props }: AddCustomEmojiProps): Rea {t('Custom_Emoji')} - {/* FIXME: replace to IconButton */} - + - {errors.emoji && {t('error-the-field-is-required', { field: t('Custom_Emoji') })}} + {errors.emoji && {t('Required_field', { field: t('Custom_Emoji') })}} {newEmojiPreview && ( diff --git a/apps/meteor/client/views/admin/customEmoji/EditCustomEmoji.tsx b/apps/meteor/client/views/admin/customEmoji/EditCustomEmoji.tsx index 8f63b2ac7b48..49ee12b69dd0 100644 --- a/apps/meteor/client/views/admin/customEmoji/EditCustomEmoji.tsx +++ b/apps/meteor/client/views/admin/customEmoji/EditCustomEmoji.tsx @@ -150,7 +150,7 @@ const EditCustomEmoji = ({ close, onChange, data, ...props }: EditCustomEmojiPro - {errors.name && {t('error-the-field-is-required', { field: t('Name') })}} + {errors.name && {t('Required_field', { field: t('Name') })}} {t('Aliases')} diff --git a/apps/meteor/client/views/admin/customSounds/AddCustomSound.tsx b/apps/meteor/client/views/admin/customSounds/AddCustomSound.tsx index 82f668a2d7d5..430d05ceaa53 100644 --- a/apps/meteor/client/views/admin/customSounds/AddCustomSound.tsx +++ b/apps/meteor/client/views/admin/customSounds/AddCustomSound.tsx @@ -1,4 +1,4 @@ -import { Field, FieldLabel, FieldRow, TextInput, Box, Icon, Margins, Button, ButtonGroup } from '@rocket.chat/fuselage'; +import { Field, FieldLabel, FieldRow, TextInput, Box, Margins, Button, ButtonGroup, IconButton } from '@rocket.chat/fuselage'; import { useToastMessageDispatch, useMethod, useTranslation } from '@rocket.chat/ui-contexts'; import type { ReactElement, FormEvent } from 'react'; import React, { useState, useCallback } from 'react'; @@ -34,8 +34,8 @@ const AddCustomSound = ({ goToNew, close, onChange, ...props }: AddCustomSoundPr const soundData = createSoundData(soundFile, name); const validation = validate(soundData, soundFile) as Array[0]>; - validation.forEach((error) => { - throw new Error(t('error-the-field-is-required', { field: t(error) })); + validation.forEach((invalidFieldName) => { + throw new Error(t('Required_field', { field: t(invalidFieldName) })); }); try { @@ -97,12 +97,9 @@ const AddCustomSound = ({ goToNew, close, onChange, ...props }: AddCustomSoundPr {t('Sound_File_mp3')} - + - {/* FIXME: replace to IconButton */} - + {sound?.name || t('None')} @@ -111,7 +108,7 @@ const AddCustomSound = ({ goToNew, close, onChange, ...props }: AddCustomSoundPr - diff --git a/apps/meteor/client/views/admin/customSounds/EditSound.tsx b/apps/meteor/client/views/admin/customSounds/EditSound.tsx index b358d0bb7c84..1274e9deda35 100644 --- a/apps/meteor/client/views/admin/customSounds/EditSound.tsx +++ b/apps/meteor/client/views/admin/customSounds/EditSound.tsx @@ -76,10 +76,10 @@ function EditSound({ close, onChange, data, ...props }: EditSoundProps): ReactEl } } - validation.forEach((error) => + validation.forEach((invalidFieldName) => dispatchToastMessage({ type: 'error', - message: t('error-the-field-is-required', { field: t(error) }), + message: t('Required_field', { field: t(invalidFieldName) }), }), ); }, @@ -131,9 +131,9 @@ function EditSound({ close, onChange, data, ...props }: EditSoundProps): ReactEl {t('Sound_File_mp3')} - + - + {sound?.name || 'none'} diff --git a/apps/meteor/client/views/admin/customUserStatus/CustomUserActiveConnections.tsx b/apps/meteor/client/views/admin/customUserStatus/CustomUserActiveConnections.tsx index 776e04fe1f69..fa97324a5722 100644 --- a/apps/meteor/client/views/admin/customUserStatus/CustomUserActiveConnections.tsx +++ b/apps/meteor/client/views/admin/customUserStatus/CustomUserActiveConnections.tsx @@ -1,11 +1,11 @@ -import { useTranslation } from '@rocket.chat/ui-contexts'; import React from 'react'; +import { useTranslation } from 'react-i18next'; import { GenericResourceUsage, GenericResourceUsageSkeleton } from '../../../components/GenericResourceUsage'; import { useActiveConnections } from '../../hooks/useActiveConnections'; const CustomUserActiveConnections = () => { - const t = useTranslation(); + const { t } = useTranslation(); const result = useActiveConnections(); diff --git a/apps/meteor/client/views/admin/customUserStatus/CustomUserStatusDisabledModal.tsx b/apps/meteor/client/views/admin/customUserStatus/CustomUserStatusDisabledModal.tsx index 510d2d9303f4..b54c2181d76f 100644 --- a/apps/meteor/client/views/admin/customUserStatus/CustomUserStatusDisabledModal.tsx +++ b/apps/meteor/client/views/admin/customUserStatus/CustomUserStatusDisabledModal.tsx @@ -1,12 +1,12 @@ -import { useTranslation } from '@rocket.chat/ui-contexts'; import React from 'react'; +import { useTranslation } from 'react-i18next'; import GenericModal from '../../../components/GenericModal'; type CustomUserStatusDisabledModalProps = { isAdmin: boolean; onConfirm: () => void; onClose: () => void }; const CustomUserStatusDisabledModal = ({ isAdmin, onConfirm, onClose }: CustomUserStatusDisabledModalProps) => { - const t = useTranslation(); + const { t } = useTranslation(); return isAdmin ? ( {t('Name')} - + - {errors?.name && {t('error-the-field-is-required', { field: t('Name') })}} + {errors.name && {errors.name.message}} {t('Presence')} @@ -104,18 +105,18 @@ const CustomUserStatusForm = ({ onClose, onReload, status }: CustomUserStatusFor )} /> - {errors.accountId && {t('Field_required')}} + {errors.accountId && {errors.accountId.message}} )} diff --git a/apps/meteor/client/views/room/webdav/WebdavFilePickerModal/FilePickerBreadcrumbs.tsx b/apps/meteor/client/views/room/webdav/WebdavFilePickerModal/FilePickerBreadcrumbs.tsx index 827e16b1ac60..3f152db142e9 100644 --- a/apps/meteor/client/views/room/webdav/WebdavFilePickerModal/FilePickerBreadcrumbs.tsx +++ b/apps/meteor/client/views/room/webdav/WebdavFilePickerModal/FilePickerBreadcrumbs.tsx @@ -1,7 +1,7 @@ import { Box, Icon, IconButton, Tag } from '@rocket.chat/fuselage'; -import { useTranslation } from '@rocket.chat/ui-contexts'; import type { ReactElement, MouseEvent } from 'react'; import React from 'react'; +import { useTranslation } from 'react-i18next'; type FilePickerBreadcrumbsProps = { parentFolders: string[]; @@ -10,7 +10,7 @@ type FilePickerBreadcrumbsProps = { }; const FilePickerBreadcrumbs = ({ parentFolders, handleBreadcrumb, handleBack }: FilePickerBreadcrumbsProps): ReactElement => { - const t = useTranslation(); + const { t } = useTranslation(); return ( diff --git a/apps/meteor/client/views/room/webdav/WebdavFilePickerModal/WebdavFilePickerTable.tsx b/apps/meteor/client/views/room/webdav/WebdavFilePickerModal/WebdavFilePickerTable.tsx index e85248fca0e9..32b379eca432 100644 --- a/apps/meteor/client/views/room/webdav/WebdavFilePickerModal/WebdavFilePickerTable.tsx +++ b/apps/meteor/client/views/room/webdav/WebdavFilePickerModal/WebdavFilePickerTable.tsx @@ -1,8 +1,8 @@ import type { IWebdavNode } from '@rocket.chat/core-typings'; import { Box, Icon } from '@rocket.chat/fuselage'; -import { useTranslation } from '@rocket.chat/ui-contexts'; import type { ReactElement } from 'react'; import React from 'react'; +import { useTranslation } from 'react-i18next'; import GenericNoResults from '../../../../components/GenericNoResults'; import { @@ -36,7 +36,7 @@ const WebdavFilePickerTable = ({ onNodeClick, isLoading, }: WebdavFilePickerTableProps): ReactElement => { - const t = useTranslation(); + const { t } = useTranslation(); return ( diff --git a/apps/meteor/client/views/root/MainLayout/RegisterUsername.tsx b/apps/meteor/client/views/root/MainLayout/RegisterUsername.tsx index bfbbce5d37e1..fd1691bcb16d 100644 --- a/apps/meteor/client/views/root/MainLayout/RegisterUsername.tsx +++ b/apps/meteor/client/views/root/MainLayout/RegisterUsername.tsx @@ -105,7 +105,10 @@ const RegisterUsername = () => { {t('Username')} - + {errors.username && ( diff --git a/apps/meteor/client/views/teams/ChannelDesertionTable/ChannelDesertionTable.tsx b/apps/meteor/client/views/teams/ChannelDesertionTable/ChannelDesertionTable.tsx index f7f14bc8199d..2bb54f1fc69c 100644 --- a/apps/meteor/client/views/teams/ChannelDesertionTable/ChannelDesertionTable.tsx +++ b/apps/meteor/client/views/teams/ChannelDesertionTable/ChannelDesertionTable.tsx @@ -1,8 +1,8 @@ import type { IRoom, Serialized } from '@rocket.chat/core-typings'; import { Box, CheckBox } from '@rocket.chat/fuselage'; -import { useTranslation } from '@rocket.chat/ui-contexts'; import type { ReactElement } from 'react'; import React, { useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; import { GenericTable, GenericTableHeaderCell, GenericTableHeader, GenericTableBody } from '../../../components/GenericTable'; import { useSort } from '../../../components/GenericTable/hooks/useSort'; @@ -29,7 +29,7 @@ const ChannelDesertionTable = ({ }: ChannelDesertionTableProps) => { const { sortBy, sortDirection, setSort } = useSort<'name' | 'ts'>('name'); - const t = useTranslation(); + const { t } = useTranslation(); const selectedRoomsLength = Object.values(selectedRooms).filter(Boolean).length; diff --git a/apps/meteor/client/views/teams/ConvertToChannelModal/ModalSteps/FirstStep.tsx b/apps/meteor/client/views/teams/ConvertToChannelModal/ModalSteps/FirstStep.tsx index ae5b30609d78..a4aba906e7d9 100644 --- a/apps/meteor/client/views/teams/ConvertToChannelModal/ModalSteps/FirstStep.tsx +++ b/apps/meteor/client/views/teams/ConvertToChannelModal/ModalSteps/FirstStep.tsx @@ -1,7 +1,7 @@ import type { IRoom, Serialized } from '@rocket.chat/core-typings'; import { Box } from '@rocket.chat/fuselage'; -import { useTranslation } from '@rocket.chat/ui-contexts'; import React from 'react'; +import { useTranslation } from 'react-i18next'; import GenericModal from '../../../../components/GenericModal'; import ChannelDesertionTable from '../../ChannelDesertionTable'; @@ -28,7 +28,7 @@ const FirstStep = ({ eligibleRoomsLength, ...props }: FirstStepProps) => { - const t = useTranslation(); + const { t } = useTranslation(); return ( { - const t = useTranslation(); + const { t } = useTranslation(); return ( void }) => { - const t = useTranslation(); + const { t } = useTranslation(); const { handleRemoveRoom, canRemoveTeamChannel } = useRemoveRoomFromTeam(room, { reload }); const { handleDelete, canDeleteRoom } = useDeleteRoom(room, { reload }); diff --git a/apps/meteor/client/views/teams/contextualBar/channels/TeamsChannels.tsx b/apps/meteor/client/views/teams/contextualBar/channels/TeamsChannels.tsx index b1847825ffab..288604727ef6 100644 --- a/apps/meteor/client/views/teams/contextualBar/channels/TeamsChannels.tsx +++ b/apps/meteor/client/views/teams/contextualBar/channels/TeamsChannels.tsx @@ -2,9 +2,9 @@ import type { IRoom } from '@rocket.chat/core-typings'; import type { SelectOption } from '@rocket.chat/fuselage'; import { Box, Icon, TextInput, Select, Throbber, ButtonGroup, Button } from '@rocket.chat/fuselage'; import { useMutableCallback, useAutoFocus, useDebouncedCallback } from '@rocket.chat/fuselage-hooks'; -import { useTranslation } from '@rocket.chat/ui-contexts'; import type { ChangeEvent, Dispatch, SetStateAction, SyntheticEvent } from 'react'; import React, { useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; import { Virtuoso } from 'react-virtuoso'; import { @@ -54,7 +54,7 @@ const TeamsChannels = ({ onClickView, reload, }: TeamsChannelsProps) => { - const t = useTranslation(); + const { t } = useTranslation(); const inputRef = useAutoFocus(true); const options: SelectOption[] = useMemo( diff --git a/apps/meteor/client/views/teams/contextualBar/info/DeleteTeam/ChannelDeletionTable/ChannelDeletionTable.tsx b/apps/meteor/client/views/teams/contextualBar/info/DeleteTeam/ChannelDeletionTable/ChannelDeletionTable.tsx index 4bd85aed663c..24555a1fc9e0 100644 --- a/apps/meteor/client/views/teams/contextualBar/info/DeleteTeam/ChannelDeletionTable/ChannelDeletionTable.tsx +++ b/apps/meteor/client/views/teams/contextualBar/info/DeleteTeam/ChannelDeletionTable/ChannelDeletionTable.tsx @@ -1,7 +1,7 @@ import type { IRoom, Serialized } from '@rocket.chat/core-typings'; import { Box, CheckBox } from '@rocket.chat/fuselage'; -import { useTranslation } from '@rocket.chat/ui-contexts'; import React from 'react'; +import { useTranslation } from 'react-i18next'; import { GenericTable, GenericTableHeaderCell, GenericTableBody, GenericTableHeader } from '../../../../../../components/GenericTable'; import { useSort } from '../../../../../../components/GenericTable/hooks/useSort'; @@ -15,7 +15,7 @@ type ChannelDeletionTableProps = { }; const ChannelDeletionTable = ({ rooms, onChangeRoomSelection, selectedRooms, onToggleAllRooms }: ChannelDeletionTableProps) => { - const t = useTranslation(); + const { t } = useTranslation(); const { sortBy, sortDirection, setSort } = useSort<'name' | 'usersCount'>('name'); const selectedRoomsLength = Object.values(selectedRooms).filter(Boolean).length; diff --git a/apps/meteor/client/views/teams/contextualBar/info/DeleteTeam/DeleteTeamChannels.tsx b/apps/meteor/client/views/teams/contextualBar/info/DeleteTeam/DeleteTeamChannels.tsx index 0a02e1243431..e87eb5825af6 100644 --- a/apps/meteor/client/views/teams/contextualBar/info/DeleteTeam/DeleteTeamChannels.tsx +++ b/apps/meteor/client/views/teams/contextualBar/info/DeleteTeam/DeleteTeamChannels.tsx @@ -1,7 +1,7 @@ import type { IRoom, Serialized } from '@rocket.chat/core-typings'; import { Box } from '@rocket.chat/fuselage'; -import { useTranslation } from '@rocket.chat/ui-contexts'; import React from 'react'; +import { useTranslation } from 'react-i18next'; import GenericModal from '../../../../../components/GenericModal'; import ChannelDeletionTable from './ChannelDeletionTable'; @@ -23,7 +23,7 @@ const DeleteTeamChannels = ({ onConfirm, onChangeRoomSelection, }: DeleteTeamChannelsProps) => { - const t = useTranslation(); + const { t } = useTranslation(); return ( { - const t = useTranslation(); + const { t } = useTranslation(); const roomIds = Object.values(deletedRooms).map(({ _id }) => _id); return ( diff --git a/apps/meteor/client/views/teams/contextualBar/info/LeaveTeam/LeaveTeamModal/LeaveTeamModalChannels.tsx b/apps/meteor/client/views/teams/contextualBar/info/LeaveTeam/LeaveTeamModal/LeaveTeamModalChannels.tsx index 0a4b0fbe6af3..230828ef6795 100644 --- a/apps/meteor/client/views/teams/contextualBar/info/LeaveTeam/LeaveTeamModal/LeaveTeamModalChannels.tsx +++ b/apps/meteor/client/views/teams/contextualBar/info/LeaveTeam/LeaveTeamModal/LeaveTeamModalChannels.tsx @@ -1,7 +1,7 @@ import type { IRoom, Serialized } from '@rocket.chat/core-typings'; -import { useTranslation } from '@rocket.chat/ui-contexts'; import type { ReactElement } from 'react'; import React from 'react'; +import { useTranslation } from 'react-i18next'; import GenericModal from '../../../../../../components/GenericModal'; import ChannelDesertionTable from '../../../../ChannelDesertionTable'; @@ -27,7 +27,7 @@ const LeaveTeamModalChannels = ({ eligibleRoomsLength, selectedRooms, }: LeaveTeamModalChannelsProps): ReactElement => { - const t = useTranslation(); + const { t } = useTranslation(); return ( { - const t = useTranslation(); + const { t } = useTranslation(); return ( { - const t = useTranslation(); + const { t } = useTranslation(); const retentionPolicy = useRetentionPolicy(room); diff --git a/apps/meteor/client/views/teams/contextualBar/members/RemoveUsersModal/RemoveUsersFirstStep.js b/apps/meteor/client/views/teams/contextualBar/members/RemoveUsersModal/RemoveUsersFirstStep.js index 9657ea153a78..d467b6957e0f 100644 --- a/apps/meteor/client/views/teams/contextualBar/members/RemoveUsersModal/RemoveUsersFirstStep.js +++ b/apps/meteor/client/views/teams/contextualBar/members/RemoveUsersModal/RemoveUsersFirstStep.js @@ -1,6 +1,6 @@ import { Box } from '@rocket.chat/fuselage'; -import { useTranslation } from '@rocket.chat/ui-contexts'; import React from 'react'; +import { useTranslation } from 'react-i18next'; import GenericModal from '../../../../../components/GenericModal'; import ChannelDesertionTable from '../../../ChannelDesertionTable'; @@ -21,7 +21,7 @@ const RemoveUsersFirstStep = ({ eligibleRoomsLength, ...props }) => { - const t = useTranslation(); + const { t } = useTranslation(); return ( { - const t = useTranslation(); + const { t } = useTranslation(); return ( (function D { handleBackspaceClick, handleOnChange, isButtonDisabled, inputError, inputName }, ref, ) { - const t = useTranslation(); + const { t } = useTranslation(); return ( { - const t = useTranslation(); + const { t } = useTranslation(); const outboundClient = useOutboundDialer(); const { closeDialModal } = useDialModal(); diff --git a/apps/meteor/definition/externals/meteor/accounts-base.d.ts b/apps/meteor/definition/externals/meteor/accounts-base.d.ts index 31b70f7b7154..0d30eed0430d 100644 --- a/apps/meteor/definition/externals/meteor/accounts-base.d.ts +++ b/apps/meteor/definition/externals/meteor/accounts-base.d.ts @@ -1,5 +1,6 @@ declare module 'meteor/accounts-base' { namespace Accounts { + const storageLocation: Window['localStorage']; function createUser( options: { username?: string; diff --git a/apps/meteor/ee/app/livechat-enterprise/server/hooks/index.ts b/apps/meteor/ee/app/livechat-enterprise/server/hooks/index.ts index c5bf0a5aa392..a4b66087be2e 100644 --- a/apps/meteor/ee/app/livechat-enterprise/server/hooks/index.ts +++ b/apps/meteor/ee/app/livechat-enterprise/server/hooks/index.ts @@ -26,3 +26,4 @@ import './afterInquiryQueued'; import './sendPdfTranscriptOnClose'; import './applyRoomRestrictions'; import './afterTagRemoved'; +import './manageDepartmentUnit'; diff --git a/apps/meteor/ee/app/livechat-enterprise/server/hooks/manageDepartmentUnit.ts b/apps/meteor/ee/app/livechat-enterprise/server/hooks/manageDepartmentUnit.ts new file mode 100644 index 000000000000..7de7ef0d6bf6 --- /dev/null +++ b/apps/meteor/ee/app/livechat-enterprise/server/hooks/manageDepartmentUnit.ts @@ -0,0 +1,53 @@ +import type { ILivechatDepartment, IOmnichannelBusinessUnit } from '@rocket.chat/core-typings'; +import { LivechatDepartment, LivechatUnit } from '@rocket.chat/models'; + +import { hasAnyRoleAsync } from '../../../../../app/authorization/server/functions/hasRole'; +import { callbacks } from '../../../../../lib/callbacks'; +import { getUnitsFromUser } from '../methods/getUnitsFromUserRoles'; + +export const manageDepartmentUnit = async ({ userId, departmentId, unitId }: { userId: string; departmentId: string; unitId: string }) => { + const accessibleUnits = await getUnitsFromUser(userId); + const isLivechatManager = await hasAnyRoleAsync(userId, ['admin', 'livechat-manager']); + const department = await LivechatDepartment.findOneById>(departmentId, { + projection: { ancestors: 1, parentId: 1 }, + }); + + const isDepartmentAlreadyInUnit = unitId && department?.ancestors?.includes(unitId); + if (!department || isDepartmentAlreadyInUnit) { + return; + } + + const currentDepartmentUnitId = department.parentId; + const canManageNewUnit = !unitId || isLivechatManager || (Array.isArray(accessibleUnits) && accessibleUnits.includes(unitId)); + const canManageCurrentUnit = + !currentDepartmentUnitId || isLivechatManager || (Array.isArray(accessibleUnits) && accessibleUnits.includes(currentDepartmentUnitId)); + if (!canManageNewUnit || !canManageCurrentUnit) { + return; + } + + if (unitId) { + const unit = await LivechatUnit.findOneById>(unitId, { + projection: { ancestors: 1 }, + }); + + if (!unit) { + return; + } + + if (currentDepartmentUnitId) { + await LivechatUnit.decrementDepartmentsCount(currentDepartmentUnitId); + } + + await LivechatDepartment.addDepartmentToUnit(departmentId, unitId, [unitId, ...(unit.ancestors || [])]); + await LivechatUnit.incrementDepartmentsCount(unitId); + return; + } + + if (currentDepartmentUnitId) { + await LivechatUnit.decrementDepartmentsCount(currentDepartmentUnitId); + } + + await LivechatDepartment.removeDepartmentFromUnit(departmentId); +}; + +callbacks.add('livechat.manageDepartmentUnit', manageDepartmentUnit, callbacks.priority.HIGH, 'livechat-manage-department-unit'); diff --git a/apps/meteor/ee/server/lib/engagementDashboard/startup.ts b/apps/meteor/ee/server/lib/engagementDashboard/startup.ts index 159b121f7043..415e0323d525 100644 --- a/apps/meteor/ee/server/lib/engagementDashboard/startup.ts +++ b/apps/meteor/ee/server/lib/engagementDashboard/startup.ts @@ -3,7 +3,12 @@ import { fillFirstDaysOfMessagesIfNeeded, handleMessagesDeleted, handleMessagesS import { fillFirstDaysOfUsersIfNeeded, handleUserCreated } from './users'; export const attachCallbacks = (): void => { - callbacks.add('afterSaveMessage', handleMessagesSent, callbacks.priority.MEDIUM, 'engagementDashboard.afterSaveMessage'); + callbacks.add( + 'afterSaveMessage', + (message, { room }) => handleMessagesSent(message, { room }), + callbacks.priority.MEDIUM, + 'engagementDashboard.afterSaveMessage', + ); callbacks.add('afterDeleteMessage', handleMessagesDeleted, callbacks.priority.MEDIUM, 'engagementDashboard.afterDeleteMessage'); callbacks.add('afterCreateUser', handleUserCreated, callbacks.priority.MEDIUM, 'engagementDashboard.afterCreateUser'); }; diff --git a/apps/meteor/ee/server/models/raw/LivechatUnit.ts b/apps/meteor/ee/server/models/raw/LivechatUnit.ts index fcabf12fa4f8..c198ee04fbb0 100644 --- a/apps/meteor/ee/server/models/raw/LivechatUnit.ts +++ b/apps/meteor/ee/server/models/raw/LivechatUnit.ts @@ -11,7 +11,6 @@ const addQueryRestrictions = async (originalQuery: Filter implement // remove other departments for await (const departmentId of savedDepartments) { if (!departmentsToSave.includes(departmentId)) { - await LivechatDepartment.updateOne( - { _id: departmentId }, - { - $set: { - parentId: null, - ancestors: null, - }, - }, - ); + await LivechatDepartment.removeDepartmentFromUnit(departmentId); } } for await (const departmentId of departmentsToSave) { - await LivechatDepartment.updateOne( - { _id: departmentId }, - { - $set: { - parentId: _id, - ancestors, - }, - }, - ); + await LivechatDepartment.addDepartmentToUnit(departmentId, _id, ancestors); } await LivechatRooms.associateRoomsWithDepartmentToUnit(departmentsToSave, _id); @@ -154,6 +137,14 @@ export class LivechatUnitRaw extends BaseRaw implement return this.updateMany(query, update); } + incrementDepartmentsCount(_id: string): Promise { + return this.updateOne({ _id }, { $inc: { numDepartments: 1 } }); + } + + decrementDepartmentsCount(_id: string): Promise { + return this.updateOne({ _id }, { $inc: { numDepartments: -1 } }); + } + async removeById(_id: string): Promise { await LivechatUnitMonitors.removeByUnitId(_id); await this.removeParentAndAncestorById(_id); diff --git a/apps/meteor/ee/server/services/CHANGELOG.md b/apps/meteor/ee/server/services/CHANGELOG.md index 1d6e058d7597..59da7bfded9c 100644 --- a/apps/meteor/ee/server/services/CHANGELOG.md +++ b/apps/meteor/ee/server/services/CHANGELOG.md @@ -1,5 +1,46 @@ # rocketchat-services +## 1.3.5-rc.1 + +### Patch Changes + +-

      Updated dependencies []: + + - @rocket.chat/core-typings@6.13.0-rc.1 + - @rocket.chat/rest-typings@6.13.0-rc.1 + - @rocket.chat/core-services@0.7.0-rc.1 + - @rocket.chat/model-typings@0.8.0-rc.1 + - @rocket.chat/models@0.3.0-rc.1 +
      + +## 1.3.5-rc.0 + +### Patch Changes + +-
      Updated dependencies [9a38c8e13f, 9eaefdc892, 274f4f5881, 532f08819e, 79c16d315a, 927710d778, 3a161c4310, 12d6307998]: + + - @rocket.chat/model-typings@0.8.0-rc.0 + - @rocket.chat/rest-typings@6.13.0-rc.0 + - @rocket.chat/core-typings@6.13.0-rc.0 + - @rocket.chat/core-services@0.7.0-rc.0 + - @rocket.chat/message-parser@0.31.30-rc.0 + - @rocket.chat/models@0.3.0-rc.0 +
      + +## 1.3.4 + +### Patch Changes + +-
      Updated dependencies [3cbb9f6252]: + + - @rocket.chat/message-parser@0.31.30 + - @rocket.chat/core-services@0.6.1 + - @rocket.chat/core-typings@6.12.1 + - @rocket.chat/rest-typings@6.12.1 + - @rocket.chat/model-typings@0.7.1 + - @rocket.chat/models@0.2.4 +
      + ## 1.3.3 ### Patch Changes diff --git a/apps/meteor/ee/server/services/ecdh-proxy/service.ts b/apps/meteor/ee/server/services/ecdh-proxy/service.ts index 7ef3e8d26dcc..a795f157c9a5 100755 --- a/apps/meteor/ee/server/services/ecdh-proxy/service.ts +++ b/apps/meteor/ee/server/services/ecdh-proxy/service.ts @@ -1,5 +1,4 @@ -import '../../startup/broker'; - +import '@rocket.chat/network-broker'; import { api } from '@rocket.chat/core-services'; import { ECDHProxy } from './ECDHProxy'; diff --git a/apps/meteor/ee/server/services/package.json b/apps/meteor/ee/server/services/package.json index 7c51605d4190..9eb2e917819d 100644 --- a/apps/meteor/ee/server/services/package.json +++ b/apps/meteor/ee/server/services/package.json @@ -1,7 +1,7 @@ { "name": "rocketchat-services", "private": true, - "version": "1.3.3", + "version": "1.3.5-rc.1", "description": "Rocket.Chat Authorization service", "main": "index.js", "scripts": { @@ -18,19 +18,20 @@ "author": "Rocket.Chat", "license": "MIT", "dependencies": { - "@rocket.chat/apps-engine": "1.45.0-alpha.868", + "@rocket.chat/apps-engine": "workspace:^", "@rocket.chat/core-services": "workspace:^", "@rocket.chat/core-typings": "workspace:^", "@rocket.chat/emitter": "~0.31.25", "@rocket.chat/message-parser": "workspace:^", "@rocket.chat/model-typings": "workspace:^", "@rocket.chat/models": "workspace:^", + "@rocket.chat/network-broker": "workspace:^", "@rocket.chat/rest-typings": "workspace:^", "@rocket.chat/string-helpers": "~0.31.25", "@rocket.chat/ui-kit": "workspace:~", "ajv": "^8.11.0", "bcrypt": "^5.0.1", - "body-parser": "^1.20.2", + "body-parser": "^1.20.3", "colorette": "^2.0.20", "cookie": "^0.5.0", "cookie-parser": "^1.4.6", @@ -40,7 +41,7 @@ "fibers": "^5.0.3", "jaeger-client": "^3.19.0", "mem": "^8.1.1", - "moleculer": "^0.14.31", + "moleculer": "^0.14.34", "mongodb": "^4.17.2", "nats": "^2.6.1", "pino": "^8.15.0", @@ -51,17 +52,17 @@ }, "devDependencies": { "@rocket.chat/icons": "~0.38.0", - "@types/cookie": "^0.5.3", - "@types/cookie-parser": "^1.4.5", - "@types/ejson": "^2.2.1", - "@types/express": "^4.17.20", - "@types/fibers": "^3.1.3", + "@types/cookie": "^0.5.4", + "@types/cookie-parser": "^1.4.7", + "@types/ejson": "^2.2.2", + "@types/express": "^4.17.21", + "@types/fibers": "^3.1.4", "@types/node": "^14.18.63", - "@types/ws": "^8.5.8", + "@types/ws": "^8.5.12", "npm-run-all": "^4.1.5", "pino-pretty": "^7.6.1", "pm2": "^5.2.0", - "ts-node": "^10.9.1", + "ts-node": "^10.9.2", "typescript": "~5.5.4" }, "volta": { diff --git a/apps/meteor/ee/server/startup/index.ts b/apps/meteor/ee/server/startup/index.ts index a8091f0e9a37..eb09ca6ed30f 100644 --- a/apps/meteor/ee/server/startup/index.ts +++ b/apps/meteor/ee/server/startup/index.ts @@ -13,7 +13,7 @@ import { isRunningMs } from '../../../server/lib/isRunningMs'; export const registerEEBroker = async (): Promise => { // only starts network broker if running in micro services mode if (isRunningMs()) { - const { broker } = await import('./broker'); + const { broker } = await import('@rocket.chat/network-broker'); api.setBroker(broker); void api.start(); diff --git a/apps/meteor/ee/tests/unit/apps/livechat-enterprise/server/hooks/manageDepartmentUnit.spec.ts b/apps/meteor/ee/tests/unit/apps/livechat-enterprise/server/hooks/manageDepartmentUnit.spec.ts new file mode 100644 index 000000000000..8fbf0dcf97a2 --- /dev/null +++ b/apps/meteor/ee/tests/unit/apps/livechat-enterprise/server/hooks/manageDepartmentUnit.spec.ts @@ -0,0 +1,183 @@ +import { expect } from 'chai'; +import { describe, it } from 'mocha'; +import proxyquire from 'proxyquire'; +import sinon from 'sinon'; + +const livechatDepartmentStub = { + findOneById: sinon.stub(), + addDepartmentToUnit: sinon.stub(), + removeDepartmentFromUnit: sinon.stub(), +}; + +const livechatUnitStub = { + findOneById: sinon.stub(), + decrementDepartmentsCount: sinon.stub(), + incrementDepartmentsCount: sinon.stub(), +}; + +const hasAnyRoleStub = sinon.stub(); +const getUnitsFromUserStub = sinon.stub(); + +const { manageDepartmentUnit } = proxyquire + .noCallThru() + .load('../../../../../../app/livechat-enterprise/server/hooks/manageDepartmentUnit.ts', { + '../methods/getUnitsFromUserRoles': { + getUnitsFromUser: getUnitsFromUserStub, + }, + '../../../../../app/authorization/server/functions/hasRole': { + hasAnyRoleAsync: hasAnyRoleStub, + }, + '@rocket.chat/models': { + LivechatDepartment: livechatDepartmentStub, + LivechatUnit: livechatUnitStub, + }, + }); + +describe('hooks/manageDepartmentUnit', () => { + beforeEach(() => { + livechatDepartmentStub.findOneById.reset(); + livechatDepartmentStub.addDepartmentToUnit.reset(); + livechatDepartmentStub.removeDepartmentFromUnit.reset(); + livechatUnitStub.findOneById.reset(); + livechatUnitStub.decrementDepartmentsCount.reset(); + livechatUnitStub.incrementDepartmentsCount.reset(); + hasAnyRoleStub.reset(); + }); + + it('should not perform any operation when an invalid department is provided', async () => { + livechatDepartmentStub.findOneById.resolves(null); + hasAnyRoleStub.resolves(true); + getUnitsFromUserStub.resolves(['unit-id']); + + await manageDepartmentUnit({ userId: 'user-id', departmentId: 'department-id', unitId: 'unit-id' }); + expect(livechatDepartmentStub.addDepartmentToUnit.notCalled).to.be.true; + expect(livechatDepartmentStub.removeDepartmentFromUnit.notCalled).to.be.true; + expect(livechatUnitStub.decrementDepartmentsCount.notCalled).to.be.true; + expect(livechatUnitStub.incrementDepartmentsCount.notCalled).to.be.true; + }); + + it('should not perform any operation if the provided department is already in unit', async () => { + livechatDepartmentStub.findOneById.resolves({ _id: 'department-id', ancestors: ['unit-id'], parentId: 'unit-id' }); + hasAnyRoleStub.resolves(true); + getUnitsFromUserStub.resolves(['unit-id']); + + await manageDepartmentUnit({ userId: 'user-id', departmentId: 'department-id', unitId: 'unit-id' }); + expect(livechatDepartmentStub.addDepartmentToUnit.notCalled).to.be.true; + expect(livechatDepartmentStub.removeDepartmentFromUnit.notCalled).to.be.true; + expect(livechatUnitStub.decrementDepartmentsCount.notCalled).to.be.true; + expect(livechatUnitStub.incrementDepartmentsCount.notCalled).to.be.true; + }); + + it("should not perform any operation if user is a monitor and can't manage new unit", async () => { + livechatDepartmentStub.findOneById.resolves({ _id: 'department-id', ancestors: ['unit-id'], parentId: 'unit-id' }); + hasAnyRoleStub.resolves(false); + getUnitsFromUserStub.resolves(['unit-id']); + + await manageDepartmentUnit({ userId: 'user-id', departmentId: 'department-id', unitId: 'new-unit-id' }); + expect(livechatDepartmentStub.addDepartmentToUnit.notCalled).to.be.true; + expect(livechatDepartmentStub.removeDepartmentFromUnit.notCalled).to.be.true; + expect(livechatUnitStub.decrementDepartmentsCount.notCalled).to.be.true; + expect(livechatUnitStub.incrementDepartmentsCount.notCalled).to.be.true; + }); + + it("should not perform any operation if user is a monitor and can't manage current unit", async () => { + livechatDepartmentStub.findOneById.resolves({ _id: 'department-id', ancestors: ['unit-id'], parentId: 'unit-id' }); + hasAnyRoleStub.resolves(false); + getUnitsFromUserStub.resolves(['new-unit-id']); + + await manageDepartmentUnit({ userId: 'user-id', departmentId: 'department-id', unitId: 'new-unit-id' }); + expect(livechatDepartmentStub.addDepartmentToUnit.notCalled).to.be.true; + expect(livechatDepartmentStub.removeDepartmentFromUnit.notCalled).to.be.true; + expect(livechatUnitStub.decrementDepartmentsCount.notCalled).to.be.true; + expect(livechatUnitStub.incrementDepartmentsCount.notCalled).to.be.true; + }); + + it('should not perform any operation if user is an admin/manager but an invalid unit is provided', async () => { + livechatDepartmentStub.findOneById.resolves({ _id: 'department-id', ancestors: ['unit-id'], parentId: 'unit-id' }); + livechatUnitStub.findOneById.resolves(undefined); + hasAnyRoleStub.resolves(true); + getUnitsFromUserStub.resolves(undefined); + + await manageDepartmentUnit({ userId: 'user-id', departmentId: 'department-id', unitId: 'new-unit-id' }); + expect(livechatDepartmentStub.addDepartmentToUnit.notCalled).to.be.true; + expect(livechatDepartmentStub.removeDepartmentFromUnit.notCalled).to.be.true; + expect(livechatUnitStub.decrementDepartmentsCount.notCalled).to.be.true; + expect(livechatUnitStub.incrementDepartmentsCount.notCalled).to.be.true; + }); + + it('should remove department from its current unit if user is an admin/manager', async () => { + livechatDepartmentStub.findOneById.resolves({ _id: 'department-id', ancestors: ['unit-id'], parentId: 'unit-id' }); + hasAnyRoleStub.resolves(true); + getUnitsFromUserStub.resolves(undefined); + + await manageDepartmentUnit({ userId: 'user-id', departmentId: 'department-id', unitId: undefined }); + expect(livechatDepartmentStub.addDepartmentToUnit.notCalled).to.be.true; + expect(livechatUnitStub.incrementDepartmentsCount.notCalled).to.be.true; + expect(livechatDepartmentStub.removeDepartmentFromUnit.calledOnceWith('department-id')).to.be.true; + expect(livechatUnitStub.decrementDepartmentsCount.calledOnceWith('unit-id')).to.be.true; + }); + + it('should add department to a unit if user is an admin/manager', async () => { + livechatDepartmentStub.findOneById.resolves({ _id: 'department-id' }); + livechatUnitStub.findOneById.resolves({ _id: 'unit-id' }); + hasAnyRoleStub.resolves(true); + getUnitsFromUserStub.resolves(undefined); + + await manageDepartmentUnit({ userId: 'user-id', departmentId: 'department-id', unitId: 'unit-id' }); + expect(livechatDepartmentStub.addDepartmentToUnit.calledOnceWith('department-id', 'unit-id', ['unit-id'])).to.be.true; + expect(livechatUnitStub.incrementDepartmentsCount.calledOnceWith('unit-id')).to.be.true; + expect(livechatDepartmentStub.removeDepartmentFromUnit.notCalled).to.be.true; + expect(livechatUnitStub.decrementDepartmentsCount.notCalled).to.be.true; + }); + + it('should move department to a new unit if user is an admin/manager', async () => { + livechatDepartmentStub.findOneById.resolves({ _id: 'department-id', ancestors: ['unit-id'], parentId: 'unit-id' }); + livechatUnitStub.findOneById.resolves({ _id: 'new-unit-id' }); + hasAnyRoleStub.resolves(true); + getUnitsFromUserStub.resolves(undefined); + + await manageDepartmentUnit({ userId: 'user-id', departmentId: 'department-id', unitId: 'new-unit-id' }); + expect(livechatDepartmentStub.addDepartmentToUnit.calledOnceWith('department-id', 'new-unit-id', ['new-unit-id'])).to.be.true; + expect(livechatUnitStub.incrementDepartmentsCount.calledOnceWith('new-unit-id')).to.be.true; + expect(livechatDepartmentStub.removeDepartmentFromUnit.notCalled).to.be.true; + expect(livechatUnitStub.decrementDepartmentsCount.calledOnceWith('unit-id')).to.be.true; + }); + + it('should remove department from its current unit if user is a monitor that supervises the current unit', async () => { + livechatDepartmentStub.findOneById.resolves({ _id: 'department-id', ancestors: ['unit-id'], parentId: 'unit-id' }); + hasAnyRoleStub.resolves(false); + getUnitsFromUserStub.resolves(['unit-id']); + + await manageDepartmentUnit({ userId: 'user-id', departmentId: 'department-id', unitId: undefined }); + expect(livechatDepartmentStub.addDepartmentToUnit.notCalled).to.be.true; + expect(livechatUnitStub.incrementDepartmentsCount.notCalled).to.be.true; + expect(livechatDepartmentStub.removeDepartmentFromUnit.calledOnceWith('department-id')).to.be.true; + expect(livechatUnitStub.decrementDepartmentsCount.calledOnceWith('unit-id')).to.be.true; + }); + + it('should add department to a unit if user is a monitor that supervises the new unit', async () => { + livechatDepartmentStub.findOneById.resolves({ _id: 'department-id' }); + livechatUnitStub.findOneById.resolves({ _id: 'unit-id' }); + hasAnyRoleStub.resolves(false); + getUnitsFromUserStub.resolves(['unit-id']); + + await manageDepartmentUnit({ userId: 'user-id', departmentId: 'department-id', unitId: 'unit-id' }); + expect(livechatDepartmentStub.addDepartmentToUnit.calledOnceWith('department-id', 'unit-id', ['unit-id'])).to.be.true; + expect(livechatUnitStub.incrementDepartmentsCount.calledOnceWith('unit-id')).to.be.true; + expect(livechatDepartmentStub.removeDepartmentFromUnit.notCalled).to.be.true; + expect(livechatUnitStub.decrementDepartmentsCount.notCalled).to.be.true; + }); + + it('should move department to a new unit if user is a monitor that supervises the current and new units', async () => { + livechatDepartmentStub.findOneById.resolves({ _id: 'department-id', ancestors: ['unit-id'], parentId: 'unit-id' }); + livechatUnitStub.findOneById.resolves({ _id: 'unit-id' }); + hasAnyRoleStub.resolves(false); + getUnitsFromUserStub.resolves(['unit-id', 'new-unit-id']); + + await manageDepartmentUnit({ userId: 'user-id', departmentId: 'department-id', unitId: 'new-unit-id' }); + expect(livechatDepartmentStub.addDepartmentToUnit.calledOnceWith('department-id', 'new-unit-id', ['new-unit-id'])).to.be.true; + expect(livechatUnitStub.incrementDepartmentsCount.calledOnceWith('new-unit-id')).to.be.true; + expect(livechatDepartmentStub.removeDepartmentFromUnit.notCalled).to.be.true; + expect(livechatUnitStub.decrementDepartmentsCount.calledOnceWith('unit-id')).to.be.true; + }); +}); diff --git a/apps/meteor/lib/callbacks.ts b/apps/meteor/lib/callbacks.ts index 7eaa9ed7595d..dcfd7a021c5e 100644 --- a/apps/meteor/lib/callbacks.ts +++ b/apps/meteor/lib/callbacks.ts @@ -225,6 +225,7 @@ type ChainedCallbackSignatures = { 'unarchiveRoom': (room: IRoom) => void; 'roomAvatarChanged': (room: IRoom) => void; 'beforeGetMentions': (mentionIds: string[], teamMentions: MessageMention[]) => Promise; + 'livechat.manageDepartmentUnit': (params: { userId: string; departmentId: string; unitId?: string }) => void; }; export type Hook = @@ -247,6 +248,7 @@ export type Hook = | 'livechat.offlineMessage' | 'livechat.onCheckRoomApiParams' | 'livechat.onLoadConfigApi' + | 'livechat.manageDepartmentUnit' | 'loginPageStateChange' | 'mapLDAPUserData' | 'onCreateUser' @@ -254,10 +256,8 @@ export type Hook = | 'onValidateLogin' | 'openBroadcast' | 'renderNotification' - | 'setReaction' | 'streamMessage' | 'streamNewMessage' - | 'unsetReaction' | 'userAvatarSet' | 'userConfirmationEmailRequested' | 'userForgotPasswordEmailRequested' diff --git a/apps/meteor/lib/publishFields.ts b/apps/meteor/lib/publishFields.ts index c1483ea86cd1..48d0f9c87d5c 100644 --- a/apps/meteor/lib/publishFields.ts +++ b/apps/meteor/lib/publishFields.ts @@ -74,6 +74,7 @@ export const roomFields = { avatarETag: 1, usersCount: 1, msgs: 1, + sidepanel: 1, // @TODO create an API to register this fields based on room type tags: 1, diff --git a/apps/meteor/package.json b/apps/meteor/package.json index 43b62def0819..b5c9003b6974 100644 --- a/apps/meteor/package.json +++ b/apps/meteor/package.json @@ -1,7 +1,7 @@ { "name": "@rocket.chat/meteor", "description": "The Ultimate Open Source WebChat Platform", - "version": "6.13.0-develop", + "version": "6.14.0-develop", "private": true, "author": { "name": "Rocket.Chat", @@ -66,7 +66,7 @@ "devDependencies": { "@axe-core/playwright": "^4.7.3", "@babel/core": "~7.22.20", - "@babel/eslint-parser": "~7.23.3", + "@babel/eslint-parser": "~7.23.10", "@babel/plugin-proposal-nullish-coalescing-operator": "~7.18.6", "@babel/plugin-proposal-optional-chaining": "~7.21.0", "@babel/preset-env": "~7.22.20", @@ -87,76 +87,76 @@ "@storybook/react": "~6.5.16", "@storybook/testing-library": "0.0.13", "@tanstack/react-query-devtools": "^4.19.1", - "@testing-library/react": "~16.0.0", + "@testing-library/react": "~16.0.1", "@testing-library/user-event": "~14.5.2", - "@types/adm-zip": "^0.5.3", + "@types/adm-zip": "^0.5.5", "@types/archiver": "^5.3.4", - "@types/bad-words": "^3.0.2", - "@types/bcrypt": "^5.0.1", - "@types/body-parser": "^1.19.4", - "@types/busboy": "^1.5.2", - "@types/chai": "~4.3.16", - "@types/chai-as-promised": "^7.1.7", - "@types/chai-datetime": "0.0.38", - "@types/chai-dom": "1.11.2", - "@types/chai-spies": "~1.0.5", - "@types/codemirror": "^5.60.12", - "@types/cookie-parser": "^1.4.5", - "@types/cors": "^2.8.15", - "@types/cssom": "^0.4.2", + "@types/bad-words": "^3.0.3", + "@types/bcrypt": "^5.0.2", + "@types/body-parser": "^1.19.5", + "@types/busboy": "^1.5.4", + "@types/chai": "~4.3.19", + "@types/chai-as-promised": "^7.1.8", + "@types/chai-datetime": "0.0.39", + "@types/chai-dom": "1.11.3", + "@types/chai-spies": "~1.0.6", + "@types/codemirror": "^5.60.15", + "@types/cookie-parser": "^1.4.7", + "@types/cors": "^2.8.17", + "@types/cssom": "^0.4.3", "@types/dompurify": "^2.3.3", - "@types/ejson": "^2.2.1", - "@types/express": "^4.17.20", + "@types/ejson": "^2.2.2", + "@types/express": "^4.17.21", "@types/express-rate-limit": "^5.1.3", - "@types/fibers": "^3.1.3", - "@types/google-libphonenumber": "^7.4.29", - "@types/gravatar": "^1.8.5", + "@types/fibers": "^3.1.4", + "@types/google-libphonenumber": "^7.4.30", + "@types/gravatar": "^1.8.6", "@types/he": "^1.1.2", - "@types/i18next-sprintf-postprocessor": "^0.2.2", - "@types/imap": "^0.8.39", - "@types/jest": "~29.5.12", + "@types/i18next-sprintf-postprocessor": "^0.2.3", + "@types/imap": "^0.8.40", + "@types/jest": "~29.5.13", "@types/jsdom": "^16.2.15", - "@types/jsdom-global": "^3.0.6", - "@types/jsrsasign": "^10.5.11", - "@types/later": "^1.2.8", + "@types/jsdom-global": "^3.0.7", + "@types/jsrsasign": "^10.5.14", + "@types/later": "^1.2.9", "@types/ldapjs": "^2.2.5", - "@types/less": "~3.0.5", + "@types/less": "~3.0.6", "@types/lodash.clonedeep": "^4.5.9", - "@types/lodash.get": "^4.4.8", - "@types/mailparser": "^3.4.3", + "@types/lodash.get": "^4.4.9", + "@types/mailparser": "^3.4.4", "@types/marked": "^4.0.8", "@types/meteor": "^2.9.8", - "@types/meteor-collection-hooks": "^0.8.8", + "@types/meteor-collection-hooks": "^0.8.9", "@types/mkdirp": "^1.0.2", "@types/mocha": "github:whitecolor/mocha-types", "@types/moment-timezone": "^0.5.30", "@types/node": "^14.18.63", - "@types/node-gcm": "^1.0.3", - "@types/node-rsa": "^1.1.3", - "@types/nodemailer": "^6.4.13", - "@types/oauth2-server": "^3.0.15", - "@types/parseurl": "^1.3.2", - "@types/prometheus-gc-stats": "^0.6.3", - "@types/proxyquire": "^1.3.30", - "@types/psl": "^1.1.2", - "@types/react": "~17.0.69", - "@types/react-dom": "~17.0.22", - "@types/rewire": "^2.5.29", + "@types/node-gcm": "^1.0.5", + "@types/node-rsa": "^1.1.4", + "@types/nodemailer": "^6.4.15", + "@types/oauth2-server": "^3.0.17", + "@types/parseurl": "^1.3.3", + "@types/prometheus-gc-stats": "^0.6.4", + "@types/proxyquire": "^1.3.31", + "@types/psl": "^1.1.3", + "@types/react": "~17.0.80", + "@types/react-dom": "~17.0.25", + "@types/rewire": "^2.5.30", "@types/sanitize-html": "^2.9.3", "@types/semver": "^7.3.10", "@types/sharp": "^0.30.5", "@types/sinon": "^10.0.20", - "@types/strict-uri-encode": "^2.0.1", + "@types/strict-uri-encode": "^2.0.2", "@types/string-strip-html": "^5.0.1", - "@types/supertest": "^2.0.15", - "@types/supports-color": "~7.2.0", - "@types/textarea-caret": "^3.0.2", - "@types/ua-parser-js": "^0.7.38", - "@types/use-subscription": "^1.0.1", - "@types/use-sync-external-store": "^0.0.5", + "@types/supertest": "^2.0.16", + "@types/supports-color": "~7.2.1", + "@types/textarea-caret": "^3.0.3", + "@types/ua-parser-js": "^0.7.39", + "@types/use-subscription": "^1.0.2", + "@types/use-sync-external-store": "^0.0.6", "@types/uuid": "^8.3.4", - "@types/xml-crypto": "~1.4.4", - "@types/xml-encryption": "~1.2.3", + "@types/xml-crypto": "~1.4.6", + "@types/xml-encryption": "~1.2.4", "@typescript-eslint/eslint-plugin": "~5.60.1", "@typescript-eslint/parser": "~5.60.1", "autoprefixer": "^9.8.8", @@ -164,12 +164,12 @@ "babel-plugin-array-includes": "^2.0.3", "babel-plugin-istanbul": "^6.1.1", "chai": "^4.3.10", - "chai-as-promised": "^7.1.1", - "chai-datetime": "^1.8.0", + "chai-as-promised": "^7.1.2", + "chai-datetime": "^1.8.1", "chai-dom": "^1.11.0", "chai-spies": "~1.0.0", "cross-env": "^7.0.3", - "docker-compose": "^0.24.3", + "docker-compose": "^0.24.8", "emojione-assets": "^4.5.0", "eslint": "~8.45.0", "eslint-config-prettier": "~8.8.0", @@ -179,19 +179,19 @@ "eslint-plugin-playwright": "~0.15.3", "eslint-plugin-prettier": "~4.2.1", "eslint-plugin-react": "~7.32.2", - "eslint-plugin-react-hooks": "~4.6.0", + "eslint-plugin-react-hooks": "~4.6.2", "eslint-plugin-testing-library": "~6.2.2", "eslint-plugin-you-dont-need-lodash-underscore": "~6.12.0", "fast-glob": "^3.2.12", - "i18next": "~23.4.9", + "i18next": "~23.14.0", "jest": "~29.7.0", "jsdom-global": "^3.0.2", "mocha": "^9.2.2", "nyc": "^15.1.0", "outdent": "~0.8.0", "pino-pretty": "^7.6.1", - "playwright-qase-reporter": "^1.2.1", - "postcss": "~8.4.31", + "playwright-qase-reporter": "^1.2.2", + "postcss": "~8.4.45", "postcss-custom-properties": "^11.0.0", "postcss-easy-import": "^3.0.0", "postcss-load-config": "^3.1.4", @@ -200,7 +200,7 @@ "postcss-url": "^10.1.3", "prettier": "~2.8.8", "proxyquire": "^2.1.3", - "react-docgen-typescript-plugin": "^1.0.5", + "react-docgen-typescript-plugin": "^1.0.8", "rewire": "^6.0.0", "sinon": "^14.0.2", "source-map": "^0.7.4", @@ -209,7 +209,7 @@ "supertest": "^6.2.3", "supports-color": "~7.2.0", "template-file": "^6.0.1", - "ts-node": "^10.9.1", + "ts-node": "^10.9.2", "typescript": "~5.5.4" }, "dependencies": { @@ -217,7 +217,7 @@ "@bugsnag/js": "~7.20.2", "@bugsnag/plugin-react": "~7.19.0", "@google-cloud/storage": "^6.11.0", - "@kaciras/deasync": "^1.0.3", + "@kaciras/deasync": "^1.0.4", "@nivo/bar": "0.84.0", "@nivo/core": "0.84.0", "@nivo/heatmap": "0.84.0", @@ -230,7 +230,7 @@ "@rocket.chat/agenda": "workspace:^", "@rocket.chat/api-client": "workspace:^", "@rocket.chat/apps": "workspace:^", - "@rocket.chat/apps-engine": "1.45.0-alpha.868", + "@rocket.chat/apps-engine": "workspace:^", "@rocket.chat/base64": "workspace:^", "@rocket.chat/cas-validate": "workspace:^", "@rocket.chat/core-services": "workspace:^", @@ -241,7 +241,7 @@ "@rocket.chat/favicon": "workspace:^", "@rocket.chat/forked-matrix-appservice-bridge": "^4.0.2", "@rocket.chat/forked-matrix-bot-sdk": "^0.6.0-beta.3", - "@rocket.chat/fuselage": "^0.59.0", + "@rocket.chat/fuselage": "^0.59.1", "@rocket.chat/fuselage-hooks": "^0.33.1", "@rocket.chat/fuselage-polyfills": "~0.31.25", "@rocket.chat/fuselage-toastbar": "^0.33.0", @@ -252,7 +252,7 @@ "@rocket.chat/icons": "~0.38.0", "@rocket.chat/instance-status": "workspace:^", "@rocket.chat/jwt": "workspace:^", - "@rocket.chat/layout": "~0.31.26", + "@rocket.chat/layout": "~0.31.27", "@rocket.chat/license": "workspace:^", "@rocket.chat/log-format": "workspace:^", "@rocket.chat/logger": "workspace:^", @@ -262,6 +262,7 @@ "@rocket.chat/model-typings": "workspace:^", "@rocket.chat/models": "workspace:^", "@rocket.chat/mp3-encoder": "0.24.0", + "@rocket.chat/network-broker": "workspace:^", "@rocket.chat/omnichannel-services": "workspace:^", "@rocket.chat/onboarding-ui": "~0.33.3", "@rocket.chat/password-policies": "workspace:^", @@ -287,15 +288,15 @@ "@slack/bolt": "^3.14.0", "@slack/rtm-api": "^6.0.0", "@tanstack/react-query": "^4.16.1", - "@types/cookie": "^0.5.3", + "@types/cookie": "^0.5.4", "@types/katex": "^0.14.0", "@types/lodash": "^4.14.200", - "@types/lodash.debounce": "^4.0.8", - "@types/object-path": "^0.11.3", - "@types/proxy-from-env": "^1.0.3", - "@types/speakeasy": "^2.0.9", + "@types/lodash.debounce": "^4.0.9", + "@types/object-path": "^0.11.4", + "@types/proxy-from-env": "^1.0.4", + "@types/speakeasy": "^2.0.10", "@xmldom/xmldom": "^0.8.10", - "adm-zip": "0.5.10", + "adm-zip": "0.5.16", "ajv": "^8.11.0", "ajv-formats": "~2.1.1", "apn": "2.2.0", @@ -306,14 +307,14 @@ "aws-sdk": "^2.1363.0", "bad-words": "^3.0.4", "bcrypt": "^5.0.1", - "body-parser": "1.20.2", + "body-parser": "1.20.3", "bson": "^4.6.4", "busboy": "^1.6.0", "bytebuffer": "5.0.1", "chalk": "^4.0.0", "change-case": "^4.1.2", "chart.js": "^3.8.0", - "codemirror": "^5.65.15", + "codemirror": "^5.65.17", "colorette": "^2.0.20", "colors": "^1.4.0", "connect": "^3.7.0", @@ -321,7 +322,7 @@ "cookie-parser": "^1.4.6", "cors": "^2.8.5", "cron": "~1.8.2", - "css-vars-ponyfill": "^2.4.8", + "css-vars-ponyfill": "^2.4.9", "csv-parse": "^5.2.0", "date-fns": "^2.28.0", "date.js": "~0.3.3", @@ -342,7 +343,7 @@ "filenamify": "^4.3.0", "filesize": "9.0.11", "generate-password": "^1.7.1", - "google-libphonenumber": "^3.2.33", + "google-libphonenumber": "^3.2.38", "gravatar": "^1.8.2", "he": "^1.2.0", "highlight.js": "^11.6.0", @@ -361,7 +362,7 @@ "jsdom": "^16.7.0", "jsrsasign": "^10.5.24", "juice": "^8.0.0", - "katex": "~0.16.9", + "katex": "~0.16.11", "ldap-escape": "^2.0.6", "ldapjs": "^2.3.3", "limax": "^3.0.0", @@ -372,13 +373,13 @@ "mailparser": "^3.4.0", "marked": "^4.2.5", "mem": "^8.1.1", - "meteor-node-stubs": "^1.2.5", + "meteor-node-stubs": "^1.2.10", "mime-db": "^1.52.0", "mime-type": "^4.0.0", "mkdirp": "^1.0.4", - "moleculer": "^0.14.31", + "moleculer": "^0.14.34", "moment": "^2.29.4", - "moment-timezone": "^0.5.43", + "moment-timezone": "^0.5.45", "mongo-message-queue": "^1.0.0", "mongodb": "^4.17.2", "nats": "^2.6.1", @@ -402,13 +403,13 @@ "query-string": "^7.1.3", "queue-fifo": "^0.2.6", "rc-scrollbars": "^1.1.6", - "re-resizable": "^6.9.9", + "re-resizable": "^6.9.18", "react": "~17.0.2", "react-aria": "~3.23.1", "react-dom": "~17.0.2", "react-error-boundary": "^3.1.4", "react-hook-form": "~7.45.4", - "react-i18next": "~13.2.2", + "react-i18next": "~15.0.1", "react-keyed-flatten-children": "^1.3.0", "react-virtuoso": "^4.7.1", "redis": "^4.0.6", @@ -419,7 +420,7 @@ "sodium-native": "^3.3.0", "sodium-plus": "^0.9.0", "speakeasy": "^2.0.0", - "stream-buffers": "^3.0.2", + "stream-buffers": "^3.0.3", "strict-uri-encode": "^2.0.0", "string-strip-html": "^7.0.3", "suretype": "~2.4.1", @@ -431,15 +432,15 @@ "twilio": "^3.76.1", "twit": "^2.2.11", "typia": "~6.9.0", - "ua-parser-js": "^1.0.37", - "underscore": "^1.13.6", + "ua-parser-js": "^1.0.38", + "underscore": "^1.13.7", "universal-perf-hooks": "^1.0.1", "url-polyfill": "^1.1.12", "use-subscription": "~1.6.0", - "use-sync-external-store": "^1.2.0", + "use-sync-external-store": "^1.2.2", "uuid": "^8.3.2", "vm2": "^3.9.19", - "webdav": "^4.11.3", + "webdav": "^4.11.4", "xml-crypto": "~3.1.0", "xml-encryption": "~3.0.2", "xml2js": "~0.5.0", diff --git a/apps/meteor/public/images/featurePreview/enhanced-navigation.png b/apps/meteor/public/images/featurePreview/enhanced-navigation.png new file mode 100644 index 000000000000..4240326ba985 Binary files /dev/null and b/apps/meteor/public/images/featurePreview/enhanced-navigation.png differ diff --git a/apps/meteor/public/images/featurePreview/resizable-contextual-bar.png b/apps/meteor/public/images/featurePreview/resizable-contextual-bar.png new file mode 100644 index 000000000000..c36c7e44a29d Binary files /dev/null and b/apps/meteor/public/images/featurePreview/resizable-contextual-bar.png differ diff --git a/apps/meteor/public/images/featurePreview/timestamp.png b/apps/meteor/public/images/featurePreview/timestamp.png new file mode 100644 index 000000000000..7573f97db55b Binary files /dev/null and b/apps/meteor/public/images/featurePreview/timestamp.png differ diff --git a/apps/meteor/server/features/EmailInbox/EmailInbox_Incoming.ts b/apps/meteor/server/features/EmailInbox/EmailInbox_Incoming.ts index 0797d896f22d..7ecb8f309b29 100644 --- a/apps/meteor/server/features/EmailInbox/EmailInbox_Incoming.ts +++ b/apps/meteor/server/features/EmailInbox/EmailInbox_Incoming.ts @@ -22,7 +22,7 @@ import { logger } from './logger'; type FileAttachment = VideoAttachmentProps & ImageAttachmentProps & AudioAttachmentProps; const language = settings.get('Language') || 'en'; -const t = (s: string): string => i18n.t(s, { lng: language }); +const t = i18n.getFixedT(language); async function getGuestByEmail(email: string, name: string, department = ''): Promise { const guest = await LivechatVisitors.findOneGuestByEmailAddress(email); diff --git a/apps/meteor/server/features/EmailInbox/EmailInbox_Outgoing.ts b/apps/meteor/server/features/EmailInbox/EmailInbox_Outgoing.ts index 51718e4937d8..44a9e2cd6a15 100644 --- a/apps/meteor/server/features/EmailInbox/EmailInbox_Outgoing.ts +++ b/apps/meteor/server/features/EmailInbox/EmailInbox_Outgoing.ts @@ -20,7 +20,7 @@ const livechatQuoteRegExp = /^\[\s\]\(https?:\/\/.+\/live\/.+\?msg=(?.+?)\)\ const getRocketCatUser = async (): Promise => Users.findOneById('rocket.cat'); const language = settings.get('Language') || 'en'; -const t = (s: string): string => i18n.t(s, { lng: language }); +const t = i18n.getFixedT(language); // TODO: change these messages with room notifications const sendErrorReplyMessage = async (error: string, options: any) => { diff --git a/apps/meteor/server/lib/i18n.ts b/apps/meteor/server/lib/i18n.ts index bc3ed6184937..28ba6cec6dad 100644 --- a/apps/meteor/server/lib/i18n.ts +++ b/apps/meteor/server/lib/i18n.ts @@ -1,6 +1,4 @@ -import type { RocketchatI18nKeys } from '@rocket.chat/i18n'; import i18nDict from '@rocket.chat/i18n'; -import type { TOptions } from 'i18next'; import { availableTranslationNamespaces, defaultTranslationNamespace, extractTranslationNamespaces, i18n } from '../../app/utils/lib/i18n'; @@ -18,13 +16,4 @@ void i18n.init({ initImmediate: false, }); -declare module 'i18next' { - // eslint-disable-next-line @typescript-eslint/naming-convention - interface TFunction { - (key: RocketchatI18nKeys, options?: TOptions): string; - (key: RocketchatI18nKeys, ...options: unknown[]): string; - (key: T extends string ? (T extends RocketchatI18nKeys ? T : never) : never, options?: TOptions): string; - } -} - export { i18n }; diff --git a/apps/meteor/server/lib/resetUserE2EKey.ts b/apps/meteor/server/lib/resetUserE2EKey.ts index 85da6e59cf60..26ac78f30ad6 100644 --- a/apps/meteor/server/lib/resetUserE2EKey.ts +++ b/apps/meteor/server/lib/resetUserE2EKey.ts @@ -20,7 +20,7 @@ const sendResetNotification = async function (uid: string): Promise { return; } - const t = (s: string): string => i18n.t(s, { lng: language }); + const t = i18n.getFixedT(language); const text = ` ${t('Your_e2e_key_has_been_reset')} diff --git a/apps/meteor/server/models/raw/BaseRaw.ts b/apps/meteor/server/models/raw/BaseRaw.ts index d822038b177e..3e763017bbd3 100644 --- a/apps/meteor/server/models/raw/BaseRaw.ts +++ b/apps/meteor/server/models/raw/BaseRaw.ts @@ -318,33 +318,33 @@ export abstract class BaseRaw< async findOneAndDelete(filter: Filter, options?: FindOneAndDeleteOptions): Promise> { if (!this.trash) { - if (options) { - return this.col.findOneAndDelete(filter, options); - } - return this.col.findOneAndDelete(filter); + return this.col.findOneAndDelete(filter, options || {}); } - const result = await this.col.findOneAndDelete(filter); - - const { value: doc } = result; + const doc = await this.col.findOne(filter); if (!doc) { - return result; + return { ok: 1, value: null }; } const { _id, ...record } = doc; - const trash: TDeleted = { ...record, _deletedAt: new Date(), __collection__: this.name, } as unknown as TDeleted; - // since the operation is not atomic, we need to make sure that the record is not already deleted/inserted await this.trash?.updateOne({ _id } as Filter, { $set: trash } as UpdateFilter, { upsert: true, }); - return result; + try { + await this.col.deleteOne({ _id } as Filter); + } catch (e) { + await this.trash?.deleteOne({ _id } as Filter); + throw e; + } + + return { ok: 1, value: doc }; } async deleteMany(filter: Filter, options?: DeleteOptions & { onTrash?: (record: ResultFields) => void }): Promise { diff --git a/apps/meteor/server/models/raw/EmojiCustom.ts b/apps/meteor/server/models/raw/EmojiCustom.ts index 300721f60d7b..ec3c6390f64d 100644 --- a/apps/meteor/server/models/raw/EmojiCustom.ts +++ b/apps/meteor/server/models/raw/EmojiCustom.ts @@ -72,4 +72,12 @@ export class EmojiCustomRaw extends BaseRaw implements IEmojiCusto create(data: InsertionModel): Promise>> { return this.insertOne(data); } + + countByNameOrAlias(name: string): Promise { + const query = { + $or: [{ name }, { aliases: name }], + }; + + return this.countDocuments(query); + } } diff --git a/apps/meteor/server/models/raw/LivechatCustomField.ts b/apps/meteor/server/models/raw/LivechatCustomField.ts index 71228f55069d..38a93f6439b4 100644 --- a/apps/meteor/server/models/raw/LivechatCustomField.ts +++ b/apps/meteor/server/models/raw/LivechatCustomField.ts @@ -13,12 +13,12 @@ export class LivechatCustomFieldRaw extends BaseRaw implem return [{ key: { scope: 1 } }]; } - findByScope( + findByScope( scope: ILivechatCustomField['scope'], options?: FindOptions, includeHidden = true, - ): FindCursor { - return this.find({ scope, ...(includeHidden === true ? {} : { visibility: { $ne: 'hidden' } }) }, options); + ): FindCursor { + return this.find({ scope, ...(includeHidden === true ? {} : { visibility: { $ne: 'hidden' } }) }, options); } findMatchingCustomFields( diff --git a/apps/meteor/server/models/raw/LivechatDepartment.ts b/apps/meteor/server/models/raw/LivechatDepartment.ts index b8263af030a8..9ecee34df5e9 100644 --- a/apps/meteor/server/models/raw/LivechatDepartment.ts +++ b/apps/meteor/server/models/raw/LivechatDepartment.ts @@ -222,6 +222,14 @@ export class LivechatDepartmentRaw extends BaseRaw implemen return this.updateOne({ _id }, { $set: { archived: true, enabled: false } }); } + addDepartmentToUnit(_id: string, unitId: string, ancestors: string[]): Promise { + return this.updateOne({ _id }, { $set: { parentId: unitId, ancestors } }); + } + + removeDepartmentFromUnit(_id: string): Promise { + return this.updateOne({ _id }, { $set: { parentId: null, ancestors: null } }); + } + async createOrUpdateDepartment( _id: string | null, data: { @@ -328,6 +336,10 @@ export class LivechatDepartmentRaw extends BaseRaw implemen return this.find(query, options); } + countDepartmentsInUnit(unitId: string): Promise { + return this.countDocuments({ parentId: unitId }); + } + findActiveByUnitIds(unitIds: string[], options: FindOptions = {}): FindCursor { const query = { enabled: true, diff --git a/apps/meteor/server/models/raw/Rooms.ts b/apps/meteor/server/models/raw/Rooms.ts index 96cd5a3a3acf..7d4a0a54dedf 100644 --- a/apps/meteor/server/models/raw/Rooms.ts +++ b/apps/meteor/server/models/raw/Rooms.ts @@ -903,6 +903,10 @@ export class RoomsRaw extends BaseRaw implements IRoomsModel { return this.findOne(query, options); } + findOneByIdAndType(roomId: IRoom['_id'], type: IRoom['t'], options: FindOptions = {}): Promise { + return this.findOne({ _id: roomId, t: type }, options); + } + setCallStatus(_id: IRoom['_id'], status: IRoom['callStatus']): Promise { const query: Filter = { _id, @@ -2059,4 +2063,85 @@ export class RoomsRaw extends BaseRaw implements IRoomsModel { return this.updateMany(query, update); } + + findChildrenOfTeam( + teamId: string, + teamRoomId: string, + userId: string, + filter?: string, + type?: 'channels' | 'discussions', + options?: FindOptions, + ): AggregationCursor<{ totalCount: { count: number }[]; paginatedResults: IRoom[] }> { + const nameFilter = filter ? new RegExp(escapeRegExp(filter), 'i') : undefined; + return this.col.aggregate<{ totalCount: { count: number }[]; paginatedResults: IRoom[] }>([ + { + $match: { + $and: [ + { + $or: [ + ...(!type || type === 'channels' ? [{ teamId }] : []), + ...(!type || type === 'discussions' ? [{ prid: teamRoomId }] : []), + ], + }, + ...(nameFilter ? [{ $or: [{ fname: nameFilter }, { name: nameFilter }] }] : []), + ], + }, + }, + { + $lookup: { + from: 'rocketchat_subscription', + let: { + roomId: '$_id', + }, + pipeline: [ + { + $match: { + $and: [ + { + $expr: { + $eq: ['$rid', '$$roomId'], + }, + }, + { + $expr: { + $eq: ['$u._id', userId], + }, + }, + { + $expr: { + $ne: ['$t', 'c'], + }, + }, + ], + }, + }, + { + $project: { _id: 1 }, + }, + ], + as: 'subscription', + }, + }, + { + $match: { + $or: [ + { t: 'c' }, + { + $expr: { + $ne: [{ $size: '$subscription' }, 0], + }, + }, + ], + }, + }, + { $project: { subscription: 0 } }, + { $sort: options?.sort || { ts: 1 } }, + { + $facet: { + totalCount: [{ $count: 'count' }], + paginatedResults: [{ $skip: options?.skip || 0 }, { $limit: options?.limit || 50 }], + }, + }, + ]); + } } diff --git a/apps/meteor/server/modules/core-apps/mention.module.ts b/apps/meteor/server/modules/core-apps/mention.module.ts index ac615def8a5a..76d3a22ea53b 100644 --- a/apps/meteor/server/modules/core-apps/mention.module.ts +++ b/apps/meteor/server/modules/core-apps/mention.module.ts @@ -44,13 +44,10 @@ export class MentionModule implements IUiKitCoreApp { if (actionId === 'dismiss') { void api.broadcast('notify.ephemeralMessage', payload.user._id, payload.room, { - msg: i18n.t( - 'You_mentioned___mentions__but_theyre_not_in_this_room', - { - mentions: joinedUsernames, - }, - payload.user.language, - ), + msg: i18n.t('You_mentioned___mentions__but_theyre_not_in_this_room', { + mentions: joinedUsernames, + lng: payload.user.language, + }), _id: payload.message, tmid: message.tmid, mentions, @@ -61,13 +58,10 @@ export class MentionModule implements IUiKitCoreApp { if (actionId === 'add-users') { void addUsersToRoomMethod(payload.user._id, { rid: payload.room, users: usernames as string[] }, payload.user); void api.broadcast('notify.ephemeralMessage', payload.user._id, payload.room, { - msg: i18n.t( - 'You_mentioned___mentions__but_theyre_not_in_this_room', - { - mentions: joinedUsernames, - }, - payload.user.language, - ), + msg: i18n.t('You_mentioned___mentions__but_theyre_not_in_this_room', { + mentions: joinedUsernames, + lng: payload.user.language, + }), tmid: message.tmid, _id: payload.message, mentions, @@ -87,13 +81,10 @@ export class MentionModule implements IUiKitCoreApp { throw new Error('Mention bot - Failed to retrieve path to room'); } - const messageText = i18n.t( - 'Youre_not_a_part_of__channel__and_I_mentioned_you_there', - { - channel: `#${sub.name}`, - }, - payload.user.language, - ); + const messageText = i18n.t('Youre_not_a_part_of__channel__and_I_mentioned_you_there', { + channel: `#${sub.name}`, + lng: payload.user.language, + }); const link = new URL(Meteor.absoluteUrl(roomPath)); link.searchParams.set('msg', message._id); @@ -109,13 +100,10 @@ export class MentionModule implements IUiKitCoreApp { ); void api.broadcast('notify.ephemeralMessage', payload.user._id, payload.room, { - msg: i18n.t( - 'You_mentioned___mentions__but_theyre_not_in_this_room_You_let_them_know_via_dm', - { - mentions: joinedUsernames, - }, - payload.user.language, - ), + msg: i18n.t('You_mentioned___mentions__but_theyre_not_in_this_room_You_let_them_know_via_dm', { + mentions: joinedUsernames, + lng: payload.user.language, + }), tmid: message.tmid, _id: payload.message, mentions, diff --git a/apps/meteor/server/routes/avatar/user.js b/apps/meteor/server/routes/avatar/user.js index 269c2e90019a..1c92d24fe300 100644 --- a/apps/meteor/server/routes/avatar/user.js +++ b/apps/meteor/server/routes/avatar/user.js @@ -32,6 +32,13 @@ export const userAvatar = async function (req, res) { return; } + if (settings.get('Accounts_AvatarExternalProviderUrl')) { + const response = await fetch(settings.get('Accounts_AvatarExternalProviderUrl').replace('{username}', requestUsername)); + response.headers.forEach((value, key) => res.setHeader(key, value)); + response.body.pipe(res); + return; + } + const reqModifiedHeader = req.headers['if-modified-since']; const file = await Avatars.findOneByName(requestUsername); @@ -52,13 +59,6 @@ export const userAvatar = async function (req, res) { return FileUpload.get(file, req, res); } - if (settings.get('Accounts_AvatarExternalProviderUrl')) { - const response = await fetch(settings.get('Accounts_AvatarExternalProviderUrl').replace('{username}', requestUsername)); - response.headers.forEach((value, key) => res.setHeader(key, value)); - response.body.pipe(res); - return; - } - // if still using "letters fallback" if (!wasFallbackModified(reqModifiedHeader, res)) { res.writeHead(304); diff --git a/apps/meteor/server/services/messages/hooks/BeforeFederationActions.ts b/apps/meteor/server/services/messages/hooks/BeforeFederationActions.ts index a954e4899970..19f42626c216 100644 --- a/apps/meteor/server/services/messages/hooks/BeforeFederationActions.ts +++ b/apps/meteor/server/services/messages/hooks/BeforeFederationActions.ts @@ -1,9 +1,10 @@ +import type { AtLeast } from '@rocket.chat/core-typings'; import { type IMessage, type IRoom, isMessageFromMatrixFederation, isRoomFederated } from '@rocket.chat/core-typings'; import { isFederationEnabled, isFederationReady } from '../../federation/utils'; export class FederationActions { - public static shouldPerformAction(message: IMessage, room: IRoom): boolean { + public static shouldPerformAction(message: IMessage, room: AtLeast): boolean { if (isMessageFromMatrixFederation(message) || isRoomFederated(room)) { return isFederationEnabled() && isFederationReady(); } diff --git a/apps/meteor/server/services/messages/service.ts b/apps/meteor/server/services/messages/service.ts index b20b5236b7fe..85afcf394f28 100644 --- a/apps/meteor/server/services/messages/service.ts +++ b/apps/meteor/server/services/messages/service.ts @@ -1,6 +1,6 @@ import type { IMessageService } from '@rocket.chat/core-services'; import { Authorization, ServiceClassInternal } from '@rocket.chat/core-services'; -import { type IMessage, type MessageTypesValues, type IUser, type IRoom, isEditedMessage } from '@rocket.chat/core-typings'; +import { type IMessage, type MessageTypesValues, type IUser, type IRoom, isEditedMessage, type AtLeast } from '@rocket.chat/core-typings'; import { Messages, Rooms } from '@rocket.chat/models'; import { deleteMessage } from '../../../app/lib/server/functions/deleteMessage'; @@ -244,7 +244,7 @@ export class MessageService extends ServiceClassInternal implements IMessageServ // await Room.join({ room, user }); // } - async beforeReacted(message: IMessage, room: IRoom) { + async beforeReacted(message: IMessage, room: AtLeast) { if (!FederationActions.shouldPerformAction(message, room)) { throw new FederationMatrixInvalidConfigurationError('Unable to react to message'); } diff --git a/apps/meteor/server/services/omnichannel-analytics/service.ts b/apps/meteor/server/services/omnichannel-analytics/service.ts index 4f479d83acdd..8b27ed4b9f57 100644 --- a/apps/meteor/server/services/omnichannel-analytics/service.ts +++ b/apps/meteor/server/services/omnichannel-analytics/service.ts @@ -160,7 +160,7 @@ export class OmnichannelAnalyticsService extends ServiceClassInternal implements return; } - const t = (s: string) => i18n.t(s, { lng: language }); + const t = i18n.getFixedT(language); const extraQuery = await callbacks.run('livechat.applyRoomRestrictions', {}); return this.overview.callAction(name, from, to, departmentId, timezone, t, extraQuery); diff --git a/apps/meteor/server/services/team/service.ts b/apps/meteor/server/services/team/service.ts index 27f7af1f1b1c..4be96bff9866 100644 --- a/apps/meteor/server/services/team/service.ts +++ b/apps/meteor/server/services/team/service.ts @@ -913,8 +913,8 @@ export class TeamService extends ServiceClassInternal implements ITeamService { }); } - async getOneByRoomId(roomId: string): Promise { - const room = await Rooms.findOneById(roomId); + async getOneByRoomId(roomId: string, options?: FindOptions): Promise { + const room = await Rooms.findOneById(roomId, { projection: { teamId: 1 } }); if (!room) { throw new Error('invalid-room'); @@ -924,7 +924,7 @@ export class TeamService extends ServiceClassInternal implements ITeamService { throw new Error('room-not-on-team'); } - return Team.findOneById(room.teamId); + return Team.findOneById(room.teamId, options); } async addRolesToMember(teamId: string, userId: string, roles: Array): Promise { @@ -1055,8 +1055,10 @@ export class TeamService extends ServiceClassInternal implements ITeamService { return rooms; } - private getParentRoom(team: AtLeast): Promise | null> { - return Rooms.findOneById>(team.roomId, { projection: { name: 1, fname: 1, t: 1 } }); + private getParentRoom(team: AtLeast): Promise | null> { + return Rooms.findOneById>(team.roomId, { + projection: { name: 1, fname: 1, t: 1, sidepanel: 1 }, + }); } async getRoomInfo( @@ -1078,4 +1080,37 @@ export class TeamService extends ServiceClassInternal implements ITeamService { const parentRoom = await this.getParentRoom(team); return { team, ...(parentRoom && { parentRoom }) }; } + + // Returns the list of rooms and discussions a user has access to inside a team + // Rooms returned are a composition of the rooms the user is in + public rooms + discussions from the main room (if any) + async listChildren( + userId: string, + team: AtLeast, + filter?: string, + type?: 'channels' | 'discussions', + sort?: Record, + skip = 0, + limit = 10, + ): Promise<{ total: number; data: IRoom[] }> { + const mainRoom = await Rooms.findOneById(team.roomId, { projection: { _id: 1 } }); + if (!mainRoom) { + throw new Error('error-invalid-team-no-main-room'); + } + + const isMember = await TeamMember.findOneByUserIdAndTeamId(userId, team._id, { + projection: { _id: 1 }, + }); + + if (!isMember) { + throw new Error('error-invalid-team-not-a-member'); + } + + const [{ totalCount: [{ count: total }] = [], paginatedResults: data = [] }] = + (await Rooms.findChildrenOfTeam(team._id, mainRoom._id, userId, filter, type, { skip, limit, sort }).toArray()) || []; + + return { + total, + data, + }; + } } diff --git a/apps/meteor/server/settings/accounts.ts b/apps/meteor/server/settings/accounts.ts index b4da1cd913e9..81166af5c200 100644 --- a/apps/meteor/server/settings/accounts.ts +++ b/apps/meteor/server/settings/accounts.ts @@ -743,6 +743,11 @@ export const createAccountSettings = () => i18nLabel: 'Sidebar_Sections_Order', i18nDescription: 'Sidebar_Sections_Order_Description', }); + + await this.add('Accounts_Default_User_Preferences_featuresPreview', '[]', { + type: 'string', + public: true, + }); }); await this.section('Avatar', async function () { diff --git a/apps/meteor/server/settings/e2e.ts b/apps/meteor/server/settings/e2e.ts index 6f22784f1709..c8a69757128b 100644 --- a/apps/meteor/server/settings/e2e.ts +++ b/apps/meteor/server/settings/e2e.ts @@ -35,4 +35,10 @@ export const createE2ESettings = () => public: true, enableQuery: { _id: 'E2E_Enable', value: true }, }); + + await this.add('E2E_Enabled_Mentions', false, { + type: 'boolean', + public: true, + enableQuery: { _id: 'E2E_Enable', value: true }, + }); }); diff --git a/apps/meteor/tests/data/livechat/department.ts b/apps/meteor/tests/data/livechat/department.ts index 72ab0af9f267..d7f22fca970b 100644 --- a/apps/meteor/tests/data/livechat/department.ts +++ b/apps/meteor/tests/data/livechat/department.ts @@ -42,36 +42,44 @@ const updateDepartment = async (departmentId: string, departmentData: Partial
    5. +export const createDepartmentWithMethod = ({ + initialAgents = [], + allowReceiveForwardOffline = false, + fallbackForwardDepartment, + name, + departmentUnit, + userCredentials = credentials, + departmentId = '', +}: { + initialAgents?: { agentId: string; username: string }[]; + allowReceiveForwardOffline?: boolean; + fallbackForwardDepartment?: string; + name?: string; + departmentUnit?: { _id?: string }; + userCredentials?: Credentials; + departmentId?: string; +} = {}): Promise => new Promise((resolve, reject) => { void request .post(methodCall('livechat:saveDepartment')) - .set(credentials) + .set(userCredentials) .send({ message: JSON.stringify({ method: 'livechat:saveDepartment', params: [ - '', + departmentId, { enabled: true, email: faker.internet.email(), showOnRegistration: true, showOnOfflineForm: true, - name: `new department ${Date.now()}`, + name: name || `new department ${Date.now()}`, description: 'created from api', allowReceiveForwardOffline, fallbackForwardDepartment, }, initialAgents, + departmentUnit, ], id: 'id', msg: 'method', @@ -93,7 +101,7 @@ type OnlineAgent = { export const createDepartmentWithAnOnlineAgent = async (): Promise<{ department: ILivechatDepartment; agent: OnlineAgent }> => { const { user, credentials } = await createAnOnlineAgent(); - const department = (await createDepartmentWithMethod()) as ILivechatDepartment; + const department = await createDepartmentWithMethod(); await addOrRemoveAgentFromDepartment(department._id, { agentId: user._id, username: user.username }, true); @@ -108,7 +116,7 @@ export const createDepartmentWithAnOnlineAgent = async (): Promise<{ department: export const createDepartmentWithAgent = async (agent: OnlineAgent): Promise<{ department: ILivechatDepartment; agent: OnlineAgent }> => { const { user, credentials } = agent; - const department = (await createDepartmentWithMethod()) as ILivechatDepartment; + const department = await createDepartmentWithMethod(); await addOrRemoveAgentFromDepartment(department._id, { agentId: user._id, username: user.username }, true); @@ -153,7 +161,7 @@ export const createDepartmentWithAnOfflineAgent = async ({ }> => { const { user, credentials } = await createAnOfflineAgent(); - const department = (await createDepartmentWithMethod(undefined, { + const department = (await createDepartmentWithMethod({ allowReceiveForwardOffline, fallbackForwardDepartment, })) as ILivechatDepartment; diff --git a/apps/meteor/tests/data/livechat/rooms.ts b/apps/meteor/tests/data/livechat/rooms.ts index b5d89762c614..46e5cbe489a9 100644 --- a/apps/meteor/tests/data/livechat/rooms.ts +++ b/apps/meteor/tests/data/livechat/rooms.ts @@ -98,11 +98,55 @@ export const createDepartment = ( name?: string, enabled = true, opts: Record = {}, + departmentUnit?: { _id?: string }, + userCredentials: Credentials = credentials, ): Promise => { return new Promise((resolve, reject) => { void request .post(api('livechat/department')) - .set(credentials) + .set(userCredentials) + .send({ + department: { + name: name || `Department ${Date.now()}`, + enabled, + showOnOfflineForm: true, + showOnRegistration: true, + email: 'a@b.com', + ...opts, + }, + agents, + departmentUnit, + }) + .end((err: Error, res: DummyResponse) => { + if (err) { + return reject(err); + } + resolve(res.body.department); + }); + }); +}; + +export const updateDepartment = ({ + departmentId, + userCredentials, + agents, + name, + enabled = true, + opts = {}, + departmentUnit, +}: { + departmentId: string; + userCredentials: Credentials; + agents?: { agentId: string }[]; + name?: string; + enabled?: boolean; + opts?: Record; + departmentUnit?: { _id?: string }; +}): Promise => { + return new Promise((resolve, reject) => { + void request + .put(api(`livechat/department/${departmentId}`)) + .set(userCredentials) .send({ department: { name: name || `Department ${Date.now()}`, @@ -113,6 +157,7 @@ export const createDepartment = ( ...opts, }, agents, + departmentUnit, }) .end((err: Error, res: DummyResponse) => { if (err) { diff --git a/apps/meteor/tests/data/livechat/units.ts b/apps/meteor/tests/data/livechat/units.ts index 03ea578e654d..8a2d0f5a833a 100644 --- a/apps/meteor/tests/data/livechat/units.ts +++ b/apps/meteor/tests/data/livechat/units.ts @@ -1,7 +1,7 @@ import { faker } from '@faker-js/faker'; import type { IOmnichannelBusinessUnit } from '@rocket.chat/core-typings'; -import { methodCall, credentials, request } from '../api-data'; +import { methodCall, credentials, request, api } from '../api-data'; import type { DummyResponse } from './utils'; export const createMonitor = async (username: string): Promise<{ _id: string; username: string }> => { @@ -57,3 +57,39 @@ export const createUnit = async ( }); }); }; + +export const deleteUnit = async (unit: IOmnichannelBusinessUnit): Promise => { + return new Promise((resolve, reject) => { + void request + .post(methodCall(`livechat:removeUnit`)) + .set(credentials) + .send({ + message: JSON.stringify({ + method: 'livechat:removeUnit', + params: [unit._id], + id: '101', + msg: 'method', + }), + }) + .end((err: Error, res: DummyResponse) => { + if (err) { + return reject(err); + } + resolve(JSON.parse(res.body.message).result); + }); + }); +}; + +export const getUnit = (unitId: string): Promise => { + return new Promise((resolve, reject) => { + void request + .get(api(`livechat/units/${unitId}`)) + .set(credentials) + .end((err: Error, res: DummyResponse) => { + if (err) { + return reject(err); + } + resolve(res.body); + }); + }); +}; diff --git a/apps/meteor/tests/data/teams.helper.ts b/apps/meteor/tests/data/teams.helper.ts index 8fc60bd19fd4..f6cba25f86c9 100644 --- a/apps/meteor/tests/data/teams.helper.ts +++ b/apps/meteor/tests/data/teams.helper.ts @@ -2,11 +2,20 @@ import type { ITeam, TEAM_TYPE } from '@rocket.chat/core-typings'; import { api, request } from './api-data'; -export const createTeam = async (credentials: Record, teamName: string, type: TEAM_TYPE): Promise => { - const response = await request.post(api('teams.create')).set(credentials).send({ - name: teamName, - type, - }); +export const createTeam = async ( + credentials: Record, + teamName: string, + type: TEAM_TYPE, + members?: string[], +): Promise => { + const response = await request + .post(api('teams.create')) + .set(credentials) + .send({ + name: teamName, + type, + ...(members && { members }), + }); return response.body.team; }; diff --git a/apps/meteor/tests/e2e/e2e-encryption.spec.ts b/apps/meteor/tests/e2e/e2e-encryption.spec.ts index 8c6297e9975d..ad98df1aaa53 100644 --- a/apps/meteor/tests/e2e/e2e-encryption.spec.ts +++ b/apps/meteor/tests/e2e/e2e-encryption.spec.ts @@ -133,11 +133,13 @@ test.describe.serial('e2e-encryption', () => { test.beforeAll(async ({ api }) => { expect((await api.post('/settings/E2E_Allow_Unencrypted_Messages', { value: true })).status()).toBe(200); + expect((await api.post('/settings/E2E_Enabled_Mentions', { value: true })).status()).toBe(200); }); test.afterAll(async ({ api }) => { expect((await api.post('/settings/E2E_Enable', { value: false })).status()).toBe(200); expect((await api.post('/settings/E2E_Allow_Unencrypted_Messages', { value: false })).status()).toBe(200); + expect((await api.post('/settings/E2E_Enabled_Mentions', { value: false })).status()).toBe(200); }); test('expect create a private channel encrypted and send an encrypted message', async ({ page }) => { @@ -265,6 +267,75 @@ test.describe.serial('e2e-encryption', () => { await expect(poHomeChannel.content.lastUserMessage.locator('.rcx-icon--name-key')).toBeVisible(); }); + test('expect create a encrypted private channel and mention user', async ({ page }) => { + const channelName = faker.string.uuid(); + + await poHomeChannel.sidenav.createEncryptedChannel(channelName); + + await expect(page).toHaveURL(`/group/${channelName}`); + + await poHomeChannel.dismissToast(); + + await expect(poHomeChannel.content.encryptedRoomHeaderIcon).toBeVisible(); + + await poHomeChannel.content.sendMessage('hello @user1'); + + const userMention = await page.getByRole('button', { + name: 'user1', + }); + + await expect(userMention).toBeVisible(); + }); + + test('expect create a encrypted private channel, mention a channel and navigate to it', async ({ page }) => { + const channelName = faker.string.uuid(); + + await poHomeChannel.sidenav.createEncryptedChannel(channelName); + + await expect(page).toHaveURL(`/group/${channelName}`); + + await poHomeChannel.dismissToast(); + + await expect(poHomeChannel.content.encryptedRoomHeaderIcon).toBeVisible(); + + await poHomeChannel.content.sendMessage('Are you in the #general channel?'); + + const channelMention = await page.getByRole('button', { + name: 'general', + }); + + await expect(channelMention).toBeVisible(); + + await channelMention.click(); + + await expect(page).toHaveURL(`/channel/general`); + }); + + test('expect create a encrypted private channel, mention a channel and user', async ({ page }) => { + const channelName = faker.string.uuid(); + + await poHomeChannel.sidenav.createEncryptedChannel(channelName); + + await expect(page).toHaveURL(`/group/${channelName}`); + + await poHomeChannel.dismissToast(); + + await expect(poHomeChannel.content.encryptedRoomHeaderIcon).toBeVisible(); + + await poHomeChannel.content.sendMessage('Are you in the #general channel, @user1 ?'); + + const channelMention = await page.getByRole('button', { + name: 'general', + }); + + const userMention = await page.getByRole('button', { + name: 'user1', + }); + + await expect(userMention).toBeVisible(); + await expect(channelMention).toBeVisible(); + }); + test('should encrypted field be available on edit room', async ({ page }) => { const channelName = faker.string.uuid(); diff --git a/apps/meteor/tests/e2e/mark-unread.spec.ts b/apps/meteor/tests/e2e/mark-unread.spec.ts new file mode 100644 index 000000000000..81ae93965856 --- /dev/null +++ b/apps/meteor/tests/e2e/mark-unread.spec.ts @@ -0,0 +1,71 @@ +import { createAuxContext } from './fixtures/createAuxContext'; +import { Users } from './fixtures/userStates'; +import { HomeChannel } from './page-objects'; +import { createTargetChannel } from './utils'; +import { test, expect } from './utils/test'; + +test.use({ storageState: Users.admin.state }); + +test.describe.serial('mark-unread', () => { + let poHomeChannel: HomeChannel; + let targetChannel: string; + + test.beforeEach(async ({ page, api }) => { + poHomeChannel = new HomeChannel(page); + targetChannel = await createTargetChannel(api, { members: ['user2'] }); + + await page.emulateMedia({ reducedMotion: 'reduce' }); + await page.goto('/home'); + }); + + test.afterEach(async ({ api }) => { + await api.post('/channels.delete', { roomName: targetChannel }); + }); + + test.describe('Mark Unread - Sidebar Action', () => { + test('should not mark empty room as unread', async () => { + await poHomeChannel.sidenav.selectMarkAsUnread(targetChannel); + + await expect(poHomeChannel.sidenav.getRoomBadge(targetChannel)).not.toBeVisible(); + }); + + test('should mark a populated room as unread', async () => { + await poHomeChannel.sidenav.openChat(targetChannel); + await poHomeChannel.content.sendMessage('this is a message for reply'); + await poHomeChannel.sidenav.selectMarkAsUnread(targetChannel); + + await expect(poHomeChannel.sidenav.getRoomBadge(targetChannel)).toBeVisible(); + }); + + test('should mark a populated room as unread - search', async () => { + await poHomeChannel.sidenav.openChat(targetChannel); + await poHomeChannel.content.sendMessage('this is a message for reply'); + await poHomeChannel.sidenav.selectMarkAsUnread(targetChannel); + await poHomeChannel.sidenav.searchRoom(targetChannel); + + await expect(poHomeChannel.sidenav.getSearchChannelBadge(targetChannel)).toBeVisible(); + }); + }); + + test.describe('Mark Unread - Message Action', () => { + let poHomeChannelUser2: HomeChannel; + + test('should mark a populated room as unread', async ({ browser }) => { + const { page: user2Page } = await createAuxContext(browser, Users.user2); + poHomeChannelUser2 = new HomeChannel(user2Page); + + await poHomeChannelUser2.sidenav.openChat(targetChannel); + await poHomeChannelUser2.content.sendMessage('this is a message for reply'); + await user2Page.close(); + + await poHomeChannel.sidenav.openChat(targetChannel); + + // wait for the sidebar item to be read + await poHomeChannel.sidenav.getSidebarItemByName(targetChannel, true).waitFor(); + await poHomeChannel.content.openLastMessageMenu(); + await poHomeChannel.markUnread.click(); + + await expect(poHomeChannel.sidenav.getRoomBadge(targetChannel)).toBeVisible(); + }); + }); +}); diff --git a/apps/meteor/tests/e2e/omnichannel/omnichannel-contact-center.spec.ts b/apps/meteor/tests/e2e/omnichannel/omnichannel-contact-center.spec.ts index d0e7b7133423..d3c667e20f82 100644 --- a/apps/meteor/tests/e2e/omnichannel/omnichannel-contact-center.spec.ts +++ b/apps/meteor/tests/e2e/omnichannel/omnichannel-contact-center.spec.ts @@ -42,7 +42,7 @@ const URL = { }; const ERROR = { - nameRequired: 'The field Name is required.', + nameRequired: 'Name required', invalidEmail: 'Invalid email address', existingEmail: 'Email already exists', existingPhone: 'Phone already exists', diff --git a/apps/meteor/tests/e2e/omnichannel/omnichannel-departaments.spec.ts b/apps/meteor/tests/e2e/omnichannel/omnichannel-departaments.spec.ts index 872eafdfb2a2..023d11de4757 100644 --- a/apps/meteor/tests/e2e/omnichannel/omnichannel-departaments.spec.ts +++ b/apps/meteor/tests/e2e/omnichannel/omnichannel-departaments.spec.ts @@ -8,8 +8,8 @@ import { createDepartment, deleteDepartment } from '../utils/omnichannel/departm import { test, expect } from '../utils/test'; const ERROR = { - requiredName: 'The field name is required.', - requiredEmail: 'The field email is required.', + requiredName: 'Name required', + requiredEmail: 'Email required', invalidEmail: 'Invalid email address', }; diff --git a/apps/meteor/tests/e2e/omnichannel/omnichannel-priorities.spec.ts b/apps/meteor/tests/e2e/omnichannel/omnichannel-priorities.spec.ts index 1da1837572e5..9ee1d8d99fd9 100644 --- a/apps/meteor/tests/e2e/omnichannel/omnichannel-priorities.spec.ts +++ b/apps/meteor/tests/e2e/omnichannel/omnichannel-priorities.spec.ts @@ -8,7 +8,7 @@ import { test, expect } from '../utils/test'; const PRIORITY_NAME = faker.person.firstName(); const ERROR = { - fieldNameRequired: 'The field Name is required.', + fieldNameRequired: 'Name required', }; test.skip(!IS_EE, 'Omnichannel Priorities > Enterprise Only'); diff --git a/apps/meteor/tests/e2e/omnichannel/omnichannel-sla-policies.spec.ts b/apps/meteor/tests/e2e/omnichannel/omnichannel-sla-policies.spec.ts index 35a371c61ba6..d5b5544e9ace 100644 --- a/apps/meteor/tests/e2e/omnichannel/omnichannel-sla-policies.spec.ts +++ b/apps/meteor/tests/e2e/omnichannel/omnichannel-sla-policies.spec.ts @@ -6,8 +6,8 @@ import { OmnichannelSlaPolicies } from '../page-objects/omnichannel-sla-policies import { test, expect } from '../utils/test'; const ERROR = { - nameRequired: 'The field Name is required.', - estimatedWaitTimeRequired: 'The field Estimated wait time (time in minutes) is required.', + nameRequired: 'Name required', + estimatedWaitTimeRequired: 'Estimated wait time (time in minutes) required', }; const INITIAL_SLA = { diff --git a/apps/meteor/tests/e2e/page-objects/fragments/home-content.ts b/apps/meteor/tests/e2e/page-objects/fragments/home-content.ts index 9d5e2081ca93..519d9a4102aa 100644 --- a/apps/meteor/tests/e2e/page-objects/fragments/home-content.ts +++ b/apps/meteor/tests/e2e/page-objects/fragments/home-content.ts @@ -85,7 +85,7 @@ export class HomeContent { await this.joinRoomIfNeeded(); await this.page.waitForSelector('[name="msg"]:not([disabled])'); await this.page.locator('[name="msg"]').fill(text); - await this.page.keyboard.press('Enter'); + await this.page.getByLabel('Send').click(); } async dispatchSlashCommand(text: string): Promise { diff --git a/apps/meteor/tests/e2e/page-objects/fragments/home-sidenav.ts b/apps/meteor/tests/e2e/page-objects/fragments/home-sidenav.ts index d0bdd5028010..823469d02a96 100644 --- a/apps/meteor/tests/e2e/page-objects/fragments/home-sidenav.ts +++ b/apps/meteor/tests/e2e/page-objects/fragments/home-sidenav.ts @@ -68,8 +68,18 @@ export class HomeSidenav { return this.page.locator('role=menuitemcheckbox[name="Profile"]'); } - getSidebarItemByName(name: string): Locator { - return this.page.locator(`[data-qa="sidebar-item"][aria-label="${name}"]`); + // TODO: refactor getSidebarItemByName to not use data-qa + getSidebarItemByName(name: string, isRead?: boolean): Locator { + return this.page.locator( + ['[data-qa="sidebar-item"]', `[aria-label="${name}"]`, isRead && '[data-unread="false"]'].filter(Boolean).join(''), + ); + } + + async selectMarkAsUnread(name: string) { + const sidebarItem = this.getSidebarItemByName(name); + await sidebarItem.focus(); + await sidebarItem.locator('.rcx-sidebar-item__menu').click(); + await this.page.getByRole('option', { name: 'Mark Unread' }).click(); } async selectPriority(name: string, priority: string) { @@ -170,4 +180,12 @@ export class HomeSidenav { await this.checkboxEncryption.click(); await this.btnCreate.click(); } + + getRoomBadge(roomName: string): Locator { + return this.getSidebarItemByName(roomName).getByRole('status', { exact: true }); + } + + getSearchChannelBadge(name: string): Locator { + return this.page.locator(`[data-qa="sidebar-item"][aria-label="${name}"]`).first().getByRole('status', { exact: true }); + } } diff --git a/apps/meteor/tests/e2e/page-objects/home-channel.ts b/apps/meteor/tests/e2e/page-objects/home-channel.ts index a2784ea4c67b..ce51896eb449 100644 --- a/apps/meteor/tests/e2e/page-objects/home-channel.ts +++ b/apps/meteor/tests/e2e/page-objects/home-channel.ts @@ -59,4 +59,8 @@ export class HomeChannel { get roomHeaderToolbar(): Locator { return this.page.locator('[role=toolbar][aria-label="Primary Room actions"]'); } + + get markUnread(): Locator { + return this.page.locator('role=menuitem[name="Mark Unread"]'); + } } diff --git a/apps/meteor/tests/end-to-end/api/livechat/09-visitors.ts b/apps/meteor/tests/end-to-end/api/livechat/09-visitors.ts index f02d9d1d1e95..31134412b8ec 100644 --- a/apps/meteor/tests/end-to-end/api/livechat/09-visitors.ts +++ b/apps/meteor/tests/end-to-end/api/livechat/09-visitors.ts @@ -1,11 +1,11 @@ import { faker } from '@faker-js/faker'; import type { ILivechatVisitor } from '@rocket.chat/core-typings'; import { expect } from 'chai'; -import { before, describe, it } from 'mocha'; +import { before, describe, it, after } from 'mocha'; import moment from 'moment'; import { type Response } from 'supertest'; -import { getCredentials, api, request, credentials } from '../../../data/api-data'; +import { getCredentials, api, request, credentials, methodCallAnon } from '../../../data/api-data'; import { createCustomField, deleteCustomField } from '../../../data/livechat/custom-fields'; import { makeAgentAvailable, @@ -217,6 +217,30 @@ describe('LIVECHAT - visitors', () => { expect(body.visitor).to.have.property('livechatData'); expect(body.visitor.livechatData).to.have.property(customFieldName, 'Not a real address :)'); }); + + describe('special cases', () => { + before(async () => { + await updateSetting('Livechat_Allow_collect_and_store_HTTP_header_informations', true); + }); + after(async () => { + await updateSetting('Livechat_Allow_collect_and_store_HTTP_header_informations', false); + }); + + // Note: this had to use the meteor method because the endpoint used `req.headers` which we cannot send as empty + // method doesn't pass them to the func allowing us to create a test for it + it('should allow to create a visitor without passing connectionData when GDPR setting is enabled', async () => { + const token = `${new Date().getTime()}-test`; + const response = await request + .post(methodCallAnon('livechat:registerGuest')) + .send({ message: `{"msg":"method","id":"23","method":"livechat:registerGuest","params":[{ "token": "${token}"}]}` }); + + expect(response.body).to.have.property('success', true); + const r = JSON.parse(response.body.message); + + expect(r.result).to.have.property('visitor'); + expect(r.result.visitor).to.have.property('token', token); + }); + }); }); describe('livechat/visitors.info', () => { diff --git a/apps/meteor/tests/end-to-end/api/livechat/14-units.ts b/apps/meteor/tests/end-to-end/api/livechat/14-units.ts index 425c776fecdb..e0c079ece243 100644 --- a/apps/meteor/tests/end-to-end/api/livechat/14-units.ts +++ b/apps/meteor/tests/end-to-end/api/livechat/14-units.ts @@ -1,12 +1,14 @@ import type { ILivechatDepartment, IOmnichannelBusinessUnit } from '@rocket.chat/core-typings'; import { expect } from 'chai'; -import { before, describe, it } from 'mocha'; +import { before, after, describe, it } from 'mocha'; -import { getCredentials, api, request, credentials } from '../../../data/api-data'; -import { createDepartment } from '../../../data/livechat/rooms'; -import { createMonitor, createUnit } from '../../../data/livechat/units'; +import { getCredentials, api, request, credentials, methodCall } from '../../../data/api-data'; +import { deleteDepartment, getDepartmentById, createDepartmentWithMethod } from '../../../data/livechat/department'; +import { createDepartment, updateDepartment } from '../../../data/livechat/rooms'; +import { createMonitor, createUnit, deleteUnit, getUnit } from '../../../data/livechat/units'; import { updatePermission, updateSetting } from '../../../data/permissions.helper'; -import { createUser, deleteUser } from '../../../data/users.helper'; +import { password } from '../../../data/user'; +import { createUser, deleteUser, login } from '../../../data/users.helper'; import { IS_EE } from '../../../e2e/config/constants'; (IS_EE ? describe : describe.skip)('[EE] LIVECHAT - Units', () => { @@ -14,6 +16,7 @@ import { IS_EE } from '../../../e2e/config/constants'; before(async () => { await updateSetting('Livechat_enabled', true); + await updatePermission('manage-livechat-departments', ['livechat-manager', 'livechat-monitor', 'admin']); }); describe('[GET] livechat/units', () => { @@ -409,4 +412,547 @@ import { IS_EE } from '../../../e2e/config/constants'; await deleteUser(user); }); }); + + describe('[POST] livechat/department', () => { + let monitor1: Awaited>; + let monitor1Credentials: Awaited>; + let monitor2: Awaited>; + let monitor2Credentials: Awaited>; + let unit: IOmnichannelBusinessUnit; + + before(async () => { + monitor1 = await createUser(); + monitor2 = await createUser(); + await createMonitor(monitor1.username); + monitor1Credentials = await login(monitor1.username, password); + await createMonitor(monitor2.username); + monitor2Credentials = await login(monitor2.username, password); + unit = await createUnit(monitor1._id, monitor1.username, []); + }); + + after(async () => Promise.all([deleteUser(monitor1), deleteUser(monitor2), deleteUnit(unit)])); + + it('should fail creating department when providing an invalid property in the department unit object', () => { + return request + .post(api('livechat/department')) + .set(credentials) + .send({ + department: { name: 'Fail-Test', enabled: true, showOnOfflineForm: true, showOnRegistration: true, email: 'bla@bla' }, + departmentUnit: { invalidProperty: true }, + }) + .expect('Content-Type', 'application/json') + .expect(400) + .expect((res) => { + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('errorType', 'invalid-params'); + }); + }); + + it('should fail creating a department into an existing unit that a monitor does not supervise', async () => { + const department = await createDepartment(undefined, undefined, undefined, undefined, { _id: unit._id }, monitor2Credentials); + + const updatedUnit = await getUnit(unit._id); + expect(updatedUnit).to.have.property('name', unit.name); + expect(updatedUnit).to.have.property('numMonitors', 1); + expect(updatedUnit).to.have.property('numDepartments', 0); + + const fullDepartment = await getDepartmentById(department._id); + expect(fullDepartment).to.not.have.property('parentId'); + expect(fullDepartment).to.not.have.property('ancestors'); + + await deleteDepartment(department._id); + }); + + it('should succesfully create a department into an existing unit as an admin', async () => { + const department = await createDepartment(undefined, undefined, undefined, undefined, { _id: unit._id }); + + const updatedUnit = await getUnit(unit._id); + expect(updatedUnit).to.have.property('name', unit.name); + expect(updatedUnit).to.have.property('numMonitors', 1); + expect(updatedUnit).to.have.property('numDepartments', 1); + + const fullDepartment = await getDepartmentById(department._id); + expect(fullDepartment).to.have.property('parentId', unit._id); + expect(fullDepartment).to.have.property('ancestors').that.is.an('array').with.lengthOf(1); + expect(fullDepartment.ancestors?.[0]).to.equal(unit._id); + + await deleteDepartment(department._id); + }); + + it('should succesfully create a department into an existing unit that a monitor supervises', async () => { + const department = await createDepartment(undefined, undefined, undefined, undefined, { _id: unit._id }, monitor1Credentials); + + // Deleting a department currently does not decrease its unit's counter. We must adjust this check when this is fixed + const updatedUnit = await getUnit(unit._id); + expect(updatedUnit).to.have.property('name', unit.name); + expect(updatedUnit).to.have.property('numMonitors', 1); + expect(updatedUnit).to.have.property('numDepartments', 2); + + const fullDepartment = await getDepartmentById(department._id); + expect(fullDepartment).to.have.property('parentId', unit._id); + expect(fullDepartment).to.have.property('ancestors').that.is.an('array').with.lengthOf(1); + expect(fullDepartment.ancestors?.[0]).to.equal(unit._id); + + await deleteDepartment(department._id); + }); + }); + + describe('[PUT] livechat/department', () => { + let monitor1: Awaited>; + let monitor1Credentials: Awaited>; + let monitor2: Awaited>; + let monitor2Credentials: Awaited>; + let unit: IOmnichannelBusinessUnit; + let department: ILivechatDepartment; + let baseDepartment: ILivechatDepartment; + + before(async () => { + monitor1 = await createUser(); + monitor2 = await createUser(); + await createMonitor(monitor1.username); + monitor1Credentials = await login(monitor1.username, password); + await createMonitor(monitor2.username); + monitor2Credentials = await login(monitor2.username, password); + department = await createDepartment(); + baseDepartment = await createDepartment(); + unit = await createUnit(monitor1._id, monitor1.username, [baseDepartment._id]); + }); + + after(async () => + Promise.all([ + deleteUser(monitor1), + deleteUser(monitor2), + deleteUnit(unit), + deleteDepartment(department._id), + deleteDepartment(baseDepartment._id), + ]), + ); + + it("should fail updating a department's unit when providing an invalid property in the department unit object", () => { + const updatedName = 'updated-department-name'; + return request + .put(api(`livechat/department/${department._id}`)) + .set(credentials) + .send({ + department: { name: updatedName, enabled: true, showOnOfflineForm: true, showOnRegistration: true, email: 'bla@bla' }, + departmentUnit: { invalidProperty: true }, + }) + .expect('Content-Type', 'application/json') + .expect(400) + .expect((res) => { + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('error', 'Match error: Unknown key in field departmentUnit.invalidProperty'); + }); + }); + + it("should fail updating a department's unit when providing an invalid _id type in the department unit object", () => { + const updatedName = 'updated-department-name'; + return request + .put(api(`livechat/department/${department._id}`)) + .set(credentials) + .send({ + department: { name: updatedName, enabled: true, showOnOfflineForm: true, showOnRegistration: true, email: 'bla@bla' }, + departmentUnit: { _id: true }, + }) + .expect('Content-Type', 'application/json') + .expect(400) + .expect((res) => { + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('error', 'Match error: Expected string, got boolean in field departmentUnit._id'); + }); + }); + + it('should fail removing the last department from a unit', () => { + const updatedName = 'updated-department-name'; + return request + .put(api(`livechat/department/${baseDepartment._id}`)) + .set(credentials) + .send({ + department: { name: updatedName, enabled: true, showOnOfflineForm: true, showOnRegistration: true, email: 'bla@bla' }, + departmentUnit: {}, + }) + .expect('Content-Type', 'application/json') + .expect(400) + .expect((res) => { + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('errorType', 'error-unit-cant-be-empty'); + }); + }); + + it('should succesfully add an existing department to a unit as an admin', async () => { + const updatedName = 'updated-department-name'; + + const updatedDepartment = await updateDepartment({ + userCredentials: credentials, + departmentId: department._id, + name: updatedName, + departmentUnit: { _id: unit._id }, + }); + expect(updatedDepartment).to.have.property('name', updatedName); + expect(updatedDepartment).to.have.property('type', 'd'); + expect(updatedDepartment).to.have.property('_id', department._id); + + const updatedUnit = await getUnit(unit._id); + expect(updatedUnit).to.have.property('name', unit.name); + expect(updatedUnit).to.have.property('numMonitors', 1); + expect(updatedUnit).to.have.property('numDepartments', 2); + + const fullDepartment = await getDepartmentById(department._id); + expect(fullDepartment).to.have.property('parentId', unit._id); + expect(fullDepartment).to.have.property('ancestors').that.is.an('array').with.lengthOf(1); + expect(fullDepartment.ancestors?.[0]).to.equal(unit._id); + }); + + it('should succesfully remove an existing department from a unit as an admin', async () => { + const updatedName = 'updated-department-name'; + + const updatedDepartment = await updateDepartment({ + userCredentials: credentials, + departmentId: department._id, + name: updatedName, + departmentUnit: {}, + }); + expect(updatedDepartment).to.have.property('name', updatedName); + expect(updatedDepartment).to.have.property('type', 'd'); + expect(updatedDepartment).to.have.property('_id', department._id); + + const updatedUnit = await getUnit(unit._id); + expect(updatedUnit).to.have.property('name', unit.name); + expect(updatedUnit).to.have.property('numMonitors', 1); + expect(updatedUnit).to.have.property('numDepartments', 1); + + const fullDepartment = await getDepartmentById(department._id); + expect(fullDepartment).to.have.property('parentId').that.is.null; + expect(fullDepartment).to.have.property('ancestors').that.is.null; + }); + + it('should fail adding a department into an existing unit that a monitor does not supervise', async () => { + const updatedName = 'updated-department-name2'; + + const updatedDepartment = await updateDepartment({ + userCredentials: monitor2Credentials, + departmentId: department._id, + name: updatedName, + departmentUnit: { _id: unit._id }, + }); + expect(updatedDepartment).to.have.property('name', updatedName); + expect(updatedDepartment).to.have.property('type', 'd'); + expect(updatedDepartment).to.have.property('_id', department._id); + + const updatedUnit = await getUnit(unit._id); + expect(updatedUnit).to.have.property('name', unit.name); + expect(updatedUnit).to.have.property('numMonitors', 1); + expect(updatedUnit).to.have.property('numDepartments', 1); + + const fullDepartment = await getDepartmentById(department._id); + expect(fullDepartment).to.have.property('parentId').that.is.null; + expect(fullDepartment).to.have.property('ancestors').that.is.null; + }); + + it('should succesfully add a department into an existing unit that a monitor supervises', async () => { + const updatedName = 'updated-department-name3'; + + const updatedDepartment = await updateDepartment({ + userCredentials: monitor1Credentials, + departmentId: department._id, + name: updatedName, + departmentUnit: { _id: unit._id }, + }); + expect(updatedDepartment).to.have.property('name', updatedName); + expect(updatedDepartment).to.have.property('type', 'd'); + expect(updatedDepartment).to.have.property('_id', department._id); + + const updatedUnit = await getUnit(unit._id); + expect(updatedUnit).to.have.property('name', unit.name); + expect(updatedUnit).to.have.property('numMonitors', 1); + expect(updatedUnit).to.have.property('numDepartments', 2); + + const fullDepartment = await getDepartmentById(department._id); + expect(fullDepartment).to.have.property('name', updatedName); + expect(fullDepartment).to.have.property('parentId', unit._id); + expect(fullDepartment).to.have.property('ancestors').that.is.an('array').with.lengthOf(1); + expect(fullDepartment.ancestors?.[0]).to.equal(unit._id); + }); + + it('should fail removing a department from a unit that a monitor does not supervise', async () => { + const updatedName = 'updated-department-name4'; + + const updatedDepartment = await updateDepartment({ + userCredentials: monitor2Credentials, + departmentId: department._id, + name: updatedName, + departmentUnit: {}, + }); + expect(updatedDepartment).to.have.property('name', updatedName); + expect(updatedDepartment).to.have.property('type', 'd'); + expect(updatedDepartment).to.have.property('_id', department._id); + + const updatedUnit = await getUnit(unit._id); + expect(updatedUnit).to.have.property('name', unit.name); + expect(updatedUnit).to.have.property('numMonitors', 1); + expect(updatedUnit).to.have.property('numDepartments', 2); + + const fullDepartment = await getDepartmentById(department._id); + expect(fullDepartment).to.have.property('name', updatedName); + expect(fullDepartment).to.have.property('parentId', unit._id); + expect(fullDepartment).to.have.property('ancestors').that.is.an('array').with.lengthOf(1); + expect(fullDepartment.ancestors?.[0]).to.equal(unit._id); + }); + + it('should succesfully remove a department from a unit that a monitor supervises', async () => { + const updatedName = 'updated-department-name5'; + + const updatedDepartment = await updateDepartment({ + userCredentials: monitor1Credentials, + departmentId: department._id, + name: updatedName, + departmentUnit: {}, + }); + expect(updatedDepartment).to.have.property('name', updatedName); + expect(updatedDepartment).to.have.property('type', 'd'); + expect(updatedDepartment).to.have.property('_id', department._id); + + const updatedUnit = await getUnit(unit._id); + expect(updatedUnit).to.have.property('name', unit.name); + expect(updatedUnit).to.have.property('numMonitors', 1); + expect(updatedUnit).to.have.property('numDepartments', 1); + + const fullDepartment = await getDepartmentById(department._id); + expect(fullDepartment).to.have.property('name', updatedName); + expect(fullDepartment).to.have.property('parentId').that.is.null; + expect(fullDepartment).to.have.property('ancestors').that.is.null; + }); + }); + + describe('[POST] livechat:saveDepartment', () => { + let monitor1: Awaited>; + let monitor1Credentials: Awaited>; + let monitor2: Awaited>; + let monitor2Credentials: Awaited>; + let unit: IOmnichannelBusinessUnit; + const departmentName = 'Test-Department-Livechat-Method'; + let testDepartmentId = ''; + let baseDepartment: ILivechatDepartment; + + before(async () => { + monitor1 = await createUser(); + monitor2 = await createUser(); + await createMonitor(monitor1.username); + monitor1Credentials = await login(monitor1.username, password); + await createMonitor(monitor2.username); + monitor2Credentials = await login(monitor2.username, password); + baseDepartment = await createDepartment(); + unit = await createUnit(monitor1._id, monitor1.username, [baseDepartment._id]); + }); + + after(async () => + Promise.all([ + deleteUser(monitor1), + deleteUser(monitor2), + deleteUnit(unit), + deleteDepartment(testDepartmentId), + deleteDepartment(baseDepartment._id), + ]), + ); + + it('should fail creating department when providing an invalid _id type in the department unit object', () => { + return request + .post(methodCall('livechat:saveDepartment')) + .set(credentials) + .send({ + message: JSON.stringify({ + method: 'livechat:saveDepartment', + params: [ + '', + { name: 'Fail-Test', enabled: true, showOnOfflineForm: true, showOnRegistration: true, email: 'bla@bla' }, + [], + { _id: true }, + ], + id: 'id', + msg: 'method', + }), + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('message').that.is.a('string'); + const data = JSON.parse(res.body.message); + expect(data).to.have.property('error').that.is.an('object'); + expect(data.error).to.have.property('errorType', 'Meteor.Error'); + expect(data.error).to.have.property('error', 'error-invalid-department-unit'); + }); + }); + + it('should fail removing last department from unit', () => { + return request + .post(methodCall('livechat:saveDepartment')) + .set(credentials) + .send({ + message: JSON.stringify({ + method: 'livechat:saveDepartment', + params: [ + baseDepartment._id, + { name: 'Fail-Test', enabled: true, showOnOfflineForm: true, showOnRegistration: true, email: 'bla@bla' }, + [], + {}, + ], + id: 'id', + msg: 'method', + }), + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('message').that.is.a('string'); + const data = JSON.parse(res.body.message); + expect(data).to.have.property('error').that.is.an('object'); + expect(data.error).to.have.property('errorType', 'Meteor.Error'); + expect(data.error).to.have.property('error', 'error-unit-cant-be-empty'); + }); + }); + + it('should fail creating a department into an existing unit that a monitor does not supervise', async () => { + const departmentName = 'Fail-Test'; + + const department = await createDepartmentWithMethod({ + userCredentials: monitor2Credentials, + name: departmentName, + departmentUnit: { _id: unit._id }, + }); + testDepartmentId = department._id; + + const updatedUnit = await getUnit(unit._id); + expect(updatedUnit).to.have.property('name', unit.name); + expect(updatedUnit).to.have.property('numMonitors', 1); + expect(updatedUnit).to.have.property('numDepartments', 1); + + const fullDepartment = await getDepartmentById(testDepartmentId); + expect(fullDepartment).to.not.have.property('parentId'); + expect(fullDepartment).to.not.have.property('ancestors'); + + await deleteDepartment(testDepartmentId); + }); + + it('should succesfully create a department into an existing unit as an admin', async () => { + const testDepartment = await createDepartmentWithMethod({ name: departmentName, departmentUnit: { _id: unit._id } }); + testDepartmentId = testDepartment._id; + + const updatedUnit = await getUnit(unit._id); + expect(updatedUnit).to.have.property('name', unit.name); + expect(updatedUnit).to.have.property('numMonitors', 1); + expect(updatedUnit).to.have.property('numDepartments', 2); + + const fullDepartment = await getDepartmentById(testDepartmentId); + expect(fullDepartment).to.have.property('parentId', unit._id); + expect(fullDepartment).to.have.property('ancestors').that.is.an('array').with.lengthOf(1); + expect(fullDepartment.ancestors?.[0]).to.equal(unit._id); + }); + + it('should succesfully remove an existing department from a unit as an admin', async () => { + await createDepartmentWithMethod({ name: departmentName, departmentUnit: {}, departmentId: testDepartmentId }); + + const updatedUnit = await getUnit(unit._id); + expect(updatedUnit).to.have.property('name', unit.name); + expect(updatedUnit).to.have.property('numMonitors', 1); + expect(updatedUnit).to.have.property('numDepartments', 1); + + const fullDepartment = await getDepartmentById(testDepartmentId); + expect(fullDepartment).to.have.property('parentId').that.is.null; + expect(fullDepartment).to.have.property('ancestors').that.is.null; + }); + + it('should succesfully add an existing department to a unit as an admin', async () => { + await createDepartmentWithMethod({ name: departmentName, departmentUnit: { _id: unit._id }, departmentId: testDepartmentId }); + + const updatedUnit = await getUnit(unit._id); + expect(updatedUnit).to.have.property('name', unit.name); + expect(updatedUnit).to.have.property('numMonitors', 1); + expect(updatedUnit).to.have.property('numDepartments', 2); + + const fullDepartment = await getDepartmentById(testDepartmentId); + expect(fullDepartment).to.have.property('parentId', unit._id); + expect(fullDepartment).to.have.property('ancestors').that.is.an('array').with.lengthOf(1); + expect(fullDepartment.ancestors?.[0]).to.equal(unit._id); + }); + + it('should succesfully remove a department from a unit that a monitor supervises', async () => { + await createDepartmentWithMethod({ + name: departmentName, + departmentUnit: {}, + departmentId: testDepartmentId, + userCredentials: monitor1Credentials, + }); + + const updatedUnit = await getUnit(unit._id); + expect(updatedUnit).to.have.property('name', unit.name); + expect(updatedUnit).to.have.property('numMonitors', 1); + expect(updatedUnit).to.have.property('numDepartments', 1); + + const fullDepartment = await getDepartmentById(testDepartmentId); + expect(fullDepartment).to.have.property('parentId').that.is.null; + expect(fullDepartment).to.have.property('ancestors').that.is.null; + }); + + it('should succesfully add an existing department to a unit that a monitor supervises', async () => { + await createDepartmentWithMethod({ + name: departmentName, + departmentUnit: { _id: unit._id }, + departmentId: testDepartmentId, + userCredentials: monitor1Credentials, + }); + + const updatedUnit = await getUnit(unit._id); + expect(updatedUnit).to.have.property('name', unit.name); + expect(updatedUnit).to.have.property('numMonitors', 1); + expect(updatedUnit).to.have.property('numDepartments', 2); + + const fullDepartment = await getDepartmentById(testDepartmentId); + expect(fullDepartment).to.have.property('parentId', unit._id); + expect(fullDepartment).to.have.property('ancestors').that.is.an('array').with.lengthOf(1); + expect(fullDepartment.ancestors?.[0]).to.equal(unit._id); + }); + + it('should fail removing a department from a unit that a monitor does not supervise', async () => { + await createDepartmentWithMethod({ + name: departmentName, + departmentUnit: {}, + departmentId: testDepartmentId, + userCredentials: monitor2Credentials, + }); + + const updatedUnit = await getUnit(unit._id); + expect(updatedUnit).to.have.property('name', unit.name); + expect(updatedUnit).to.have.property('numMonitors', 1); + expect(updatedUnit).to.have.property('numDepartments', 2); + + const fullDepartment = await getDepartmentById(testDepartmentId); + expect(fullDepartment).to.have.property('parentId', unit._id); + expect(fullDepartment).to.have.property('ancestors').that.is.an('array').with.lengthOf(1); + expect(fullDepartment.ancestors?.[0]).to.equal(unit._id); + + await deleteDepartment(testDepartmentId); + }); + + it('should succesfully create a department in a unit that a monitor supervises', async () => { + const testDepartment = await createDepartmentWithMethod({ + name: departmentName, + departmentUnit: { _id: unit._id }, + userCredentials: monitor1Credentials, + }); + testDepartmentId = testDepartment._id; + + // Deleting a department currently does not decrease its unit's counter. We must adjust this check when this is fixed + const updatedUnit = await getUnit(unit._id); + expect(updatedUnit).to.have.property('name', unit.name); + expect(updatedUnit).to.have.property('numMonitors', 1); + expect(updatedUnit).to.have.property('numDepartments', 3); + + const fullDepartment = await getDepartmentById(testDepartmentId); + expect(fullDepartment).to.have.property('parentId', unit._id); + expect(fullDepartment).to.have.property('ancestors').that.is.an('array').with.lengthOf(1); + expect(fullDepartment.ancestors?.[0]).to.equal(unit._id); + }); + }); }); diff --git a/apps/meteor/tests/end-to-end/api/livechat/contacts.ts b/apps/meteor/tests/end-to-end/api/livechat/contacts.ts index 957d22ba92ae..c33ef255c25c 100644 --- a/apps/meteor/tests/end-to-end/api/livechat/contacts.ts +++ b/apps/meteor/tests/end-to-end/api/livechat/contacts.ts @@ -569,4 +569,67 @@ describe('LIVECHAT - contacts', () => { }); }); }); + + describe('[GET] omnichannel/contacts.get', () => { + let contactId: string; + const contact = { + name: faker.person.fullName(), + emails: [faker.internet.email().toLowerCase()], + phones: [faker.phone.number()], + contactManager: agentUser?._id, + }; + + before(async () => { + await updatePermission('view-livechat-contact', ['admin']); + const { body } = await request + .post(api('omnichannel/contacts')) + .set(credentials) + .send({ ...contact }); + contactId = body.contactId; + }); + + after(async () => { + await restorePermissionToRoles('view-livechat-contact'); + }); + + it('should be able get a contact by id', async () => { + const res = await request.get(api(`omnichannel/contacts.get`)).set(credentials).query({ contactId }); + + expect(res.status).to.be.equal(200); + expect(res.body).to.have.property('success', true); + expect(res.body.contact._id).to.be.equal(contactId); + expect(res.body.contact.name).to.be.equal(contact.name); + expect(res.body.contact.emails).to.be.deep.equal(contact.emails); + expect(res.body.contact.phones).to.be.deep.equal(contact.phones); + expect(res.body.contact.contactManager).to.be.equal(contact.contactManager); + }); + + it('should return null if contact does not exist', async () => { + const res = await request.get(api(`omnichannel/contacts.get`)).set(credentials).query({ contactId: 'invalid' }); + + expect(res.status).to.be.equal(200); + expect(res.body).to.have.property('success', true); + expect(res.body.contact).to.be.null; + }); + + it("should return an error if user doesn't have 'view-livechat-contact' permission", async () => { + await removePermissionFromAllRoles('view-livechat-contact'); + + const res = await request.get(api(`omnichannel/contacts.get`)).set(credentials).query({ contactId }); + + expect(res.body).to.have.property('success', false); + expect(res.body.error).to.be.equal('User does not have the permissions required for this action [error-unauthorized]'); + + await restorePermissionToRoles('view-livechat-contact'); + }); + + it('should return an error if contactId is missing', async () => { + const res = await request.get(api(`omnichannel/contacts.get`)).set(credentials); + + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('error'); + expect(res.body.error).to.be.equal("must have required property 'contactId' [invalid-params]"); + expect(res.body.errorType).to.be.equal('invalid-params'); + }); + }); }); diff --git a/apps/meteor/tests/end-to-end/api/methods.ts b/apps/meteor/tests/end-to-end/api/methods.ts index 197a71f8e8f2..e3c42389e506 100644 --- a/apps/meteor/tests/end-to-end/api/methods.ts +++ b/apps/meteor/tests/end-to-end/api/methods.ts @@ -616,9 +616,19 @@ describe('Meteor.methods', () => { describe('[@cleanRoomHistory]', () => { let rid: IRoom['_id']; - + let testUser: IUser; + let testUserCredentials: Credentials; let channelName: string; + before('update permissions', async () => { + await updatePermission('clean-channel-history', ['admin', 'user']); + }); + + before('create test user', async () => { + testUser = await createUser(); + testUserCredentials = await login(testUser.username, password); + }); + before('create room', (done) => { channelName = `methods-test-channel-${Date.now()}`; void request @@ -676,7 +686,36 @@ describe('Meteor.methods', () => { .end(done); }); - after(() => deleteRoom({ type: 'p', roomId: rid })); + after(() => + Promise.all([deleteRoom({ type: 'p', roomId: rid }), deleteUser(testUser), updatePermission('clean-channel-history', ['admin'])]), + ); + + it('should throw an error if user is not part of the room', async () => { + await request + .post(methodCall('cleanRoomHistory')) + .set(testUserCredentials) + .send({ + message: JSON.stringify({ + method: 'cleanRoomHistory', + params: [ + { + roomId: rid, + oldest: { $date: new Date().getTime() }, + latest: { $date: new Date().getTime() }, + }, + ], + id: 'id', + msg: 'method', + }), + }) + .expect(200) + .expect((res) => { + expect(res.body).to.have.a.property('success', true); + const data = JSON.parse(res.body.message); + expect(data).to.have.a.property('error').that.is.an('object'); + expect(data.error).to.have.a.property('error', 'error-not-allowed'); + }); + }); it('should not change the _updatedAt value when nothing is changed on the room', async () => { const roomBefore = await request.get(api('groups.info')).set(credentials).query({ @@ -2602,6 +2641,158 @@ describe('Meteor.methods', () => { updateSetting('Message_AllowEditing_BlockEditInMinutes', 0), ]); }); + + describe('message deletion when user is not part of the room', () => { + let ridTestRoom: IRoom['_id']; + let messageIdTestRoom: IMessage['_id']; + let testUser: TestUser; + let testUserCredentials: Credentials; + + before('create room, add new owner, and leave room', async () => { + testUser = await createUser(); + testUserCredentials = await login(testUser.username, password); + const channelName = `methods-test-channel-${Date.now()}`; + + await request + .post(api('groups.create')) + .set(testUserCredentials) + .send({ + name: channelName, + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.nested.property('group._id'); + expect(res.body).to.have.nested.property('group.name', channelName); + expect(res.body).to.have.nested.property('group.t', 'p'); + expect(res.body).to.have.nested.property('group.msgs', 0); + ridTestRoom = res.body.group._id; + }); + + await request + .post(methodCall('sendMessage')) + .set(testUserCredentials) + .send({ + message: JSON.stringify({ + method: 'sendMessage', + params: [ + { + _id: `${Date.now() + Math.random()}`, + rid: ridTestRoom, + msg: 'just a random test message', + }, + ], + id: 'id', + msg: 'method', + }), + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.a.property('success', true); + expect(res.body).to.have.a.property('message').that.is.a('string'); + const data = JSON.parse(res.body.message); + expect(data).to.have.a.property('result').that.is.an('object'); + messageIdTestRoom = data.result._id; + }); + + await request + .post(methodCall('addUsersToRoom')) + .set(testUserCredentials) + .send({ + message: JSON.stringify({ + method: 'addUsersToRoom', + params: [ + { + rid: ridTestRoom, + users: ['rocket.cat'], + }, + ], + id: 'id', + msg: 'method', + }), + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.a.property('success', true); + expect(res.body).to.have.a.property('message').that.is.a('string'); + }); + + await request + .post(api('groups.addOwner')) + .set(testUserCredentials) + .send({ + roomId: ridTestRoom, + userId: 'rocket.cat', + }) + .expect((res) => { + expect(res.body).to.have.a.property('success', true); + }); + + await request + .post(api('groups.leave')) + .set(testUserCredentials) + .send({ + roomId: ridTestRoom, + }) + .expect(200) + .expect((res) => { + expect(res.body).to.have.a.property('success', true); + }); + }); + + it('should not delete a message if the user is no longer member of the room', async () => { + await request + .post(methodCall('deleteMessage')) + .set(testUserCredentials) + .send({ + message: JSON.stringify({ + method: 'deleteMessage', + params: [{ _id: messageIdTestRoom, rid: ridTestRoom }], + id: 'id', + msg: 'method', + }), + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.a.property('success', true); + expect(res.body).to.have.a.property('message').that.is.a('string'); + const data = JSON.parse(res.body.message); + expect(data).to.have.a.property('msg', 'result'); + expect(data).to.have.a.property('id', 'id'); + expect(data.error).to.have.a.property('error', 'error-action-not-allowed'); + }); + }); + + it('should not delete a message if the user was never part of the room', async () => { + await request + .post(methodCall('deleteMessage')) + .set(credentials) + .send({ + message: JSON.stringify({ + method: 'deleteMessage', + params: [{ _id: messageIdTestRoom, rid: ridTestRoom }], + id: 'id', + msg: 'method', + }), + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.a.property('success', true); + expect(res.body).to.have.a.property('message').that.is.a('string'); + const data = JSON.parse(res.body.message); + expect(data).to.have.a.property('msg', 'result'); + expect(data).to.have.a.property('id', 'id'); + expect(data.error).to.have.a.property('error', 'error-action-not-allowed'); + }); + }); + + after(() => Promise.all([deleteRoom({ type: 'p', roomId: ridTestRoom }), deleteUser(testUser)])); + }); }); describe('[@setUserActiveStatus]', () => { @@ -3348,6 +3539,7 @@ describe('Meteor.methods', () => { .end(done); }); }); + (IS_EE ? describe : describe.skip)('[@auditGetAuditions] EE', () => { let testUser: TestUser; let testUserCredentials: Credentials; @@ -3451,4 +3643,305 @@ describe('Meteor.methods', () => { }); }); }); + + describe('UpdateOTRAck', () => { + let testUser: TestUser; + let testUser2: TestUser; + let testUserCredentials: Credentials; + let dmTestId: IRoom['_id']; + + before(async () => { + testUser = await createUser(); + testUser2 = await createUser(); + testUserCredentials = await login(testUser.username, password); + }); + + before('create direct conversation between both users', (done) => { + void request + .post(methodCall('createDirectMessage')) + .set(testUserCredentials) + .send({ + message: JSON.stringify({ + method: 'createDirectMessage', + params: [testUser2.username], + id: 'id', + msg: 'method', + }), + }) + .end((_err, res) => { + const result = JSON.parse(res.body.message); + expect(result.result).to.be.an('object'); + expect(result.result).to.have.property('rid').that.is.an('string'); + + dmTestId = result.result.rid; + done(); + }); + }); + + after(() => Promise.all([deleteRoom({ type: 'd', roomId: dmTestId }), deleteUser(testUser), deleteUser(testUser2)])); + + it('should fail if required parameters are not present', async () => { + await request + .post(methodCall('updateOTRAck')) + .set(credentials) + .send({ + message: JSON.stringify({ + method: 'updateOTRAck', + params: [ + { + message: { + _id: 'czjFdkFab7H5bWxYq', + // rid: 'test', + msg: 'test', + t: 'otr', + ts: { $date: 1725447664093 }, + u: { + _id: 'test', + username: 'test', + name: 'test', + }, + }, + ack: 'test', + }, + ], + id: '18', + msg: 'method', + }), + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.a.property('message'); + const data = JSON.parse(res.body.message); + expect(data).to.have.a.property('error'); + expect(data.error).to.have.a.property('message', "Match error: Missing key 'rid'"); + }); + }); + + it('should fail if required parameters have a different type', async () => { + await request + .post(methodCall('updateOTRAck')) + .set(credentials) + .send({ + message: JSON.stringify({ + method: 'updateOTRAck', + params: [ + { + message: { + _id: 'czjFdkFab7H5bWxYq', + rid: { $ne: 'test' }, + msg: 'test', + t: 'otr', + ts: { $date: 1725447664093 }, + u: { + _id: 'test', + username: 'test', + name: 'test', + }, + }, + ack: 'test', + }, + ], + id: '18', + msg: 'method', + }), + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.a.property('message'); + const data = JSON.parse(res.body.message); + expect(data).to.have.a.property('error'); + expect(data.error).to.have.a.property('message', 'Match error: Expected string, got object in field rid'); + }); + }); + + it('should fail if "t" is not "otr"', async () => { + await request + .post(methodCall('updateOTRAck')) + .set(credentials) + .send({ + message: JSON.stringify({ + method: 'updateOTRAck', + params: [ + { + message: { + _id: 'czjFdkFab7H5bWxYq', + rid: 'test', + msg: 'test', + t: 'notOTR', + ts: { $date: 1725447664093 }, + u: { + _id: 'test', + username: 'test', + name: 'test', + }, + }, + ack: 'test', + }, + ], + id: '18', + msg: 'method', + }), + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.a.property('message'); + const data = JSON.parse(res.body.message); + expect(data).to.have.a.property('error'); + expect(data.error).to.have.a.property('message', 'Invalid message type [error-invalid-message]'); + }); + }); + + it('should fail if room does not exist', async () => { + await request + .post(methodCall('updateOTRAck')) + .set(credentials) + .send({ + message: JSON.stringify({ + method: 'updateOTRAck', + params: [ + { + message: { + _id: 'czjFdkFab7H5bWxYq', + rid: 'test', + msg: 'test', + t: 'otr', + ts: { $date: 1725447664093 }, + u: { + _id: 'test', + username: 'test', + name: 'test', + }, + }, + ack: 'test', + }, + ], + id: '18', + msg: 'method', + }), + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.a.property('message'); + const data = JSON.parse(res.body.message); + expect(data).to.have.a.property('error'); + expect(data.error).to.have.a.property('message', 'Invalid room [error-invalid-room]'); + }); + }); + + it('should fail if room is not a DM', async () => { + await request + .post(methodCall('updateOTRAck')) + .set(credentials) + .send({ + message: JSON.stringify({ + method: 'updateOTRAck', + params: [ + { + message: { + _id: 'czjFdkFab7H5bWxYq', + rid: 'GENERAL', + msg: 'test', + t: 'otr', + ts: { $date: 1725447664093 }, + u: { + _id: 'test', + username: 'test', + name: 'test', + }, + }, + ack: 'test', + }, + ], + id: '18', + msg: 'method', + }), + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.a.property('message'); + const data = JSON.parse(res.body.message); + expect(data).to.have.a.property('error'); + expect(data.error).to.have.a.property('message', 'Invalid room [error-invalid-room]'); + }); + }); + + it('should fail if user is not part of DM room', async () => { + await request + .post(methodCall('updateOTRAck')) + .set(credentials) + .send({ + message: JSON.stringify({ + method: 'updateOTRAck', + params: [ + { + message: { + _id: 'czjFdkFab7H5bWxYq', + rid: dmTestId, + msg: 'test', + t: 'otr', + ts: { $date: 1725447664093 }, + u: { + _id: testUser._id, + username: testUser.username, + name: 'test', + }, + }, + ack: 'test', + }, + ], + id: '18', + msg: 'method', + }), + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.a.property('message'); + const data = JSON.parse(res.body.message); + expect(data).to.have.a.property('error'); + expect(data.error).to.have.a.property('message', 'Invalid user, not in room [error-invalid-user]'); + }); + }); + + it('should pass if all parameters are present and user is part of DM room', async () => { + await request + .post(methodCall('updateOTRAck')) + .set(testUserCredentials) + .send({ + message: JSON.stringify({ + method: 'updateOTRAck', + params: [ + { + message: { + _id: 'czjFdkFab7H5bWxYq', + rid: dmTestId, + msg: 'test', + t: 'otr', + ts: { $date: 1725447664093 }, + u: { + _id: testUser._id, + username: testUser.username, + name: 'test', + }, + }, + ack: 'test', + }, + ], + id: '18', + msg: 'method', + }), + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.a.property('message'); + expect(res.body).to.have.a.property('success', true); + }); + }); + }); }); diff --git a/apps/meteor/tests/end-to-end/api/miscellaneous.ts b/apps/meteor/tests/end-to-end/api/miscellaneous.ts index b8341f7c0994..d933f1f3c4b3 100644 --- a/apps/meteor/tests/end-to-end/api/miscellaneous.ts +++ b/apps/meteor/tests/end-to-end/api/miscellaneous.ts @@ -186,6 +186,7 @@ describe('miscellaneous', () => { 'muteFocusedConversations', 'notifyCalendarEvents', 'enableMobileRinging', + 'featuresPreview', ].filter((p) => Boolean(p)); expect(res.body).to.have.property('success', true); diff --git a/apps/meteor/tests/end-to-end/api/rooms.ts b/apps/meteor/tests/end-to-end/api/rooms.ts index 15f85964ffff..5047af7956d8 100644 --- a/apps/meteor/tests/end-to-end/api/rooms.ts +++ b/apps/meteor/tests/end-to-end/api/rooms.ts @@ -1133,6 +1133,34 @@ describe('[Rooms]', () => { }) .end(done); }); + describe('test user is not part of room', async () => { + beforeEach(async () => { + await updatePermission('clean-channel-history', ['admin', 'user']); + }); + + afterEach(async () => { + await updatePermission('clean-channel-history', ['admin']); + }); + + it('should return an error when the user with right privileges is not part of the room', async () => { + await request + .post(api('rooms.cleanHistory')) + .set(userCredentials) + .send({ + roomId: privateChannel._id, + latest: '9999-12-31T23:59:59.000Z', + oldest: '0001-01-01T00:00:00.000Z', + limit: 2000, + }) + .expect('Content-Type', 'application/json') + .expect(400) + .expect((res) => { + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('errorType', 'error-not-allowed'); + expect(res.body).to.have.property('error', 'User does not have access to the room [error-not-allowed]'); + }); + }); + }); }); describe('[/rooms.info]', () => { let testChannel: IRoom; diff --git a/apps/meteor/tests/end-to-end/api/teams.ts b/apps/meteor/tests/end-to-end/api/teams.ts index ca07d3e32679..b630a97b1727 100644 --- a/apps/meteor/tests/end-to-end/api/teams.ts +++ b/apps/meteor/tests/end-to-end/api/teams.ts @@ -2217,4 +2217,325 @@ describe('[Teams]', () => { }); }); }); + + describe('[teams.listChildren]', () => { + const teamName = `team-${Date.now()}`; + let testTeam: ITeam; + let testPrivateTeam: ITeam; + let testUser: IUser; + let testUserCredentials: Credentials; + + let privateRoom: IRoom; + let privateRoom2: IRoom; + let publicRoom: IRoom; + let publicRoom2: IRoom; + + let discussionOnPrivateRoom: IRoom; + let discussionOnPublicRoom: IRoom; + let discussionOnMainRoom: IRoom; + + before('Create test team', async () => { + testUser = await createUser(); + testUserCredentials = await login(testUser.username, password); + + const members = testUser.username ? [testUser.username] : []; + testTeam = await createTeam(credentials, teamName, 0, members); + testPrivateTeam = await createTeam(testUserCredentials, `${teamName}private`, 1, []); + }); + + before('make user owner', async () => { + await request + .post(api('teams.updateMember')) + .set(credentials) + .send({ + teamName: testTeam.name, + member: { + userId: testUser._id, + roles: ['member', 'owner'], + }, + }) + .expect('Content-Type', 'application/json') + .expect(200); + }); + + before('create rooms', async () => { + privateRoom = (await createRoom({ type: 'p', name: `test-p-${Date.now()}` })).body.group; + privateRoom2 = (await createRoom({ type: 'p', name: `test-p2-${Date.now()}`, credentials: testUserCredentials })).body.group; + publicRoom = (await createRoom({ type: 'c', name: `test-c-${Date.now()}` })).body.channel; + publicRoom2 = (await createRoom({ type: 'c', name: `test-c2-${Date.now()}` })).body.channel; + + await Promise.all([ + request + .post(api('teams.addRooms')) + .set(credentials) + .send({ + rooms: [privateRoom._id, publicRoom._id, publicRoom2._id], + teamId: testTeam._id, + }) + .expect(200), + request + .post(api('teams.addRooms')) + .set(testUserCredentials) + .send({ + rooms: [privateRoom2._id], + teamId: testTeam._id, + }) + .expect(200), + ]); + }); + + before('Create discussions', async () => { + discussionOnPrivateRoom = ( + await request + .post(api('rooms.createDiscussion')) + .set(credentials) + .send({ + prid: privateRoom._id, + t_name: `test-d-${Date.now()}`, + }) + ).body.discussion; + discussionOnPublicRoom = ( + await request + .post(api('rooms.createDiscussion')) + .set(credentials) + .send({ + prid: publicRoom._id, + t_name: `test-d-${Date.now()}`, + }) + ).body.discussion; + discussionOnMainRoom = ( + await request + .post(api('rooms.createDiscussion')) + .set(credentials) + .send({ + prid: testTeam.roomId, + t_name: `test-d-${Date.now()}`, + }) + ).body.discussion; + }); + + after(async () => { + await Promise.all([ + deleteRoom({ type: 'p', roomId: privateRoom._id }), + deleteRoom({ type: 'p', roomId: privateRoom2._id }), + deleteRoom({ type: 'c', roomId: publicRoom._id }), + deleteRoom({ type: 'c', roomId: publicRoom2._id }), + deleteRoom({ type: 'p', roomId: discussionOnPrivateRoom._id }), + deleteRoom({ type: 'c', roomId: discussionOnPublicRoom._id }), + deleteRoom({ type: 'c', roomId: discussionOnMainRoom._id }), + deleteTeam(credentials, teamName), + deleteTeam(credentials, testPrivateTeam.name), + deleteUser({ _id: testUser._id }), + ]); + }); + + it('should fail if user is not logged in', async () => { + await request.get(api('teams.listChildren')).expect(401); + }); + + it('should fail if teamId is not passed as queryparam', async () => { + await request.get(api('teams.listChildren')).set(credentials).expect(400); + }); + + it('should fail if teamId is not valid', async () => { + await request.get(api('teams.listChildren')).set(credentials).query({ teamId: 'invalid' }).expect(404); + }); + + it('should fail if teamId is empty', async () => { + await request.get(api('teams.listChildren')).set(credentials).query({ teamId: '' }).expect(404); + }); + + it('should fail if both properties are passed', async () => { + await request.get(api('teams.listChildren')).set(credentials).query({ teamId: testTeam._id, teamName: testTeam.name }).expect(400); + }); + + it('should fail if teamName is empty', async () => { + await request.get(api('teams.listChildren')).set(credentials).query({ teamName: '' }).expect(404); + }); + + it('should fail if teamName is invalid', async () => { + await request.get(api('teams.listChildren')).set(credentials).query({ teamName: 'invalid' }).expect(404); + }); + + it('should fail if roomId is empty', async () => { + await request.get(api('teams.listChildren')).set(credentials).query({ roomId: '' }).expect(404); + }); + + it('should fail if roomId is invalid', async () => { + await request.get(api('teams.listChildren')).set(credentials).query({ teamName: 'invalid' }).expect(404); + }); + + it('should return a list of valid rooms for user', async () => { + const res = await request.get(api('teams.listChildren')).query({ teamId: testTeam._id }).set(credentials).expect(200); + + expect(res.body).to.have.property('total').to.be.equal(5); + expect(res.body).to.have.property('data').to.be.an('array'); + expect(res.body.data).to.have.lengthOf(5); + + const mainRoom = res.body.data.find((room: IRoom) => room._id === testTeam.roomId); + expect(mainRoom).to.be.an('object'); + + const publicChannel1 = res.body.data.find((room: IRoom) => room._id === publicRoom._id); + expect(publicChannel1).to.be.an('object'); + + const publicChannel2 = res.body.data.find((room: IRoom) => room._id === publicRoom2._id); + expect(publicChannel2).to.be.an('object'); + + const privateChannel1 = res.body.data.find((room: IRoom) => room._id === privateRoom._id); + expect(privateChannel1).to.be.an('object'); + + const privateChannel2 = res.body.data.find((room: IRoom) => room._id === privateRoom2._id); + expect(privateChannel2).to.be.undefined; + + const discussionOnP = res.body.data.find((room: IRoom) => room._id === discussionOnPrivateRoom._id); + expect(discussionOnP).to.be.undefined; + + const discussionOnC = res.body.data.find((room: IRoom) => room._id === discussionOnPublicRoom._id); + expect(discussionOnC).to.be.undefined; + + const mainDiscussion = res.body.data.find((room: IRoom) => room._id === discussionOnMainRoom._id); + expect(mainDiscussion).to.be.an('object'); + }); + + it('should return a valid list of rooms for non admin member too', async () => { + const res = await request.get(api('teams.listChildren')).query({ teamName: testTeam.name }).set(testUserCredentials).expect(200); + + expect(res.body).to.have.property('total').to.be.equal(5); + expect(res.body).to.have.property('data').to.be.an('array'); + expect(res.body.data).to.have.lengthOf(5); + + const mainRoom = res.body.data.find((room: IRoom) => room._id === testTeam.roomId); + expect(mainRoom).to.be.an('object'); + + const publicChannel1 = res.body.data.find((room: IRoom) => room._id === publicRoom._id); + expect(publicChannel1).to.be.an('object'); + + const publicChannel2 = res.body.data.find((room: IRoom) => room._id === publicRoom2._id); + expect(publicChannel2).to.be.an('object'); + + const privateChannel1 = res.body.data.find((room: IRoom) => room._id === privateRoom._id); + expect(privateChannel1).to.be.undefined; + + const privateChannel2 = res.body.data.find((room: IRoom) => room._id === privateRoom2._id); + expect(privateChannel2).to.be.an('object'); + + const discussionOnP = res.body.data.find((room: IRoom) => room._id === discussionOnPrivateRoom._id); + expect(discussionOnP).to.be.undefined; + + const discussionOnC = res.body.data.find((room: IRoom) => room._id === discussionOnPublicRoom._id); + expect(discussionOnC).to.be.undefined; + + const mainDiscussion = res.body.data.find((room: IRoom) => room._id === discussionOnMainRoom._id); + expect(mainDiscussion).to.be.an('object'); + }); + + it('should return a valid list of rooms for non admin member too when filtering by teams main room id', async () => { + const res = await request.get(api('teams.listChildren')).query({ roomId: testTeam.roomId }).set(testUserCredentials).expect(200); + + expect(res.body).to.have.property('total').to.be.equal(5); + expect(res.body).to.have.property('data').to.be.an('array'); + expect(res.body.data).to.have.lengthOf(5); + + const mainRoom = res.body.data.find((room: IRoom) => room._id === testTeam.roomId); + expect(mainRoom).to.be.an('object'); + + const publicChannel1 = res.body.data.find((room: IRoom) => room._id === publicRoom._id); + expect(publicChannel1).to.be.an('object'); + + const publicChannel2 = res.body.data.find((room: IRoom) => room._id === publicRoom2._id); + expect(publicChannel2).to.be.an('object'); + + const privateChannel1 = res.body.data.find((room: IRoom) => room._id === privateRoom._id); + expect(privateChannel1).to.be.undefined; + + const privateChannel2 = res.body.data.find((room: IRoom) => room._id === privateRoom2._id); + expect(privateChannel2).to.be.an('object'); + + const discussionOnP = res.body.data.find((room: IRoom) => room._id === discussionOnPrivateRoom._id); + expect(discussionOnP).to.be.undefined; + + const discussionOnC = res.body.data.find((room: IRoom) => room._id === discussionOnPublicRoom._id); + expect(discussionOnC).to.be.undefined; + + const mainDiscussion = res.body.data.find((room: IRoom) => room._id === discussionOnMainRoom._id); + expect(mainDiscussion).to.be.an('object'); + }); + + it('should return a list of rooms filtered by name using the filter parameter', async () => { + const res = await request + .get(api('teams.listChildren')) + .query({ teamId: testTeam._id, filter: 'test-p' }) + .set(credentials) + .expect(200); + + expect(res.body).to.have.property('total').to.be.equal(1); + expect(res.body).to.have.property('data').to.be.an('array'); + expect(res.body.data[0]._id).to.be.equal(privateRoom._id); + expect(res.body.data.find((room: IRoom) => room._id === privateRoom2._id)).to.be.undefined; + }); + + it('should paginate results', async () => { + const res = await request + .get(api('teams.listChildren')) + .query({ teamId: testTeam._id, offset: 1, count: 2 }) + .set(credentials) + .expect(200); + + expect(res.body).to.have.property('total').to.be.equal(5); + expect(res.body).to.have.property('data').to.be.an('array'); + expect(res.body.data).to.have.lengthOf(2); + }); + + it('should return only items of type channel', async () => { + const res = await request + .get(api('teams.listChildren')) + .query({ teamId: testTeam._id, type: 'channels' }) + .set(credentials) + .expect(200); + + expect(res.body).to.have.property('total').to.be.equal(4); + expect(res.body).to.have.property('data').to.be.an('array'); + expect(res.body.data).to.have.lengthOf(4); + expect(res.body.data.some((room: IRoom) => !!room.prid)).to.be.false; + }); + + it('should return only items of type discussion', async () => { + const res = await request + .get(api('teams.listChildren')) + .query({ teamId: testTeam._id, type: 'discussions' }) + .set(credentials) + .expect(200); + + expect(res.body).to.have.property('total').to.be.equal(1); + expect(res.body).to.have.property('data').to.be.an('array'); + expect(res.body.data).to.have.lengthOf(1); + expect(res.body.data.every((room: IRoom) => !!room.prid)).to.be.true; + }); + + it('should return both when type is not passed', async () => { + const res = await request.get(api('teams.listChildren')).query({ teamId: testTeam._id }).set(credentials).expect(200); + + expect(res.body).to.have.property('total').to.be.equal(5); + expect(res.body).to.have.property('data').to.be.an('array'); + expect(res.body.data).to.have.lengthOf(5); + expect(res.body.data.some((room: IRoom) => !!room.prid)).to.be.true; + expect(res.body.data.some((room: IRoom) => !room.prid)).to.be.true; + }); + + it('should fail if type is other than channel or discussion', async () => { + await request.get(api('teams.listChildren')).query({ teamId: testTeam._id, type: 'other' }).set(credentials).expect(400); + }); + + it('should properly list children of a private team', async () => { + const res = await request.get(api('teams.listChildren')).query({ teamId: testPrivateTeam._id }).set(testUserCredentials).expect(200); + + expect(res.body).to.have.property('total').to.be.equal(1); + expect(res.body).to.have.property('data').to.be.an('array'); + expect(res.body.data).to.have.lengthOf(1); + }); + + it('should throw an error when a non member user tries to fetch info for team', async () => { + await request.get(api('teams.listChildren')).query({ teamId: testPrivateTeam._id }).set(credentials).expect(400); + }); + }); }); diff --git a/apps/meteor/tests/unit/app/lib/server/functions/setUsername.spec.ts b/apps/meteor/tests/unit/app/lib/server/functions/setUsername.spec.ts new file mode 100644 index 000000000000..c6b6f9a26fae --- /dev/null +++ b/apps/meteor/tests/unit/app/lib/server/functions/setUsername.spec.ts @@ -0,0 +1,271 @@ +import { expect } from 'chai'; +import proxyquire from 'proxyquire'; +import sinon from 'sinon'; + +describe('setUsername', () => { + const userId = 'userId'; + const username = 'validUsername'; + + const stubs = { + Users: { + findOneById: sinon.stub(), + setUsername: sinon.stub(), + }, + Accounts: { + sendEnrollmentEmail: sinon.stub(), + }, + settings: { + get: sinon.stub(), + }, + api: { + broadcast: sinon.stub(), + }, + Invites: { + findOneById: sinon.stub(), + }, + callbacks: { + run: sinon.stub(), + }, + checkUsernameAvailability: sinon.stub(), + validateUsername: sinon.stub(), + saveUserIdentity: sinon.stub(), + joinDefaultChannels: sinon.stub(), + getAvatarSuggestionForUser: sinon.stub(), + setUserAvatar: sinon.stub(), + addUserToRoom: sinon.stub(), + notifyOnUserChange: sinon.stub(), + RateLimiter: { + limitFunction: sinon.stub(), + }, + underscore: { + escape: sinon.stub(), + }, + SystemLogger: sinon.stub(), + }; + + const { setUsernameWithValidation, _setUsername } = proxyquire + .noCallThru() + .load('../../../../../../app/lib/server/functions/setUsername', { + 'meteor/meteor': { Meteor: { Error } }, + '@rocket.chat/core-services': { api: stubs.api }, + '@rocket.chat/models': { Users: stubs.Users, Invites: stubs.Invites }, + 'meteor/accounts-base': { Accounts: stubs.Accounts }, + 'underscore': stubs.underscore, + '../../../settings/server': { settings: stubs.settings }, + '../lib': { notifyOnUserChange: stubs.notifyOnUserChange, RateLimiter: stubs.RateLimiter }, + './addUserToRoom': { addUserToRoom: stubs.addUserToRoom }, + './checkUsernameAvailability': { checkUsernameAvailability: stubs.checkUsernameAvailability }, + './getAvatarSuggestionForUser': { getAvatarSuggestionForUser: stubs.getAvatarSuggestionForUser }, + './joinDefaultChannels': { joinDefaultChannels: stubs.joinDefaultChannels }, + './saveUserIdentity': { saveUserIdentity: stubs.saveUserIdentity }, + './setUserAvatar': { setUserAvatar: stubs.setUserAvatar }, + './validateUsername': { validateUsername: stubs.validateUsername }, + '../../../../lib/callbacks': { callbacks: stubs.callbacks }, + '../../../../server/lib/logger/system': { SystemLogger: stubs.SystemLogger }, + }); + + afterEach(() => { + stubs.Users.findOneById.reset(); + stubs.Users.setUsername.reset(); + stubs.Accounts.sendEnrollmentEmail.reset(); + stubs.settings.get.reset(); + stubs.api.broadcast.reset(); + stubs.Invites.findOneById.reset(); + stubs.callbacks.run.reset(); + stubs.checkUsernameAvailability.reset(); + stubs.validateUsername.reset(); + stubs.saveUserIdentity.reset(); + stubs.joinDefaultChannels.reset(); + stubs.getAvatarSuggestionForUser.reset(); + stubs.setUserAvatar.reset(); + stubs.addUserToRoom.reset(); + stubs.notifyOnUserChange.reset(); + stubs.RateLimiter.limitFunction.reset(); + stubs.underscore.escape.reset(); + stubs.SystemLogger.reset(); + }); + + describe('setUsernameWithValidation', () => { + it('should throw an error if username is invalid', async () => { + try { + await setUsernameWithValidation(userId, ''); + } catch (error: any) { + expect(error.message).to.equal('error-invalid-username'); + } + }); + + it('should throw an error if user is not found', async () => { + stubs.Users.findOneById.withArgs(userId).returns(null); + + try { + await setUsernameWithValidation(userId, username); + } catch (error: any) { + expect(stubs.Users.findOneById.calledOnce).to.be.true; + expect(error.message).to.equal('error-invalid-user'); + } + }); + + it('should throw an error if username change is not allowed', async () => { + stubs.Users.findOneById.resolves({ username: 'oldUsername' }); + stubs.settings.get.withArgs('Accounts_AllowUsernameChange').returns(false); + + try { + await setUsernameWithValidation(userId, username); + } catch (error: any) { + expect(stubs.settings.get.calledOnce).to.be.true; + expect(error.message).to.equal('error-not-allowed'); + } + }); + + it('should throw an error if username is not valid', async () => { + stubs.Users.findOneById.resolves({ username: null }); + stubs.validateUsername.returns(false); + + try { + await setUsernameWithValidation(userId, 'invalid-username'); + } catch (error: any) { + expect(stubs.validateUsername.calledOnce).to.be.true; + expect(error.message).to.equal('username-invalid'); + } + }); + + it('should throw an error if username is already in use', async () => { + stubs.Users.findOneById.resolves({ username: null }); + stubs.validateUsername.returns(true); + stubs.checkUsernameAvailability.resolves(false); + + try { + await setUsernameWithValidation(userId, 'existingUsername'); + } catch (error: any) { + expect(stubs.checkUsernameAvailability.calledOnce).to.be.true; + expect(error.message).to.equal('error-field-unavailable'); + } + }); + + it('should save the user identity when valid username is set', async () => { + stubs.Users.findOneById.resolves({ _id: userId, username: null }); + stubs.settings.get.withArgs('Accounts_AllowUsernameChange').returns(true); + stubs.validateUsername.returns(true); + stubs.checkUsernameAvailability.resolves(true); + stubs.saveUserIdentity.resolves(true); + + await setUsernameWithValidation(userId, 'newUsername'); + + expect(stubs.saveUserIdentity.calledOnce).to.be.true; + expect(stubs.joinDefaultChannels.calledOnceWith(userId, undefined)).to.be.true; + }); + }); + + describe('_setUsername', () => { + it('should return false if userId or username is missing', async () => { + const result = await _setUsername(null, '', {}); + expect(result).to.be.false; + }); + + it('should return false if username is invalid', async () => { + stubs.validateUsername.returns(false); + + const result = await _setUsername(userId, 'invalid-username', {}); + expect(result).to.be.false; + }); + + it('should return user if username is already set', async () => { + stubs.validateUsername.returns(true); + const mockUser = { username }; + + const result = await _setUsername(userId, username, mockUser); + expect(result).to.equal(mockUser); + }); + + it('should set username when user has no previous username', async () => { + const mockUser = { _id: userId, emails: [{ address: 'test@example.com' }] }; + stubs.validateUsername.returns(true); + stubs.Users.findOneById.resolves(mockUser); + stubs.checkUsernameAvailability.resolves(true); + + await _setUsername(userId, username, mockUser); + + expect(stubs.Users.setUsername.calledOnceWith(userId, username)); + expect(stubs.checkUsernameAvailability.calledOnceWith(username)); + expect(stubs.api.broadcast.calledOnceWith('user.autoupdate', { user: mockUser })); + }); + + it('should set username when user has and old that is different from new', async () => { + const mockUser = { _id: userId, username: 'oldUsername', emails: [{ address: 'test@example.com' }] }; + stubs.validateUsername.returns(true); + stubs.Users.findOneById.resolves(mockUser); + stubs.checkUsernameAvailability.resolves(true); + + await _setUsername(userId, username, mockUser); + + expect(stubs.Users.setUsername.calledOnceWith(userId, username)); + expect(stubs.checkUsernameAvailability.calledOnceWith(username)); + expect(stubs.api.broadcast.calledOnceWith('user.autoupdate', { user: mockUser })); + }); + + it('should set username when user has and old that is different from new', async () => { + const mockUser = { _id: userId, username: 'oldUsername', emails: [{ address: 'test@example.com' }] }; + stubs.validateUsername.returns(true); + stubs.Users.findOneById.resolves(mockUser); + stubs.checkUsernameAvailability.resolves(true); + + await _setUsername(userId, username, mockUser); + + expect(stubs.Users.setUsername.calledOnceWith(userId, username)); + expect(stubs.checkUsernameAvailability.calledOnceWith(username)); + expect(stubs.api.broadcast.calledOnceWith('user.autoupdate', { user: mockUser })); + }); + + it('should set avatar if Accounts_SetDefaultAvatar is enabled', async () => { + const mockUser = { _id: userId, username: null }; + stubs.validateUsername.returns(true); + stubs.Users.findOneById.resolves(mockUser); + stubs.checkUsernameAvailability.resolves(true); + stubs.settings.get.withArgs('Accounts_SetDefaultAvatar').returns(true); + stubs.getAvatarSuggestionForUser.resolves({ gravatar: { blob: 'blobData', contentType: 'image/png' } }); + + await _setUsername(userId, username, mockUser); + + expect(stubs.setUserAvatar.calledOnceWith(mockUser, 'blobData', 'image/png', 'gravatar')).to.be.true; + }); + + it('should not set avatar if Accounts_SetDefaultAvatar is disabled', async () => { + const mockUser = { _id: userId, username: null }; + stubs.validateUsername.returns(true); + stubs.Users.findOneById.resolves(mockUser); + stubs.checkUsernameAvailability.resolves(true); + stubs.settings.get.withArgs('Accounts_SetDefaultAvatar').returns(false); + + await _setUsername(userId, username, mockUser); + + expect(stubs.setUserAvatar.called).to.be.false; + }); + + it('should not set avatar if no avatar suggestions are available', async () => { + const mockUser = { _id: userId, username: null }; + stubs.validateUsername.returns(true); + stubs.Users.findOneById.resolves(mockUser); + stubs.checkUsernameAvailability.resolves(true); + stubs.settings.get.withArgs('Accounts_SetDefaultAvatar').returns(true); + stubs.getAvatarSuggestionForUser.resolves({}); + + await _setUsername(userId, username, mockUser); + + expect(stubs.setUserAvatar.called).to.be.false; + }); + + it('should add user to room if inviteToken is present', async () => { + const mockUser = { _id: userId, username: null, inviteToken: 'invite token' }; + stubs.validateUsername.returns(true); + stubs.Users.findOneById.resolves(mockUser); + stubs.checkUsernameAvailability.resolves(true); + stubs.settings.get.withArgs('Accounts_SetDefaultAvatar').returns(true); + stubs.getAvatarSuggestionForUser.resolves({ gravatar: { blob: 'blobData', contentType: 'image/png' } }); + stubs.Invites.findOneById.resolves({ rid: 'room id' }); + + await _setUsername(userId, username, mockUser); + + expect(stubs.addUserToRoom.calledOnceWith('room id', mockUser)).to.be.true; + }); + }); +}); diff --git a/apps/meteor/tests/unit/app/reactions/server/setReaction.spec.ts b/apps/meteor/tests/unit/app/reactions/server/setReaction.spec.ts new file mode 100644 index 000000000000..e267825a6a18 --- /dev/null +++ b/apps/meteor/tests/unit/app/reactions/server/setReaction.spec.ts @@ -0,0 +1,428 @@ +import { expect } from 'chai'; +import { beforeEach, describe, it } from 'mocha'; +import p from 'proxyquire'; +import sinon from 'sinon'; + +const meteorMethodsMock = sinon.stub(); +const emojiList: Record = {}; +const modelsMock = { + EmojiCustom: { + countByNameOrAlias: sinon.stub(), + }, + Users: { + findOneById: sinon.stub(), + }, + Messages: { + findOneById: sinon.stub(), + setReactions: sinon.stub(), + unsetReactions: sinon.stub(), + }, + Rooms: { + findOneById: sinon.stub(), + unsetReactionsInLastMessage: sinon.stub(), + setReactionsInLastMessage: sinon.stub(), + }, +}; +const canAccessRoomAsyncMock = sinon.stub(); +const isTheLastMessageMock = sinon.stub(); +const notifyOnMessageChangeMock = sinon.stub(); +const hasPermissionAsyncMock = sinon.stub(); +const i18nMock = { t: sinon.stub() }; +const callbacksRunMock = sinon.stub(); +const meteorErrorMock = class extends Error { + constructor(message: string) { + super(message); + } +}; + +const { removeUserReaction, executeSetReaction, setReaction } = p.noCallThru().load('../../../../../app/reactions/server/setReaction.ts', { + '@rocket.chat/models': modelsMock, + '@rocket.chat/core-services': { Message: { beforeReacted: sinon.stub() } }, + 'meteor/meteor': { Meteor: { methods: meteorMethodsMock, Error: meteorErrorMock } }, + '../../../lib/callbacks': { callbacks: { run: callbacksRunMock } }, + '../../../server/lib/i18n': { i18n: i18nMock }, + '../../authorization/server': { canAccessRoomAsync: canAccessRoomAsyncMock }, + '../../authorization/server/functions/hasPermission': { hasPermissionAsync: hasPermissionAsyncMock }, + '../../emoji/server': { emoji: { list: emojiList } }, + '../../lib/server/functions/isTheLastMessage': { isTheLastMessage: isTheLastMessageMock }, + '../../lib/server/lib/notifyListener': { + notifyOnMessageChange: notifyOnMessageChangeMock, + }, +}); + +describe('Reactions', () => { + describe('removeUserReaction', () => { + it('should return the message unmodified when no reactions exist', () => { + const message = {}; + + const result = removeUserReaction(message as any, 'test', 'test'); + expect(result).to.equal(message); + }); + it('should remove the reaction from a message', () => { + const message = { + reactions: { + test: { + usernames: ['test', 'test2'], + }, + }, + }; + + const result = removeUserReaction(message as any, 'test', 'test'); + expect(result.reactions.test.usernames).to.not.include('test'); + expect(result.reactions.test.usernames).to.include('test2'); + }); + it('should remove the reaction from a message when the user is the last one on the array', () => { + const message = { + reactions: { + test: { + usernames: ['test'], + }, + }, + }; + + const result = removeUserReaction(message as any, 'test', 'test'); + expect(result.reactions.test).to.be.undefined; + }); + it('should remove username only from the reaction thats passed in', () => { + const message = { + reactions: { + test: { + usernames: ['test', 'test2'], + }, + other: { + usernames: ['test', 'test2'], + }, + }, + }; + + const result = removeUserReaction(message as any, 'test', 'test'); + expect(result.reactions.test.usernames).to.not.include('test'); + expect(result.reactions.test.usernames).to.include('test2'); + expect(result.reactions.other.usernames).to.include('test'); + expect(result.reactions.other.usernames).to.include('test2'); + }); + it('should do nothing if username is not in the reaction', () => { + const message = { + reactions: { + test: { + usernames: ['test', 'test2'], + }, + }, + }; + + const result = removeUserReaction(message as any, 'test', 'test3'); + expect(result.reactions.test.usernames).to.not.include('test3'); + expect(result.reactions.test.usernames).to.include('test'); + expect(result.reactions.test.usernames).to.include('test2'); + }); + }); + describe('executeSetReaction', () => { + beforeEach(() => { + modelsMock.EmojiCustom.countByNameOrAlias.reset(); + }); + it('should throw an error if reaction is not on emojione list', async () => { + modelsMock.EmojiCustom.countByNameOrAlias.resolves(0); + await expect(executeSetReaction('test', 'test', 'test')).to.be.rejectedWith('error-not-allowed'); + }); + it('should fail if user does not exist', async () => { + modelsMock.EmojiCustom.countByNameOrAlias.resolves(1); + await expect(executeSetReaction('test', 'test', 'test')).to.be.rejectedWith('error-invalid-user'); + }); + it('should fail if message does not exist', async () => { + modelsMock.EmojiCustom.countByNameOrAlias.resolves(1); + modelsMock.Users.findOneById.resolves({ username: 'test' }); + await expect(executeSetReaction('test', 'test', 'test')).to.be.rejectedWith('error-not-allowed'); + }); + it('should return nothing if user already reacted and its trying to react again', async () => { + modelsMock.EmojiCustom.countByNameOrAlias.resolves(1); + modelsMock.Users.findOneById.resolves({ username: 'test' }); + modelsMock.Messages.findOneById.resolves({ reactions: { ':test:': { usernames: ['test'] } } }); + expect(await executeSetReaction('test', 'test', 'test', true)).to.be.undefined; + }); + it('should return nothing if user hasnt reacted and its trying to unreact', async () => { + modelsMock.EmojiCustom.countByNameOrAlias.resolves(1); + modelsMock.Users.findOneById.resolves({ username: 'test' }); + modelsMock.Messages.findOneById.resolves({ reactions: { ':test:': { usernames: ['testxxxx'] } } }); + expect(await executeSetReaction('test', 'test', 'test', false)).to.be.undefined; + }); + it('should fail if room does not exist', async () => { + modelsMock.EmojiCustom.countByNameOrAlias.resolves(1); + modelsMock.Users.findOneById.resolves({ username: 'test' }); + modelsMock.Messages.findOneById.resolves({ reactions: { ':test:': { usernames: ['test'] } } }); + modelsMock.Rooms.findOneById.resolves(undefined); + await expect(executeSetReaction('test', 'test', 'test')).to.be.rejectedWith('error-not-allowed'); + }); + it('should fail if user doesnt have acccess to the room', async () => { + modelsMock.EmojiCustom.countByNameOrAlias.resolves(1); + modelsMock.Users.findOneById.resolves({ username: 'test' }); + modelsMock.Messages.findOneById.resolves({ reactions: { ':test:': { usernames: ['test'] } } }); + modelsMock.Rooms.findOneById.resolves({ t: 'd' }); + canAccessRoomAsyncMock.resolves(false); + await expect(executeSetReaction('test', 'test', 'test')).to.be.rejectedWith('not-authorized'); + }); + it('should call setReaction with correct params', async () => { + modelsMock.EmojiCustom.countByNameOrAlias.resolves(1); + modelsMock.Users.findOneById.resolves({ username: 'test' }); + modelsMock.Messages.findOneById.resolves({ reactions: { ':test:': { usernames: ['test'] } } }); + modelsMock.Rooms.findOneById.resolves({ t: 'c' }); + canAccessRoomAsyncMock.resolves(true); + + const res = await executeSetReaction('test', 'test', 'test'); + expect(res).to.be.undefined; + }); + it('should use the message from param when the type is not an string', async () => { + modelsMock.EmojiCustom.countByNameOrAlias.resolves(1); + modelsMock.Users.findOneById.resolves({ username: 'test' }); + modelsMock.Rooms.findOneById.resolves({ t: 'c' }); + canAccessRoomAsyncMock.resolves(true); + + await executeSetReaction('test', 'test', { reactions: { ':test:': { usernames: ['test'] } } }); + expect(modelsMock.Messages.findOneById.calledOnce).to.be.false; + }); + }); + describe('setReaction', () => { + beforeEach(() => { + canAccessRoomAsyncMock.reset(); + hasPermissionAsyncMock.reset(); + isTheLastMessageMock.reset(); + modelsMock.Messages.setReactions.reset(); + modelsMock.Rooms.setReactionsInLastMessage.reset(); + modelsMock.Rooms.unsetReactionsInLastMessage.reset(); + modelsMock.Messages.unsetReactions.reset(); + callbacksRunMock.reset(); + }); + it('should throw an error if user is muted from the room', async () => { + const room = { + muted: ['test'], + }; + const user = { + username: 'test', + }; + const message = { + _id: 'test', + }; + await expect(setReaction(room, user, message, ':test:')).to.be.rejectedWith('error-not-allowed'); + }); + it('should throw an error if room is readonly and cannot be reacted when readonly and user trying doesnt have permissions and user is not unmuted from room', async () => { + const room = { + ro: true, + reactWhenReadOnly: false, + }; + const user = { + username: 'test', + }; + const message = { + _id: 'test', + }; + canAccessRoomAsyncMock.resolves(false); + await expect(setReaction(room, user, message, ':test:')).to.be.rejectedWith("You can't send messages because the room is readonly."); + }); + it('should remove the user reaction if userAlreadyReacted is true and call unsetReaction if reaction is the last one on message', async () => { + const room = { + _id: 'test', + }; + const user = { + username: 'test', + }; + const message = { + _id: 'test', + reactions: { + ':test:': { + usernames: ['test'], + }, + }, + }; + const reaction = ':test:'; + + await setReaction(room, user, message, reaction, true); + expect(modelsMock.Messages.unsetReactions.calledWith(message._id)).to.be.true; + }); + it('should call Rooms.unsetReactionsInLastMessage when userAlreadyReacted is true and reaction is the last one on message', async () => { + const room = { + _id: 'test', + lastMessage: 'test', + }; + const user = { + username: 'test', + }; + const message = { + _id: 'test', + reactions: { + ':test:': { + usernames: ['test'], + }, + }, + }; + const reaction = ':test:'; + + isTheLastMessageMock.resolves(true); + + await setReaction(room, user, message, reaction, true); + expect(modelsMock.Messages.unsetReactions.calledWith(message._id)).to.be.true; + expect(modelsMock.Rooms.unsetReactionsInLastMessage.calledWith(room._id)).to.be.true; + }); + it('should update the reactions object when userAlreadyReacted is true and there is more reactions on message', async () => { + const room = { + _id: 'test', + }; + const user = { + username: 'test', + }; + const message = { + _id: 'test', + reactions: { + ':test:': { + usernames: ['test'], + }, + ':test2:': { + usernames: ['test'], + }, + }, + }; + const reaction = ':test:'; + + await setReaction(room, user, message, reaction, true); + expect(modelsMock.Messages.setReactions.calledWith(message._id, sinon.match({ ':test2:': { usernames: ['test'] } }))).to.be.true; + }); + it('should call Rooms.setReactionsInLastMessage when userAlreadyReacted is true and reaction is not the last one on message', async () => { + const room = { + _id: 'test', + lastMessage: 'test', + }; + const user = { + username: 'test', + }; + const message = { + _id: 'test', + reactions: { + ':test:': { + usernames: ['test'], + }, + ':test2:': { + usernames: ['test'], + }, + }, + }; + const reaction = ':test:'; + + isTheLastMessageMock.resolves(true); + + await setReaction(room, user, message, reaction, true); + expect(modelsMock.Messages.setReactions.calledWith(message._id, sinon.match({ ':test2:': { usernames: ['test'] } }))).to.be.true; + expect(modelsMock.Rooms.setReactionsInLastMessage.calledWith(room._id, sinon.match({ ':test2:': { usernames: ['test'] } }))).to.be + .true; + }); + it('should call afterUnsetReaction callback when userAlreadyReacted is true', async () => { + const room = { + _id: 'test', + }; + const user = { + username: 'test', + }; + const message = { + _id: 'test', + reactions: { + ':test:': { + usernames: ['test'], + }, + }, + }; + const reaction = ':test:'; + + await setReaction(room, user, message, reaction, true); + expect( + callbacksRunMock.calledWith( + 'afterUnsetReaction', + sinon.match({ _id: 'test' }), + sinon.match({ user, reaction, shouldReact: false, oldMessage: message }), + ), + ).to.be.true; + }); + it('should set reactions when userAlreadyReacted is false', async () => { + const room = { + _id: 'test', + }; + const user = { + username: 'test', + }; + const message = { + _id: 'test', + }; + const reaction = ':test:'; + await setReaction(room, user, message, reaction, false); + expect(modelsMock.Messages.setReactions.calledWith(message._id, sinon.match({ ':test:': { usernames: ['test'] } }))).to.be.true; + }); + it('should properly add username to the list of reactions when userAlreadyReacted is false', async () => { + const room = { + _id: 'test', + }; + const user = { + username: 'test2', + }; + const message = { + _id: 'test', + reactions: { + ':test:': { + usernames: ['test'], + }, + }, + }; + const reaction = ':test:'; + + await setReaction(room, user, message, reaction, false); + expect(modelsMock.Messages.setReactions.calledWith(message._id, sinon.match({ ':test:': { usernames: ['test', 'test2'] } }))).to.be + .true; + }); + it('should call Rooms.setReactionInLastMessage when userAlreadyReacted is false', async () => { + const room = { + _id: 'x5', + lastMessage: 'test', + }; + const user = { + username: 'test', + }; + const message = { + _id: 'test', + }; + const reaction = ':test:'; + + isTheLastMessageMock.resolves(true); + + await setReaction(room, user, message, reaction, false); + expect(modelsMock.Messages.setReactions.calledWith(message._id, sinon.match({ ':test:': { usernames: ['test'] } }))).to.be.true; + expect(modelsMock.Rooms.setReactionsInLastMessage.calledWith(room._id, sinon.match({ ':test:': { usernames: ['test'] } }))).to.be + .true; + }); + it('should call afterSetReaction callback when userAlreadyReacted is false', async () => { + const room = { + _id: 'test', + }; + const user = { + username: 'test', + }; + const message = { + _id: 'test', + }; + const reaction = ':test:'; + + await setReaction(room, user, message, reaction, false); + expect( + callbacksRunMock.calledWith('afterSetReaction', sinon.match({ _id: 'test' }), sinon.match({ user, reaction, shouldReact: true })), + ).to.be.true; + }); + it('should return undefined on a successful reaction', async () => { + const room = { + _id: 'test', + }; + const user = { + username: 'test', + }; + const message = { + _id: 'test', + }; + const reaction = ':test:'; + + expect(await setReaction(room, user, message, reaction, false)).to.be.undefined; + }); + }); +}); diff --git a/apps/uikit-playground/CHANGELOG.md b/apps/uikit-playground/CHANGELOG.md index 63a8eb47d7c6..d2793509ff54 100644 --- a/apps/uikit-playground/CHANGELOG.md +++ b/apps/uikit-playground/CHANGELOG.md @@ -1,5 +1,44 @@ # @rocket.chat/uikit-playground +## 0.5.0-rc.1 + +### Patch Changes + +-
      Updated dependencies []: + + - @rocket.chat/ui-contexts@11.0.0-rc.1 + - @rocket.chat/fuselage-ui-kit@11.0.0-rc.1 + - @rocket.chat/ui-avatar@7.0.0-rc.1 + - @rocket.chat/core-typings@6.13.0-rc.1 +
      + +## 0.5.0-rc.0 + +### Minor Changes + +- ([#32821](https://github.com/RocketChat/Rocket.Chat/pull/32821)) Replaced new `SidebarV2` components under feature preview + +### Patch Changes + +-
      Updated dependencies [599762739a, 274f4f5881, cd0d50016e, 78e6ba4820, 927710d778, 12d6307998]: + + - @rocket.chat/fuselage-ui-kit@11.0.0-rc.0 + - @rocket.chat/core-typings@6.13.0-rc.0 + - @rocket.chat/ui-avatar@7.0.0-rc.0 + - @rocket.chat/ui-contexts@11.0.0-rc.0 +
      + +## 0.4.1 + +### Patch Changes + +-
      Updated dependencies []: + + - @rocket.chat/fuselage-ui-kit@10.0.1 + - @rocket.chat/ui-contexts@10.0.1 + - @rocket.chat/ui-avatar@6.0.1 +
      + ## 0.4.0 ### Minor Changes diff --git a/apps/uikit-playground/package.json b/apps/uikit-playground/package.json index 233e71b719e5..82ebd11ee8dc 100644 --- a/apps/uikit-playground/package.json +++ b/apps/uikit-playground/package.json @@ -1,7 +1,7 @@ { "name": "@rocket.chat/uikit-playground", "private": true, - "version": "0.4.0", + "version": "0.5.0-rc.1", "type": "module", "scripts": { "dev": "vite", @@ -15,8 +15,9 @@ "@codemirror/lang-json": "^6.0.1", "@codemirror/tooltip": "^0.19.16", "@lezer/highlight": "^1.1.6", + "@rocket.chat/core-typings": "workspace:^", "@rocket.chat/css-in-js": "~0.31.25", - "@rocket.chat/fuselage": "^0.59.0", + "@rocket.chat/fuselage": "^0.59.1", "@rocket.chat/fuselage-hooks": "^0.33.1", "@rocket.chat/fuselage-polyfills": "~0.31.25", "@rocket.chat/fuselage-toastbar": "^0.33.0", @@ -38,19 +39,19 @@ "react-split-pane": "^0.1.92", "react-virtuoso": "^4.7.1", "reactflow": "^11.7.2", - "use-subscription": "^1.8.0" + "use-subscription": "^1.8.2" }, "devDependencies": { - "@types/react": "~17.0.69", - "@types/react-beautiful-dnd": "^13.1.6", - "@types/react-dom": "~17.0.22", - "@types/use-subscription": "^1.0.1", + "@types/react": "~17.0.80", + "@types/react-beautiful-dnd": "^13.1.8", + "@types/react-dom": "~17.0.25", + "@types/use-subscription": "^1.0.2", "@typescript-eslint/eslint-plugin": "~5.60.1", "@typescript-eslint/parser": "~5.60.1", "@vitejs/plugin-react": "^4.0.0", "eslint": "~8.45.0", - "eslint-plugin-react-hooks": "^4.6.0", - "eslint-plugin-react-refresh": "^0.4.4", + "eslint-plugin-react-hooks": "^4.6.2", + "eslint-plugin-react-refresh": "^0.4.11", "typescript": "~5.5.4", "vite": "^4.3.9" }, diff --git a/apps/uikit-playground/vite.config.ts b/apps/uikit-playground/vite.config.ts index 61a5ab30e647..4d382d652859 100644 --- a/apps/uikit-playground/vite.config.ts +++ b/apps/uikit-playground/vite.config.ts @@ -7,11 +7,11 @@ export default defineConfig(() => ({ esbuild: {}, plugins: [react()], optimizeDeps: { - include: ['@rocket.chat/ui-contexts', '@rocket.chat/message-parser'], + include: ['@rocket.chat/ui-contexts', '@rocket.chat/message-parser', '@rocket.chat/core-typings'], }, build: { commonjsOptions: { - include: [/ui-contexts/, /message-parser/, /node_modules/], + include: [/ui-contexts/, /core-typings/, /message-parser/, /node_modules/], }, }, })); diff --git a/docker-compose-ci.yml b/docker-compose-ci.yml index 23ee3b125524..0fe101c8fab1 100644 --- a/docker-compose-ci.yml +++ b/docker-compose-ci.yml @@ -8,6 +8,8 @@ services: build: dockerfile: ${RC_DOCKERFILE} context: /tmp/build + args: + DENO_VERSION: ${DENO_VERSION} image: ghcr.io/${LOWERCASE_REPOSITORY}/rocket.chat:${RC_DOCKER_TAG} environment: - TEST_MODE=true @@ -39,6 +41,7 @@ services: context: . args: SERVICE: authorization-service + DENO_VERSION: ${DENO_VERSION} image: ghcr.io/${LOWERCASE_REPOSITORY}/authorization-service:${DOCKER_TAG} environment: - 'MONGO_URL=${MONGO_URL}' @@ -73,6 +76,7 @@ services: context: . args: SERVICE: presence-service + DENO_VERSION: ${DENO_VERSION} image: ghcr.io/${LOWERCASE_REPOSITORY}/presence-service:${DOCKER_TAG} environment: - MONGO_URL=${MONGO_URL} diff --git a/ee/apps/account-service/CHANGELOG.md b/ee/apps/account-service/CHANGELOG.md index 8d306d7b1c17..4c86f21b24c4 100644 --- a/ee/apps/account-service/CHANGELOG.md +++ b/ee/apps/account-service/CHANGELOG.md @@ -1,5 +1,44 @@ # @rocket.chat/account-service +## 0.4.8-rc.1 + +### Patch Changes + +-
      Updated dependencies []: + + - @rocket.chat/core-typings@6.13.0-rc.1 + - @rocket.chat/rest-typings@6.13.0-rc.1 + - @rocket.chat/core-services@0.7.0-rc.1 + - @rocket.chat/model-typings@0.8.0-rc.1 + - @rocket.chat/models@0.3.0-rc.1 +
      + +## 0.4.8-rc.0 + +### Patch Changes + +-
      Updated dependencies [9a38c8e13f, 9eaefdc892, 274f4f5881, 532f08819e, 927710d778, 3a161c4310, 12d6307998]: + + - @rocket.chat/model-typings@0.8.0-rc.0 + - @rocket.chat/rest-typings@6.13.0-rc.0 + - @rocket.chat/core-typings@6.13.0-rc.0 + - @rocket.chat/core-services@0.7.0-rc.0 + - @rocket.chat/models@0.3.0-rc.0 +
      + +## 0.4.7 + +### Patch Changes + +-
      Updated dependencies []: + + - @rocket.chat/core-services@0.6.1 + - @rocket.chat/core-typings@6.12.1 + - @rocket.chat/rest-typings@6.12.1 + - @rocket.chat/model-typings@0.7.1 + - @rocket.chat/models@0.2.4 +
      + ## 0.4.6 ### Patch Changes diff --git a/ee/apps/account-service/Dockerfile b/ee/apps/account-service/Dockerfile index acbc5b0371d2..a97290a43394 100644 --- a/ee/apps/account-service/Dockerfile +++ b/ee/apps/account-service/Dockerfile @@ -7,6 +7,10 @@ WORKDIR /app COPY ./packages/core-services/package.json packages/core-services/package.json COPY ./packages/core-services/dist packages/core-services/dist +COPY ./packages/apps-engine/package.json packages/apps-engine/package.json +COPY ./packages/apps-engine/client packages/apps-engine/client +COPY ./packages/apps-engine/definition packages/apps-engine/definition + COPY ./packages/agenda/package.json packages/agenda/package.json COPY ./packages/agenda/dist packages/agenda/dist @@ -37,6 +41,9 @@ COPY ./packages/logger/dist packages/logger/dist COPY ./packages/server-cloud-communication/ packages/server-cloud-communication/ +COPY ./ee/packages/network-broker/package.json ee/packages/network-broker/package.json +COPY ./ee/packages/network-broker/dist ee/packages/network-broker/dist + COPY ./ee/packages/license/package.json packages/license/package.json COPY ./ee/packages/license/dist packages/license/dist diff --git a/ee/apps/account-service/package.json b/ee/apps/account-service/package.json index 8143df3577a7..755327bfd874 100644 --- a/ee/apps/account-service/package.json +++ b/ee/apps/account-service/package.json @@ -1,7 +1,7 @@ { "name": "@rocket.chat/account-service", "private": true, - "version": "0.4.6", + "version": "0.4.8-rc.1", "description": "Rocket.Chat Account service", "scripts": { "build": "tsc -p tsconfig.json", @@ -20,6 +20,7 @@ "@rocket.chat/emitter": "~0.31.25", "@rocket.chat/model-typings": "workspace:^", "@rocket.chat/models": "workspace:^", + "@rocket.chat/network-broker": "workspace:^", "@rocket.chat/rest-typings": "workspace:^", "@rocket.chat/string-helpers": "~0.31.25", "@rocket.chat/tools": "workspace:^", @@ -29,9 +30,9 @@ "event-loop-stats": "^1.4.1", "eventemitter3": "^4.0.7", "fibers": "^5.0.3", - "gc-stats": "^1.4.0", + "gc-stats": "^1.4.1", "mem": "^8.1.1", - "moleculer": "^0.14.31", + "moleculer": "^0.14.34", "mongodb": "^4.17.2", "nats": "^2.4.0", "pino": "^8.15.0", @@ -40,11 +41,11 @@ }, "devDependencies": { "@rocket.chat/eslint-config": "workspace:^", - "@types/bcrypt": "^5.0.1", - "@types/gc-stats": "^1.4.2", - "@types/polka": "^0.5.6", + "@types/bcrypt": "^5.0.2", + "@types/gc-stats": "^1.4.3", + "@types/polka": "^0.5.7", "eslint": "~8.45.0", - "ts-node": "^10.9.1", + "ts-node": "^10.9.2", "typescript": "~5.5.4" }, "main": "./dist/ee/apps/account-service/src/service.js", diff --git a/ee/apps/account-service/src/service.ts b/ee/apps/account-service/src/service.ts index f166233ca137..c2f64e37bde3 100755 --- a/ee/apps/account-service/src/service.ts +++ b/ee/apps/account-service/src/service.ts @@ -1,19 +1,15 @@ -import { api } from '@rocket.chat/core-services'; -import type { Document } from 'mongodb'; +import { api, getConnection, getTrashCollection } from '@rocket.chat/core-services'; +import { broker } from '@rocket.chat/network-broker'; import polka from 'polka'; import { registerServiceModels } from '../../../../apps/meteor/ee/server/lib/registerServiceModels'; -import { Collections, getCollection, getConnection } from '../../../../apps/meteor/ee/server/services/mongo'; -import { broker } from '../../../../apps/meteor/ee/server/startup/broker'; const PORT = process.env.PORT || 3033; (async () => { const db = await getConnection(); - const trash = await getCollection(Collections.Trash); - - registerServiceModels(db, trash); + registerServiceModels(db, await getTrashCollection()); api.setBroker(broker); diff --git a/ee/apps/authorization-service/CHANGELOG.md b/ee/apps/authorization-service/CHANGELOG.md index 494c052e8cf8..c587761cdb38 100644 --- a/ee/apps/authorization-service/CHANGELOG.md +++ b/ee/apps/authorization-service/CHANGELOG.md @@ -1,5 +1,44 @@ # @rocket.chat/authorization-service +## 0.4.8-rc.1 + +### Patch Changes + +-
      Updated dependencies []: + + - @rocket.chat/core-typings@6.13.0-rc.1 + - @rocket.chat/rest-typings@6.13.0-rc.1 + - @rocket.chat/core-services@0.7.0-rc.1 + - @rocket.chat/model-typings@0.8.0-rc.1 + - @rocket.chat/models@0.3.0-rc.1 +
      + +## 0.4.8-rc.0 + +### Patch Changes + +-
      Updated dependencies [9a38c8e13f, 9eaefdc892, 274f4f5881, 532f08819e, 927710d778, 3a161c4310, 12d6307998]: + + - @rocket.chat/model-typings@0.8.0-rc.0 + - @rocket.chat/rest-typings@6.13.0-rc.0 + - @rocket.chat/core-typings@6.13.0-rc.0 + - @rocket.chat/core-services@0.7.0-rc.0 + - @rocket.chat/models@0.3.0-rc.0 +
      + +## 0.4.7 + +### Patch Changes + +-
      Updated dependencies []: + + - @rocket.chat/core-services@0.6.1 + - @rocket.chat/core-typings@6.12.1 + - @rocket.chat/rest-typings@6.12.1 + - @rocket.chat/model-typings@0.7.1 + - @rocket.chat/models@0.2.4 +
      + ## 0.4.6 ### Patch Changes diff --git a/ee/apps/authorization-service/Dockerfile b/ee/apps/authorization-service/Dockerfile index c662d8765300..9ddeadd380fe 100644 --- a/ee/apps/authorization-service/Dockerfile +++ b/ee/apps/authorization-service/Dockerfile @@ -7,6 +7,10 @@ WORKDIR /app COPY ./packages/core-services/package.json packages/core-services/package.json COPY ./packages/core-services/dist packages/core-services/dist +COPY ./packages/apps-engine/package.json packages/apps-engine/package.json +COPY ./packages/apps-engine/client packages/apps-engine/client +COPY ./packages/apps-engine/definition packages/apps-engine/definition + COPY ./packages/agenda/package.json packages/agenda/package.json COPY ./packages/agenda/dist packages/agenda/dist @@ -37,6 +41,9 @@ COPY ./packages/logger/dist packages/logger/dist COPY ./packages/server-cloud-communication/ packages/server-cloud-communication/ +COPY ./ee/packages/network-broker/package.json ee/packages/network-broker/package.json +COPY ./ee/packages/network-broker/dist ee/packages/network-broker/dist + COPY ./ee/packages/license/package.json packages/license/package.json COPY ./ee/packages/license/dist packages/license/dist diff --git a/ee/apps/authorization-service/package.json b/ee/apps/authorization-service/package.json index c4c0ec82f507..e9de2c4acd74 100644 --- a/ee/apps/authorization-service/package.json +++ b/ee/apps/authorization-service/package.json @@ -1,7 +1,7 @@ { "name": "@rocket.chat/authorization-service", "private": true, - "version": "0.4.6", + "version": "0.4.8-rc.1", "description": "Rocket.Chat Authorization service", "scripts": { "build": "tsc -p tsconfig.json", @@ -20,6 +20,7 @@ "@rocket.chat/emitter": "~0.31.25", "@rocket.chat/model-typings": "workspace:^", "@rocket.chat/models": "workspace:^", + "@rocket.chat/network-broker": "workspace:^", "@rocket.chat/rest-typings": "workspace:^", "@rocket.chat/string-helpers": "~0.31.25", "@types/node": "^14.18.63", @@ -27,9 +28,9 @@ "event-loop-stats": "^1.4.1", "eventemitter3": "^4.0.7", "fibers": "^5.0.3", - "gc-stats": "^1.4.0", + "gc-stats": "^1.4.1", "mem": "^8.1.1", - "moleculer": "^0.14.31", + "moleculer": "^0.14.34", "mongodb": "^4.17.2", "nats": "^2.4.0", "pino": "^8.15.0", @@ -37,10 +38,10 @@ }, "devDependencies": { "@rocket.chat/eslint-config": "workspace:^", - "@types/gc-stats": "^1.4.2", - "@types/polka": "^0.5.6", + "@types/gc-stats": "^1.4.3", + "@types/polka": "^0.5.7", "eslint": "~8.45.0", - "ts-node": "^10.9.1", + "ts-node": "^10.9.2", "typescript": "~5.5.4" }, "main": "./dist/ee/apps/authorization-service/src/service.js", diff --git a/ee/apps/authorization-service/src/service.ts b/ee/apps/authorization-service/src/service.ts index 29162b636229..1698ef7a115c 100755 --- a/ee/apps/authorization-service/src/service.ts +++ b/ee/apps/authorization-service/src/service.ts @@ -1,19 +1,15 @@ -import { api } from '@rocket.chat/core-services'; -import type { Document } from 'mongodb'; +import { api, getConnection, getTrashCollection } from '@rocket.chat/core-services'; +import { broker } from '@rocket.chat/network-broker'; import polka from 'polka'; import { registerServiceModels } from '../../../../apps/meteor/ee/server/lib/registerServiceModels'; -import { Collections, getCollection, getConnection } from '../../../../apps/meteor/ee/server/services/mongo'; -import { broker } from '../../../../apps/meteor/ee/server/startup/broker'; const PORT = process.env.PORT || 3034; (async () => { const db = await getConnection(); - const trash = await getCollection(Collections.Trash); - - registerServiceModels(db, trash); + registerServiceModels(db, await getTrashCollection()); api.setBroker(broker); diff --git a/ee/apps/ddp-streamer/CHANGELOG.md b/ee/apps/ddp-streamer/CHANGELOG.md index 97565b8bde88..975b9af1abf2 100644 --- a/ee/apps/ddp-streamer/CHANGELOG.md +++ b/ee/apps/ddp-streamer/CHANGELOG.md @@ -1,5 +1,47 @@ # @rocket.chat/ddp-streamer +## 0.3.8-rc.1 + +### Patch Changes + +-
      Updated dependencies []: + + - @rocket.chat/core-typings@6.13.0-rc.1 + - @rocket.chat/rest-typings@6.13.0-rc.1 + - @rocket.chat/core-services@0.7.0-rc.1 + - @rocket.chat/model-typings@0.8.0-rc.1 + - @rocket.chat/models@0.3.0-rc.1 + - @rocket.chat/instance-status@0.1.8-rc.1 +
      + +## 0.3.8-rc.0 + +### Patch Changes + +-
      Updated dependencies [9a38c8e13f, 9eaefdc892, 274f4f5881, 532f08819e, 927710d778, 3a161c4310, 12d6307998]: + + - @rocket.chat/model-typings@0.8.0-rc.0 + - @rocket.chat/rest-typings@6.13.0-rc.0 + - @rocket.chat/core-typings@6.13.0-rc.0 + - @rocket.chat/core-services@0.7.0-rc.0 + - @rocket.chat/models@0.3.0-rc.0 + - @rocket.chat/instance-status@0.1.7-rc.0 +
      + +## 0.3.7 + +### Patch Changes + +-
      Updated dependencies []: + + - @rocket.chat/core-services@0.6.1 + - @rocket.chat/core-typings@6.12.1 + - @rocket.chat/rest-typings@6.12.1 + - @rocket.chat/model-typings@0.7.1 + - @rocket.chat/models@0.2.4 + - @rocket.chat/instance-status@0.1.7 +
      + ## 0.3.6 ### Patch Changes diff --git a/ee/apps/ddp-streamer/Dockerfile b/ee/apps/ddp-streamer/Dockerfile index f556cbde6752..dea2bc3790a1 100644 --- a/ee/apps/ddp-streamer/Dockerfile +++ b/ee/apps/ddp-streamer/Dockerfile @@ -7,6 +7,10 @@ WORKDIR /app COPY ./packages/core-services/package.json packages/core-services/package.json COPY ./packages/core-services/dist packages/core-services/dist +COPY ./packages/apps-engine/package.json packages/apps-engine/package.json +COPY ./packages/apps-engine/client packages/apps-engine/client +COPY ./packages/apps-engine/definition packages/apps-engine/definition + COPY ./packages/agenda/package.json packages/agenda/package.json COPY ./packages/agenda/dist packages/agenda/dist @@ -40,6 +44,9 @@ COPY ./packages/logger/dist packages/logger/dist COPY ./packages/server-cloud-communication/ packages/server-cloud-communication/ +COPY ./ee/packages/network-broker/package.json ee/packages/network-broker/package.json +COPY ./ee/packages/network-broker/dist ee/packages/network-broker/dist + COPY ./ee/packages/license/package.json packages/license/package.json COPY ./ee/packages/license/dist packages/license/dist diff --git a/ee/apps/ddp-streamer/package.json b/ee/apps/ddp-streamer/package.json index be56cb76469c..f250b9e33106 100644 --- a/ee/apps/ddp-streamer/package.json +++ b/ee/apps/ddp-streamer/package.json @@ -1,7 +1,7 @@ { "name": "@rocket.chat/ddp-streamer", "private": true, - "version": "0.3.6", + "version": "0.3.8-rc.1", "description": "Rocket.Chat DDP-Streamer service", "scripts": { "build": "tsc -p tsconfig.json", @@ -15,7 +15,6 @@ ], "author": "Rocket.Chat", "dependencies": { - "@rocket.chat/apps-engine": "1.45.0-alpha.868", "@rocket.chat/core-services": "workspace:^", "@rocket.chat/core-typings": "workspace:^", "@rocket.chat/emitter": "~0.31.25", @@ -23,6 +22,7 @@ "@rocket.chat/logger": "workspace:^", "@rocket.chat/model-typings": "workspace:^", "@rocket.chat/models": "workspace:^", + "@rocket.chat/network-broker": "workspace:^", "@rocket.chat/rest-typings": "workspace:^", "@rocket.chat/string-helpers": "~0.31.25", "colorette": "^1.4.0", @@ -30,33 +30,34 @@ "event-loop-stats": "^1.4.1", "eventemitter3": "^4.0.7", "fibers": "^5.0.3", - "gc-stats": "^1.4.0", + "gc-stats": "^1.4.1", "jaeger-client": "^3.19.0", "mem": "^8.1.1", - "moleculer": "^0.14.31", + "moleculer": "^0.14.34", "mongodb": "^4.17.2", "nats": "^2.4.0", "pino": "^8.15.0", "polka": "^0.5.2", "sharp": "^0.32.6", - "underscore": "^1.13.6", + "underscore": "^1.13.7", "uuid": "^7.0.3", "ws": "^8.8.1" }, "devDependencies": { + "@rocket.chat/apps-engine": "workspace:^", "@rocket.chat/ddp-client": "workspace:~", "@rocket.chat/eslint-config": "workspace:^", - "@types/ejson": "^2.2.1", - "@types/gc-stats": "^1.4.2", + "@types/ejson": "^2.2.2", + "@types/gc-stats": "^1.4.3", "@types/meteor": "^2.9.8", "@types/node": "^14.18.63", - "@types/polka": "^0.5.6", + "@types/polka": "^0.5.7", "@types/sharp": "^0.30.5", "@types/uuid": "^8.3.4", - "@types/ws": "^8.5.8", + "@types/ws": "^8.5.12", "eslint": "~8.45.0", "pino-pretty": "^7.6.1", - "ts-node": "^10.9.1", + "ts-node": "^10.9.2", "typescript": "~5.5.4" }, "main": "./dist/service.js", diff --git a/ee/apps/ddp-streamer/src/service.ts b/ee/apps/ddp-streamer/src/service.ts index b5cd20f7ec02..58552240cadd 100755 --- a/ee/apps/ddp-streamer/src/service.ts +++ b/ee/apps/ddp-streamer/src/service.ts @@ -1,16 +1,12 @@ -import { api } from '@rocket.chat/core-services'; -import type { Document } from 'mongodb'; +import { api, getConnection, getTrashCollection } from '@rocket.chat/core-services'; +import { broker } from '@rocket.chat/network-broker'; import { registerServiceModels } from '../../../../apps/meteor/ee/server/lib/registerServiceModels'; -import { Collections, getCollection, getConnection } from '../../../../apps/meteor/ee/server/services/mongo'; -import { broker } from '../../../../apps/meteor/ee/server/startup/broker'; (async () => { const db = await getConnection(); - const trash = await getCollection(Collections.Trash); - - registerServiceModels(db, trash); + registerServiceModels(db, await getTrashCollection()); api.setBroker(broker); diff --git a/ee/apps/omnichannel-transcript/CHANGELOG.md b/ee/apps/omnichannel-transcript/CHANGELOG.md index 21fc879f9fd9..613d6ef3f377 100644 --- a/ee/apps/omnichannel-transcript/CHANGELOG.md +++ b/ee/apps/omnichannel-transcript/CHANGELOG.md @@ -1,5 +1,47 @@ # @rocket.chat/omnichannel-transcript +## 0.4.8-rc.1 + +### Patch Changes + +-
      Updated dependencies []: + + - @rocket.chat/core-typings@6.13.0-rc.1 + - @rocket.chat/omnichannel-services@0.3.5-rc.1 + - @rocket.chat/pdf-worker@0.2.5-rc.1 + - @rocket.chat/core-services@0.7.0-rc.1 + - @rocket.chat/model-typings@0.8.0-rc.1 + - @rocket.chat/models@0.3.0-rc.1 +
      + +## 0.4.8-rc.0 + +### Patch Changes + +-
      Updated dependencies [9a38c8e13f, 274f4f5881, 532f08819e, 927710d778, 3a161c4310, 12d6307998]: + + - @rocket.chat/model-typings@0.8.0-rc.0 + - @rocket.chat/core-typings@6.13.0-rc.0 + - @rocket.chat/core-services@0.7.0-rc.0 + - @rocket.chat/models@0.3.0-rc.0 + - @rocket.chat/omnichannel-services@0.3.4-rc.0 + - @rocket.chat/pdf-worker@0.2.4-rc.0 +
      + +## 0.4.7 + +### Patch Changes + +-
      Updated dependencies []: + + - @rocket.chat/core-services@0.6.1 + - @rocket.chat/core-typings@6.12.1 + - @rocket.chat/omnichannel-services@0.3.4 + - @rocket.chat/pdf-worker@0.2.4 + - @rocket.chat/model-typings@0.7.1 + - @rocket.chat/models@0.2.4 +
      + ## 0.4.6 ### Patch Changes diff --git a/ee/apps/omnichannel-transcript/Dockerfile b/ee/apps/omnichannel-transcript/Dockerfile index 6a93a8e5e8be..0f18534e1453 100644 --- a/ee/apps/omnichannel-transcript/Dockerfile +++ b/ee/apps/omnichannel-transcript/Dockerfile @@ -7,6 +7,10 @@ WORKDIR /app COPY ./packages/core-services/package.json packages/core-services/package.json COPY ./packages/core-services/dist packages/core-services/dist +COPY ./packages/apps-engine/package.json packages/apps-engine/package.json +COPY ./packages/apps-engine/client packages/apps-engine/client +COPY ./packages/apps-engine/definition packages/apps-engine/definition + COPY ./packages/agenda/package.json packages/agenda/package.json COPY ./packages/agenda/dist packages/agenda/dist @@ -37,6 +41,9 @@ COPY ./packages/logger/dist packages/logger/dist COPY ./packages/server-cloud-communication/ packages/server-cloud-communication/ +COPY ./ee/packages/network-broker/package.json ee/packages/network-broker/package.json +COPY ./ee/packages/network-broker/dist ee/packages/network-broker/dist + COPY ./ee/packages/license/package.json packages/license/package.json COPY ./ee/packages/license/dist packages/license/dist diff --git a/ee/apps/omnichannel-transcript/package.json b/ee/apps/omnichannel-transcript/package.json index 0a8ba5e9f8cc..4094ada4ef18 100644 --- a/ee/apps/omnichannel-transcript/package.json +++ b/ee/apps/omnichannel-transcript/package.json @@ -1,7 +1,7 @@ { "name": "@rocket.chat/omnichannel-transcript", "private": true, - "version": "0.4.6", + "version": "0.4.8-rc.1", "description": "Rocket.Chat service", "scripts": { "build": "tsc -p tsconfig.json", @@ -22,6 +22,7 @@ "@rocket.chat/logger": "workspace:^", "@rocket.chat/model-typings": "workspace:^", "@rocket.chat/models": "workspace:^", + "@rocket.chat/network-broker": "workspace:^", "@rocket.chat/omnichannel-services": "workspace:^", "@rocket.chat/pdf-worker": "workspace:^", "@rocket.chat/tools": "workspace:^", @@ -31,10 +32,10 @@ "event-loop-stats": "^1.4.1", "eventemitter3": "^4.0.7", "fibers": "^5.0.3", - "gc-stats": "^1.4.0", + "gc-stats": "^1.4.1", "mem": "^8.1.1", - "moleculer": "^0.14.31", - "moment-timezone": "^0.5.43", + "moleculer": "^0.14.34", + "moment-timezone": "^0.5.45", "mongo-message-queue": "^1.0.0", "mongodb": "^4.17.2", "nats": "^2.4.0", @@ -43,10 +44,10 @@ }, "devDependencies": { "@rocket.chat/eslint-config": "workspace:^", - "@types/gc-stats": "^1.4.2", - "@types/polka": "^0.5.6", + "@types/gc-stats": "^1.4.3", + "@types/polka": "^0.5.7", "eslint": "~8.45.0", - "ts-node": "^10.9.1", + "ts-node": "^10.9.2", "typescript": "~5.5.4" }, "main": "./dist/ee/apps/omnichannel-transcript/src/service.js", diff --git a/ee/apps/omnichannel-transcript/src/service.ts b/ee/apps/omnichannel-transcript/src/service.ts index 14cbc5b8438a..ad60687d5ba4 100644 --- a/ee/apps/omnichannel-transcript/src/service.ts +++ b/ee/apps/omnichannel-transcript/src/service.ts @@ -1,20 +1,16 @@ -import { api } from '@rocket.chat/core-services'; +import { api, getConnection, getTrashCollection } from '@rocket.chat/core-services'; import { Logger } from '@rocket.chat/logger'; -import type { Document } from 'mongodb'; +import { broker } from '@rocket.chat/network-broker'; import polka from 'polka'; import { registerServiceModels } from '../../../../apps/meteor/ee/server/lib/registerServiceModels'; -import { Collections, getCollection, getConnection } from '../../../../apps/meteor/ee/server/services/mongo'; -import { broker } from '../../../../apps/meteor/ee/server/startup/broker'; const PORT = process.env.PORT || 3036; (async () => { const db = await getConnection(); - const trash = await getCollection(Collections.Trash); - - registerServiceModels(db, trash); + registerServiceModels(db, await getTrashCollection()); api.setBroker(broker); diff --git a/ee/apps/presence-service/CHANGELOG.md b/ee/apps/presence-service/CHANGELOG.md index a1ed37bb6fde..795b6a2df283 100644 --- a/ee/apps/presence-service/CHANGELOG.md +++ b/ee/apps/presence-service/CHANGELOG.md @@ -1,5 +1,44 @@ # @rocket.chat/presence-service +## 0.4.8-rc.1 + +### Patch Changes + +-
      Updated dependencies []: + + - @rocket.chat/core-typings@6.13.0-rc.1 + - @rocket.chat/presence@0.2.8-rc.1 + - @rocket.chat/core-services@0.7.0-rc.1 + - @rocket.chat/model-typings@0.8.0-rc.1 + - @rocket.chat/models@0.3.0-rc.1 +
      + +## 0.4.8-rc.0 + +### Patch Changes + +-
      Updated dependencies [9a38c8e13f, 274f4f5881, 532f08819e, 927710d778, 3a161c4310, 12d6307998]: + + - @rocket.chat/model-typings@0.8.0-rc.0 + - @rocket.chat/core-typings@6.13.0-rc.0 + - @rocket.chat/core-services@0.7.0-rc.0 + - @rocket.chat/models@0.3.0-rc.0 + - @rocket.chat/presence@0.2.7-rc.0 +
      + +## 0.4.7 + +### Patch Changes + +-
      Updated dependencies []: + + - @rocket.chat/core-services@0.6.1 + - @rocket.chat/core-typings@6.12.1 + - @rocket.chat/presence@0.2.7 + - @rocket.chat/model-typings@0.7.1 + - @rocket.chat/models@0.2.4 +
      + ## 0.4.6 ### Patch Changes diff --git a/ee/apps/presence-service/Dockerfile b/ee/apps/presence-service/Dockerfile index aa9c1c0bd6c9..78c6a98f809a 100644 --- a/ee/apps/presence-service/Dockerfile +++ b/ee/apps/presence-service/Dockerfile @@ -13,6 +13,10 @@ COPY ./packages/agenda/dist packages/agenda/dist COPY ./packages/core-services/package.json packages/core-services/package.json COPY ./packages/core-services/dist packages/core-services/dist +COPY ./packages/apps-engine/package.json packages/apps-engine/package.json +COPY ./packages/apps-engine/client packages/apps-engine/client +COPY ./packages/apps-engine/definition packages/apps-engine/definition + COPY ./packages/core-typings/package.json packages/core-typings/package.json COPY ./packages/core-typings/dist packages/core-typings/dist @@ -40,6 +44,9 @@ COPY ./packages/logger/dist packages/logger/dist COPY ./packages/server-cloud-communication/ packages/server-cloud-communication/ +COPY ./ee/packages/network-broker/package.json ee/packages/network-broker/package.json +COPY ./ee/packages/network-broker/dist ee/packages/network-broker/dist + COPY ./ee/packages/license/package.json packages/license/package.json COPY ./ee/packages/license/dist packages/license/dist diff --git a/ee/apps/presence-service/package.json b/ee/apps/presence-service/package.json index 5ab5a6238266..736fffd09e49 100644 --- a/ee/apps/presence-service/package.json +++ b/ee/apps/presence-service/package.json @@ -1,7 +1,7 @@ { "name": "@rocket.chat/presence-service", "private": true, - "version": "0.4.6", + "version": "0.4.8-rc.1", "description": "Rocket.Chat Presence service", "scripts": { "build": "tsc -p tsconfig.json", @@ -20,6 +20,7 @@ "@rocket.chat/emitter": "~0.31.25", "@rocket.chat/model-typings": "workspace:^", "@rocket.chat/models": "workspace:^", + "@rocket.chat/network-broker": "workspace:^", "@rocket.chat/presence": "workspace:^", "@rocket.chat/string-helpers": "~0.31.25", "@types/node": "^14.18.63", @@ -27,9 +28,9 @@ "event-loop-stats": "^1.4.1", "eventemitter3": "^4.0.7", "fibers": "^5.0.3", - "gc-stats": "^1.4.0", + "gc-stats": "^1.4.1", "mem": "^8.1.1", - "moleculer": "^0.14.31", + "moleculer": "^0.14.34", "mongodb": "^4.17.2", "nats": "^2.4.0", "pino": "^8.15.0", @@ -37,10 +38,10 @@ }, "devDependencies": { "@rocket.chat/eslint-config": "workspace:^", - "@types/gc-stats": "^1.4.2", - "@types/polka": "^0.5.6", + "@types/gc-stats": "^1.4.3", + "@types/polka": "^0.5.7", "eslint": "~8.45.0", - "ts-node": "^10.9.1", + "ts-node": "^10.9.2", "typescript": "~5.5.4" }, "main": "./dist/ee/apps/presence-service/src/service.js", diff --git a/ee/apps/presence-service/src/service.ts b/ee/apps/presence-service/src/service.ts index b7275a29106f..0c51c30dc577 100755 --- a/ee/apps/presence-service/src/service.ts +++ b/ee/apps/presence-service/src/service.ts @@ -1,19 +1,15 @@ -import { api } from '@rocket.chat/core-services'; -import type { Document } from 'mongodb'; +import { api, getConnection, getTrashCollection } from '@rocket.chat/core-services'; +import { broker } from '@rocket.chat/network-broker'; import polka from 'polka'; import { registerServiceModels } from '../../../../apps/meteor/ee/server/lib/registerServiceModels'; -import { Collections, getCollection, getConnection } from '../../../../apps/meteor/ee/server/services/mongo'; -import { broker } from '../../../../apps/meteor/ee/server/startup/broker'; const PORT = process.env.PORT || 3031; (async () => { const db = await getConnection(); - const trash = await getCollection(Collections.Trash); - - registerServiceModels(db, trash); + registerServiceModels(db, await getTrashCollection()); api.setBroker(broker); diff --git a/ee/apps/queue-worker/CHANGELOG.md b/ee/apps/queue-worker/CHANGELOG.md index 16dc2590f38b..cca63889589c 100644 --- a/ee/apps/queue-worker/CHANGELOG.md +++ b/ee/apps/queue-worker/CHANGELOG.md @@ -1,5 +1,44 @@ # @rocket.chat/queue-worker +## 0.4.8-rc.1 + +### Patch Changes + +-
      Updated dependencies []: + + - @rocket.chat/core-typings@6.13.0-rc.1 + - @rocket.chat/omnichannel-services@0.3.5-rc.1 + - @rocket.chat/core-services@0.7.0-rc.1 + - @rocket.chat/model-typings@0.8.0-rc.1 + - @rocket.chat/models@0.3.0-rc.1 +
      + +## 0.4.8-rc.0 + +### Patch Changes + +-
      Updated dependencies [9a38c8e13f, 274f4f5881, 532f08819e, 927710d778, 3a161c4310, 12d6307998]: + + - @rocket.chat/model-typings@0.8.0-rc.0 + - @rocket.chat/core-typings@6.13.0-rc.0 + - @rocket.chat/core-services@0.7.0-rc.0 + - @rocket.chat/models@0.3.0-rc.0 + - @rocket.chat/omnichannel-services@0.3.4-rc.0 +
      + +## 0.4.7 + +### Patch Changes + +-
      Updated dependencies []: + + - @rocket.chat/core-services@0.6.1 + - @rocket.chat/core-typings@6.12.1 + - @rocket.chat/omnichannel-services@0.3.4 + - @rocket.chat/model-typings@0.7.1 + - @rocket.chat/models@0.2.4 +
      + ## 0.4.6 ### Patch Changes diff --git a/ee/apps/queue-worker/Dockerfile b/ee/apps/queue-worker/Dockerfile index 6a93a8e5e8be..0f18534e1453 100644 --- a/ee/apps/queue-worker/Dockerfile +++ b/ee/apps/queue-worker/Dockerfile @@ -7,6 +7,10 @@ WORKDIR /app COPY ./packages/core-services/package.json packages/core-services/package.json COPY ./packages/core-services/dist packages/core-services/dist +COPY ./packages/apps-engine/package.json packages/apps-engine/package.json +COPY ./packages/apps-engine/client packages/apps-engine/client +COPY ./packages/apps-engine/definition packages/apps-engine/definition + COPY ./packages/agenda/package.json packages/agenda/package.json COPY ./packages/agenda/dist packages/agenda/dist @@ -37,6 +41,9 @@ COPY ./packages/logger/dist packages/logger/dist COPY ./packages/server-cloud-communication/ packages/server-cloud-communication/ +COPY ./ee/packages/network-broker/package.json ee/packages/network-broker/package.json +COPY ./ee/packages/network-broker/dist ee/packages/network-broker/dist + COPY ./ee/packages/license/package.json packages/license/package.json COPY ./ee/packages/license/dist packages/license/dist diff --git a/ee/apps/queue-worker/package.json b/ee/apps/queue-worker/package.json index 7844b0c27693..6712d59bba5e 100644 --- a/ee/apps/queue-worker/package.json +++ b/ee/apps/queue-worker/package.json @@ -1,7 +1,7 @@ { "name": "@rocket.chat/queue-worker", "private": true, - "version": "0.4.6", + "version": "0.4.8-rc.1", "description": "Rocket.Chat service", "scripts": { "build": "tsc -p tsconfig.json", @@ -22,6 +22,7 @@ "@rocket.chat/logger": "workspace:^", "@rocket.chat/model-typings": "workspace:^", "@rocket.chat/models": "workspace:^", + "@rocket.chat/network-broker": "workspace:^", "@rocket.chat/omnichannel-services": "workspace:^", "@types/node": "^14.18.63", "ejson": "^2.2.3", @@ -29,10 +30,10 @@ "event-loop-stats": "^1.4.1", "eventemitter3": "^4.0.7", "fibers": "^5.0.3", - "gc-stats": "^1.4.0", + "gc-stats": "^1.4.1", "mem": "^8.1.1", - "moleculer": "^0.14.31", - "moment-timezone": "^0.5.43", + "moleculer": "^0.14.34", + "moment-timezone": "^0.5.45", "mongo-message-queue": "^1.0.0", "mongodb": "^4.17.2", "nats": "^2.4.0", @@ -41,10 +42,10 @@ }, "devDependencies": { "@rocket.chat/eslint-config": "workspace:^", - "@types/gc-stats": "^1.4.2", - "@types/polka": "^0.5.6", + "@types/gc-stats": "^1.4.3", + "@types/polka": "^0.5.7", "eslint": "~8.45.0", - "ts-node": "^10.9.1", + "ts-node": "^10.9.2", "typescript": "~5.5.4" }, "main": "./dist/ee/apps/queue-worker/src/service.js", diff --git a/ee/apps/queue-worker/src/service.ts b/ee/apps/queue-worker/src/service.ts index 8583257a6860..c11376d56534 100644 --- a/ee/apps/queue-worker/src/service.ts +++ b/ee/apps/queue-worker/src/service.ts @@ -1,20 +1,16 @@ -import { api } from '@rocket.chat/core-services'; +import { api, getConnection, getTrashCollection } from '@rocket.chat/core-services'; import { Logger } from '@rocket.chat/logger'; -import type { Document } from 'mongodb'; +import { broker } from '@rocket.chat/network-broker'; import polka from 'polka'; import { registerServiceModels } from '../../../../apps/meteor/ee/server/lib/registerServiceModels'; -import { Collections, getCollection, getConnection } from '../../../../apps/meteor/ee/server/services/mongo'; -import { broker } from '../../../../apps/meteor/ee/server/startup/broker'; const PORT = process.env.PORT || 3038; (async () => { const db = await getConnection(); - const trash = await getCollection(Collections.Trash); - - registerServiceModels(db, trash); + registerServiceModels(db, await getTrashCollection()); api.setBroker(broker); diff --git a/ee/apps/stream-hub-service/CHANGELOG.md b/ee/apps/stream-hub-service/CHANGELOG.md index ccbb83e7de0b..05843d5502b1 100644 --- a/ee/apps/stream-hub-service/CHANGELOG.md +++ b/ee/apps/stream-hub-service/CHANGELOG.md @@ -1,5 +1,41 @@ # @rocket.chat/stream-hub-service +## 0.4.8-rc.1 + +### Patch Changes + +-
      Updated dependencies []: + + - @rocket.chat/core-typings@6.13.0-rc.1 + - @rocket.chat/core-services@0.7.0-rc.1 + - @rocket.chat/model-typings@0.8.0-rc.1 + - @rocket.chat/models@0.3.0-rc.1 +
      + +## 0.4.8-rc.0 + +### Patch Changes + +-
      Updated dependencies [9a38c8e13f, 274f4f5881, 532f08819e, 927710d778, 3a161c4310, 12d6307998]: + + - @rocket.chat/model-typings@0.8.0-rc.0 + - @rocket.chat/core-typings@6.13.0-rc.0 + - @rocket.chat/core-services@0.7.0-rc.0 + - @rocket.chat/models@0.3.0-rc.0 +
      + +## 0.4.7 + +### Patch Changes + +-
      Updated dependencies []: + + - @rocket.chat/core-services@0.6.1 + - @rocket.chat/core-typings@6.12.1 + - @rocket.chat/model-typings@0.7.1 + - @rocket.chat/models@0.2.4 +
      + ## 0.4.6 ### Patch Changes diff --git a/ee/apps/stream-hub-service/Dockerfile b/ee/apps/stream-hub-service/Dockerfile index c662d8765300..9ddeadd380fe 100644 --- a/ee/apps/stream-hub-service/Dockerfile +++ b/ee/apps/stream-hub-service/Dockerfile @@ -7,6 +7,10 @@ WORKDIR /app COPY ./packages/core-services/package.json packages/core-services/package.json COPY ./packages/core-services/dist packages/core-services/dist +COPY ./packages/apps-engine/package.json packages/apps-engine/package.json +COPY ./packages/apps-engine/client packages/apps-engine/client +COPY ./packages/apps-engine/definition packages/apps-engine/definition + COPY ./packages/agenda/package.json packages/agenda/package.json COPY ./packages/agenda/dist packages/agenda/dist @@ -37,6 +41,9 @@ COPY ./packages/logger/dist packages/logger/dist COPY ./packages/server-cloud-communication/ packages/server-cloud-communication/ +COPY ./ee/packages/network-broker/package.json ee/packages/network-broker/package.json +COPY ./ee/packages/network-broker/dist ee/packages/network-broker/dist + COPY ./ee/packages/license/package.json packages/license/package.json COPY ./ee/packages/license/dist packages/license/dist diff --git a/ee/apps/stream-hub-service/package.json b/ee/apps/stream-hub-service/package.json index 030371478cb2..4858d24d9611 100644 --- a/ee/apps/stream-hub-service/package.json +++ b/ee/apps/stream-hub-service/package.json @@ -1,7 +1,7 @@ { "name": "@rocket.chat/stream-hub-service", "private": true, - "version": "0.4.6", + "version": "0.4.8-rc.1", "description": "Rocket.Chat Stream Hub service", "scripts": { "build": "tsc -p tsconfig.json", @@ -21,15 +21,16 @@ "@rocket.chat/logger": "workspace:^", "@rocket.chat/model-typings": "workspace:^", "@rocket.chat/models": "workspace:^", + "@rocket.chat/network-broker": "workspace:^", "@rocket.chat/string-helpers": "~0.31.25", "@types/node": "^14.18.63", "ejson": "^2.2.3", "event-loop-stats": "^1.4.1", "eventemitter3": "^4.0.7", "fibers": "^5.0.3", - "gc-stats": "^1.4.0", + "gc-stats": "^1.4.1", "mem": "^8.1.1", - "moleculer": "^0.14.31", + "moleculer": "^0.14.34", "mongodb": "^4.17.2", "nats": "^2.4.0", "pino": "^8.15.0", @@ -38,11 +39,11 @@ "devDependencies": { "@rocket.chat/eslint-config": "workspace:^", "@rocket.chat/rest-typings": "workspace:^", - "@types/bcrypt": "^5.0.1", - "@types/gc-stats": "^1.4.2", - "@types/polka": "^0.5.6", + "@types/bcrypt": "^5.0.2", + "@types/gc-stats": "^1.4.3", + "@types/polka": "^0.5.7", "eslint": "~8.45.0", - "ts-node": "^10.9.1", + "ts-node": "^10.9.2", "typescript": "~5.5.4" }, "main": "./dist/ee/apps/stream-hub-service/src/service.js", diff --git a/ee/apps/stream-hub-service/src/service.ts b/ee/apps/stream-hub-service/src/service.ts index 4975b5b306be..5e035548dc38 100755 --- a/ee/apps/stream-hub-service/src/service.ts +++ b/ee/apps/stream-hub-service/src/service.ts @@ -1,11 +1,9 @@ -import { api } from '@rocket.chat/core-services'; +import { api, getConnection, getTrashCollection } from '@rocket.chat/core-services'; import { Logger } from '@rocket.chat/logger'; -import type { Document } from 'mongodb'; +import { broker } from '@rocket.chat/network-broker'; import polka from 'polka'; import { registerServiceModels } from '../../../../apps/meteor/ee/server/lib/registerServiceModels'; -import { Collections, getCollection, getConnection } from '../../../../apps/meteor/ee/server/services/mongo'; -import { broker } from '../../../../apps/meteor/ee/server/startup/broker'; import { DatabaseWatcher } from '../../../../apps/meteor/server/database/DatabaseWatcher'; import { StreamHub } from './StreamHub'; @@ -14,9 +12,7 @@ const PORT = process.env.PORT || 3035; (async () => { const db = await getConnection(); - const trash = await getCollection(Collections.Trash); - - registerServiceModels(db, trash); + registerServiceModels(db, await getTrashCollection()); api.setBroker(broker); diff --git a/ee/packages/license/CHANGELOG.md b/ee/packages/license/CHANGELOG.md index 181b6dfb0e7f..92b07995bb4b 100644 --- a/ee/packages/license/CHANGELOG.md +++ b/ee/packages/license/CHANGELOG.md @@ -1,5 +1,32 @@ # @rocket.chat/license +## 0.2.8-rc.1 + +### Patch Changes + +-
      Updated dependencies []: + + - @rocket.chat/core-typings@6.13.0-rc.1 +
      + +## 0.2.8-rc.0 + +### Patch Changes + +-
      Updated dependencies [274f4f5881, 927710d778, 12d6307998]: + + - @rocket.chat/core-typings@6.13.0-rc.0 +
      + +## 0.2.7 + +### Patch Changes + +-
      Updated dependencies []: + + - @rocket.chat/core-typings@6.12.1 +
      + ## 0.2.6 ### Patch Changes diff --git a/ee/packages/license/package.json b/ee/packages/license/package.json index 0b66c13574c2..30a89b4334bd 100644 --- a/ee/packages/license/package.json +++ b/ee/packages/license/package.json @@ -1,12 +1,12 @@ { "name": "@rocket.chat/license", - "version": "0.2.6", + "version": "0.2.8-rc.1", "private": true, "devDependencies": { "@rocket.chat/jest-presets": "workspace:~", - "@types/bcrypt": "^5.0.1", - "@types/jest": "~29.5.12", - "@types/ws": "^8.5.8", + "@types/bcrypt": "^5.0.2", + "@types/jest": "~29.5.13", + "@types/ws": "^8.5.12", "eslint": "~8.45.0", "jest": "~29.7.0", "jest-websocket-mock": "~2.5.0", diff --git a/ee/packages/network-broker/.eslintrc.json b/ee/packages/network-broker/.eslintrc.json new file mode 100644 index 000000000000..a83aeda48e66 --- /dev/null +++ b/ee/packages/network-broker/.eslintrc.json @@ -0,0 +1,4 @@ +{ + "extends": ["@rocket.chat/eslint-config"], + "ignorePatterns": ["**/dist"] +} diff --git a/ee/packages/network-broker/jest.config.ts b/ee/packages/network-broker/jest.config.ts new file mode 100644 index 000000000000..c18c8ae02465 --- /dev/null +++ b/ee/packages/network-broker/jest.config.ts @@ -0,0 +1,6 @@ +import server from '@rocket.chat/jest-presets/server'; +import type { Config } from 'jest'; + +export default { + preset: server.preset, +} satisfies Config; diff --git a/ee/packages/network-broker/package.json b/ee/packages/network-broker/package.json new file mode 100644 index 000000000000..c4d3ea1b284b --- /dev/null +++ b/ee/packages/network-broker/package.json @@ -0,0 +1,39 @@ +{ + "name": "@rocket.chat/network-broker", + "version": "0.1.0", + "private": true, + "devDependencies": { + "@rocket.chat/eslint-config": "workspace:^", + "@types/chai": "~4.3.19", + "@types/ejson": "^2.2.2", + "@types/node": "^14.18.63", + "@types/sinon": "^10.0.20", + "chai": "^4.3.10", + "eslint": "~8.45.0", + "jest": "~29.7.0", + "sinon": "^14.0.2", + "typescript": "~5.5.4" + }, + "scripts": { + "lint": "eslint src", + "lint:fix": "eslint src --fix", + "test": "jest", + "build": "tsc", + "testunit": "jest", + "typecheck": "tsc --noEmit --skipLibCheck" + }, + "main": "./dist/index.js", + "typings": "./dist/index.d.ts", + "files": [ + "/dist" + ], + "volta": { + "extends": "../../../package.json" + }, + "dependencies": { + "@rocket.chat/core-services": "workspace:^", + "ejson": "^2.2.3", + "moleculer": "^0.14.34", + "pino": "^8.15.0" + } +} diff --git a/apps/meteor/ee/server/lib/EnterpriseCheck.ts b/ee/packages/network-broker/src/EnterpriseCheck.ts similarity index 100% rename from apps/meteor/ee/server/lib/EnterpriseCheck.ts rename to ee/packages/network-broker/src/EnterpriseCheck.ts diff --git a/apps/meteor/ee/tests/unit/server/NetworkBroker.tests.ts b/ee/packages/network-broker/src/NetworkBroker.test.ts similarity index 85% rename from apps/meteor/ee/tests/unit/server/NetworkBroker.tests.ts rename to ee/packages/network-broker/src/NetworkBroker.test.ts index 1aac9c33fc33..c79fb12e7049 100644 --- a/apps/meteor/ee/tests/unit/server/NetworkBroker.tests.ts +++ b/ee/packages/network-broker/src/NetworkBroker.test.ts @@ -2,8 +2,8 @@ import { ServiceClass } from '@rocket.chat/core-services'; import { expect } from 'chai'; import sinon from 'sinon'; -import { BrokerMocked } from '../../../../tests/mocks/server/BrokerMocked'; -import { NetworkBroker } from '../../../server/NetworkBroker'; +import { BrokerMocked } from '../../../../apps/meteor/tests/mocks/server/BrokerMocked'; +import { NetworkBroker } from './NetworkBroker'; class DelayedStopBroker extends BrokerMocked { async destroyService(name: string) { diff --git a/apps/meteor/ee/server/NetworkBroker.ts b/ee/packages/network-broker/src/NetworkBroker.ts similarity index 94% rename from apps/meteor/ee/server/NetworkBroker.ts rename to ee/packages/network-broker/src/NetworkBroker.ts index 0fed6fca542d..e326357cba0a 100644 --- a/apps/meteor/ee/server/NetworkBroker.ts +++ b/ee/packages/network-broker/src/NetworkBroker.ts @@ -2,7 +2,7 @@ import { asyncLocalStorage } from '@rocket.chat/core-services'; import type { IBroker, IBrokerNode, IServiceMetrics, IServiceClass, EventSignatures } from '@rocket.chat/core-services'; import type { ServiceBroker, Context, ServiceSchema } from 'moleculer'; -import { EnterpriseCheck } from './lib/EnterpriseCheck'; +import { EnterpriseCheck } from './EnterpriseCheck'; const events: { [k: string]: string } = { onNodeConnected: '$node.connected', @@ -25,7 +25,7 @@ const waitForServicesTimeout = parseInt(WAIT_FOR_SERVICES_TIMEOUT, 10) || 10000; export class NetworkBroker implements IBroker { private broker: ServiceBroker; - private started: Promise; + private started: Promise = Promise.resolve(false); metrics: IServiceMetrics; @@ -36,7 +36,9 @@ export class NetworkBroker implements IBroker { } async call(method: string, data: any): Promise { - await this.started; + if (!(await this.started)) { + return; + } const context = asyncLocalStorage.getStore(); @@ -54,7 +56,9 @@ export class NetworkBroker implements IBroker { } async waitAndCall(method: string, data: any): Promise { - await this.started; + if (!(await this.started)) { + return; + } try { await this.broker.waitForServices(method.split('.')[0], waitForServicesTimeout); @@ -182,6 +186,8 @@ export class NetworkBroker implements IBroker { } async start(): Promise { - this.started = this.broker.start(); + await this.broker.start(); + + this.started = Promise.resolve(true); } } diff --git a/apps/meteor/ee/server/startup/broker.ts b/ee/packages/network-broker/src/index.ts similarity index 98% rename from apps/meteor/ee/server/startup/broker.ts rename to ee/packages/network-broker/src/index.ts index daae4ace4e05..caa12890b514 100644 --- a/apps/meteor/ee/server/startup/broker.ts +++ b/ee/packages/network-broker/src/index.ts @@ -3,7 +3,7 @@ import EJSON from 'ejson'; import { Errors, Serializers, ServiceBroker } from 'moleculer'; import { pino } from 'pino'; -import { NetworkBroker } from '../NetworkBroker'; +import { NetworkBroker } from './NetworkBroker'; const { MS_NAMESPACE = '', diff --git a/ee/packages/network-broker/tsconfig.json b/ee/packages/network-broker/tsconfig.json new file mode 100644 index 000000000000..ada83b80ff89 --- /dev/null +++ b/ee/packages/network-broker/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../../../tsconfig.base.server.json", + "compilerOptions": { + "declaration": true, + "outDir": "./dist", + "rootDir": "./src", + }, + "files": ["./src/index.ts"] +} diff --git a/ee/packages/omnichannel-services/CHANGELOG.md b/ee/packages/omnichannel-services/CHANGELOG.md index f54493a31cd1..339277317d7f 100644 --- a/ee/packages/omnichannel-services/CHANGELOG.md +++ b/ee/packages/omnichannel-services/CHANGELOG.md @@ -1,5 +1,47 @@ # @rocket.chat/omnichannel-services +## 0.3.5-rc.1 + +### Patch Changes + +-
      Updated dependencies []: + + - @rocket.chat/core-typings@6.13.0-rc.1 + - @rocket.chat/rest-typings@6.13.0-rc.1 + - @rocket.chat/pdf-worker@0.2.5-rc.1 + - @rocket.chat/core-services@0.7.0-rc.1 + - @rocket.chat/model-typings@0.8.0-rc.1 + - @rocket.chat/models@0.3.0-rc.1 +
      + +## 0.3.5-rc.0 + +### Patch Changes + +-
      Updated dependencies [9a38c8e13f, 9eaefdc892, 274f4f5881, 532f08819e, 927710d778, 3a161c4310, 12d6307998]: + + - @rocket.chat/model-typings@0.8.0-rc.0 + - @rocket.chat/rest-typings@6.13.0-rc.0 + - @rocket.chat/core-typings@6.13.0-rc.0 + - @rocket.chat/core-services@0.7.0-rc.0 + - @rocket.chat/models@0.3.0-rc.0 + - @rocket.chat/pdf-worker@0.2.4-rc.0 +
      + +## 0.3.4 + +### Patch Changes + +-
      Updated dependencies []: + + - @rocket.chat/core-services@0.6.1 + - @rocket.chat/core-typings@6.12.1 + - @rocket.chat/rest-typings@6.12.1 + - @rocket.chat/pdf-worker@0.2.4 + - @rocket.chat/model-typings@0.7.1 + - @rocket.chat/models@0.2.4 +
      + ## 0.3.3 ### Patch Changes diff --git a/ee/packages/omnichannel-services/package.json b/ee/packages/omnichannel-services/package.json index 6df90c641375..98f507325586 100644 --- a/ee/packages/omnichannel-services/package.json +++ b/ee/packages/omnichannel-services/package.json @@ -1,11 +1,11 @@ { "name": "@rocket.chat/omnichannel-services", - "version": "0.3.3", + "version": "0.3.5-rc.1", "private": true, "devDependencies": { "@rocket.chat/eslint-config": "workspace:^", "@rocket.chat/jest-presets": "workspace:~", - "@types/jest": "~29.5.12", + "@types/jest": "~29.5.13", "eslint": "~8.45.0", "jest": "~29.7.0", "typescript": "~5.5.4" @@ -28,7 +28,7 @@ "eventemitter3": "^4.0.7", "fibers": "^5.0.3", "mem": "^8.1.1", - "moment-timezone": "^0.5.43", + "moment-timezone": "^0.5.45", "mongo-message-queue": "^1.0.0", "mongodb": "^4.17.2", "pino": "^8.15.0" diff --git a/ee/packages/pdf-worker/CHANGELOG.md b/ee/packages/pdf-worker/CHANGELOG.md index ccc01c92e84f..60f03a15a1de 100644 --- a/ee/packages/pdf-worker/CHANGELOG.md +++ b/ee/packages/pdf-worker/CHANGELOG.md @@ -1,5 +1,32 @@ # @rocket.chat/pdf-worker +## 0.2.5-rc.1 + +### Patch Changes + +-
      Updated dependencies []: + + - @rocket.chat/core-typings@6.13.0-rc.1 +
      + +## 0.2.5-rc.0 + +### Patch Changes + +-
      Updated dependencies [274f4f5881, 927710d778, 12d6307998]: + + - @rocket.chat/core-typings@6.13.0-rc.0 +
      + +## 0.2.4 + +### Patch Changes + +-
      Updated dependencies []: + + - @rocket.chat/core-typings@6.12.1 +
      + ## 0.2.3 ### Patch Changes diff --git a/ee/packages/pdf-worker/package.json b/ee/packages/pdf-worker/package.json index afd5a4d8e314..570b3baab2e2 100644 --- a/ee/packages/pdf-worker/package.json +++ b/ee/packages/pdf-worker/package.json @@ -1,16 +1,16 @@ { "name": "@rocket.chat/pdf-worker", - "version": "0.2.3", + "version": "0.2.5-rc.1", "private": true, "devDependencies": { "@rocket.chat/jest-presets": "workspace:~", "@storybook/addon-essentials": "~6.5.16", "@storybook/react": "~6.5.16", - "@testing-library/react": "~16.0.0", - "@types/emojione": "^2.2.8", - "@types/jest": "~29.5.12", - "@types/react": "~17.0.69", - "@types/react-dom": "~17.0.22", + "@testing-library/react": "~16.0.1", + "@types/emojione": "^2.2.9", + "@types/jest": "~29.5.13", + "@types/react": "~17.0.80", + "@types/react-dom": "~17.0.25", "eslint": "~8.45.0", "jest": "~29.7.0", "react-dom": "~18.3.1", @@ -38,7 +38,7 @@ "emoji-assets": "^7.0.1", "emoji-toolkit": "^7.0.1", "moment": "^2.29.4", - "moment-timezone": "^0.5.43", + "moment-timezone": "^0.5.45", "react": "~18.3.1" }, "volta": { diff --git a/ee/packages/presence/CHANGELOG.md b/ee/packages/presence/CHANGELOG.md index a4effccb5f2b..7b8bc5b28116 100644 --- a/ee/packages/presence/CHANGELOG.md +++ b/ee/packages/presence/CHANGELOG.md @@ -1,5 +1,38 @@ # @rocket.chat/presence +## 0.2.8-rc.1 + +### Patch Changes + +-
      Updated dependencies []: + + - @rocket.chat/core-typings@6.13.0-rc.1 + - @rocket.chat/core-services@0.7.0-rc.1 + - @rocket.chat/models@0.3.0-rc.1 +
      + +## 0.2.8-rc.0 + +### Patch Changes + +-
      Updated dependencies [274f4f5881, 532f08819e, 927710d778, 3a161c4310, 12d6307998]: + + - @rocket.chat/core-typings@6.13.0-rc.0 + - @rocket.chat/core-services@0.7.0-rc.0 + - @rocket.chat/models@0.3.0-rc.0 +
      + +## 0.2.7 + +### Patch Changes + +-
      Updated dependencies []: + + - @rocket.chat/core-services@0.6.1 + - @rocket.chat/core-typings@6.12.1 + - @rocket.chat/models@0.2.4 +
      + ## 0.2.6 ### Patch Changes diff --git a/ee/packages/presence/package.json b/ee/packages/presence/package.json index 4d599562b278..06bbc244fd48 100644 --- a/ee/packages/presence/package.json +++ b/ee/packages/presence/package.json @@ -1,12 +1,12 @@ { "name": "@rocket.chat/presence", - "version": "0.2.6", + "version": "0.2.8-rc.1", "private": true, "devDependencies": { "@babel/core": "~7.22.20", "@babel/preset-env": "~7.22.20", "@babel/preset-typescript": "~7.22.15", - "@rocket.chat/apps-engine": "1.45.0-alpha.868", + "@rocket.chat/apps-engine": "workspace:^", "@rocket.chat/eslint-config": "workspace:^", "@rocket.chat/rest-typings": "workspace:^", "@types/node": "^14.18.63", diff --git a/ee/packages/ui-theming/CHANGELOG.md b/ee/packages/ui-theming/CHANGELOG.md index 3a4956d146c3..b25ae24af33d 100644 --- a/ee/packages/ui-theming/CHANGELOG.md +++ b/ee/packages/ui-theming/CHANGELOG.md @@ -1,5 +1,11 @@ # @rocket.chat/ui-theming +## 0.3.0-rc.0 + +### Minor Changes + +- ([#32821](https://github.com/RocketChat/Rocket.Chat/pull/32821)) Replaced new `SidebarV2` components under feature preview + ## 0.2.1 ### Patch Changes diff --git a/ee/packages/ui-theming/package.json b/ee/packages/ui-theming/package.json index 5f3bb4977d7d..29fe229ca532 100644 --- a/ee/packages/ui-theming/package.json +++ b/ee/packages/ui-theming/package.json @@ -1,21 +1,21 @@ { "name": "@rocket.chat/ui-theming", - "version": "0.2.1", + "version": "0.3.0-rc.0", "private": true, "devDependencies": { "@rocket.chat/css-in-js": "~0.31.25", - "@rocket.chat/fuselage": "^0.59.0", + "@rocket.chat/fuselage": "^0.59.1", "@rocket.chat/fuselage-hooks": "^0.33.1", "@rocket.chat/icons": "~0.38.0", "@rocket.chat/ui-contexts": "workspace:~", - "@types/react": "~17.0.69", + "@types/react": "~17.0.80", "eslint": "~8.45.0", "eslint-plugin-anti-trojan-source": "~1.1.1", "eslint-plugin-react": "~7.32.2", - "eslint-plugin-react-hooks": "~4.6.0", + "eslint-plugin-react-hooks": "~4.6.2", "eslint-plugin-testing-library": "^5.11.1", "react": "~17.0.2", - "react-docgen-typescript-plugin": "~1.0.5", + "react-docgen-typescript-plugin": "~1.0.8", "typescript": "~5.5.4" }, "scripts": { diff --git a/package.json b/package.json index 60ec7b46f62f..f06deed440ff 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "rocket.chat", - "version": "6.13.0-develop", + "version": "6.14.0-develop", "description": "Rocket.Chat Monorepo", "main": "index.js", "private": true, @@ -18,10 +18,10 @@ }, "devDependencies": { "@changesets/cli": "^2.26.2", - "@types/chart.js": "^2.9.39", - "@types/js-yaml": "^4.0.8", + "@types/chart.js": "^2.9.41", + "@types/js-yaml": "^4.0.9", "ts-node": "^10.9.2", - "turbo": "latest" + "turbo": "^2.1.2" }, "workspaces": [ "apps/*", @@ -64,7 +64,9 @@ "@storybook/react-docgen-typescript-plugin@1.0.2-canary.6.9d540b91e815f8fc2f8829189deb00553559ff63.0": "patch:@storybook/react-docgen-typescript-plugin@npm%3A1.0.2-canary.6.9d540b91e815f8fc2f8829189deb00553559ff63.0#./.yarn/patches/@storybook-react-docgen-typescript-plugin-npm-1.0.2-canary.6.9d540b91e815f8fc2f8829189deb00553559ff63.0-b31cc57c40.patch", "mongodb@^4.17.1": "patch:mongodb@npm:4.17.1#.yarn/patches/mongodb-npm-4.17.1-a2fe811ff1.patch", "@rocket.chat/forked-matrix-sdk-crypto-nodejs": "0.1.0-beta.13", - "typia@~6.9.0": "patch:typia@npm%3A6.9.0#./.yarn/patches/typia-npm-6.9.0-2fd4d85f25.patch" + "typia@~6.9.0": "patch:typia@npm%3A6.9.0#./.yarn/patches/typia-npm-6.9.0-2fd4d85f25.patch", + "react-i18next@~15.0.1": "patch:react-i18next@npm%3A15.0.1#./.yarn/patches/react-i18next-npm-15.0.1-0812bb73aa.patch", + "moleculer@^0.14.34": "patch:moleculer@npm%3A0.14.34#./.yarn/patches/moleculer-npm-0.14.34-440e26767d.patch" }, "dependencies": { "node-gyp": "^9.4.1" diff --git a/packages/agenda/package.json b/packages/agenda/package.json index f09921de6c31..2d1fc084c383 100644 --- a/packages/agenda/package.json +++ b/packages/agenda/package.json @@ -8,11 +8,11 @@ "date.js": "~0.3.3", "debug": "~4.1.1", "human-interval": "^2.0.1", - "moment-timezone": "~0.5.43", + "moment-timezone": "~0.5.45", "mongodb": "^4.17.2" }, "devDependencies": { - "@types/debug": "^4.1.10", + "@types/debug": "^4.1.12", "eslint": "~8.45.0", "typescript": "~5.5.4" }, diff --git a/packages/api-client/CHANGELOG.md b/packages/api-client/CHANGELOG.md index 50096d81e901..18b99f365728 100644 --- a/packages/api-client/CHANGELOG.md +++ b/packages/api-client/CHANGELOG.md @@ -1,5 +1,35 @@ # @rocket.chat/api-client +## 0.2.8-rc.1 + +### Patch Changes + +-
      Updated dependencies []: + + - @rocket.chat/core-typings@6.13.0-rc.1 + - @rocket.chat/rest-typings@6.13.0-rc.1 +
      + +## 0.2.8-rc.0 + +### Patch Changes + +-
      Updated dependencies [9a38c8e13f, 9eaefdc892, 274f4f5881, 532f08819e, 927710d778, 3a161c4310, 12d6307998]: + + - @rocket.chat/rest-typings@6.13.0-rc.0 + - @rocket.chat/core-typings@6.13.0-rc.0 +
      + +## 0.2.7 + +### Patch Changes + +-
      Updated dependencies []: + + - @rocket.chat/core-typings@6.12.1 + - @rocket.chat/rest-typings@6.12.1 +
      + ## 0.2.6 ### Patch Changes diff --git a/packages/api-client/package.json b/packages/api-client/package.json index 23e0d7233a0f..b87cdb6600d9 100644 --- a/packages/api-client/package.json +++ b/packages/api-client/package.json @@ -1,10 +1,10 @@ { "name": "@rocket.chat/api-client", - "version": "0.2.6", + "version": "0.2.8-rc.1", "devDependencies": { "@rocket.chat/jest-presets": "workspace:~", - "@types/jest": "~29.5.12", - "@types/strict-uri-encode": "^2.0.1", + "@types/jest": "~29.5.13", + "@types/strict-uri-encode": "^2.0.2", "eslint": "~8.45.0", "jest": "~29.7.0", "jest-fetch-mock": "^3.0.3", diff --git a/packages/apps-engine/.eslintignore b/packages/apps-engine/.eslintignore new file mode 100644 index 000000000000..f7e4e0b38e59 --- /dev/null +++ b/packages/apps-engine/.eslintignore @@ -0,0 +1,8 @@ +!/gulpfile.js +/client +/definition +/docs +/server +/lib +/deno-runtime +/.deno diff --git a/packages/apps-engine/.eslintrc.json b/packages/apps-engine/.eslintrc.json new file mode 100644 index 000000000000..8e42d7cfa9f6 --- /dev/null +++ b/packages/apps-engine/.eslintrc.json @@ -0,0 +1,58 @@ +{ + "extends": "@rocket.chat/eslint-config", + "parser": "@typescript-eslint/parser", + "parserOptions": { + "project": "./tsconfig-lint.json" + }, + "rules": { + "@typescript-eslint/ban-types": [ + "error", + { + "types": { + "{}": false + } + } + ], + "@typescript-eslint/naming-convention": [ + "error", + { + "selector": ["function", "parameter", "variable"], + "modifiers": ["destructured"], + "format": null + }, + { + "selector": ["variable"], + "format": ["camelCase", "UPPER_CASE", "PascalCase"], + "leadingUnderscore": "allowSingleOrDouble" + }, + { + "selector": ["function"], + "format": ["camelCase", "PascalCase"], + "leadingUnderscore": "allowSingleOrDouble" + }, + { + "selector": ["parameter"], + "format": ["camelCase"], + "leadingUnderscore": "allow" + }, + { + "selector": ["parameter"], + "format": ["camelCase"], + "modifiers": ["unused"], + "leadingUnderscore": "allow" + }, + { + "selector": ["interface"], + "format": ["PascalCase"], + "custom": { + "regex": "^I[A-Z]", + "match": true + } + } + ], + "@typescript-eslint/no-empty-function": "off", + "@typescript-eslint/no-unused-vars": ["error", { "args": "none" }], + "new-cap": "off", + "no-await-in-loop": "off" + } +} diff --git a/packages/apps-engine/.gitignore b/packages/apps-engine/.gitignore new file mode 100644 index 000000000000..c4568f9c7430 --- /dev/null +++ b/packages/apps-engine/.gitignore @@ -0,0 +1,61 @@ +# Created by https://www.gitignore.io/api/node + +### Node ### +# Logs +logs +*.log +npm-debug.log* + +# Runtime data +pids +*.pid +*.seed +*.pid.lock + +# Directory for instrumented libs generated by jscoverage/JSCover +lib-cov + +# Coverage directory used by tools like istanbul +coverage + +# nyc test coverage +.nyc_output + +# Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) +.grunt + +# node-waf configuration +.lock-wscript + +# Compiled binary addons (http://nodejs.org/api/addons.html) +build/Release + +# Dependency directories +node_modules +jspm_packages + +# Optional npm cache directory +.npm + +.deno +.deno-cache + +# Optional REPL history +.node_repl_history + +### Typings ### +## Ignore downloaded typings +/typings + +## dev environment stuff +/examples/ +/dev-dist/ +/data/ +/tests/test-data/dbs +/client +/definition +/server +/lib + +.DS_Store +.idea/ diff --git a/packages/apps-engine/.prettierrc b/packages/apps-engine/.prettierrc new file mode 100644 index 000000000000..9b77117b35c0 --- /dev/null +++ b/packages/apps-engine/.prettierrc @@ -0,0 +1,7 @@ +{ + "tabWidth": 4, + "useTabs": false, + "singleQuote": true, + "trailingComma": "all", + "printWidth": 160 +} diff --git a/packages/apps-engine/README.md b/packages/apps-engine/README.md new file mode 100644 index 000000000000..d73e09f1aed7 --- /dev/null +++ b/packages/apps-engine/README.md @@ -0,0 +1,125 @@ +## Thoughts While Working (for docs) +- Apps which don't provide a valid uuid4 id will be assigned one, but this is not recommended and your App should provide an id +- The language strings are only done on the clients (`TAPi18next.addResourceBundle(lang, projectName, translations);`) +- The implementer of this should restrict the server setting access and environmental variables. Idea is to allow the implementer to have a default set of restricted ones while letting the admin/owner of the server to restrict it even further or lift the restriction on some more. Simple interface with settings and checkbox to allow/disallow them. :thinking: + +## What does the Apps-Engine enable you to do? +The Apps-Engine is Rocket.Chat's _plugin framework_ - it provides the APIs for Rocket.Chat Apps to interact with the host system. + +Currently, a Rocket.Chat App can: +- Listen to message events + - before/after sent + - before/after updated + - before/after deleted +- Listen to room events + - before/after created + - before/after deleted +- Send messages to users and livechat visitors +- Register new slash commands +- Register new HTTP endpoints + +Some features the Engine allows Apps to use: +- Key-Value Storage system +- App specific settings + +## Development environment with Rocket.Chat +When developing new functionalities, you need to integrate the local version of the Apps-Engine with your local version of Rocket.Chat. + +First of all, make sure you've installed all required packages and compiled the changes you've made to the Apps-Engine, since that is what Rocket.Chat will execute: +```sh +npm install +npm run compile +``` + +Now, you need to setup a local Rocket.Chat server, [so head to the project's README for instructions on getting started](https://github.com/RocketChat/Rocket.Chat#development) (if you haven't already). Make sure to actually clone the repo, since you will probably need to add some code to it in order to make your new functionality work. + +After that, `cd` into Rocket.Chat folder and run: +```sh +meteor npm install PATH_TO_APPS_ENGINE +``` + +Where `PATH_TO_APPS_ENGINE` is the path to the Apps-Engine repo you've cloned. + +That's it! Now when you start Rocket.Chat with the `meteor` command, it will use your local Apps-Engine instead of the one on NPM :) + +Whenever you make changes to the engine, run `npm run compile` again - meteor will take care of restarting the server due to the changes. + +## Troubleshooting +1. Sometimes, when you update the Apps-Engine code and compile it while Rocket.Chat is running, you might run on errors similar to these: + +``` +Unable to resolve some modules: + + "@rocket.chat/apps-engine/definition/AppStatus" in +/Users/dev/rocket.chat/Rocket.Chat/app/apps/client/admin/helpers.js (web.browser) + +If you notice problems related to these missing modules, consider running: + + meteor npm install --save @rocket.chat/apps-engine +``` + +Simply restart the meteor process and it should be fixed. + +2. Sometimes when using `meteor npm install PATH_TO_APPS_ENGINE` will cause the following error :- + +``` +npm ERR! code ENOENT +npm ERR! syscall rename +npm ERR! path PATH_TO_ROCKETCHAT/node_modules/.staging/@rocket.chat/apps-engine-c7135600/node_modules/@babel/code-frame +npm ERR! dest PATH_TO_ROCKETCHAT/node_modules/.staging/@babel/code-frame-f3697825 +npm ERR! errno -2 +npm ERR! enoent ENOENT: no such file or directory, rename 'PATH_TO_ROCKETCHAT/node_modules/.staging/@rocket.chat/apps-engine-c7135600/node_modules/@babel/code-frame' -> 'PATH_TO_ROCKETCHAT/node_modules/.staging/@babel/code-frame-f3697825' +npm ERR! enoent This is related to npm not being able to find a file. +npm ERR! enoent +``` +Here `PATH_TO_ROCKETCHAT` is the path to the main rocketchat server repo in your system +To correct this we reinstall the package once again deleting the previous package +``` +~/Rocket.Chat$ rm -rf node_modules/@rocket.chat/apps-engine +~/Rocket.Chat$ cd PATH_TO_APP_ENGINE +~/Rocket.Chat.Apps-engine$ npm install +~/Rocket.Chat.Apps-engine$ cd PATH_TO_ROCKETCHAT +~/Rocket.Chat$ meteor npm install ../Rocket.Chat.Apps-engine +``` + +## Implementer Needs to Implement: +- `src/server/storage/AppStorage` +- `src/server/storage/AppLogStorage` +- `src/server/bridges/*` + +## Testing Framework: +Makes great usage of TypeScript and decorators: https://github.com/alsatian-test/alsatian/wiki +* To run the tests do: `npm run unit-tests` +* To generate the coverage information: `npm run check-coverage` +* To view the coverage: `npm run view-coverage` + +# Rocket.Chat Apps TypeScript Definitions + +## Handlers +Handlers are essentially "listeners" for different events, except there are various ways to handle an event. +When something happens there is `pre` and `post` handlers. +The set of `pre` handlers happens before the event is finalized. +The set of `post` handlers happens after the event is finalized. +With that said, the rule of thumb is that if you are going to modify, extend, or change the data backing the event then that should be done in the `pre` handlers. If you are simply wanting to listen for when something happens and not modify anything, then the `post` is the way to go. + +The order in which they happen is: +* Pre**Event**Prevent +* Pre**Event**Extend +* Pre**Event**Modify +* Post**Event** + +Here is an explanation of what each of them means: +* **Prevent**: This is ran to determine whether the event should be prevented or not. +* **Extend**: This is ran to allow extending the data without being destructive of the data (adding an attachment to a message for example). +* **Modify**: This is ran and allows for destructive changes to the data (change any and everything). +* Post**Event**: Is mostly for simple listening and no changes can be made to the data. + +## Generating/Updating Documentation +To update or generate the documentation, please commit your changes first and then in a second commit provide the updated documentation. + +# Engage with us +## Share your story +We’d love to hear about [your experience](https://survey.zohopublic.com/zs/e4BUFG) and potentially feature it on our [Blog](https://rocket.chat/case-studies/?utm_source=github&utm_medium=readme&utm_campaign=community). + +## Subscribe for Updates +Once a month our marketing team releases an email update with news about product releases, company related topics, events and use cases. [Sign Up!](https://rocket.chat/newsletter/?utm_source=github&utm_medium=readme&utm_campaign=community) diff --git a/packages/apps-engine/deno-runtime/.gitignore b/packages/apps-engine/deno-runtime/.gitignore new file mode 100644 index 000000000000..5942ea3a153e --- /dev/null +++ b/packages/apps-engine/deno-runtime/.gitignore @@ -0,0 +1 @@ +.deno/ diff --git a/packages/apps-engine/deno-runtime/AppObjectRegistry.ts b/packages/apps-engine/deno-runtime/AppObjectRegistry.ts new file mode 100644 index 000000000000..9069c17eaac5 --- /dev/null +++ b/packages/apps-engine/deno-runtime/AppObjectRegistry.ts @@ -0,0 +1,26 @@ +export type Maybe = T | null | undefined; + +export const AppObjectRegistry = new class { + registry: Record = {}; + + public get(key: string): Maybe { + return this.registry[key] as Maybe; + } + + public set(key: string, value: unknown): void { + this.registry[key] = value; + } + + public has(key: string): boolean { + return key in this.registry; + } + + public delete(key: string): void { + delete this.registry[key]; + } + + public clear(): void { + this.registry = {}; + } +} + diff --git a/packages/apps-engine/deno-runtime/acorn-walk.d.ts b/packages/apps-engine/deno-runtime/acorn-walk.d.ts new file mode 100644 index 000000000000..25861f3bce0f --- /dev/null +++ b/packages/apps-engine/deno-runtime/acorn-walk.d.ts @@ -0,0 +1,170 @@ +import type acorn from "./acorn.d.ts"; + +export type FullWalkerCallback = ( + node: acorn.AnyNode, + state: TState, + type: string +) => void + +export type FullAncestorWalkerCallback = ( + node: acorn.AnyNode, + state: TState, + ancestors: acorn.AnyNode[], + type: string +) => void + +type AggregateType = { + Expression: acorn.Expression, + Statement: acorn.Statement, + Pattern: acorn.Pattern, + ForInit: acorn.VariableDeclaration | acorn.Expression +} + +export type SimpleVisitors = { + [type in acorn.AnyNode["type"]]?: (node: Extract, state: TState) => void +} & { + [type in keyof AggregateType]?: (node: AggregateType[type], state: TState) => void +} + +export type AncestorVisitors = { + [type in acorn.AnyNode["type"]]?: ( node: Extract, state: TState, ancestors: acorn.Node[] +) => void +} & { + [type in keyof AggregateType]?: (node: AggregateType[type], state: TState, ancestors: acorn.Node[]) => void +} + +export type WalkerCallback = (node: acorn.Node, state: TState) => void + +export type RecursiveVisitors = { + [type in acorn.AnyNode["type"]]?: ( node: Extract, state: TState, callback: WalkerCallback) => void +} & { + [type in keyof AggregateType]?: (node: AggregateType[type], state: TState, callback: WalkerCallback) => void +} + +export type FindPredicate = (type: string, node: acorn.Node) => boolean + +export interface Found { + node: acorn.Node, + state: TState +} + +/** + * does a 'simple' walk over a tree + * @param node the AST node to walk + * @param visitors an object with properties whose names correspond to node types in the {@link https://github.com/estree/estree | ESTree spec}. The properties should contain functions that will be called with the node object and, if applicable the state at that point. + * @param base a walker algorithm + * @param state a start state. The default walker will simply visit all statements and expressions and not produce a meaningful state. (An example of a use of state is to track scope at each point in the tree.) + */ +export function simple( + node: acorn.Node, + visitors: SimpleVisitors, + base?: RecursiveVisitors, + state?: TState +): void + +/** + * does a 'simple' walk over a tree, building up an array of ancestor nodes (including the current node) and passing the array to the callbacks as a third parameter. + * @param node + * @param visitors + * @param base + * @param state + */ +export function ancestor( + node: acorn.Node, + visitors: AncestorVisitors, + base?: RecursiveVisitors, + state?: TState + ): void + +/** + * does a 'recursive' walk, where the walker functions are responsible for continuing the walk on the child nodes of their target node. + * @param node + * @param state the start state + * @param functions contain an object that maps node types to walker functions + * @param base provides the fallback walker functions for node types that aren't handled in the {@link functions} object. If not given, the default walkers will be used. + */ +export function recursive( + node: acorn.Node, + state: TState, + functions: RecursiveVisitors, + base?: RecursiveVisitors +): void + +/** + * does a 'full' walk over a tree, calling the {@link callback} with the arguments (node, state, type) for each node + * @param node + * @param callback + * @param base + * @param state + */ +export function full( + node: acorn.Node, + callback: FullWalkerCallback, + base?: RecursiveVisitors, + state?: TState +): void + +/** + * does a 'full' walk over a tree, building up an array of ancestor nodes (including the current node) and passing the array to the callbacks as a third parameter. + * @param node + * @param callback + * @param base + * @param state + */ +export function fullAncestor( + node: acorn.AnyNode, + callback: FullAncestorWalkerCallback, + base?: RecursiveVisitors, + state?: TState +): void + +/** + * builds a new walker object by using the walker functions in {@link functions} and filling in the missing ones by taking defaults from {@link base}. + * @param functions + * @param base + */ +export function make( + functions: RecursiveVisitors, + base?: RecursiveVisitors +): RecursiveVisitors + +/** + * tries to locate a node in a tree at the given start and/or end offsets, which satisfies the predicate test. {@link start} and {@link end} can be either `null` (as wildcard) or a `number`. {@link test} may be a string (indicating a node type) or a function that takes (nodeType, node) arguments and returns a boolean indicating whether this node is interesting. {@link base} and {@link state} are optional, and can be used to specify a custom walker. Nodes are tested from inner to outer, so if two nodes match the boundaries, the inner one will be preferred. + * @param node + * @param start + * @param end + * @param type + * @param base + * @param state + */ +export function findNodeAt( + node: acorn.AnyNode, + start: number | undefined, + end?: number | undefined, + type?: FindPredicate | string, + base?: RecursiveVisitors, + state?: TState +): Found | undefined + +/** + * like {@link findNodeAt}, but will match any node that exists 'around' (spanning) the given position. + * @param node + * @param start + * @param type + * @param base + * @param state + */ +export function findNodeAround( + node: acorn.AnyNode, + start: number | undefined, + type?: FindPredicate | string, + base?: RecursiveVisitors, + state?: TState +): Found | undefined + +/** + * similar to {@link findNodeAround}, but will match all nodes after the given position (testing outer nodes before inner nodes). + */ +export const findNodeAfter: typeof findNodeAround + +export const base: RecursiveVisitors diff --git a/packages/apps-engine/deno-runtime/acorn.d.ts b/packages/apps-engine/deno-runtime/acorn.d.ts new file mode 100644 index 000000000000..0b5bc6b407b2 --- /dev/null +++ b/packages/apps-engine/deno-runtime/acorn.d.ts @@ -0,0 +1,857 @@ +export interface Node { + start?: number + end?: number + type: string + range?: [number, number] + loc?: SourceLocation | null +} + +export interface SourceLocation { + source?: string | null + start: Position + end: Position +} + +export interface Position { + /** 1-based */ + line: number + /** 0-based */ + column: number +} + +export interface Identifier extends Node { + type: "Identifier" + name: string +} + +export interface Literal extends Node { + type: "Literal" + value?: string | boolean | null | number | RegExp | bigint + raw?: string + regex?: { + pattern: string + flags: string + } + bigint?: string +} + +export interface Program extends Node { + type: "Program" + body: Array + sourceType: "script" | "module" +} + +export interface Function extends Node { + id?: Identifier | null + params: Array + body: BlockStatement | Expression + generator: boolean + expression: boolean + async: boolean +} + +export interface ExpressionStatement extends Node { + type: "ExpressionStatement" + expression: Expression | Literal + directive?: string +} + +export interface BlockStatement extends Node { + type: "BlockStatement" + body: Array +} + +export interface EmptyStatement extends Node { + type: "EmptyStatement" +} + +export interface DebuggerStatement extends Node { + type: "DebuggerStatement" +} + +export interface WithStatement extends Node { + type: "WithStatement" + object: Expression + body: Statement +} + +export interface ReturnStatement extends Node { + type: "ReturnStatement" + argument?: Expression | null +} + +export interface LabeledStatement extends Node { + type: "LabeledStatement" + label: Identifier + body: Statement +} + +export interface BreakStatement extends Node { + type: "BreakStatement" + label?: Identifier | null +} + +export interface ContinueStatement extends Node { + type: "ContinueStatement" + label?: Identifier | null +} + +export interface IfStatement extends Node { + type: "IfStatement" + test: Expression + consequent: Statement + alternate?: Statement | null +} + +export interface SwitchStatement extends Node { + type: "SwitchStatement" + discriminant: Expression + cases: Array +} + +export interface SwitchCase extends Node { + type: "SwitchCase" + test?: Expression | null + consequent: Array +} + +export interface ThrowStatement extends Node { + type: "ThrowStatement" + argument: Expression +} + +export interface TryStatement extends Node { + type: "TryStatement" + block: BlockStatement + handler?: CatchClause | null + finalizer?: BlockStatement | null +} + +export interface CatchClause extends Node { + type: "CatchClause" + param?: Pattern | null + body: BlockStatement +} + +export interface WhileStatement extends Node { + type: "WhileStatement" + test: Expression + body: Statement +} + +export interface DoWhileStatement extends Node { + type: "DoWhileStatement" + body: Statement + test: Expression +} + +export interface ForStatement extends Node { + type: "ForStatement" + init?: VariableDeclaration | Expression | null + test?: Expression | null + update?: Expression | null + body: Statement +} + +export interface ForInStatement extends Node { + type: "ForInStatement" + left: VariableDeclaration | Pattern + right: Expression + body: Statement +} + +export interface FunctionDeclaration extends Function { + type: "FunctionDeclaration" + id: Identifier + body: BlockStatement +} + +export interface VariableDeclaration extends Node { + type: "VariableDeclaration" + declarations: Array + kind: "var" | "let" | "const" +} + +export interface VariableDeclarator extends Node { + type: "VariableDeclarator" + id: Pattern + init?: Expression | null +} + +export interface ThisExpression extends Node { + type: "ThisExpression" +} + +export interface ArrayExpression extends Node { + type: "ArrayExpression" + elements: Array +} + +export interface ObjectExpression extends Node { + type: "ObjectExpression" + properties: Array +} + +export interface Property extends Node { + type: "Property" + key: Expression + value: Expression + kind: "init" | "get" | "set" + method: boolean + shorthand: boolean + computed: boolean +} + +export interface FunctionExpression extends Function { + type: "FunctionExpression" + body: BlockStatement +} + +export interface UnaryExpression extends Node { + type: "UnaryExpression" + operator: UnaryOperator + prefix: boolean + argument: Expression +} + +export type UnaryOperator = "-" | "+" | "!" | "~" | "typeof" | "void" | "delete" + +export interface UpdateExpression extends Node { + type: "UpdateExpression" + operator: UpdateOperator + argument: Expression + prefix: boolean +} + +export type UpdateOperator = "++" | "--" + +export interface BinaryExpression extends Node { + type: "BinaryExpression" + operator: BinaryOperator + left: Expression | PrivateIdentifier + right: Expression +} + +export type BinaryOperator = "==" | "!=" | "===" | "!==" | "<" | "<=" | ">" | ">=" | "<<" | ">>" | ">>>" | "+" | "-" | "*" | "/" | "%" | "|" | "^" | "&" | "in" | "instanceof" | "**" + +export interface AssignmentExpression extends Node { + type: "AssignmentExpression" + operator: AssignmentOperator + left: Pattern + right: Expression +} + +export type AssignmentOperator = "=" | "+=" | "-=" | "*=" | "/=" | "%=" | "<<=" | ">>=" | ">>>=" | "|=" | "^=" | "&=" | "**=" | "||=" | "&&=" | "??=" + +export interface LogicalExpression extends Node { + type: "LogicalExpression" + operator: LogicalOperator + left: Expression + right: Expression +} + +export type LogicalOperator = "||" | "&&" | "??" + +export interface MemberExpression extends Node { + type: "MemberExpression" + object: Expression | Super + property: Expression | PrivateIdentifier + computed: boolean + optional: boolean +} + +export interface ConditionalExpression extends Node { + type: "ConditionalExpression" + test: Expression + alternate: Expression + consequent: Expression +} + +export interface CallExpression extends Node { + type: "CallExpression" + callee: Expression | Super + arguments: Array + optional: boolean +} + +export interface NewExpression extends Node { + type: "NewExpression" + callee: Expression + arguments: Array +} + +export interface SequenceExpression extends Node { + type: "SequenceExpression" + expressions: Array +} + +export interface ForOfStatement extends Node { + type: "ForOfStatement" + left: VariableDeclaration | Pattern + right: Expression + body: Statement + await: boolean +} + +export interface Super extends Node { + type: "Super" +} + +export interface SpreadElement extends Node { + type: "SpreadElement" + argument: Expression +} + +export interface ArrowFunctionExpression extends Function { + type: "ArrowFunctionExpression" +} + +export interface YieldExpression extends Node { + type: "YieldExpression" + argument?: Expression | null + delegate: boolean +} + +export interface TemplateLiteral extends Node { + type: "TemplateLiteral" + quasis: Array + expressions: Array +} + +export interface TaggedTemplateExpression extends Node { + type: "TaggedTemplateExpression" + tag: Expression + quasi: TemplateLiteral +} + +export interface TemplateElement extends Node { + type: "TemplateElement" + tail: boolean + value: { + cooked?: string | null + raw: string + } +} + +export interface AssignmentProperty extends Node { + type: "Property" + key: Expression + value: Pattern + kind: "init" + method: false + shorthand: boolean + computed: boolean +} + +export interface ObjectPattern extends Node { + type: "ObjectPattern" + properties: Array +} + +export interface ArrayPattern extends Node { + type: "ArrayPattern" + elements: Array +} + +export interface RestElement extends Node { + type: "RestElement" + argument: Pattern +} + +export interface AssignmentPattern extends Node { + type: "AssignmentPattern" + left: Pattern + right: Expression +} + +export interface Class extends Node { + id?: Identifier | null + superClass?: Expression | null + body: ClassBody +} + +export interface ClassBody extends Node { + type: "ClassBody" + body: Array +} + +export interface MethodDefinition extends Node { + type: "MethodDefinition" + key: Expression | PrivateIdentifier + value: FunctionExpression + kind: "constructor" | "method" | "get" | "set" + computed: boolean + static: boolean +} + +export interface ClassDeclaration extends Class { + type: "ClassDeclaration" + id: Identifier +} + +export interface ClassExpression extends Class { + type: "ClassExpression" +} + +export interface MetaProperty extends Node { + type: "MetaProperty" + meta: Identifier + property: Identifier +} + +export interface ImportDeclaration extends Node { + type: "ImportDeclaration" + specifiers: Array + source: Literal +} + +export interface ImportSpecifier extends Node { + type: "ImportSpecifier" + imported: Identifier | Literal + local: Identifier +} + +export interface ImportDefaultSpecifier extends Node { + type: "ImportDefaultSpecifier" + local: Identifier +} + +export interface ImportNamespaceSpecifier extends Node { + type: "ImportNamespaceSpecifier" + local: Identifier +} + +export interface ExportNamedDeclaration extends Node { + type: "ExportNamedDeclaration" + declaration?: Declaration | null + specifiers: Array + source?: Literal | null +} + +export interface ExportSpecifier extends Node { + type: "ExportSpecifier" + exported: Identifier | Literal + local: Identifier | Literal +} + +export interface AnonymousFunctionDeclaration extends Function { + type: "FunctionDeclaration" + id: null + body: BlockStatement +} + +export interface AnonymousClassDeclaration extends Class { + type: "ClassDeclaration" + id: null +} + +export interface ExportDefaultDeclaration extends Node { + type: "ExportDefaultDeclaration" + declaration: AnonymousFunctionDeclaration | FunctionDeclaration | AnonymousClassDeclaration | ClassDeclaration | Expression +} + +export interface ExportAllDeclaration extends Node { + type: "ExportAllDeclaration" + source: Literal + exported?: Identifier | Literal | null +} + +export interface AwaitExpression extends Node { + type: "AwaitExpression" + argument: Expression +} + +export interface ChainExpression extends Node { + type: "ChainExpression" + expression: MemberExpression | CallExpression +} + +export interface ImportExpression extends Node { + type: "ImportExpression" + source: Expression +} + +export interface ParenthesizedExpression extends Node { + type: "ParenthesizedExpression" + expression: Expression +} + +export interface PropertyDefinition extends Node { + type: "PropertyDefinition" + key: Expression | PrivateIdentifier + value?: Expression | null + computed: boolean + static: boolean +} + +export interface PrivateIdentifier extends Node { + type: "PrivateIdentifier" + name: string +} + +export interface StaticBlock extends Node { + type: "StaticBlock" + body: Array +} + +export type Statement = +| ExpressionStatement +| BlockStatement +| EmptyStatement +| DebuggerStatement +| WithStatement +| ReturnStatement +| LabeledStatement +| BreakStatement +| ContinueStatement +| IfStatement +| SwitchStatement +| ThrowStatement +| TryStatement +| WhileStatement +| DoWhileStatement +| ForStatement +| ForInStatement +| ForOfStatement +| Declaration + +export type Declaration = +| FunctionDeclaration +| VariableDeclaration +| ClassDeclaration + +export type Expression = +| Identifier +| Literal +| ThisExpression +| ArrayExpression +| ObjectExpression +| FunctionExpression +| UnaryExpression +| UpdateExpression +| BinaryExpression +| AssignmentExpression +| LogicalExpression +| MemberExpression +| ConditionalExpression +| CallExpression +| NewExpression +| SequenceExpression +| ArrowFunctionExpression +| YieldExpression +| TemplateLiteral +| TaggedTemplateExpression +| ClassExpression +| MetaProperty +| AwaitExpression +| ChainExpression +| ImportExpression +| ParenthesizedExpression + +export type Pattern = +| Identifier +| MemberExpression +| ObjectPattern +| ArrayPattern +| RestElement +| AssignmentPattern + +export type ModuleDeclaration = +| ImportDeclaration +| ExportNamedDeclaration +| ExportDefaultDeclaration +| ExportAllDeclaration + +export type AnyNode = Statement | Expression | Declaration | ModuleDeclaration | Literal | Program | SwitchCase | CatchClause | Property | Super | SpreadElement | TemplateElement | AssignmentProperty | ObjectPattern | ArrayPattern | RestElement | AssignmentPattern | ClassBody | MethodDefinition | MetaProperty | ImportSpecifier | ImportDefaultSpecifier | ImportNamespaceSpecifier | ExportSpecifier | AnonymousFunctionDeclaration | AnonymousClassDeclaration | PropertyDefinition | PrivateIdentifier | StaticBlock | VariableDeclaration | VariableDeclarator + +export function parse(input: string, options: Options): Program + +export function parseExpressionAt(input: string, pos: number, options: Options): Expression + +export function tokenizer(input: string, options: Options): { + getToken(): Token + [Symbol.iterator](): Iterator +} + +export type ecmaVersion = 3 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 2015 | 2016 | 2017 | 2018 | 2019 | 2020 | 2021 | 2022 | 2023 | 2024 | "latest" + +export interface Options { + /** + * `ecmaVersion` indicates the ECMAScript version to parse. Must be + * either 3, 5, 6 (or 2015), 7 (2016), 8 (2017), 9 (2018), 10 + * (2019), 11 (2020), 12 (2021), 13 (2022), 14 (2023), or `"latest"` + * (the latest version the library supports). This influences + * support for strict mode, the set of reserved words, and support + * for new syntax features. + */ + ecmaVersion: ecmaVersion + + /** + * `sourceType` indicates the mode the code should be parsed in. + * Can be either `"script"` or `"module"`. This influences global + * strict mode and parsing of `import` and `export` declarations. + */ + sourceType?: "script" | "module" + + /** + * a callback that will be called when a semicolon is automatically inserted. + * @param lastTokEnd the position of the comma as an offset + * @param lastTokEndLoc location if {@link locations} is enabled + */ + onInsertedSemicolon?: (lastTokEnd: number, lastTokEndLoc?: Position) => void + + /** + * similar to `onInsertedSemicolon`, but for trailing commas + * @param lastTokEnd the position of the comma as an offset + * @param lastTokEndLoc location if `locations` is enabled + */ + onTrailingComma?: (lastTokEnd: number, lastTokEndLoc?: Position) => void + + /** + * By default, reserved words are only enforced if ecmaVersion >= 5. + * Set `allowReserved` to a boolean value to explicitly turn this on + * an off. When this option has the value "never", reserved words + * and keywords can also not be used as property names. + */ + allowReserved?: boolean | "never" + + /** + * When enabled, a return at the top level is not considered an error. + */ + allowReturnOutsideFunction?: boolean + + /** + * When enabled, import/export statements are not constrained to + * appearing at the top of the program, and an import.meta expression + * in a script isn't considered an error. + */ + allowImportExportEverywhere?: boolean + + /** + * By default, `await` identifiers are allowed to appear at the top-level scope only if {@link ecmaVersion} >= 2022. + * When enabled, await identifiers are allowed to appear at the top-level scope, + * but they are still not allowed in non-async functions. + */ + allowAwaitOutsideFunction?: boolean + + /** + * When enabled, super identifiers are not constrained to + * appearing in methods and do not raise an error when they appear elsewhere. + */ + allowSuperOutsideMethod?: boolean + + /** + * When enabled, hashbang directive in the beginning of file is + * allowed and treated as a line comment. Enabled by default when + * {@link ecmaVersion} >= 2023. + */ + allowHashBang?: boolean + + /** + * By default, the parser will verify that private properties are + * only used in places where they are valid and have been declared. + * Set this to false to turn such checks off. + */ + checkPrivateFields?: boolean + + /** + * When `locations` is on, `loc` properties holding objects with + * `start` and `end` properties as {@link Position} objects will be attached to the + * nodes. + */ + locations?: boolean + + /** + * a callback that will cause Acorn to call that export function with object in the same + * format as tokens returned from `tokenizer().getToken()`. Note + * that you are not allowed to call the parser from the + * callback—that will corrupt its internal state. + */ + onToken?: ((token: Token) => void) | Token[] + + + /** + * This takes a export function or an array. + * + * When a export function is passed, Acorn will call that export function with `(block, text, start, + * end)` parameters whenever a comment is skipped. `block` is a + * boolean indicating whether this is a block (`/* *\/`) comment, + * `text` is the content of the comment, and `start` and `end` are + * character offsets that denote the start and end of the comment. + * When the {@link locations} option is on, two more parameters are + * passed, the full locations of {@link Position} export type of the start and + * end of the comments. + * + * When a array is passed, each found comment of {@link Comment} export type is pushed to the array. + * + * Note that you are not allowed to call the + * parser from the callback—that will corrupt its internal state. + */ + onComment?: (( + isBlock: boolean, text: string, start: number, end: number, startLoc?: Position, + endLoc?: Position + ) => void) | Comment[] + + /** + * Nodes have their start and end characters offsets recorded in + * `start` and `end` properties (directly on the node, rather than + * the `loc` object, which holds line/column data. To also add a + * [semi-standardized][range] `range` property holding a `[start, + * end]` array with the same numbers, set the `ranges` option to + * `true`. + */ + ranges?: boolean + + /** + * It is possible to parse multiple files into a single AST by + * passing the tree produced by parsing the first file as + * `program` option in subsequent parses. This will add the + * toplevel forms of the parsed file to the `Program` (top) node + * of an existing parse tree. + */ + program?: Node + + /** + * When {@link locations} is on, you can pass this to record the source + * file in every node's `loc` object. + */ + sourceFile?: string + + /** + * This value, if given, is stored in every node, whether {@link locations} is on or off. + */ + directSourceFile?: string + + /** + * When enabled, parenthesized expressions are represented by + * (non-standard) ParenthesizedExpression nodes + */ + preserveParens?: boolean +} + +export class Parser { + options: Options + input: string + + constructor(options: Options, input: string, startPos?: number) + parse(): Program + + static parse(input: string, options: Options): Program + static parseExpressionAt(input: string, pos: number, options: Options): Expression + static tokenizer(input: string, options: Options): { + getToken(): Token + [Symbol.iterator](): Iterator + } + static extend(...plugins: ((BaseParser: typeof Parser) => typeof Parser)[]): typeof Parser +} + +export const defaultOptions: Options + +export function getLineInfo(input: string, offset: number): Position + +export class TokenType { + label: string + keyword: string | undefined +} + +export const tokTypes: { + num: TokenType + regexp: TokenType + string: TokenType + name: TokenType + privateId: TokenType + eof: TokenType + + bracketL: TokenType + bracketR: TokenType + braceL: TokenType + braceR: TokenType + parenL: TokenType + parenR: TokenType + comma: TokenType + semi: TokenType + colon: TokenType + dot: TokenType + question: TokenType + questionDot: TokenType + arrow: TokenType + template: TokenType + invalidTemplate: TokenType + ellipsis: TokenType + backQuote: TokenType + dollarBraceL: TokenType + + eq: TokenType + assign: TokenType + incDec: TokenType + prefix: TokenType + logicalOR: TokenType + logicalAND: TokenType + bitwiseOR: TokenType + bitwiseXOR: TokenType + bitwiseAND: TokenType + equality: TokenType + relational: TokenType + bitShift: TokenType + plusMin: TokenType + modulo: TokenType + star: TokenType + slash: TokenType + starstar: TokenType + coalesce: TokenType + + _break: TokenType + _case: TokenType + _catch: TokenType + _continue: TokenType + _debugger: TokenType + _default: TokenType + _do: TokenType + _else: TokenType + _finally: TokenType + _for: TokenType + _function: TokenType + _if: TokenType + _return: TokenType + _switch: TokenType + _throw: TokenType + _try: TokenType + _var: TokenType + _const: TokenType + _while: TokenType + _with: TokenType + _new: TokenType + _this: TokenType + _super: TokenType + _class: TokenType + _extends: TokenType + _export: TokenType + _import: TokenType + _null: TokenType + _true: TokenType + _false: TokenType + _in: TokenType + _instanceof: TokenType + _typeof: TokenType + _void: TokenType + _delete: TokenType +} + +export interface Comment { + type: "Line" | "Block" + value: string + start: number + end: number + loc?: SourceLocation + range?: [number, number] +} + +export class Token { + type: TokenType + start: number + end: number + loc?: SourceLocation + range?: [number, number] +} + +export const version: string diff --git a/packages/apps-engine/deno-runtime/deno.jsonc b/packages/apps-engine/deno-runtime/deno.jsonc new file mode 100644 index 000000000000..231d0924237a --- /dev/null +++ b/packages/apps-engine/deno-runtime/deno.jsonc @@ -0,0 +1,16 @@ +{ + "imports": { + "@rocket.chat/apps-engine/": "./../src/", + "@rocket.chat/ui-kit": "npm:@rocket.chat/ui-kit@^0.31.22", + "@msgpack/msgpack": "npm:@msgpack/msgpack@3.0.0-beta2", + "acorn": "npm:acorn@8.10.0", + "acorn-walk": "npm:acorn-walk@8.2.0", + "astring": "npm:astring@1.8.6", + "jsonrpc-lite": "npm:jsonrpc-lite@2.2.0", + "stack-trace": "npm:stack-trace@0.0.10", + "uuid": "npm:uuid@8.3.2" + }, + "tasks": { + "test": "deno test --no-check --allow-read=../../../" + } +} diff --git a/packages/apps-engine/deno-runtime/deno.lock b/packages/apps-engine/deno-runtime/deno.lock new file mode 100644 index 000000000000..86cebf98f63a --- /dev/null +++ b/packages/apps-engine/deno-runtime/deno.lock @@ -0,0 +1,107 @@ +{ + "version": "3", + "packages": { + "specifiers": { + "npm:@msgpack/msgpack@3.0.0-beta2": "npm:@msgpack/msgpack@3.0.0-beta2", + "npm:@rocket.chat/ui-kit@^0.31.22": "npm:@rocket.chat/ui-kit@0.31.25_@rocket.chat+icons@0.32.0", + "npm:acorn-walk@8.2.0": "npm:acorn-walk@8.2.0", + "npm:acorn@8.10.0": "npm:acorn@8.10.0", + "npm:astring@1.8.6": "npm:astring@1.8.6", + "npm:jsonrpc-lite@2.2.0": "npm:jsonrpc-lite@2.2.0", + "npm:stack-trace": "npm:stack-trace@0.0.10", + "npm:stack-trace@0.0.10": "npm:stack-trace@0.0.10", + "npm:uuid@8.3.2": "npm:uuid@8.3.2" + }, + "npm": { + "@msgpack/msgpack@3.0.0-beta2": { + "integrity": "sha512-y+l1PNV0XDyY8sM3YtuMLK5vE3/hkfId+Do8pLo/OPxfxuFAUwcGz3oiiUuV46/aBpwTzZ+mRWVMtlSKbradhw==", + "dependencies": {} + }, + "@rocket.chat/icons@0.32.0": { + "integrity": "sha512-7yhhELKNLb9kUtXCvau0V+iMXraV2bOsxcPjc/ZtLR5VeeIDTeaflqRWGtLroX6f3bE+J1n5qB5zi8A4YXuH2g==", + "dependencies": {} + }, + "@rocket.chat/ui-kit@0.31.25_@rocket.chat+icons@0.32.0": { + "integrity": "sha512-yTgTKDw9SMlJ6p8n0PDO6zSvox/nHYUrwCIvILQeAK6PvTrgSe/u9CvU7ATTYjnQiQ603yEGR6dxjF4euCGdNA==", + "dependencies": { + "@rocket.chat/icons": "@rocket.chat/icons@0.32.0" + } + }, + "acorn-walk@8.2.0": { + "integrity": "sha512-k+iyHEuPgSw6SbuDpGQM+06HQUa04DZ3o+F6CSzXMvvI5KMvnaEqXe+YVe555R9nn6GPt404fos4wcgpw12SDA==", + "dependencies": {} + }, + "acorn@8.10.0": { + "integrity": "sha512-F0SAmZ8iUtS//m8DmCTA0jlh6TDKkHQyK6xc6V4KDTyZKA9dnvX9/3sRTVQrWm79glUAZbnmmNcdYwUIHWVybw==", + "dependencies": {} + }, + "astring@1.8.6": { + "integrity": "sha512-ISvCdHdlTDlH5IpxQJIex7BWBywFWgjJSVdwst+/iQCoEYnyOaQ95+X1JGshuBjGp6nxKUy1jMgE3zPqN7fQdg==", + "dependencies": {} + }, + "jsonrpc-lite@2.2.0": { + "integrity": "sha512-/cbbSxtZWs1O7R4tWqabrCM/t3N8qKUZMAg9IUqpPvUs6UyRvm6pCNYkskyKN/XU0UgffW+NY2ZRr8t0AknX7g==", + "dependencies": {} + }, + "stack-trace@0.0.10": { + "integrity": "sha512-KGzahc7puUKkzyMt+IqAep+TVNbKP+k2Lmwhub39m1AsTSkaDutx56aDCo+HLDzf/D26BIHTJWNiTG1KAJiQCg==", + "dependencies": {} + }, + "uuid@8.3.2": { + "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", + "dependencies": {} + } + } + }, + "remote": { + "https://deno.land/std@0.203.0/assert/_constants.ts": "8a9da298c26750b28b326b297316cdde860bc237533b07e1337c021379e6b2a9", + "https://deno.land/std@0.203.0/assert/_diff.ts": "1a3c044aedf77647d6cac86b798c6417603361b66b54c53331b312caeb447aea", + "https://deno.land/std@0.203.0/assert/_format.ts": "a69126e8a469009adf4cf2a50af889aca364c349797e63174884a52ff75cf4c7", + "https://deno.land/std@0.203.0/assert/assert.ts": "9a97dad6d98c238938e7540736b826440ad8c1c1e54430ca4c4e623e585607ee", + "https://deno.land/std@0.203.0/assert/assert_almost_equals.ts": "e15ca1f34d0d5e0afae63b3f5d975cbd18335a132e42b0c747d282f62ad2cd6c", + "https://deno.land/std@0.203.0/assert/assert_array_includes.ts": "6856d7f2c3544bc6e62fb4646dfefa3d1df5ff14744d1bca19f0cbaf3b0d66c9", + "https://deno.land/std@0.203.0/assert/assert_equals.ts": "d8ec8a22447fbaf2fc9d7c3ed2e66790fdb74beae3e482855d75782218d68227", + "https://deno.land/std@0.203.0/assert/assert_exists.ts": "407cb6b9fb23a835cd8d5ad804e2e2edbbbf3870e322d53f79e1c7a512e2efd7", + "https://deno.land/std@0.203.0/assert/assert_false.ts": "0ccbcaae910f52c857192ff16ea08bda40fdc79de80846c206bfc061e8c851c6", + "https://deno.land/std@0.203.0/assert/assert_greater.ts": "ae2158a2d19313bf675bf7251d31c6dc52973edb12ac64ac8fc7064152af3e63", + "https://deno.land/std@0.203.0/assert/assert_greater_or_equal.ts": "1439da5ebbe20855446cac50097ac78b9742abe8e9a43e7de1ce1426d556e89c", + "https://deno.land/std@0.203.0/assert/assert_instance_of.ts": "3aedb3d8186e120812d2b3a5dea66a6e42bf8c57a8bd927645770bd21eea554c", + "https://deno.land/std@0.203.0/assert/assert_is_error.ts": "c21113094a51a296ffaf036767d616a78a2ae5f9f7bbd464cd0197476498b94b", + "https://deno.land/std@0.203.0/assert/assert_less.ts": "aec695db57db42ec3e2b62e97e1e93db0063f5a6ec133326cc290ff4b71b47e4", + "https://deno.land/std@0.203.0/assert/assert_less_or_equal.ts": "5fa8b6a3ffa20fd0a05032fe7257bf985d207b85685fdbcd23651b70f928c848", + "https://deno.land/std@0.203.0/assert/assert_match.ts": "c4083f80600bc190309903c95e397a7c9257ff8b5ae5c7ef91e834704e672e9b", + "https://deno.land/std@0.203.0/assert/assert_not_equals.ts": "9f1acab95bd1f5fc9a1b17b8027d894509a745d91bac1718fdab51dc76831754", + "https://deno.land/std@0.203.0/assert/assert_not_instance_of.ts": "0c14d3dfd9ab7a5276ed8ed0b18c703d79a3d106102077ec437bfe7ed912bd22", + "https://deno.land/std@0.203.0/assert/assert_not_match.ts": "3796a5b0c57a1ce6c1c57883dd4286be13a26f715ea662318ab43a8491a13ab0", + "https://deno.land/std@0.203.0/assert/assert_not_strict_equals.ts": "ca6c6d645e95fbc873d25320efeb8c4c6089a9a5e09f92d7c1c4b6e935c2a6ad", + "https://deno.land/std@0.203.0/assert/assert_object_match.ts": "d8fc2867cfd92eeacf9cea621e10336b666de1874a6767b5ec48988838370b54", + "https://deno.land/std@0.203.0/assert/assert_rejects.ts": "45c59724de2701e3b1f67c391d6c71c392363635aad3f68a1b3408f9efca0057", + "https://deno.land/std@0.203.0/assert/assert_strict_equals.ts": "b1f538a7ea5f8348aeca261d4f9ca603127c665e0f2bbfeb91fa272787c87265", + "https://deno.land/std@0.203.0/assert/assert_string_includes.ts": "b821d39ebf5cb0200a348863c86d8c4c4b398e02012ce74ad15666fc4b631b0c", + "https://deno.land/std@0.203.0/assert/assert_throws.ts": "63784e951475cb7bdfd59878cd25a0931e18f6dc32a6077c454b2cd94f4f4bcd", + "https://deno.land/std@0.203.0/assert/assertion_error.ts": "4d0bde9b374dfbcbe8ac23f54f567b77024fb67dbb1906a852d67fe050d42f56", + "https://deno.land/std@0.203.0/assert/equal.ts": "9f1a46d5993966d2596c44e5858eec821859b45f783a5ee2f7a695dfc12d8ece", + "https://deno.land/std@0.203.0/assert/fail.ts": "c36353d7ae6e1f7933d45f8ea51e358c8c4b67d7e7502028598fe1fea062e278", + "https://deno.land/std@0.203.0/assert/mod.ts": "37c49a26aae2b254bbe25723434dc28cd7532e444cf0b481a97c045d110ec085", + "https://deno.land/std@0.203.0/assert/unimplemented.ts": "d56fbeecb1f108331a380f72e3e010a1f161baa6956fd0f7cf3e095ae1a4c75a", + "https://deno.land/std@0.203.0/assert/unreachable.ts": "4600dc0baf7d9c15a7f7d234f00c23bca8f3eba8b140286aaca7aa998cf9a536", + "https://deno.land/std@0.203.0/fmt/colors.ts": "c51c4642678eb690dcf5ffee5918b675bf01a33fba82acf303701ae1a4f8c8d9", + "https://deno.land/std@0.203.0/testing/_test_suite.ts": "30f018feeb3835f12ab198d8a518f9089b1bcb2e8c838a8b615ab10d5005465c", + "https://deno.land/std@0.203.0/testing/bdd.ts": "3f446df5ef8e856a869e8eec54c8482590415741ff0b6358a00c43486cc15769", + "https://deno.land/std@0.203.0/testing/mock.ts": "6576b4aa55ee20b1990d656a78fff83599e190948c00e9f25a7f3ac5e9d6492d", + "https://deno.land/std@0.216.0/io/types.ts": "748bbb3ac96abda03594ef5a0db15ce5450dcc6c0d841c8906f8b10ac8d32c96", + "https://deno.land/std@0.216.0/io/write_all.ts": "24aac2312bb21096ae3ae0b102b22c26164d3249dff96dbac130958aa736f038" + }, + "workspace": { + "dependencies": [ + "npm:@msgpack/msgpack@3.0.0-beta2", + "npm:@rocket.chat/ui-kit@^0.31.22", + "npm:acorn-walk@8.2.0", + "npm:acorn@8.10.0", + "npm:astring@1.8.6", + "npm:jsonrpc-lite@2.2.0", + "npm:stack-trace@0.0.10", + "npm:uuid@8.3.2" + ] + } +} diff --git a/packages/apps-engine/deno-runtime/handlers/api-handler.ts b/packages/apps-engine/deno-runtime/handlers/api-handler.ts new file mode 100644 index 000000000000..32d30e532fd3 --- /dev/null +++ b/packages/apps-engine/deno-runtime/handlers/api-handler.ts @@ -0,0 +1,46 @@ +import { Defined, JsonRpcError } from 'jsonrpc-lite'; +import type { IApiEndpoint } from '@rocket.chat/apps-engine/definition/api/IApiEndpoint.ts'; + +import { AppObjectRegistry } from '../AppObjectRegistry.ts'; +import { Logger } from '../lib/logger.ts'; +import { AppAccessorsInstance } from '../lib/accessors/mod.ts'; + +export default async function apiHandler(call: string, params: unknown): Promise { + const [, path, httpMethod] = call.split(':'); + + const endpoint = AppObjectRegistry.get(`api:${path}`); + const logger = AppObjectRegistry.get('logger'); + + if (!endpoint) { + return new JsonRpcError(`Endpoint ${path} not found`, -32000); + } + + const method = endpoint[httpMethod as keyof IApiEndpoint]; + + if (typeof method !== 'function') { + return new JsonRpcError(`${path}'s ${httpMethod} not exists`, -32000); + } + + const [request, endpointInfo] = params as Array; + + logger?.debug(`${path}'s ${call} is being executed...`, request); + + try { + // deno-lint-ignore ban-types + const result = await (method as Function).apply(endpoint, [ + request, + endpointInfo, + AppAccessorsInstance.getReader(), + AppAccessorsInstance.getModifier(), + AppAccessorsInstance.getHttp(), + AppAccessorsInstance.getPersistence(), + ]); + + logger?.debug(`${path}'s ${call} was successfully executed.`); + + return result; + } catch (e) { + logger?.debug(`${path}'s ${call} was unsuccessful.`); + return new JsonRpcError(e.message || "Internal server error", -32000); + } +} diff --git a/packages/apps-engine/deno-runtime/handlers/app/construct.ts b/packages/apps-engine/deno-runtime/handlers/app/construct.ts new file mode 100644 index 000000000000..798a83d0923c --- /dev/null +++ b/packages/apps-engine/deno-runtime/handlers/app/construct.ts @@ -0,0 +1,126 @@ +import type { IParseAppPackageResult } from '@rocket.chat/apps-engine/server/compiler/IParseAppPackageResult.ts'; + +import { AppObjectRegistry } from '../../AppObjectRegistry.ts'; +import { require } from '../../lib/require.ts'; +import { sanitizeDeprecatedUsage } from '../../lib/sanitizeDeprecatedUsage.ts'; +import { AppAccessorsInstance } from '../../lib/accessors/mod.ts'; +import { Socket } from 'node:net'; + +const ALLOWED_NATIVE_MODULES = ['path', 'url', 'crypto', 'buffer', 'stream', 'net', 'http', 'https', 'zlib', 'util', 'punycode', 'os', 'querystring', 'fs']; +const ALLOWED_EXTERNAL_MODULES = ['uuid']; + + +function prepareEnvironment() { + // Deno does not behave equally to Node when it comes to piping content to a socket + // So we intervene here + const originalFinal = Socket.prototype._final; + Socket.prototype._final = function _final(cb) { + // Deno closes the readable stream in the Socket earlier than Node + // The exact reason for that is yet unknown, so we'll need to simply delay the execution + // which allows data to be read in a response + setTimeout(() => originalFinal.call(this, cb), 1); + }; +} + +// As the apps are bundled, the only times they will call require are +// 1. To require native modules +// 2. To require external npm packages we may provide +// 3. To require apps-engine files +function buildRequire(): (module: string) => unknown { + return (module: string): unknown => { + if (ALLOWED_NATIVE_MODULES.includes(module)) { + return require(`node:${module}`); + } + + if (ALLOWED_EXTERNAL_MODULES.includes(module)) { + return require(`npm:${module}`); + } + + if (module.startsWith('@rocket.chat/apps-engine')) { + // Our `require` function knows how to handle these + return require(module); + } + + throw new Error(`Module ${module} is not allowed`); + }; +} + +function wrapAppCode(code: string): (require: (module: string) => unknown) => Promise> { + return new Function( + 'require', + ` + const { Buffer } = require('buffer'); + const exports = {}; + const module = { exports }; + const _error = console.error.bind(console); + const _console = { + log: _error, + error: _error, + debug: _error, + info: _error, + warn: _error, + }; + + const result = (async (exports,module,require,Buffer,console,globalThis,Deno) => { + ${code}; + })(exports,module,require,Buffer,_console,undefined,undefined); + + return result.then(() => module.exports);`, + ) as (require: (module: string) => unknown) => Promise>; +} + +export default async function handleConstructApp(params: unknown): Promise { + if (!Array.isArray(params)) { + throw new Error('Invalid params', { cause: 'invalid_param_type' }); + } + + const [appPackage] = params as [IParseAppPackageResult]; + + if (!appPackage?.info?.id || !appPackage?.info?.classFile || !appPackage?.files) { + throw new Error('Invalid params', { cause: 'invalid_param_type' }); + } + + prepareEnvironment(); + + AppObjectRegistry.set('id', appPackage.info.id); + const source = sanitizeDeprecatedUsage(appPackage.files[appPackage.info.classFile]); + + const require = buildRequire(); + const exports = await wrapAppCode(source)(require); + + // This is the same naive logic we've been using in the App Compiler + // Applying the correct type here is quite difficult because of the dynamic nature of the code + // deno-lint-ignore no-explicit-any + const appClass = Object.values(exports)[0] as any; + const logger = AppObjectRegistry.get('logger'); + + const app = new appClass(appPackage.info, logger, AppAccessorsInstance.getDefaultAppAccessors()); + + if (typeof app.getName !== 'function') { + throw new Error('App must contain a getName function'); + } + + if (typeof app.getNameSlug !== 'function') { + throw new Error('App must contain a getNameSlug function'); + } + + if (typeof app.getVersion !== 'function') { + throw new Error('App must contain a getVersion function'); + } + + if (typeof app.getID !== 'function') { + throw new Error('App must contain a getID function'); + } + + if (typeof app.getDescription !== 'function') { + throw new Error('App must contain a getDescription function'); + } + + if (typeof app.getRequiredApiVersion !== 'function') { + throw new Error('App must contain a getRequiredApiVersion function'); + } + + AppObjectRegistry.set('app', app); + + return true; +} diff --git a/packages/apps-engine/deno-runtime/handlers/app/handleGetStatus.ts b/packages/apps-engine/deno-runtime/handlers/app/handleGetStatus.ts new file mode 100644 index 000000000000..5428d989812e --- /dev/null +++ b/packages/apps-engine/deno-runtime/handlers/app/handleGetStatus.ts @@ -0,0 +1,15 @@ +import type { App } from '@rocket.chat/apps-engine/definition/App.ts'; + +import { AppObjectRegistry } from '../../AppObjectRegistry.ts'; + +export default function handleGetStatus(): Promise { + const app = AppObjectRegistry.get('app'); + + if (typeof app?.getStatus !== 'function') { + throw new Error('App must contain a getStatus function', { + cause: 'invalid_app', + }); + } + + return app.getStatus(); +} diff --git a/packages/apps-engine/deno-runtime/handlers/app/handleInitialize.ts b/packages/apps-engine/deno-runtime/handlers/app/handleInitialize.ts new file mode 100644 index 000000000000..ad90d3b01e25 --- /dev/null +++ b/packages/apps-engine/deno-runtime/handlers/app/handleInitialize.ts @@ -0,0 +1,19 @@ +import type { App } from '@rocket.chat/apps-engine/definition/App.ts'; + +import { AppObjectRegistry } from '../../AppObjectRegistry.ts'; +import { AppAccessorsInstance } from '../../lib/accessors/mod.ts'; + +export default async function handleInitialize(): Promise { + const app = AppObjectRegistry.get('app'); + + if (typeof app?.initialize !== 'function') { + throw new Error('App must contain an initialize function', { + cause: 'invalid_app', + }); + } + + await app.initialize(AppAccessorsInstance.getConfigurationExtend(), AppAccessorsInstance.getEnvironmentRead()); + + return true; +} + diff --git a/packages/apps-engine/deno-runtime/handlers/app/handleOnDisable.ts b/packages/apps-engine/deno-runtime/handlers/app/handleOnDisable.ts new file mode 100644 index 000000000000..e66c2414fd0a --- /dev/null +++ b/packages/apps-engine/deno-runtime/handlers/app/handleOnDisable.ts @@ -0,0 +1,19 @@ +import type { App } from '@rocket.chat/apps-engine/definition/App.ts'; + +import { AppObjectRegistry } from '../../AppObjectRegistry.ts'; +import { AppAccessorsInstance } from '../../lib/accessors/mod.ts'; + +export default async function handleOnDisable(): Promise { + const app = AppObjectRegistry.get('app'); + + if (typeof app?.onDisable !== 'function') { + throw new Error('App must contain an onDisable function', { + cause: 'invalid_app', + }); + } + + await app.onDisable(AppAccessorsInstance.getConfigurationModify()); + + return true; +} + diff --git a/packages/apps-engine/deno-runtime/handlers/app/handleOnEnable.ts b/packages/apps-engine/deno-runtime/handlers/app/handleOnEnable.ts new file mode 100644 index 000000000000..1bdf84476422 --- /dev/null +++ b/packages/apps-engine/deno-runtime/handlers/app/handleOnEnable.ts @@ -0,0 +1,16 @@ +import type { App } from '@rocket.chat/apps-engine/definition/App.ts'; + +import { AppObjectRegistry } from '../../AppObjectRegistry.ts'; +import { AppAccessorsInstance } from '../../lib/accessors/mod.ts'; + +export default function handleOnEnable(): Promise { + const app = AppObjectRegistry.get('app'); + + if (typeof app?.onEnable !== 'function') { + throw new Error('App must contain an onEnable function', { + cause: 'invalid_app', + }); + } + + return app.onEnable(AppAccessorsInstance.getEnvironmentRead(), AppAccessorsInstance.getConfigurationModify()); +} diff --git a/packages/apps-engine/deno-runtime/handlers/app/handleOnInstall.ts b/packages/apps-engine/deno-runtime/handlers/app/handleOnInstall.ts new file mode 100644 index 000000000000..aebf7628a914 --- /dev/null +++ b/packages/apps-engine/deno-runtime/handlers/app/handleOnInstall.ts @@ -0,0 +1,30 @@ +import type { App } from '@rocket.chat/apps-engine/definition/App.ts'; + +import { AppObjectRegistry } from '../../AppObjectRegistry.ts'; +import { AppAccessorsInstance } from '../../lib/accessors/mod.ts'; + +export default async function handleOnInstall(params: unknown): Promise { + const app = AppObjectRegistry.get('app'); + + if (typeof app?.onInstall !== 'function') { + throw new Error('App must contain an onInstall function', { + cause: 'invalid_app', + }); + } + + if (!Array.isArray(params)) { + throw new Error('Invalid params', { cause: 'invalid_param_type' }); + } + + const [context] = params as [Record]; + + await app.onInstall( + context, + AppAccessorsInstance.getReader(), + AppAccessorsInstance.getHttp(), + AppAccessorsInstance.getPersistence(), + AppAccessorsInstance.getModifier(), + ); + + return true; +} diff --git a/packages/apps-engine/deno-runtime/handlers/app/handleOnPreSettingUpdate.ts b/packages/apps-engine/deno-runtime/handlers/app/handleOnPreSettingUpdate.ts new file mode 100644 index 000000000000..19646fa6704f --- /dev/null +++ b/packages/apps-engine/deno-runtime/handlers/app/handleOnPreSettingUpdate.ts @@ -0,0 +1,22 @@ +import type { App } from '@rocket.chat/apps-engine/definition/App.ts'; + +import { AppObjectRegistry } from '../../AppObjectRegistry.ts'; +import { AppAccessorsInstance } from '../../lib/accessors/mod.ts'; + +export default function handleOnPreSettingUpdate(params: unknown): Promise { + const app = AppObjectRegistry.get('app'); + + if (typeof app?.onPreSettingUpdate !== 'function') { + throw new Error('App must contain an onPreSettingUpdate function', { + cause: 'invalid_app', + }); + } + + if (!Array.isArray(params)) { + throw new Error('Invalid params', { cause: 'invalid_param_type' }); + } + + const [setting] = params as [Record]; + + return app.onPreSettingUpdate(setting, AppAccessorsInstance.getConfigurationModify(), AppAccessorsInstance.getReader(), AppAccessorsInstance.getHttp()); +} diff --git a/packages/apps-engine/deno-runtime/handlers/app/handleOnSettingUpdated.ts b/packages/apps-engine/deno-runtime/handlers/app/handleOnSettingUpdated.ts new file mode 100644 index 000000000000..07084bc22425 --- /dev/null +++ b/packages/apps-engine/deno-runtime/handlers/app/handleOnSettingUpdated.ts @@ -0,0 +1,24 @@ +import type { App } from '@rocket.chat/apps-engine/definition/App.ts'; + +import { AppObjectRegistry } from '../../AppObjectRegistry.ts'; +import { AppAccessorsInstance } from '../../lib/accessors/mod.ts'; + +export default async function handleOnSettingUpdated(params: unknown): Promise { + const app = AppObjectRegistry.get('app'); + + if (typeof app?.onSettingUpdated !== 'function') { + throw new Error('App must contain an onSettingUpdated function', { + cause: 'invalid_app', + }); + } + + if (!Array.isArray(params)) { + throw new Error('Invalid params', { cause: 'invalid_param_type' }); + } + + const [setting] = params as [Record]; + + await app.onSettingUpdated(setting, AppAccessorsInstance.getConfigurationModify(), AppAccessorsInstance.getReader(), AppAccessorsInstance.getHttp()); + + return true; +} diff --git a/packages/apps-engine/deno-runtime/handlers/app/handleOnUninstall.ts b/packages/apps-engine/deno-runtime/handlers/app/handleOnUninstall.ts new file mode 100644 index 000000000000..865819728ca4 --- /dev/null +++ b/packages/apps-engine/deno-runtime/handlers/app/handleOnUninstall.ts @@ -0,0 +1,30 @@ +import type { App } from '@rocket.chat/apps-engine/definition/App.ts'; + +import { AppObjectRegistry } from '../../AppObjectRegistry.ts'; +import { AppAccessorsInstance } from '../../lib/accessors/mod.ts'; + +export default async function handleOnUninstall(params: unknown): Promise { + const app = AppObjectRegistry.get('app'); + + if (typeof app?.onUninstall !== 'function') { + throw new Error('App must contain an onUninstall function', { + cause: 'invalid_app', + }); + } + + if (!Array.isArray(params)) { + throw new Error('Invalid params', { cause: 'invalid_param_type' }); + } + + const [context] = params as [Record]; + + await app.onUninstall( + context, + AppAccessorsInstance.getReader(), + AppAccessorsInstance.getHttp(), + AppAccessorsInstance.getPersistence(), + AppAccessorsInstance.getModifier(), + ); + + return true; +} diff --git a/packages/apps-engine/deno-runtime/handlers/app/handleOnUpdate.ts b/packages/apps-engine/deno-runtime/handlers/app/handleOnUpdate.ts new file mode 100644 index 000000000000..f21e4f947d5d --- /dev/null +++ b/packages/apps-engine/deno-runtime/handlers/app/handleOnUpdate.ts @@ -0,0 +1,30 @@ +import type { App } from '@rocket.chat/apps-engine/definition/App.ts'; + +import { AppObjectRegistry } from '../../AppObjectRegistry.ts'; +import { AppAccessorsInstance } from '../../lib/accessors/mod.ts'; + +export default async function handleOnUpdate(params: unknown): Promise { + const app = AppObjectRegistry.get('app'); + + if (typeof app?.onUpdate !== 'function') { + throw new Error('App must contain an onUpdate function', { + cause: 'invalid_app', + }); + } + + if (!Array.isArray(params)) { + throw new Error('Invalid params', { cause: 'invalid_param_type' }); + } + + const [context] = params as [Record]; + + await app.onUpdate( + context, + AppAccessorsInstance.getReader(), + AppAccessorsInstance.getHttp(), + AppAccessorsInstance.getPersistence(), + AppAccessorsInstance.getModifier(), + ); + + return true; +} diff --git a/packages/apps-engine/deno-runtime/handlers/app/handleSetStatus.ts b/packages/apps-engine/deno-runtime/handlers/app/handleSetStatus.ts new file mode 100644 index 000000000000..c39ab2a16d62 --- /dev/null +++ b/packages/apps-engine/deno-runtime/handlers/app/handleSetStatus.ts @@ -0,0 +1,29 @@ +import type { App } from '@rocket.chat/apps-engine/definition/App.ts'; +import type { AppStatus as _AppStatus } from '@rocket.chat/apps-engine/definition/AppStatus.ts'; + +import { AppObjectRegistry } from '../../AppObjectRegistry.ts'; +import { require } from '../../lib/require.ts'; + +const { AppStatus } = require('@rocket.chat/apps-engine/definition/AppStatus.js') as { + AppStatus: typeof _AppStatus; +}; + +export default async function handleSetStatus(params: unknown): Promise { + if (!Array.isArray(params) || !Object.values(AppStatus).includes(params[0])) { + throw new Error('Invalid params', { cause: 'invalid_param_type' }); + } + + const [status] = params as [typeof AppStatus]; + + const app = AppObjectRegistry.get('app'); + + if (!app || typeof app['setStatus'] !== 'function') { + throw new Error('App must contain a setStatus function', { + cause: 'invalid_app', + }); + } + + await app['setStatus'](status); + + return null; +} diff --git a/packages/apps-engine/deno-runtime/handlers/app/handler.ts b/packages/apps-engine/deno-runtime/handlers/app/handler.ts new file mode 100644 index 000000000000..2a44f34cb7fe --- /dev/null +++ b/packages/apps-engine/deno-runtime/handlers/app/handler.ts @@ -0,0 +1,112 @@ +import type { App } from '@rocket.chat/apps-engine/definition/App.ts'; +import { Defined, JsonRpcError } from 'jsonrpc-lite'; + +import handleConstructApp from './construct.ts'; +import handleInitialize from './handleInitialize.ts'; +import handleGetStatus from './handleGetStatus.ts'; +import handleSetStatus from './handleSetStatus.ts'; +import handleOnEnable from './handleOnEnable.ts'; +import handleOnInstall from './handleOnInstall.ts'; +import handleOnDisable from './handleOnDisable.ts'; +import handleOnUninstall from './handleOnUninstall.ts'; +import handleOnPreSettingUpdate from './handleOnPreSettingUpdate.ts'; +import handleOnSettingUpdated from './handleOnSettingUpdated.ts'; +import handleListener from '../listener/handler.ts'; +import handleUIKitInteraction, { uikitInteractions } from '../uikit/handler.ts'; +import { AppObjectRegistry } from '../../AppObjectRegistry.ts'; +import handleOnUpdate from './handleOnUpdate.ts'; + +export default async function handleApp(method: string, params: unknown): Promise { + const [, appMethod] = method.split(':'); + + // We don't want the getStatus method to generate logs, so we handle it separately + if (appMethod === 'getStatus') { + return handleGetStatus(); + } + + // `app` will be undefined if the method here is "app:construct" + const app = AppObjectRegistry.get('app'); + + app?.getLogger().debug(`'${appMethod}' is being called...`); + + if (uikitInteractions.includes(appMethod)) { + return handleUIKitInteraction(appMethod, params).then((result) => { + if (result instanceof JsonRpcError) { + app?.getLogger().debug(`'${appMethod}' was unsuccessful.`, result.message); + } else { + app?.getLogger().debug(`'${appMethod}' was successfully called! The result is:`, result); + } + + return result; + }); + } + + if (appMethod.startsWith('check') || appMethod.startsWith('execute')) { + return handleListener(appMethod, params).then((result) => { + if (result instanceof JsonRpcError) { + app?.getLogger().debug(`'${appMethod}' was unsuccessful.`, result.message); + } else { + app?.getLogger().debug(`'${appMethod}' was successfully called! The result is:`, result); + } + + return result; + }); + } + + try { + let result: Defined | JsonRpcError; + + switch (appMethod) { + case 'construct': + result = await handleConstructApp(params); + break; + case 'initialize': + result = await handleInitialize(); + break; + case 'setStatus': + result = await handleSetStatus(params); + break; + case 'onEnable': + result = await handleOnEnable(); + break; + case 'onDisable': + result = await handleOnDisable(); + break; + case 'onInstall': + result = await handleOnInstall(params); + break; + case 'onUninstall': + result = await handleOnUninstall(params); + break; + case 'onPreSettingUpdate': + result = await handleOnPreSettingUpdate(params); + break; + case 'onSettingUpdated': + result = await handleOnSettingUpdated(params); + break; + case 'onUpdate': + result = await handleOnUpdate(params); + break; + default: + throw new JsonRpcError('Method not found', -32601); + } + + app?.getLogger().debug(`'${appMethod}' was successfully called! The result is:`, result); + + return result; + } catch (e: unknown) { + if (!(e instanceof Error)) { + return new JsonRpcError('Unknown error', -32000, e); + } + + if ((e.cause as string)?.includes('invalid_param_type')) { + return JsonRpcError.invalidParams(null); + } + + if ((e.cause as string)?.includes('invalid_app')) { + return JsonRpcError.internalError({ message: 'App unavailable' }); + } + + return new JsonRpcError(e.message, -32000, e); + } +} diff --git a/packages/apps-engine/deno-runtime/handlers/listener/handler.ts b/packages/apps-engine/deno-runtime/handlers/listener/handler.ts new file mode 100644 index 000000000000..1e6de20538fc --- /dev/null +++ b/packages/apps-engine/deno-runtime/handlers/listener/handler.ts @@ -0,0 +1,150 @@ +import { Defined, JsonRpcError } from 'jsonrpc-lite'; +import type { App } from '@rocket.chat/apps-engine/definition/App.ts'; +import type { IMessage } from '@rocket.chat/apps-engine/definition/messages/IMessage.ts'; +import type { IRoom } from '@rocket.chat/apps-engine/definition/rooms/IRoom.ts'; +import type { AppsEngineException as _AppsEngineException } from '@rocket.chat/apps-engine/definition/exceptions/AppsEngineException.ts'; + +import { AppObjectRegistry } from '../../AppObjectRegistry.ts'; +import { MessageExtender } from '../../lib/accessors/extenders/MessageExtender.ts'; +import { RoomExtender } from '../../lib/accessors/extenders/RoomExtender.ts'; +import { MessageBuilder } from '../../lib/accessors/builders/MessageBuilder.ts'; +import { RoomBuilder } from '../../lib/accessors/builders/RoomBuilder.ts'; +import { AppAccessors, AppAccessorsInstance } from '../../lib/accessors/mod.ts'; +import { require } from '../../lib/require.ts'; +import createRoom from '../../lib/roomFactory.ts'; +import { Room } from "../../lib/room.ts"; + +const { AppsEngineException } = require('@rocket.chat/apps-engine/definition/exceptions/AppsEngineException.js') as { + AppsEngineException: typeof _AppsEngineException; +}; + +export default async function handleListener(evtInterface: string, params: unknown): Promise { + const app = AppObjectRegistry.get('app'); + + const eventExecutor = app?.[evtInterface as keyof App]; + + if (typeof eventExecutor !== 'function') { + return JsonRpcError.methodNotFound({ + message: 'Invalid event interface called on app', + }); + } + + if (!Array.isArray(params) || params.length < 1 || params.length > 2) { + return JsonRpcError.invalidParams(null); + } + + try { + const args = parseArgs({ AppAccessorsInstance }, evtInterface, params); + return await (eventExecutor as (...args: unknown[]) => Promise).apply(app, args); + } catch (e) { + if (e instanceof JsonRpcError) { + return e; + } + + if (e instanceof AppsEngineException) { + return new JsonRpcError(e.message, AppsEngineException.JSONRPC_ERROR_CODE, { name: e.name }); + } + + return JsonRpcError.internalError({ message: e.message }); + } + +} + +export function parseArgs(deps: { AppAccessorsInstance: AppAccessors }, evtMethod: string, params: unknown[]): unknown[] { + const { AppAccessorsInstance } = deps; + /** + * param1 is the context for the event handler execution + * param2 is an optional extra content that some hanlers require + */ + const [param1, param2] = params as [unknown, unknown]; + + if (!param1) { + throw JsonRpcError.invalidParams(null); + } + + let context = param1; + + if (evtMethod.includes('Message')) { + context = hydrateMessageObjects(context) as Record; + } else if (evtMethod.endsWith('RoomUserJoined') || evtMethod.endsWith('RoomUserLeave')) { + (context as Record).room = createRoom((context as Record).room as IRoom, AppAccessorsInstance.getSenderFn()); + } else if (evtMethod.includes('PreRoom')) { + context = createRoom(context as IRoom, AppAccessorsInstance.getSenderFn()); + } + + const args: unknown[] = [context, AppAccessorsInstance.getReader(), AppAccessorsInstance.getHttp()]; + + // "check" events will only go this far - (context, reader, http) + if (evtMethod.startsWith('check')) { + // "checkPostMessageDeleted" has an extra param - (context, reader, http, extraContext) + if (param2) { + args.push(hydrateMessageObjects(param2)); + } + + return args; + } + + // From this point on, all events will require (reader, http, persistence) injected + args.push(AppAccessorsInstance.getPersistence()); + + // "extend" events have an additional "Extender" param - (context, extender, reader, http, persistence) + if (evtMethod.endsWith('Extend')) { + if (evtMethod.includes('Message')) { + args.splice(1, 0, new MessageExtender(param1 as IMessage)); + } else if (evtMethod.includes('Room')) { + args.splice(1, 0, new RoomExtender(param1 as IRoom)); + } + + return args; + } + + // "Modify" events have an additional "Builder" param - (context, builder, reader, http, persistence) + if (evtMethod.endsWith('Modify')) { + if (evtMethod.includes('Message')) { + args.splice(1, 0, new MessageBuilder(param1 as IMessage)); + } else if (evtMethod.includes('Room')) { + args.splice(1, 0, new RoomBuilder(param1 as IRoom)); + } + + return args; + } + + // From this point on, all events will require (reader, http, persistence, modifier) injected + args.push(AppAccessorsInstance.getModifier()); + + // This guy gets an extra one + if (evtMethod === 'executePostMessageDeleted') { + if (!param2) { + throw JsonRpcError.invalidParams(null); + } + + args.push(hydrateMessageObjects(param2)); + } + + return args; +} + +/** + * Hydrate the context object with the correct IMessage + * + * Some information is lost upon serializing the data from listeners through the pipes, + * so here we hydrate the complete object as necessary + */ +function hydrateMessageObjects(context: unknown): unknown { + if (objectIsRawMessage(context)) { + context.room = createRoom(context.room as IRoom, AppAccessorsInstance.getSenderFn()); + } else if ((context as Record)?.message) { + (context as Record).message = hydrateMessageObjects((context as Record).message); + } + + return context; +} + +function objectIsRawMessage(value: unknown): value is IMessage { + if (!value) return false; + + const { id, room, sender, createdAt } = value as Record; + + // Check if we have the fields of a message and the room hasn't already been hydrated + return !!(id && room && sender && createdAt) && !(room instanceof Room); +} diff --git a/packages/apps-engine/deno-runtime/handlers/scheduler-handler.ts b/packages/apps-engine/deno-runtime/handlers/scheduler-handler.ts new file mode 100644 index 000000000000..0145034957f2 --- /dev/null +++ b/packages/apps-engine/deno-runtime/handlers/scheduler-handler.ts @@ -0,0 +1,51 @@ +import { Defined, JsonRpcError } from 'jsonrpc-lite'; +import type { App } from '@rocket.chat/apps-engine/definition/App.ts'; +import type { IProcessor } from '@rocket.chat/apps-engine/definition/scheduler/IProcessor.ts'; + +import { AppObjectRegistry } from '../AppObjectRegistry.ts'; +import { AppAccessorsInstance } from '../lib/accessors/mod.ts'; + +export default async function handleScheduler(method: string, params: unknown): Promise { + const [, processorId] = method.split(':'); + if (!Array.isArray(params)) { + return JsonRpcError.invalidParams({ message: 'Invalid params' }); + } + + const [context] = params as [Record]; + + const app = AppObjectRegistry.get('app'); + + if (!app) { + return JsonRpcError.internalError({ message: 'App not found' }); + } + + // AppSchedulerManager will append the appId to the processor name to avoid conflicts + const processor = AppObjectRegistry.get(`scheduler:${processorId}`); + + if (!processor) { + return JsonRpcError.methodNotFound({ + message: `Could not find processor for method ${method}`, + }); + } + + app.getLogger().debug(`Job processor ${processor.id} is being executed...`); + + try { + await processor.processor( + context, + AppAccessorsInstance.getReader(), + AppAccessorsInstance.getModifier(), + AppAccessorsInstance.getHttp(), + AppAccessorsInstance.getPersistence(), + ); + + app.getLogger().debug(`Job processor ${processor.id} was successfully executed`); + + return null; + } catch (e) { + app.getLogger().error(e); + app.getLogger().error(`Job processor ${processor.id} was unsuccessful`); + + return JsonRpcError.internalError({ message: e.message }); + } +} diff --git a/packages/apps-engine/deno-runtime/handlers/slashcommand-handler.ts b/packages/apps-engine/deno-runtime/handlers/slashcommand-handler.ts new file mode 100644 index 000000000000..cfebf0d1460e --- /dev/null +++ b/packages/apps-engine/deno-runtime/handlers/slashcommand-handler.ts @@ -0,0 +1,122 @@ +import { Defined, JsonRpcError } from 'jsonrpc-lite'; + +import type { App } from "@rocket.chat/apps-engine/definition/App.ts"; +import type { IRoom } from '@rocket.chat/apps-engine/definition/rooms/IRoom.ts'; +import type { ISlashCommand } from '@rocket.chat/apps-engine/definition/slashcommands/ISlashCommand.ts'; +import type { SlashCommandContext as _SlashCommandContext } from '@rocket.chat/apps-engine/definition/slashcommands/SlashCommandContext.ts'; +import type { Room as _Room } from '@rocket.chat/apps-engine/server/rooms/Room.ts'; + +import { AppObjectRegistry } from '../AppObjectRegistry.ts'; +import { AppAccessors, AppAccessorsInstance } from '../lib/accessors/mod.ts'; +import { require } from '../lib/require.ts'; +import createRoom from '../lib/roomFactory.ts'; + +// For some reason Deno couldn't understand the typecast to the original interfaces and said it wasn't a constructor type +const { SlashCommandContext } = require('@rocket.chat/apps-engine/definition/slashcommands/SlashCommandContext.js') as { + SlashCommandContext: typeof _SlashCommandContext; +}; + +export default async function slashCommandHandler(call: string, params: unknown): Promise { + const [, commandName, method] = call.split(':'); + + const command = AppObjectRegistry.get(`slashcommand:${commandName}`); + + if (!command) { + return new JsonRpcError(`Slashcommand ${commandName} not found`, -32000); + } + + let result: Awaited> | Awaited>; + + // If the command is registered, we're pretty safe to assume the app is not undefined + const app = AppObjectRegistry.get('app')!; + + app.getLogger().debug(`${commandName}'s ${method} is being executed...`, params); + + try { + if (method === 'executor' || method === 'previewer') { + result = await handleExecutor({ AppAccessorsInstance }, command, method, params); + } else if (method === 'executePreviewItem') { + result = await handlePreviewItem({ AppAccessorsInstance }, command, params); + } else { + return new JsonRpcError(`Method ${method} not found on slashcommand ${commandName}`, -32000); + } + + app.getLogger().debug(`${commandName}'s ${method} was successfully executed.`); + } catch (error) { + app.getLogger().debug(`${commandName}'s ${method} was unsuccessful.`); + + return new JsonRpcError(error.message, -32000); + } + + return result; +} + +/** + * @param deps Dependencies that need to be injected into the slashcommand + * @param command The slashcommand that is being executed + * @param method The method that is being executed + * @param params The parameters that are being passed to the method + */ +export function handleExecutor(deps: { AppAccessorsInstance: AppAccessors }, command: ISlashCommand, method: 'executor' | 'previewer', params: unknown) { + const executor = command[method]; + + if (typeof executor !== 'function') { + throw new Error(`Method ${method} not found on slashcommand ${command.command}`); + } + + if (!Array.isArray(params) || typeof params[0] !== 'object' || !params[0]) { + throw new Error(`First parameter must be an object`); + } + + const { sender, room, params: args, threadId, triggerId } = params[0] as Record; + + const context = new SlashCommandContext( + sender as _SlashCommandContext['sender'], + createRoom(room as IRoom, deps.AppAccessorsInstance.getSenderFn()), + args as _SlashCommandContext['params'], + threadId as _SlashCommandContext['threadId'], + triggerId as _SlashCommandContext['triggerId'], + ); + + return executor.apply(command, [ + context, + deps.AppAccessorsInstance.getReader(), + deps.AppAccessorsInstance.getModifier(), + deps.AppAccessorsInstance.getHttp(), + deps.AppAccessorsInstance.getPersistence(), + ]); +} + +/** + * @param deps Dependencies that need to be injected into the slashcommand + * @param command The slashcommand that is being executed + * @param params The parameters that are being passed to the method + */ +export function handlePreviewItem(deps: { AppAccessorsInstance: AppAccessors }, command: ISlashCommand, params: unknown) { + if (typeof command.executePreviewItem !== 'function') { + throw new Error(`Method not found on slashcommand ${command.command}`); + } + + if (!Array.isArray(params) || typeof params[0] !== 'object' || !params[0]) { + throw new Error(`First parameter must be an object`); + } + + const [previewItem, { sender, room, params: args, threadId, triggerId }] = params as [Record, Record]; + + const context = new SlashCommandContext( + sender as _SlashCommandContext['sender'], + createRoom(room as IRoom, deps.AppAccessorsInstance.getSenderFn()), + args as _SlashCommandContext['params'], + threadId as _SlashCommandContext['threadId'], + triggerId as _SlashCommandContext['triggerId'], + ); + + return command.executePreviewItem( + previewItem, + context, + deps.AppAccessorsInstance.getReader(), + deps.AppAccessorsInstance.getModifier(), + deps.AppAccessorsInstance.getHttp(), + deps.AppAccessorsInstance.getPersistence(), + ); +} diff --git a/packages/apps-engine/deno-runtime/handlers/tests/api-handler.test.ts b/packages/apps-engine/deno-runtime/handlers/tests/api-handler.test.ts new file mode 100644 index 000000000000..a3789f755542 --- /dev/null +++ b/packages/apps-engine/deno-runtime/handlers/tests/api-handler.test.ts @@ -0,0 +1,79 @@ +// deno-lint-ignore-file no-explicit-any +import { assertEquals, assertObjectMatch } from 'https://deno.land/std@0.203.0/assert/mod.ts'; +import { beforeEach, describe, it } from 'https://deno.land/std@0.203.0/testing/bdd.ts'; +import { spy } from "https://deno.land/std@0.203.0/testing/mock.ts"; + +import { AppObjectRegistry } from '../../AppObjectRegistry.ts'; +import { assertInstanceOf } from "https://deno.land/std@0.203.0/assert/assert_instance_of.ts"; +import { JsonRpcError } from "jsonrpc-lite"; +import type { IApiEndpoint } from "@rocket.chat/apps-engine/definition/api/IApiEndpoint.ts"; +import apiHandler from "../api-handler.ts"; + +describe('handlers > api', () => { + const mockEndpoint: IApiEndpoint = { + path: '/test', + // deno-lint-ignore no-unused-vars + get: (request: any, endpoint: any, read: any, modify: any, http: any, persis: any) => Promise.resolve('ok'), + // deno-lint-ignore no-unused-vars + post: (request: any, endpoint: any, read: any, modify: any, http: any, persis: any) => Promise.resolve('ok'), + // deno-lint-ignore no-unused-vars + put: (request: any, endpoint: any, read: any, modify: any, http: any, persis: any) => { throw new Error('Method execution error example') }, + } + + beforeEach(() => { + AppObjectRegistry.clear(); + AppObjectRegistry.set('api:/test', mockEndpoint); + }); + + it('correctly handles execution of an api endpoint method GET', async () => { + const _spy = spy(mockEndpoint, 'get'); + + const result = await apiHandler('api:/test:get', ['request', 'endpointInfo']); + + assertEquals(result, 'ok'); + assertEquals(_spy.calls[0].args.length, 6); + assertEquals(_spy.calls[0].args[0], 'request'); + assertEquals(_spy.calls[0].args[1], 'endpointInfo'); + }); + + it('correctly handles execution of an api endpoint method POST', async () => { + const _spy = spy(mockEndpoint, 'post'); + + const result = await apiHandler('api:/test:post', ['request', 'endpointInfo']); + + assertEquals(result, 'ok'); + assertEquals(_spy.calls[0].args.length, 6); + assertEquals(_spy.calls[0].args[0], 'request'); + assertEquals(_spy.calls[0].args[1], 'endpointInfo'); + }); + + it('correctly handles an error if the method not exists for the selected endpoint', async () => { + const result = await apiHandler(`api:/test:delete`, ['request', 'endpointInfo']); + + assertInstanceOf(result, JsonRpcError) + assertObjectMatch(result, { + message: `/test's delete not exists`, + code: -32000 + }) + }); + + it('correctly handles an error if endpoint not exists', async () => { + const result = await apiHandler(`api:/error:get`, ['request', 'endpointInfo']); + + assertInstanceOf(result, JsonRpcError) + assertObjectMatch(result, { + message: `Endpoint /error not found`, + code: -32000 + }) + }); + + it('correctly handles an error if the method execution fails', async () => { + const result = await apiHandler(`api:/test:put`, ['request', 'endpointInfo']); + + assertInstanceOf(result, JsonRpcError) + assertObjectMatch(result, { + message: `Method execution error example`, + code: -32000 + }) + }); +}); diff --git a/packages/apps-engine/deno-runtime/handlers/tests/listener-handler.test.ts b/packages/apps-engine/deno-runtime/handlers/tests/listener-handler.test.ts new file mode 100644 index 000000000000..3e3663b06d22 --- /dev/null +++ b/packages/apps-engine/deno-runtime/handlers/tests/listener-handler.test.ts @@ -0,0 +1,234 @@ +// deno-lint-ignore-file no-explicit-any +import { assertEquals, assertInstanceOf, assertObjectMatch } from 'https://deno.land/std@0.203.0/assert/mod.ts'; +import { describe, it } from 'https://deno.land/std@0.203.0/testing/bdd.ts'; + +import { parseArgs } from '../listener/handler.ts'; +import { AppAccessors } from '../../lib/accessors/mod.ts'; +import { Room } from '../../lib/room.ts'; +import { MessageExtender } from '../../lib/accessors/extenders/MessageExtender.ts'; +import { RoomExtender } from '../../lib/accessors/extenders/RoomExtender.ts'; +import { MessageBuilder } from '../../lib/accessors/builders/MessageBuilder.ts'; +import { RoomBuilder } from '../../lib/accessors/builders/RoomBuilder.ts'; + +describe('handlers > listeners', () => { + const mockAppAccessors = { + getReader: () => ({ __type: 'reader' }), + getHttp: () => ({ __type: 'http' }), + getModifier: () => ({ __type: 'modifier' }), + getPersistence: () => ({ __type: 'persistence' }), + getSenderFn: () => (id: string) => Promise.resolve([{ __type: 'bridgeCall' }, { id }]), + } as unknown as AppAccessors; + + it('correctly parses the arguments for a request to trigger the "checkPreMessageSentPrevent" method', () => { + const evtMethod = 'checkPreMessageSentPrevent'; + // For the 'checkPreMessageSentPrevent' method, the context will be a message in a real scenario + const evtArgs = [{ __type: 'context' }]; + + const params = parseArgs({ AppAccessorsInstance: mockAppAccessors }, evtMethod, evtArgs); + + assertEquals(params.length, 3); + assertEquals(params[0], { __type: 'context' }); + assertEquals(params[1], { __type: 'reader' }); + assertEquals(params[2], { __type: 'http' }); + }); + + it('correctly parses the arguments for a request to trigger the "checkPostMessageDeleted" method', () => { + const evtMethod = 'checkPostMessageDeleted'; + // For the 'checkPostMessageDeleted' method, the context will be a message in a real scenario, + // and the extraContext will provide further information such the user who deleted the message + const evtArgs = [{ __type: 'context' }, { __type: 'extraContext' }]; + + const params = parseArgs({ AppAccessorsInstance: mockAppAccessors }, evtMethod, evtArgs); + + assertEquals(params.length, 4); + assertEquals(params[0], { __type: 'context' }); + assertEquals(params[1], { __type: 'reader' }); + assertEquals(params[2], { __type: 'http' }); + assertEquals(params[3], { __type: 'extraContext' }); + }); + + it('correctly parses the arguments for a request to trigger the "checkPreRoomCreateExtend" method', () => { + const evtMethod = 'checkPreRoomCreateExtend'; + // For the 'checkPreRoomCreateExtend' method, the context will be a room in a real scenario + const evtArgs = [ + { + id: 'fake', + type: 'fake', + slugifiedName: 'fake', + creator: 'fake', + createdAt: Date.now(), + }, + ]; + + const params = parseArgs({ AppAccessorsInstance: mockAppAccessors }, evtMethod, evtArgs); + + assertEquals(params.length, 3); + + assertInstanceOf(params[0], Room); + assertEquals(params[1], { __type: 'reader' }); + assertEquals(params[2], { __type: 'http' }); + }); + + it('correctly parses the arguments for a request to trigger the "executePreMessageSentExtend" method', () => { + const evtMethod = 'executePreMessageSentExtend'; + // For the 'executePreMessageSentExtend' method, the context will be a message in a real scenario + const evtArgs = [{ __type: 'context' }]; + + const params = parseArgs({ AppAccessorsInstance: mockAppAccessors }, evtMethod, evtArgs); + + assertEquals(params.length, 5); + // Instantiating the MessageExtender might modify the original object, so we need to assert it matches instead of equals + assertObjectMatch(params[0] as Record, { + __type: 'context', + }); + assertInstanceOf(params[1], MessageExtender); + assertEquals(params[2], { __type: 'reader' }); + assertEquals(params[3], { __type: 'http' }); + assertEquals(params[4], { __type: 'persistence' }); + }); + + it('correctly parses the arguments for a request to trigger the "executePreRoomCreateExtend" method', () => { + const evtMethod = 'executePreRoomCreateExtend'; + // For the 'executePreRoomCreateExtend' method, the context will be a room in a real scenario + const evtArgs = [{ __type: 'context' }]; + + const params = parseArgs({ AppAccessorsInstance: mockAppAccessors }, evtMethod, evtArgs); + + assertEquals(params.length, 5); + // Instantiating the RoomExtender might modify the original object, so we need to assert it matches instead of equals + assertObjectMatch(params[0] as Record, { + __type: 'context', + }); + assertInstanceOf(params[1], RoomExtender); + assertEquals(params[2], { __type: 'reader' }); + assertEquals(params[3], { __type: 'http' }); + assertEquals(params[4], { __type: 'persistence' }); + }); + + it('correctly parses the arguments for a request to trigger the "executePreMessageSentModify" method', () => { + const evtMethod = 'executePreMessageSentModify'; + // For the 'executePreMessageSentModify' method, the context will be a message in a real scenario + const evtArgs = [{ __type: 'context' }]; + + const params = parseArgs({ AppAccessorsInstance: mockAppAccessors }, evtMethod, evtArgs); + + assertEquals(params.length, 5); + // Instantiating the MessageBuilder might modify the original object, so we need to assert it matches instead of equals + assertObjectMatch(params[0] as Record, { + __type: 'context', + }); + assertInstanceOf(params[1], MessageBuilder); + assertEquals(params[2], { __type: 'reader' }); + assertEquals(params[3], { __type: 'http' }); + assertEquals(params[4], { __type: 'persistence' }); + }); + + it('correctly parses the arguments for a request to trigger the "executePreRoomCreateModify" method', () => { + const evtMethod = 'executePreRoomCreateModify'; + // For the 'executePreRoomCreateModify' method, the context will be a room in a real scenario + const evtArgs = [{ __type: 'context' }]; + + const params = parseArgs({ AppAccessorsInstance: mockAppAccessors }, evtMethod, evtArgs); + + assertEquals(params.length, 5); + // Instantiating the RoomBuilder might modify the original object, so we need to assert it matches instead of equals + assertObjectMatch(params[0] as Record, { + __type: 'context', + }); + assertInstanceOf(params[1], RoomBuilder); + assertEquals(params[2], { __type: 'reader' }); + assertEquals(params[3], { __type: 'http' }); + assertEquals(params[4], { __type: 'persistence' }); + }); + + it('correctly parses the arguments for a request to trigger the "executePostRoomUserJoined" method', () => { + const evtMethod = 'executePostRoomUserJoined'; + // For the 'executePostRoomUserJoined' method, the context will be a room in a real scenario + const room = { + id: 'fake', + type: 'fake', + slugifiedName: 'fake', + creator: 'fake', + createdAt: Date.now(), + }; + + const evtArgs = [{ __type: 'context', room }]; + + const params = parseArgs({ AppAccessorsInstance: mockAppAccessors }, evtMethod, evtArgs); + + assertEquals(params.length, 5); + assertInstanceOf((params[0] as any).room, Room); + assertEquals(params[1], { __type: 'reader' }); + assertEquals(params[2], { __type: 'http' }); + assertEquals(params[3], { __type: 'persistence' }); + assertEquals(params[4], { __type: 'modifier' }); + }); + + it('correctly parses the arguments for a request to trigger the "executePostRoomUserLeave" method', () => { + const evtMethod = 'executePostRoomUserLeave'; + // For the 'executePostRoomUserLeave' method, the context will be a room in a real scenario + const room = { + id: 'fake', + type: 'fake', + slugifiedName: 'fake', + creator: 'fake', + createdAt: Date.now(), + }; + + const evtArgs = [{ __type: 'context', room }]; + + const params = parseArgs({ AppAccessorsInstance: mockAppAccessors }, evtMethod, evtArgs); + + assertEquals(params.length, 5); + assertInstanceOf((params[0] as any).room, Room); + assertEquals(params[1], { __type: 'reader' }); + assertEquals(params[2], { __type: 'http' }); + assertEquals(params[3], { __type: 'persistence' }); + assertEquals(params[4], { __type: 'modifier' }); + }); + + it('correctly parses the arguments for a request to trigger the "executePostMessageDeleted" method', () => { + const evtMethod = 'executePostMessageDeleted'; + // For the 'executePostMessageDeleted' method, the context will be a message in a real scenario + const evtArgs = [{ __type: 'context' }, { __type: 'extraContext' }]; + + const params = parseArgs({ AppAccessorsInstance: mockAppAccessors }, evtMethod, evtArgs); + + assertEquals(params.length, 6); + assertEquals(params[0], { __type: 'context' }); + assertEquals(params[1], { __type: 'reader' }); + assertEquals(params[2], { __type: 'http' }); + assertEquals(params[3], { __type: 'persistence' }); + assertEquals(params[4], { __type: 'modifier' }); + assertEquals(params[5], { __type: 'extraContext' }); + }); + + it('correctly parses the arguments for a request to trigger the "executePostMessageSent" method', () => { + const evtMethod = 'executePostMessageSent'; + // For the 'executePostMessageDeleted' method, the context will be a message in a real scenario + const evtArgs = [ + { + id: 'fake', + sender: 'fake', + createdAt: Date.now(), + room: { + id: 'fake-room', + type: 'fake', + slugifiedName: 'fake', + creator: 'fake', + createdAt: Date.now(), + }, + }, + ]; + + const params = parseArgs({ AppAccessorsInstance: mockAppAccessors }, evtMethod, evtArgs); + + assertEquals(params.length, 5); + assertObjectMatch((params[0] as Record), { id: 'fake' }); + assertInstanceOf((params[0] as any).room, Room); + assertEquals(params[1], { __type: 'reader' }); + assertEquals(params[2], { __type: 'http' }); + assertEquals(params[3], { __type: 'persistence' }); + assertEquals(params[4], { __type: 'modifier' }); + }); +}); diff --git a/packages/apps-engine/deno-runtime/handlers/tests/scheduler-handler.test.ts b/packages/apps-engine/deno-runtime/handlers/tests/scheduler-handler.test.ts new file mode 100644 index 000000000000..681f228bdb3e --- /dev/null +++ b/packages/apps-engine/deno-runtime/handlers/tests/scheduler-handler.test.ts @@ -0,0 +1,46 @@ +import { assertEquals } from 'https://deno.land/std@0.203.0/assert/mod.ts'; +import { afterAll, beforeEach, describe, it } from 'https://deno.land/std@0.203.0/testing/bdd.ts'; + +import { AppObjectRegistry } from '../../AppObjectRegistry.ts'; +import { AppAccessors } from '../../lib/accessors/mod.ts'; +import handleScheduler from '../scheduler-handler.ts'; + +describe('handlers > scheduler', () => { + const mockAppAccessors = new AppAccessors(() => + Promise.resolve({ + id: 'mockId', + result: {}, + jsonrpc: '2.0', + serialize: () => '', + }), + ); + + const mockApp = { + getID: () => 'mockApp', + getLogger: () => ({ + debug: () => {}, + error: () => {}, + }), + }; + + beforeEach(() => { + AppObjectRegistry.clear(); + AppObjectRegistry.set('app', mockApp); + mockAppAccessors.getConfigurationExtend().scheduler.registerProcessors([ + { + id: 'mockId', + processor: () => Promise.resolve('it works!'), + }, + ]); + }); + + afterAll(() => { + AppObjectRegistry.clear(); + }); + + it('correctly executes a request to a processor', async () => { + const result = await handleScheduler('scheduler:mockId', [{}]); + + assertEquals(result, null); + }); +}); diff --git a/packages/apps-engine/deno-runtime/handlers/tests/slashcommand-handler.test.ts b/packages/apps-engine/deno-runtime/handlers/tests/slashcommand-handler.test.ts new file mode 100644 index 000000000000..d3da4b132d66 --- /dev/null +++ b/packages/apps-engine/deno-runtime/handlers/tests/slashcommand-handler.test.ts @@ -0,0 +1,152 @@ +// deno-lint-ignore-file no-explicit-any +import { assertEquals, assertInstanceOf } from 'https://deno.land/std@0.203.0/assert/mod.ts'; +import { beforeEach, describe, it } from 'https://deno.land/std@0.203.0/testing/bdd.ts'; +import { spy } from 'https://deno.land/std@0.203.0/testing/mock.ts'; + +import { AppObjectRegistry } from '../../AppObjectRegistry.ts'; +import { AppAccessors } from '../../lib/accessors/mod.ts'; +import { handleExecutor, handlePreviewItem } from '../slashcommand-handler.ts'; +import { Room } from "../../lib/room.ts"; + +describe('handlers > slashcommand', () => { + const mockAppAccessors = { + getReader: () => ({ __type: 'reader' }), + getHttp: () => ({ __type: 'http' }), + getModifier: () => ({ __type: 'modifier' }), + getPersistence: () => ({ __type: 'persistence' }), + getSenderFn: () => (id: string) => Promise.resolve([{ __type: 'bridgeCall' }, { id }]), + } as unknown as AppAccessors; + + const mockCommandExecutorOnly = { + command: 'executor-only', + i18nParamsExample: 'test', + i18nDescription: 'test', + providesPreview: false, + // deno-lint-ignore no-unused-vars + async executor(context: any, read: any, modify: any, http: any, persis: any): Promise {}, + }; + + const mockCommandExecutorAndPreview = { + command: 'executor-and-preview', + i18nParamsExample: 'test', + i18nDescription: 'test', + providesPreview: true, + // deno-lint-ignore no-unused-vars + async executor(context: any, read: any, modify: any, http: any, persis: any): Promise {}, + // deno-lint-ignore no-unused-vars + async previewer(context: any, read: any, modify: any, http: any, persis: any): Promise {}, + // deno-lint-ignore no-unused-vars + async executePreviewItem(previewItem: any, context: any, read: any, modify: any, http: any, persis: any): Promise {}, + }; + + const mockCommandPreviewWithNoExecutor = { + command: 'preview-with-no-executor', + i18nParamsExample: 'test', + i18nDescription: 'test', + providesPreview: true, + // deno-lint-ignore no-unused-vars + async previewer(context: any, read: any, modify: any, http: any, persis: any): Promise {}, + // deno-lint-ignore no-unused-vars + async executePreviewItem(previewItem: any, context: any, read: any, modify: any, http: any, persis: any): Promise {}, + }; + + beforeEach(() => { + AppObjectRegistry.clear(); + AppObjectRegistry.set('slashcommand:executor-only', mockCommandExecutorOnly); + AppObjectRegistry.set('slashcommand:executor-and-preview', mockCommandExecutorAndPreview); + AppObjectRegistry.set('slashcommand:preview-with-no-executor', mockCommandPreviewWithNoExecutor); + }); + + it('correctly handles execution of a slash command', async () => { + const mockContext = { + sender: { __type: 'sender' }, + room: { __type: 'room' }, + params: { __type: 'params' }, + threadId: 'threadId', + triggerId: 'triggerId', + }; + + const _spy = spy(mockCommandExecutorOnly, 'executor'); + + await handleExecutor({ AppAccessorsInstance: mockAppAccessors }, mockCommandExecutorOnly, 'executor', [mockContext]); + + const context = _spy.calls[0].args[0]; + + assertInstanceOf(context.getRoom(), Room); + assertEquals(context.getSender(), { __type: 'sender' }); + assertEquals(context.getArguments(), { __type: 'params' }); + assertEquals(context.getThreadId(), 'threadId'); + assertEquals(context.getTriggerId(), 'triggerId'); + + assertEquals(_spy.calls[0].args[1], mockAppAccessors.getReader()); + assertEquals(_spy.calls[0].args[2], mockAppAccessors.getModifier()); + assertEquals(_spy.calls[0].args[3], mockAppAccessors.getHttp()); + assertEquals(_spy.calls[0].args[4], mockAppAccessors.getPersistence()); + + _spy.restore(); + }); + + it('correctly handles execution of a slash command previewer', async () => { + const mockContext = { + sender: { __type: 'sender' }, + room: { __type: 'room' }, + params: { __type: 'params' }, + threadId: 'threadId', + triggerId: 'triggerId', + }; + + const _spy = spy(mockCommandExecutorAndPreview, 'previewer'); + + await handleExecutor({ AppAccessorsInstance: mockAppAccessors }, mockCommandExecutorAndPreview, 'previewer', [mockContext]); + + const context = _spy.calls[0].args[0]; + + assertInstanceOf(context.getRoom(), Room); + assertEquals(context.getSender(), { __type: 'sender' }); + assertEquals(context.getArguments(), { __type: 'params' }); + assertEquals(context.getThreadId(), 'threadId'); + assertEquals(context.getTriggerId(), 'triggerId'); + + assertEquals(_spy.calls[0].args[1], mockAppAccessors.getReader()); + assertEquals(_spy.calls[0].args[2], mockAppAccessors.getModifier()); + assertEquals(_spy.calls[0].args[3], mockAppAccessors.getHttp()); + assertEquals(_spy.calls[0].args[4], mockAppAccessors.getPersistence()); + + _spy.restore(); + }); + + it('correctly handles execution of a slash command preview item executor', async () => { + const mockContext = { + sender: { __type: 'sender' }, + room: { __type: 'room' }, + params: { __type: 'params' }, + threadId: 'threadId', + triggerId: 'triggerId', + }; + + const mockPreviewItem = { + id: 'previewItemId', + type: 'image', + value: 'https://example.com/image.png', + }; + + const _spy = spy(mockCommandExecutorAndPreview, 'executePreviewItem'); + + await handlePreviewItem({ AppAccessorsInstance: mockAppAccessors }, mockCommandExecutorAndPreview, [mockPreviewItem, mockContext]); + + const context = _spy.calls[0].args[1]; + + assertInstanceOf(context.getRoom(), Room); + assertEquals(context.getSender(), { __type: 'sender' }); + assertEquals(context.getArguments(), { __type: 'params' }); + assertEquals(context.getThreadId(), 'threadId'); + assertEquals(context.getTriggerId(), 'triggerId'); + + assertEquals(_spy.calls[0].args[2], mockAppAccessors.getReader()); + assertEquals(_spy.calls[0].args[3], mockAppAccessors.getModifier()); + assertEquals(_spy.calls[0].args[4], mockAppAccessors.getHttp()); + assertEquals(_spy.calls[0].args[5], mockAppAccessors.getPersistence()); + + _spy.restore(); + }); +}); diff --git a/packages/apps-engine/deno-runtime/handlers/tests/uikit-handler.test.ts b/packages/apps-engine/deno-runtime/handlers/tests/uikit-handler.test.ts new file mode 100644 index 000000000000..f2293d6c98e0 --- /dev/null +++ b/packages/apps-engine/deno-runtime/handlers/tests/uikit-handler.test.ts @@ -0,0 +1,99 @@ +// deno-lint-ignore-file no-explicit-any +import { assertInstanceOf } from 'https://deno.land/std@0.203.0/assert/mod.ts'; +import { afterAll, beforeEach, describe, it } from 'https://deno.land/std@0.203.0/testing/bdd.ts'; + +import { AppObjectRegistry } from '../../AppObjectRegistry.ts'; +import handleUIKitInteraction, { + UIKitActionButtonInteractionContext, + UIKitBlockInteractionContext, + UIKitLivechatBlockInteractionContext, + UIKitViewCloseInteractionContext, + UIKitViewSubmitInteractionContext, +} from '../uikit/handler.ts'; + +describe('handlers > uikit', () => { + const mockApp = { + getID: (): string => 'appId', + executeBlockActionHandler: (context: any): Promise => Promise.resolve(context), + executeViewSubmitHandler: (context: any): Promise => Promise.resolve(context), + executeViewClosedHandler: (context: any): Promise => Promise.resolve(context), + executeActionButtonHandler: (context: any): Promise => Promise.resolve(context), + executeLivechatBlockActionHandler: (context: any): Promise => Promise.resolve(context), + }; + + beforeEach(() => { + AppObjectRegistry.set('app', mockApp); + }); + + afterAll(() => { + AppObjectRegistry.clear(); + }); + + it('successfully handles a call for "executeBlockActionHandler"', async () => { + const result = await handleUIKitInteraction('executeBlockActionHandler', [ + { + actionId: 'actionId', + blockId: 'blockId', + value: 'value', + viewId: 'viewId', + }, + ]); + + assertInstanceOf(result, UIKitBlockInteractionContext); + }); + + it('successfully handles a call for "executeViewSubmitHandler"', async () => { + const result = await handleUIKitInteraction('executeViewSubmitHandler', [ + { + viewId: 'viewId', + appId: 'appId', + userId: 'userId', + isAppUser: true, + values: {}, + }, + ]); + + assertInstanceOf(result, UIKitViewSubmitInteractionContext); + }); + + it('successfully handles a call for "executeViewClosedHandler"', async () => { + const result = await handleUIKitInteraction('executeViewClosedHandler', [ + { + viewId: 'viewId', + appId: 'appId', + userId: 'userId', + isAppUser: true, + }, + ]); + + assertInstanceOf(result, UIKitViewCloseInteractionContext); + }); + + it('successfully handles a call for "executeActionButtonHandler"', async () => { + const result = await handleUIKitInteraction('executeActionButtonHandler', [ + { + actionId: 'actionId', + appId: 'appId', + userId: 'userId', + isAppUser: true, + }, + ]); + + assertInstanceOf(result, UIKitActionButtonInteractionContext); + }); + + it('successfully handles a call for "executeLivechatBlockActionHandler"', async () => { + const result = await handleUIKitInteraction('executeLivechatBlockActionHandler', [ + { + actionId: 'actionId', + appId: 'appId', + userId: 'userId', + visitor: {}, + isAppUser: true, + room: {}, + }, + ]); + + assertInstanceOf(result, UIKitLivechatBlockInteractionContext); + }); +}); diff --git a/packages/apps-engine/deno-runtime/handlers/tests/videoconference-handler.test.ts b/packages/apps-engine/deno-runtime/handlers/tests/videoconference-handler.test.ts new file mode 100644 index 000000000000..a32d3175e24d --- /dev/null +++ b/packages/apps-engine/deno-runtime/handlers/tests/videoconference-handler.test.ts @@ -0,0 +1,122 @@ +// deno-lint-ignore-file no-explicit-any +import { assertEquals, assertObjectMatch } from 'https://deno.land/std@0.203.0/assert/mod.ts'; +import { beforeEach, describe, it } from 'https://deno.land/std@0.203.0/testing/bdd.ts'; +import { spy } from 'https://deno.land/std@0.203.0/testing/mock.ts'; + +import { AppObjectRegistry } from '../../AppObjectRegistry.ts'; +import videoconfHandler from '../videoconference-handler.ts'; +import { assertInstanceOf } from 'https://deno.land/std@0.203.0/assert/assert_instance_of.ts'; +import { JsonRpcError } from 'jsonrpc-lite'; + +describe('handlers > videoconference', () => { + // deno-lint-ignore no-unused-vars + const mockMethodWithoutParam = (read: any, modify: any, http: any, persis: any): Promise => Promise.resolve('ok none'); + // deno-lint-ignore no-unused-vars + const mockMethodWithOneParam = (call: any, read: any, modify: any, http: any, persis: any): Promise => Promise.resolve('ok one'); + // deno-lint-ignore no-unused-vars + const mockMethodWithTwoParam = (call: any, user: any, read: any, modify: any, http: any, persis: any): Promise => Promise.resolve('ok two'); + // deno-lint-ignore no-unused-vars + const mockMethodWithThreeParam = (call: any, user: any, options: any, read: any, modify: any, http: any, persis: any): Promise => + Promise.resolve('ok three'); + const mockProvider = { + empty: mockMethodWithoutParam, + one: mockMethodWithOneParam, + two: mockMethodWithTwoParam, + three: mockMethodWithThreeParam, + notAFunction: true, + error: () => { + throw new Error('Method execution error example'); + }, + }; + + beforeEach(() => { + AppObjectRegistry.clear(); + AppObjectRegistry.set('videoConfProvider:test-provider', mockProvider); + }); + + it('correctly handles execution of a videoconf method without additional params', async () => { + const _spy = spy(mockProvider, 'empty'); + + const result = await videoconfHandler('videoconference:test-provider:empty', []); + + assertEquals(result, 'ok none'); + assertEquals(_spy.calls[0].args.length, 4); + + _spy.restore(); + }); + + it('correctly handles execution of a videoconf method with one param', async () => { + const _spy = spy(mockProvider, 'one'); + + const result = await videoconfHandler('videoconference:test-provider:one', ['call']); + + assertEquals(result, 'ok one'); + assertEquals(_spy.calls[0].args.length, 5); + assertEquals(_spy.calls[0].args[0], 'call'); + + _spy.restore(); + }); + + it('correctly handles execution of a videoconf method with two params', async () => { + const _spy = spy(mockProvider, 'two'); + + const result = await videoconfHandler('videoconference:test-provider:two', ['call', 'user']); + + assertEquals(result, 'ok two'); + assertEquals(_spy.calls[0].args.length, 6); + assertEquals(_spy.calls[0].args[0], 'call'); + assertEquals(_spy.calls[0].args[1], 'user'); + + _spy.restore(); + }); + + it('correctly handles execution of a videoconf method with three params', async () => { + const _spy = spy(mockProvider, 'three'); + + const result = await videoconfHandler('videoconference:test-provider:three', ['call', 'user', 'options']); + + assertEquals(result, 'ok three'); + assertEquals(_spy.calls[0].args.length, 7); + assertEquals(_spy.calls[0].args[0], 'call'); + assertEquals(_spy.calls[0].args[1], 'user'); + assertEquals(_spy.calls[0].args[2], 'options'); + + _spy.restore(); + }); + + it('correctly handles an error on execution of a videoconf method', async () => { + const result = await videoconfHandler('videoconference:test-provider:error', []); + + assertInstanceOf(result, JsonRpcError); + assertObjectMatch(result, { + message: 'Method execution error example', + code: -32000, + }); + }); + + it('correctly handles an error when provider is not found', async () => { + const providerName = 'error-provider'; + const result = await videoconfHandler(`videoconference:${providerName}:method`, []); + + assertInstanceOf(result, JsonRpcError); + assertObjectMatch(result, { + message: `Provider ${providerName} not found`, + code: -32000, + }); + }); + + it('correctly handles an error if method is not a function of provider', async () => { + const methodName = 'notAFunction'; + const providerName = 'test-provider'; + const result = await videoconfHandler(`videoconference:${providerName}:${methodName}`, []); + + assertInstanceOf(result, JsonRpcError); + assertObjectMatch(result, { + message: 'Method not found', + code: -32601, + data: { + message: `Method ${methodName} not found on provider ${providerName}`, + }, + }); + }); +}); diff --git a/packages/apps-engine/deno-runtime/handlers/uikit/handler.ts b/packages/apps-engine/deno-runtime/handlers/uikit/handler.ts new file mode 100644 index 000000000000..5a418f242ad9 --- /dev/null +++ b/packages/apps-engine/deno-runtime/handlers/uikit/handler.ts @@ -0,0 +1,82 @@ +import { Defined, JsonRpcError } from 'jsonrpc-lite'; +import type { App } from '@rocket.chat/apps-engine/definition/App.ts'; + +import { require } from '../../lib/require.ts'; +import { AppObjectRegistry } from '../../AppObjectRegistry.ts'; +import { AppAccessorsInstance } from '../../lib/accessors/mod.ts'; + +export const uikitInteractions = [ + 'executeBlockActionHandler', + 'executeViewSubmitHandler', + 'executeViewClosedHandler', + 'executeActionButtonHandler', + 'executeLivechatBlockActionHandler', +]; + +export const { + UIKitBlockInteractionContext, + UIKitViewSubmitInteractionContext, + UIKitViewCloseInteractionContext, + UIKitActionButtonInteractionContext, +} = require('@rocket.chat/apps-engine/definition/uikit/UIKitInteractionContext.js'); + +export const { UIKitLivechatBlockInteractionContext } = require('@rocket.chat/apps-engine/definition/uikit/livechat/UIKitLivechatInteractionContext.js'); + +export default async function handleUIKitInteraction(method: string, params: unknown): Promise { + if (!uikitInteractions.includes(method)) { + return JsonRpcError.methodNotFound(null); + } + + if (!Array.isArray(params)) { + return JsonRpcError.invalidParams(null); + } + + const app = AppObjectRegistry.get('app'); + + const interactionHandler = app?.[method as keyof App] as unknown; + + if (!app || typeof interactionHandler !== 'function') { + return JsonRpcError.methodNotFound({ + message: `App does not implement method "${method}"`, + }); + } + + const [payload] = params as [Record]; + + if (!payload) { + return JsonRpcError.invalidParams(null); + } + + let context; + + switch (method) { + case 'executeBlockActionHandler': + context = new UIKitBlockInteractionContext(payload); + break; + case 'executeViewSubmitHandler': + context = new UIKitViewSubmitInteractionContext(payload); + break; + case 'executeViewClosedHandler': + context = new UIKitViewCloseInteractionContext(payload); + break; + case 'executeActionButtonHandler': + context = new UIKitActionButtonInteractionContext(payload); + break; + case 'executeLivechatBlockActionHandler': + context = new UIKitLivechatBlockInteractionContext(payload); + break; + } + + try { + return await interactionHandler.call( + app, + context, + AppAccessorsInstance.getReader(), + AppAccessorsInstance.getHttp(), + AppAccessorsInstance.getPersistence(), + AppAccessorsInstance.getModifier(), + ); + } catch (e) { + return JsonRpcError.internalError({ message: e.message }); + } +} diff --git a/packages/apps-engine/deno-runtime/handlers/videoconference-handler.ts b/packages/apps-engine/deno-runtime/handlers/videoconference-handler.ts new file mode 100644 index 000000000000..0347519db2e6 --- /dev/null +++ b/packages/apps-engine/deno-runtime/handlers/videoconference-handler.ts @@ -0,0 +1,49 @@ +import { Defined, JsonRpcError } from 'jsonrpc-lite'; +import type { IVideoConfProvider } from '@rocket.chat/apps-engine/definition/videoConfProviders/IVideoConfProvider.ts'; + +import { AppObjectRegistry } from '../AppObjectRegistry.ts'; +import { AppAccessorsInstance } from '../lib/accessors/mod.ts'; +import { Logger } from '../lib/logger.ts'; + +export default async function videoConferenceHandler(call: string, params: unknown): Promise { + const [, providerName, methodName] = call.split(':'); + + const provider = AppObjectRegistry.get(`videoConfProvider:${providerName}`); + const logger = AppObjectRegistry.get('logger'); + + if (!provider) { + return new JsonRpcError(`Provider ${providerName} not found`, -32000); + } + + const method = provider[methodName as keyof IVideoConfProvider]; + + if (typeof method !== 'function') { + return JsonRpcError.methodNotFound({ + message: `Method ${methodName} not found on provider ${providerName}`, + }); + } + + const [videoconf, user, options] = params as Array; + + logger?.debug(`Executing ${methodName} on video conference provider...`); + + const args = [...(videoconf ? [videoconf] : []), ...(user ? [user] : []), ...(options ? [options] : [])]; + + try { + // deno-lint-ignore ban-types + const result = await (method as Function).apply(provider, [ + ...args, + AppAccessorsInstance.getReader(), + AppAccessorsInstance.getModifier(), + AppAccessorsInstance.getHttp(), + AppAccessorsInstance.getPersistence(), + ]); + + logger?.debug(`Video Conference Provider's ${methodName} was successfully executed.`); + + return result; + } catch (e) { + logger?.debug(`Video Conference Provider's ${methodName} was unsuccessful.`); + return new JsonRpcError(e.message, -32000); + } +} diff --git a/packages/apps-engine/deno-runtime/lib/accessors/builders/BlockBuilder.ts b/packages/apps-engine/deno-runtime/lib/accessors/builders/BlockBuilder.ts new file mode 100644 index 000000000000..e1602860fe97 --- /dev/null +++ b/packages/apps-engine/deno-runtime/lib/accessors/builders/BlockBuilder.ts @@ -0,0 +1,216 @@ +import { v1 as uuid } from 'uuid'; + +import type { + BlockType as _BlockType, + IActionsBlock, + IBlock, + IConditionalBlock, + IConditionalBlockFilters, + IContextBlock, + IImageBlock, + IInputBlock, + ISectionBlock, +} from '@rocket.chat/apps-engine/definition/uikit/blocks/Blocks.ts'; +import type { + BlockElementType as _BlockElementType, + IBlockElement, + IButtonElement, + IImageElement, + IInputElement, + IInteractiveElement, + IMultiStaticSelectElement, + IOverflowMenuElement, + IPlainTextInputElement, + ISelectElement, + IStaticSelectElement, +} from '@rocket.chat/apps-engine/definition/uikit/blocks/Elements.ts'; +import type { ITextObject, TextObjectType as _TextObjectType } from '@rocket.chat/apps-engine/definition/uikit/blocks/Objects.ts'; + +import { AppObjectRegistry } from '../../../AppObjectRegistry.ts'; +import { require } from '../../../lib/require.ts'; + +const { BlockType } = require('@rocket.chat/apps-engine/definition/uikit/blocks/Blocks.js') as { BlockType: typeof _BlockType }; +const { BlockElementType } = require('@rocket.chat/apps-engine/definition/uikit/blocks/Elements.js') as { BlockElementType: typeof _BlockElementType }; +const { TextObjectType } = require('@rocket.chat/apps-engine/definition/uikit/blocks/Objects.js') as { TextObjectType: typeof _TextObjectType }; + +type BlockFunctionParameter = Omit; +type ElementFunctionParameter = T extends IInteractiveElement + ? Omit | Partial> + : Omit; + +type SectionBlockParam = BlockFunctionParameter; +type ImageBlockParam = BlockFunctionParameter; +type ActionsBlockParam = BlockFunctionParameter; +type ContextBlockParam = BlockFunctionParameter; +type InputBlockParam = BlockFunctionParameter; + +type ButtonElementParam = ElementFunctionParameter; +type ImageElementParam = ElementFunctionParameter; +type OverflowMenuElementParam = ElementFunctionParameter; +type PlainTextInputElementParam = ElementFunctionParameter; +type StaticSelectElementParam = ElementFunctionParameter; +type MultiStaticSelectElementParam = ElementFunctionParameter; + +/** + * @deprecated please prefer the rocket.chat/ui-kit components + */ +export class BlockBuilder { + private readonly blocks: Array; + private readonly appId: string; + + constructor() { + this.blocks = []; + this.appId = String(AppObjectRegistry.get('id')); + } + + public addSectionBlock(block: SectionBlockParam): BlockBuilder { + this.addBlock({ type: BlockType.SECTION, ...block } as ISectionBlock); + + return this; + } + + public addImageBlock(block: ImageBlockParam): BlockBuilder { + this.addBlock({ type: BlockType.IMAGE, ...block } as IImageBlock); + + return this; + } + + public addDividerBlock(): BlockBuilder { + this.addBlock({ type: BlockType.DIVIDER }); + + return this; + } + + public addActionsBlock(block: ActionsBlockParam): BlockBuilder { + this.addBlock({ type: BlockType.ACTIONS, ...block } as IActionsBlock); + + return this; + } + + public addContextBlock(block: ContextBlockParam): BlockBuilder { + this.addBlock({ type: BlockType.CONTEXT, ...block } as IContextBlock); + + return this; + } + + public addInputBlock(block: InputBlockParam): BlockBuilder { + this.addBlock({ type: BlockType.INPUT, ...block } as IInputBlock); + + return this; + } + + public addConditionalBlock(innerBlocks: BlockBuilder | Array, condition?: IConditionalBlockFilters): BlockBuilder { + const render = innerBlocks instanceof BlockBuilder ? innerBlocks.getBlocks() : innerBlocks; + + this.addBlock({ + type: BlockType.CONDITIONAL, + render, + when: condition, + } as IConditionalBlock); + + return this; + } + + public getBlocks() { + return this.blocks; + } + + public newPlainTextObject(text: string, emoji = false): ITextObject { + return { + type: TextObjectType.PLAINTEXT, + text, + emoji, + }; + } + + public newMarkdownTextObject(text: string): ITextObject { + return { + type: TextObjectType.MARKDOWN, + text, + }; + } + + public newButtonElement(info: ButtonElementParam): IButtonElement { + return this.newInteractiveElement({ + type: BlockElementType.BUTTON, + ...info, + } as IButtonElement); + } + + public newImageElement(info: ImageElementParam): IImageElement { + return { + type: BlockElementType.IMAGE, + ...info, + }; + } + + public newOverflowMenuElement(info: OverflowMenuElementParam): IOverflowMenuElement { + return this.newInteractiveElement({ + type: BlockElementType.OVERFLOW_MENU, + ...info, + } as IOverflowMenuElement); + } + + public newPlainTextInputElement(info: PlainTextInputElementParam): IPlainTextInputElement { + return this.newInputElement({ + type: BlockElementType.PLAIN_TEXT_INPUT, + ...info, + } as IPlainTextInputElement); + } + + public newStaticSelectElement(info: StaticSelectElementParam): IStaticSelectElement { + return this.newSelectElement({ + type: BlockElementType.STATIC_SELECT, + ...info, + } as IStaticSelectElement); + } + + public newMultiStaticElement(info: MultiStaticSelectElementParam): IMultiStaticSelectElement { + return this.newSelectElement({ + type: BlockElementType.MULTI_STATIC_SELECT, + ...info, + } as IMultiStaticSelectElement); + } + + private newInteractiveElement(element: T): T { + if (!element.actionId) { + element.actionId = this.generateActionId(); + } + + return element; + } + + private newInputElement(element: T): T { + if (!element.actionId) { + element.actionId = this.generateActionId(); + } + + return element; + } + + private newSelectElement(element: T): T { + if (!element.actionId) { + element.actionId = this.generateActionId(); + } + + return element; + } + + private addBlock(block: IBlock): void { + if (!block.blockId) { + block.blockId = this.generateBlockId(); + } + + block.appId = this.appId; + + this.blocks.push(block); + } + + private generateBlockId(): string { + return uuid(); + } + + private generateActionId(): string { + return uuid(); + } +} diff --git a/packages/apps-engine/deno-runtime/lib/accessors/builders/DiscussionBuilder.ts b/packages/apps-engine/deno-runtime/lib/accessors/builders/DiscussionBuilder.ts new file mode 100644 index 000000000000..e2c2dc021438 --- /dev/null +++ b/packages/apps-engine/deno-runtime/lib/accessors/builders/DiscussionBuilder.ts @@ -0,0 +1,59 @@ +import type { IDiscussionBuilder as _IDiscussionBuilder } from '@rocket.chat/apps-engine/definition/accessors/IDiscussionBuilder.ts'; +import type { IMessage } from '@rocket.chat/apps-engine/definition/messages/IMessage.ts'; +import type { IRoom } from '@rocket.chat/apps-engine/definition/rooms/IRoom.ts'; +import type { IRoomBuilder } from '@rocket.chat/apps-engine/definition/accessors/IRoomBuilder.ts'; + +import type { RocketChatAssociationModel as _RocketChatAssociationModel } from '@rocket.chat/apps-engine/definition/metadata/RocketChatAssociations.ts'; +import type { RoomType as _RoomType } from '@rocket.chat/apps-engine/definition/rooms/RoomType.ts'; + +import { RoomBuilder } from './RoomBuilder.ts'; +import { require } from '../../../lib/require.ts'; + +const { RocketChatAssociationModel } = require('@rocket.chat/apps-engine/definition/metadata/RocketChatAssociations.js') as { + RocketChatAssociationModel: typeof _RocketChatAssociationModel; +}; + +const { RoomType } = require('@rocket.chat/apps-engine/definition/rooms/RoomType.js') as { RoomType: typeof _RoomType }; + +export interface IDiscussionBuilder extends _IDiscussionBuilder, IRoomBuilder {} + +export class DiscussionBuilder extends RoomBuilder implements IDiscussionBuilder { + public kind: _RocketChatAssociationModel.DISCUSSION; + + private reply?: string; + + private parentMessage?: IMessage; + + constructor(data?: Partial) { + super(data); + this.kind = RocketChatAssociationModel.DISCUSSION; + this.room.type = RoomType.PRIVATE_GROUP; + } + + public setParentRoom(parentRoom: IRoom): IDiscussionBuilder { + this.room.parentRoom = parentRoom; + return this; + } + + public getParentRoom(): IRoom { + return this.room.parentRoom!; + } + + public setReply(reply: string): IDiscussionBuilder { + this.reply = reply; + return this; + } + + public getReply(): string { + return this.reply!; + } + + public setParentMessage(parentMessage: IMessage): IDiscussionBuilder { + this.parentMessage = parentMessage; + return this; + } + + public getParentMessage(): IMessage { + return this.parentMessage!; + } +} diff --git a/packages/apps-engine/deno-runtime/lib/accessors/builders/LivechatMessageBuilder.ts b/packages/apps-engine/deno-runtime/lib/accessors/builders/LivechatMessageBuilder.ts new file mode 100644 index 000000000000..a12024ab7b5d --- /dev/null +++ b/packages/apps-engine/deno-runtime/lib/accessors/builders/LivechatMessageBuilder.ts @@ -0,0 +1,204 @@ +import type { RocketChatAssociationModel as _RocketChatAssociationModel } from '@rocket.chat/apps-engine/definition/metadata/RocketChatAssociations.ts'; +import type { RoomType as _RoomType } from '@rocket.chat/apps-engine/definition/rooms/RoomType.ts'; + +import type { ILivechatMessageBuilder } from '@rocket.chat/apps-engine/definition/accessors/ILivechatMessageBuilder.ts'; +import type { IMessage } from '@rocket.chat/apps-engine/definition/messages/IMessage.ts'; +import type { IMessageAttachment } from '@rocket.chat/apps-engine/definition/messages/IMessageAttachment.ts'; +import type { IRoom } from '@rocket.chat/apps-engine/definition/rooms/IRoom.ts'; +import type { IUser } from '@rocket.chat/apps-engine/definition/users/IUser.ts'; +import type { ILivechatMessage as EngineLivechatMessage } from '@rocket.chat/apps-engine/definition/livechat/ILivechatMessage.ts'; +import type { IVisitor } from '@rocket.chat/apps-engine/definition/livechat/IVisitor.ts'; +import type { IMessageBuilder } from '@rocket.chat/apps-engine/definition/accessors/IMessageBuilder.ts'; + +import { MessageBuilder } from './MessageBuilder.ts'; +import { require } from '../../../lib/require.ts'; + +const { RocketChatAssociationModel } = require('@rocket.chat/apps-engine/definition/metadata/RocketChatAssociations.js') as { + RocketChatAssociationModel: typeof _RocketChatAssociationModel; +}; + +const { RoomType } = require('@rocket.chat/apps-engine/definition/rooms/RoomType.js') as { RoomType: typeof _RoomType }; + +export interface ILivechatMessage extends EngineLivechatMessage, IMessage {} + +export class LivechatMessageBuilder implements ILivechatMessageBuilder { + public kind: _RocketChatAssociationModel.LIVECHAT_MESSAGE; + + private msg: ILivechatMessage; + + constructor(message?: ILivechatMessage) { + this.kind = RocketChatAssociationModel.LIVECHAT_MESSAGE; + this.msg = message || ({} as ILivechatMessage); + } + + public setData(data: ILivechatMessage): ILivechatMessageBuilder { + delete data.id; + this.msg = data; + + return this; + } + + public setRoom(room: IRoom): ILivechatMessageBuilder { + this.msg.room = room; + return this; + } + + public getRoom(): IRoom { + return this.msg.room; + } + + public setSender(sender: IUser): ILivechatMessageBuilder { + this.msg.sender = sender; + delete this.msg.visitor; + + return this; + } + + public getSender(): IUser { + return this.msg.sender; + } + + public setText(text: string): ILivechatMessageBuilder { + this.msg.text = text; + return this; + } + + public getText(): string { + return this.msg.text!; + } + + public setEmojiAvatar(emoji: string): ILivechatMessageBuilder { + this.msg.emoji = emoji; + return this; + } + + public getEmojiAvatar(): string { + return this.msg.emoji!; + } + + public setAvatarUrl(avatarUrl: string): ILivechatMessageBuilder { + this.msg.avatarUrl = avatarUrl; + return this; + } + + public getAvatarUrl(): string { + return this.msg.avatarUrl!; + } + + public setUsernameAlias(alias: string): ILivechatMessageBuilder { + this.msg.alias = alias; + return this; + } + + public getUsernameAlias(): string { + return this.msg.alias!; + } + + public addAttachment(attachment: IMessageAttachment): ILivechatMessageBuilder { + if (!this.msg.attachments) { + this.msg.attachments = []; + } + + this.msg.attachments.push(attachment); + return this; + } + + public setAttachments(attachments: Array): ILivechatMessageBuilder { + this.msg.attachments = attachments; + return this; + } + + public getAttachments(): Array { + return this.msg.attachments!; + } + + public replaceAttachment(position: number, attachment: IMessageAttachment): ILivechatMessageBuilder { + if (!this.msg.attachments) { + this.msg.attachments = []; + } + + if (!this.msg.attachments[position]) { + throw new Error(`No attachment found at the index of "${position}" to replace.`); + } + + this.msg.attachments[position] = attachment; + return this; + } + + public removeAttachment(position: number): ILivechatMessageBuilder { + if (!this.msg.attachments) { + this.msg.attachments = []; + } + + if (!this.msg.attachments[position]) { + throw new Error(`No attachment found at the index of "${position}" to remove.`); + } + + this.msg.attachments.splice(position, 1); + + return this; + } + + public setEditor(user: IUser): ILivechatMessageBuilder { + this.msg.editor = user; + return this; + } + + public getEditor(): IUser { + return this.msg.editor; + } + + public setGroupable(groupable: boolean): ILivechatMessageBuilder { + this.msg.groupable = groupable; + return this; + } + + public getGroupable(): boolean { + return this.msg.groupable!; + } + + public setParseUrls(parseUrls: boolean): ILivechatMessageBuilder { + this.msg.parseUrls = parseUrls; + return this; + } + + public getParseUrls(): boolean { + return this.msg.parseUrls!; + } + + public setToken(token: string): ILivechatMessageBuilder { + this.msg.token = token; + return this; + } + + public getToken(): string { + return this.msg.token!; + } + + public setVisitor(visitor: IVisitor): ILivechatMessageBuilder { + this.msg.visitor = visitor; + delete this.msg.sender; + + return this; + } + + public getVisitor(): IVisitor { + return this.msg.visitor; + } + + public getMessage(): ILivechatMessage { + if (!this.msg.room) { + throw new Error('The "room" property is required.'); + } + + if (this.msg.room.type !== RoomType.LIVE_CHAT) { + throw new Error('The room is not a Livechat room'); + } + + return this.msg; + } + + public getMessageBuilder(): IMessageBuilder { + return new MessageBuilder(this.msg as IMessage); + } +} diff --git a/packages/apps-engine/deno-runtime/lib/accessors/builders/MessageBuilder.ts b/packages/apps-engine/deno-runtime/lib/accessors/builders/MessageBuilder.ts new file mode 100644 index 000000000000..98cd919f7b00 --- /dev/null +++ b/packages/apps-engine/deno-runtime/lib/accessors/builders/MessageBuilder.ts @@ -0,0 +1,232 @@ +import { LayoutBlock } from '@rocket.chat/ui-kit'; + +import type { IMessageBuilder } from '@rocket.chat/apps-engine/definition/accessors/IMessageBuilder.ts'; +import type { RocketChatAssociationModel as _RocketChatAssociationModel } from '@rocket.chat/apps-engine/definition/metadata/RocketChatAssociations.ts'; +import type { IMessage } from '@rocket.chat/apps-engine/definition/messages/IMessage.ts'; +import type { IMessageAttachment } from '@rocket.chat/apps-engine/definition/messages/IMessageAttachment.ts'; +import type { IUser } from '@rocket.chat/apps-engine/definition/users/IUser.ts'; +import type { IRoom } from '@rocket.chat/apps-engine/definition/rooms/IRoom.ts'; +import type { IBlock } from '@rocket.chat/apps-engine/definition/uikit/blocks/Blocks.ts'; + +import { BlockBuilder } from './BlockBuilder.ts'; +import { require } from '../../../lib/require.ts'; + +const { RocketChatAssociationModel } = require('@rocket.chat/apps-engine/definition/metadata/RocketChatAssociations.js') as { + RocketChatAssociationModel: typeof _RocketChatAssociationModel; +}; + +export class MessageBuilder implements IMessageBuilder { + public kind: _RocketChatAssociationModel.MESSAGE; + + private msg: IMessage; + + constructor(message?: IMessage) { + this.kind = RocketChatAssociationModel.MESSAGE; + this.msg = message || ({} as IMessage); + } + + public setData(data: IMessage): IMessageBuilder { + delete data.id; + this.msg = data; + + return this as IMessageBuilder; + } + + public setUpdateData(data: IMessage, editor: IUser): IMessageBuilder { + this.msg = data; + this.msg.editor = editor; + this.msg.editedAt = new Date(); + + return this as IMessageBuilder; + } + + public setThreadId(threadId: string): IMessageBuilder { + this.msg.threadId = threadId; + + return this as IMessageBuilder; + } + + public getThreadId(): string { + return this.msg.threadId!; + } + + public setRoom(room: IRoom): IMessageBuilder { + this.msg.room = room; + return this as IMessageBuilder; + } + + public getRoom(): IRoom { + return this.msg.room; + } + + public setSender(sender: IUser): IMessageBuilder { + this.msg.sender = sender; + return this as IMessageBuilder; + } + + public getSender(): IUser { + return this.msg.sender; + } + + public setText(text: string): IMessageBuilder { + this.msg.text = text; + return this as IMessageBuilder; + } + + public getText(): string { + return this.msg.text!; + } + + public setEmojiAvatar(emoji: string): IMessageBuilder { + this.msg.emoji = emoji; + return this as IMessageBuilder; + } + + public getEmojiAvatar(): string { + return this.msg.emoji!; + } + + public setAvatarUrl(avatarUrl: string): IMessageBuilder { + this.msg.avatarUrl = avatarUrl; + return this as IMessageBuilder; + } + + public getAvatarUrl(): string { + return this.msg.avatarUrl!; + } + + public setUsernameAlias(alias: string): IMessageBuilder { + this.msg.alias = alias; + return this as IMessageBuilder; + } + + public getUsernameAlias(): string { + return this.msg.alias!; + } + + public addAttachment(attachment: IMessageAttachment): IMessageBuilder { + if (!this.msg.attachments) { + this.msg.attachments = []; + } + + this.msg.attachments.push(attachment); + return this as IMessageBuilder; + } + + public setAttachments(attachments: Array): IMessageBuilder { + this.msg.attachments = attachments; + return this as IMessageBuilder; + } + + public getAttachments(): Array { + return this.msg.attachments!; + } + + public replaceAttachment(position: number, attachment: IMessageAttachment): IMessageBuilder { + if (!this.msg.attachments) { + this.msg.attachments = []; + } + + if (!this.msg.attachments[position]) { + throw new Error(`No attachment found at the index of "${position}" to replace.`); + } + + this.msg.attachments[position] = attachment; + return this as IMessageBuilder; + } + + public removeAttachment(position: number): IMessageBuilder { + if (!this.msg.attachments) { + this.msg.attachments = []; + } + + if (!this.msg.attachments[position]) { + throw new Error(`No attachment found at the index of "${position}" to remove.`); + } + + this.msg.attachments.splice(position, 1); + + return this as IMessageBuilder; + } + + public setEditor(user: IUser): IMessageBuilder { + this.msg.editor = user; + return this as IMessageBuilder; + } + + public getEditor(): IUser { + return this.msg.editor; + } + + public setGroupable(groupable: boolean): IMessageBuilder { + this.msg.groupable = groupable; + return this as IMessageBuilder; + } + + public getGroupable(): boolean { + return this.msg.groupable!; + } + + public setParseUrls(parseUrls: boolean): IMessageBuilder { + this.msg.parseUrls = parseUrls; + return this as IMessageBuilder; + } + + public getParseUrls(): boolean { + return this.msg.parseUrls!; + } + + public getMessage(): IMessage { + if (!this.msg.room) { + throw new Error('The "room" property is required.'); + } + + return this.msg; + } + + public addBlocks(blocks: BlockBuilder | Array) { + if (!Array.isArray(this.msg.blocks)) { + this.msg.blocks = []; + } + + if (blocks instanceof BlockBuilder) { + this.msg.blocks.push(...blocks.getBlocks()); + } else { + this.msg.blocks.push(...blocks); + } + + return this as IMessageBuilder; + } + + public setBlocks(blocks: BlockBuilder | Array) { + if (blocks instanceof BlockBuilder) { + this.msg.blocks = blocks.getBlocks(); + } else { + this.msg.blocks = blocks; + } + + return this as IMessageBuilder; + } + + public getBlocks() { + return this.msg.blocks!; + } + + public addCustomField(key: string, value: unknown): IMessageBuilder { + if (!this.msg.customFields) { + this.msg.customFields = {}; + } + + if (this.msg.customFields[key]) { + throw new Error(`The message already contains a custom field by the key: ${key}`); + } + + if (key.includes('.')) { + throw new Error(`The given key contains a period, which is not allowed. Key: ${key}`); + } + + this.msg.customFields[key] = value; + + return this as IMessageBuilder; + } +} diff --git a/packages/apps-engine/deno-runtime/lib/accessors/builders/RoomBuilder.ts b/packages/apps-engine/deno-runtime/lib/accessors/builders/RoomBuilder.ts new file mode 100644 index 000000000000..38983475162d --- /dev/null +++ b/packages/apps-engine/deno-runtime/lib/accessors/builders/RoomBuilder.ts @@ -0,0 +1,163 @@ +import type { IRoomBuilder } from '@rocket.chat/apps-engine/definition/accessors/IRoomBuilder.ts'; +import type { IRoom } from '@rocket.chat/apps-engine/definition/rooms/IRoom.ts'; +import type { IUser } from '@rocket.chat/apps-engine/definition/users/IUser.ts'; + +import type { RoomType } from '@rocket.chat/apps-engine/definition/rooms/RoomType.ts'; +import type { RocketChatAssociationModel as _RocketChatAssociationModel } from '@rocket.chat/apps-engine/definition/metadata/RocketChatAssociations.ts'; + +import { require } from '../../../lib/require.ts'; + +const { RocketChatAssociationModel } = require('@rocket.chat/apps-engine/definition/metadata/RocketChatAssociations.js') as { + RocketChatAssociationModel: typeof _RocketChatAssociationModel; +}; + +export class RoomBuilder implements IRoomBuilder { + public kind: _RocketChatAssociationModel.ROOM | _RocketChatAssociationModel.DISCUSSION; + + protected room: IRoom; + + private members: Array; + + constructor(data?: Partial) { + this.kind = RocketChatAssociationModel.ROOM; + this.room = (data || { customFields: {} }) as IRoom; + this.members = []; + } + + public setData(data: Partial): IRoomBuilder { + delete data.id; + this.room = data as IRoom; + + return this; + } + + public setDisplayName(name: string): IRoomBuilder { + this.room.displayName = name; + return this; + } + + public getDisplayName(): string { + return this.room.displayName!; + } + + public setSlugifiedName(name: string): IRoomBuilder { + this.room.slugifiedName = name; + return this; + } + + public getSlugifiedName(): string { + return this.room.slugifiedName; + } + + public setType(type: RoomType): IRoomBuilder { + this.room.type = type; + return this; + } + + public getType(): RoomType { + return this.room.type; + } + + public setCreator(creator: IUser): IRoomBuilder { + this.room.creator = creator; + return this; + } + + public getCreator(): IUser { + return this.room.creator; + } + + /** + * @deprecated + */ + public addUsername(username: string): IRoomBuilder { + this.addMemberToBeAddedByUsername(username); + return this; + } + + /** + * @deprecated + */ + public setUsernames(usernames: Array): IRoomBuilder { + this.setMembersToBeAddedByUsernames(usernames); + return this; + } + + /** + * @deprecated + */ + public getUsernames(): Array { + const usernames = this.getMembersToBeAddedUsernames(); + if (usernames && usernames.length > 0) { + return usernames; + } + return this.room.usernames || []; + } + + public addMemberToBeAddedByUsername(username: string): IRoomBuilder { + this.members.push(username); + return this; + } + + public setMembersToBeAddedByUsernames(usernames: Array): IRoomBuilder { + this.members = usernames; + return this; + } + + public getMembersToBeAddedUsernames(): Array { + return this.members; + } + + public setDefault(isDefault: boolean): IRoomBuilder { + this.room.isDefault = isDefault; + return this; + } + + public getIsDefault(): boolean { + return this.room.isDefault!; + } + + public setReadOnly(isReadOnly: boolean): IRoomBuilder { + this.room.isReadOnly = isReadOnly; + return this; + } + + public getIsReadOnly(): boolean { + return this.room.isReadOnly!; + } + + public setDisplayingOfSystemMessages(displaySystemMessages: boolean): IRoomBuilder { + this.room.displaySystemMessages = displaySystemMessages; + return this; + } + + public getDisplayingOfSystemMessages(): boolean { + return this.room.displaySystemMessages!; + } + + public addCustomField(key: string, value: object): IRoomBuilder { + if (typeof this.room.customFields !== 'object') { + this.room.customFields = {}; + } + + this.room.customFields[key] = value; + return this; + } + + public setCustomFields(fields: { [key: string]: object }): IRoomBuilder { + this.room.customFields = fields; + return this; + } + + public getCustomFields(): { [key: string]: object } { + return this.room.customFields!; + } + + public getUserIds(): Array { + return this.room.userIds!; + } + + public getRoom(): IRoom { + return this.room; + } +} diff --git a/packages/apps-engine/deno-runtime/lib/accessors/builders/UserBuilder.ts b/packages/apps-engine/deno-runtime/lib/accessors/builders/UserBuilder.ts new file mode 100644 index 000000000000..01c11a13f7a3 --- /dev/null +++ b/packages/apps-engine/deno-runtime/lib/accessors/builders/UserBuilder.ts @@ -0,0 +1,81 @@ +import type { IUserBuilder } from '@rocket.chat/apps-engine/definition/accessors/IUserBuilder.ts'; +import type { IUser } from '@rocket.chat/apps-engine/definition/users/IUser.ts'; +import type { IUserSettings } from '@rocket.chat/apps-engine/definition/users/IUserSettings.ts'; +import type { IUserEmail } from '@rocket.chat/apps-engine/definition/users/IUserEmail.ts'; +import type { RocketChatAssociationModel as _RocketChatAssociationModel } from '@rocket.chat/apps-engine/definition/metadata/RocketChatAssociations.ts'; + +import { require } from '../../../lib/require.ts'; + +const { RocketChatAssociationModel } = require('@rocket.chat/apps-engine/definition/metadata/RocketChatAssociations.js') as { + RocketChatAssociationModel: typeof _RocketChatAssociationModel; +}; + +export class UserBuilder implements IUserBuilder { + public kind: _RocketChatAssociationModel.USER; + + private user: Partial; + + constructor(user?: Partial) { + this.kind = RocketChatAssociationModel.USER; + this.user = user || ({} as Partial); + } + + public setData(data: Partial): IUserBuilder { + delete data.id; + this.user = data; + + return this; + } + + public setEmails(emails: Array): IUserBuilder { + this.user.emails = emails; + return this; + } + + public getEmails(): Array { + return this.user.emails!; + } + + public setDisplayName(name: string): IUserBuilder { + this.user.name = name; + return this; + } + + public getDisplayName(): string { + return this.user.name!; + } + + public setUsername(username: string): IUserBuilder { + this.user.username = username; + return this; + } + + public getUsername(): string { + return this.user.username!; + } + + public setRoles(roles: Array): IUserBuilder { + this.user.roles = roles; + return this; + } + + public getRoles(): Array { + return this.user.roles!; + } + + public getSettings(): Partial { + return this.user.settings; + } + + public getUser(): Partial { + if (!this.user.username) { + throw new Error('The "username" property is required.'); + } + + if (!this.user.name) { + throw new Error('The "name" property is required.'); + } + + return this.user; + } +} diff --git a/packages/apps-engine/deno-runtime/lib/accessors/builders/VideoConferenceBuilder.ts b/packages/apps-engine/deno-runtime/lib/accessors/builders/VideoConferenceBuilder.ts new file mode 100644 index 000000000000..e617cdddf154 --- /dev/null +++ b/packages/apps-engine/deno-runtime/lib/accessors/builders/VideoConferenceBuilder.ts @@ -0,0 +1,94 @@ +import type { IVideoConferenceBuilder } from '@rocket.chat/apps-engine/definition/accessors/IVideoConferenceBuilder.ts'; +import type { IGroupVideoConference } from '@rocket.chat/apps-engine/definition/videoConferences/IVideoConference.ts'; + +import type { RocketChatAssociationModel as _RocketChatAssociationModel } from '@rocket.chat/apps-engine/definition/metadata/RocketChatAssociations.ts'; + +import { require } from '../../../lib/require.ts'; + +const { RocketChatAssociationModel } = require('@rocket.chat/apps-engine/definition/metadata/RocketChatAssociations.js') as { + RocketChatAssociationModel: typeof _RocketChatAssociationModel; +}; + +export type AppVideoConference = Pick & { + createdBy: IGroupVideoConference['createdBy']['_id']; +}; + +export class VideoConferenceBuilder implements IVideoConferenceBuilder { + public kind: _RocketChatAssociationModel.VIDEO_CONFERENCE = RocketChatAssociationModel.VIDEO_CONFERENCE; + + protected call: AppVideoConference; + + constructor(data?: Partial) { + this.call = (data || {}) as AppVideoConference; + } + + public setData(data: Partial): IVideoConferenceBuilder { + this.call = { + rid: data.rid!, + createdBy: data.createdBy, + providerName: data.providerName!, + title: data.title!, + discussionRid: data.discussionRid, + }; + + return this; + } + + public setRoomId(rid: string): IVideoConferenceBuilder { + this.call.rid = rid; + return this; + } + + public getRoomId(): string { + return this.call.rid; + } + + public setCreatedBy(userId: string): IVideoConferenceBuilder { + this.call.createdBy = userId; + return this; + } + + public getCreatedBy(): string { + return this.call.createdBy; + } + + public setProviderName(userId: string): IVideoConferenceBuilder { + this.call.providerName = userId; + return this; + } + + public getProviderName(): string { + return this.call.providerName; + } + + public setProviderData(data: Record | undefined): IVideoConferenceBuilder { + this.call.providerData = data; + return this; + } + + public getProviderData(): Record { + return this.call.providerData!; + } + + public setTitle(userId: string): IVideoConferenceBuilder { + this.call.title = userId; + return this; + } + + public getTitle(): string { + return this.call.title; + } + + public setDiscussionRid(rid: AppVideoConference['discussionRid']): IVideoConferenceBuilder { + this.call.discussionRid = rid; + return this; + } + + public getDiscussionRid(): AppVideoConference['discussionRid'] { + return this.call.discussionRid; + } + + public getVideoConference(): AppVideoConference { + return this.call; + } +} diff --git a/packages/apps-engine/deno-runtime/lib/accessors/extenders/HttpExtender.ts b/packages/apps-engine/deno-runtime/lib/accessors/extenders/HttpExtender.ts new file mode 100644 index 000000000000..c323d385de9d --- /dev/null +++ b/packages/apps-engine/deno-runtime/lib/accessors/extenders/HttpExtender.ts @@ -0,0 +1,62 @@ +import type { + IHttpExtend, + IHttpPreRequestHandler, + IHttpPreResponseHandler +} from "@rocket.chat/apps-engine/definition/accessors/IHttp.ts"; + +export class HttpExtend implements IHttpExtend { + private headers: Map; + + private params: Map; + + private requests: Array; + + private responses: Array; + + constructor() { + this.headers = new Map(); + this.params = new Map(); + this.requests = []; + this.responses = []; + } + + public provideDefaultHeader(key: string, value: string): void { + this.headers.set(key, value); + } + + public provideDefaultHeaders(headers: { [key: string]: string }): void { + Object.keys(headers).forEach((key) => this.headers.set(key, headers[key])); + } + + public provideDefaultParam(key: string, value: string): void { + this.params.set(key, value); + } + + public provideDefaultParams(params: { [key: string]: string }): void { + Object.keys(params).forEach((key) => this.params.set(key, params[key])); + } + + public providePreRequestHandler(handler: IHttpPreRequestHandler): void { + this.requests.push(handler); + } + + public providePreResponseHandler(handler: IHttpPreResponseHandler): void { + this.responses.push(handler); + } + + public getDefaultHeaders(): Map { + return new Map(this.headers); + } + + public getDefaultParams(): Map { + return new Map(this.params); + } + + public getPreRequestHandlers(): Array { + return Array.from(this.requests); + } + + public getPreResponseHandlers(): Array { + return Array.from(this.responses); + } +} diff --git a/packages/apps-engine/deno-runtime/lib/accessors/extenders/MessageExtender.ts b/packages/apps-engine/deno-runtime/lib/accessors/extenders/MessageExtender.ts new file mode 100644 index 000000000000..abf1629c760a --- /dev/null +++ b/packages/apps-engine/deno-runtime/lib/accessors/extenders/MessageExtender.ts @@ -0,0 +1,66 @@ +import type { IMessageExtender } from '@rocket.chat/apps-engine/definition/accessors/IMessageExtender.ts'; +import type { RocketChatAssociationModel as _RocketChatAssociationModel } from '@rocket.chat/apps-engine/definition/metadata/RocketChatAssociations.ts'; +import type { IMessage } from '@rocket.chat/apps-engine/definition/messages/IMessage.ts'; +import type { IMessageAttachment } from '@rocket.chat/apps-engine/definition/messages/IMessageAttachment.ts'; + +import { require } from '../../../lib/require.ts'; + +const { RocketChatAssociationModel } = require('@rocket.chat/apps-engine/definition/metadata/RocketChatAssociations.js') as { + RocketChatAssociationModel: typeof _RocketChatAssociationModel; +}; + +export class MessageExtender implements IMessageExtender { + public readonly kind: _RocketChatAssociationModel.MESSAGE; + + constructor(private msg: IMessage) { + this.kind = RocketChatAssociationModel.MESSAGE; + + if (!Array.isArray(msg.attachments)) { + this.msg.attachments = []; + } + } + + public addCustomField(key: string, value: unknown): IMessageExtender { + if (!this.msg.customFields) { + this.msg.customFields = {}; + } + + if (this.msg.customFields[key]) { + throw new Error(`The message already contains a custom field by the key: ${key}`); + } + + if (key.includes('.')) { + throw new Error(`The given key contains a period, which is not allowed. Key: ${key}`); + } + + this.msg.customFields[key] = value; + + return this; + } + + public addAttachment(attachment: IMessageAttachment): IMessageExtender { + this.ensureAttachment(); + + this.msg.attachments!.push(attachment); + + return this; + } + + public addAttachments(attachments: Array): IMessageExtender { + this.ensureAttachment(); + + this.msg.attachments = this.msg.attachments!.concat(attachments); + + return this; + } + + public getMessage(): IMessage { + return structuredClone(this.msg); + } + + private ensureAttachment(): void { + if (!Array.isArray(this.msg.attachments)) { + this.msg.attachments = []; + } + } +} diff --git a/packages/apps-engine/deno-runtime/lib/accessors/extenders/RoomExtender.ts b/packages/apps-engine/deno-runtime/lib/accessors/extenders/RoomExtender.ts new file mode 100644 index 000000000000..6509d5dae90e --- /dev/null +++ b/packages/apps-engine/deno-runtime/lib/accessors/extenders/RoomExtender.ts @@ -0,0 +1,61 @@ +import type { IRoomExtender } from '@rocket.chat/apps-engine/definition/accessors/IRoomExtender.ts'; +import type { RocketChatAssociationModel as _RocketChatAssociationModel } from '@rocket.chat/apps-engine/definition/metadata/RocketChatAssociations.ts'; +import type { IRoom } from '@rocket.chat/apps-engine/definition/rooms/IRoom.ts'; +import type { IUser } from '@rocket.chat/apps-engine/definition/users/IUser.ts'; + +import { require } from '../../../lib/require.ts'; + +const { RocketChatAssociationModel } = require('@rocket.chat/apps-engine/definition/metadata/RocketChatAssociations.js') as { + RocketChatAssociationModel: typeof _RocketChatAssociationModel; +}; + +export class RoomExtender implements IRoomExtender { + public kind: _RocketChatAssociationModel.ROOM; + + private members: Array; + + constructor(private room: IRoom) { + this.kind = RocketChatAssociationModel.ROOM; + this.members = []; + } + + public addCustomField(key: string, value: unknown): IRoomExtender { + if (!this.room.customFields) { + this.room.customFields = {}; + } + + if (this.room.customFields[key]) { + throw new Error(`The room already contains a custom field by the key: ${key}`); + } + + if (key.includes('.')) { + throw new Error(`The given key contains a period, which is not allowed. Key: ${key}`); + } + + this.room.customFields[key] = value; + + return this; + } + + public addMember(user: IUser): IRoomExtender { + if (this.members.find((u) => u.username === user.username)) { + throw new Error('The user is already in the room.'); + } + + this.members.push(user); + + return this; + } + + public getMembersBeingAdded(): Array { + return this.members; + } + + public getUsernamesOfMembersBeingAdded(): Array { + return this.members.map((u) => u.username); + } + + public getRoom(): IRoom { + return structuredClone(this.room); + } +} diff --git a/packages/apps-engine/deno-runtime/lib/accessors/extenders/VideoConferenceExtend.ts b/packages/apps-engine/deno-runtime/lib/accessors/extenders/VideoConferenceExtend.ts new file mode 100644 index 000000000000..9616bf619067 --- /dev/null +++ b/packages/apps-engine/deno-runtime/lib/accessors/extenders/VideoConferenceExtend.ts @@ -0,0 +1,69 @@ +import type { IVideoConferenceExtender } from '@rocket.chat/apps-engine/definition/accessors/IVideoConferenceExtend.ts'; +import type { VideoConference, VideoConferenceMember } from '@rocket.chat/apps-engine/definition/videoConferences/IVideoConference.ts'; +import type { IVideoConferenceUser } from '@rocket.chat/apps-engine/definition/videoConferences/IVideoConferenceUser.ts'; +import type { RocketChatAssociationModel as _RocketChatAssociationModel } from '@rocket.chat/apps-engine/definition/metadata/RocketChatAssociations.ts'; + +import { require } from '../../../lib/require.ts'; + +const { RocketChatAssociationModel } = require('@rocket.chat/apps-engine/definition/metadata/RocketChatAssociations.js') as { + RocketChatAssociationModel: typeof _RocketChatAssociationModel; +}; + +export class VideoConferenceExtender implements IVideoConferenceExtender { + public kind: _RocketChatAssociationModel.VIDEO_CONFERENCE; + + constructor(private videoConference: VideoConference) { + this.kind = RocketChatAssociationModel.VIDEO_CONFERENCE; + } + + public setProviderData(value: Record): IVideoConferenceExtender { + this.videoConference.providerData = value; + + return this; + } + + public setStatus(value: VideoConference['status']): IVideoConferenceExtender { + this.videoConference.status = value; + + return this; + } + + public setEndedBy(value: IVideoConferenceUser['_id']): IVideoConferenceExtender { + this.videoConference.endedBy = { + _id: value, + // Name and username will be loaded automatically by the bridge + username: '', + name: '', + }; + + return this; + } + + public setEndedAt(value: VideoConference['endedAt']): IVideoConferenceExtender { + this.videoConference.endedAt = value; + + return this; + } + + public addUser(userId: VideoConferenceMember['_id'], ts?: VideoConferenceMember['ts']): IVideoConferenceExtender { + this.videoConference.users.push({ + _id: userId, + ts, + // Name and username will be loaded automatically by the bridge + username: '', + name: '', + }); + + return this; + } + + public setDiscussionRid(rid: VideoConference['discussionRid']): IVideoConferenceExtender { + this.videoConference.discussionRid = rid; + + return this; + } + + public getVideoConference(): VideoConference { + return structuredClone(this.videoConference); + } +} diff --git a/packages/apps-engine/deno-runtime/lib/accessors/http.ts b/packages/apps-engine/deno-runtime/lib/accessors/http.ts new file mode 100644 index 000000000000..f55838e60186 --- /dev/null +++ b/packages/apps-engine/deno-runtime/lib/accessors/http.ts @@ -0,0 +1,92 @@ +import type { + IHttp, + IHttpExtend, + IHttpRequest, + IHttpResponse, +} from "@rocket.chat/apps-engine/definition/accessors/IHttp.ts"; +import type { IPersistence } from "@rocket.chat/apps-engine/definition/accessors/IPersistence.ts"; +import type { IRead } from "@rocket.chat/apps-engine/definition/accessors/IRead.ts"; + +import * as Messenger from '../messenger.ts'; +import { AppObjectRegistry } from "../../AppObjectRegistry.ts"; + +type RequestMethod = 'get' | 'post' | 'put' | 'head' | 'delete' | 'patch'; + +export class Http implements IHttp { + private httpExtender: IHttpExtend; + private read: IRead; + private persistence: IPersistence; + private senderFn: typeof Messenger.sendRequest; + + constructor(read: IRead, persistence: IPersistence, httpExtender: IHttpExtend, senderFn: typeof Messenger.sendRequest) { + this.read = read; + this.persistence = persistence; + this.httpExtender = httpExtender; + this.senderFn = senderFn; + // this.httpExtender = new HttpExtend(); + } + + public get(url: string, options?: IHttpRequest): Promise { + return this._processHandler(url, 'get', options); + } + + public put(url: string, options?: IHttpRequest): Promise { + return this._processHandler(url, 'put', options); + } + + public post(url: string, options?: IHttpRequest): Promise { + return this._processHandler(url, 'post', options); + } + + public del(url: string, options?: IHttpRequest): Promise { + return this._processHandler(url, 'delete', options); + } + + public patch(url: string, options?: IHttpRequest): Promise { + return this._processHandler(url, 'patch', options); + } + + private async _processHandler(url: string, method: RequestMethod, options?: IHttpRequest): Promise { + let request = options || {}; + + if (typeof request.headers === 'undefined') { + request.headers = {}; + } + + this.httpExtender.getDefaultHeaders().forEach((value: string, key: string) => { + if (typeof request.headers?.[key] !== 'string') { + request.headers![key] = value; + } + }); + + if (typeof request.params === 'undefined') { + request.params = {}; + } + + this.httpExtender.getDefaultParams().forEach((value: string, key: string) => { + if (typeof request.params?.[key] !== 'string') { + request.params![key] = value; + } + }); + + for (const handler of this.httpExtender.getPreRequestHandlers()) { + request = await handler.executePreHttpRequest(url, request, this.read, this.persistence); + } + + let { result: response } = await this.senderFn({ + method: `bridges:getHttpBridge:doCall`, + params: [{ + appId: AppObjectRegistry.get('id'), + method, + url, + request, + }], + }) + + for (const handler of this.httpExtender.getPreResponseHandlers()) { + response = await handler.executePreHttpResponse(response as IHttpResponse, this.read, this.persistence); + } + + return response as IHttpResponse; + } +} diff --git a/packages/apps-engine/deno-runtime/lib/accessors/mod.ts b/packages/apps-engine/deno-runtime/lib/accessors/mod.ts new file mode 100644 index 000000000000..e71f014421ab --- /dev/null +++ b/packages/apps-engine/deno-runtime/lib/accessors/mod.ts @@ -0,0 +1,302 @@ +import type { IAppAccessors } from '@rocket.chat/apps-engine/definition/accessors/IAppAccessors.ts'; +import type { IApiEndpointMetadata } from '@rocket.chat/apps-engine/definition/api/IApiEndpointMetadata.ts'; +import type { IEnvironmentWrite } from '@rocket.chat/apps-engine/definition/accessors/IEnvironmentWrite.ts'; +import type { IEnvironmentRead } from '@rocket.chat/apps-engine/definition/accessors/IEnvironmentRead.ts'; +import type { IConfigurationModify } from '@rocket.chat/apps-engine/definition/accessors/IConfigurationModify.ts'; +import type { IRead } from '@rocket.chat/apps-engine/definition/accessors/IRead.ts'; +import type { IModify } from '@rocket.chat/apps-engine/definition/accessors/IModify.ts'; +import type { INotifier } from '@rocket.chat/apps-engine/definition/accessors/INotifier.ts'; +import type { IPersistence } from '@rocket.chat/apps-engine/definition/accessors/IPersistence.ts'; +import type { IHttp, IHttpExtend } from '@rocket.chat/apps-engine/definition/accessors/IHttp.ts'; +import type { IConfigurationExtend } from '@rocket.chat/apps-engine/definition/accessors/IConfigurationExtend.ts'; +import type { ISlashCommand } from '@rocket.chat/apps-engine/definition/slashcommands/ISlashCommand.ts'; +import type { IProcessor } from '@rocket.chat/apps-engine/definition/scheduler/IProcessor.ts'; +import type { IApi } from '@rocket.chat/apps-engine/definition/api/IApi.ts'; +import type { IVideoConfProvider } from '@rocket.chat/apps-engine/definition/videoConfProviders/IVideoConfProvider.ts'; + +import { Http } from './http.ts'; +import { HttpExtend } from './extenders/HttpExtender.ts'; +import * as Messenger from '../messenger.ts'; +import { AppObjectRegistry } from '../../AppObjectRegistry.ts'; +import { ModifyCreator } from './modify/ModifyCreator.ts'; +import { ModifyUpdater } from './modify/ModifyUpdater.ts'; +import { ModifyExtender } from './modify/ModifyExtender.ts'; +import { Notifier } from './notifier.ts'; + +const httpMethods = ['get', 'post', 'put', 'delete', 'head', 'options', 'patch'] as const; + +// We need to create this object first thing, as we'll handle references to it later on +if (!AppObjectRegistry.has('apiEndpoints')) { + AppObjectRegistry.set('apiEndpoints', []); +} + +export class AppAccessors { + private defaultAppAccessors?: IAppAccessors; + private environmentRead?: IEnvironmentRead; + private environmentWriter?: IEnvironmentWrite; + private configModifier?: IConfigurationModify; + private configExtender?: IConfigurationExtend; + private reader?: IRead; + private modifier?: IModify; + private persistence?: IPersistence; + private creator?: ModifyCreator; + private updater?: ModifyUpdater; + private extender?: ModifyExtender; + private httpExtend: IHttpExtend = new HttpExtend(); + private http?: IHttp; + private notifier?: INotifier; + + private proxify: (namespace: string, overrides?: Record unknown>) => T; + + constructor(private readonly senderFn: typeof Messenger.sendRequest) { + this.proxify = (namespace: string, overrides: Record unknown> = {}): T => + new Proxy( + { __kind: `accessor:${namespace}` }, + { + get: + (_target: unknown, prop: string) => + (...params: unknown[]) => { + // We don't want to send a request for this prop + if (prop === 'toJSON') { + return {}; + } + + // If the prop is inteded to be overriden by the caller + if (prop in overrides) { + return overrides[prop].apply(undefined, params); + } + + return senderFn({ + method: `accessor:${namespace}:${prop}`, + params, + }) + .then((response) => response.result) + .catch((err) => { throw new Error(err.error) }); + }, + }, + ) as T; + + this.http = new Http(this.getReader(), this.getPersistence(), this.httpExtend, this.getSenderFn()); + this.notifier = new Notifier(this.getSenderFn()); + } + + public getSenderFn() { + return this.senderFn; + } + + public getEnvironmentRead(): IEnvironmentRead { + if (!this.environmentRead) { + this.environmentRead = { + getSettings: () => this.proxify('getEnvironmentRead:getSettings'), + getServerSettings: () => this.proxify('getEnvironmentRead:getServerSettings'), + getEnvironmentVariables: () => this.proxify('getEnvironmentRead:getEnvironmentVariables'), + }; + } + + return this.environmentRead; + } + + public getEnvironmentWrite() { + if (!this.environmentWriter) { + this.environmentWriter = { + getSettings: () => this.proxify('getEnvironmentWrite:getSettings'), + getServerSettings: () => this.proxify('getEnvironmentWrite:getServerSettings'), + }; + } + + return this.environmentWriter; + } + + public getConfigurationModify() { + if (!this.configModifier) { + this.configModifier = { + scheduler: this.proxify('getConfigurationModify:scheduler'), + slashCommands: { + _proxy: this.proxify('getConfigurationModify:slashCommands'), + modifySlashCommand(slashcommand: ISlashCommand) { + // Store the slashcommand instance to use when the Apps-Engine calls the slashcommand + AppObjectRegistry.set(`slashcommand:${slashcommand.command}`, slashcommand); + + return this._proxy.modifySlashCommand(slashcommand); + }, + disableSlashCommand(command: string) { + return this._proxy.disableSlashCommand(command); + }, + enableSlashCommand(command: string) { + return this._proxy.enableSlashCommand(command); + }, + }, + serverSettings: this.proxify('getConfigurationModify:serverSettings'), + }; + } + + return this.configModifier; + } + + public getConfigurationExtend() { + if (!this.configExtender) { + const senderFn = this.senderFn; + + this.configExtender = { + ui: this.proxify('getConfigurationExtend:ui'), + http: this.httpExtend, + settings: this.proxify('getConfigurationExtend:settings'), + externalComponents: this.proxify('getConfigurationExtend:externalComponents'), + api: { + _proxy: this.proxify('getConfigurationExtend:api'), + async provideApi(api: IApi) { + const apiEndpoints = AppObjectRegistry.get('apiEndpoints')!; + + api.endpoints.forEach((endpoint) => { + endpoint._availableMethods = httpMethods.filter((method) => typeof endpoint[method] === 'function'); + + // We need to keep a reference to the endpoint around for us to call the executor later + AppObjectRegistry.set(`api:${endpoint.path}`, endpoint); + }); + + const result = await this._proxy.provideApi(api); + + // Let's call the listApis method to cache the info from the endpoints + // Also, since this is a side-effect, we do it async so we can return to the caller + senderFn({ method: 'accessor:api:listApis' }) + .then((response) => apiEndpoints.push(...(response.result as IApiEndpointMetadata[]))) + .catch((err) => err.error); + + return result; + }, + }, + scheduler: { + _proxy: this.proxify('getConfigurationExtend:scheduler'), + registerProcessors(processors: IProcessor[]) { + // Store the processor instance to use when the Apps-Engine calls the processor + processors.forEach((processor) => { + AppObjectRegistry.set(`scheduler:${processor.id}`, processor); + }); + + return this._proxy.registerProcessors(processors); + }, + }, + videoConfProviders: { + _proxy: this.proxify('getConfigurationExtend:videoConfProviders'), + provideVideoConfProvider(provider: IVideoConfProvider) { + // Store the videoConfProvider instance to use when the Apps-Engine calls the videoConfProvider + AppObjectRegistry.set(`videoConfProvider:${provider.name}`, provider); + + return this._proxy.provideVideoConfProvider(provider); + }, + }, + slashCommands: { + _proxy: this.proxify('getConfigurationExtend:slashCommands'), + provideSlashCommand(slashcommand: ISlashCommand) { + // Store the slashcommand instance to use when the Apps-Engine calls the slashcommand + AppObjectRegistry.set(`slashcommand:${slashcommand.command}`, slashcommand); + + return this._proxy.provideSlashCommand(slashcommand); + }, + }, + }; + } + + return this.configExtender; + } + + public getDefaultAppAccessors() { + if (!this.defaultAppAccessors) { + this.defaultAppAccessors = { + environmentReader: this.getEnvironmentRead(), + environmentWriter: this.getEnvironmentWrite(), + reader: this.getReader(), + http: this.getHttp(), + providedApiEndpoints: AppObjectRegistry.get('apiEndpoints') as IApiEndpointMetadata[], + }; + } + + return this.defaultAppAccessors; + } + + public getReader() { + if (!this.reader) { + this.reader = { + getEnvironmentReader: () => ({ + getSettings: () => this.proxify('getReader:getEnvironmentReader:getSettings'), + getServerSettings: () => this.proxify('getReader:getEnvironmentReader:getServerSettings'), + getEnvironmentVariables: () => this.proxify('getReader:getEnvironmentReader:getEnvironmentVariables'), + }), + getMessageReader: () => this.proxify('getReader:getMessageReader'), + getPersistenceReader: () => this.proxify('getReader:getPersistenceReader'), + getRoomReader: () => this.proxify('getReader:getRoomReader'), + getUserReader: () => this.proxify('getReader:getUserReader'), + getNotifier: () => this.getNotifier(), + getLivechatReader: () => this.proxify('getReader:getLivechatReader'), + getUploadReader: () => this.proxify('getReader:getUploadReader'), + getCloudWorkspaceReader: () => this.proxify('getReader:getCloudWorkspaceReader'), + getVideoConferenceReader: () => this.proxify('getReader:getVideoConferenceReader'), + getOAuthAppsReader: () => this.proxify('getReader:getOAuthAppsReader'), + getThreadReader: () => this.proxify('getReader:getThreadReader'), + getRoleReader: () => this.proxify('getReader:getRoleReader'), + }; + } + + return this.reader; + } + + public getModifier() { + if (!this.modifier) { + this.modifier = { + getCreator: this.getCreator.bind(this), + getUpdater: this.getUpdater.bind(this), + getExtender: this.getExtender.bind(this), + getDeleter: () => this.proxify('getModifier:getDeleter'), + getNotifier: () => this.getNotifier(), + getUiController: () => this.proxify('getModifier:getUiController'), + getScheduler: () => this.proxify('getModifier:getScheduler'), + getOAuthAppsModifier: () => this.proxify('getModifier:getOAuthAppsModifier'), + getModerationModifier: () => this.proxify('getModifier:getModerationModifier'), + }; + } + + return this.modifier; + } + + public getPersistence() { + if (!this.persistence) { + this.persistence = this.proxify('getPersistence'); + } + + return this.persistence; + } + + public getHttp() { + return this.http; + } + + private getCreator() { + if (!this.creator) { + this.creator = new ModifyCreator(this.senderFn); + } + + return this.creator; + } + + private getUpdater() { + if (!this.updater) { + this.updater = new ModifyUpdater(this.senderFn); + } + + return this.updater; + } + + private getExtender() { + if (!this.extender) { + this.extender = new ModifyExtender(this.senderFn); + } + + return this.extender; + } + + private getNotifier() { + return this.notifier; + } +} + +export const AppAccessorsInstance = new AppAccessors(Messenger.sendRequest); diff --git a/packages/apps-engine/deno-runtime/lib/accessors/modify/ModifyCreator.ts b/packages/apps-engine/deno-runtime/lib/accessors/modify/ModifyCreator.ts new file mode 100644 index 000000000000..06797551a621 --- /dev/null +++ b/packages/apps-engine/deno-runtime/lib/accessors/modify/ModifyCreator.ts @@ -0,0 +1,344 @@ +import type { IModifyCreator } from '@rocket.chat/apps-engine/definition/accessors/IModifyCreator.ts'; +import type { IUploadCreator } from '@rocket.chat/apps-engine/definition/accessors/IUploadCreator.ts'; +import type { IEmailCreator } from '@rocket.chat/apps-engine/definition/accessors/IEmailCreator.ts'; +import type { ILivechatCreator } from '@rocket.chat/apps-engine/definition/accessors/ILivechatCreator.ts'; +import type { IMessage } from '@rocket.chat/apps-engine/definition/messages/IMessage.ts'; +import type { IRoom } from '@rocket.chat/apps-engine/definition/rooms/IRoom.ts'; +import type { IBotUser } from '@rocket.chat/apps-engine/definition/users/IBotUser.ts'; +import type { UserType as _UserType } from '@rocket.chat/apps-engine/definition/users/UserType.ts'; +import type { RocketChatAssociationModel as _RocketChatAssociationModel } from '@rocket.chat/apps-engine/definition/metadata/RocketChatAssociations.ts'; +import type { IMessageBuilder } from '@rocket.chat/apps-engine/definition/accessors/IMessageBuilder.ts'; +import type { IRoomBuilder } from '@rocket.chat/apps-engine/definition/accessors/IRoomBuilder.ts'; +import type { IUserBuilder } from '@rocket.chat/apps-engine/definition/accessors/IUserBuilder.ts'; +import type { IVideoConferenceBuilder } from '@rocket.chat/apps-engine/definition/accessors/IVideoConferenceBuilder.ts'; +import type { RoomType as _RoomType } from '@rocket.chat/apps-engine/definition/rooms/RoomType.ts'; +import type { ILivechatMessageBuilder } from '@rocket.chat/apps-engine/definition/accessors/ILivechatMessageBuilder.ts'; +import type { UIHelper as _UIHelper } from '@rocket.chat/apps-engine/server/misc/UIHelper.ts'; + +import * as Messenger from '../../messenger.ts'; + +import { BlockBuilder } from '../builders/BlockBuilder.ts'; +import { MessageBuilder } from '../builders/MessageBuilder.ts'; +import { DiscussionBuilder, IDiscussionBuilder } from '../builders/DiscussionBuilder.ts'; +import { ILivechatMessage, LivechatMessageBuilder } from '../builders/LivechatMessageBuilder.ts'; +import { RoomBuilder } from '../builders/RoomBuilder.ts'; +import { UserBuilder } from '../builders/UserBuilder.ts'; +import { AppVideoConference, VideoConferenceBuilder } from '../builders/VideoConferenceBuilder.ts'; +import { AppObjectRegistry } from '../../../AppObjectRegistry.ts'; +import { require } from '../../../lib/require.ts'; + +const { UIHelper } = require('@rocket.chat/apps-engine/server/misc/UIHelper.js') as { UIHelper: typeof _UIHelper }; +const { RoomType } = require('@rocket.chat/apps-engine/definition/rooms/RoomType.js') as { RoomType: typeof _RoomType }; +const { UserType } = require('@rocket.chat/apps-engine/definition/users/UserType.js') as { UserType: typeof _UserType }; +const { RocketChatAssociationModel } = require('@rocket.chat/apps-engine/definition/metadata/RocketChatAssociations.js') as { + RocketChatAssociationModel: typeof _RocketChatAssociationModel; +}; + +export class ModifyCreator implements IModifyCreator { + constructor(private readonly senderFn: typeof Messenger.sendRequest) { } + + getLivechatCreator(): ILivechatCreator { + return new Proxy( + { __kind: 'getLivechatCreator' }, + { + get: (_target: unknown, prop: string) => { + // It's not worthwhile to make an asynchronous request for such a simple method + if (prop === 'createToken') { + return () => Math.random().toString(36).substring(2, 15) + Math.random().toString(36).substring(2, 15); + } + + if (prop === 'toJSON') { + return () => ({}); + } + + return (...params: unknown[]) => + this.senderFn({ + method: `accessor:getModifier:getCreator:getLivechatCreator:${prop}`, + params, + }) + .then((response) => response.result) + .catch((err) => { + throw new Error(err.error); + }); + }, + }, + ) as ILivechatCreator; + } + + getUploadCreator(): IUploadCreator { + return new Proxy( + { __kind: 'getUploadCreator' }, + { + get: + (_target: unknown, prop: string) => + (...params: unknown[]) => + prop === 'toJSON' + ? {} + : this.senderFn({ + method: `accessor:getModifier:getCreator:getUploadCreator:${prop}`, + params, + }) + .then((response) => response.result) + .catch((err) => { + throw new Error(err.error); + }), + }, + ) as IUploadCreator; + } + + getEmailCreator(): IEmailCreator { + return new Proxy( + { __kind: 'getEmailCreator' }, + { + get: (_target: unknown, prop: string) => + (...params: unknown[]) => + prop === 'toJSON' + ? {} + : this.senderFn({ + method: `accessor:getModifier:getCreator:getEmailCreator:${prop}`, + params + }) + .then((response) => response.result) + .catch((err) => { + throw new Error(err.error); + }), + } + ) + } + + getBlockBuilder() { + return new BlockBuilder(); + } + + startMessage(data?: IMessage) { + if (data) { + delete data.id; + } + + return new MessageBuilder(data); + } + + startLivechatMessage(data?: ILivechatMessage) { + if (data) { + delete data.id; + } + + return new LivechatMessageBuilder(data); + } + + startRoom(data?: IRoom) { + if (data) { + // @ts-ignore - this has been imported from the Apps-Engine + delete data.id; + } + + return new RoomBuilder(data); + } + + startDiscussion(data?: Partial) { + if (data) { + delete data.id; + } + + return new DiscussionBuilder(data); + } + + startVideoConference(data?: Partial) { + return new VideoConferenceBuilder(data); + } + + startBotUser(data?: Partial) { + if (data) { + delete data.id; + + const { roles } = data; + + if (roles?.length) { + const hasRole = roles + .map((role: string) => role.toLocaleLowerCase()) + .some((role: string) => role === 'admin' || role === 'owner' || role === 'moderator'); + + if (hasRole) { + throw new Error('Invalid role assigned to the user. Should not be admin, owner or moderator.'); + } + } + + if (!data.type) { + data.type = UserType.BOT; + } + } + + return new UserBuilder(data); + } + + public finish( + builder: IMessageBuilder | ILivechatMessageBuilder | IRoomBuilder | IDiscussionBuilder | IVideoConferenceBuilder | IUserBuilder, + ): Promise { + switch (builder.kind) { + case RocketChatAssociationModel.MESSAGE: + return this._finishMessage(builder as IMessageBuilder); + case RocketChatAssociationModel.LIVECHAT_MESSAGE: + return this._finishLivechatMessage(builder as ILivechatMessageBuilder); + case RocketChatAssociationModel.ROOM: + return this._finishRoom(builder as IRoomBuilder); + case RocketChatAssociationModel.DISCUSSION: + return this._finishDiscussion(builder as IDiscussionBuilder); + case RocketChatAssociationModel.VIDEO_CONFERENCE: + return this._finishVideoConference(builder as IVideoConferenceBuilder); + case RocketChatAssociationModel.USER: + return this._finishUser(builder as IUserBuilder); + default: + throw new Error('Invalid builder passed to the ModifyCreator.finish function.'); + } + } + + private async _finishMessage(builder: IMessageBuilder): Promise { + const result = builder.getMessage(); + delete result.id; + + if (!result.sender || !result.sender.id) { + const response = await this.senderFn({ + method: 'bridges:getUserBridge:doGetAppUser', + params: ['APP_ID'], + }); + + const appUser = response.result; + + if (!appUser) { + throw new Error('Invalid sender assigned to the message.'); + } + + result.sender = appUser; + } + + if (result.blocks?.length) { + // Can we move this elsewhere? This AppObjectRegistry usage doesn't really belong here, but where? + result.blocks = UIHelper.assignIds(result.blocks, AppObjectRegistry.get('id') || ''); + } + + const response = await this.senderFn({ + method: 'bridges:getMessageBridge:doCreate', + params: [result, AppObjectRegistry.get('id')], + }); + + return String(response.result); + } + + private async _finishLivechatMessage(builder: ILivechatMessageBuilder): Promise { + if (builder.getSender() && !builder.getVisitor()) { + return this._finishMessage(builder.getMessageBuilder()); + } + + const result = builder.getMessage(); + delete result.id; + + if (!result.token && (!result.visitor || !result.visitor.token)) { + throw new Error('Invalid visitor sending the message'); + } + + result.token = result.visitor ? result.visitor.token : result.token; + + const response = await this.senderFn({ + method: 'bridges:getLivechatBridge:doCreateMessage', + params: [result, AppObjectRegistry.get('id')], + }); + + return String(response.result); + } + + private async _finishRoom(builder: IRoomBuilder): Promise { + const result = builder.getRoom(); + delete result.id; + + if (!result.type) { + throw new Error('Invalid type assigned to the room.'); + } + + if (result.type !== RoomType.LIVE_CHAT) { + if (!result.creator || !result.creator.id) { + throw new Error('Invalid creator assigned to the room.'); + } + } + + if (result.type !== RoomType.DIRECT_MESSAGE) { + if (result.type !== RoomType.LIVE_CHAT) { + if (!result.slugifiedName || !result.slugifiedName.trim()) { + throw new Error('Invalid slugifiedName assigned to the room.'); + } + } + + if (!result.displayName || !result.displayName.trim()) { + throw new Error('Invalid displayName assigned to the room.'); + } + } + + const response = await this.senderFn({ + method: 'bridges:getRoomBridge:doCreate', + params: [result, builder.getMembersToBeAddedUsernames(), AppObjectRegistry.get('id')], + }); + + return String(response.result); + } + + private async _finishDiscussion(builder: IDiscussionBuilder): Promise { + const room = builder.getRoom(); + delete room.id; + + if (!room.creator || !room.creator.id) { + throw new Error('Invalid creator assigned to the discussion.'); + } + + if (!room.slugifiedName || !room.slugifiedName.trim()) { + throw new Error('Invalid slugifiedName assigned to the discussion.'); + } + + if (!room.displayName || !room.displayName.trim()) { + throw new Error('Invalid displayName assigned to the discussion.'); + } + + if (!room.parentRoom || !room.parentRoom.id) { + throw new Error('Invalid parentRoom assigned to the discussion.'); + } + + const response = await this.senderFn({ + method: 'bridges:getRoomBridge:doCreateDiscussion', + params: [room, builder.getParentMessage(), builder.getReply(), builder.getMembersToBeAddedUsernames(), AppObjectRegistry.get('id')], + }); + + return String(response.result); + } + + private async _finishVideoConference(builder: IVideoConferenceBuilder): Promise { + const videoConference = builder.getVideoConference(); + + if (!videoConference.createdBy) { + throw new Error('Invalid creator assigned to the video conference.'); + } + + if (!videoConference.providerName?.trim()) { + throw new Error('Invalid provider name assigned to the video conference.'); + } + + if (!videoConference.rid) { + throw new Error('Invalid roomId assigned to the video conference.'); + } + + const response = await this.senderFn({ + method: 'bridges:getVideoConferenceBridge:doCreate', + params: [videoConference, AppObjectRegistry.get('id')], + }); + + return String(response.result); + } + + private async _finishUser(builder: IUserBuilder): Promise { + const user = builder.getUser(); + + const response = await this.senderFn({ + method: 'bridges:getUserBridge:doCreate', + params: [user, AppObjectRegistry.get('id')], + }); + + return String(response.result); + } +} diff --git a/packages/apps-engine/deno-runtime/lib/accessors/modify/ModifyExtender.ts b/packages/apps-engine/deno-runtime/lib/accessors/modify/ModifyExtender.ts new file mode 100644 index 000000000000..c0793d015c64 --- /dev/null +++ b/packages/apps-engine/deno-runtime/lib/accessors/modify/ModifyExtender.ts @@ -0,0 +1,93 @@ +import type { IModifyExtender } from '@rocket.chat/apps-engine/definition/accessors/IModifyExtender.ts'; +import type { IMessage } from '@rocket.chat/apps-engine/definition/messages/IMessage.ts'; +import type { IMessageExtender } from '@rocket.chat/apps-engine/definition/accessors/IMessageExtender.ts'; +import type { IRoomExtender } from '@rocket.chat/apps-engine/definition/accessors/IRoomExtender.ts'; +import type { IVideoConferenceExtender } from '@rocket.chat/apps-engine/definition/accessors/IVideoConferenceExtend.ts'; +import type { IUser } from '@rocket.chat/apps-engine/definition/users/IUser.ts'; +import type { VideoConference } from '@rocket.chat/apps-engine/definition/videoConferences/IVideoConference.ts'; +import type { IRoom } from '@rocket.chat/apps-engine/definition/rooms/IRoom.ts'; +import type { RocketChatAssociationModel as _RocketChatAssociationModel } from '@rocket.chat/apps-engine/definition/metadata/RocketChatAssociations.ts'; + +import * as Messenger from '../../messenger.ts'; +import { AppObjectRegistry } from '../../../AppObjectRegistry.ts'; +import { MessageExtender } from '../extenders/MessageExtender.ts'; +import { RoomExtender } from '../extenders/RoomExtender.ts'; +import { VideoConferenceExtender } from '../extenders/VideoConferenceExtend.ts'; +import { require } from '../../../lib/require.ts'; + +const { RocketChatAssociationModel } = require('@rocket.chat/apps-engine/definition/metadata/RocketChatAssociations.js') as { + RocketChatAssociationModel: typeof _RocketChatAssociationModel; +}; + +export class ModifyExtender implements IModifyExtender { + constructor(private readonly senderFn: typeof Messenger.sendRequest) {} + + public async extendMessage(messageId: string, updater: IUser): Promise { + const result = await this.senderFn({ + method: 'bridges:getMessageBridge:doGetById', + params: [messageId, AppObjectRegistry.get('id')], + }); + + const msg = result.result as IMessage; + + msg.editor = updater; + msg.editedAt = new Date(); + + return new MessageExtender(msg); + } + + public async extendRoom(roomId: string, _updater: IUser): Promise { + const result = await this.senderFn({ + method: 'bridges:getRoomBridge:doGetById', + params: [roomId, AppObjectRegistry.get('id')], + }); + + const room = result.result as IRoom; + + room.updatedAt = new Date(); + + return new RoomExtender(room); + } + + public async extendVideoConference(id: string): Promise { + const result = await this.senderFn({ + method: 'bridges:getVideoConferenceBridge:doGetById', + params: [id, AppObjectRegistry.get('id')], + }); + + const call = result.result as VideoConference; + + call._updatedAt = new Date(); + + return new VideoConferenceExtender(call); + } + + public async finish(extender: IMessageExtender | IRoomExtender | IVideoConferenceExtender): Promise { + switch (extender.kind) { + case RocketChatAssociationModel.MESSAGE: + await this.senderFn({ + method: 'bridges:getMessageBridge:doUpdate', + params: [(extender as IMessageExtender).getMessage(), AppObjectRegistry.get('id')], + }); + break; + case RocketChatAssociationModel.ROOM: + await this.senderFn({ + method: 'bridges:getRoomBridge:doUpdate', + params: [ + (extender as IRoomExtender).getRoom(), + (extender as IRoomExtender).getUsernamesOfMembersBeingAdded(), + AppObjectRegistry.get('id'), + ], + }); + break; + case RocketChatAssociationModel.VIDEO_CONFERENCE: + await this.senderFn({ + method: 'bridges:getVideoConferenceBridge:doUpdate', + params: [(extender as IVideoConferenceExtender).getVideoConference(), AppObjectRegistry.get('id')], + }); + break; + default: + throw new Error('Invalid extender passed to the ModifyExtender.finish function.'); + } + } +} diff --git a/packages/apps-engine/deno-runtime/lib/accessors/modify/ModifyUpdater.ts b/packages/apps-engine/deno-runtime/lib/accessors/modify/ModifyUpdater.ts new file mode 100644 index 000000000000..8befe7bfa983 --- /dev/null +++ b/packages/apps-engine/deno-runtime/lib/accessors/modify/ModifyUpdater.ts @@ -0,0 +1,153 @@ +import type { IModifyUpdater } from '@rocket.chat/apps-engine/definition/accessors/IModifyUpdater.ts'; +import type { ILivechatUpdater } from '@rocket.chat/apps-engine/definition/accessors/ILivechatUpdater.ts'; +import type { IUserUpdater } from '@rocket.chat/apps-engine/definition/accessors/IUserUpdater.ts'; +import type { IMessageBuilder } from '@rocket.chat/apps-engine/definition/accessors/IMessageBuilder.ts'; +import type { IRoomBuilder } from '@rocket.chat/apps-engine/definition/accessors/IRoomBuilder.ts'; +import type { IUser } from '@rocket.chat/apps-engine/definition/users/IUser.ts'; +import type { IMessage } from '@rocket.chat/apps-engine/definition/messages/IMessage.ts'; +import type { IRoom } from '@rocket.chat/apps-engine/definition/rooms/IRoom.ts'; + +import type { UIHelper as _UIHelper } from '@rocket.chat/apps-engine/server/misc/UIHelper.ts'; +import type { RoomType as _RoomType } from '@rocket.chat/apps-engine/definition/rooms/RoomType.ts'; +import type { RocketChatAssociationModel as _RocketChatAssociationModel } from '@rocket.chat/apps-engine/definition/metadata/RocketChatAssociations.ts'; + +import * as Messenger from '../../messenger.ts'; + +import { MessageBuilder } from '../builders/MessageBuilder.ts'; +import { RoomBuilder } from '../builders/RoomBuilder.ts'; +import { AppObjectRegistry } from '../../../AppObjectRegistry.ts'; + +import { require } from '../../../lib/require.ts'; + +const { UIHelper } = require('@rocket.chat/apps-engine/server/misc/UIHelper.js') as { UIHelper: typeof _UIHelper }; +const { RoomType } = require('@rocket.chat/apps-engine/definition/rooms/RoomType.js') as { RoomType: typeof _RoomType }; +const { RocketChatAssociationModel } = require('@rocket.chat/apps-engine/definition/metadata/RocketChatAssociations.js') as { + RocketChatAssociationModel: typeof _RocketChatAssociationModel; +}; + +export class ModifyUpdater implements IModifyUpdater { + constructor(private readonly senderFn: typeof Messenger.sendRequest) { } + + public getLivechatUpdater(): ILivechatUpdater { + return new Proxy( + { __kind: 'getLivechatUpdater' }, + { + get: + (_target: unknown, prop: string) => + (...params: unknown[]) => + prop === 'toJSON' + ? {} + : this.senderFn({ + method: `accessor:getModifier:getUpdater:getLivechatUpdater:${prop}`, + params, + }) + .then((response) => response.result) + .catch((err) => { + throw new Error(err.error); + }), + }, + ) as ILivechatUpdater; + } + + public getUserUpdater(): IUserUpdater { + return new Proxy( + { __kind: 'getUserUpdater' }, + { + get: + (_target: unknown, prop: string) => + (...params: unknown[]) => + prop === 'toJSON' + ? {} + : this.senderFn({ + method: `accessor:getModifier:getUpdater:getUserUpdater:${prop}`, + params, + }) + .then((response) => response.result) + .catch((err) => { + throw new Error(err.error); + }), + }, + ) as IUserUpdater; + } + + public async message(messageId: string, _updater: IUser): Promise { + const response = await this.senderFn({ + method: 'bridges:getMessageBridge:doGetById', + params: [messageId, AppObjectRegistry.get('id')], + }); + + return new MessageBuilder(response.result as IMessage); + } + + public async room(roomId: string, _updater: IUser): Promise { + const response = await this.senderFn({ + method: 'bridges:getRoomBridge:doGetById', + params: [roomId, AppObjectRegistry.get('id')], + }); + + return new RoomBuilder(response.result as IRoom); + } + + public finish(builder: IMessageBuilder | IRoomBuilder): Promise { + switch (builder.kind) { + case RocketChatAssociationModel.MESSAGE: + return this._finishMessage(builder as IMessageBuilder); + case RocketChatAssociationModel.ROOM: + return this._finishRoom(builder as IRoomBuilder); + default: + throw new Error('Invalid builder passed to the ModifyUpdater.finish function.'); + } + } + + private async _finishMessage(builder: IMessageBuilder): Promise { + const result = builder.getMessage(); + + if (!result.id) { + throw new Error("Invalid message, can't update a message without an id."); + } + + if (!result.sender?.id) { + throw new Error('Invalid sender assigned to the message.'); + } + + if (result.blocks?.length) { + result.blocks = UIHelper.assignIds(result.blocks, AppObjectRegistry.get('id') || ''); + } + + await this.senderFn({ + method: 'bridges:getMessageBridge:doUpdate', + params: [result, AppObjectRegistry.get('id')], + }); + } + + private async _finishRoom(builder: IRoomBuilder): Promise { + const result = builder.getRoom(); + + if (!result.id) { + throw new Error("Invalid room, can't update a room without an id."); + } + + if (!result.type) { + throw new Error('Invalid type assigned to the room.'); + } + + if (result.type !== RoomType.LIVE_CHAT) { + if (!result.creator || !result.creator.id) { + throw new Error('Invalid creator assigned to the room.'); + } + + if (!result.slugifiedName || !result.slugifiedName.trim()) { + throw new Error('Invalid slugifiedName assigned to the room.'); + } + } + + if (!result.displayName || !result.displayName.trim()) { + throw new Error('Invalid displayName assigned to the room.'); + } + + await this.senderFn({ + method: 'bridges:getRoomBridge:doUpdate', + params: [result, builder.getMembersToBeAddedUsernames(), AppObjectRegistry.get('id')], + }); + } +} diff --git a/packages/apps-engine/deno-runtime/lib/accessors/notifier.ts b/packages/apps-engine/deno-runtime/lib/accessors/notifier.ts new file mode 100644 index 000000000000..625d68c1039f --- /dev/null +++ b/packages/apps-engine/deno-runtime/lib/accessors/notifier.ts @@ -0,0 +1,75 @@ +import type { IMessageBuilder, INotifier } from '@rocket.chat/apps-engine/definition/accessors'; +import type { ITypingOptions } from '@rocket.chat/apps-engine/definition/accessors/INotifier.ts'; +import type { _TypingScope } from '@rocket.chat/apps-engine/definition/accessors/INotifier.ts'; +import type { IMessage } from '@rocket.chat/apps-engine/definition/messages'; +import type { IRoom } from '@rocket.chat/apps-engine/definition/rooms'; +import type { IUser } from '@rocket.chat/apps-engine/definition/users'; +import { MessageBuilder } from './builders/MessageBuilder.ts'; +import { AppObjectRegistry } from '../../AppObjectRegistry.ts'; +import * as Messenger from '../messenger.ts'; +import { require } from "../require.ts"; + +const { TypingScope } = require('@rocket.chat/apps-engine/definition/accessors/INotifier.js') as { + TypingScope: typeof _TypingScope; +}; + +export class Notifier implements INotifier { + private senderFn: typeof Messenger.sendRequest; + + constructor(senderFn: typeof Messenger.sendRequest) { + this.senderFn = senderFn; + } + + public async notifyUser(user: IUser, message: IMessage): Promise { + if (!message.sender || !message.sender.id) { + const appUser = await this.getAppUser(); + + message.sender = appUser; + } + + await this.callMessageBridge('doNotifyUser', [user, message, AppObjectRegistry.get('id')]); + } + + public async notifyRoom(room: IRoom, message: IMessage): Promise { + if (!message.sender || !message.sender.id) { + const appUser = await this.getAppUser(); + + message.sender = appUser; + } + + await this.callMessageBridge('doNotifyRoom', [room, message, AppObjectRegistry.get('id')]); + } + + public async typing(options: ITypingOptions): Promise<() => Promise> { + options.scope = options.scope || TypingScope.Room; + + if (!options.username) { + const appUser = await this.getAppUser(); + options.username = (appUser && appUser.name) || ''; + } + + const appId = AppObjectRegistry.get('id'); + + await this.callMessageBridge('doTyping', [{ ...options, isTyping: true }, appId]); + + return async () => { + await this.callMessageBridge('doTyping', [{ ...options, isTyping: false }, appId]); + }; + } + + public getMessageBuilder(): IMessageBuilder { + return new MessageBuilder(); + } + + private async callMessageBridge(method: string, params: Array): Promise { + await this.senderFn({ + method: `bridges:getMessageBridge:${method}`, + params, + }); + } + + private async getAppUser(): Promise { + const response = await this.senderFn({ method: 'bridges:getUserBridge:doGetAppUser', params: [AppObjectRegistry.get('id')] }); + return response.result; + } +} diff --git a/packages/apps-engine/deno-runtime/lib/accessors/tests/AppAccessors.test.ts b/packages/apps-engine/deno-runtime/lib/accessors/tests/AppAccessors.test.ts new file mode 100644 index 000000000000..04592eadd3db --- /dev/null +++ b/packages/apps-engine/deno-runtime/lib/accessors/tests/AppAccessors.test.ts @@ -0,0 +1,122 @@ +import { afterAll, beforeEach, describe, it } from 'https://deno.land/std@0.203.0/testing/bdd.ts'; +import { assertEquals } from 'https://deno.land/std@0.203.0/assert/assert_equals.ts'; + +import { AppAccessors } from '../mod.ts'; +import { AppObjectRegistry } from '../../../AppObjectRegistry.ts'; + +describe('AppAccessors', () => { + let appAccessors: AppAccessors; + const senderFn = (r: object) => + Promise.resolve({ + id: Math.random().toString(36).substring(2), + jsonrpc: '2.0', + result: r, + serialize() { + return JSON.stringify(this); + }, + }); + + beforeEach(() => { + appAccessors = new AppAccessors(senderFn); + AppObjectRegistry.clear(); + }); + + afterAll(() => { + AppObjectRegistry.clear(); + }); + + it('creates the correct format for IRead calls', async () => { + const roomRead = appAccessors.getReader().getRoomReader(); + const room = await roomRead.getById('123'); + + assertEquals(room, { + params: ['123'], + method: 'accessor:getReader:getRoomReader:getById', + }); + }); + + it('creates the correct format for IEnvironmentRead calls from IRead', async () => { + const reader = appAccessors.getReader().getEnvironmentReader().getEnvironmentVariables(); + const room = await reader.getValueByName('NODE_ENV'); + + assertEquals(room, { + params: ['NODE_ENV'], + method: 'accessor:getReader:getEnvironmentReader:getEnvironmentVariables:getValueByName', + }); + }); + + it('creates the correct format for IEvironmentRead calls', async () => { + const envRead = appAccessors.getEnvironmentRead(); + const env = await envRead.getServerSettings().getValueById('123'); + + assertEquals(env, { + params: ['123'], + method: 'accessor:getEnvironmentRead:getServerSettings:getValueById', + }); + }); + + it('creates the correct format for IEvironmentWrite calls', async () => { + const envRead = appAccessors.getEnvironmentWrite(); + const env = await envRead.getServerSettings().incrementValue('123', 6); + + assertEquals(env, { + params: ['123', 6], + method: 'accessor:getEnvironmentWrite:getServerSettings:incrementValue', + }); + }); + + it('creates the correct format for IConfigurationModify calls', async () => { + const configModify = appAccessors.getConfigurationModify(); + const command = await configModify.slashCommands.modifySlashCommand({ + command: 'test', + i18nDescription: 'test', + i18nParamsExample: 'test', + providesPreview: true, + }); + + assertEquals(command, { + params: [ + { + command: 'test', + i18nDescription: 'test', + i18nParamsExample: 'test', + providesPreview: true, + }, + ], + method: 'accessor:getConfigurationModify:slashCommands:modifySlashCommand', + }); + }); + + it('correctly stores a reference to a slashcommand object and sends a request via proxy', async () => { + const configExtend = appAccessors.getConfigurationExtend(); + + const slashcommand = { + command: 'test', + i18nDescription: 'test', + i18nParamsExample: 'test', + providesPreview: true, + executor() { + return Promise.resolve(); + }, + }; + + const result = await configExtend.slashCommands.provideSlashCommand(slashcommand); + + assertEquals(AppObjectRegistry.get('slashcommand:test'), slashcommand); + + // The function will not be serialized and sent to the main process + delete result.params[0].executor; + + assertEquals(result, { + method: 'accessor:getConfigurationExtend:slashCommands:provideSlashCommand', + params: [ + { + command: 'test', + i18nDescription: 'test', + i18nParamsExample: 'test', + providesPreview: true, + }, + ], + }); + }); +}); diff --git a/packages/apps-engine/deno-runtime/lib/accessors/tests/ModifyCreator.test.ts b/packages/apps-engine/deno-runtime/lib/accessors/tests/ModifyCreator.test.ts new file mode 100644 index 000000000000..5927869e6c84 --- /dev/null +++ b/packages/apps-engine/deno-runtime/lib/accessors/tests/ModifyCreator.test.ts @@ -0,0 +1,106 @@ +// deno-lint-ignore-file no-explicit-any +import { afterAll, beforeEach, describe, it } from 'https://deno.land/std@0.203.0/testing/bdd.ts'; +import { assertSpyCall, spy } from 'https://deno.land/std@0.203.0/testing/mock.ts'; +import { assert, assertEquals, assertNotInstanceOf } from 'https://deno.land/std@0.203.0/assert/mod.ts'; + +import { AppObjectRegistry } from '../../../AppObjectRegistry.ts'; +import { ModifyCreator } from '../modify/ModifyCreator.ts'; + +describe('ModifyCreator', () => { + const senderFn = (r: any) => + Promise.resolve({ + id: Math.random().toString(36).substring(2), + jsonrpc: '2.0', + result: r, + serialize() { + return JSON.stringify(this); + }, + }); + + beforeEach(() => { + AppObjectRegistry.clear(); + AppObjectRegistry.set('id', 'deno-test'); + }); + + afterAll(() => { + AppObjectRegistry.clear(); + }); + + it('sends the correct payload in the request to create a message', async () => { + const spying = spy(senderFn); + const modifyCreator = new ModifyCreator(spying); + const messageBuilder = modifyCreator.startMessage(); + + // Importing types from the Apps-Engine is problematic, so we'll go with `any` here + messageBuilder + .setRoom({ id: '123' } as any) + .setSender({ id: '456' } as any) + .setText('Hello World') + .setUsernameAlias('alias') + .setAvatarUrl('https://avatars.com/123'); + + // We can't get a legitimate return value here, so we ignore it + // but we need to know that the request sent was well formed + await modifyCreator.finish(messageBuilder); + + assertSpyCall(spying, 0, { + args: [ + { + method: 'bridges:getMessageBridge:doCreate', + params: [ + { + room: { id: '123' }, + sender: { id: '456' }, + text: 'Hello World', + alias: 'alias', + avatarUrl: 'https://avatars.com/123', + }, + 'deno-test', + ], + }, + ], + }); + }); + + it('sends the correct payload in the request to upload a buffer', async () => { + const modifyCreator = new ModifyCreator(senderFn); + + const result = await modifyCreator.getUploadCreator().uploadBuffer(new Uint8Array([1, 2, 3, 4]), 'text/plain'); + + assertEquals(result, { + method: 'accessor:getModifier:getCreator:getUploadCreator:uploadBuffer', + params: [new Uint8Array([1, 2, 3, 4]), 'text/plain'], + }); + }); + + it('sends the correct payload in the request to create a visitor', async () => { + const modifyCreator = new ModifyCreator(senderFn); + + const result = (await modifyCreator.getLivechatCreator().createVisitor({ + token: 'random token', + username: 'random username for visitor', + name: 'Random Visitor', + })) as any; // We modified the send function so it changed the original return type of the function + + assertEquals(result, { + method: 'accessor:getModifier:getCreator:getLivechatCreator:createVisitor', + params: [ + { + token: 'random token', + username: 'random username for visitor', + name: 'Random Visitor', + }, + ], + }); + }); + + // This test is important because if we return a promise we break API compatibility + it('does not return a promise for calls of the createToken() method of the LivechatCreator', () => { + const modifyCreator = new ModifyCreator(senderFn); + + const result = modifyCreator.getLivechatCreator().createToken(); + + assertNotInstanceOf(result, Promise); + assert(typeof result === 'string', `Expected "${result}" to be of type "string", but got "${typeof result}"`); + }); +}); diff --git a/packages/apps-engine/deno-runtime/lib/accessors/tests/ModifyExtender.test.ts b/packages/apps-engine/deno-runtime/lib/accessors/tests/ModifyExtender.test.ts new file mode 100644 index 000000000000..1ec056e02ce3 --- /dev/null +++ b/packages/apps-engine/deno-runtime/lib/accessors/tests/ModifyExtender.test.ts @@ -0,0 +1,120 @@ +// deno-lint-ignore-file no-explicit-any +import { afterAll, beforeEach, describe, it } from 'https://deno.land/std@0.203.0/testing/bdd.ts'; +import { assertSpyCall, spy } from 'https://deno.land/std@0.203.0/testing/mock.ts'; + +import { AppObjectRegistry } from '../../../AppObjectRegistry.ts'; +import { ModifyExtender } from '../modify/ModifyExtender.ts'; + +describe('ModifyExtender', () => { + let extender: ModifyExtender; + + const senderFn = (r: any) => + Promise.resolve({ + id: Math.random().toString(36).substring(2), + jsonrpc: '2.0', + result: structuredClone(r), + serialize() { + return JSON.stringify(this); + }, + }); + + beforeEach(() => { + AppObjectRegistry.clear(); + AppObjectRegistry.set('id', 'deno-test'); + extender = new ModifyExtender(senderFn); + }); + + afterAll(() => { + AppObjectRegistry.clear(); + }); + + it('correctly formats requests for the extend message requests', async () => { + const _spy = spy(extender, 'senderFn' as keyof ModifyExtender); + + const messageExtender = await extender.extendMessage('message-id', { _id: 'user-id' } as any); + + assertSpyCall(_spy, 0, { + args: [ + { + method: 'bridges:getMessageBridge:doGetById', + params: ['message-id', 'deno-test'], + }, + ], + }); + + messageExtender.addCustomField('key', 'value'); + + await extender.finish(messageExtender); + + assertSpyCall(_spy, 1, { + args: [ + { + method: 'bridges:getMessageBridge:doUpdate', + params: [messageExtender.getMessage(), 'deno-test'], + }, + ], + }); + + _spy.restore(); + }); + + it('correctly formats requests for the extend room requests', async () => { + const _spy = spy(extender, 'senderFn' as keyof ModifyExtender); + + const roomExtender = await extender.extendRoom('room-id', { _id: 'user-id' } as any); + + assertSpyCall(_spy, 0, { + args: [ + { + method: 'bridges:getRoomBridge:doGetById', + params: ['room-id', 'deno-test'], + }, + ], + }); + + roomExtender.addCustomField('key', 'value'); + + await extender.finish(roomExtender); + + assertSpyCall(_spy, 1, { + args: [ + { + method: 'bridges:getRoomBridge:doUpdate', + params: [roomExtender.getRoom(), [], 'deno-test'], + }, + ], + }); + + _spy.restore(); + }); + + it('correctly formats requests for the extend video conference requests', async () => { + const _spy = spy(extender, 'senderFn' as keyof ModifyExtender); + + const videoConferenceExtender = await extender.extendVideoConference('video-conference-id'); + + assertSpyCall(_spy, 0, { + args: [ + { + method: 'bridges:getVideoConferenceBridge:doGetById', + params: ['video-conference-id', 'deno-test'], + }, + ], + }); + + videoConferenceExtender.setStatus(4); + + await extender.finish(videoConferenceExtender); + + assertSpyCall(_spy, 1, { + args: [ + { + method: 'bridges:getVideoConferenceBridge:doUpdate', + params: [videoConferenceExtender.getVideoConference(), 'deno-test'], + }, + ], + }); + + _spy.restore(); + }); +}); diff --git a/packages/apps-engine/deno-runtime/lib/accessors/tests/ModifyUpdater.test.ts b/packages/apps-engine/deno-runtime/lib/accessors/tests/ModifyUpdater.test.ts new file mode 100644 index 000000000000..313275c967cf --- /dev/null +++ b/packages/apps-engine/deno-runtime/lib/accessors/tests/ModifyUpdater.test.ts @@ -0,0 +1,128 @@ +// deno-lint-ignore-file no-explicit-any +import { afterAll, beforeEach, describe, it } from 'https://deno.land/std@0.203.0/testing/bdd.ts'; +import { assertSpyCall, spy } from 'https://deno.land/std@0.203.0/testing/mock.ts'; +import { assertEquals } from 'https://deno.land/std@0.203.0/assert/mod.ts'; + +import { AppObjectRegistry } from '../../../AppObjectRegistry.ts'; +import { ModifyUpdater } from '../modify/ModifyUpdater.ts'; + +describe('ModifyUpdater', () => { + let modifyUpdater: ModifyUpdater; + + const senderFn = (r: any) => + Promise.resolve({ + id: Math.random().toString(36).substring(2), + jsonrpc: '2.0', + result: structuredClone(r), + serialize() { + return JSON.stringify(this); + }, + }); + + beforeEach(() => { + AppObjectRegistry.clear(); + AppObjectRegistry.set('id', 'deno-test'); + modifyUpdater = new ModifyUpdater(senderFn); + }); + + afterAll(() => { + AppObjectRegistry.clear(); + }); + + it('correctly formats requests for the update message flow', async () => { + const _spy = spy(modifyUpdater, 'senderFn' as keyof ModifyUpdater); + + const messageBuilder = await modifyUpdater.message('123', { id: '456' } as any); + + assertSpyCall(_spy, 0, { + args: [ + { + method: 'bridges:getMessageBridge:doGetById', + params: ['123', 'deno-test'], + }, + ], + }); + + messageBuilder.setUpdateData( + { + id: '123', + room: { id: '123' }, + sender: { id: '456' }, + text: 'Hello World', + }, + { + id: '456', + }, + ); + + await modifyUpdater.finish(messageBuilder); + + assertSpyCall(_spy, 1, { + args: [ + { + method: 'bridges:getMessageBridge:doUpdate', + params: [messageBuilder.getMessage(), 'deno-test'], + }, + ], + }); + + _spy.restore(); + }); + + it('correctly formats requests for the update room flow', async () => { + const _spy = spy(modifyUpdater, 'senderFn' as keyof ModifyUpdater); + + const roomBuilder = await modifyUpdater.room('123', { id: '456' } as any); + + assertSpyCall(_spy, 0, { + args: [ + { + method: 'bridges:getRoomBridge:doGetById', + params: ['123', 'deno-test'], + }, + ], + }); + + roomBuilder.setData({ + id: '123', + type: 'c', + displayName: 'Test Room', + slugifiedName: 'test-room', + creator: { id: '456' }, + }); + + roomBuilder.setMembersToBeAddedByUsernames(['username1', 'username2']); + + // We need to sneak in the id as the `modifyUpdater.room` call won't have legitimate data + roomBuilder.getRoom().id = '123'; + + await modifyUpdater.finish(roomBuilder); + + assertSpyCall(_spy, 1, { + args: [ + { + method: 'bridges:getRoomBridge:doUpdate', + params: [roomBuilder.getRoom(), roomBuilder.getMembersToBeAddedUsernames(), 'deno-test'], + }, + ], + }); + }); + + it('correctly formats requests to UserUpdater methods', async () => { + const result = await modifyUpdater.getUserUpdater().updateStatusText({ id: '123' } as any, 'Hello World') as any; + + assertEquals(result, { + method: 'accessor:getModifier:getUpdater:getUserUpdater:updateStatusText', + params: [{ id: '123' }, 'Hello World'], + }); + }); + + it('correctly formats requests to LivechatUpdater methods', async () => { + const result = await modifyUpdater.getLivechatUpdater().closeRoom({ id: '123' } as any, 'close it!') as any; + + assertEquals(result, { + method: 'accessor:getModifier:getUpdater:getLivechatUpdater:closeRoom', + params: [{ id: '123' }, 'close it!'], + }); + }); +}); diff --git a/packages/apps-engine/deno-runtime/lib/ast/mod.ts b/packages/apps-engine/deno-runtime/lib/ast/mod.ts new file mode 100644 index 000000000000..09f4994f2bad --- /dev/null +++ b/packages/apps-engine/deno-runtime/lib/ast/mod.ts @@ -0,0 +1,64 @@ +import { generate } from "astring"; +// @deno-types="../../acorn.d.ts" +import { Program, parse } from "acorn"; +// @deno-types="../../acorn-walk.d.ts" +import { fullAncestor } from "acorn-walk"; + +import * as operations from "./operations.ts"; +import type { WalkerState } from "./operations.ts"; + +function fixAst(ast: Program): boolean { + const pendingOperations = [ + operations.fixLivechatIsOnlineCalls, + operations.checkReassignmentOfModifiedIdentifiers, + operations.fixRoomUsernamesCalls, + ]; + + // Have we touched the tree? + let isModified = false; + + while (pendingOperations.length) { + const ops = pendingOperations.splice(0); + const state: WalkerState = { + isModified: false, + functionIdentifiers: new Set(), + }; + + fullAncestor(ast, (node, state, ancestors, type) => { + ops.forEach(operation => operation(node, state, ancestors, type)); + }, undefined, state); + + if (state.isModified) { + isModified = true; + } + + if (state.functionIdentifiers.size) { + pendingOperations.push( + operations.buildFixModifiedFunctionsOperation(state.functionIdentifiers), + operations.checkReassignmentOfModifiedIdentifiers + ); + } + } + + return isModified; +} + +export function fixBrokenSynchronousAPICalls(appSource: string): string { + const astRootNode = parse(appSource, { + ecmaVersion: 2017, + // Allow everything, we don't want to complain if code is badly written + // Also, since the code itself has been transpiled, the chance of getting + // shenanigans is lower + allowReserved: true, + allowReturnOutsideFunction: true, + allowImportExportEverywhere: true, + allowAwaitOutsideFunction: true, + allowSuperOutsideMethod: true, + }); + + if (fixAst(astRootNode)) { + return generate(astRootNode); + } + + return appSource; +} diff --git a/packages/apps-engine/deno-runtime/lib/ast/operations.ts b/packages/apps-engine/deno-runtime/lib/ast/operations.ts new file mode 100644 index 000000000000..d3886348041f --- /dev/null +++ b/packages/apps-engine/deno-runtime/lib/ast/operations.ts @@ -0,0 +1,239 @@ +// @deno-types="../../acorn.d.ts" +import { AnyNode, AssignmentExpression, AwaitExpression, Expression, Function, Identifier, MethodDefinition, Property } from 'acorn'; +// @deno-types="../../acorn-walk.d.ts" +import { FullAncestorWalkerCallback } from 'acorn-walk'; + +export type WalkerState = { + isModified: boolean; + functionIdentifiers: Set; +}; + +export function getFunctionIdentifier(ancestors: AnyNode[], functionNodeIndex: number) { + const parent = ancestors[functionNodeIndex - 1]; + + // If there is a parent node and it's not a computed property, we can try to + // extract an identifier for our function from it. This needs to be done first + // because when functions are assigned to named symbols, this will be the only + // way to call it, even if the function itself has an identifier + // Consider the following block: + // + // const foo = function bar() {} + // + // Even though the function itself has a name, the only way to call it in the + // program is wiht `foo()` + if (parent && !(parent as Property | MethodDefinition).computed) { + // Several node types can have an id prop of type Identifier + const { id } = parent as unknown as { id?: Identifier }; + if (id?.type === 'Identifier') { + return id.name; + } + + // Usually assignments to object properties (MethodDefinition, Property) + const { key } = parent as MethodDefinition | Property; + if (key?.type === 'Identifier') { + return key.name; + } + + // Variable assignments have left hand side that can be used as Identifier + const { left } = parent as AssignmentExpression; + + // Simple assignment: `const fn = () => {}` + if (left?.type === 'Identifier') { + return left.name; + } + + // Object property assignment: `obj.fn = () => {}` + if (left?.type === 'MemberExpression' && !left.computed) { + return (left.property as Identifier).name; + } + } + + // nodeIndex needs to be the index of a Function node (either FunctionDeclaration or FunctionExpression) + const currentNode = ancestors[functionNodeIndex] as Function; + + // Function declarations or expressions can be directly named + if (currentNode.id?.type === 'Identifier') { + return currentNode.id.name; + } +} + +export function wrapWithAwait(node: Expression) { + if (!node.type.endsWith('Expression')) { + throw new Error(`Can't wrap "${node.type}" with await`); + } + + const innerNode: Expression = { ...node }; + + node.type = 'AwaitExpression'; + // starting here node has become an AwaitExpression + (node as AwaitExpression).argument = innerNode; + + Object.keys(node).forEach((key) => !['type', 'argument'].includes(key) && delete node[key as keyof AnyNode]); +} + +export function asyncifyScope(ancestors: AnyNode[], state: WalkerState) { + const functionNodeIndex = ancestors.findLastIndex((n) => 'async' in n); + if (functionNodeIndex === -1) return; + + // At this point this is a node with an "async" property, so it has to be + // of type Function - let TS know about that + const functionScopeNode = ancestors[functionNodeIndex] as Function; + + if (functionScopeNode.async) { + return; + } + + functionScopeNode.async = true; + + // If the parent of a function node is a call expression, we're talking about an IIFE + // Should we care about this case as well? + // const parentNode = ancestors[functionScopeIndex-1]; + // if (parentNode?.type === 'CallExpression' && ancestors[functionScopeIndex-2] && ancestors[functionScopeIndex-2].type !== 'AwaitExpression') { + // pendingOperations.push(buildFunctionPredicate(getFunctionIdentifier(ancestors, functionScopeIndex-2))); + // } + + const identifier = getFunctionIdentifier(ancestors, functionNodeIndex); + + // We can't fix calls of functions which name we can't determine at compile time + if (!identifier) return; + + state.functionIdentifiers.add(identifier); +} + +export function buildFixModifiedFunctionsOperation(functionIdentifiers: Set): FullAncestorWalkerCallback { + return function _fixModifiedFunctionsOperation(node, state, ancestors) { + if (node.type !== 'CallExpression') return; + + let isWrappable = false; + + // This node is a simple call to a function, like `fn()` + isWrappable = node.callee.type === 'Identifier' && functionIdentifiers.has(node.callee.name); + + // This node is a call to an object property or instance method, like `obj.fn()`, but not computed like `obj[fn]()` + isWrappable ||= + node.callee.type === 'MemberExpression' && + !node.callee.computed && + node.callee.property?.type === 'Identifier' && + functionIdentifiers.has(node.callee.property.name); + + // This is a weird dereferencing technique used by bundlers, and since we'll be dealing with bundled sources we have to check for it + // e.g. `r=(0,fn)(e)` + if (!isWrappable && node.callee.type === 'SequenceExpression') { + const [, secondExpression] = node.callee.expressions; + isWrappable = secondExpression?.type === 'Identifier' && functionIdentifiers.has(secondExpression.name); + isWrappable ||= + secondExpression?.type === 'MemberExpression' && + !secondExpression.computed && + secondExpression.property.type === 'Identifier' && + functionIdentifiers.has(secondExpression.property.name); + } + + if (!isWrappable) return; + + // ancestors[ancestors.length-1] === node, so here we're checking for parent node + const parentNode = ancestors[ancestors.length - 2]; + if (!parentNode || parentNode.type === 'AwaitExpression') return; + + wrapWithAwait(node); + asyncifyScope(ancestors, state); + + state.isModified = true; + }; +} + +export const checkReassignmentOfModifiedIdentifiers: FullAncestorWalkerCallback = (node, { functionIdentifiers }, _ancestors) => { + if (node.type === 'AssignmentExpression') { + if (node.operator !== '=') return; + + let identifier = ''; + + if (node.left.type === 'Identifier') identifier = node.left.name; + + if (node.left.type === 'MemberExpression' && !node.left.computed) { + identifier = (node.left.property as Identifier).name; + } + + if (!identifier || node.right.type !== 'Identifier' || !functionIdentifiers.has(node.right.name)) return; + + functionIdentifiers.add(identifier); + + return; + } + + if (node.type === 'VariableDeclarator') { + if (node.id.type !== 'Identifier' || functionIdentifiers.has(node.id.name)) return; + + if (node.init?.type !== 'Identifier' || !functionIdentifiers.has(node.init?.name)) return; + + functionIdentifiers.add(node.id.name); + + return; + } + + // "Property" is for plain objects, "PropertyDefinition" is for classes + // but both share the same structure + if (node.type === 'Property' || node.type === 'PropertyDefinition') { + if (node.key.type !== 'Identifier' || functionIdentifiers.has(node.key.name)) return; + + if (node.value?.type !== 'Identifier' || !functionIdentifiers.has(node.value.name)) return; + + functionIdentifiers.add(node.key.name); + + return; + } +}; + +export const fixLivechatIsOnlineCalls: FullAncestorWalkerCallback = (node, state, ancestors) => { + if (node.type !== 'MemberExpression' || node.computed) return; + + if ((node.property as Identifier).name !== 'isOnline') return; + + if (node.object.type !== 'CallExpression') return; + + if (node.object.callee.type !== 'MemberExpression') return; + + if ((node.object.callee.property as Identifier).name !== 'getLivechatReader') return; + + let parentIndex = ancestors.length - 2; + let targetNode = ancestors[parentIndex]; + + if (targetNode.type !== 'CallExpression') { + targetNode = node; + } else { + parentIndex--; + } + + // If we're already wrapped with an await, nothing to do + if (ancestors[parentIndex].type === 'AwaitExpression') return; + + // If we're in the middle of a chained member access, we can't wrap with await + if (ancestors[parentIndex].type === 'MemberExpression') return; + + wrapWithAwait(targetNode); + asyncifyScope(ancestors, state); + + state.isModified = true; +}; + +export const fixRoomUsernamesCalls: FullAncestorWalkerCallback = (node, state, ancestors) => { + if (node.type !== 'MemberExpression' || node.computed) return; + + if ((node.property as Identifier).name !== 'usernames') return; + + let parentIndex = ancestors.length - 2; + let targetNode = ancestors[parentIndex]; + + if (targetNode.type !== 'CallExpression') { + targetNode = node; + } else { + parentIndex--; + } + + // If we're already wrapped with an await, nothing to do + if (ancestors[parentIndex].type === 'AwaitExpression') return; + + wrapWithAwait(targetNode); + asyncifyScope(ancestors, state); + + state.isModified = true; +} diff --git a/packages/apps-engine/deno-runtime/lib/ast/tests/data/ast_blocks.ts b/packages/apps-engine/deno-runtime/lib/ast/tests/data/ast_blocks.ts new file mode 100644 index 000000000000..330d2bf52620 --- /dev/null +++ b/packages/apps-engine/deno-runtime/lib/ast/tests/data/ast_blocks.ts @@ -0,0 +1,436 @@ +// @deno-types="../../../../acorn.d.ts" +import { AnyNode, ClassDeclaration, ExpressionStatement, FunctionDeclaration, VariableDeclaration } from 'acorn'; + +/** + * Partial AST blocks to support testing. + * `start` and `end` properties are omitted for brevity. + */ + +type TestNodeExcerpt = { + code: string; + node: N; +}; + +export const FunctionDeclarationFoo: TestNodeExcerpt = { + code: 'function foo() {}', + node: { + type: 'FunctionDeclaration', + id: { + type: 'Identifier', + name: 'foo', + }, + expression: false, + generator: false, + async: false, + params: [], + body: { + type: 'BlockStatement', + body: [], + }, + }, +}; + +export const ConstFooAssignedFunctionExpression: TestNodeExcerpt = { + code: 'const foo = function() {}', + node: { + type: 'VariableDeclaration', + kind: 'const', + declarations: [ + { + type: 'VariableDeclarator', + id: { + type: 'Identifier', + name: 'foo', + }, + init: { + type: 'FunctionExpression', + id: null, + expression: false, + generator: false, + async: false, + params: [], + body: { + type: 'BlockStatement', + body: [], + }, + }, + }, + ], + }, +}; + +export const AssignmentExpressionOfArrowFunctionToFooIdentifier: TestNodeExcerpt = { + code: 'foo = () => {}', + node: { + type: 'ExpressionStatement', + expression: { + type: 'AssignmentExpression', + operator: '=', + left: { + type: 'Identifier', + name: 'foo', + }, + right: { + type: 'ArrowFunctionExpression', + id: null, + expression: false, + generator: false, + async: false, + params: [], + body: { + type: 'BlockStatement', + body: [], + }, + }, + }, + }, +}; + +export const AssignmentExpressionOfNamedFunctionToFooMemberExpression: TestNodeExcerpt = { + code: 'obj.foo = function bar() {}', + node: { + type: 'ExpressionStatement', + expression: { + type: 'AssignmentExpression', + operator: '=', + left: { + type: 'MemberExpression', + object: { + type: 'Identifier', + name: 'a', + }, + property: { + type: 'Identifier', + name: 'foo', + }, + computed: false, + optional: false, + }, + right: { + type: 'FunctionExpression', + id: null, + expression: false, + generator: false, + async: false, + params: [], + body: { + type: 'BlockStatement', + body: [], + }, + }, + }, + }, +}; + +export const MethodDefinitionOfFooInClassBar: TestNodeExcerpt = { + code: 'class Bar { foo() {} }', + node: { + type: 'ClassDeclaration', + id: { + type: 'Identifier', + name: 'Bar', + }, + superClass: null, + body: { + type: 'ClassBody', + body: [ + { + type: 'MethodDefinition', + key: { + type: 'Identifier', + name: 'foo', + }, + value: { + type: 'FunctionExpression', + id: null, + expression: false, + generator: false, + async: false, + params: [], + body: { + type: 'BlockStatement', + body: [], + }, + }, + kind: 'method', + computed: false, + static: false, + }, + ], + }, + }, +}; + +export const SimpleCallExpressionOfFoo: TestNodeExcerpt = { + code: 'foo()', + node: { + type: 'ExpressionStatement', + expression: { + type: 'CallExpression', + callee: { + type: 'Identifier', + name: 'foo', + }, + arguments: [], + optional: false, + }, + }, +}; + +export const SyncFunctionDeclarationWithAsyncCallExpression: TestNodeExcerpt = { + // NOTE: this is invalid syntax, it won't be parsed by acorn + // but it can be an intermediary state of the AST after we run + // `wrapWithAwait` on "bar" call expressions, for instance + code: 'function foo() { return () => await bar() }', + node: { + type: 'FunctionDeclaration', + id: { + type: 'Identifier', + name: 'foo', + }, + expression: false, + generator: false, + async: false, + params: [], + body: { + type: 'BlockStatement', + body: [ + { + type: 'ReturnStatement', + argument: { + type: 'ArrowFunctionExpression', + id: null, + expression: true, + generator: false, + async: false, + params: [], + body: { + type: 'AwaitExpression', + argument: { + type: 'CallExpression', + callee: { + type: 'Identifier', + name: 'bar', + }, + arguments: [], + optional: false, + }, + }, + }, + }, + ], + }, + }, +}; + +export const AssignmentOfFooToBar: TestNodeExcerpt = { + code: 'bar = foo', + node: { + type: 'ExpressionStatement', + expression: { + type: 'AssignmentExpression', + operator: '=', + left: { + type: 'Identifier', + name: 'bar', + }, + right: { + type: 'Identifier', + name: 'foo', + }, + }, + }, +}; + +export const AssignmentOfFooToBarMemberExpression: TestNodeExcerpt = { + code: 'obj.bar = foo', + node: { + type: 'ExpressionStatement', + expression: { + type: 'AssignmentExpression', + operator: '=', + left: { + type: 'MemberExpression', + computed: false, + optional: false, + object: { + type: 'Identifier', + name: 'obj', + }, + property: { + type: 'Identifier', + name: 'bar', + }, + }, + right: { + type: 'Identifier', + name: 'foo', + }, + }, + }, +}; + +export const AssignmentOfFooToBarVariableDeclarator: TestNodeExcerpt = { + code: 'const bar = foo', + node: { + type: 'VariableDeclaration', + kind: 'const', + declarations: [ + { + type: 'VariableDeclarator', + id: { + type: 'Identifier', + name: 'bar', + }, + init: { + type: 'Identifier', + name: 'foo', + }, + }, + ], + }, +}; + +export const AssignmentOfFooToBarPropertyDefinition: TestNodeExcerpt = { + code: 'class baz { bar = foo }', + node: { + type: 'ClassDeclaration', + id: { + type: 'Identifier', + name: 'baz', + }, + superClass: null, + body: { + type: 'ClassBody', + body: [ + { + type: 'PropertyDefinition', + static: false, + computed: false, + key: { + type: 'Identifier', + name: 'bar', + }, + value: { + type: 'Identifier', + name: 'foo', + }, + }, + ], + }, + }, +}; + +const fixSimpleCallExpressionCode = ` +function bar() { + const a = foo(); + + return a; +}`; + +export const FixSimpleCallExpression: TestNodeExcerpt = { + code: fixSimpleCallExpressionCode, + node: { + type: 'FunctionDeclaration', + id: { + type: 'Identifier', + name: 'bar', + }, + expression: false, + generator: false, + async: false, + params: [], + body: { + type: 'BlockStatement', + body: [ + { + type: 'VariableDeclaration', + kind: 'const', + declarations: [ + { + type: 'VariableDeclarator', + id: { + type: 'Identifier', + name: 'a', + }, + init: { + type: 'CallExpression', + callee: { + type: 'Identifier', + name: 'foo', + }, + arguments: [], + optional: false, + }, + }, + ], + }, + { + type: 'ReturnStatement', + argument: { + type: 'Identifier', + name: 'a', + }, + }, + ], + }, + }, +}; + +export const ArrowFunctionDerefCallExpression: TestNodeExcerpt = { + // NOTE: this call strategy is widely used by bundlers; it's used to sever the `this` + // reference in the method from the object that contains it. This is mostly because + // the bundler wants to ensure that it does not messes up the bindings in the code it + // generates. + // + // This would be similar to doing `foo.call(undefined)` + code: 'const bar = () => (0, e.foo)();', + node: { + type: 'VariableDeclaration', + kind: 'const', + declarations: [ + { + type: 'VariableDeclarator', + id: { + type: 'Identifier', + name: 'bar', + }, + init: { + type: 'ArrowFunctionExpression', + id: null, + expression: true, + generator: false, + async: false, + params: [], + body: { + type: 'CallExpression', + optional: false, + arguments: [], + callee: { + type: 'SequenceExpression', + expressions: [ + { + type: 'Literal', + value: 0, + }, + { + type: 'MemberExpression', + object: { + type: 'Identifier', + name: 'e', + }, + property: { + type: 'Identifier', + name: 'foo', + }, + computed: false, + optional: false, + }, + ], + }, + }, + }, + }, + ], + }, +}; diff --git a/packages/apps-engine/deno-runtime/lib/ast/tests/operations.test.ts b/packages/apps-engine/deno-runtime/lib/ast/tests/operations.test.ts new file mode 100644 index 000000000000..2b00c271f730 --- /dev/null +++ b/packages/apps-engine/deno-runtime/lib/ast/tests/operations.test.ts @@ -0,0 +1,245 @@ +import { assertEquals, assertThrows } from 'https://deno.land/std@0.203.0/assert/mod.ts'; +import { beforeEach, describe, it } from 'https://deno.land/std@0.203.0/testing/bdd.ts'; + +import { WalkerState, asyncifyScope, buildFixModifiedFunctionsOperation, checkReassignmentOfModifiedIdentifiers, getFunctionIdentifier, wrapWithAwait } from '../operations.ts'; +import { + ArrowFunctionDerefCallExpression, + AssignmentExpressionOfArrowFunctionToFooIdentifier, + AssignmentExpressionOfNamedFunctionToFooMemberExpression, + AssignmentOfFooToBar, + AssignmentOfFooToBarMemberExpression, + AssignmentOfFooToBarPropertyDefinition, + AssignmentOfFooToBarVariableDeclarator, + ConstFooAssignedFunctionExpression, + FixSimpleCallExpression, + FunctionDeclarationFoo, + MethodDefinitionOfFooInClassBar, + SimpleCallExpressionOfFoo, + SyncFunctionDeclarationWithAsyncCallExpression, +} from './data/ast_blocks.ts'; +import { AnyNode, ArrowFunctionExpression, AssignmentExpression, AwaitExpression, Expression, MethodDefinition, ReturnStatement, VariableDeclaration } from '../../../acorn.d.ts'; +import { assertNotEquals } from 'https://deno.land/std@0.203.0/assert/assert_not_equals.ts'; + +describe('getFunctionIdentifier', () => { + it(`identifies the name "foo" for the code \`${FunctionDeclarationFoo.code}\``, () => { + // ancestors array is built by the walking lib + const nodeAncestors = [FunctionDeclarationFoo.node]; + const functionNodeIndex = 0; + assertEquals('foo', getFunctionIdentifier(nodeAncestors, functionNodeIndex)); + }); + + it(`identifies the name "foo" for the code \`${ConstFooAssignedFunctionExpression.code}\``, () => { + // ancestors array is built by the walking lib + const nodeAncestors = [ + ConstFooAssignedFunctionExpression.node, // VariableDeclaration + ConstFooAssignedFunctionExpression.node.declarations[0], // VariableDeclarator + ConstFooAssignedFunctionExpression.node.declarations[0].init! // FunctionExpression + ]; + const functionNodeIndex = 2; + assertEquals('foo', getFunctionIdentifier(nodeAncestors, functionNodeIndex)); + }); + + it(`identifies the name "foo" for the code \`${AssignmentExpressionOfArrowFunctionToFooIdentifier.code}\``, () => { + // ancestors array is built by the walking lib + const nodeAncestors = [ + AssignmentExpressionOfArrowFunctionToFooIdentifier.node, // ExpressionStatement + AssignmentExpressionOfArrowFunctionToFooIdentifier.node.expression, // AssignmentExpression + (AssignmentExpressionOfArrowFunctionToFooIdentifier.node.expression as AssignmentExpression).right, // ArrowFunctionExpression + ]; + const functionNodeIndex = 2; + assertEquals('foo', getFunctionIdentifier(nodeAncestors, functionNodeIndex)); + }); + + it(`identifies the name "foo" for the code \`${AssignmentExpressionOfNamedFunctionToFooMemberExpression.code}\``, () => { + // ancestors array is built by the walking lib + const nodeAncestors = [ + AssignmentExpressionOfNamedFunctionToFooMemberExpression.node, // ExpressionStatement + AssignmentExpressionOfNamedFunctionToFooMemberExpression.node.expression, // AssignmentExpression + (AssignmentExpressionOfNamedFunctionToFooMemberExpression.node.expression as AssignmentExpression).right, // FunctionExpression + ]; + const functionNodeIndex = 2; + assertEquals('foo', getFunctionIdentifier(nodeAncestors, functionNodeIndex)); + }); + + it(`identifies the name "foo" for the code \`${MethodDefinitionOfFooInClassBar.code}\``, () => { + // ancestors array is built by the walking lib + const nodeAncestors = [ + MethodDefinitionOfFooInClassBar.node, // ClassDeclaration + MethodDefinitionOfFooInClassBar.node.body, // ClassBody + MethodDefinitionOfFooInClassBar.node.body!.body[0], // MethodDefinition + (MethodDefinitionOfFooInClassBar.node.body!.body[0] as MethodDefinition).value, // FunctionExpression + ]; + const functionNodeIndex = 3; + assertEquals('foo', getFunctionIdentifier(nodeAncestors, functionNodeIndex)); + }); +}); + +describe('wrapWithAwait', () => { + it('wraps a call expression with await', () => { + const node = structuredClone(SimpleCallExpressionOfFoo.node.expression); + wrapWithAwait(node); + + assertEquals('AwaitExpression', node.type); + assertNotEquals(SimpleCallExpressionOfFoo.node.expression.type, node.type); + assertEquals(SimpleCallExpressionOfFoo.node.expression, (node as AwaitExpression).argument); + }); + + it('throws if node is not an expression', () => { + const node = structuredClone(SimpleCallExpressionOfFoo.node); + assertThrows(() => wrapWithAwait(node as unknown as Expression)); + }) +}); + +describe('asyncifyScope', () => { + it('makes only the first function scope async', () => { + const node = structuredClone(SyncFunctionDeclarationWithAsyncCallExpression.node); + const ancestors: AnyNode[] = [ + node, // FunctionDeclaration + node.body, // BlockStatement + node.body!.body[0], // ReturnStatement + (node.body!.body[0] as ReturnStatement).argument!, // ArrowFunctionExpression + ((node.body!.body[0] as ReturnStatement).argument! as ArrowFunctionExpression).body, // AwaitExpression + (((node.body!.body[0] as ReturnStatement).argument! as ArrowFunctionExpression).body as AwaitExpression).argument, // CallExpression + ]; + const state: WalkerState = { + isModified: false, + functionIdentifiers: new Set(), + } + + asyncifyScope(ancestors, state); + + // Assert the function did indeed change the expression to async + assertEquals(((node.body.body[0] as ReturnStatement).argument as ArrowFunctionExpression).async, true) + + // Assert the function did NOT change all ancestors in the chain + assertEquals(node.async, false); + + // Assert it couldn't find a function identifier + assertEquals(state.functionIdentifiers.size, 0); + }); +}); + +describe('checkReassignmentofModifiedIdentifiers', () => { + it(`identifies the reassignment of "foo" in the code "${AssignmentOfFooToBar.code}"`, () => { + const node = structuredClone(AssignmentOfFooToBar.node); + const ancestors: AnyNode[] = [ + node, // ExpressionStatement + node.expression, // AssignmentExpression + (node.expression as AssignmentExpression).right, // Identifier + ]; + const state: WalkerState = { + isModified: true, + functionIdentifiers: new Set(['foo']), + } + + checkReassignmentOfModifiedIdentifiers(node.expression, state, ancestors, ''); + + assertEquals(state.functionIdentifiers.has('bar'), true); + }); + + it(`identifies the reassignment of "foo" in the code "${AssignmentOfFooToBarMemberExpression.code}"`, () => { + const node = structuredClone(AssignmentOfFooToBarMemberExpression.node); + const ancestors: AnyNode[] = [ + node, // ExpressionStatement + node.expression, // AssignmentExpression + (node.expression as AssignmentExpression).right, // Identifier + ]; + const state: WalkerState = { + isModified: true, + functionIdentifiers: new Set(['foo']), + } + + checkReassignmentOfModifiedIdentifiers(node.expression, state, ancestors, ''); + + assertEquals(state.functionIdentifiers.has('bar'), true); + }); + + it(`identifies the reassignment of "foo" in the code "${AssignmentOfFooToBarVariableDeclarator.code}"`, () => { + const node = structuredClone(AssignmentOfFooToBarVariableDeclarator.node); + const ancestors: AnyNode[] = [ + node, // VariableDeclaration + node.declarations[0], // VariableDeclarator + ]; + const state: WalkerState = { + isModified: true, + functionIdentifiers: new Set(['foo']), + } + + checkReassignmentOfModifiedIdentifiers(node.declarations[0], state, ancestors, ''); + + assertEquals(state.functionIdentifiers.has('bar'), true); + }); + + it(`identifies the reassignment of "foo" in the code "${AssignmentOfFooToBarPropertyDefinition.code}"`, () => { + const node = structuredClone(AssignmentOfFooToBarPropertyDefinition.node); + const ancestors: AnyNode[] = [ + node, // ClassDeclaration + node.body, // ClassBody + node.body.body[0], // PropertyDefinition + ]; + const state: WalkerState = { + isModified: true, + functionIdentifiers: new Set(['foo']), + } + + checkReassignmentOfModifiedIdentifiers(node.body.body[0], state, ancestors, ''); + + assertEquals(state.functionIdentifiers.has('bar'), true); + }); +}); + +describe('buildFixModifiedFunctionsOperation', function() { + const state: WalkerState = { + isModified: false, + functionIdentifiers: new Set(['foo']), + }; + + const fixFunction = buildFixModifiedFunctionsOperation(state.functionIdentifiers); + + beforeEach(() => { + state.isModified = false; + state.functionIdentifiers = new Set(['foo']); + }); + + it(`fixes calls of "foo" in the code "${FixSimpleCallExpression.code}"`, () => { + const node = structuredClone(FixSimpleCallExpression.node); + const ancestors: AnyNode[] = [ + node, // FunctionDeclaration + node.body, // BlockStatement + node.body.body[0], // VariableDeclaration + (node.body.body[0] as VariableDeclaration).declarations[0], // VariableDeclarator + (node.body.body[0] as VariableDeclaration).declarations[0].init!, // CallExpression + ]; + + fixFunction(ancestors[4], state, ancestors, ''); + + assertEquals(state.isModified, true); + assertEquals(state.functionIdentifiers.has('bar'), true); + assertNotEquals(FixSimpleCallExpression.node, node); + assertEquals(node.async, true); + assertEquals(ancestors[4].type, 'AwaitExpression'); + }); + + it(`fixes calls of "foo" in the code "${ArrowFunctionDerefCallExpression.code}"`, () => { + const node = structuredClone(ArrowFunctionDerefCallExpression.node); + const ancestors: AnyNode[] = [ + node, // VariableDeclaration + node.declarations[0], // VariableDeclarator + node.declarations[0].init!, // ArrowFunctionExpression + (node.declarations[0].init as ArrowFunctionExpression).body, // CallExpression + ]; + + fixFunction(ancestors[3], state, ancestors, ''); + + // Recorded that a modification has been made + assertEquals(state.isModified, true); + // Recorded that the enclosing scope of the call also requires fixing + assertEquals(state.functionIdentifiers.has('bar'), true); + // Original node and fixed node are different + assertNotEquals(ArrowFunctionDerefCallExpression.node, node); + // The function call is now await'ed + assertEquals(ancestors[3].type, 'AwaitExpression'); + // The parent function of the call is now marked as async + assertEquals((ancestors[2] as ArrowFunctionExpression).async, true); + }); +}) diff --git a/packages/apps-engine/deno-runtime/lib/codec.ts b/packages/apps-engine/deno-runtime/lib/codec.ts new file mode 100644 index 000000000000..288db46169dc --- /dev/null +++ b/packages/apps-engine/deno-runtime/lib/codec.ts @@ -0,0 +1,43 @@ +import { Buffer } from 'node:buffer'; +import { Decoder, Encoder, ExtensionCodec } from '@msgpack/msgpack'; + +import type { App as _App } from '@rocket.chat/apps-engine/definition/App.ts'; +import { require } from "./require.ts"; + +const { App } = require('@rocket.chat/apps-engine/definition/App.js') as { + App: typeof _App; +}; + +const extensionCodec = new ExtensionCodec(); + +extensionCodec.register({ + type: 0, + encode: (object: unknown) => { + // We don't care about functions, but also don't want to throw an error + if (typeof object === 'function' || object instanceof App) { + return new Uint8Array(0); + } + + return null; + }, + decode: (_data: Uint8Array) => undefined, +}); + +// Since Deno doesn't have Buffer by default, we need to use Uint8Array +extensionCodec.register({ + type: 1, + encode: (object: unknown) => { + if (object instanceof Buffer) { + return new Uint8Array(object.buffer, object.byteOffset, object.byteLength); + } + + return null; + }, + // msgpack will reuse the Uint8Array instance, so WE NEED to copy it instead of simply creating a view + decode: (data: Uint8Array) => { + return Buffer.from(data); + }, +}); + +export const encoder = new Encoder({ extensionCodec }); +export const decoder = new Decoder({ extensionCodec }); diff --git a/packages/apps-engine/deno-runtime/lib/logger.ts b/packages/apps-engine/deno-runtime/lib/logger.ts new file mode 100644 index 000000000000..ea2701c70230 --- /dev/null +++ b/packages/apps-engine/deno-runtime/lib/logger.ts @@ -0,0 +1,142 @@ +import stackTrace from 'stack-trace'; +import { AppObjectRegistry } from '../AppObjectRegistry.ts'; + +export interface StackFrame { + getTypeName(): string; + getFunctionName(): string; + getMethodName(): string; + getFileName(): string; + getLineNumber(): number; + getColumnNumber(): number; + isNative(): boolean; + isConstructor(): boolean; +} + +enum LogMessageSeverity { + DEBUG = 'debug', + INFORMATION = 'info', + LOG = 'log', + WARNING = 'warning', + ERROR = 'error', + SUCCESS = 'success', +} + +type Entry = { + caller: string; + severity: LogMessageSeverity; + method: string; + timestamp: Date; + args: Array; +}; + +interface ILoggerStorageEntry { + appId: string; + method: string; + entries: Array; + startTime: Date; + endTime: Date; + totalTime: number; + _createdAt: Date; +} + +export class Logger { + private entries: Array; + private start: Date; + private method: string; + + constructor(method: string) { + this.method = method; + this.entries = []; + this.start = new Date(); + } + + public debug(...args: Array): void { + this.addEntry(LogMessageSeverity.DEBUG, this.getStack(stackTrace.get()), ...args); + } + + public info(...args: Array): void { + this.addEntry(LogMessageSeverity.INFORMATION, this.getStack(stackTrace.get()), ...args); + } + + public log(...args: Array): void { + this.addEntry(LogMessageSeverity.LOG, this.getStack(stackTrace.get()), ...args); + } + + public warn(...args: Array): void { + this.addEntry(LogMessageSeverity.WARNING, this.getStack(stackTrace.get()), ...args); + } + + public error(...args: Array): void { + this.addEntry(LogMessageSeverity.ERROR, this.getStack(stackTrace.get()), ...args); + } + + public success(...args: Array): void { + this.addEntry(LogMessageSeverity.SUCCESS, this.getStack(stackTrace.get()), ...args); + } + + private addEntry(severity: LogMessageSeverity, caller: string, ...items: Array): void { + const i = items.map((args) => { + if (args instanceof Error) { + return JSON.stringify(args, Object.getOwnPropertyNames(args)); + } + if (typeof args === 'object' && args !== null && 'stack' in args) { + return JSON.stringify(args, Object.getOwnPropertyNames(args)); + } + if (typeof args === 'object' && args !== null && 'message' in args) { + return JSON.stringify(args, Object.getOwnPropertyNames(args)); + } + const str = JSON.stringify(args, null, 2); + return str ? JSON.parse(str) : str; // force call toJSON to prevent circular references + }); + + this.entries.push({ + caller, + severity, + method: this.method, + timestamp: new Date(), + args: i, + }); + } + + private getStack(stack: Array): string { + let func = 'anonymous'; + + if (stack.length === 1) { + return func; + } + + const frame = stack[1]; + + if (frame.getMethodName() === null) { + func = 'anonymous OR constructor'; + } else { + func = frame.getMethodName(); + } + + if (frame.getFunctionName() !== null) { + func = `${func} -> ${frame.getFunctionName()}`; + } + + return func; + } + + private getTotalTime(): number { + return new Date().getTime() - this.start.getTime(); + } + + public hasEntries(): boolean { + return this.entries.length > 0; + } + + public getLogs(): ILoggerStorageEntry { + return { + appId: AppObjectRegistry.get('id')!, + method: this.method, + entries: this.entries, + startTime: this.start, + endTime: new Date(), + totalTime: this.getTotalTime(), + _createdAt: new Date(), + }; + } +} diff --git a/packages/apps-engine/deno-runtime/lib/messenger.ts b/packages/apps-engine/deno-runtime/lib/messenger.ts new file mode 100644 index 000000000000..1e9ffe05c6c5 --- /dev/null +++ b/packages/apps-engine/deno-runtime/lib/messenger.ts @@ -0,0 +1,199 @@ +import { writeAll } from "https://deno.land/std@0.216.0/io/write_all.ts"; + +import * as jsonrpc from 'jsonrpc-lite'; + +import { AppObjectRegistry } from '../AppObjectRegistry.ts'; +import type { Logger } from './logger.ts'; +import { encoder } from './codec.ts'; + +export type RequestDescriptor = Pick; + +export type NotificationDescriptor = Pick; + +export type SuccessResponseDescriptor = Pick; + +export type ErrorResponseDescriptor = Pick; + +export type JsonRpcRequest = jsonrpc.IParsedObjectRequest | jsonrpc.IParsedObjectNotification; +export type JsonRpcResponse = jsonrpc.IParsedObjectSuccess | jsonrpc.IParsedObjectError; + +export function isRequest(message: jsonrpc.IParsedObject): message is JsonRpcRequest { + return message.type === 'request' || message.type === 'notification'; +} + +export function isResponse(message: jsonrpc.IParsedObject): message is JsonRpcResponse { + return message.type === 'success' || message.type === 'error'; +} + +export function isErrorResponse(message: jsonrpc.JsonRpc): message is jsonrpc.ErrorObject { + return message instanceof jsonrpc.ErrorObject; +} + +const COMMAND_PONG = '_zPONG'; + +export const RPCResponseObserver = new EventTarget(); + +export const Queue = new (class Queue { + private queue: Uint8Array[] = []; + private isProcessing = false; + + private async processQueue() { + if (this.isProcessing) { + return; + } + + this.isProcessing = true; + + while (this.queue.length) { + const message = this.queue.shift(); + + if (message) { + await Transport.send(message); + } + } + + this.isProcessing = false; + } + + public enqueue(message: jsonrpc.JsonRpc | typeof COMMAND_PONG) { + this.queue.push(encoder.encode(message)); + this.processQueue(); + } +}); + +export const Transport = new (class Transporter { + private selectedTransport: Transporter['stdoutTransport'] | Transporter['noopTransport']; + + constructor() { + this.selectedTransport = this.stdoutTransport.bind(this); + } + + private async stdoutTransport(message: Uint8Array): Promise { + await writeAll(Deno.stdout, message); + } + + private async noopTransport(_message: Uint8Array): Promise {} + + public selectTransport(transport: 'stdout' | 'noop'): void { + switch (transport) { + case 'stdout': + this.selectedTransport = this.stdoutTransport.bind(this); + break; + case 'noop': + this.selectedTransport = this.noopTransport.bind(this); + break; + } + } + + public send(message: Uint8Array): Promise { + return this.selectedTransport(message); + } +})(); + +export function parseMessage(message: string | Record) { + let parsed: jsonrpc.IParsedObject | jsonrpc.IParsedObject[]; + + if (typeof message === 'string') { + parsed = jsonrpc.parse(message); + } else { + parsed = jsonrpc.parseObject(message); + } + + if (Array.isArray(parsed)) { + throw jsonrpc.error(null, jsonrpc.JsonRpcError.invalidRequest(null)); + } + + if (parsed.type === 'invalid') { + throw jsonrpc.error(null, parsed.payload); + } + + return parsed; +} + +export async function sendInvalidRequestError(): Promise { + const rpc = jsonrpc.error(null, jsonrpc.JsonRpcError.invalidRequest(null)); + + await Queue.enqueue(rpc); +} + +export async function sendInvalidParamsError(id: jsonrpc.ID): Promise { + const rpc = jsonrpc.error(id, jsonrpc.JsonRpcError.invalidParams(null)); + + await Queue.enqueue(rpc); +} + +export async function sendParseError(): Promise { + const rpc = jsonrpc.error(null, jsonrpc.JsonRpcError.parseError(null)); + + await Queue.enqueue(rpc); +} + +export async function sendMethodNotFound(id: jsonrpc.ID): Promise { + const rpc = jsonrpc.error(id, jsonrpc.JsonRpcError.methodNotFound(null)); + + await Queue.enqueue(rpc); +} + +export async function errorResponse({ error: { message, code = -32000, data = {} }, id }: ErrorResponseDescriptor): Promise { + const logger = AppObjectRegistry.get('logger'); + + if (logger?.hasEntries()) { + data.logs = logger.getLogs(); + } + + const rpc = jsonrpc.error(id, new jsonrpc.JsonRpcError(message, code, data)); + + await Queue.enqueue(rpc); +} + +export async function successResponse({ id, result }: SuccessResponseDescriptor): Promise { + const payload = { value: result } as Record; + const logger = AppObjectRegistry.get('logger'); + + if (logger?.hasEntries()) { + payload.logs = logger.getLogs(); + } + + const rpc = jsonrpc.success(id, payload); + + await Queue.enqueue(rpc); +} + +export function pongResponse(): Promise { + return Promise.resolve(Queue.enqueue(COMMAND_PONG)); +} + +export async function sendRequest(requestDescriptor: RequestDescriptor): Promise { + const request = jsonrpc.request(Math.random().toString(36).slice(2), requestDescriptor.method, requestDescriptor.params); + + // TODO: add timeout to this + const responsePromise = new Promise((resolve, reject) => { + const handler = (event: Event) => { + if (event instanceof ErrorEvent) { + reject(event.error); + } + + if (event instanceof CustomEvent) { + resolve(event.detail); + } + + RPCResponseObserver.removeEventListener(`response:${request.id}`, handler); + }; + + RPCResponseObserver.addEventListener(`response:${request.id}`, handler); + }); + + await Queue.enqueue(request); + + return responsePromise as Promise; +} + +export function sendNotification({ method, params }: NotificationDescriptor) { + const request = jsonrpc.notification(method, params); + + Queue.enqueue(request); +} + +export function log(params: jsonrpc.RpcParams) { + sendNotification({ method: 'log', params }); +} diff --git a/packages/apps-engine/deno-runtime/lib/require.ts b/packages/apps-engine/deno-runtime/lib/require.ts new file mode 100644 index 000000000000..3288ecf67ffa --- /dev/null +++ b/packages/apps-engine/deno-runtime/lib/require.ts @@ -0,0 +1,14 @@ +import { createRequire } from 'node:module'; + +const _require = createRequire(import.meta.url); + +export const require = (mod: string) => { + // When we try to import something from the apps-engine, we resolve the path using import maps from Deno + // However, the import maps are configured to look at the source folder for typescript files, but during + // runtime those files are not available + if (mod.startsWith('@rocket.chat/apps-engine')) { + mod = import.meta.resolve(mod).replace('file://', '').replace('src/', ''); + } + + return _require(mod); +} diff --git a/packages/apps-engine/deno-runtime/lib/room.ts b/packages/apps-engine/deno-runtime/lib/room.ts new file mode 100644 index 000000000000..b7423cdd31ff --- /dev/null +++ b/packages/apps-engine/deno-runtime/lib/room.ts @@ -0,0 +1,104 @@ +import type { IRoom } from '@rocket.chat/apps-engine/definition/rooms/IRoom.ts'; +import type { RoomType } from '@rocket.chat/apps-engine/definition/rooms/RoomType.ts'; +import type { IUser } from '@rocket.chat/apps-engine/definition/users/IUser.ts'; +import type { AppManager } from '@rocket.chat/apps-engine/server/AppManager.ts'; + +const PrivateManager = Symbol('RoomPrivateManager'); + +export class Room { + public id: string | undefined; + + public displayName?: string; + + public slugifiedName: string | undefined; + + public type: RoomType | undefined; + + public creator: IUser | undefined; + + public isDefault?: boolean; + + public isReadOnly?: boolean; + + public displaySystemMessages?: boolean; + + public messageCount?: number; + + public createdAt?: Date; + + public updatedAt?: Date; + + public lastModifiedAt?: Date; + + public customFields?: { [key: string]: unknown }; + + public userIds?: Array; + + private _USERNAMES: Promise> | undefined; + + private [PrivateManager]: AppManager | undefined; + + /** + * @deprecated + */ + public get usernames(): Promise> { + if (!this._USERNAMES) { + this._USERNAMES = this[PrivateManager]?.getBridges().getInternalBridge().doGetUsernamesOfRoomById(this.id); + } + + return this._USERNAMES || Promise.resolve([]); + } + + public set usernames(usernames) {} + + public constructor(room: IRoom, manager: AppManager) { + Object.assign(this, room); + + Object.defineProperty(this, PrivateManager, { + configurable: false, + enumerable: false, + writable: false, + value: manager, + }); + } + + get value(): object { + return { + id: this.id, + displayName: this.displayName, + slugifiedName: this.slugifiedName, + type: this.type, + creator: this.creator, + isDefault: this.isDefault, + isReadOnly: this.isReadOnly, + displaySystemMessages: this.displaySystemMessages, + messageCount: this.messageCount, + createdAt: this.createdAt, + updatedAt: this.updatedAt, + lastModifiedAt: this.lastModifiedAt, + customFields: this.customFields, + userIds: this.userIds, + }; + } + + public async getUsernames(): Promise> { + // Get usernames + if (!this._USERNAMES) { + this._USERNAMES = await this[PrivateManager]?.getBridges().getInternalBridge().doGetUsernamesOfRoomById(this.id); + } + + return this._USERNAMES || []; + } + + public toJSON() { + return this.value; + } + + public toString() { + return this.value; + } + + public valueOf() { + return this.value; + } +} diff --git a/packages/apps-engine/deno-runtime/lib/roomFactory.ts b/packages/apps-engine/deno-runtime/lib/roomFactory.ts new file mode 100644 index 000000000000..8c270eeb86b9 --- /dev/null +++ b/packages/apps-engine/deno-runtime/lib/roomFactory.ts @@ -0,0 +1,27 @@ +import type { IRoom } from "@rocket.chat/apps-engine/definition/rooms/IRoom.ts"; +import type { AppManager } from "@rocket.chat/apps-engine/server/AppManager.ts"; + +import { AppAccessors } from "./accessors/mod.ts"; +import { Room } from "./room.ts"; +import { JsonRpcError } from "jsonrpc-lite"; + +const getMockAppManager = (senderFn: AppAccessors['senderFn']) => ({ + getBridges: () => ({ + getInternalBridge: () => ({ + doGetUsernamesOfRoomById: (roomId: string) => { + return senderFn({ + method: 'bridges:getInternalBridge:doGetUsernamesOfRoomById', + params: [roomId], + }).then((result) => result.result).catch((err) => { + throw new JsonRpcError(`Error getting usernames of room: ${err}`, -32000); + }) + }, + }), + }), +}); + +export default function createRoom(room: IRoom, senderFn: AppAccessors['senderFn']) { + const mockAppManager = getMockAppManager(senderFn); + + return new Room(room, mockAppManager as unknown as AppManager); +} diff --git a/packages/apps-engine/deno-runtime/lib/sanitizeDeprecatedUsage.ts b/packages/apps-engine/deno-runtime/lib/sanitizeDeprecatedUsage.ts new file mode 100644 index 000000000000..91cf6587e741 --- /dev/null +++ b/packages/apps-engine/deno-runtime/lib/sanitizeDeprecatedUsage.ts @@ -0,0 +1,20 @@ +import { fixBrokenSynchronousAPICalls } from "./ast/mod.ts"; + +function hasPotentialDeprecatedUsage(source: string) { + return ( + // potential usage of Room.usernames getter + source.includes('.usernames') || + // potential usage of LivechatRead.isOnline method + source.includes('.isOnline(') || + // potential usage of LivechatCreator.createToken method + source.includes('.createToken(') + ) +} + +export function sanitizeDeprecatedUsage(source: string) { + if (!hasPotentialDeprecatedUsage(source)) { + return source; + } + + return fixBrokenSynchronousAPICalls(source); +} diff --git a/packages/apps-engine/deno-runtime/lib/tests/logger.test.ts b/packages/apps-engine/deno-runtime/lib/tests/logger.test.ts new file mode 100644 index 000000000000..f69a728e79af --- /dev/null +++ b/packages/apps-engine/deno-runtime/lib/tests/logger.test.ts @@ -0,0 +1,111 @@ +import { assertEquals } from 'https://deno.land/std@0.203.0/assert/mod.ts'; +import { describe, it } from 'https://deno.land/std@0.203.0/testing/bdd.ts'; +import { Logger } from "../logger.ts"; + +describe('Logger', () => { + it('getLogs should return an array of entries', () => { + const logger = new Logger('test'); + logger.info('test'); + const logs = logger.getLogs(); + assertEquals(logs.entries.length, 1); + assertEquals(logs.method, 'test'); + }) + + it('should be able to add entries of different severity', () => { + const logger = new Logger('test'); + logger.info('test'); + logger.debug('test'); + logger.error('test'); + const logs = logger.getLogs(); + assertEquals(logs.entries.length, 3); + assertEquals(logs.entries[0].severity, 'info'); + assertEquals(logs.entries[1].severity, 'debug'); + assertEquals(logs.entries[2].severity, 'error'); + }) + + it('should be able to add an info entry', () => { + const logger = new Logger('test'); + logger.info('test'); + const logs = logger.getLogs(); + assertEquals(logs.entries.length, 1); + assertEquals(logs.entries[0].args[0], 'test'); + assertEquals(logs.entries[0].method, 'test'); + assertEquals(logs.entries[0].severity, 'info'); + }); + + it('should be able to add an debug entry', () => { + const logger = new Logger('test'); + logger.debug('test'); + const logs = logger.getLogs(); + assertEquals(logs.entries.length, 1); + assertEquals(logs.entries[0].args[0], 'test'); + assertEquals(logs.entries[0].method, 'test'); + assertEquals(logs.entries[0].severity, 'debug'); + }); + + it('should be able to add an error entry', () => { + const logger = new Logger('test'); + logger.error('test'); + const logs = logger.getLogs(); + assertEquals(logs.entries.length, 1); + assertEquals(logs.entries[0].args[0], 'test'); + assertEquals(logs.entries[0].method, 'test'); + assertEquals(logs.entries[0].severity, 'error'); + }); + + it('should be able to add an success entry', () => { + const logger = new Logger('test'); + logger.success('test'); + const logs = logger.getLogs(); + assertEquals(logs.entries.length, 1); + assertEquals(logs.entries[0].args[0], 'test'); + assertEquals(logs.entries[0].method, 'test'); + assertEquals(logs.entries[0].severity, 'success'); + }); + + it('should be able to add an warning entry', () => { + const logger = new Logger('test'); + logger.warn('test'); + const logs = logger.getLogs(); + assertEquals(logs.entries.length, 1); + assertEquals(logs.entries[0].args[0], 'test'); + assertEquals(logs.entries[0].method, 'test'); + assertEquals(logs.entries[0].severity, 'warning'); + }); + + it('should be able to add an log entry', () => { + const logger = new Logger('test'); + logger.log('test'); + const logs = logger.getLogs(); + assertEquals(logs.entries.length, 1); + assertEquals(logs.entries[0].args[0], 'test'); + assertEquals(logs.entries[0].method, 'test'); + assertEquals(logs.entries[0].severity, 'log'); + }); + + it('should be able to add an entry with multiple arguments', () => { + const logger = new Logger('test'); + logger.log('test', 'test', 'test'); + const logs = logger.getLogs(); + assertEquals(logs.entries.length, 1); + assertEquals(logs.entries[0].args[0], 'test'); + assertEquals(logs.entries[0].args[1], 'test'); + assertEquals(logs.entries[0].args[2], 'test'); + assertEquals(logs.entries[0].method, 'test'); + assertEquals(logs.entries[0].severity, 'log'); + }); + + it('should be able to add an entry with multiple arguments of different types', () => { + const logger = new Logger('test'); + logger.log('test', 1, true, { foo: 'bar' }); + const logs = logger.getLogs(); + assertEquals(logs.entries.length, 1); + assertEquals(logs.entries[0].args[0], 'test'); + assertEquals(logs.entries[0].args[1], 1); + assertEquals(logs.entries[0].args[2], true); + assertEquals(logs.entries[0].args[3], { foo: 'bar' }); + assertEquals(logs.entries[0].method, 'test'); + assertEquals(logs.entries[0].severity, 'log'); + }); + +}) diff --git a/packages/apps-engine/deno-runtime/lib/tests/messenger.test.ts b/packages/apps-engine/deno-runtime/lib/tests/messenger.test.ts new file mode 100644 index 000000000000..9b4f128380bc --- /dev/null +++ b/packages/apps-engine/deno-runtime/lib/tests/messenger.test.ts @@ -0,0 +1,96 @@ +import { assertEquals, assertObjectMatch } from 'https://deno.land/std@0.203.0/assert/mod.ts'; +import { afterAll, beforeEach, describe, it } from 'https://deno.land/std@0.203.0/testing/bdd.ts'; +import { spy } from 'https://deno.land/std@0.203.0/testing/mock.ts'; + +import * as Messenger from '../messenger.ts'; +import { AppObjectRegistry } from '../../AppObjectRegistry.ts'; +import { Logger } from '../logger.ts'; + +describe('Messenger', () => { + beforeEach(() => { + AppObjectRegistry.clear(); + AppObjectRegistry.set('logger', new Logger('test')); + AppObjectRegistry.set('id', 'test'); + Messenger.Transport.selectTransport('noop'); + }); + + afterAll(() => { + AppObjectRegistry.clear(); + Messenger.Transport.selectTransport('stdout'); + }); + + it('should add logs to success responses', async () => { + const theSpy = spy(Messenger.Queue, 'enqueue'); + + const logger = AppObjectRegistry.get('logger') as Logger; + + logger.info('test'); + + await Messenger.successResponse({ id: 'test', result: 'test' }); + + assertEquals(theSpy.calls.length, 1); + + const [responseArgument] = theSpy.calls[0].args; + + assertObjectMatch(responseArgument, { + jsonrpc: '2.0', + id: 'test', + result: { + value: 'test', + logs: { + appId: 'test', + method: 'test', + entries: [ + { + severity: 'info', + method: 'test', + args: ['test'], + caller: 'anonymous OR constructor', + }, + ], + }, + }, + }); + + theSpy.restore(); + }); + + it('should add logs to error responses', async () => { + const theSpy = spy(Messenger.Queue, 'enqueue'); + + const logger = AppObjectRegistry.get('logger') as Logger; + + logger.info('test'); + + await Messenger.errorResponse({ id: 'test', error: { code: -32000, message: 'test' } }); + + assertEquals(theSpy.calls.length, 1); + + const [responseArgument] = theSpy.calls[0].args; + + assertObjectMatch(responseArgument, { + jsonrpc: '2.0', + id: 'test', + error: { + code: -32000, + message: 'test', + data: { + logs: { + appId: 'test', + method: 'test', + entries: [ + { + severity: 'info', + method: 'test', + args: ['test'], + caller: 'anonymous OR constructor', + }, + ], + }, + }, + }, + }); + + theSpy.restore(); + }); +}); diff --git a/packages/apps-engine/deno-runtime/main.ts b/packages/apps-engine/deno-runtime/main.ts new file mode 100644 index 000000000000..09be5258ecd0 --- /dev/null +++ b/packages/apps-engine/deno-runtime/main.ts @@ -0,0 +1,129 @@ +if (!Deno.args.includes('--subprocess')) { + Deno.stderr.writeSync( + new TextEncoder().encode(` + This is a Deno wrapper for Rocket.Chat Apps. It is not meant to be executed stand-alone; + It is instead meant to be executed as a subprocess by the Apps-Engine framework. + `), + ); + Deno.exit(1001); +} + +import { JsonRpcError } from 'jsonrpc-lite'; +import type { App } from '@rocket.chat/apps-engine/definition/App.ts'; + +import * as Messenger from './lib/messenger.ts'; +import { decoder } from './lib/codec.ts'; +import { AppObjectRegistry } from './AppObjectRegistry.ts'; +import { Logger } from './lib/logger.ts'; + +import slashcommandHandler from './handlers/slashcommand-handler.ts'; +import videoConferenceHandler from './handlers/videoconference-handler.ts'; +import apiHandler from './handlers/api-handler.ts'; +import handleApp from './handlers/app/handler.ts'; +import handleScheduler from './handlers/scheduler-handler.ts'; + +type Handlers = { + app: typeof handleApp; + api: typeof apiHandler; + slashcommand: typeof slashcommandHandler; + videoconference: typeof videoConferenceHandler; + scheduler: typeof handleScheduler; + ping: (method: string, params: unknown) => 'pong'; +}; + +const COMMAND_PING = '_zPING'; + +async function requestRouter({ type, payload }: Messenger.JsonRpcRequest): Promise { + const methodHandlers: Handlers = { + app: handleApp, + api: apiHandler, + slashcommand: slashcommandHandler, + videoconference: videoConferenceHandler, + scheduler: handleScheduler, + ping: (_method, _params) => 'pong', + }; + + // We're not handling notifications at the moment + if (type === 'notification') { + return Messenger.sendInvalidRequestError(); + } + + const { id, method, params } = payload; + + const logger = new Logger(method); + AppObjectRegistry.set('logger', logger); + + const app = AppObjectRegistry.get('app'); + + if (app) { + // Same logic as applied in the ProxiedApp class previously + (app as unknown as Record).logger = logger; + } + + const [methodPrefix] = method.split(':') as [keyof Handlers]; + const handler = methodHandlers[methodPrefix]; + + if (!handler) { + return Messenger.errorResponse({ + error: { message: 'Method not found', code: -32601 }, + id, + }); + } + + const result = await handler(method, params); + + if (result instanceof JsonRpcError) { + return Messenger.errorResponse({ id, error: result }); + } + + return Messenger.successResponse({ id, result }); +} + +function handleResponse(response: Messenger.JsonRpcResponse): void { + let event: Event; + + if (response.type === 'error') { + event = new ErrorEvent(`response:${response.payload.id}`, { + error: response.payload, + }); + } else { + event = new CustomEvent(`response:${response.payload.id}`, { + detail: response.payload, + }); + } + + Messenger.RPCResponseObserver.dispatchEvent(event); +} + +async function main() { + Messenger.sendNotification({ method: 'ready' }); + + for await (const message of decoder.decodeStream(Deno.stdin.readable)) { + try { + // Process PING command first as it is not JSON RPC + if (message === COMMAND_PING) { + Messenger.pongResponse(); + continue; + } + + const JSONRPCMessage = Messenger.parseMessage(message as Record); + + if (Messenger.isRequest(JSONRPCMessage)) { + void requestRouter(JSONRPCMessage); + continue; + } + + if (Messenger.isResponse(JSONRPCMessage)) { + handleResponse(JSONRPCMessage); + } + } catch (error) { + if (Messenger.isErrorResponse(error)) { + await Messenger.errorResponse(error); + } else { + await Messenger.sendParseError(); + } + } + } +} + +main(); diff --git a/packages/apps-engine/package.json b/packages/apps-engine/package.json new file mode 100644 index 000000000000..c229ff447000 --- /dev/null +++ b/packages/apps-engine/package.json @@ -0,0 +1,132 @@ +{ + "name": "@rocket.chat/apps-engine", + "version": "1.47.0-alpha", + "description": "The engine code for the Rocket.Chat Apps which manages, runs, translates, coordinates and all of that.", + "main": "index", + "typings": "index", + "scripts": { + "start": "run-s .:build:clean .:build:watch", + "testunit": "run-p .:test:node .:test:deno", + ".:test:node": "NODE_ENV=test ts-node ./tests/runner.ts", + ".:test:deno": "cd deno-runtime && deno task test", + "lint": "run-p .:lint:eslint .:lint:deno", + ".:lint:eslint": "eslint .", + ".:lint:deno": "deno lint --ignore=deno-runtime/.deno deno-runtime/", + "fix-lint": "eslint . --fix", + "build": "run-s .:build:clean .:build:default .:build:deno-cache", + ".:build:clean": "rimraf client definition server", + ".:build:default": "tsc -p tsconfig.json", + ".:build:deno-cache": "node scripts/deno-cache.js", + ".:build:watch": "yarn .:build:default --watch", + "typecheck": "tsc -p tsconfig.json --noEmit", + "bundle": "node scripts/bundle.js", + "gen-doc": "typedoc", + "prepack": "yarn bundle" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/RocketChat/Rocket.Chat.Apps-engine.git" + }, + "keywords": [ + "rocket.chat", + "team chat", + "apps engine" + ], + "files": [ + "client/**", + "definition/**", + "deno-runtime/**", + "lib/**", + "scripts/**", + "server/**" + ], + "publishConfig": { + "access": "public" + }, + "author": { + "name": "Rocket.Chat", + "url": "https://rocket.chat/" + }, + "contributors": [ + { + "name": "Bradley Hilton", + "email": "bradley.hilton@rocket.chat" + }, + { + "name": "Rodrigo Nascimento", + "email": "rodrigo.nascimento@rocket.chat" + }, + { + "name": "Douglas Gubert", + "email": "douglas.gubert@rocket.chat" + } + ], + "license": "MIT", + "bugs": { + "url": "https://github.com/RocketChat/Rocket.Chat.Apps-engine/issues" + }, + "homepage": "https://github.com/RocketChat/Rocket.Chat.Apps-engine#readme", + "devDependencies": { + "@rocket.chat/eslint-config": "workspace:~", + "@rocket.chat/ui-kit": "workspace:~", + "@types/adm-zip": "^0.5.0", + "@types/debug": "^4.1.12", + "@types/lodash.clonedeep": "^4.5.7", + "@types/nedb": "^1.8.12", + "@types/node": "^18.0.0", + "@types/semver": "^5.5.0", + "@types/stack-trace": "0.0.29", + "@types/uuid": "~8.3.4", + "@typescript-eslint/eslint-plugin": "~5.60.1", + "@typescript-eslint/parser": "~5.60.1", + "alsatian": "^2.4.0", + "browserify": "^16.5.2", + "eslint": "~8.45.0", + "nedb": "^1.8.0", + "npm-run-all": "^4.1.5", + "nyc": "^14.1.1", + "rimraf": "^6.0.1", + "tap-bark": "^1.0.0", + "ts-node": "^6.2.0", + "typedoc": "~0.24.8", + "typescript": "~5.1.6", + "uglify-es": "^3.3.9" + }, + "dependencies": { + "@msgpack/msgpack": "3.0.0-beta2", + "adm-zip": "^0.5.9", + "cryptiles": "^4.1.3", + "debug": "^4.3.4", + "esbuild": "^0.20.2", + "jose": "^4.11.1", + "jsonrpc-lite": "^2.2.0", + "lodash.clonedeep": "^4.5.0", + "semver": "^5.7.1", + "stack-trace": "0.0.10", + "uuid": "~8.3.2" + }, + "peerDependencies": { + "@rocket.chat/ui-kit": "workspace:^" + }, + "nyc": { + "include": [ + "src/*.ts", + "src/server/**/*.ts" + ], + "extension": [ + ".ts" + ], + "reporter": [ + "lcov", + "json", + "html" + ], + "all": true + }, + "volta": { + "extends": "../../package.json" + }, + "installConfig": { + "hoistingLimits": "workspaces" + } +} diff --git a/packages/apps-engine/scripts/bundle.js b/packages/apps-engine/scripts/bundle.js new file mode 100644 index 000000000000..a7f8932bec12 --- /dev/null +++ b/packages/apps-engine/scripts/bundle.js @@ -0,0 +1,35 @@ +const fs = require('fs'); +const path = require('path'); +const { Readable } = require('stream'); + +const browserify = require('browserify'); +const { minify } = require('uglify-es'); + +const targetDir = path.join(__dirname, '..', 'client'); + +// browserify accepts either a stream or a file path +const glue = new Readable({ + read() { + console.log('read'); + this.push("window.AppsEngineUIClient = require('./AppsEngineUIClient').AppsEngineUIClient;"); + this.push(null); + }, +}); + +async function main() { + const bundle = await new Promise((resolve, reject) => + browserify(glue, { + basedir: targetDir, + }).bundle((err, bundle) => { + if (err) return reject(err); + + resolve(bundle.toString()); + }), + ); + + const result = minify(bundle); + + fs.writeFileSync(path.join(targetDir, 'AppsEngineUIClient.min.js'), result.code); +} + +main(); diff --git a/packages/apps-engine/scripts/deno-cache.js b/packages/apps-engine/scripts/deno-cache.js new file mode 100644 index 000000000000..acf2f4b977b4 --- /dev/null +++ b/packages/apps-engine/scripts/deno-cache.js @@ -0,0 +1,25 @@ +const childProcess = require('child_process'); +const path = require('path'); + +try { + childProcess.execSync('deno info'); +} catch (e) { + console.error( + 'Could not execute "deno" in the system. It is now a requirement for the Apps-Engine framework, and Rocket.Chat apps will not work without it.\n', + 'Make sure to install Deno and run the installation process for the Apps-Engine again. More info on https://docs.deno.com/runtime/manual/getting_started/installation', + ); + process.exit(1); +} + +const rootPath = path.join(__dirname, '..'); +const denoRuntimePath = path.join(rootPath, 'deno-runtime'); +const DENO_DIR = process.env.DENO_DIR ?? path.join(rootPath, '.deno-cache'); + +childProcess.execSync('deno cache main.ts', { + cwd: denoRuntimePath, + env: { + DENO_DIR, + PATH: process.env.PATH, + }, + stdio: 'inherit', +}); diff --git a/packages/apps-engine/src/client/AppClientManager.ts b/packages/apps-engine/src/client/AppClientManager.ts new file mode 100644 index 000000000000..5d409f148f5f --- /dev/null +++ b/packages/apps-engine/src/client/AppClientManager.ts @@ -0,0 +1,28 @@ +import type { IAppInfo } from '../definition/metadata'; +import { AppServerCommunicator } from './AppServerCommunicator'; +import { AppsEngineUIHost } from './AppsEngineUIHost'; + +export class AppClientManager { + private apps: Array; + + constructor(private readonly appsEngineUIHost: AppsEngineUIHost, private readonly communicator?: AppServerCommunicator) { + if (!(appsEngineUIHost instanceof AppsEngineUIHost)) { + throw new Error('The appClientUIHost must extend appClientUIHost'); + } + + if (communicator && !(communicator instanceof AppServerCommunicator)) { + throw new Error('The communicator must extend AppServerCommunicator'); + } + + this.apps = []; + } + + public async load(): Promise { + this.apps = await this.communicator.getEnabledApps(); + console.log('Enabled apps:', this.apps); + } + + public async initialize(): Promise { + this.appsEngineUIHost.initialize(); + } +} diff --git a/packages/apps-engine/src/client/AppServerCommunicator.ts b/packages/apps-engine/src/client/AppServerCommunicator.ts new file mode 100644 index 000000000000..fae400bc7ff7 --- /dev/null +++ b/packages/apps-engine/src/client/AppServerCommunicator.ts @@ -0,0 +1,16 @@ +import type { IAppInfo } from '../definition/metadata'; + +export abstract class AppServerCommunicator { + public abstract getEnabledApps(): Promise>; + + public abstract getDisabledApps(): Promise>; + + // Map> + public abstract getLanguageAdditions(): Promise>>; + + // Map> + public abstract getSlashCommands(): Promise>>; + + // Map> + public abstract getContextualBarButtons(): Promise>>; +} diff --git a/packages/apps-engine/src/client/AppsEngineUIClient.ts b/packages/apps-engine/src/client/AppsEngineUIClient.ts new file mode 100644 index 000000000000..620be5e21d01 --- /dev/null +++ b/packages/apps-engine/src/client/AppsEngineUIClient.ts @@ -0,0 +1,70 @@ +import { ACTION_ID_LENGTH, MESSAGE_ID } from './constants'; +import type { IExternalComponentRoomInfo, IExternalComponentUserInfo } from './definition'; +import { AppsEngineUIMethods } from './definition/AppsEngineUIMethods'; +import { randomString } from './utils'; + +/** + * Represents the SDK provided to the external component. + */ +export class AppsEngineUIClient { + private listener: (this: Window, ev: MessageEvent) => any; + + private callbacks: Map any>; + + constructor() { + this.listener = () => console.log('init'); + this.callbacks = new Map(); + } + + /** + * Get the current user's information. + * + * @return the information of the current user. + */ + public getUserInfo(): Promise { + return this.call(AppsEngineUIMethods.GET_USER_INFO); + } + + /** + * Get the current room's information. + * + * @return the information of the current room. + */ + public getRoomInfo(): Promise { + return this.call(AppsEngineUIMethods.GET_ROOM_INFO); + } + + /** + * Initialize the app SDK for communicating with Rocket.Chat + */ + public init(): void { + this.listener = ({ data }) => { + if (!data?.hasOwnProperty(MESSAGE_ID)) { + return; + } + + const { + [MESSAGE_ID]: { id, payload }, + } = data; + + if (this.callbacks.has(id)) { + const resolve = this.callbacks.get(id); + + if (typeof resolve === 'function') { + resolve(payload); + } + this.callbacks.delete(id); + } + }; + window.addEventListener('message', this.listener); + } + + private call(action: string, payload?: any): Promise { + return new Promise((resolve) => { + const id = randomString(ACTION_ID_LENGTH); + + window.parent.postMessage({ [MESSAGE_ID]: { action, payload, id } }, '*'); + this.callbacks.set(id, resolve); + }); + } +} diff --git a/packages/apps-engine/src/client/AppsEngineUIHost.ts b/packages/apps-engine/src/client/AppsEngineUIHost.ts new file mode 100644 index 000000000000..02f82f236ed2 --- /dev/null +++ b/packages/apps-engine/src/client/AppsEngineUIHost.ts @@ -0,0 +1,78 @@ +import { MESSAGE_ID } from './constants'; +import type { IAppsEngineUIResponse, IExternalComponentRoomInfo, IExternalComponentUserInfo } from './definition'; +import { AppsEngineUIMethods } from './definition'; + +type HandleActionData = IExternalComponentUserInfo | IExternalComponentRoomInfo; + +/** + * Represents the host which handles API calls from external components. + */ +export abstract class AppsEngineUIHost { + /** + * The message emitter who calling the API. + */ + private responseDestination!: Window; + + constructor() { + this.initialize(); + } + + /** + * initialize the AppClientUIHost by registering window `message` listener + */ + public initialize() { + window.addEventListener('message', async ({ data, source }) => { + if (!data?.hasOwnProperty(MESSAGE_ID)) { + return; + } + + this.responseDestination = source as Window; + + const { + [MESSAGE_ID]: { action, id }, + } = data; + + switch (action) { + case AppsEngineUIMethods.GET_USER_INFO: + this.handleAction(action, id, await this.getClientUserInfo()); + break; + case AppsEngineUIMethods.GET_ROOM_INFO: + this.handleAction(action, id, await this.getClientRoomInfo()); + break; + } + }); + } + + /** + * Get the current user's information. + */ + public abstract getClientUserInfo(): Promise; + + /** + * Get the opened room's information. + */ + public abstract getClientRoomInfo(): Promise; + + /** + * Handle the action sent from the external component. + * @param action the name of the action + * @param id the unique id of the API call + * @param data The data that will return to the caller + */ + private async handleAction(action: AppsEngineUIMethods, id: string, data: HandleActionData): Promise { + if (this.responseDestination instanceof MessagePort || this.responseDestination instanceof ServiceWorker) { + return; + } + + this.responseDestination.postMessage( + { + [MESSAGE_ID]: { + id, + action, + payload: data, + } as IAppsEngineUIResponse, + }, + '*', + ); + } +} diff --git a/packages/apps-engine/src/client/constants/index.ts b/packages/apps-engine/src/client/constants/index.ts new file mode 100644 index 000000000000..bd7f2e779ca1 --- /dev/null +++ b/packages/apps-engine/src/client/constants/index.ts @@ -0,0 +1,6 @@ +/** + * The id length of each action. + */ +export const ACTION_ID_LENGTH = 80; + +export const MESSAGE_ID = 'rc-apps-engine-ui'; diff --git a/packages/apps-engine/src/client/definition/AppsEngineUIMethods.ts b/packages/apps-engine/src/client/definition/AppsEngineUIMethods.ts new file mode 100644 index 000000000000..df150f9ce62b --- /dev/null +++ b/packages/apps-engine/src/client/definition/AppsEngineUIMethods.ts @@ -0,0 +1,7 @@ +/** + * The actions provided by the AppClientSDK. + */ +export enum AppsEngineUIMethods { + GET_USER_INFO = 'getUserInfo', + GET_ROOM_INFO = 'getRoomInfo', +} diff --git a/packages/apps-engine/src/client/definition/IAppsEngineUIResponse.ts b/packages/apps-engine/src/client/definition/IAppsEngineUIResponse.ts new file mode 100644 index 000000000000..dff7289226a4 --- /dev/null +++ b/packages/apps-engine/src/client/definition/IAppsEngineUIResponse.ts @@ -0,0 +1,19 @@ +import type { IExternalComponentRoomInfo, IExternalComponentUserInfo } from './index'; + +/** + * The response to the AppClientSDK's API call. + */ +export interface IAppsEngineUIResponse { + /** + * The name of the action + */ + action: string; + /** + * The unique id of the API call + */ + id: string; + /** + * The data that will return to the caller + */ + payload: IExternalComponentUserInfo | IExternalComponentRoomInfo; +} diff --git a/packages/apps-engine/src/client/definition/IExternalComponentRoomInfo.ts b/packages/apps-engine/src/client/definition/IExternalComponentRoomInfo.ts new file mode 100644 index 000000000000..32ca449e9650 --- /dev/null +++ b/packages/apps-engine/src/client/definition/IExternalComponentRoomInfo.ts @@ -0,0 +1,16 @@ +import type { IRoom } from '../../definition/rooms'; +import type { IExternalComponentUserInfo } from './IExternalComponentUserInfo'; + +type ClientRoomInfo = Pick; + +/** + * Represents the room's information returned to the + * external component. + */ +export interface IExternalComponentRoomInfo extends ClientRoomInfo { + /** + * the list that contains all the users belonging + * to this room. + */ + members: Array; +} diff --git a/packages/apps-engine/src/client/definition/IExternalComponentUserInfo.ts b/packages/apps-engine/src/client/definition/IExternalComponentUserInfo.ts new file mode 100644 index 000000000000..9212f5b39876 --- /dev/null +++ b/packages/apps-engine/src/client/definition/IExternalComponentUserInfo.ts @@ -0,0 +1,14 @@ +import type { IUser } from '../../definition/users'; + +type ClientUserInfo = Pick; + +/** + * Represents the user's information returned to + * the external component. + */ +export interface IExternalComponentUserInfo extends ClientUserInfo { + /** + * the avatar URL of the Rocket.Chat user + */ + avatarUrl: string; +} diff --git a/packages/apps-engine/src/client/definition/index.ts b/packages/apps-engine/src/client/definition/index.ts new file mode 100644 index 000000000000..70a1fe884a6a --- /dev/null +++ b/packages/apps-engine/src/client/definition/index.ts @@ -0,0 +1,4 @@ +export * from './AppsEngineUIMethods'; +export * from './IExternalComponentUserInfo'; +export * from './IExternalComponentRoomInfo'; +export * from './IAppsEngineUIResponse'; diff --git a/packages/apps-engine/src/client/index.ts b/packages/apps-engine/src/client/index.ts new file mode 100644 index 000000000000..2ebfee0264d2 --- /dev/null +++ b/packages/apps-engine/src/client/index.ts @@ -0,0 +1,4 @@ +import { AppClientManager } from './AppClientManager'; +import { AppServerCommunicator } from './AppServerCommunicator'; + +export { AppClientManager, AppServerCommunicator }; diff --git a/packages/apps-engine/src/client/utils/index.ts b/packages/apps-engine/src/client/utils/index.ts new file mode 100644 index 000000000000..f5e851e7d50f --- /dev/null +++ b/packages/apps-engine/src/client/utils/index.ts @@ -0,0 +1,18 @@ +/** + * Generate a random string with the specified length. + * @param length the length for the generated random string. + */ +export function randomString(length: number): string { + const buffer: Array = []; + const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'; + + for (let i = 0; i < length; i++) { + buffer.push(chars[getRandomInt(chars.length)]); + } + + return buffer.join(''); +} + +function getRandomInt(max: number): number { + return Math.floor(Math.random() * Math.floor(max)); +} diff --git a/packages/apps-engine/src/definition/App.ts b/packages/apps-engine/src/definition/App.ts new file mode 100644 index 000000000000..0044f35134b6 --- /dev/null +++ b/packages/apps-engine/src/definition/App.ts @@ -0,0 +1,236 @@ +import { AppStatus } from './AppStatus'; +import type { IApp } from './IApp'; +import type { + IAppAccessors, + IAppInstallationContext, + IAppUninstallationContext, + IConfigurationExtend, + IConfigurationModify, + IEnvironmentRead, + IHttp, + ILogger, + IModify, + IPersistence, + IRead, + IAppUpdateContext, +} from './accessors'; +import type { IAppAuthorInfo } from './metadata/IAppAuthorInfo'; +import type { IAppInfo } from './metadata/IAppInfo'; +import type { ISetting } from './settings'; +import type { ISettingUpdateContext } from './settings/ISettingUpdateContext'; + +export abstract class App implements IApp { + private status: AppStatus = AppStatus.UNKNOWN; + + /** + * Create a new App, this is called whenever the server starts up and initiates the Apps. + * Note, your implementation of this class should call `super(name, id, version)` so we have it. + * Also, please use the `initialize()` method to do items instead of the constructor as the constructor + * *might* be called more than once but the `initialize()` will only be called once. + */ + public constructor(private readonly info: IAppInfo, private readonly logger: ILogger, private readonly accessors?: IAppAccessors) { + this.logger.debug( + `Constructed the App ${this.info.name} (${this.info.id})`, + `v${this.info.version} which depends on the API v${this.info.requiredApiVersion}!`, + `Created by ${this.info.author.name}`, + ); + + this.setStatus(AppStatus.CONSTRUCTED); + } + + public async getStatus(): Promise { + return this.status; + } + + /** + * Get the name of this App. + * + * @return {string} the name + */ + public getName(): string { + return this.info.name; + } + + /** + * Gets the sluggified name of this App. + * + * @return {string} the name slugged + */ + public getNameSlug(): string { + return this.info.nameSlug; + } + + /** + * Gets the username of this App's app user. + * + * @return {string} the username of the app user + * + * @deprecated This method will be removed in the next major version. + * Please use read.getUserReader().getAppUser() instead. + */ + public getAppUserUsername(): string { + return `${this.info.nameSlug}.bot`; + } + + /** + * Get the ID of this App, please see for how to obtain an ID for your App. + * + * @return {number} the ID + */ + public getID(): string { + return this.info.id; + } + + /** + * Get the version of this App, using http://semver.org/. + * + * @return {string} the version + */ + public getVersion(): string { + return this.info.version; + } + + /** + * Get the description of this App, mostly used to show to the clients/administrators. + * + * @return {string} the description + */ + public getDescription(): string { + return this.info.description; + } + + /** + * Gets the API Version which this App depends on (http://semver.org/). + * This property is used for the dependency injections. + * + * @return {string} the required api version + */ + public getRequiredApiVersion(): string { + return this.info.requiredApiVersion; + } + + /** + * Gets the information regarding the author/maintainer of this App. + * + * @return author information + */ + public getAuthorInfo(): IAppAuthorInfo { + return this.info.author; + } + + /** + * Gets the entirity of the App's information. + * + * @return App information + */ + public getInfo(): IAppInfo { + return this.info; + } + + /** + * Gets the ILogger instance for this App. + * + * @return the logger instance + */ + public getLogger(): ILogger { + return this.logger; + } + + public getAccessors(): IAppAccessors { + return this.accessors; + } + + /** + * Method which will be called when the App is initialized. This is the recommended place + * to add settings and slash commands. If an error is thrown, all commands will be unregistered. + */ + public async initialize(configurationExtend: IConfigurationExtend, environmentRead: IEnvironmentRead): Promise { + await this.extendConfiguration(configurationExtend, environmentRead); + } + + /** + * Method which is called when this App is enabled and can be called several + * times during this instance's life time. Once after the `initialize()` is called, + * pending it doesn't throw an error, and then anytime the App is enabled by the user. + * If this method, `onEnable()`, returns false, then this App will not + * actually be enabled (ex: a setting isn't configured). + * + * @return whether the App should be enabled or not + */ + public async onEnable(environment: IEnvironmentRead, configurationModify: IConfigurationModify): Promise { + return true; + } + + /** + * Method which is called when this App is disabled and it can be called several times. + * If this App was enabled and then the user disabled it, this method will be called. + */ + public async onDisable(configurationModify: IConfigurationModify): Promise {} + + /** + * Method which is called when the App is uninstalled and it is called one single time. + * + * This method will NOT be called when an App is getting disabled manually, ONLY when + * it's being uninstalled from Rocket.Chat. + */ + public async onUninstall(context: IAppUninstallationContext, read: IRead, http: IHttp, persistence: IPersistence, modify: IModify): Promise {} + + /** + * Method which is called when the App is installed and it is called one single time. + * + * This method is NOT called when the App is updated. + */ + public async onInstall(context: IAppInstallationContext, read: IRead, http: IHttp, persistence: IPersistence, modify: IModify): Promise {} + + /** + * Method which is called when the App is updated and it is called one single time. + * + * This method is NOT called when the App is installed. + */ + public async onUpdate(context: IAppUpdateContext, read: IRead, http: IHttp, persistence: IPersistence, modify: IModify): Promise {} + + /** + * Method which is called whenever a setting which belongs to this App has been updated + * by an external system and not this App itself. The setting passed is the newly updated one. + * + * @param setting the setting which was updated + * @param configurationModify the accessor to modifiy the system + * @param reader the reader accessor + * @param http an accessor to the outside world + */ + public async onSettingUpdated(setting: ISetting, configurationModify: IConfigurationModify, read: IRead, http: IHttp): Promise {} + + /** + * Method which is called before a setting which belongs to this App is going to be updated + * by an external system and not this App itself. The setting passed is the newly updated one. + * + * @param setting the setting which is going to be updated + * @param configurationModify the accessor to modifiy the system + * @param reader the reader accessor + * @param http an accessor to the outside world + */ + public async onPreSettingUpdate(context: ISettingUpdateContext, configurationModify: IConfigurationModify, read: IRead, http: IHttp): Promise { + return context.newSetting; + } + + /** + * Method will be called during initialization. It allows for adding custom configuration options and defaults + * @param configuration + */ + protected async extendConfiguration(configuration: IConfigurationExtend, environmentRead: IEnvironmentRead): Promise {} + + /** + * Sets the status this App is now at, use only when 100% true (it's protected for a reason). + * + * @param status the new status of this App + */ + protected async setStatus(status: AppStatus): Promise { + this.logger.debug(`The status is now: ${status}`); + this.status = status; + } + + // Avoid leaking references if object is serialized (e.g. to be sent over IPC) + public toJSON(): Record { + return this.info; + } +} diff --git a/packages/apps-engine/src/definition/AppStatus.ts b/packages/apps-engine/src/definition/AppStatus.ts new file mode 100644 index 000000000000..31638a8d0f1c --- /dev/null +++ b/packages/apps-engine/src/definition/AppStatus.ts @@ -0,0 +1,65 @@ +export enum AppStatus { + /** The status is known, aka not been constructed the proper way. */ + UNKNOWN = 'unknown', + /** The App has been constructed but that's it. */ + CONSTRUCTED = 'constructed', + /** The App's `initialize()` was called and returned true. */ + INITIALIZED = 'initialized', + /** The App's `onEnable()` was called, returned true, and this was done automatically (system start up). */ + AUTO_ENABLED = 'auto_enabled', + /** The App's `onEnable()` was called, returned true, and this was done by the user such as installing a new one. */ + MANUALLY_ENABLED = 'manually_enabled', + /** + * The App was disabled due to an error while attempting to compile it. + * An attempt to enable it again will fail, as it needs to be updated. + */ + COMPILER_ERROR_DISABLED = 'compiler_error_disabled', + /** + * The App was disable due to its license being invalid + */ + INVALID_LICENSE_DISABLED = 'invalid_license_disabled', + /** + * The app was disabled due to an invalid installation or validation in its signature. + */ + INVALID_INSTALLATION_DISABLED = 'invalid_installation_disabled', + /** The App was disabled due to an unrecoverable error being thrown. */ + ERROR_DISABLED = 'error_disabled', + /** The App was manually disabled by a user. */ + MANUALLY_DISABLED = 'manually_disabled', + INVALID_SETTINGS_DISABLED = 'invalid_settings_disabled', + /** The App was disabled due to other circumstances. */ + DISABLED = 'disabled', +} + +export class AppStatusUtilsDef { + public isEnabled(status: AppStatus): boolean { + switch (status) { + case AppStatus.AUTO_ENABLED: + case AppStatus.MANUALLY_ENABLED: + return true; + default: + return false; + } + } + + public isDisabled(status: AppStatus): boolean { + switch (status) { + case AppStatus.COMPILER_ERROR_DISABLED: + case AppStatus.ERROR_DISABLED: + case AppStatus.MANUALLY_DISABLED: + case AppStatus.INVALID_SETTINGS_DISABLED: + case AppStatus.INVALID_LICENSE_DISABLED: + case AppStatus.INVALID_INSTALLATION_DISABLED: + case AppStatus.DISABLED: + return true; + default: + return false; + } + } + + public isError(status: AppStatus): boolean { + return [AppStatus.ERROR_DISABLED, AppStatus.COMPILER_ERROR_DISABLED].includes(status); + } +} + +export const AppStatusUtils = new AppStatusUtilsDef(); diff --git a/packages/apps-engine/src/definition/IApp.ts b/packages/apps-engine/src/definition/IApp.ts new file mode 100644 index 000000000000..53faff9647f4 --- /dev/null +++ b/packages/apps-engine/src/definition/IApp.ts @@ -0,0 +1,90 @@ +import type { AppStatus } from './AppStatus'; +import type { IAppAccessors } from './accessors'; +import type { ILogger } from './accessors/ILogger'; +import type { IAppAuthorInfo } from './metadata/IAppAuthorInfo'; +import type { IAppInfo } from './metadata/IAppInfo'; + +export interface IApp { + /** + * Gets the status of this App. + * + * @return {AppStatus} the status/state of the App + */ + getStatus(): Promise; + + /** + * Get the name of this App. + * + * @return {string} the name + */ + getName(): string; + + /** + * Gets the sluggified name of this App. + * + * @return {string} the name slugged + */ + getNameSlug(): string; + + /** + * Gets the username of this App's app user. + * + * @return {string} the username of the app user + * + * @deprecated This method will be removed in the next major version. + * Please use read.getAppUser instead. + */ + getAppUserUsername(): string; + + /** + * Get the ID of this App, please see for how to obtain an ID for your App. + * + * @return {number} the ID + */ + getID(): string; + + /** + * Get the version of this App, using http://semver.org/. + * + * @return {string} the version + */ + getVersion(): string; + + /** + * Get the description of this App, mostly used to show to the clients/administrators. + * + * @return {string} the description + */ + getDescription(): string; + + /** + * Gets the API Version which this App depends on (http://semver.org/). + * This property is used for the dependency injections. + * + * @return {string} the required api version + */ + getRequiredApiVersion(): string; + + /** + * Gets the information regarding the author/maintainer of this App. + * + * @return author information + */ + getAuthorInfo(): IAppAuthorInfo; + + /** + * Gets the entirity of the App's information. + * + * @return App information + */ + getInfo(): IAppInfo; + + /** + * Gets the ILogger instance for this App. + * + * @return the logger instance + */ + getLogger(): ILogger; + + getAccessors(): IAppAccessors; +} diff --git a/packages/apps-engine/src/definition/LICENSE b/packages/apps-engine/src/definition/LICENSE new file mode 100644 index 000000000000..42ea81dc8cdb --- /dev/null +++ b/packages/apps-engine/src/definition/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2015-2023 Rocket.Chat Technologies Corp. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/apps-engine/src/definition/accessors/IApiExtend.ts b/packages/apps-engine/src/definition/accessors/IApiExtend.ts new file mode 100644 index 000000000000..ab3210105e9c --- /dev/null +++ b/packages/apps-engine/src/definition/accessors/IApiExtend.ts @@ -0,0 +1,16 @@ +import type { IApi } from '../api'; + +/** + * This accessor provides methods for adding a custom api. + * It is provided during the initialization of your App + */ + +export interface IApiExtend { + /** + * Adds an api which can be called by external services lateron. + * Should an api already exists an error will be thrown. + * + * @param api the command information + */ + provideApi(api: IApi): Promise; +} diff --git a/packages/apps-engine/src/definition/accessors/IAppAccessors.ts b/packages/apps-engine/src/definition/accessors/IAppAccessors.ts new file mode 100644 index 000000000000..c2ea3bfcacea --- /dev/null +++ b/packages/apps-engine/src/definition/accessors/IAppAccessors.ts @@ -0,0 +1,11 @@ +import type { IEnvironmentRead, IHttp, IRead } from '.'; +import type { IApiEndpointMetadata } from '../api'; +import type { IEnvironmentWrite } from './IEnvironmentWrite'; + +export interface IAppAccessors { + readonly environmentReader: IEnvironmentRead; + readonly environmentWriter: IEnvironmentWrite; + readonly reader: IRead; + readonly http: IHttp; + readonly providedApiEndpoints: Array; +} diff --git a/packages/apps-engine/src/definition/accessors/IAppInstallationContext.ts b/packages/apps-engine/src/definition/accessors/IAppInstallationContext.ts new file mode 100644 index 000000000000..0ca1c08ba0dc --- /dev/null +++ b/packages/apps-engine/src/definition/accessors/IAppInstallationContext.ts @@ -0,0 +1,5 @@ +import type { IUser } from '../users'; + +export interface IAppInstallationContext { + user: IUser; +} diff --git a/packages/apps-engine/src/definition/accessors/IAppUninstallationContext.ts b/packages/apps-engine/src/definition/accessors/IAppUninstallationContext.ts new file mode 100644 index 000000000000..96ddbfa03298 --- /dev/null +++ b/packages/apps-engine/src/definition/accessors/IAppUninstallationContext.ts @@ -0,0 +1,5 @@ +import type { IUser } from '../users'; + +export interface IAppUninstallationContext { + user: IUser; +} diff --git a/packages/apps-engine/src/definition/accessors/IAppUpdateContext.ts b/packages/apps-engine/src/definition/accessors/IAppUpdateContext.ts new file mode 100644 index 000000000000..d0bcf7ea280b --- /dev/null +++ b/packages/apps-engine/src/definition/accessors/IAppUpdateContext.ts @@ -0,0 +1,6 @@ +import type { IUser } from '../users'; + +export interface IAppUpdateContext { + user?: IUser; + oldAppVersion: string; +} diff --git a/packages/apps-engine/src/definition/accessors/ICloudWorkspaceRead.ts b/packages/apps-engine/src/definition/accessors/ICloudWorkspaceRead.ts new file mode 100644 index 000000000000..c78749fae59b --- /dev/null +++ b/packages/apps-engine/src/definition/accessors/ICloudWorkspaceRead.ts @@ -0,0 +1,24 @@ +import type { IWorkspaceToken } from '../cloud/IWorkspaceToken'; + +/** + * Accessor that enables apps to read information + * related to the Cloud connectivity of the workspace. + * + * Methods in this accessor will usually connect to the + * Rocket.Chat Cloud, which means they won't work properly + * in air-gapped environment. + * + * This accessor available via `IRead` object, which is + * usually received as a parameter wherever it's available. + */ +export interface ICloudWorkspaceRead { + /** + * Returns an access token that can be used to access + * Cloud Services on the workspace's behalf. + * + * @param scope The scope that the token should be authorized with + * + * @RequiresPermission cloud.workspace-token; scopes: Array + */ + getWorkspaceToken(scope: string): Promise; +} diff --git a/packages/apps-engine/src/definition/accessors/IConfigurationExtend.ts b/packages/apps-engine/src/definition/accessors/IConfigurationExtend.ts new file mode 100644 index 000000000000..a58f127a0421 --- /dev/null +++ b/packages/apps-engine/src/definition/accessors/IConfigurationExtend.ts @@ -0,0 +1,36 @@ +import type { IApiExtend } from './IApiExtend'; +import type { IExternalComponentsExtend } from './IExternalComponentsExtend'; +import type { IHttpExtend } from './IHttp'; +import type { ISchedulerExtend } from './ISchedulerExtend'; +import type { ISettingsExtend } from './ISettingsExtend'; +import type { ISlashCommandsExtend } from './ISlashCommandsExtend'; +import type { IUIExtend } from './IUIExtend'; +import type { IVideoConfProvidersExtend } from './IVideoConfProvidersExtend'; + +/** + * This accessor provides methods for declaring the configuration + * of your App. It is provided during initialization of your App. + */ +export interface IConfigurationExtend { + /** Accessor for customing the handling of IHttp requests and responses your App causes. */ + readonly http: IHttpExtend; + + /** Accessor for declaring the settings your App provides. */ + readonly settings: ISettingsExtend; + + /** Accessor for declaring the commands which your App provides. */ + readonly slashCommands: ISlashCommandsExtend; + + /** Accessor for declaring api endpoints. */ + readonly api: IApiExtend; + + readonly externalComponents: IExternalComponentsExtend; + + /** Accessor for declaring tasks that can be scheduled (like cron) */ + readonly scheduler: ISchedulerExtend; + /** Accessor for registering different elements in the host UI */ + readonly ui: IUIExtend; + + /** Accessor for declaring the videoconf providers which your App provides. */ + readonly videoConfProviders: IVideoConfProvidersExtend; +} diff --git a/packages/apps-engine/src/definition/accessors/IConfigurationModify.ts b/packages/apps-engine/src/definition/accessors/IConfigurationModify.ts new file mode 100644 index 000000000000..d0f818e2e028 --- /dev/null +++ b/packages/apps-engine/src/definition/accessors/IConfigurationModify.ts @@ -0,0 +1,18 @@ +import type { ISchedulerModify } from './ISchedulerModify'; +import type { IServerSettingsModify } from './IServerSettingsModify'; +import type { ISlashCommandsModify } from './ISlashCommandsModify'; + +/** + * This accessor provides methods for modifying the configuration + * of Rocket.Chat. It is provided during "onEnable" of your App. + */ +export interface IConfigurationModify { + /** Accessor for modifying the settings inside of Rocket.Chat. */ + readonly serverSettings: IServerSettingsModify; + + /** Accessor for modifying the slash commands inside of Rocket.Chat. */ + readonly slashCommands: ISlashCommandsModify; + + /** Accessor for modifying schedulers */ + readonly scheduler: ISchedulerModify; +} diff --git a/packages/apps-engine/src/definition/accessors/IDiscussionBuilder.ts b/packages/apps-engine/src/definition/accessors/IDiscussionBuilder.ts new file mode 100644 index 000000000000..51dc0c2c4f92 --- /dev/null +++ b/packages/apps-engine/src/definition/accessors/IDiscussionBuilder.ts @@ -0,0 +1,25 @@ +import type { IRoomBuilder } from '.'; +import type { IMessage } from '../messages'; +import type { RocketChatAssociationModel } from '../metadata'; +import type { IRoom } from '../rooms'; + +/** + * Interface for building out a room. + * Please note, a room creator, name, and type must be set otherwise you will NOT + * be able to successfully save the room object. + */ +export interface IDiscussionBuilder extends IRoomBuilder { + kind: RocketChatAssociationModel.DISCUSSION; + + setParentRoom(parentRoom: IRoom): IDiscussionBuilder; + + getParentRoom(): IRoom; + + setParentMessage(parentMessage: IMessage): IDiscussionBuilder; + + getParentMessage(): IMessage; + + setReply(reply: string): IDiscussionBuilder; + + getReply(): string; +} diff --git a/packages/apps-engine/src/definition/accessors/IEmailCreator.ts b/packages/apps-engine/src/definition/accessors/IEmailCreator.ts new file mode 100644 index 000000000000..d5d051bc2dff --- /dev/null +++ b/packages/apps-engine/src/definition/accessors/IEmailCreator.ts @@ -0,0 +1,10 @@ +import type { IEmail } from '../email'; + +export interface IEmailCreator { + /** + * Sends an email through Rocket.Chat + * + * @param email the email data + */ + send(email: IEmail): Promise; +} diff --git a/packages/apps-engine/src/definition/accessors/IEnvironmentRead.ts b/packages/apps-engine/src/definition/accessors/IEnvironmentRead.ts new file mode 100644 index 000000000000..81b50bee77c4 --- /dev/null +++ b/packages/apps-engine/src/definition/accessors/IEnvironmentRead.ts @@ -0,0 +1,27 @@ +import type { IEnvironmentalVariableRead } from './IEnvironmentalVariableRead'; +import type { IServerSettingRead } from './IServerSettingRead'; +import type { ISettingRead } from './ISettingRead'; + +/** + * Allows read-access to the App's settings, + * the certain server's settings along with environmental + * variables all of which are not user created. + */ +export interface IEnvironmentRead { + /** Gets an instance of the App's settings reader. */ + getSettings(): ISettingRead; + + /** + * Gets an instance of the Server's Settings reader. + * Please note: Due to security concerns, only a subset of settings + * are accessible. + */ + getServerSettings(): IServerSettingRead; + + /** + * Gets an instance of the Environmental Variables reader. + * Please note: Due to security concerns, only a subset of + * them are readable. + */ + getEnvironmentVariables(): IEnvironmentalVariableRead; +} diff --git a/packages/apps-engine/src/definition/accessors/IEnvironmentWrite.ts b/packages/apps-engine/src/definition/accessors/IEnvironmentWrite.ts new file mode 100644 index 000000000000..ab1869ab67cb --- /dev/null +++ b/packages/apps-engine/src/definition/accessors/IEnvironmentWrite.ts @@ -0,0 +1,10 @@ +import type { IServerSettingUpdater } from './IServerSettingUpdater'; +import type { ISettingUpdater } from './ISettingUpdater'; + +/** + * Allows write-access to the App's settings, + */ +export interface IEnvironmentWrite { + getSettings(): ISettingUpdater; + getServerSettings(): IServerSettingUpdater; +} diff --git a/packages/apps-engine/src/definition/accessors/IEnvironmentalVariableRead.ts b/packages/apps-engine/src/definition/accessors/IEnvironmentalVariableRead.ts new file mode 100644 index 000000000000..3bb77b033e83 --- /dev/null +++ b/packages/apps-engine/src/definition/accessors/IEnvironmentalVariableRead.ts @@ -0,0 +1,11 @@ +/** A reader for reading the Environmental Variables. */ +export interface IEnvironmentalVariableRead { + /** Gets the value for a variable. */ + getValueByName(envVarName: string): Promise; + + /** Checks to see if Apps can access the given variable name. */ + isReadable(envVarName: string): Promise; + + /** Checks to see if any value is set for the given variable name. */ + isSet(envVarName: string): Promise; +} diff --git a/packages/apps-engine/src/definition/accessors/IExternalComponentsExtend.ts b/packages/apps-engine/src/definition/accessors/IExternalComponentsExtend.ts new file mode 100644 index 000000000000..6a3dc781056f --- /dev/null +++ b/packages/apps-engine/src/definition/accessors/IExternalComponentsExtend.ts @@ -0,0 +1,17 @@ +import type { IExternalComponent } from '../externalComponent'; + +/** + * This accessor provides a method for registering external + * components. This is provided during the initialization of your App. + */ +export interface IExternalComponentsExtend { + /** + * Register an external component to the system. + * If you call this method twice and the component + * has the same name as before, the first one will be + * overwritten as the names provided **must** be unique. + * + * @param externalComponent the external component to be registered + */ + register(externalComponent: IExternalComponent): Promise; +} diff --git a/packages/apps-engine/src/definition/accessors/IHttp.ts b/packages/apps-engine/src/definition/accessors/IHttp.ts new file mode 100644 index 000000000000..8c5eeb9a4d55 --- /dev/null +++ b/packages/apps-engine/src/definition/accessors/IHttp.ts @@ -0,0 +1,202 @@ +import type { IPersistence } from './IPersistence'; +import type { IRead } from './IRead'; + +/** + * The Http package allows users to call out to an external web service. + * Based off of: https://github.com/meteor-typings/meteor/blob/master/1.4/main.d.ts#L869 + */ +export interface IHttp { + get(url: string, options?: IHttpRequest): Promise; + + post(url: string, options?: IHttpRequest): Promise; + + put(url: string, options?: IHttpRequest): Promise; + + del(url: string, options?: IHttpRequest): Promise; + + patch(url: string, options?: IHttpRequest): Promise; +} + +export enum RequestMethod { + GET = 'get', + POST = 'post', + PUT = 'put', + DELETE = 'delete', + HEAD = 'head', + OPTIONS = 'options', + PATCH = 'patch', +} + +export interface IHttpRequest { + content?: string; + data?: any; + query?: string; + params?: { + [key: string]: string; + }; + auth?: string; + headers?: { + [key: string]: string; + }; + timeout?: number; + /** + * The encoding to be used on response data. + * + * If null, the body is returned as a Buffer. Anything else (including the default value of undefined) + * will be passed as the encoding parameter to toString() (meaning this is effectively 'utf8' by default). + * (Note: if you expect binary data, you should set encoding: null.) + */ + encoding?: string | null; + /** + * if `true`, requires SSL certificates be valid. + * + * Defaul: `true`; + */ + strictSSL?: boolean; + /** + * If `true`, the server certificate is verified against the list of supplied CAs. + * + * Default: `true`. + * + * https://nodejs.org/api/tls.html#tls_tls_connect_options_callback + */ + rejectUnauthorized?: boolean; +} + +export interface IHttpResponse { + url: string; + method: RequestMethod; + statusCode: number; + headers?: { + [key: string]: string; + }; + content?: string; + data?: any; +} + +export interface IHttpExtend { + /** + * A method for providing a single header which is added to every request. + * + * @param key the name of the header + * @param value the header's content + */ + provideDefaultHeader(key: string, value: string): void; + + /** + * A method for providing more than one header which are added to every request. + * + * @param headers an object with strings as the keys (header name) and strings as values (header content) + */ + provideDefaultHeaders(headers: { [key: string]: string }): void; + + /** + * A method for providing a single query parameter which is added to every request. + * + * @param key the name of the query parameter + * @param value the query parameter's content + */ + provideDefaultParam(key: string, value: string): void; + + /** + * A method for providing more than one query parameters which are added to every request. + * + * @param headers an object with strings as the keys (parameter name) and strings as values (parameter content) + */ + provideDefaultParams(params: { [key: string]: string }): void; + + /** + * Method for providing a function which is called before every request is called out to the final destination. + * This can be called more than once which means there can be more than one handler. The order provided is the order called. + * Note: if this handler throws an error when it is executed then the request will be aborted. + * + * @param handler the instance of the IHttpPreRequestHandler + */ + providePreRequestHandler(handler: IHttpPreRequestHandler): void; + + /** + * Method for providing a function which is called after every response is got from the url and before the result is returned. + * This can be called more than once which means there can be more than one handler. The order provided is the order called. + * Note: if this handler throws an error when it is executed then the respone will not be returned + * + * @param handler the instance of the IHttpPreResponseHandler + */ + providePreResponseHandler(handler: IHttpPreResponseHandler): void; + + /** + * A method for getting all of the default headers provided, the value is a readonly and any modifications done will be ignored. + * Please use the provider methods for adding them. + */ + getDefaultHeaders(): Map; + + /** + * A method for getting all of the default parameters provided, the value is a readonly and any modifications done will be ignored. + * Please use the provider methods for adding them. + */ + getDefaultParams(): Map; + + /** + * A method for getting all of the pre-request handlers provided, the value is a readonly and any modifications done will be ignored. + * Please use the provider methods for adding them. + */ + getPreRequestHandlers(): Array; + + /** + * A method for getting all of the pre-response handlers provided, the value is a readonly and any modifications done will be ignored. + * Please use the provider methods for adding them. + */ + getPreResponseHandlers(): Array; +} + +export interface IHttpPreRequestHandler { + executePreHttpRequest(url: string, request: IHttpRequest, read: IRead, persistence: IPersistence): Promise; +} + +export interface IHttpPreResponseHandler { + executePreHttpResponse(response: IHttpResponse, read: IRead, persistence: IPersistence): Promise; +} + +export enum HttpStatusCode { + CONTINUE = 100, + SWITCHING_PROTOCOLS = 101, + OK = 200, + CREATED = 201, + ACCEPTED = 202, + NON_AUTHORITATIVE_INFORMATION = 203, + NO_CONTENT = 204, + RESET_CONTENT = 205, + PARTIAL_CONTENT = 206, + MULTIPLE_CHOICES = 300, + MOVED_PERMANENTLY = 301, + FOUND = 302, + SEE_OTHER = 303, + NOT_MODIFIED = 304, + USE_PROXY = 305, + TEMPORARY_REDIRECT = 307, + BAD_REQUEST = 400, + UNAUTHORIZED = 401, + PAYMENT_REQUIRED = 402, + FORBIDDEN = 403, + NOT_FOUND = 404, + METHOD_NOT_ALLOWED = 405, + NOT_ACCEPTABLE = 406, + PROXY_AUTHENTICATION_REQUIRED = 407, + REQUEST_TIMEOUT = 408, + CONFLICT = 409, + GONE = 410, + LENGTH_REQUIRED = 411, + PRECONDITION_FAILED = 412, + REQUEST_ENTITY_TOO_LARGE = 413, + REQUEST_URI_TOO_LONG = 414, + UNSUPPORTED_MEDIA_TYPE = 415, + REQUESTED_RANGE_NOT_SATISFIABLE = 416, + EXPECTATION_FAILED = 417, + UNPROCESSABLE_ENTITY = 422, + TOO_MANY_REQUESTS = 429, + INTERNAL_SERVER_ERROR = 500, + NOT_IMPLEMENTED = 501, + BAD_GATEWAY = 502, + SERVICE_UNAVAILABLE = 503, + GATEWAY_TIMEOUT = 504, + HTTP_VERSION_NOT_SUPPORTED = 505, +} diff --git a/packages/apps-engine/src/definition/accessors/ILivechatCreator.ts b/packages/apps-engine/src/definition/accessors/ILivechatCreator.ts new file mode 100644 index 000000000000..56a3ec17ec27 --- /dev/null +++ b/packages/apps-engine/src/definition/accessors/ILivechatCreator.ts @@ -0,0 +1,43 @@ +import type { ILivechatRoom, IVisitor } from '../livechat'; +import type { IUser } from '../users'; + +export interface IExtraRoomParams { + source?: ILivechatRoom['source']; + customFields?: { + [key: string]: unknown; + }; +} + +export interface ILivechatCreator { + /** + * Creates a room to connect the `visitor` to an `agent`. + * + * This method uses the Livechat routing method configured + * in the server + * + * @param visitor The Livechat Visitor that started the conversation + * @param agent The agent responsible for the room + */ + createRoom(visitor: IVisitor, agent: IUser, extraParams?: IExtraRoomParams): Promise; + + /** + * @deprecated Use `createAndReturnVisitor` instead. + * Creates a Livechat visitor + * + * @param visitor Data of the visitor to be created + */ + createVisitor(visitor: IVisitor): Promise; + + /** + * Creates a Livechat visitor + * + * @param visitor Data of the visitor to be created + */ + createAndReturnVisitor(visitor: IVisitor): Promise; + + /** + * Creates a token to be used when + * creating a new livechat visitor + */ + createToken(): string; +} diff --git a/packages/apps-engine/src/definition/accessors/ILivechatMessageBuilder.ts b/packages/apps-engine/src/definition/accessors/ILivechatMessageBuilder.ts new file mode 100644 index 000000000000..c36b078f53a6 --- /dev/null +++ b/packages/apps-engine/src/definition/accessors/ILivechatMessageBuilder.ts @@ -0,0 +1,219 @@ +import type { ILivechatMessage, IVisitor } from '../livechat'; +import type { IMessageAttachment } from '../messages'; +import type { RocketChatAssociationModel } from '../metadata'; +import type { IRoom } from '../rooms'; +import type { IUser } from '../users'; +import type { IMessageBuilder } from './IMessageBuilder'; + +/** + * Interface for building out a livechat message. + * Please note, that a room and sender must be associated otherwise you will NOT + * be able to successfully save the message object. + */ +export interface ILivechatMessageBuilder { + kind: RocketChatAssociationModel.LIVECHAT_MESSAGE; + + /** + * Provides a convient way to set the data for the message. + * Note: Providing an "id" field here will be ignored. + * + * @param message the message data to set + */ + setData(message: ILivechatMessage): ILivechatMessageBuilder; + + /** + * Sets the room where this message should be sent to. + * + * @param room the room where to send + */ + setRoom(room: IRoom): ILivechatMessageBuilder; + + /** + * Gets the room where this message was sent to. + */ + getRoom(): IRoom; + + /** + * Sets the sender of this message. + * + * @param sender the user sending the message + */ + setSender(sender: IUser): ILivechatMessageBuilder; + + /** + * Gets the User which sent the message. + */ + getSender(): IUser; + + /** + * Sets the text of the message. + * + * @param text the actual text + */ + setText(text: string): ILivechatMessageBuilder; + + /** + * Gets the message text. + */ + getText(): string; + + /** + * Sets the emoji to use for the avatar, this overwrites the current avatar + * whether it be the user's or the avatar url provided. + * + * @param emoji the emoji code + */ + setEmojiAvatar(emoji: string): ILivechatMessageBuilder; + + /** + * Gets the emoji used for the avatar. + */ + getEmojiAvatar(): string; + + /** + * Sets the url which to display for the avatar, this overwrites the current + * avatar whether it be the user's or an emoji one. + * + * @param avatarUrl image url to use as the avatar + */ + setAvatarUrl(avatarUrl: string): ILivechatMessageBuilder; + + /** + * Gets the url used for the avatar. + */ + getAvatarUrl(): string; + + /** + * Sets the display text of the sender's username that is visible. + * + * @param alias the username alias to display + */ + setUsernameAlias(alias: string): ILivechatMessageBuilder; + + /** + * Gets the display text of the sender's username that is visible. + */ + getUsernameAlias(): string; + + /** + * Adds one attachment to the message's list of attachments, this will not + * overwrite any existing ones but just adds. + * + * @param attachment the attachment to add + */ + addAttachment(attachment: IMessageAttachment): ILivechatMessageBuilder; + + /** + * Sets the attachments for the message, replacing and destroying all of the current attachments. + * + * @param attachments array of the attachments + */ + setAttachments(attachments: Array): ILivechatMessageBuilder; + + /** + * Gets the attachments array for the message + */ + getAttachments(): Array; + + /** + * Replaces an attachment at the given position (index). + * If there is no attachment at that position, there will be an error thrown. + * + * @param position the index of the attachment to replace + * @param attachment the attachment to replace with + */ + replaceAttachment(position: number, attachment: IMessageAttachment): ILivechatMessageBuilder; + + /** + * Removes an attachment at the given position (index). + * If there is no attachment at that position, there will be an error thrown. + * + * @param position the index of the attachment to remove + */ + removeAttachment(position: number): ILivechatMessageBuilder; + + /** + * Sets the user who is editing this message. + * This is required if you are modifying an existing message. + * + * @param user the editor + */ + setEditor(user: IUser): ILivechatMessageBuilder; + + /** + * Gets the user who edited the message + */ + getEditor(): IUser; + + /** + * Sets whether this message can group with others. + * This is desirable if you want to avoid confusion with other integrations. + * + * @param groupable whether this message can group with others + */ + setGroupable(groupable: boolean): ILivechatMessageBuilder; + + /** + * Gets whether this message can group with others. + */ + getGroupable(): boolean; + + /** + * Sets whether this message should have any URLs in the text + * parsed by Rocket.Chat and get the details added to the message's + * attachments. + * + * @param parseUrls whether URLs should be parsed in this message + */ + setParseUrls(parseUrls: boolean): ILivechatMessageBuilder; + + /** + * Gets whether this message should have its URLs parsed + */ + getParseUrls(): boolean; + + /** + * Set the token of the livechat visitor that + * sent the message + * + * @param token The Livechat visitor's token + */ + setToken(token: string): ILivechatMessageBuilder; + + /** + * Gets the token of the livechat visitor that + * sent the message + */ + getToken(): string; + + /** + * If the sender of the message is a Livechat Visitor, + * set the visitor who sent the message. + * + * If you set the visitor property of a message, the + * sender will be emptied + * + * @param visitor The visitor who sent the message + */ + setVisitor(visitor: IVisitor): ILivechatMessageBuilder; + + /** + * Get the visitor who sent the message, + * if any + */ + getVisitor(): IVisitor; + + /** + * Gets the resulting message that has been built up to the point of calling it. + * + * *Note:* This will error out if the Room has not been defined OR if the room + * is not of type RoomType.LIVE_CHAT. + */ + getMessage(): ILivechatMessage; + + /** + * Returns a message builder based on the + * livechat message of this builder + */ + getMessageBuilder(): IMessageBuilder; +} diff --git a/packages/apps-engine/src/definition/accessors/ILivechatRead.ts b/packages/apps-engine/src/definition/accessors/ILivechatRead.ts new file mode 100644 index 000000000000..a756d162c337 --- /dev/null +++ b/packages/apps-engine/src/definition/accessors/ILivechatRead.ts @@ -0,0 +1,38 @@ +import type { IDepartment } from '../livechat'; +import type { ILivechatRoom } from '../livechat/ILivechatRoom'; +import type { IVisitor } from '../livechat/IVisitor'; +import type { IMessage } from '../messages'; + +export interface ILivechatRead { + /** + * Gets online status of the livechat. + * @param departmentId (optional) the id of the livechat department + * @deprecated use `isOnlineAsync` instead + */ + isOnline(departmentId?: string): boolean; + /** + * Gets online status of the livechat. + * @param departmentId (optional) the id of the livechat department + */ + isOnlineAsync(departmentId?: string): Promise; + getDepartmentsEnabledWithAgents(): Promise>; + getLivechatRooms(visitor: IVisitor, departmentId?: string): Promise>; + getLivechatOpenRoomsByAgentId(agentId: string): Promise>; + getLivechatTotalOpenRoomsByAgentId(agentId: string): Promise; + /** + * @deprecated This method does not adhere to the conversion practices applied + * elsewhere in the Apps-Engine and will be removed in the next major version. + * Prefer the alternative methods to fetch visitors. + */ + getLivechatVisitors(query: object): Promise>; + getLivechatVisitorById(id: string): Promise; + getLivechatVisitorByEmail(email: string): Promise; + getLivechatVisitorByToken(token: string): Promise; + getLivechatVisitorByPhoneNumber(phoneNumber: string): Promise; + getLivechatDepartmentByIdOrName(value: string): Promise; + /** + * @experimental we do not encourage the wider usage of this method, + * as we're evaluating its performance and fit for the API. + */ + _fetchLivechatRoomMessages(roomId: string): Promise>; +} diff --git a/packages/apps-engine/src/definition/accessors/ILivechatUpdater.ts b/packages/apps-engine/src/definition/accessors/ILivechatUpdater.ts new file mode 100644 index 000000000000..fb75cf9ecf3b --- /dev/null +++ b/packages/apps-engine/src/definition/accessors/ILivechatUpdater.ts @@ -0,0 +1,33 @@ +import type { ILivechatTransferData, IVisitor } from '../livechat'; +import type { IRoom } from '../rooms'; +import type { IUser } from '../users'; + +export interface ILivechatUpdater { + /** + * Transfer a Livechat visitor to another room + * + * @param visitor Visitor to be transferred + * @param transferData The data to execute the transferring + */ + transferVisitor(visitor: IVisitor, transferData: ILivechatTransferData): Promise; + + /** + * Closes a Livechat room + * + * @param room The room to be closed + * @param comment The comment explaining the reason for closing the room + * @param closer The user that closes the room + */ + closeRoom(room: IRoom, comment: string, closer?: IUser): Promise; + + /** + * Set a livechat visitor's custom fields by its token + * @param token The visitor's token + * @param key The key in the custom fields + * @param value The value to be set + * @param overwrite Whether overwrite or not + * + * @returns Promise to whether success or not + */ + setCustomFields(token: IVisitor['token'], key: string, value: string, overwrite: boolean): Promise; +} diff --git a/packages/apps-engine/src/definition/accessors/ILogEntry.ts b/packages/apps-engine/src/definition/accessors/ILogEntry.ts new file mode 100644 index 000000000000..4dc46693b6d9 --- /dev/null +++ b/packages/apps-engine/src/definition/accessors/ILogEntry.ts @@ -0,0 +1,22 @@ +export enum LogMessageSeverity { + DEBUG = 'debug', + INFORMATION = 'info', + LOG = 'log', + WARNING = 'warning', + ERROR = 'error', + SUCCESS = 'success', +} + +/** + * Message which will be passed to a UI (either in a log or in the application's UI) + */ +export interface ILogEntry { + /** The function name who did this logging, this is automatically added (can be null). */ + caller?: string; + /** The severity rate, this is automatically added. */ + severity: LogMessageSeverity; + /** When this entry was made. */ + timestamp: Date; + /** The items which were logged. */ + args: Array; +} diff --git a/packages/apps-engine/src/definition/accessors/ILogger.ts b/packages/apps-engine/src/definition/accessors/ILogger.ts new file mode 100644 index 000000000000..eac6e531802d --- /dev/null +++ b/packages/apps-engine/src/definition/accessors/ILogger.ts @@ -0,0 +1,29 @@ +import type { AppMethod } from '../metadata/AppMethod'; +import type { ILogEntry } from './ILogEntry'; + +/** + * This logger provides a way to log various levels to the entire system. + * When used, the items passed in will be logged to the database. This will + * allow people to easily see what happened (users) or debug what went wrong. + */ +export interface ILogger { + method: `${AppMethod}`; + + debug(...items: Array): void; + info(...items: Array): void; + log(...items: Array): void; + warn(...items: Array): void; + error(...items: Array): void; + success(...items: Array): void; + + /** Gets the entries logged. */ + getEntries(): Array; + /** Gets the method which this logger is for. */ + getMethod(): `${AppMethod}`; + /** Gets when this logger was constructed. */ + getStartTime(): Date; + /** Gets the end time, usually Date.now(). */ + getEndTime(): Date; + /** Gets the amount of time this was a logger, start - Date.now(). */ + getTotalTime(): number; +} diff --git a/packages/apps-engine/src/definition/accessors/IMessageBuilder.ts b/packages/apps-engine/src/definition/accessors/IMessageBuilder.ts new file mode 100644 index 000000000000..024a4b123ff8 --- /dev/null +++ b/packages/apps-engine/src/definition/accessors/IMessageBuilder.ts @@ -0,0 +1,236 @@ +import type { LayoutBlock } from '@rocket.chat/ui-kit'; + +import type { IMessage, IMessageAttachment } from '../messages'; +import type { RocketChatAssociationModel } from '../metadata'; +import type { IRoom } from '../rooms'; +import type { BlockBuilder, IBlock } from '../uikit'; +import type { IUser } from '../users'; + +/** + * Interface for building out a message. + * Please note, that a room and sender must be associated otherwise you will NOT + * be able to successfully save the message object. + */ +export interface IMessageBuilder { + kind: RocketChatAssociationModel.MESSAGE; + + /** + * Provides a convenient way to set the data for the message. + * Note: Providing an "id" field here will be ignored. + * + * @param message the message data to set + */ + setData(message: IMessage): IMessageBuilder; + + /** + * Provides a convenient way to set the data for the message + * keeping the "id" field so as to update the message later. + * + * @param message the message data to set + * @param editor the user who edited the updated message + */ + setUpdateData(message: IMessage, editor: IUser): IMessageBuilder; + + /** + * Sets the thread to which this message belongs, if any. + * + * @param threadId The id of the thread + */ + setThreadId(threadId: string): IMessageBuilder; + + /** + * Retrieves the threadId to which this message belongs, + * if any. + * + * If you would like to retrieve the actual message that + * the thread originated from, you can use the + * `IMessageRead.getById()` method + */ + getThreadId(): string; + + /** + * Sets the room where this message should be sent to. + * + * @param room the room where to send + */ + setRoom(room: IRoom): IMessageBuilder; + + /** + * Gets the room where this message was sent to. + */ + getRoom(): IRoom; + + /** + * Sets the sender of this message. + * + * @param sender the user sending the message + */ + setSender(sender: IUser): IMessageBuilder; + + /** + * Gets the User which sent the message. + */ + getSender(): IUser; + + /** + * Sets the text of the message. + * + * @param text the actual text + */ + setText(text: string): IMessageBuilder; + + /** + * Gets the message text. + */ + getText(): string; + + /** + * Sets the emoji to use for the avatar, this overwrites the current avatar + * whether it be the user's or the avatar url provided. + * + * @param emoji the emoji code + */ + setEmojiAvatar(emoji: string): IMessageBuilder; + + /** + * Gets the emoji used for the avatar. + */ + getEmojiAvatar(): string; + + /** + * Sets the url which to display for the avatar, this overwrites the current + * avatar whether it be the user's or an emoji one. + * + * @param avatarUrl image url to use as the avatar + */ + setAvatarUrl(avatarUrl: string): IMessageBuilder; + + /** + * Gets the url used for the avatar. + */ + getAvatarUrl(): string; + + /** + * Sets the display text of the sender's username that is visible. + * + * @param alias the username alias to display + */ + setUsernameAlias(alias: string): IMessageBuilder; + + /** + * Gets the display text of the sender's username that is visible. + */ + getUsernameAlias(): string; + + /** + * Adds one attachment to the message's list of attachments, this will not + * overwrite any existing ones but just adds. + * + * @param attachment the attachment to add + */ + addAttachment(attachment: IMessageAttachment): IMessageBuilder; + + /** + * Sets the attachments for the message, replacing and destroying all of the current attachments. + * + * @param attachments array of the attachments + */ + setAttachments(attachments: Array): IMessageBuilder; + + /** + * Gets the attachments array for the message + */ + getAttachments(): Array; + + /** + * Replaces an attachment at the given position (index). + * If there is no attachment at that position, there will be an error thrown. + * + * @param position the index of the attachment to replace + * @param attachment the attachment to replace with + */ + replaceAttachment(position: number, attachment: IMessageAttachment): IMessageBuilder; + + /** + * Removes an attachment at the given position (index). + * If there is no attachment at that position, there will be an error thrown. + * + * @param position the index of the attachment to remove + */ + removeAttachment(position: number): IMessageBuilder; + + /** + * Sets the user who is editing this message. + * This is required if you are modifying an existing message. + * + * @param user the editor + */ + setEditor(user: IUser): IMessageBuilder; + + /** + * Gets the user who edited the message + */ + getEditor(): IUser; + + /** + * Sets whether this message can group with others. + * This is desirable if you want to avoid confusion with other integrations. + * + * @param groupable whether this message can group with others + */ + setGroupable(groupable: boolean): IMessageBuilder; + + /** + * Gets whether this message can group with others. + */ + getGroupable(): boolean; + + /** + * Sets whether this message should have any URLs in the text + * parsed by Rocket.Chat and get the details added to the message's + * attachments. + * + * @param parseUrls whether URLs should be parsed in this message + */ + setParseUrls(parseUrls: boolean): IMessageBuilder; + + /** + * Gets whether this message should have its URLs parsed + */ + getParseUrls(): boolean; + + /** + * Gets the resulting message that has been built up to the point of calling it. + * + * *Note:* This will error out if the Room has not been defined. + */ + getMessage(): IMessage; + + /** + * Adds a block collection to the message's + * own collection + */ + addBlocks(blocks: BlockBuilder | Array): IMessageBuilder; + + /** + * Sets the block collection of the message + * + * @param blocks + */ + setBlocks(blocks: BlockBuilder | Array): IMessageBuilder; + + /** + * Gets the block collection of the message + */ + getBlocks(): Array; + + /** + * Adds a custom field to the message. + * Note: This key can not already exist or it will throw an error. + * Note: The key must not contain a period in it, an error will be thrown. + * + * @param key the name of the custom field + * @param value the value of this custom field + */ + addCustomField(key: string, value: any): IMessageBuilder; +} diff --git a/packages/apps-engine/src/definition/accessors/IMessageExtender.ts b/packages/apps-engine/src/definition/accessors/IMessageExtender.ts new file mode 100644 index 000000000000..7db010bae081 --- /dev/null +++ b/packages/apps-engine/src/definition/accessors/IMessageExtender.ts @@ -0,0 +1,36 @@ +import type { IMessage, IMessageAttachment } from '../messages'; +import type { RocketChatAssociationModel } from '../metadata'; + +export interface IMessageExtender { + kind: RocketChatAssociationModel.MESSAGE; + + /** + * Adds a custom field to the message. + * Note: This key can not already exist or it will throw an error. + * Note: The key must not contain a period in it, an error will be thrown. + * + * @param key the name of the custom field + * @param value the value of this custom field + */ + addCustomField(key: string, value: any): IMessageExtender; + + /** + * Adds a single attachment to the message. + * + * @param attachment the item to add + */ + addAttachment(attachment: IMessageAttachment): IMessageExtender; + + /** + * Adds all of the provided attachments to the message. + * + * @param attachments an array of attachments + */ + addAttachments(attachments: Array): IMessageExtender; + + /** + * Gets the resulting message that has been extended at the point of calling it. + * Note: modifying the returned value will have no effect. + */ + getMessage(): IMessage; +} diff --git a/packages/apps-engine/src/definition/accessors/IMessageRead.ts b/packages/apps-engine/src/definition/accessors/IMessageRead.ts new file mode 100644 index 000000000000..10c99d26388b --- /dev/null +++ b/packages/apps-engine/src/definition/accessors/IMessageRead.ts @@ -0,0 +1,15 @@ +import type { IMessage } from '../messages/index'; +import type { IRoom } from '../rooms/IRoom'; +import type { IUser } from '../users/IUser'; + +/** + * This accessor provides methods for accessing + * messages in a read-only-fashion. + */ +export interface IMessageRead { + getById(id: string): Promise; + + getSenderUser(messageId: string): Promise; + + getRoom(messageId: string): Promise; +} diff --git a/packages/apps-engine/src/definition/accessors/IMessageUpdater.ts b/packages/apps-engine/src/definition/accessors/IMessageUpdater.ts new file mode 100644 index 000000000000..b21baacae04f --- /dev/null +++ b/packages/apps-engine/src/definition/accessors/IMessageUpdater.ts @@ -0,0 +1,21 @@ +import type { Reaction } from '../messages'; + +export interface IMessageUpdater { + /** + * Add a reaction to a message + * + * @param messageId the id of the message + * @param userId the id of the user + * @param reaction the reaction + */ + addReaction(messageId: string, userId: string, reaction: Reaction): Promise; + + /** + * Remove a reaction from a message + * + * @param messageId the id of the message + * @param userId the id of the user + * @param reaction the reaction + */ + removeReaction(messageId: string, userId: string, reaction: Reaction): Promise; +} diff --git a/packages/apps-engine/src/definition/accessors/IModerationModify.ts b/packages/apps-engine/src/definition/accessors/IModerationModify.ts new file mode 100644 index 000000000000..6b0f54968a04 --- /dev/null +++ b/packages/apps-engine/src/definition/accessors/IModerationModify.ts @@ -0,0 +1,27 @@ +import type { IMessage } from '../messages'; +import type { IUser } from '../users'; + +export interface IModerationModify { + /** + * Provides a way for Apps to report a message. + * @param messageId the messageId to report + * @param description the description of the report + * @param userId the userId to be reported + * @param appId the app id + */ + report(messageId: string, description: string, userId: string, appId: string): Promise; + + /** + * Provides a way for Apps to dismiss reports by message id. + * @param messageId the messageId to dismiss reports + * @param appId the app id + */ + dismissReportsByMessageId(messageId: IMessage['id'], reason: string, action: string, appId: string): Promise; + + /** + * Provides a way for Apps to dismiss reports by user id. + * @param userId the userId to dismiss reports + * @param appId the app id + */ + dismissReportsByUserId(userId: IUser['id'], reason: string, action: string, appId: string): Promise; +} diff --git a/packages/apps-engine/src/definition/accessors/IModify.ts b/packages/apps-engine/src/definition/accessors/IModify.ts new file mode 100644 index 000000000000..e76e4cc76824 --- /dev/null +++ b/packages/apps-engine/src/definition/accessors/IModify.ts @@ -0,0 +1,45 @@ +import type { IModerationModify } from './IModerationModify'; +import type { IModifyCreator } from './IModifyCreator'; +import type { IModifyDeleter } from './IModifyDeleter'; +import type { IModifyExtender } from './IModifyExtender'; +import type { IModifyUpdater } from './IModifyUpdater'; +import type { INotifier } from './INotifier'; +import type { IOAuthAppsModify } from './IOAuthAppsModify'; +import type { ISchedulerModify } from './ISchedulerModify'; +import type { IUIController } from './IUIController'; + +export interface IModify { + getCreator(): IModifyCreator; + + getDeleter(): IModifyDeleter; + + getExtender(): IModifyExtender; + + getUpdater(): IModifyUpdater; + + /** + * Gets the accessor for sending notifications to a user or users in a room. + * + * @returns the notifier accessor + */ + getNotifier(): INotifier; + /** + * Gets the accessor for interacting with the UI + */ + getUiController(): IUIController; + + /** + * Gets the accessor for creating scheduled jobs + */ + getScheduler(): ISchedulerModify; + + /** + * Gets the accessor for creating OAuth apps + */ + getOAuthAppsModifier(): IOAuthAppsModify; + /** + * Gets the accessor for modifying moderation + * @returns the moderation accessor + */ + getModerationModifier(): IModerationModify; +} diff --git a/packages/apps-engine/src/definition/accessors/IModifyCreator.ts b/packages/apps-engine/src/definition/accessors/IModifyCreator.ts new file mode 100644 index 000000000000..6c2acd50493c --- /dev/null +++ b/packages/apps-engine/src/definition/accessors/IModifyCreator.ts @@ -0,0 +1,100 @@ +import type { ILivechatMessage } from '../livechat'; +import type { IMessage } from '../messages'; +import type { IRoom } from '../rooms'; +import type { BlockBuilder } from '../uikit'; +import type { IBotUser } from '../users/IBotUser'; +import type { AppVideoConference } from '../videoConferences'; +import type { IDiscussionBuilder } from './IDiscussionBuilder'; +import type { IEmailCreator } from './IEmailCreator'; +import type { ILivechatCreator } from './ILivechatCreator'; +import type { ILivechatMessageBuilder } from './ILivechatMessageBuilder'; +import type { IMessageBuilder } from './IMessageBuilder'; +import type { IRoomBuilder } from './IRoomBuilder'; +import type { IUploadCreator } from './IUploadCreator'; +import type { IUserBuilder } from './IUserBuilder'; +import type { IVideoConferenceBuilder } from './IVideoConferenceBuilder'; + +export interface IModifyCreator { + /** + * Get the creator object responsible for the + * Livechat integrations + */ + getLivechatCreator(): ILivechatCreator; + + /** + * Get the creator object responsible for the upload. + */ + getUploadCreator(): IUploadCreator; + + /** + * Gets the creator object responsible for email sending + */ + getEmailCreator(): IEmailCreator; + + /** + * @deprecated please prefer the rocket.chat/ui-kit components + * + * Gets a new instance of a BlockBuilder + */ + getBlockBuilder(): BlockBuilder; + /** + * Starts the process for building a new message object. + * + * @param data (optional) the initial data to pass into the builder, + * the `id` property will be ignored + * @return an IMessageBuilder instance + */ + startMessage(data?: IMessage): IMessageBuilder; + + /** + * Starts the process for building a new livechat message object. + * + * @param data (optional) the initial data to pass into the builder, + * the `id` property will be ignored + * @return an IMessageBuilder instance + */ + startLivechatMessage(data?: ILivechatMessage): ILivechatMessageBuilder; + + /** + * Starts the process for building a new room. + * + * @param data (optional) the initial data to pass into the builder, + * the `id` property will be ignored + * @return an IRoomBuilder instance + */ + startRoom(data?: IRoom): IRoomBuilder; + + /** + * Starts the process for building a new discussion. + * + * @param data (optional) the initial data to pass into the builder, + * the `id` property will be ignored + * @return an IDiscussionBuilder instance + */ + startDiscussion(data?: Partial): IDiscussionBuilder; + + /** + * Starts the process for building a new video conference. + * + * @param data (optional) the initial data to pass into the builder, + * @return an IVideoConferenceBuilder instance + */ + startVideoConference(data?: Partial): IVideoConferenceBuilder; + + /** + * Starts the process for building a new bot user. + * + * @param data (optional) the initial data to pass into the builder, + * the `id` property will be ignored + * @return an IUserBuilder instance + */ + startBotUser(data?: Partial): IUserBuilder; + + /** + * Finishes the creating process, saving the object to the database. + * + * @param builder the builder instance + * @return the resulting `id` of the resulting object + */ + finish(builder: IMessageBuilder | ILivechatMessageBuilder | IRoomBuilder | IDiscussionBuilder | IVideoConferenceBuilder | IUserBuilder): Promise; +} diff --git a/packages/apps-engine/src/definition/accessors/IModifyDeleter.ts b/packages/apps-engine/src/definition/accessors/IModifyDeleter.ts new file mode 100644 index 000000000000..7d1103ba13f3 --- /dev/null +++ b/packages/apps-engine/src/definition/accessors/IModifyDeleter.ts @@ -0,0 +1,12 @@ +import type { IMessage } from '../messages'; +import type { IUser, UserType } from '../users'; + +export interface IModifyDeleter { + deleteRoom(roomId: string): Promise; + + deleteUsers(appId: Exclude, userType: UserType.APP | UserType.BOT): Promise; + + deleteMessage(message: IMessage, user: IUser): Promise; + + removeUsersFromRoom(roomId: string, usernames: Array): Promise; +} diff --git a/packages/apps-engine/src/definition/accessors/IModifyExtender.ts b/packages/apps-engine/src/definition/accessors/IModifyExtender.ts new file mode 100644 index 000000000000..786b23975407 --- /dev/null +++ b/packages/apps-engine/src/definition/accessors/IModifyExtender.ts @@ -0,0 +1,40 @@ +import type { IUser } from '../users'; +import type { IMessageExtender } from './IMessageExtender'; +import type { IRoomExtender } from './IRoomExtender'; +import type { IVideoConferenceExtender } from './IVideoConferenceExtend'; + +export interface IModifyExtender { + /** + * Modifies a message in a non-destructive way: Properties can be added to it, + * but existing properties cannot be changed. + * + * @param messageId the id of the message to be extended + * @param updater the user who is updating/extending the message + * @return the extender instance for the message + */ + extendMessage(messageId: string, updater: IUser): Promise; + + /** + * Modifies a room in a non-destructive way: Properties can be added to it, + * but existing properties cannot be changed. + * + * @param roomId the id of the room to be extended + * @param updater the user who is updating/extending the room + * @return the extender instance for the room + */ + extendRoom(roomId: string, updater: IUser): Promise; + + /** + * Modifies a video conference in a non-destructive way: Properties can be added to it, + * but existing properties cannot be changed. + */ + extendVideoConference(id: string): Promise; + + /** + * Finishes the extending process, saving the object to the database. + * Note: If there is an issue or error while updating, this will throw an error. + * + * @param extender the extender instance + */ + finish(extender: IRoomExtender | IMessageExtender | IVideoConferenceExtender): Promise; +} diff --git a/packages/apps-engine/src/definition/accessors/IModifyUpdater.ts b/packages/apps-engine/src/definition/accessors/IModifyUpdater.ts new file mode 100644 index 000000000000..60dcf90b2df7 --- /dev/null +++ b/packages/apps-engine/src/definition/accessors/IModifyUpdater.ts @@ -0,0 +1,52 @@ +import type { IUser } from '../users'; +import type { ILivechatUpdater } from './ILivechatUpdater'; +import type { IMessageBuilder } from './IMessageBuilder'; +import type { IMessageUpdater } from './IMessageUpdater'; +import type { IRoomBuilder } from './IRoomBuilder'; +import type { IUserUpdater } from './IUserUpdater'; + +export interface IModifyUpdater { + /** + * Get the updater object responsible for the + * Livechat integrations + */ + getLivechatUpdater(): ILivechatUpdater; + + /** + * Gets the update object responsible for + * methods that update users + */ + getUserUpdater(): IUserUpdater; + + /** + * Get the updater object responsible for + * methods that update messages + */ + getMessageUpdater(): IMessageUpdater; + + /** + * Modifies an existing message. + * Raises an exception if a non-existent messageId is supplied + * + * @param messageId the id of the existing message to modfiy and build + * @param updater the user who is updating the message + */ + message(messageId: string, updater: IUser): Promise; + + /** + * Modifies an existing room. + * Raises an exception if a non-existent roomId is supplied + * + * @param roomId the id of the existing room to modify and build + * @param updater the user who is updating the room + */ + room(roomId: string, updater: IUser): Promise; + + /** + * Finishes the updating process, saving the object to the database. + * Note: If there is an issue or error while updating, this will throw an error. + * + * @param builder the builder instance + */ + finish(builder: IMessageBuilder | IRoomBuilder): Promise; +} diff --git a/packages/apps-engine/src/definition/accessors/INotifier.ts b/packages/apps-engine/src/definition/accessors/INotifier.ts new file mode 100644 index 000000000000..a41fb22c4ff6 --- /dev/null +++ b/packages/apps-engine/src/definition/accessors/INotifier.ts @@ -0,0 +1,63 @@ +import type { IMessage } from '../messages'; +import type { IRoom } from '../rooms'; +import type { IUser } from '../users'; +import type { IMessageBuilder } from './IMessageBuilder'; + +export enum TypingScope { + Room = 'room', +} + +export interface ITypingOptions { + /** + * The typing scope where the typing message should be presented, + * TypingScope.Room by default. + */ + scope?: TypingScope; + /** + * The id of the typing scope + * + * TypingScope.Room <-> room.id + */ + id: string; + /** + * The name of the user who is typing the message + * + * **Note**: If not provided, it will use app assigned + * user's name by default. + */ + username?: string; +} + +export interface INotifier { + /** + * Notifies the provided user of the provided message. + * + * **Note**: Notifications only are shown to the user if they are + * online and it only stays around for the duration of their session. + * + * @param user The user who should be notified + * @param message The message with the content to notify the user about + */ + notifyUser(user: IUser, message: IMessage): Promise; + + /** + * Notifies all of the users in the provided room. + * + * **Note**: Notifications only are shown to those online + * and it only stays around for the duration of their session. + * + * @param room The room which to notify the users in + * @param message The message content to notify users about + */ + notifyRoom(room: IRoom, message: IMessage): Promise; + + /** + * Notifies all of the users a typing indicator in the provided scope. + * + * @returns a cancellation function to stop typing + */ + typing(options: ITypingOptions): Promise<() => Promise>; + + /** Gets a new message builder for building a notification message. */ + getMessageBuilder(): IMessageBuilder; +} diff --git a/packages/apps-engine/src/definition/accessors/IOAuthApp.ts b/packages/apps-engine/src/definition/accessors/IOAuthApp.ts new file mode 100644 index 000000000000..1c2edbe19c95 --- /dev/null +++ b/packages/apps-engine/src/definition/accessors/IOAuthApp.ts @@ -0,0 +1,13 @@ +export interface IOAuthApp { + id: string; + name: string; + active: boolean; + clientId?: string; + clientSecret?: string; + redirectUri: string; + createdAt?: string; + updatedAt?: string; + createdBy: { username: string; id: string }; +} + +export type IOAuthAppParams = Omit; diff --git a/packages/apps-engine/src/definition/accessors/IOAuthAppsModify.ts b/packages/apps-engine/src/definition/accessors/IOAuthAppsModify.ts new file mode 100644 index 000000000000..72d0a4ddce5a --- /dev/null +++ b/packages/apps-engine/src/definition/accessors/IOAuthAppsModify.ts @@ -0,0 +1,23 @@ +import type { IOAuthAppParams } from './IOAuthApp'; + +export interface IOAuthAppsModify { + /** + * Create an OAuthApp + * @param OAuthApp - the OAuth app to create, in case the clientId and the clientSecret is not sent it will generate automatically + * @param appId - the app id + */ + createOAuthApp(OAuthApp: IOAuthAppParams, appId: string): Promise; + /** + * Update the OAuth app info + * @param OAuthApp - OAuth data that will be updated + * @param id - OAuth app id + * @param appId - the app id + */ + updateOAuthApp(OAuthApp: IOAuthAppParams, id: string, appId: string): Promise; + /** + * Deletes the OAuth app + * @param id - OAuth app id + * @param appId - the app id + */ + deleteOAuthApp(id: string, appId: string): Promise; +} diff --git a/packages/apps-engine/src/definition/accessors/IOAuthAppsReader.ts b/packages/apps-engine/src/definition/accessors/IOAuthAppsReader.ts new file mode 100644 index 000000000000..97d544cd9366 --- /dev/null +++ b/packages/apps-engine/src/definition/accessors/IOAuthAppsReader.ts @@ -0,0 +1,16 @@ +import type { IOAuthApp } from './IOAuthApp'; + +export interface IOAuthAppsReader { + /** + * Returns the OAuth app info by its id + * @param id - OAuth app id + * @param appId - the app id + */ + getOAuthAppById(id: string, appId: string): Promise; + /** + * Returns the OAuth app info by its name + * @param name - OAuth app name + * @param appId - the app id + */ + getOAuthAppByName(name: string, appId: string): Promise>; +} diff --git a/packages/apps-engine/src/definition/accessors/IPersistence.ts b/packages/apps-engine/src/definition/accessors/IPersistence.ts new file mode 100644 index 000000000000..30f1d676539e --- /dev/null +++ b/packages/apps-engine/src/definition/accessors/IPersistence.ts @@ -0,0 +1,97 @@ +import type { RocketChatAssociationRecord } from '../metadata'; + +/** + * Provides an accessor write data to the App's persistent storage. + * A App only has access to its own persistent storage and does not + * have access to any other App's. + */ +export interface IPersistence { + /** + * Creates a new record in the App's persistent storage, returning the resulting "id". + * + * @param data the actual data to store, must be an object otherwise it will error out. + * @return the resulting record's id + */ + create(data: object): Promise; + + /** + * Creates a new record in the App's persistent storage with the associated information + * being provided. + * + * @param data the actual data to store, must be an object otherwise it will error out + * @param association the association data which includes the model and record id + * @return the resulting record's id + */ + createWithAssociation(data: object, association: RocketChatAssociationRecord): Promise; + + /** + * Creates a new record in the App's persistent storage with the data being + * associated with more than one Rocket.Chat record. + * + * @param data the actual data to store, must be an object otherwise it will error out + * @param associations an array of association data which includes the model and record id + * @return the resulting record's id + */ + createWithAssociations(data: object, associations: Array): Promise; + + /** + * Updates an existing record with the data provided in the App's persistent storage. + * This will throw an error if the record doesn't currently exist or if the data is not an object. + * + * @param id the data record's id + * @param data the actual data to store, must be an object otherwise it will error out + * @param upsert whether a record should be created if the id to be updated does not exist + * @return the id of the updated/upserted record + */ + update(id: string, data: object, upsert?: boolean): Promise; + + /** + * Updates an existing record with the data provided in the App's persistent storage which are + * associated with provided information. + * This will throw an error if the record doesn't currently exist or if the data is not an object. + * + * @param association the association record + * @param data the actual data to store, must be an object otherwise it will error out + * @param upsert whether a record should be created if the id to be updated does not exist + * @return the id of the updated/upserted record + */ + updateByAssociation(association: RocketChatAssociationRecord, data: object, upsert?: boolean): Promise; + + /** + * Updates an existing record with the data provided in the App's persistent storage which are + * associated with more than one Rocket.Chat record. + * This will throw an error if the record doesn't currently exist or if the data is not an object. + * + * @param associations an array of association data which includes the model and record id + * @param data the actual data to store, must be an object otherwise it will error out + * @param upsert whether a record should be created if the id to be updated does not exist + * @return the id of the updated/upserted record + */ + updateByAssociations(associations: Array, data: object, upsert?: boolean): Promise; + + /** + * Removes a record by the provided id and returns the removed record. + * + * @param id of the record to remove + * @return the data record which was removed + */ + remove(id: string): Promise; + + /** + * Removes all of the records in persistent storage which are associated with the provided information. + * + * @param association the information about the association for the records to be removed + * @return the data of the removed records + */ + removeByAssociation(association: RocketChatAssociationRecord): Promise>; + + /** + * Removes all of the records in persistent storage which are associated with the provided information. + * More than one association acts like an AND which means a record in persistent storage must have all + * of the associations to be considered a match. + * + * @param associations the information about the associations for the records to be removed + * @return the data of the removed records + */ + removeByAssociations(associations: Array): Promise>; +} diff --git a/packages/apps-engine/src/definition/accessors/IPersistenceRead.ts b/packages/apps-engine/src/definition/accessors/IPersistenceRead.ts new file mode 100644 index 000000000000..d0d1178a44d0 --- /dev/null +++ b/packages/apps-engine/src/definition/accessors/IPersistenceRead.ts @@ -0,0 +1,40 @@ +import type { RocketChatAssociationRecord } from '../metadata'; + +/** + * Provides a read-only accessor for the App's persistent storage. + * A App only has access to its own persistent storage and does not + * have access to any other App's. + */ +export interface IPersistenceRead { + /** + * Retrieves a record from the App's persistent storage by the provided id. + * A "falsey" value (undefined or null or false) is returned should nothing exist + * in the storage by the provided id. + * + * @param id the record to get's id + * @return the record if it exists, falsey if not + */ + read(id: string): Promise; + + /** + * Retrieves a record from the App's persistent storage by the provided id. + * An empty array is returned should there be no records associated with the + * data provided. + * + * @param association the association record to query the persistent storage for + * @return array of the records if any exists, empty array if none exist + */ + readByAssociation(association: RocketChatAssociationRecord): Promise>; + + /** + * Retrieves a record from the App's persistent storage by the provided id. + * Providing more than one association record acts like an AND which means a record + * in persistent storage must have all of the associations to be considered a match. + * An empty array is returned should there be no records associated with the + * data provided. + * + * @param associations the association records to query the persistent storage for + * @return array of the records if any exists, empty array if none exist + */ + readByAssociations(associations: Array): Promise>; +} diff --git a/packages/apps-engine/src/definition/accessors/IRead.ts b/packages/apps-engine/src/definition/accessors/IRead.ts new file mode 100644 index 000000000000..17f66d6218dc --- /dev/null +++ b/packages/apps-engine/src/definition/accessors/IRead.ts @@ -0,0 +1,52 @@ +import type { ICloudWorkspaceRead } from './ICloudWorkspaceRead'; +import type { IEnvironmentRead } from './IEnvironmentRead'; +import type { ILivechatRead } from './ILivechatRead'; +import type { IMessageRead } from './IMessageRead'; +import type { INotifier } from './INotifier'; +import type { IOAuthAppsReader } from './IOAuthAppsReader'; +import type { IPersistenceRead } from './IPersistenceRead'; +import type { IRoleRead } from './IRoleRead'; +import type { IRoomRead } from './IRoomRead'; +import type { IThreadRead } from './IThreadRead'; +import type { IUploadRead } from './IUploadRead'; +import type { IUserRead } from './IUserRead'; +import type { IVideoConferenceRead } from './IVideoConferenceRead'; + +/** + * The IRead accessor provides methods for accessing the + * Rocket.Chat's environment in a read-only-fashion. + * It is safe to be injected in multiple places, idempotent and extensible + */ +export interface IRead { + /** Gets the IEnvironmentRead instance, contains settings and environmental variables. */ + getEnvironmentReader(): IEnvironmentRead; + + /** Gets the IThreadRead instance */ + + getThreadReader(): IThreadRead; + + /** Gets the IMessageRead instance. */ + getMessageReader(): IMessageRead; + + /** Gets the IPersistenceRead instance. */ + getPersistenceReader(): IPersistenceRead; + + /** Gets the IRoomRead instance. */ + getRoomReader(): IRoomRead; + + /** Gets the IUserRead instance. */ + getUserReader(): IUserRead; + + /** Gets the INotifier for notifying users/rooms. */ + getNotifier(): INotifier; + + getLivechatReader(): ILivechatRead; + getUploadReader(): IUploadRead; + getCloudWorkspaceReader(): ICloudWorkspaceRead; + + getVideoConferenceReader(): IVideoConferenceRead; + + getOAuthAppsReader(): IOAuthAppsReader; + + getRoleReader(): IRoleRead; +} diff --git a/packages/apps-engine/src/definition/accessors/IRoleRead.ts b/packages/apps-engine/src/definition/accessors/IRoleRead.ts new file mode 100644 index 000000000000..fb56ed306a32 --- /dev/null +++ b/packages/apps-engine/src/definition/accessors/IRoleRead.ts @@ -0,0 +1,27 @@ +import type { IRole } from '../roles'; + +/** + * Interface for reading roles. + */ +export interface IRoleRead { + /** + * Retrieves a role by its id or name. + * @param idOrName The id or name of the role to retrieve. + * @param appId The id of the app. + * @returns The role, if found. + * @returns null if no role is found. + * @throws If there is an error while retrieving the role. + */ + getOneByIdOrName(idOrName: IRole['id'] | IRole['name'], appId: string): Promise; + + /** + * Retrieves all custom roles. + * @param appId The id of the app. + * @returns All custom roles. + * @throws If there is an error while retrieving the roles. + * @throws If the app does not have the necessary permissions. + * @see IRole.protected + * @see AppPermissions.role.read + */ + getCustomRoles(appId: string): Promise>; +} diff --git a/packages/apps-engine/src/definition/accessors/IRoomBuilder.ts b/packages/apps-engine/src/definition/accessors/IRoomBuilder.ts new file mode 100644 index 000000000000..b92955896380 --- /dev/null +++ b/packages/apps-engine/src/definition/accessors/IRoomBuilder.ts @@ -0,0 +1,186 @@ +import type { RocketChatAssociationModel } from '../metadata'; +import type { IRoom, RoomType } from '../rooms'; +import type { IUser } from '../users'; + +/** + * Interface for building out a room. + * Please note, a room creator, name, and type must be set otherwise you will NOT + * be able to successfully save the room object. + */ +export interface IRoomBuilder { + kind: RocketChatAssociationModel.ROOM | RocketChatAssociationModel.DISCUSSION; + + /** + * Provides a convient way to set the data for the room. + * Note: Providing an "id" field here will be ignored. + * + * @param room the room data to set + */ + setData(room: Partial): IRoomBuilder; + + /** + * Sets the display name of this room. + * + * @param name the display name of the room + */ + setDisplayName(name: string): IRoomBuilder; + + /** + * Gets the display name of this room. + */ + getDisplayName(): string; + + /** + * Sets the slugified name of this room, it must align to the rules of Rocket.Chat room + * names otherwise there will be an error thrown (no spaces, special characters, etc). + * + * @param name the slugified name + */ + setSlugifiedName(name: string): IRoomBuilder; + + /** + * Gets the slugified name of this room. + */ + getSlugifiedName(): string; + + /** + * Sets the room's type. + * + * @param type the room type + */ + setType(type: RoomType): IRoomBuilder; + + /** + * Gets the room's type. + */ + getType(): RoomType; + + /** + * Sets the creator of the room. + * + * @param creator the user who created the room + */ + setCreator(creator: IUser): IRoomBuilder; + + /** + * Gets the room's creator. + */ + getCreator(): IUser; + + /** + * Adds a user to the room, these are by username until further notice. + * + * @param username the user's username to add to the room + * @deprecated in favor of `addMemberToBeAddedByUsername`. This method will be removed on version 2.0.0 + */ + addUsername(username: string): IRoomBuilder; + + /** + * Sets the usernames of who are joined to the room. + * + * @param usernames the list of usernames + * @deprecated in favor of `setMembersByUsernames`. This method will be removed on version 2.0.0 + */ + setUsernames(usernames: Array): IRoomBuilder; + + /** + * Gets the usernames of users in the room. + * @deprecated in favor of `getMembersUsernames`. This method will be removed on version 2.0.0 + */ + getUsernames(): Array; + + /** + * Adds a member to the room by username + * + * @param username the user's username to add to the room + */ + addMemberToBeAddedByUsername(username: string): IRoomBuilder; + + /** + * Sets a list of members to the room by usernames + * + * @param usernames the list of usernames + */ + setMembersToBeAddedByUsernames(usernames: Array): IRoomBuilder; + + /** + * Gets the list of usernames of the members who are been added to the room + */ + getMembersToBeAddedUsernames(): Array; + + /** + * Sets whether this room should be a default room or not. + * This means that new users will automatically join this room + * when they join the server. + * + * @param isDefault room should be default or not + */ + setDefault(isDefault: boolean): IRoomBuilder; + + /** + * Gets whether this room is a default room or not. + */ + getIsDefault(): boolean; + + /** + * Sets whether this room should be in read only state or not. + * This means that users without the required permission to talk when + * a room is muted will not be able to talk but instead will only be + * able to read the contents of the room. + * + * @param isReadOnly whether it should be read only or not + */ + setReadOnly(isReadOnly: boolean): IRoomBuilder; + + /** + * Gets whether this room is on read only state or not. + */ + getIsReadOnly(): boolean; + + /** + * Sets whether this room should display the system messages (like user join, etc) + * or not. This means that whenever a system event, such as joining or leaving, happens + * then Rocket.Chat won't send the message to the channel. + * + * @param displaySystemMessages whether the messages should display or not + */ + setDisplayingOfSystemMessages(displaySystemMessages: boolean): IRoomBuilder; + + /** + * Gets whether this room should display the system messages or not. + */ + getDisplayingOfSystemMessages(): boolean; + + /** + * Adds a custom field to the room. + * Note: This will replace an existing field with the same key should it exist already. + * + * @param key the name of the key + * @param value the value of the custom field + */ + addCustomField(key: string, value: object): IRoomBuilder; + + /** + * Sets the entire custom field property to an object provided. This will overwrite + * every existing key/values which are unrecoverable. + * + * @param fields the data to set + */ + setCustomFields(fields: { [key: string]: object }): IRoomBuilder; + + /** + * Gets the custom field property of the room. + */ + getCustomFields(): { [key: string]: object }; + + /** + * Gets user ids of members from a direct message + */ + getUserIds(): Array; + + /** + * Gets the resulting room that has been built up to the point of calling this method. + * Note: modifying the returned value will have no effect. + */ + getRoom(): IRoom; +} diff --git a/packages/apps-engine/src/definition/accessors/IRoomExtender.ts b/packages/apps-engine/src/definition/accessors/IRoomExtender.ts new file mode 100644 index 000000000000..4135c63edd13 --- /dev/null +++ b/packages/apps-engine/src/definition/accessors/IRoomExtender.ts @@ -0,0 +1,40 @@ +import type { RocketChatAssociationModel } from '../metadata'; +import type { IRoom } from '../rooms'; +import type { IUser } from '../users'; + +export interface IRoomExtender { + kind: RocketChatAssociationModel.ROOM; + + /** + * Adds a custom field to the room. + * Note: This key can not already exist or it will throw an error. + * Note: The key must not contain a period in it, an error will be thrown. + * + * @param key the name of the custom field + * @param value the value of this custom field + */ + addCustomField(key: string, value: any): IRoomExtender; + + /** + * Adds a user to the room. + * + * @param user the user which is to be added to the room + */ + addMember(user: IUser): IRoomExtender; + + /** + * Get a list of users being added to the room. + */ + getMembersBeingAdded(): Array; + + /** + * Get a list of usernames of users being added to the room. + */ + getUsernamesOfMembersBeingAdded(): Array; + + /** + * Gets the resulting room that has been extended at the point of calling this. + * Note: modifying the returned value will have no effect. + */ + getRoom(): IRoom; +} diff --git a/packages/apps-engine/src/definition/accessors/IRoomRead.ts b/packages/apps-engine/src/definition/accessors/IRoomRead.ts new file mode 100644 index 000000000000..f4e0df33239d --- /dev/null +++ b/packages/apps-engine/src/definition/accessors/IRoomRead.ts @@ -0,0 +1,93 @@ +import type { GetMessagesOptions } from '../../server/bridges/RoomBridge'; +import type { IMessageRaw } from '../messages/index'; +import type { IRoom } from '../rooms/index'; +import type { IUser } from '../users/index'; + +/** + * This accessor provides methods for accessing + * rooms in a read-only-fashion. + */ +export interface IRoomRead { + /** + * Gets a room by an id. + * + * @param id the id of the room + * @returns the room + */ + getById(id: string): Promise; + + /** + * Gets just the creator of the room by the room's id. + * + * @param id the id of the room + * @returns the creator of the room + */ + getCreatorUserById(id: string): Promise; + + /** + * Gets a room by its name. + * + * @param name the name of the room + * @returns the room + */ + getByName(name: string): Promise; + + /** + * Gets just the creator of the room by the room's name. + * + * @param name the name of the room + * @returns the creator of the room + */ + getCreatorUserByName(name: string): Promise; + + /** + * Retrieves an array of messages from the specified room. + * + * @param roomId The unique identifier of the room from which to retrieve messages. + * @param options Optional parameters for retrieving messages: + * - limit: The maximum number of messages to retrieve. Maximum 100 + * - skip: The number of messages to skip (for pagination). + * - sort: An object defining the sorting order of the messages. Each key is a field to sort by, and the value is either "asc" for ascending order or "desc" for descending order. + * @returns A Promise that resolves to an array of IMessage objects representing the messages in the room. + */ + getMessages(roomId: string, options?: Partial): Promise>; + + /** + * Gets an iterator for all of the users in the provided room. + * + * @param roomId the room's id + * @returns an iterator for the users in the room + */ + getMembers(roomId: string): Promise>; + + /** + * Gets a direct room with all usernames + * @param usernames all usernames belonging to the direct room + * @returns the room + */ + getDirectByUsernames(usernames: Array): Promise; + + /** + * Get a list of the moderators of a given room + * + * @param roomId the room's id + * @returns a list of the users with the moderator role in the room + */ + getModerators(roomId: string): Promise>; + + /** + * Get a list of the owners of a given room + * + * @param roomId the room's id + * @returns a list of the users with the owner role in the room + */ + getOwners(roomId: string): Promise>; + + /** + * Get a list of the leaders of a given room + * + * @param roomId the room's id + * @returns a list of the users with the leader role in the room + */ + getLeaders(roomId: string): Promise>; +} diff --git a/packages/apps-engine/src/definition/accessors/ISchedulerExtend.ts b/packages/apps-engine/src/definition/accessors/ISchedulerExtend.ts new file mode 100644 index 000000000000..fc003e34b587 --- /dev/null +++ b/packages/apps-engine/src/definition/accessors/ISchedulerExtend.ts @@ -0,0 +1,11 @@ +import type { IProcessor } from '../scheduler'; + +export interface ISchedulerExtend { + /** + * Register processors that can be scheduled to run + * + * @param {Array} processors An array of processors + * @returns List of task ids run at startup, or void no startup run is set + */ + registerProcessors(processors: Array): Promise>; +} diff --git a/packages/apps-engine/src/definition/accessors/ISchedulerModify.ts b/packages/apps-engine/src/definition/accessors/ISchedulerModify.ts new file mode 100644 index 000000000000..04faa8700799 --- /dev/null +++ b/packages/apps-engine/src/definition/accessors/ISchedulerModify.ts @@ -0,0 +1,31 @@ +import type { IOnetimeSchedule, IRecurringSchedule } from '../scheduler'; + +/** + * This accessor provides methods to work with the Job Scheduler + */ +export interface ISchedulerModify { + /** + * Schedules a registered processor to run _once_. + * + * @param {IOnetimeSchedule} job + * @returns jobid as string + */ + scheduleOnce(job: IOnetimeSchedule): Promise; + /** + * Schedules a registered processor to run in recurrencly according to a given interval + * + * @param {IRecurringSchedule} job + * @returns jobid as string + */ + scheduleRecurring(job: IRecurringSchedule): Promise; + /** + * Cancels a running job given its jobId + * + * @param {string} jobId + */ + cancelJob(jobId: string): Promise; + /** + * Cancels all the running jobs from the app + */ + cancelAllJobs(): Promise; +} diff --git a/packages/apps-engine/src/definition/accessors/IServerSettingRead.ts b/packages/apps-engine/src/definition/accessors/IServerSettingRead.ts new file mode 100644 index 000000000000..ecb7af279241 --- /dev/null +++ b/packages/apps-engine/src/definition/accessors/IServerSettingRead.ts @@ -0,0 +1,43 @@ +import type { ISetting } from '../settings/ISetting'; + +/** + * Reader for the settings inside of the server (Rocket.Chat). + * Only a subset of them are exposed to Apps. + */ +export interface IServerSettingRead { + /** + * Gets a server setting by id. + * Please note: a error will be thrown if not found + * or trying to access one that isn't exposed. + * + * @param id the id of the setting to get + * @return the setting + */ + getOneById(id: string): Promise; + + /** + * Gets a server setting's value by id. + * Please note: a error will be thrown if not found + * or trying to access one that isn't exposed. + * + * @param id the id of the setting to get + * @return the setting's value + */ + getValueById(id: string): Promise; + + /** + * Gets all of the server settings which are exposed + * to the Apps. + * + * @return an iterator of the exposed settings + */ + getAll(): Promise>; + + /** + * Checks if the server setting for the id provided is readable, + * will return true or false and won't throw an error. + * + * @param id the server setting id + */ + isReadableById(id: string): Promise; +} diff --git a/packages/apps-engine/src/definition/accessors/IServerSettingUpdater.ts b/packages/apps-engine/src/definition/accessors/IServerSettingUpdater.ts new file mode 100644 index 000000000000..766285c25953 --- /dev/null +++ b/packages/apps-engine/src/definition/accessors/IServerSettingUpdater.ts @@ -0,0 +1,6 @@ +import type { ISetting } from '../settings/ISetting'; + +export interface IServerSettingUpdater { + updateOne(setting: ISetting): Promise; + incrementValue(id: ISetting['id'], value?: number): Promise; +} diff --git a/packages/apps-engine/src/definition/accessors/IServerSettingsModify.ts b/packages/apps-engine/src/definition/accessors/IServerSettingsModify.ts new file mode 100644 index 000000000000..400bd5ae211f --- /dev/null +++ b/packages/apps-engine/src/definition/accessors/IServerSettingsModify.ts @@ -0,0 +1,40 @@ +import type { ISetting } from '../settings'; + +/** + * This accessor provides methods to change default setting options + * of Rocket.Chat in a compatible way. It is provided during + * your App's "onEnable". + */ +export interface IServerSettingsModify { + /** + * Hides an existing settings group. + * + * @param name The technical name of the group + */ + hideGroup(name: string): Promise; + + /** + * Hides a setting. This does not influence the actual functionality (the setting will still + * have its value and can be programatically read), but the administrator will not be able to see it anymore + * + * @param id the id of the setting to hide + */ + hideSetting(id: string): Promise; + + /** + * Modifies the configured value of another setting, please use it with caution as an invalid + * setting configuration could cause a Rocket.Chat instance to become unstable. + * + * @param setting the modified setting (id must be provided) + */ + modifySetting(setting: ISetting): Promise; + + /** + * Increases the setting value by the specified amount. + * To be used only with statistic settings that track the amount of times an action has been performed + * + * @param id the id of the existing Rocket.Chat setting + * @param value how much should the count be increased by. Defaults to 1. + */ + incrementValue(id: ISetting['id'], value?: number): Promise; +} diff --git a/packages/apps-engine/src/definition/accessors/ISettingRead.ts b/packages/apps-engine/src/definition/accessors/ISettingRead.ts new file mode 100644 index 000000000000..142ee895b161 --- /dev/null +++ b/packages/apps-engine/src/definition/accessors/ISettingRead.ts @@ -0,0 +1,23 @@ +import type { ISetting } from '../settings/index'; + +/** + * This accessor provides methods for accessing + * App settings in a read-only-fashion. + */ +export interface ISettingRead { + /** + * Gets the App's setting by the provided id. + * Does not throw an error but instead will return undefined it doesn't exist. + * + * @param id the id of the setting + */ + getById(id: string): Promise; + + /** + * Gets the App's setting value by the provided id. + * Note: this will throw an error if the setting doesn't exist + * + * @param id the id of the setting value to get + */ + getValueById(id: string): Promise; +} diff --git a/packages/apps-engine/src/definition/accessors/ISettingUpdater.ts b/packages/apps-engine/src/definition/accessors/ISettingUpdater.ts new file mode 100644 index 000000000000..3826286df6c9 --- /dev/null +++ b/packages/apps-engine/src/definition/accessors/ISettingUpdater.ts @@ -0,0 +1,5 @@ +import type { ISetting } from '../settings/ISetting'; + +export interface ISettingUpdater { + updateValue(id: ISetting['id'], value: ISetting['value']): Promise; +} diff --git a/packages/apps-engine/src/definition/accessors/ISettingsExtend.ts b/packages/apps-engine/src/definition/accessors/ISettingsExtend.ts new file mode 100644 index 000000000000..249776379645 --- /dev/null +++ b/packages/apps-engine/src/definition/accessors/ISettingsExtend.ts @@ -0,0 +1,17 @@ +import type { ISetting } from '../settings/index'; + +/** + * This accessor provides methods for adding custom settings, + * which are displayed on your App's page. + * This is provided on initialization of your App. + */ +export interface ISettingsExtend { + /** + * Adds a setting which can be configured by an administrator. + * Settings can only be added to groups which have been provided by this App earlier + * and if a group is not provided, the setting will appear outside of a group. + * + * @param setting the setting + */ + provideSetting(setting: ISetting): Promise; +} diff --git a/packages/apps-engine/src/definition/accessors/ISlashCommandsExtend.ts b/packages/apps-engine/src/definition/accessors/ISlashCommandsExtend.ts new file mode 100644 index 000000000000..4895e61bf96f --- /dev/null +++ b/packages/apps-engine/src/definition/accessors/ISlashCommandsExtend.ts @@ -0,0 +1,16 @@ +import type { ISlashCommand } from '../slashcommands'; + +/** + * This accessor provides methods for adding custom slash commands. + * It is provided during the initialization of your App + */ + +export interface ISlashCommandsExtend { + /** + * Adds a slash command which can be used during conversations lateron. + * Should a command already exists an error will be thrown. + * + * @param slashCommand the command information + */ + provideSlashCommand(slashCommand: ISlashCommand): Promise; +} diff --git a/packages/apps-engine/src/definition/accessors/ISlashCommandsModify.ts b/packages/apps-engine/src/definition/accessors/ISlashCommandsModify.ts new file mode 100644 index 000000000000..b9e3d4c3e615 --- /dev/null +++ b/packages/apps-engine/src/definition/accessors/ISlashCommandsModify.ts @@ -0,0 +1,30 @@ +import type { ISlashCommand } from '../slashcommands'; + +/** + * This accessor provides methods for modifying existing Rocket.Chat slash commands. + * It is provided during "onEnable" of your App. + */ +export interface ISlashCommandsModify { + /** + * Modifies an existing command. The command must either be your App's + * own command or a system command. One App can not modify another + * App's command. + * + * @param slashCommand the modified slash command + */ + modifySlashCommand(slashCommand: ISlashCommand): Promise; + + /** + * Renders an existing slash command un-usable. + * + * @param command the command's usage without the slash + */ + disableSlashCommand(command: string): Promise; + + /** + * Enables an existing slash command to be usable again. + * + * @param command the command's usage without the slash + */ + enableSlashCommand(command: string): Promise; +} diff --git a/packages/apps-engine/src/definition/accessors/IThreadRead.ts b/packages/apps-engine/src/definition/accessors/IThreadRead.ts new file mode 100644 index 000000000000..72ceae996eec --- /dev/null +++ b/packages/apps-engine/src/definition/accessors/IThreadRead.ts @@ -0,0 +1,9 @@ +import type { IMessage } from '../messages/index'; + +/** + * This accessor provides methods for accessing + * Thread messages in a read-only-fashion. + */ +export interface IThreadRead { + getThreadById(id: string): Promise | undefined>; +} diff --git a/packages/apps-engine/src/definition/accessors/IUIController.ts b/packages/apps-engine/src/definition/accessors/IUIController.ts new file mode 100644 index 000000000000..be7e91f5a05b --- /dev/null +++ b/packages/apps-engine/src/definition/accessors/IUIController.ts @@ -0,0 +1,31 @@ +import type { Omit } from '../../lib/utils'; +import type { IUIKitErrorInteraction, IUIKitInteraction, IUIKitSurface } from '../uikit'; +import type { IUIKitContextualBarViewParam, IUIKitModalViewParam } from '../uikit/UIKitInteractionResponder'; +import type { IUser } from '../users'; + +export type IUIKitInteractionParam = Omit; +export type IUIKitErrorInteractionParam = Omit; + +export type IUIKitSurfaceViewParam = Omit & Partial>; + +export interface IUIController { + /** + * @deprecated please prefer the `openSurfaceView` method + */ + openModalView(view: IUIKitModalViewParam, context: IUIKitInteractionParam, user: IUser): Promise; + /** + * @deprecated please prefer the `updateSurfaceView` method + */ + updateModalView(view: IUIKitModalViewParam, context: IUIKitInteractionParam, user: IUser): Promise; + /** + * @deprecated please prefer the `openSurfaceView` method + */ + openContextualBarView(view: IUIKitContextualBarViewParam, context: IUIKitInteractionParam, user: IUser): Promise; + /** + * @deprecated please prefer the `updateSurfaceView` method + */ + updateContextualBarView(view: IUIKitContextualBarViewParam, context: IUIKitInteractionParam, user: IUser): Promise; + setViewError(errorInteraction: IUIKitErrorInteractionParam, context: IUIKitInteractionParam, user: IUser): Promise; + openSurfaceView(view: IUIKitSurfaceViewParam, context: IUIKitInteractionParam, user: IUser): Promise; + updateSurfaceView(view: IUIKitSurfaceViewParam, context: IUIKitInteractionParam, user: IUser): Promise; +} diff --git a/packages/apps-engine/src/definition/accessors/IUIExtend.ts b/packages/apps-engine/src/definition/accessors/IUIExtend.ts new file mode 100644 index 000000000000..3dca2e32809b --- /dev/null +++ b/packages/apps-engine/src/definition/accessors/IUIExtend.ts @@ -0,0 +1,5 @@ +import type { IUIActionButtonDescriptor } from '../ui'; + +export interface IUIExtend { + registerButton(button: IUIActionButtonDescriptor): void; +} diff --git a/packages/apps-engine/src/definition/accessors/IUploadCreator.ts b/packages/apps-engine/src/definition/accessors/IUploadCreator.ts new file mode 100644 index 000000000000..25262b42e7d3 --- /dev/null +++ b/packages/apps-engine/src/definition/accessors/IUploadCreator.ts @@ -0,0 +1,12 @@ +import type { IUpload } from '../uploads'; +import type { IUploadDescriptor } from '../uploads/IUploadDescriptor'; + +export interface IUploadCreator { + /** + * Create an upload to a room + * + * @param buffer A Buffer with the file's content (See [here](https://nodejs.org/api/buffer.html) + * @param descriptor The metadata about the upload + */ + uploadBuffer(buffer: Buffer, descriptor: IUploadDescriptor): Promise; +} diff --git a/packages/apps-engine/src/definition/accessors/IUploadRead.ts b/packages/apps-engine/src/definition/accessors/IUploadRead.ts new file mode 100644 index 000000000000..ce4029a8c0c8 --- /dev/null +++ b/packages/apps-engine/src/definition/accessors/IUploadRead.ts @@ -0,0 +1,7 @@ +import type { IUpload } from '../uploads'; + +export interface IUploadRead { + getById(id: string): Promise; + getBufferById(id: string): Promise; + getBuffer(upload: IUpload): Promise; +} diff --git a/packages/apps-engine/src/definition/accessors/IUserBuilder.ts b/packages/apps-engine/src/definition/accessors/IUserBuilder.ts new file mode 100644 index 000000000000..4e0c52aa893a --- /dev/null +++ b/packages/apps-engine/src/definition/accessors/IUserBuilder.ts @@ -0,0 +1,60 @@ +import type { RocketChatAssociationModel } from '../metadata'; +import type { IUser, IUserEmail } from '../users'; + +/** + * Interface for creating a user. + * Please note, a username and email provided must be unique else you will NOT + * be able to successfully save the user object. + */ +export interface IUserBuilder { + kind: RocketChatAssociationModel.USER; + + /** + * Provides a convient way to set the data for the user. + * Note: Providing an "id" field here will be ignored. + * + * @param user the user data to set + */ + setData(user: Partial): IUserBuilder; + + /** + * Sets emails of the user + * + * @param emails the array of email addresses of the user + */ + setEmails(emails: Array): IUserBuilder; + + /** + * Gets emails of the user + */ + getEmails(): Array; + + /** + * Sets the display name of this user. + * + * @param name the display name of the user + */ + setDisplayName(name: string): IUserBuilder; + + /** + * Gets the display name of this user. + */ + getDisplayName(): string; + + /** + * Sets the username for the user + * + * @param username username of the user + */ + setUsername(username: string): IUserBuilder; + + /** + * Gets the username of this user + */ + getUsername(): string; + + /** + * Gets the user + */ + getUser(): Partial; +} diff --git a/packages/apps-engine/src/definition/accessors/IUserRead.ts b/packages/apps-engine/src/definition/accessors/IUserRead.ts new file mode 100644 index 000000000000..33c4c6e455e4 --- /dev/null +++ b/packages/apps-engine/src/definition/accessors/IUserRead.ts @@ -0,0 +1,22 @@ +import type { IUser } from '../users/index'; + +/** + * This accessor provides methods for accessing + * users in a read-only-fashion. + */ +export interface IUserRead { + getById(id: string): Promise; + + getByUsername(username: string): Promise; + + /** + * Gets the app user of this app. + */ + getAppUser(appId?: string): Promise; + + /** + * Gets the user's badge count (unread messages count). + * @param uid user's id + */ + getUserUnreadMessageCount(uid: string): Promise; +} diff --git a/packages/apps-engine/src/definition/accessors/IUserUpdater.ts b/packages/apps-engine/src/definition/accessors/IUserUpdater.ts new file mode 100644 index 000000000000..8c57b4dadfa8 --- /dev/null +++ b/packages/apps-engine/src/definition/accessors/IUserUpdater.ts @@ -0,0 +1,18 @@ +import type { IUser } from '../users/IUser'; + +/** + * Updating a user is a more granular approach, since + * it is one of the more sensitive aspects of Rocket.Chat - + * or any other system for that matter. + * + * Allowing apps to modify _all_ the aspects of a user + * would open a critical surface for them to abuse such + * power and "take hold" of a server, for instance. + */ +export interface IUserUpdater { + updateStatusText(user: IUser, statusText: IUser['statusText']): Promise; + updateStatus(user: IUser, statusText: IUser['statusText'], status: IUser['status']): Promise; + updateBio(user: IUser, bio: IUser['bio']): Promise; + updateCustomFields(user: IUser, customFields: IUser['customFields']): Promise; + deactivate(userId: IUser['id'], confirmRelinquish: boolean): Promise; +} diff --git a/packages/apps-engine/src/definition/accessors/IVideoConfProvidersExtend.ts b/packages/apps-engine/src/definition/accessors/IVideoConfProvidersExtend.ts new file mode 100644 index 000000000000..c61224893e11 --- /dev/null +++ b/packages/apps-engine/src/definition/accessors/IVideoConfProvidersExtend.ts @@ -0,0 +1,15 @@ +import type { IVideoConfProvider } from '../videoConfProviders'; + +/** + * This accessor provides methods for adding videoconf providers. + * It is provided during the initialization of your App + */ + +export interface IVideoConfProvidersExtend { + /** + * Adds a videoconf provider + * + * @param provider the provider information + */ + provideVideoConfProvider(provider: IVideoConfProvider): Promise; +} diff --git a/packages/apps-engine/src/definition/accessors/IVideoConferenceBuilder.ts b/packages/apps-engine/src/definition/accessors/IVideoConferenceBuilder.ts new file mode 100644 index 000000000000..11b96da0e4ef --- /dev/null +++ b/packages/apps-engine/src/definition/accessors/IVideoConferenceBuilder.ts @@ -0,0 +1,34 @@ +import type { RocketChatAssociationModel } from '../metadata'; +import type { AppVideoConference } from '../videoConferences'; + +export interface IVideoConferenceBuilder { + kind: RocketChatAssociationModel.VIDEO_CONFERENCE; + + setData(call: Partial): IVideoConferenceBuilder; + + setRoomId(rid: string): IVideoConferenceBuilder; + + getRoomId(): string; + + setCreatedBy(userId: string): IVideoConferenceBuilder; + + getCreatedBy(): string; + + setProviderName(name: string): IVideoConferenceBuilder; + + getProviderName(): string; + + setProviderData(data: Record): IVideoConferenceBuilder; + + getProviderData(): Record; + + setTitle(name: string): IVideoConferenceBuilder; + + getTitle(): string; + + setDiscussionRid(rid: string | undefined): IVideoConferenceBuilder; + + getDiscussionRid(): string | undefined; + + getVideoConference(): AppVideoConference; +} diff --git a/packages/apps-engine/src/definition/accessors/IVideoConferenceExtend.ts b/packages/apps-engine/src/definition/accessors/IVideoConferenceExtend.ts new file mode 100644 index 000000000000..d9b7e5838368 --- /dev/null +++ b/packages/apps-engine/src/definition/accessors/IVideoConferenceExtend.ts @@ -0,0 +1,21 @@ +import type { RocketChatAssociationModel } from '../metadata'; +import type { IVideoConferenceUser, VideoConference } from '../videoConferences'; +import type { VideoConferenceMember } from '../videoConferences/IVideoConference'; + +export interface IVideoConferenceExtender { + kind: RocketChatAssociationModel.VIDEO_CONFERENCE; + + setProviderData(value: Record): IVideoConferenceExtender; + + setStatus(value: VideoConference['status']): IVideoConferenceExtender; + + setEndedBy(value: IVideoConferenceUser['_id']): IVideoConferenceExtender; + + setEndedAt(value: VideoConference['endedAt']): IVideoConferenceExtender; + + addUser(userId: VideoConferenceMember['_id'], ts?: VideoConferenceMember['ts']): IVideoConferenceExtender; + + setDiscussionRid(rid: VideoConference['discussionRid']): IVideoConferenceExtender; + + getVideoConference(): VideoConference; +} diff --git a/packages/apps-engine/src/definition/accessors/IVideoConferenceRead.ts b/packages/apps-engine/src/definition/accessors/IVideoConferenceRead.ts new file mode 100644 index 000000000000..aa2d53d70590 --- /dev/null +++ b/packages/apps-engine/src/definition/accessors/IVideoConferenceRead.ts @@ -0,0 +1,15 @@ +import type { VideoConference } from '../videoConferences/IVideoConference'; + +/** + * This accessor provides methods for accessing + * video conferences in a read-only-fashion. + */ +export interface IVideoConferenceRead { + /** + * Gets a video conference by an id. + * + * @param id the id of the video conference + * @returns the video conference + */ + getById(id: string): Promise; +} diff --git a/packages/apps-engine/src/definition/accessors/index.ts b/packages/apps-engine/src/definition/accessors/index.ts new file mode 100644 index 000000000000..e98a4208fe13 --- /dev/null +++ b/packages/apps-engine/src/definition/accessors/index.ts @@ -0,0 +1,58 @@ +export * from './IApiExtend'; +export * from './IAppAccessors'; +export * from './IAppInstallationContext'; +export * from './IAppUpdateContext'; +export * from './IAppUninstallationContext'; +export * from './ICloudWorkspaceRead'; +export * from './IConfigurationExtend'; +export * from './IConfigurationModify'; +export * from './IDiscussionBuilder'; +export * from './IEnvironmentalVariableRead'; +export * from './IEnvironmentRead'; +export * from './IEnvironmentWrite'; +export * from './IExternalComponentsExtend'; +export * from './IHttp'; +export * from './ILivechatCreator'; +export * from './ILivechatMessageBuilder'; +export * from './ILivechatRead'; +export * from './ILivechatUpdater'; +export * from './ILogEntry'; +export * from './ILogger'; +export * from './IMessageBuilder'; +export * from './IMessageExtender'; +export * from './IMessageRead'; +export * from './IMessageUpdater'; +export * from './IModify'; +export * from './IModifyCreator'; +export * from './IModifyDeleter'; +export * from './IModifyExtender'; +export * from './IModifyUpdater'; +export * from './INotifier'; +export * from './IPersistence'; +export * from './IPersistenceRead'; +export * from './IRead'; +export * from './IRoleRead'; +export * from './IRoomBuilder'; +export * from './IRoomExtender'; +export * from './IRoomRead'; +export * from './ISchedulerExtend'; +export * from './ISchedulerModify'; +export * from './IServerSettingRead'; +export * from './IServerSettingsModify'; +export * from './IServerSettingUpdater'; +export * from './ISettingRead'; +export * from './ISettingsExtend'; +export * from './ISettingUpdater'; +export * from './ISlashCommandsExtend'; +export * from './ISlashCommandsModify'; +export * from './IUIController'; +export * from './IUIExtend'; +export * from './IUploadCreator'; +export * from './IUploadRead'; +export * from './IUserBuilder'; +export * from './IUserRead'; +export * from './IVideoConferenceBuilder'; +export * from './IVideoConferenceExtend'; +export * from './IVideoConferenceRead'; +export * from './IVideoConfProvidersExtend'; +export * from './IModerationModify'; diff --git a/packages/apps-engine/src/definition/api/ApiEndpoint.ts b/packages/apps-engine/src/definition/api/ApiEndpoint.ts new file mode 100644 index 000000000000..8ab7610c9b4f --- /dev/null +++ b/packages/apps-engine/src/definition/api/ApiEndpoint.ts @@ -0,0 +1,40 @@ +import type { IApp } from '../IApp'; +import { HttpStatusCode } from '../accessors'; +import type { IApiEndpoint } from './IApiEndpoint'; +import type { IApiResponse, IApiResponseJSON } from './IResponse'; + +/** Represents an api endpoint that is being provided. */ +export abstract class ApiEndpoint implements IApiEndpoint { + /** + * The last part of the api URL. Example: https://{your-server-address}/api/apps/public/{your-app-id}/{path} + * or https://{your-server-address}/api/apps/private/{your-app-id}/{private-hash}/{path} + */ + public path: string; + + constructor(public app: IApp) {} + + /** + * Return response with status 200 (OK) and a optional content + * @param content + */ + protected success(content?: any): IApiResponse { + return { + status: HttpStatusCode.OK, + content, + }; + } + + /** + * Return a json response adding Content Type header as + * application/json if not already provided + * @param reponse + */ + protected json(response: IApiResponseJSON): IApiResponse { + if (!response.headers || !response.headers['content-type']) { + response.headers = response.headers || {}; + response.headers['content-type'] = 'application/json'; + } + + return response; + } +} diff --git a/packages/apps-engine/src/definition/api/IApi.ts b/packages/apps-engine/src/definition/api/IApi.ts new file mode 100644 index 000000000000..9ad2f42ed5a8 --- /dev/null +++ b/packages/apps-engine/src/definition/api/IApi.ts @@ -0,0 +1,58 @@ +import type { IApiEndpoint } from './IApiEndpoint'; + +/** + * Represents an api that is being provided. + */ +export interface IApi { + /** + * Provides the visibility method of the URL, see the ApiVisibility descriptions for more information + */ + visibility: ApiVisibility; + /** + * Provides the visibility method of the URL, see the ApiSecurity descriptions for more information + */ + security: ApiSecurity; + /** + * Provide enpoints for this api registry + */ + endpoints: Array; +} + +export enum ApiVisibility { + /** + * A public Api has a fixed format for a url. Using it enables an + * easy to remember structure, however, it also means the url is + * intelligently guessed. As a result, we recommend having some + * sort of security setup if you must have a public api.Whether + * you use the provided security, ApiSecurity, or implement your own. + * Url format: + * `https://{your-server-address}/api/apps/public/{your-app-id}/{path}` + */ + PUBLIC, + /** + * Private Api's contain a random value in the url format, + * making them harder go guess by default. The random value + * will be generated whenever the App is installed on a server. + * This means that the URL will not be the same on any server, + * but will remain the same throughout the lifecycle of an App + * including updates. As a result, if a user uninstalls the App + * and reinstalls the App, then the random value will change. + * Url format: + * `https://{your-server-address}/api/apps/private/{your-app-id}/{random-hash}/{path}` + */ + PRIVATE, +} + +export enum ApiSecurity { + /** + * No security check will be executed agains the calls made to this URL + */ + UNSECURE, + /** + * Only calls containing a valid token will be able to execute the api + * Mutiple tokens can be generated to access the api, by default one + * will be generated automatically. + * @param `X-Auth-Token` + */ + // CHECKSUM_SECRET, +} diff --git a/packages/apps-engine/src/definition/api/IApiEndpoint.ts b/packages/apps-engine/src/definition/api/IApiEndpoint.ts new file mode 100644 index 000000000000..b369fc175dc2 --- /dev/null +++ b/packages/apps-engine/src/definition/api/IApiEndpoint.ts @@ -0,0 +1,47 @@ +import type { IHttp, IModify, IPersistence, IRead } from '../accessors'; +import type { IApiEndpointInfo } from './IApiEndpointInfo'; +import type { IApiExample } from './IApiExample'; +import type { IApiRequest } from './IRequest'; +import type { IApiResponse } from './IResponse'; + +/** + * Represents an api endpoint that is being provided. + */ +export interface IApiEndpoint { + /** + * The last part of the api URL. Example: https://{your-server-address}/api/apps/public/{your-app-id}/{path} + * or https://{your-server-address}/api/apps/private/{your-app-id}/{private-hash}/{path} + */ + path: string; + examples?: { [key: string]: IApiExample }; + /** + * Whether this endpoint requires an authenticated user to access it. + * + * The authentication will be done by the host server using its own + * authentication system. + * + * If no authentication is provided, the request will be automatically + * rejected with a 401 status code. + */ + authRequired?: boolean; + + /** + * The methods that are available for this endpoint. + * This property is provided by the Runtime and should not be set manually. + * + * Its values are used on the Apps-Engine to validate the request method. + */ + _availableMethods?: string[]; + + /** + * Called whenever the publically accessible url for this App is called, + * if you handle the methods differently then split it out so your code doesn't get too big. + */ + get?(request: IApiRequest, endpoint: IApiEndpointInfo, read: IRead, modify: IModify, http: IHttp, persis: IPersistence): Promise; + post?(request: IApiRequest, endpoint: IApiEndpointInfo, read: IRead, modify: IModify, http: IHttp, persis: IPersistence): Promise; + put?(request: IApiRequest, endpoint: IApiEndpointInfo, read: IRead, modify: IModify, http: IHttp, persis: IPersistence): Promise; + delete?(request: IApiRequest, endpoint: IApiEndpointInfo, read: IRead, modify: IModify, http: IHttp, persis: IPersistence): Promise; + head?(request: IApiRequest, endpoint: IApiEndpointInfo, read: IRead, modify: IModify, http: IHttp, persis: IPersistence): Promise; + options?(request: IApiRequest, endpoint: IApiEndpointInfo, read: IRead, modify: IModify, http: IHttp, persis: IPersistence): Promise; + patch?(request: IApiRequest, endpoint: IApiEndpointInfo, read: IRead, modify: IModify, http: IHttp, persis: IPersistence): Promise; +} diff --git a/packages/apps-engine/src/definition/api/IApiEndpointInfo.ts b/packages/apps-engine/src/definition/api/IApiEndpointInfo.ts new file mode 100644 index 000000000000..de9144784b34 --- /dev/null +++ b/packages/apps-engine/src/definition/api/IApiEndpointInfo.ts @@ -0,0 +1,6 @@ +export interface IApiEndpointInfo { + basePath: string; + fullPath: string; + appId: string; + hash?: string; +} diff --git a/packages/apps-engine/src/definition/api/IApiEndpointMetadata.ts b/packages/apps-engine/src/definition/api/IApiEndpointMetadata.ts new file mode 100644 index 000000000000..0ede26045f79 --- /dev/null +++ b/packages/apps-engine/src/definition/api/IApiEndpointMetadata.ts @@ -0,0 +1,10 @@ +import type { IApiExample } from './IApiExample'; + +export interface IApiEndpointMetadata { + path: string; + computedPath: string; + methods: Array; + examples?: { + [key: string]: IApiExample; + }; +} diff --git a/packages/apps-engine/src/definition/api/IApiExample.ts b/packages/apps-engine/src/definition/api/IApiExample.ts new file mode 100644 index 000000000000..f870d59e643d --- /dev/null +++ b/packages/apps-engine/src/definition/api/IApiExample.ts @@ -0,0 +1,19 @@ +/** + * Represents the parameters of an api example. + */ +export interface IApiExample { + params?: { [key: string]: string }; + query?: { [key: string]: string }; + headers?: { [key: string]: string }; + content?: any; +} + +/** + * Decorator to describe api examples + */ +export function example(options: IApiExample) { + return (target: any, propertyKey: string, descriptor: PropertyDescriptor) => { + target.examples = target.examples || {}; + target.examples[propertyKey] = options; + }; +} diff --git a/packages/apps-engine/src/definition/api/IRequest.ts b/packages/apps-engine/src/definition/api/IRequest.ts new file mode 100644 index 000000000000..027de11b2026 --- /dev/null +++ b/packages/apps-engine/src/definition/api/IRequest.ts @@ -0,0 +1,16 @@ +import type { RequestMethod } from '../accessors'; +import type { IUser } from '../users'; + +export interface IApiRequest { + method: RequestMethod; + headers: { [key: string]: string }; + query: { [key: string]: string }; + params: { [key: string]: string }; + content: any; + privateHash?: string; + /** + * The user that is making the request, as + * authenticated by Rocket.Chat's strategy. + */ + user?: IUser; +} diff --git a/packages/apps-engine/src/definition/api/IResponse.ts b/packages/apps-engine/src/definition/api/IResponse.ts new file mode 100644 index 000000000000..8f394b8a93b0 --- /dev/null +++ b/packages/apps-engine/src/definition/api/IResponse.ts @@ -0,0 +1,13 @@ +import type { HttpStatusCode } from '../accessors'; + +export interface IApiResponse { + status: HttpStatusCode; + headers?: { [key: string]: string }; + content?: any; +} + +export interface IApiResponseJSON { + status: HttpStatusCode; + headers?: { [key: string]: string }; + content?: { [key: string]: any }; +} diff --git a/packages/apps-engine/src/definition/api/index.ts b/packages/apps-engine/src/definition/api/index.ts new file mode 100644 index 000000000000..41e4482f2ec6 --- /dev/null +++ b/packages/apps-engine/src/definition/api/index.ts @@ -0,0 +1,8 @@ +export { ApiEndpoint } from './ApiEndpoint'; +export { IApi, ApiVisibility, ApiSecurity } from './IApi'; +export { IApiEndpoint } from './IApiEndpoint'; +export { IApiEndpointInfo } from './IApiEndpointInfo'; +export { IApiExample, example } from './IApiExample'; +export { IApiRequest } from './IRequest'; +export { IApiResponse } from './IResponse'; +export { IApiEndpointMetadata } from './IApiEndpointMetadata'; diff --git a/packages/apps-engine/src/definition/app-schema.json b/packages/apps-engine/src/definition/app-schema.json new file mode 100644 index 000000000000..68c2e0c19edc --- /dev/null +++ b/packages/apps-engine/src/definition/app-schema.json @@ -0,0 +1,75 @@ +{ + "$schema": "http://json-schema.org/draft-04/schema#", + "title": "Rocket.Chat App", + "description": "A Rocket.Chat App declaration for usage inside of Rocket.Chat.", + "type": "object", + "properties": { + "id": { + "description": "The App's unique identifier in uuid v4 format. This is optional, although recommended, however if you are going to publish on the App store, you will be assigned one.", + "type": "string", + "pattern": "^[0-9a-fA-f]{8}-[0-9a-fA-F]{4}-4[0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}$", + "minLength": 36, + "maxLength": 36 + }, + "name": { + "description": "The public visible name of this App.", + "type": "string" + }, + "nameSlug": { + "description": "A url friendly slugged version of your App's name.", + "type": "string", + "pattern": "^([a-z]|\\-)+$", + "minLength": 3 + }, + "version": { + "description": "The version of this App which will be used for display publicly and letting users know there is an update. This uses the semver format.", + "type": "string", + "pattern": "^(?:\\d*)\\.(?:\\d*)\\.(?:\\d*)$", + "minLength": 5 + }, + "description": { + "description": "A description of this App, used to explain what this App does and provides for the user.", + "type": "string" + }, + "requiredApiVersion": { + "description": "The required version of the App's API which this App depends on. This uses the semver format.", + "type": "string", + "pattern": "^(?:\\^|~)?(?:\\d*)\\.(?:\\d*)\\.(?:\\d*)$", + "minLength": 5 + }, + "author": { + "type": "object", + "properties": { + "name": { + "description": "The author's name who created this App.", + "type": "string" + }, + "support": { + "description": "The place where people can get support for this App, whether email or website.", + "type": "string" + }, + "homepage": { + "description": "The homepage for this App, it can be a Github or the author's website.", + "type": "string", + "format": "uri" + } + }, + "required": ["name", "support"] + }, + "classFile": { + "type": "string", + "description": "The name of the file which contains your App TypeScript source code.", + "pattern": "^.*\\.(ts)$" + }, + "iconFile": { + "type": "string", + "description": "The name of the file to use as the icon.", + "pattern": "^.*\\.(png|jpg|jpeg|gif)$" + }, + "assetsFolder": { + "type": "string", + "description": "The name of the folder which contains all of your resources, it should not start with a period." + } + }, + "required": ["id", "name", "nameSlug", "version", "description", "requiredApiVersion", "author", "classFile", "iconFile"] +} diff --git a/packages/apps-engine/src/definition/assets/IAsset.ts b/packages/apps-engine/src/definition/assets/IAsset.ts new file mode 100644 index 000000000000..30c54ae6565d --- /dev/null +++ b/packages/apps-engine/src/definition/assets/IAsset.ts @@ -0,0 +1,6 @@ +export interface IAsset { + name: string; + path: string; + type: string; + public: boolean; +} diff --git a/packages/apps-engine/src/definition/assets/IAssetProvider.ts b/packages/apps-engine/src/definition/assets/IAssetProvider.ts new file mode 100644 index 000000000000..4e1da50222fa --- /dev/null +++ b/packages/apps-engine/src/definition/assets/IAssetProvider.ts @@ -0,0 +1,5 @@ +import type { IAsset } from './IAsset'; + +export interface IAssetProvider { + getAssets(): Array; +} diff --git a/packages/apps-engine/src/definition/assets/index.ts b/packages/apps-engine/src/definition/assets/index.ts new file mode 100644 index 000000000000..98abab64ddf5 --- /dev/null +++ b/packages/apps-engine/src/definition/assets/index.ts @@ -0,0 +1,4 @@ +import { IAsset } from './IAsset'; +import { IAssetProvider } from './IAssetProvider'; + +export { IAsset, IAssetProvider }; diff --git a/packages/apps-engine/src/definition/cloud/IWorkspaceToken.ts b/packages/apps-engine/src/definition/cloud/IWorkspaceToken.ts new file mode 100644 index 000000000000..40a46bf7e37f --- /dev/null +++ b/packages/apps-engine/src/definition/cloud/IWorkspaceToken.ts @@ -0,0 +1,4 @@ +export interface IWorkspaceToken { + token: string; + expiresAt: Date; +} diff --git a/packages/apps-engine/src/definition/email/IEmail.ts b/packages/apps-engine/src/definition/email/IEmail.ts new file mode 100644 index 000000000000..ca81b23e5bcc --- /dev/null +++ b/packages/apps-engine/src/definition/email/IEmail.ts @@ -0,0 +1,9 @@ +export interface IEmail { + to: string | string[]; + from: string; + replyTo?: string; + subject: string; + html?: string; + text?: string; + headers?: string; +} diff --git a/packages/apps-engine/src/definition/email/IEmailDescriptor.ts b/packages/apps-engine/src/definition/email/IEmailDescriptor.ts new file mode 100644 index 000000000000..168bae039168 --- /dev/null +++ b/packages/apps-engine/src/definition/email/IEmailDescriptor.ts @@ -0,0 +1,11 @@ +export interface IEmailDescriptor { + from?: string | undefined; + to?: string | Array | undefined; + cc?: string | Array | undefined; + bcc?: string | Array | undefined; + replyTo?: string | Array | undefined; + subject?: string | undefined; + text?: string | undefined; + html?: string | undefined; + headers?: Record | undefined; +} diff --git a/packages/apps-engine/src/definition/email/IPreEmailSent.ts b/packages/apps-engine/src/definition/email/IPreEmailSent.ts new file mode 100644 index 000000000000..2d5e40c92851 --- /dev/null +++ b/packages/apps-engine/src/definition/email/IPreEmailSent.ts @@ -0,0 +1,25 @@ +import type { IEmailDescriptor, IPreEmailSentContext } from '.'; +import type { IHttp, IModify, IPersistence, IRead } from '../accessors'; +import { AppMethod } from '../metadata'; + +/** + * Event interface that allows apps to + * register as a handler of of the `IPreEmailSent` + * event. + * + * This event is trigger before the mailer sends + * an email. + * + * To prevent the email from being sent, you can + * throw an error with a message specifying the + * reason for rejection. + */ +export interface IPreEmailSent { + [AppMethod.EXECUTE_PRE_EMAIL_SENT]( + context: IPreEmailSentContext, + read: IRead, + http: IHttp, + persis: IPersistence, + modify: IModify, + ): Promise; +} diff --git a/packages/apps-engine/src/definition/email/IPreEmailSentContext.ts b/packages/apps-engine/src/definition/email/IPreEmailSentContext.ts new file mode 100644 index 000000000000..7427424f88eb --- /dev/null +++ b/packages/apps-engine/src/definition/email/IPreEmailSentContext.ts @@ -0,0 +1,6 @@ +import type { IEmailDescriptor } from './IEmailDescriptor'; + +export interface IPreEmailSentContext { + context: unknown; + email: IEmailDescriptor; +} diff --git a/packages/apps-engine/src/definition/email/index.ts b/packages/apps-engine/src/definition/email/index.ts new file mode 100644 index 000000000000..6074ebaec4c3 --- /dev/null +++ b/packages/apps-engine/src/definition/email/index.ts @@ -0,0 +1,4 @@ +export * from './IEmailDescriptor'; +export * from './IPreEmailSent'; +export * from './IPreEmailSentContext'; +export * from './IEmail'; diff --git a/packages/apps-engine/src/definition/example-app.json b/packages/apps-engine/src/definition/example-app.json new file mode 100644 index 000000000000..e78048d1d316 --- /dev/null +++ b/packages/apps-engine/src/definition/example-app.json @@ -0,0 +1,13 @@ +{ + //This is an example of how a app.json file will look like + "name": "Testing", + "nameSlug": "testing", + "description": "Testing description", + "version": "1.0.0", + "requiredApiVersion": "0.0.1", + "author": { + "name": "Bradley Hilton", + "support": "https://github.com/RocketChat/Rocket.Chat.Apps-engine" + }, + "classFile": "ExampleApp.ts" +} diff --git a/packages/apps-engine/src/definition/exceptions/AppsEngineException.ts b/packages/apps-engine/src/definition/exceptions/AppsEngineException.ts new file mode 100644 index 000000000000..a3e802aa69d8 --- /dev/null +++ b/packages/apps-engine/src/definition/exceptions/AppsEngineException.ts @@ -0,0 +1,32 @@ +/** + * The internal exception from the framework + * + * It's used to signal to the outside world that + * a _known_ exception has happened during the execution + * of the apps. + * + * It's the base exception for other known classes + * such as UserNotAllowedException, which is used + * to inform the host that an app identified + * that a user cannot perform some action, e.g. + * join a room + */ +export class AppsEngineException extends Error { + public name = 'AppsEngineException'; + + public static JSONRPC_ERROR_CODE = -32070; + + public message: string; + + constructor(message?: string) { + super(); + this.message = message; + } + + public getErrorInfo() { + return { + name: this.name, + message: this.message, + }; + } +} diff --git a/packages/apps-engine/src/definition/exceptions/EssentialAppDisabledException.ts b/packages/apps-engine/src/definition/exceptions/EssentialAppDisabledException.ts new file mode 100644 index 000000000000..e5043d93e336 --- /dev/null +++ b/packages/apps-engine/src/definition/exceptions/EssentialAppDisabledException.ts @@ -0,0 +1,16 @@ +import { AppsEngineException } from '.'; + +/** + * This exception informs the host system that an + * app essential to the execution of a system action + * is disabled, so the action should be halted. + * + * Apps can register to be considered essential to + * the execution of internal events of the framework + * such as `IPreMessageSentPrevent`, `IPreRoomUserJoined`, + * etc. + * + * This is used interally by the framework and is not + * intended to be thrown manually by apps. + */ +export class EssentialAppDisabledException extends AppsEngineException {} diff --git a/packages/apps-engine/src/definition/exceptions/FileUploadNotAllowedException.ts b/packages/apps-engine/src/definition/exceptions/FileUploadNotAllowedException.ts new file mode 100644 index 000000000000..0ae9d98edb3f --- /dev/null +++ b/packages/apps-engine/src/definition/exceptions/FileUploadNotAllowedException.ts @@ -0,0 +1,12 @@ +import { AppsEngineException } from './AppsEngineException'; + +/** + * This exception informs the host system that an + * app has determined that a file upload is not + * allowed to be completed. + * + * Currently it is expected to be thrown by the + * following events: + * - IPreFileUpload + */ +export class FileUploadNotAllowedException extends AppsEngineException {} diff --git a/packages/apps-engine/src/definition/exceptions/InvalidSettingValueException.ts b/packages/apps-engine/src/definition/exceptions/InvalidSettingValueException.ts new file mode 100644 index 000000000000..2b1a193accb2 --- /dev/null +++ b/packages/apps-engine/src/definition/exceptions/InvalidSettingValueException.ts @@ -0,0 +1,8 @@ +import { AppsEngineException } from './AppsEngineException'; + +/** + * This exception informs the host system that an + * app has determined that an invalid setting value + * is passed. + */ +export class InvalidSettingValueException extends AppsEngineException {} diff --git a/packages/apps-engine/src/definition/exceptions/UserNotAllowedException.ts b/packages/apps-engine/src/definition/exceptions/UserNotAllowedException.ts new file mode 100644 index 000000000000..d81969d8f62a --- /dev/null +++ b/packages/apps-engine/src/definition/exceptions/UserNotAllowedException.ts @@ -0,0 +1,14 @@ +import { AppsEngineException } from '.'; + +/** + * This exception informs the host system that an + * app has determined that an user is not allowed + * to perform a specific action. + * + * Currently it is expected to be thrown by the + * following events: + * - IPreRoomCreatePrevent + * - IPreRoomUserJoined + * - IPreRoomUserLeave + */ +export class UserNotAllowedException extends AppsEngineException {} diff --git a/packages/apps-engine/src/definition/exceptions/index.ts b/packages/apps-engine/src/definition/exceptions/index.ts new file mode 100644 index 000000000000..6129978c2f89 --- /dev/null +++ b/packages/apps-engine/src/definition/exceptions/index.ts @@ -0,0 +1,5 @@ +export * from './AppsEngineException'; +export * from './EssentialAppDisabledException'; +export * from './UserNotAllowedException'; +export * from './FileUploadNotAllowedException'; +export * from './InvalidSettingValueException'; diff --git a/packages/apps-engine/src/definition/externalComponent/IExternalComponent.ts b/packages/apps-engine/src/definition/externalComponent/IExternalComponent.ts new file mode 100644 index 000000000000..7c750e1c7e22 --- /dev/null +++ b/packages/apps-engine/src/definition/externalComponent/IExternalComponent.ts @@ -0,0 +1,51 @@ +import type { IExternalComponentOptions } from './IExternalComponentOptions'; +import type { IExternalComponentState } from './IExternalComponentState'; +/** + * Represents an external component that is being provided. + */ +export interface IExternalComponent { + /** + * Provides the appId of the app which the external component belongs to. + */ + appId: string; + /** + * Provides the name of the external component. This key must be unique. + */ + name: string; + /** + * Provides the description of the external component. + */ + description: string; + /** + * Provides the icon's url or base64 string. + */ + icon: string; + /** + * Provides the location which external component needs + * to register, see the ExternalComponentLocation descriptions + * for the more information. + */ + location: ExternalComponentLocation; + /** + * Provides the url that external component will load. + */ + url: string; + /** + * Provides options for the external component. + */ + options?: IExternalComponentOptions; + /** + * Represents the current state of the external component. + * The value is *null* until the ExternalComponentOpened + * event is triggered. It doesn't make sense to get its value in + * PreExternalComponentOpenedPrevent, PreExternalComponentOpenedModify + * and PreExternalComponentOpenedExtend handlers. + */ + state?: IExternalComponentState; +} + +export enum ExternalComponentLocation { + CONTEXTUAL_BAR = 'CONTEXTUAL_BAR', + + MODAL = 'MODAL', +} diff --git a/packages/apps-engine/src/definition/externalComponent/IExternalComponentOptions.ts b/packages/apps-engine/src/definition/externalComponent/IExternalComponentOptions.ts new file mode 100644 index 000000000000..2581c047ab43 --- /dev/null +++ b/packages/apps-engine/src/definition/externalComponent/IExternalComponentOptions.ts @@ -0,0 +1,10 @@ +export interface IExternalComponentOptions { + /** + * The width of the external component + */ + width?: number; + /** + * The height of the external component + */ + height?: number; +} diff --git a/packages/apps-engine/src/definition/externalComponent/IExternalComponentState.ts b/packages/apps-engine/src/definition/externalComponent/IExternalComponentState.ts new file mode 100644 index 000000000000..4c401f3e28d1 --- /dev/null +++ b/packages/apps-engine/src/definition/externalComponent/IExternalComponentState.ts @@ -0,0 +1,16 @@ +import type { IExternalComponentRoomInfo, IExternalComponentUserInfo } from '../../client/definition'; + +/** + * The state of an external component, which contains the + * current user's information and the current room's information. + */ +export interface IExternalComponentState { + /** + * The user who opened this external component + */ + currentUser: IExternalComponentUserInfo; + /** + * The room where the external component belongs to + */ + currentRoom: IExternalComponentRoomInfo; +} diff --git a/packages/apps-engine/src/definition/externalComponent/IPostExternalComponentClosed.ts b/packages/apps-engine/src/definition/externalComponent/IPostExternalComponentClosed.ts new file mode 100644 index 000000000000..24d224ba2913 --- /dev/null +++ b/packages/apps-engine/src/definition/externalComponent/IPostExternalComponentClosed.ts @@ -0,0 +1,16 @@ +import type { IHttp, IPersistence, IRead } from '../accessors'; +import type { IExternalComponent } from './IExternalComponent'; + +/** + * Handler called after an external component is closed. + */ +export interface IPostExternalComponentClosed { + /** + * Method called after an external component is closed. + * + * @param externalComponent The external component which was closed + * @param read An accessor to the environment + * @param http An accessor to the outside world + */ + executePostExternalComponentClosed(externalComponent: IExternalComponent, read: IRead, http: IHttp, persistence: IPersistence): Promise; +} diff --git a/packages/apps-engine/src/definition/externalComponent/IPostExternalComponentOpened.ts b/packages/apps-engine/src/definition/externalComponent/IPostExternalComponentOpened.ts new file mode 100644 index 000000000000..8a09ebe711d2 --- /dev/null +++ b/packages/apps-engine/src/definition/externalComponent/IPostExternalComponentOpened.ts @@ -0,0 +1,16 @@ +import type { IHttp, IPersistence, IRead } from '../accessors'; +import type { IExternalComponent } from './IExternalComponent'; + +/** + * Handler called after an external component is opened. + */ +export interface IPostExternalComponentOpened { + /** + * Method called after an external component is opened. + * + * @param externalComponent The external component which was opened + * @param read An accessor to the environment + * @param http An accessor to the outside world + */ + executePostExternalComponentOpened(externalComponent: IExternalComponent, read: IRead, http: IHttp, persistence: IPersistence): Promise; +} diff --git a/packages/apps-engine/src/definition/externalComponent/index.ts b/packages/apps-engine/src/definition/externalComponent/index.ts new file mode 100644 index 000000000000..acd4bbf44982 --- /dev/null +++ b/packages/apps-engine/src/definition/externalComponent/index.ts @@ -0,0 +1,5 @@ +import { IExternalComponent } from './IExternalComponent'; +import { IPostExternalComponentClosed } from './IPostExternalComponentClosed'; +import { IPostExternalComponentOpened } from './IPostExternalComponentOpened'; + +export { IExternalComponent, IPostExternalComponentClosed, IPostExternalComponentOpened }; diff --git a/packages/apps-engine/src/definition/livechat/IDepartment.ts b/packages/apps-engine/src/definition/livechat/IDepartment.ts new file mode 100644 index 000000000000..1a59c9835612 --- /dev/null +++ b/packages/apps-engine/src/definition/livechat/IDepartment.ts @@ -0,0 +1,17 @@ +export interface IDepartment { + id: string; + name?: string; + email?: string; + description?: string; + offlineMessageChannelName?: string; + requestTagBeforeClosingChat?: false; + chatClosingTags?: Array; + abandonedRoomsCloseCustomMessage?: string; + waitingQueueMessage?: string; + departmentsAllowedToForward?: string; + enabled: boolean; + updatedAt: Date; + numberOfAgents: number; + showOnOfflineForm: boolean; + showOnRegistration: boolean; +} diff --git a/packages/apps-engine/src/definition/livechat/ILivechatEventContext.ts b/packages/apps-engine/src/definition/livechat/ILivechatEventContext.ts new file mode 100644 index 000000000000..b94f07ef0250 --- /dev/null +++ b/packages/apps-engine/src/definition/livechat/ILivechatEventContext.ts @@ -0,0 +1,7 @@ +import type { IUser } from '../users'; +import type { ILivechatRoom } from './ILivechatRoom'; + +export interface ILivechatEventContext { + agent: IUser; + room: ILivechatRoom; +} diff --git a/packages/apps-engine/src/definition/livechat/ILivechatMessage.ts b/packages/apps-engine/src/definition/livechat/ILivechatMessage.ts new file mode 100644 index 000000000000..d7cc5497d70e --- /dev/null +++ b/packages/apps-engine/src/definition/livechat/ILivechatMessage.ts @@ -0,0 +1,7 @@ +import type { IMessage } from '../messages/IMessage'; +import type { IVisitor } from './IVisitor'; + +export interface ILivechatMessage extends IMessage { + visitor?: IVisitor; + token?: string; +} diff --git a/packages/apps-engine/src/definition/livechat/ILivechatRoom.ts b/packages/apps-engine/src/definition/livechat/ILivechatRoom.ts new file mode 100644 index 000000000000..e3f55142331a --- /dev/null +++ b/packages/apps-engine/src/definition/livechat/ILivechatRoom.ts @@ -0,0 +1,55 @@ +import { RoomType } from '../rooms'; +import type { IRoom } from '../rooms/IRoom'; +import type { IUser } from '../users'; +import type { IDepartment } from './IDepartment'; +import type { IVisitor } from './IVisitor'; + +export enum OmnichannelSourceType { + WIDGET = 'widget', + EMAIL = 'email', + SMS = 'sms', + APP = 'app', + OTHER = 'other', +} + +interface IOmnichannelSourceApp { + type: 'app'; + id: string; + // A human readable alias that goes with the ID, for post analytical purposes + alias?: string; + // A label to be shown in the room info + label?: string; + sidebarIcon?: string; + defaultIcon?: string; +} +type OmnichannelSource = + | { + type: Exclude; + } + | IOmnichannelSourceApp; + +export interface IVisitorChannelInfo { + lastMessageTs?: Date; + phone?: string; +} + +export interface ILivechatRoom extends IRoom { + visitor: IVisitor; + visitorChannelInfo?: IVisitorChannelInfo; + department?: IDepartment; + closer: 'user' | 'visitor' | 'bot'; + closedBy?: IUser; + servedBy?: IUser; + responseBy?: IUser; + isWaitingResponse: boolean; + isOpen: boolean; + closedAt?: Date; + source?: OmnichannelSource; +} + +export const isLivechatRoom = (room: IRoom): room is ILivechatRoom => { + return room.type === RoomType.LIVE_CHAT; +}; +export const isLivechatFromApp = (room: ILivechatRoom): room is ILivechatRoom & { source: IOmnichannelSourceApp } => { + return room.source && room.source.type === 'app'; +}; diff --git a/packages/apps-engine/src/definition/livechat/ILivechatRoomClosedHandler.ts b/packages/apps-engine/src/definition/livechat/ILivechatRoomClosedHandler.ts new file mode 100644 index 000000000000..dba672ad391b --- /dev/null +++ b/packages/apps-engine/src/definition/livechat/ILivechatRoomClosedHandler.ts @@ -0,0 +1,19 @@ +import type { IHttp, IPersistence, IRead } from '../accessors'; +import { AppMethod } from '../metadata'; +import type { ILivechatRoom } from './ILivechatRoom'; + +/** + * Handler called after a livechat room is closed. + * @deprecated please prefer the IPostLivechatRoomClosed event + */ +export interface ILivechatRoomClosedHandler { + /** + * Method called *after* a livechat room is closed. + * + * @param livechatRoom The livechat room which is closed. + * @param read An accessor to the environment + * @param http An accessor to the outside world + * @param persistence An accessor to the App's persistence + */ + [AppMethod.EXECUTE_LIVECHAT_ROOM_CLOSED_HANDLER](data: ILivechatRoom, read: IRead, http: IHttp, persistence: IPersistence): Promise; +} diff --git a/packages/apps-engine/src/definition/livechat/ILivechatTransferData.ts b/packages/apps-engine/src/definition/livechat/ILivechatTransferData.ts new file mode 100644 index 000000000000..988d0a2ec5fd --- /dev/null +++ b/packages/apps-engine/src/definition/livechat/ILivechatTransferData.ts @@ -0,0 +1,8 @@ +import type { IUser } from '../users'; +import type { ILivechatRoom } from './ILivechatRoom'; + +export interface ILivechatTransferData { + currentRoom: ILivechatRoom; + targetAgent?: IUser; + targetDepartment?: string; +} diff --git a/packages/apps-engine/src/definition/livechat/ILivechatTransferEventContext.ts b/packages/apps-engine/src/definition/livechat/ILivechatTransferEventContext.ts new file mode 100644 index 000000000000..74db472751d4 --- /dev/null +++ b/packages/apps-engine/src/definition/livechat/ILivechatTransferEventContext.ts @@ -0,0 +1,15 @@ +import type { IRoom } from '../rooms'; +import type { IUser } from '../users'; +import type { IDepartment } from './IDepartment'; + +export enum LivechatTransferEventType { + AGENT = 'agent', + DEPARTMENT = 'department', +} + +export interface ILivechatTransferEventContext { + type: LivechatTransferEventType; + room: IRoom; + from: IUser | IDepartment; + to: IUser | IDepartment; +} diff --git a/packages/apps-engine/src/definition/livechat/IPostLivechatAgentAssigned.ts b/packages/apps-engine/src/definition/livechat/IPostLivechatAgentAssigned.ts new file mode 100644 index 000000000000..5c322534c9ff --- /dev/null +++ b/packages/apps-engine/src/definition/livechat/IPostLivechatAgentAssigned.ts @@ -0,0 +1,25 @@ +import type { IHttp, IModify, IPersistence, IRead } from '../accessors'; +import { AppMethod } from '../metadata'; +import type { ILivechatEventContext } from './ILivechatEventContext'; + +/** + * Handler called after the assignment of a livechat agent. + */ +export interface IPostLivechatAgentAssigned { + /** + * Handler called *after* the assignment of a livechat agent. + * + * @param data the livechat context data which contains agent's info and room's info. + * @param read An accessor to the environment + * @param http An accessor to the outside world + * @param persis An accessor to the App's persistence + * @param modify An accessor to the modifier + */ + [AppMethod.EXECUTE_POST_LIVECHAT_AGENT_ASSIGNED]( + context: ILivechatEventContext, + read: IRead, + http: IHttp, + persis: IPersistence, + modify?: IModify, + ): Promise; +} diff --git a/packages/apps-engine/src/definition/livechat/IPostLivechatAgentUnassigned.ts b/packages/apps-engine/src/definition/livechat/IPostLivechatAgentUnassigned.ts new file mode 100644 index 000000000000..2884cfa94b83 --- /dev/null +++ b/packages/apps-engine/src/definition/livechat/IPostLivechatAgentUnassigned.ts @@ -0,0 +1,25 @@ +import type { IHttp, IModify, IPersistence, IRead } from '../accessors'; +import { AppMethod } from '../metadata'; +import type { ILivechatEventContext } from './ILivechatEventContext'; + +/** + * Handler called after the unassignment of a livechat agent. + */ +export interface IPostLivechatAgentUnassigned { + /** + * Handler called *after* the unassignment of a livechat agent. + * + * @param data the livechat context data which contains agent's info and room's info. + * @param read An accessor to the environment + * @param http An accessor to the outside world + * @param persis An accessor to the App's persistence + * @param modify An accessor to the modifier + */ + [AppMethod.EXECUTE_POST_LIVECHAT_AGENT_UNASSIGNED]( + context: ILivechatEventContext, + read: IRead, + http: IHttp, + persis: IPersistence, + modify?: IModify, + ): Promise; +} diff --git a/packages/apps-engine/src/definition/livechat/IPostLivechatGuestSaved.ts b/packages/apps-engine/src/definition/livechat/IPostLivechatGuestSaved.ts new file mode 100644 index 000000000000..2c5edc0b9692 --- /dev/null +++ b/packages/apps-engine/src/definition/livechat/IPostLivechatGuestSaved.ts @@ -0,0 +1,19 @@ +import type { IHttp, IModify, IPersistence, IRead } from '../accessors'; +import { AppMethod } from '../metadata'; +import type { IVisitor } from './IVisitor'; + +/** + * Handler called after the guest's info get saved. + */ +export interface IPostLivechatGuestSaved { + /** + * Handler called *after* the guest's info get saved. + * + * @param data the livechat context data which contains guest's info and room's info. + * @param read An accessor to the environment + * @param http An accessor to the outside world + * @param persis An accessor to the App's persistence + * @param modify An accessor to the modifier + */ + [AppMethod.EXECUTE_POST_LIVECHAT_GUEST_SAVED](context: IVisitor, read: IRead, http: IHttp, persis: IPersistence, modify: IModify): Promise; +} diff --git a/packages/apps-engine/src/definition/livechat/IPostLivechatRoomClosed.ts b/packages/apps-engine/src/definition/livechat/IPostLivechatRoomClosed.ts new file mode 100644 index 000000000000..072e02e8c721 --- /dev/null +++ b/packages/apps-engine/src/definition/livechat/IPostLivechatRoomClosed.ts @@ -0,0 +1,19 @@ +import type { IHttp, IModify, IPersistence, IRead } from '../accessors'; +import { AppMethod } from '../metadata'; +import type { ILivechatRoom } from './ILivechatRoom'; + +/** + * Handler called after a livechat room is closed. + */ +export interface IPostLivechatRoomClosed { + /** + * Method called *after* a livechat room is closed. + * + * @param livechatRoom The livechat room which is closed. + * @param read An accessor to the environment + * @param http An accessor to the outside world + * @param persis An accessor to the App's persistence + * @param modify An accessor to the modifier + */ + [AppMethod.EXECUTE_POST_LIVECHAT_ROOM_CLOSED](room: ILivechatRoom, read: IRead, http: IHttp, persis: IPersistence, modify?: IModify): Promise; +} diff --git a/packages/apps-engine/src/definition/livechat/IPostLivechatRoomSaved.ts b/packages/apps-engine/src/definition/livechat/IPostLivechatRoomSaved.ts new file mode 100644 index 000000000000..13b6d1cb2e4b --- /dev/null +++ b/packages/apps-engine/src/definition/livechat/IPostLivechatRoomSaved.ts @@ -0,0 +1,19 @@ +import type { IHttp, IModify, IPersistence, IRead } from '../accessors'; +import { AppMethod } from '../metadata'; +import type { ILivechatRoom } from './ILivechatRoom'; + +/** + * Handler called after the room's info get saved. + */ +export interface IPostLivechatRoomSaved { + /** + * Handler called *after* the room's info get saved. + * + * @param data the livechat context data which contains room's info. + * @param read An accessor to the environment + * @param http An accessor to the outside world + * @param persis An accessor to the App's persistence + * @param modify An accessor to the modifier + */ + [AppMethod.EXECUTE_POST_LIVECHAT_ROOM_SAVED](context: ILivechatRoom, read: IRead, http: IHttp, persis: IPersistence, modify: IModify): Promise; +} diff --git a/packages/apps-engine/src/definition/livechat/IPostLivechatRoomStarted.ts b/packages/apps-engine/src/definition/livechat/IPostLivechatRoomStarted.ts new file mode 100644 index 000000000000..237dcd9566e9 --- /dev/null +++ b/packages/apps-engine/src/definition/livechat/IPostLivechatRoomStarted.ts @@ -0,0 +1,19 @@ +import type { IHttp, IModify, IPersistence, IRead } from '../accessors'; +import { AppMethod } from '../metadata'; +import type { ILivechatRoom } from './ILivechatRoom'; + +/** + * Handler called after a livechat room is started. + */ +export interface IPostLivechatRoomStarted { + /** + * Method called *after* a livechat room is started. + * + * @param livechatRoom The livechat room which is started. + * @param read An accessor to the environment + * @param http An accessor to the outside world + * @param persis An accessor to the App's persistence + * @param modify An accessor to the modifier + */ + [AppMethod.EXECUTE_POST_LIVECHAT_ROOM_STARTED](room: ILivechatRoom, read: IRead, http: IHttp, persis: IPersistence, modify?: IModify): Promise; +} diff --git a/packages/apps-engine/src/definition/livechat/IPostLivechatRoomTransferred.ts b/packages/apps-engine/src/definition/livechat/IPostLivechatRoomTransferred.ts new file mode 100644 index 000000000000..aa86f8d358d3 --- /dev/null +++ b/packages/apps-engine/src/definition/livechat/IPostLivechatRoomTransferred.ts @@ -0,0 +1,13 @@ +import type { IHttp, IModify, IPersistence, IRead } from '../accessors'; +import { AppMethod } from '../metadata'; +import type { ILivechatTransferEventContext } from './ILivechatTransferEventContext'; + +export interface IPostLivechatRoomTransferred { + [AppMethod.EXECUTE_POST_LIVECHAT_ROOM_TRANSFERRED]( + context: ILivechatTransferEventContext, + read: IRead, + http: IHttp, + persis: IPersistence, + modify: IModify, + ): Promise; +} diff --git a/packages/apps-engine/src/definition/livechat/IVisitor.ts b/packages/apps-engine/src/definition/livechat/IVisitor.ts new file mode 100644 index 000000000000..db5876dd912d --- /dev/null +++ b/packages/apps-engine/src/definition/livechat/IVisitor.ts @@ -0,0 +1,16 @@ +import type { IVisitorEmail } from './IVisitorEmail'; +import type { IVisitorPhone } from './IVisitorPhone'; + +export interface IVisitor { + id?: string; + token: string; + username: string; + updatedAt?: Date; + name: string; + department?: string; + phone?: Array; + visitorEmails?: Array; + status?: string; + customFields?: { [key: string]: any }; + livechatData?: { [key: string]: any }; +} diff --git a/packages/apps-engine/src/definition/livechat/IVisitorEmail.ts b/packages/apps-engine/src/definition/livechat/IVisitorEmail.ts new file mode 100644 index 000000000000..a1e35380666e --- /dev/null +++ b/packages/apps-engine/src/definition/livechat/IVisitorEmail.ts @@ -0,0 +1,3 @@ +export interface IVisitorEmail { + address: string; +} diff --git a/packages/apps-engine/src/definition/livechat/IVisitorPhone.ts b/packages/apps-engine/src/definition/livechat/IVisitorPhone.ts new file mode 100644 index 000000000000..fe112777e7d7 --- /dev/null +++ b/packages/apps-engine/src/definition/livechat/IVisitorPhone.ts @@ -0,0 +1,3 @@ +export interface IVisitorPhone { + phoneNumber: string; +} diff --git a/packages/apps-engine/src/definition/livechat/index.ts b/packages/apps-engine/src/definition/livechat/index.ts new file mode 100644 index 000000000000..5ea8d9f42885 --- /dev/null +++ b/packages/apps-engine/src/definition/livechat/index.ts @@ -0,0 +1,38 @@ +import { IDepartment } from './IDepartment'; +import { ILivechatEventContext } from './ILivechatEventContext'; +import { ILivechatMessage } from './ILivechatMessage'; +import { ILivechatRoom } from './ILivechatRoom'; +import { ILivechatRoomClosedHandler } from './ILivechatRoomClosedHandler'; +import { ILivechatTransferData } from './ILivechatTransferData'; +import { ILivechatTransferEventContext, LivechatTransferEventType } from './ILivechatTransferEventContext'; +import { IPostLivechatAgentAssigned } from './IPostLivechatAgentAssigned'; +import { IPostLivechatAgentUnassigned } from './IPostLivechatAgentUnassigned'; +import { IPostLivechatGuestSaved } from './IPostLivechatGuestSaved'; +import { IPostLivechatRoomClosed } from './IPostLivechatRoomClosed'; +import { IPostLivechatRoomSaved } from './IPostLivechatRoomSaved'; +import { IPostLivechatRoomStarted } from './IPostLivechatRoomStarted'; +import { IPostLivechatRoomTransferred } from './IPostLivechatRoomTransferred'; +import { IVisitor } from './IVisitor'; +import { IVisitorEmail } from './IVisitorEmail'; +import { IVisitorPhone } from './IVisitorPhone'; + +export { + ILivechatEventContext, + ILivechatMessage, + ILivechatRoom, + IPostLivechatAgentAssigned, + IPostLivechatAgentUnassigned, + IPostLivechatGuestSaved, + IPostLivechatRoomStarted, + IPostLivechatRoomClosed, + IPostLivechatRoomSaved, + IPostLivechatRoomTransferred, + ILivechatRoomClosedHandler, + ILivechatTransferData, + ILivechatTransferEventContext, + IDepartment, + IVisitor, + IVisitorEmail, + IVisitorPhone, + LivechatTransferEventType, +}; diff --git a/packages/apps-engine/src/definition/messages/IMessage.ts b/packages/apps-engine/src/definition/messages/IMessage.ts new file mode 100644 index 000000000000..d7ea6357497a --- /dev/null +++ b/packages/apps-engine/src/definition/messages/IMessage.ts @@ -0,0 +1,34 @@ +import type { LayoutBlock } from '@rocket.chat/ui-kit'; + +import type { IRoom } from '../rooms'; +import type { IBlock } from '../uikit'; +import type { IUser, IUserLookup } from '../users'; +import type { IMessageAttachment } from './IMessageAttachment'; +import type { IMessageFile } from './IMessageFile'; +import type { IMessageReactions } from './IMessageReaction'; + +export interface IMessage { + id?: string; + threadId?: string; + room: IRoom; + sender: IUser; + text?: string; + createdAt?: Date; + updatedAt?: Date; + editor?: IUser; + editedAt?: Date; + emoji?: string; + avatarUrl?: string; + alias?: string; + file?: IMessageFile; + attachments?: Array; + reactions?: IMessageReactions; + groupable?: boolean; + parseUrls?: boolean; + customFields?: { [key: string]: any }; + blocks?: Array; + starred?: Array<{ _id: string }>; + pinned?: boolean; + pinnedAt?: Date; + pinnedBy?: IUserLookup; +} diff --git a/packages/apps-engine/src/definition/messages/IMessageAction.ts b/packages/apps-engine/src/definition/messages/IMessageAction.ts new file mode 100644 index 000000000000..3f32e4aa781d --- /dev/null +++ b/packages/apps-engine/src/definition/messages/IMessageAction.ts @@ -0,0 +1,17 @@ +import type { MessageActionType } from './MessageActionType'; +import type { MessageProcessingType } from './MessageProcessingType'; + +/** + * Interface which represents an action which can be added to a message. + */ +export interface IMessageAction { + type: MessageActionType; + text?: string; + url?: string; + image_url?: string; + is_webview?: boolean; + webview_height_ratio?: string; + msg?: string; + msg_in_chat_window?: boolean; + msg_processing_type?: MessageProcessingType; +} diff --git a/packages/apps-engine/src/definition/messages/IMessageAttachment.ts b/packages/apps-engine/src/definition/messages/IMessageAttachment.ts new file mode 100644 index 000000000000..96dd8aa1fe34 --- /dev/null +++ b/packages/apps-engine/src/definition/messages/IMessageAttachment.ts @@ -0,0 +1,43 @@ +import type { IMessageAction } from './IMessageAction'; +import type { IMessageAttachmentAuthor } from './IMessageAttachmentAuthor'; +import type { IMessageAttachmentField } from './IMessageAttachmentField'; +import type { IMessageAttachmentTitle } from './IMessageAttachmentTitle'; +import type { MessageActionButtonsAlignment } from './MessageActionButtonsAlignment'; + +/** + * Interface which represents an attachment which can be added to a message. + */ +export interface IMessageAttachment { + /** Causes the image, audio, and video sections to be hidding when this is true. */ + collapsed?: boolean; + /** The color you want the order on the left side to be, supports any valid background-css value. */ + color?: string; // TODO: Maybe we change this to a Color class which has helper methods? + /** The text to display for this attachment. */ + text?: string; + /** Displays the time next to the text portion. */ + timestamp?: Date; + /** Only applicable if the timestamp is provided, as it makes the time clickable to this link. */ + timestampLink?: string; + /** An image that displays to the left of the text, looks better when this is relatively small. */ + thumbnailUrl?: string; + /** Author portion of the attachment. */ + author?: IMessageAttachmentAuthor; + /** Title portion of the attachment. */ + title?: IMessageAttachmentTitle; + /** The image to display, will be "big" and easy to see. */ + imageUrl?: string; + /** Audio file to play, only supports what html's