diff --git a/.github/actions/build-docker-image-service/action.yml b/.github/actions/build-docker-image-service/action.yml new file mode 100644 index 000000000000..aba20175b940 --- /dev/null +++ b/.github/actions/build-docker-image-service/action.yml @@ -0,0 +1,69 @@ +name: 'Build Micro Services Docker image' +description: 'Build Rocket.Chat Micro Services Docker images' + +inputs: + docker-tag: + required: true + service: + required: true + username: + required: false + password: + required: false + +outputs: + image-name: + value: ${{ steps.build-image.outputs.image-name }} + +runs: + using: "composite" + steps: + # - shell: bash + # name: Free disk space + # run: | + # sudo swapoff -a + # sudo rm -f /swapfile + # sudo apt clean + # docker rmi $(docker image ls -aq) + # df -h + + - shell: bash + id: build-image + run: | + LOWERCASE_REPOSITORY=$(echo "${{ github.repository_owner }}" | tr "[:upper:]" "[:lower:]") + + IMAGE_TAG="${{ inputs.docker-tag }}" + + IMAGE_NAME="ghcr.io/${LOWERCASE_REPOSITORY}/${{ inputs.service }}-service:${IMAGE_TAG}" + + echo "Building Docker image for service: ${{ inputs.service }}:${IMAGE_TAG}" + + if [[ "${{ inputs.service }}" == "ddp-streamer" ]]; then + DOCKERFILE_PATH="./ee/apps/ddp-streamer/Dockerfile" + else + DOCKERFILE_PATH="./apps/meteor/ee/server/services/Dockerfile" + fi + + docker build \ + --build-arg SERVICE=${{ inputs.service }} \ + -t ${IMAGE_NAME} \ + -f ${DOCKERFILE_PATH} \ + . + + echo "::set-output name=image-name::${IMAGE_NAME}" + + - name: Login to GitHub Container Registry + if: github.event.pull_request.head.repo.full_name == github.repository || github.event_name == 'release' || github.ref == 'refs/heads/develop' + uses: docker/login-action@v2 + with: + registry: ghcr.io + username: ${{ inputs.username }} + password: ${{ inputs.password }} + + - name: Publish image + shell: bash + if: github.event.pull_request.head.repo.full_name == github.repository || github.event_name == 'release' || github.ref == 'refs/heads/develop' + run: | + echo "Push Docker image: ${{ steps.build-image.outputs.image-name }}" + + docker push ${{ steps.build-image.outputs.image-name }} diff --git a/.github/actions/build-docker-image/action.yml b/.github/actions/build-docker-image/action.yml new file mode 100644 index 000000000000..240c502efd1c --- /dev/null +++ b/.github/actions/build-docker-image/action.yml @@ -0,0 +1,88 @@ +name: 'Build Docker image' +description: 'Build Rocket.Chat Docker image' + +inputs: + root-dir: + required: true + docker-tag: + required: true + release: + required: true + username: + required: false + password: + required: false + +outputs: + image-name: + value: ${{ steps.build-image.outputs.image-name }} + +runs: + using: composite + steps: + # - shell: bash + # name: Free disk space + # run: | + # sudo swapoff -a + # sudo rm -f /swapfile + # sudo apt clean + # docker rmi $(docker image ls -aq) + # df -h + + - shell: bash + id: build-image + run: | + cd ${{ inputs.root-dir }} + + LOWERCASE_REPOSITORY=$(echo "${{ github.repository_owner }}" | tr "[:upper:]" "[:lower:]") + + IMAGE_NAME_BASE="ghcr.io/${LOWERCASE_REPOSITORY}/rocket.chat:${{ inputs.docker-tag }}" + + IMAGE_NAME="${IMAGE_NAME_BASE}.${{ inputs.release }}" + + echo "Build Docker image ${IMAGE_NAME}" + + DOCKER_PATH="${GITHUB_WORKSPACE}/apps/meteor/.docker" + if [[ '${{ inputs.release }}' = 'preview' ]]; then + DOCKER_PATH="${DOCKER_PATH}-mongo" + fi; + + DOCKERFILE_PATH="${DOCKER_PATH}/Dockerfile" + if [[ '${{ inputs.release }}' = 'alpine' ]]; then + DOCKERFILE_PATH="${DOCKERFILE_PATH}.${{ inputs.release }}" + fi; + + echo "Copy Dockerfile for release: ${{ inputs.release }}" + cp $DOCKERFILE_PATH ./Dockerfile + if [ -e ${DOCKER_PATH}/entrypoint.sh ]; then + cp ${DOCKER_PATH}/entrypoint.sh . + fi; + + echo "Build ${{ inputs.release }} Docker image" + docker build -t $IMAGE_NAME . + + echo "::set-output name=image-name-base::${IMAGE_NAME_BASE}" + echo "::set-output name=image-name::${IMAGE_NAME}" + + - name: Login to GitHub Container Registry + if: github.event.pull_request.head.repo.full_name == github.repository || github.event_name == 'release' || github.ref == 'refs/heads/develop' + uses: docker/login-action@v2 + with: + registry: ghcr.io + username: ${{ inputs.username }} + password: ${{ inputs.password }} + + - name: Publish image + shell: bash + if: github.event.pull_request.head.repo.full_name == github.repository || github.event_name == 'release' || github.ref == 'refs/heads/develop' + run: | + echo "Push Docker image: ${{ steps.build-image.outputs.image-name }}" + + docker push ${{ steps.build-image.outputs.image-name }} + + if [[ '${{ inputs.release }}' = 'official' ]]; then + echo "Push release official without variant" + + docker tag ${{ steps.build-image.outputs.image-name }} ${{ steps.build-image.outputs.image-name-base }} + docker push ${{ steps.build-image.outputs.image-name-base }} + fi; diff --git a/.github/workflows/build_and_test.yml b/.github/workflows/build_and_test.yml index ecfb34c5b1e8..e62ee8217486 100644 --- a/.github/workflows/build_and_test.yml +++ b/.github/workflows/build_and_test.yml @@ -15,10 +15,50 @@ concurrency: env: CI: true - MONGO_URL: mongodb://localhost:27017 + MONGO_URL: mongodb://localhost:27017/rocketchat + MONGO_OPLOG_URL: mongodb://mongo:27017/local TOOL_NODE_FLAGS: --max_old_space_size=4096 + TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }} + TURBO_TEAM: ${{ secrets.TURBO_TEAM }} jobs: + release-versions: + runs-on: ubuntu-latest + outputs: + release: ${{ steps.by-tag.outputs.release }} + latest-release: ${{ steps.latest.outputs.latest-release }} + docker-tag: ${{ steps.docker.outputs.docker-tag }} + gh-docker-tag: ${{ steps.docker.outputs.gh-docker-tag }} + steps: + - id: by-tag + run: | + if echo "$GITHUB_REF_NAME" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+$' ; then + RELEASE="latest" + elif echo "$GITHUB_REF_NAME" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+-rc\.[0-9]+$' ; then + RELEASE="release-candidate" + fi + echo "RELEASE: ${RELEASE}" + echo "::set-output name=release::${RELEASE}" + + - id: latest + run: | + LATEST_RELEASE="$( + git -c 'versionsort.suffix=-' ls-remote -t --exit-code --refs --sort=-v:refname "https://github.com/$GITHUB_REPOSITORY" '*' | + sed -En '1!q;s/^[[:xdigit:]]+[[:space:]]+refs\/tags\/(.+)/\1/gp' + )" + echo "LATEST_RELEASE: ${LATEST_RELEASE}" + echo "::set-output name=latest-release::${LATEST_RELEASE}" + + - id: docker + run: | + if [[ '${{ github.event_name }}' == 'pull_request' ]]; then + DOCKER_TAG="pr-${{ github.event.number }}" + else + DOCKER_TAG="gh-${{ github.run_id }}" + fi + echo "DOCKER_TAG: ${DOCKER_TAG}" + echo "::set-output name=gh-docker-tag::${DOCKER_TAG}" + build: runs-on: ubuntu-20.04 @@ -33,12 +73,13 @@ jobs: echo "github.event_name: ${{ github.event_name }}" cat $GITHUB_EVENT_PATH + - uses: actions/checkout@v3 + - name: Use Node.js 14.18.3 uses: actions/setup-node@v3 with: node-version: '14.18.3' - - - uses: actions/checkout@v3 + cache: 'yarn' - name: Free disk space run: | @@ -48,40 +89,22 @@ jobs: docker rmi $(docker image ls -aq) df -h - # TODO is this required? - # - name: check package-lock - # run: | - # npx package-lock-check - - - uses: c-hive/gha-yarn-cache@v2 - - name: Cache turbo - id: cache-turbo - uses: actions/cache@v2 - with: - path: | - ./node_modules/.turbo - key: ${{ runner.OS }}-turbo-${{ hashFiles('**/yarn.lock') }} - restore-keys: | - ${{ runner.os }}-turbo- - ${{ runner.os }}- - - # TODO change to use turbo cache - name: Cache meteor local uses: actions/cache@v2 with: path: ./apps/meteor/.meteor/local - key: ${{ runner.OS }}-meteor_cache-${{ hashFiles('apps/meteor/.meteor/versions') }} + key: meteor-local-cache-${{ runner.OS }}-${{ hashFiles('apps/meteor/.meteor/versions') }} restore-keys: | - ${{ runner.os }}-meteor_cache- - ${{ runner.os }}- + meteor-local-cache-${{ runner.os }}- + - name: Cache meteor uses: actions/cache@v2 with: path: ~/.meteor - key: ${{ runner.OS }}-meteor-${{ hashFiles('apps/meteor/.meteor/release') }} + key: meteor-cache-${{ runner.OS }}-${{ hashFiles('apps/meteor/.meteor/release') }} restore-keys: | - ${{ runner.os }}-meteor- - ${{ runner.os }}- + meteor-cache-${{ runner.os }}- + - name: Install Meteor run: | # Restore bin from cache @@ -114,13 +137,20 @@ jobs: - name: yarn install run: yarn - - run: yarn lint + - name: TurboRepo local server + uses: felixmosh/turborepo-gh-artifacts@v1 + with: + repo-token: ${{ secrets.GITHUB_TOKEN }} + server-token: ${{ secrets.TURBO_SERVER_TOKEN }} + + - name: Lint + run: yarn lint --api="http://127.0.0.1:9080" - - run: yarn turbo run translation-check + - name: Translation check + run: yarn turbo run translation-check --api="http://127.0.0.1:9080" - name: TS typecheck - run: | - yarn turbo run typecheck + run: yarn turbo run typecheck --api="http://127.0.0.1:9080" - name: Reset Meteor if: startsWith(github.ref, 'refs/tags/') == 'true' || github.ref == 'refs/heads/develop' @@ -132,39 +162,61 @@ jobs: if: startsWith(github.ref, 'refs/pull/') == true env: METEOR_PROFILE: 1000 - run: | - yarn build:ci -- --debug --directory /tmp/build-test + run: yarn build:ci --api="http://127.0.0.1:9080" -- --debug --directory dist - name: Build Rocket.Chat if: startsWith(github.ref, 'refs/pull/') != true - run: | - yarn build:ci -- --directory /tmp/build-test + run: yarn build:ci --api="http://127.0.0.1:9080" -- --directory dist - name: Prepare build run: | - mkdir /tmp/build/ - cd /tmp/build-test - tar czf /tmp/build/Rocket.Chat.tar.gz bundle - cd /tmp/build-test/bundle/programs/server - npm install --production - cd /tmp - tar czf Rocket.Chat.test.tar.gz ./build-test - - - name: Store build for tests - uses: actions/upload-artifact@v2 - with: - name: build-test - path: /tmp/Rocket.Chat.test.tar.gz + cd apps/meteor/dist + tar czf /tmp/Rocket.Chat.tar.gz bundle - name: Store build uses: actions/upload-artifact@v2 + with: + name: build + path: /tmp/Rocket.Chat.tar.gz + + build-docker-preview: + runs-on: ubuntu-20.04 + needs: [build, release-versions] + if: github.event_name == 'release' || github.ref == 'refs/heads/develop' + steps: + - uses: actions/checkout@v3 + + - name: Use Node.js ${{ matrix.node-version }} + uses: actions/setup-node@v3 + with: + node-version: ${{ matrix.node-version }} + cache: 'yarn' + + - name: Restore build + uses: actions/download-artifact@v2 with: name: build path: /tmp/build + - name: Unpack build + run: | + cd /tmp/build + tar xzf Rocket.Chat.tar.gz + rm Rocket.Chat.tar.gz + + - name: Build Docker image + id: build-docker-image-preview + uses: ./.github/actions/build-docker-image + with: + root-dir: /tmp/build + docker-tag: ${{ needs.release-versions.outputs.gh-docker-tag }} + release: preview + username: ${{ secrets.CR_USER }} + password: ${{ secrets.CR_PAT }} + test: runs-on: ubuntu-20.04 - needs: build + needs: [build, release-versions] strategy: matrix: @@ -177,79 +229,142 @@ jobs: with: mongoDBVersion: ${{ matrix.mongodb-version }} --replSet=rs0 - - name: Restore build for tests - uses: actions/download-artifact@v2 - with: - name: build-test - path: /tmp - - - name: Decompress build - run: | - cd /tmp - tar xzf Rocket.Chat.test.tar.gz - cd - + - uses: actions/checkout@v3 - name: Use Node.js ${{ matrix.node-version }} uses: actions/setup-node@v3 with: node-version: ${{ matrix.node-version }} + cache: 'yarn' - name: Setup Chrome - run: | - npm i chromedriver + run: npm i chromedriver - name: Configure Replica Set run: | docker exec mongo mongo --eval 'rs.initiate({_id:"rs0", members: [{"_id":1, "host":"localhost:27017"}]})' docker exec mongo mongo --eval 'rs.status()' - - uses: actions/checkout@v3 + - name: yarn install + run: yarn - - uses: c-hive/gha-yarn-cache@v2 - - name: Cache turbo - id: cache-turbo - uses: actions/cache@v2 + - name: TurboRepo local server + uses: felixmosh/turborepo-gh-artifacts@v1 with: - path: | - ./node_modules/.turbo - key: ${{ runner.OS }}-turbo-${{ hashFiles('**/yarn.lock') }} - restore-keys: | - ${{ runner.os }}-turbo- - ${{ runner.os }}- - - - name: Yarn install - # if: steps.cache-nodemodules.outputs.cache-hit != 'true' || steps.cache-cypress.outputs.cache-hit != 'true' - run: yarn + repo-token: ${{ secrets.GITHUB_TOKEN }} + server-token: ${{ secrets.TURBO_SERVER_TOKEN }} - name: Unit Test - run: yarn testunit + run: yarn testunit --api="http://127.0.0.1:9080" - - name: Install Playwright + - name: Restore build + uses: actions/download-artifact@v2 + with: + name: build + path: /tmp/build + + - name: Unpack build run: | - cd ./apps/meteor - npx playwright install --with-deps + cd /tmp/build + tar xzf Rocket.Chat.tar.gz + rm Rocket.Chat.tar.gz + + - name: Build Docker image + id: build-docker-image + if: matrix.mongodb-version != '5.0' + uses: ./.github/actions/build-docker-image + with: + root-dir: /tmp/build + docker-tag: ${{ needs.release-versions.outputs.gh-docker-tag }} + release: official + username: ${{ secrets.CR_USER }} + password: ${{ secrets.CR_PAT }} + + - name: Build Alpine Docker image + id: build-docker-image-alpine + if: matrix.mongodb-version == '5.0' + uses: ./.github/actions/build-docker-image + with: + root-dir: /tmp/build + docker-tag: ${{ needs.release-versions.outputs.gh-docker-tag }} + release: alpine + username: ${{ secrets.CR_USER }} + password: ${{ secrets.CR_PAT }} + + # TODO move startup/restart to its own github action + - name: Start up Rocket.Chat + run: | + LOWERCASE_REPOSITORY=$(echo "${{ github.repository_owner }}" | tr "[:upper:]" "[:lower:]") + + # test alpine image on mongo 5.0 (no special reason to be mongo 5.0 but we need to test alpine at least once) + if [[ '${{ matrix.mongodb-version }}' = '5.0' ]]; then + IMAGE_TAG="${{ needs.release-versions.outputs.gh-docker-tag }}.alpine" + else + IMAGE_TAG="${{ needs.release-versions.outputs.gh-docker-tag }}.official" + fi; + + IMAGE_NAME="ghcr.io/${LOWERCASE_REPOSITORY}/rocket.chat:${IMAGE_TAG}" + + docker run --name rocketchat -d \ + --link mongo \ + -p 3000:3000 \ + -e TEST_MODE=true \ + -e MONGO_URL=mongodb://mongo:27017/rocketchat \ + -e MONGO_OPLOG_URL=mongodb://mongo:27017/local \ + ${IMAGE_NAME} - name: E2E Test API - env: - TEST_MODE: 'true' - MONGO_URL: mongodb://localhost:27017/rocketchat - MONGO_OPLOG_URL: mongodb://localhost:27017/local run: | + docker logs rocketchat --tail=50 + cd ./apps/meteor - echo -e 'pcm.!default {\n type hw\n card 0\n}\n\nctl.!default {\n type hw\n card 0\n}' > ~/.asoundrc - Xvfb -screen 0 1024x768x24 :99 & - for i in $(seq 1 5); do (docker exec mongo mongo rocketchat --eval 'db.dropDatabase()') && npm run testci -- --test=testapi && s=0 && break || s=$? && sleep 1; done; (exit $s) + for i in $(seq 1 5); do + docker stop rocketchat + docker exec mongo mongo rocketchat --eval 'db.dropDatabase()' - - name: E2E Test UI - env: - TEST_MODE: 'true' - MONGO_URL: mongodb://localhost:27017/rocketchat - MONGO_OPLOG_URL: mongodb://localhost:27017/local + NOW=$(date "+%Y-%m-%dT%H:%M:%SZ") + echo $NOW + + docker start rocketchat + + until echo "$(docker logs rocketchat --since $NOW)" | grep -q "SERVER RUNNING"; do + echo "Waiting Rocket.Chat to start up" + ((c++)) && ((c==10)) && exit 1 + sleep 10 + done + + npm run testapi && s=0 && break || s=$? && docker logs rocketchat --tail=100; + done; + exit $s + + - name: Install Playwright run: | cd ./apps/meteor + npx playwright install --with-deps + + - name: E2E Test UI + run: | echo -e 'pcm.!default {\n type hw\n card 0\n}\n\nctl.!default {\n type hw\n card 0\n}' > ~/.asoundrc Xvfb -screen 0 1024x768x24 :99 & - docker exec mongo mongo rocketchat --eval 'db.dropDatabase()' && npm run testci -- --test=test:playwright + + docker logs rocketchat --tail=50 + + docker stop rocketchat + docker exec mongo mongo rocketchat --eval 'db.dropDatabase()' + + NOW=$(date "+%Y-%m-%dT%H:%M:%SZ") + echo $NOW + + docker start rocketchat + + until echo "$(docker logs rocketchat --since $NOW)" | grep -q "SERVER RUNNING"; do + echo "Waiting Rocket.Chat to start up" + ((c++)) && ((c==10)) && exit 1 + sleep 10 + done + + cd ./apps/meteor + npm run test:playwright - name: Store playwright test trace uses: actions/upload-artifact@v2 @@ -260,7 +375,7 @@ jobs: test-ee: runs-on: ubuntu-20.04 - needs: build + needs: [build, release-versions] strategy: matrix: @@ -276,311 +391,304 @@ jobs: - name: Launch NATS run: sudo docker run --name nats -d -p 4222:4222 nats:2.4 - - name: Restore build for tests - uses: actions/download-artifact@v2 - with: - name: build-test - path: /tmp - - - name: Decompress build - run: | - cd /tmp - tar xzf Rocket.Chat.test.tar.gz - cd - + - uses: actions/checkout@v3 - name: Use Node.js ${{ matrix.node-version }} uses: actions/setup-node@v3 with: node-version: ${{ matrix.node-version }} + cache: 'yarn' - name: Setup Chrome - run: | - npm i chromedriver + run: npm i chromedriver - name: Configure Replica Set run: | docker exec mongo mongo --eval 'rs.initiate({_id:"rs0", members: [{"_id":1, "host":"localhost:27017"}]})' docker exec mongo mongo --eval 'rs.status()' - - uses: actions/checkout@v3 - - - uses: c-hive/gha-yarn-cache@v2 - - name: Cache turbo - id: cache-turbo - uses: actions/cache@v2 + - name: TurboRepo local server + uses: felixmosh/turborepo-gh-artifacts@v1 with: - path: | - ./node_modules/.turbo - key: ${{ runner.OS }}-turbo-${{ hashFiles('**/yarn.lock') }} - restore-keys: | - ${{ runner.os }}-turbo- - ${{ runner.os }}- + repo-token: ${{ secrets.GITHUB_TOKEN }} + server-token: ${{ secrets.TURBO_SERVER_TOKEN }} - - name: Yarn install + - name: yarn install run: yarn - - name: Build micro services - run: yarn build - - - name: E2E Test API - env: - TEST_MODE: 'true' - MONGO_URL: mongodb://localhost:27017/rocketchat - MONGO_OPLOG_URL: mongodb://localhost:27017/local - ENTERPRISE_LICENSE: ${{ secrets.ENTERPRISE_LICENSE }} - TRANSPORTER: nats://localhost:4222 - SKIP_PROCESS_EVENT_REGISTRATION: 'true' - run: | - echo -e 'pcm.!default {\n type hw\n card 0\n}\n\nctl.!default {\n type hw\n card 0\n}' > ~/.asoundrc - Xvfb -screen 0 1024x768x24 :99 & - - cd ./apps/meteor/ + - name: Unit Test + run: yarn testunit --api="http://127.0.0.1:9080" - for i in $(seq 1 5); do (docker exec mongo mongo rocketchat --eval 'db.dropDatabase()') && npm run testci -- --enterprise --test=testapi && s=0 && break || s=$? && sleep 1; done; (exit $s) + - name: Restore build + uses: actions/download-artifact@v2 + with: + name: build + path: /tmp/build - - name: Install Playwright + - name: Unpack build run: | - cd ./apps/meteor/ - npx playwright install --with-deps - - - name: E2E Test UI - env: - TEST_MODE: 'true' - MONGO_URL: mongodb://localhost:27017/rocketchat - MONGO_OPLOG_URL: mongodb://localhost:27017/local - ENTERPRISE_LICENSE: ${{ secrets.ENTERPRISE_LICENSE }} - TRANSPORTER: nats://localhost:4222 - TEST_API_URL: http://localhost:4000 - OVERWRITE_SETTING_Site_Url: http://localhost:4000 - SKIP_PROCESS_EVENT_REGISTRATION: 'true' - run: | - echo -e 'pcm.!default {\n type hw\n card 0\n}\n\nctl.!default {\n type hw\n card 0\n}' > ~/.asoundrc - Xvfb -screen 0 1024x768x24 :99 & - - cd ./apps/meteor - - docker exec mongo mongo rocketchat --eval 'db.dropDatabase()' && npm run testci -- --enterprise --test=test:playwright:ee + cd /tmp/build + tar xzf Rocket.Chat.tar.gz + rm Rocket.Chat.tar.gz - - name: Store playwright test trace - uses: actions/upload-artifact@v2 - if: failure() + - name: Build Docker image + id: build-docker-image + uses: ./.github/actions/build-docker-image with: - name: ee-playwright-test-trace - path: ./apps/meteor/tests/e2e/test-failures* - - # notification: - # runs-on: ubuntu-20.04 - # needs: test - - # steps: - # - name: Rocket.Chat Notification - # uses: RocketChat/Rocket.Chat.GitHub.Action.Notification@1.1.1 - # with: - # type: ${{ job.status }} - # job_name: '**Build and Test**' - # url: ${{ secrets.ROCKETCHAT_WEBHOOK }} - # commit: true - # token: ${{ secrets.GITHUB_TOKEN }} - - build-image-pr: - runs-on: ubuntu-20.04 - if: github.event.pull_request.head.repo.full_name == github.repository - - strategy: - matrix: - release: ['official', 'preview'] - - steps: - - uses: actions/checkout@v3 + root-dir: /tmp/build + docker-tag: ${{ needs.release-versions.outputs.gh-docker-tag }} + release: official + username: ${{ secrets.CR_USER }} + password: ${{ secrets.CR_PAT }} - - name: Login to GitHub Container Registry - uses: docker/login-action@v1 + - name: 'Build Docker image: account' + uses: ./.github/actions/build-docker-image-service with: - registry: ghcr.io + docker-tag: ${{ needs.release-versions.outputs.gh-docker-tag }} + service: account username: ${{ secrets.CR_USER }} password: ${{ secrets.CR_PAT }} - - name: Free disk space - run: | - sudo swapoff -a - sudo rm -f /swapfile - sudo apt clean - docker rmi $(docker image ls -aq) - df -h - - uses: c-hive/gha-yarn-cache@v2 - - name: Cache turbo - id: cache-turbo - uses: actions/cache@v2 + - name: 'Build Docker image: authorization' + uses: ./.github/actions/build-docker-image-service with: - path: | - ./node_modules/.turbo - key: ${{ runner.OS }}-turbo-${{ hashFiles('**/yarn.lock') }} - restore-keys: | - ${{ runner.os }}-turbo- - ${{ runner.os }}- + docker-tag: ${{ needs.release-versions.outputs.gh-docker-tag }} + service: authorization + username: ${{ secrets.CR_USER }} + password: ${{ secrets.CR_PAT }} - - name: Cache meteor local - uses: actions/cache@v2 + - name: 'Build Docker image: ddp-streamer' + uses: ./.github/actions/build-docker-image-service with: - path: ./apps/meteor/.meteor/local - key: ${{ runner.OS }}-meteor_cache-${{ hashFiles('.meteor/versions') }} - restore-keys: | - ${{ runner.os }}-meteor_cache- - ${{ runner.os }}- - - name: Cache meteor - uses: actions/cache@v2 + docker-tag: ${{ needs.release-versions.outputs.gh-docker-tag }} + service: ddp-streamer + username: ${{ secrets.CR_USER }} + password: ${{ secrets.CR_PAT }} + + - name: 'Build Docker image: presence' + uses: ./.github/actions/build-docker-image-service with: - path: ~/.meteor - key: ${{ runner.OS }}-meteor-${{ hashFiles('.meteor/release') }} - restore-keys: | - ${{ runner.os }}-meteor- - ${{ runner.os }}- - - name: Use Node.js 14.18.3 - uses: actions/setup-node@v3 + docker-tag: ${{ needs.release-versions.outputs.gh-docker-tag }} + service: presence + username: ${{ secrets.CR_USER }} + password: ${{ secrets.CR_PAT }} + + - name: 'Build Docker image: stream-hub' + uses: ./.github/actions/build-docker-image-service with: - node-version: '14.18.3' + docker-tag: ${{ needs.release-versions.outputs.gh-docker-tag }} + service: stream-hub + username: ${{ secrets.CR_USER }} + password: ${{ secrets.CR_PAT }} - - name: Install Meteor + - name: Launch Traefik run: | - # Restore bin from cache - set +e - METEOR_SYMLINK_TARGET=$(readlink ~/.meteor/meteor) - METEOR_TOOL_DIRECTORY=$(dirname "$METEOR_SYMLINK_TARGET") - set -e - LAUNCHER=$HOME/.meteor/$METEOR_TOOL_DIRECTORY/scripts/admin/launch-meteor - if [ -e $LAUNCHER ] - then - echo "Cached Meteor bin found, restoring it" - sudo cp "$LAUNCHER" "/usr/local/bin/meteor" - else - echo "No cached Meteor bin found." - fi - - # only install meteor if bin isn't found - command -v meteor >/dev/null 2>&1 || curl https://install.meteor.com | sed s/--progress-bar/-sL/g | /bin/sh + docker run --name traefik -d \ + -p 3000:80 \ + -v /var/run/docker.sock:/var/run/docker.sock \ + traefik:2.7 \ + --providers.docker=true - - name: Versions + # TODO move startup/restart to its own github action + - name: Start up Rocket.Chat run: | - npm --versions - yarn -v - node -v - meteor --version - meteor npm --versions - meteor node -v - git version + LOWERCASE_REPOSITORY=$(echo "${{ github.repository_owner }}" | tr "[:upper:]" "[:lower:]") - - name: Yarn install - # if: steps.cache-nodemodules.outputs.cache-hit != 'true' - run: yarn + docker run --name rocketchat -d \ + --link mongo \ + --link nats \ + -e TEST_MODE=true \ + -e MONGO_URL=mongodb://mongo:27017/rocketchat \ + -e MONGO_OPLOG_URL=mongodb://mongo:27017/local \ + -e TRANSPORTER=nats://nats:4222 \ + -e MOLECULER_LOG_LEVEL=info \ + -e ENTERPRISE_LICENSE="${{ secrets.ENTERPRISE_LICENSE }}" \ + -e SKIP_PROCESS_EVENT_REGISTRATION=true \ + --label 'traefik.http.routers.rocketchat.rule=PathPrefix(`/`)' \ + ghcr.io/${LOWERCASE_REPOSITORY}/rocket.chat:${{ needs.release-versions.outputs.gh-docker-tag }}.official + + # spin up all micro services + docker run --name ddp-streamer -d \ + --link mongo \ + --link nats \ + -e PORT=4000 \ + -e MONGO_URL=mongodb://mongo:27017/rocketchat \ + -e MONGO_OPLOG_URL=mongodb://mongo:27017/local \ + -e TRANSPORTER=nats://nats:4222 \ + -e MOLECULER_LOG_LEVEL=info \ + --label 'traefik.http.services.ddp-streamer.loadbalancer.server.port=4000' \ + --label 'traefik.http.routers.ddp-streamer.rule=PathPrefix(`/websocket`) || PathPrefix(`/sockjs`)' \ + ghcr.io/${LOWERCASE_REPOSITORY}/ddp-streamer-service:${{ needs.release-versions.outputs.gh-docker-tag }} + + - name: 'Start service: stream-hub' + run: | + LOWERCASE_REPOSITORY=$(echo "${{ github.repository_owner }}" | tr "[:upper:]" "[:lower:]") - # To reduce memory need during actual build, build the packages solely first - # - name: Build a Meteor cache - # run: | - # # to do this we can clear the main files and it build the rest - # echo "" > server/main.ts - # echo "" > client/main.ts - # sed -i.backup 's/rocketchat:livechat/#rocketchat:livechat/' .meteor/packages - # meteor build --server-only --debug --directory /tmp/build-temp - # git checkout -- server/main.ts client/main.ts .meteor/packages + docker run --name stream-hub -d \ + --link mongo \ + --link nats \ + -e MONGO_URL=mongodb://mongo:27017/rocketchat \ + -e MONGO_OPLOG_URL=mongodb://mongo:27017/local \ + -e TRANSPORTER=nats://nats:4222 \ + -e MOLECULER_LOG_LEVEL=info \ + ghcr.io/${LOWERCASE_REPOSITORY}/stream-hub-service:${{ needs.release-versions.outputs.gh-docker-tag }} - - name: Build Rocket.Chat - run: yarn build:ci -- --directory /tmp/build-pr + until echo "$(docker logs stream-hub)" | grep -q "NetworkBroker started successfully"; do + echo "Waiting 'stream-hub' to start up" + ((c++)) && ((c==10)) && exit 1 + sleep 10 + done - - name: Build Docker image for PRs + - name: 'Start service: account' run: | - cd /tmp/build-pr + LOWERCASE_REPOSITORY=$(echo "${{ github.repository_owner }}" | tr "[:upper:]" "[:lower:]") + + docker run --name account -d \ + --link mongo \ + --link nats \ + -e MONGO_URL=mongodb://mongo:27017/rocketchat \ + -e MONGO_OPLOG_URL=mongodb://mongo:27017/local \ + -e TRANSPORTER=nats://nats:4222 \ + -e MOLECULER_LOG_LEVEL=info \ + ghcr.io/${LOWERCASE_REPOSITORY}/account-service:${{ needs.release-versions.outputs.gh-docker-tag }} + until echo "$(docker logs account)" | grep -q "NetworkBroker started successfully"; do + echo "Waiting 'account' to start up" + ((c++)) && ((c==10)) && exit 1 + sleep 10 + done + + - name: 'Start service: authorization' + run: | LOWERCASE_REPOSITORY=$(echo "${{ github.repository_owner }}" | tr "[:upper:]" "[:lower:]") - IMAGE_NAME="rocket.chat" - if [[ '${{ matrix.release }}' = 'preview' ]]; then - IMAGE_NAME="${IMAGE_NAME}.preview" - fi; - IMAGE_NAME="ghcr.io/${LOWERCASE_REPOSITORY}/${IMAGE_NAME}:pr-${{ github.event.number }}" + docker run --name authorization -d \ + --link mongo \ + --link nats \ + -e MONGO_URL=mongodb://mongo:27017/rocketchat \ + -e MONGO_OPLOG_URL=mongodb://mongo:27017/local \ + -e TRANSPORTER=nats://nats:4222 \ + -e MOLECULER_LOG_LEVEL=info \ + ghcr.io/${LOWERCASE_REPOSITORY}/authorization-service:${{ needs.release-versions.outputs.gh-docker-tag }} - echo "Build official Docker image ${IMAGE_NAME}" + until echo "$(docker logs authorization)" | grep -q "NetworkBroker started successfully"; do + echo "Waiting 'authorization' to start up" + ((c++)) && ((c==10)) && exit 1 + sleep 10 + done - DOCKER_PATH="${GITHUB_WORKSPACE}/apps/meteor/.docker" - if [[ '${{ matrix.release }}' = 'preview' ]]; then - DOCKER_PATH="${DOCKER_PATH}-mongo" - fi; + - name: 'Start service: presence' + run: | + LOWERCASE_REPOSITORY=$(echo "${{ github.repository_owner }}" | tr "[:upper:]" "[:lower:]") - echo "Build ${{ matrix.release }} Docker image" - cp ${DOCKER_PATH}/Dockerfile . - if [ -e ${DOCKER_PATH}/entrypoint.sh ]; then - cp ${DOCKER_PATH}/entrypoint.sh . - fi; + docker run --name presence -d \ + --link mongo \ + --link nats \ + -e MONGO_URL=mongodb://mongo:27017/rocketchat \ + -e MONGO_OPLOG_URL=mongodb://mongo:27017/local \ + -e TRANSPORTER=nats://nats:4222 \ + -e MOLECULER_LOG_LEVEL=info \ + ghcr.io/${LOWERCASE_REPOSITORY}/presence-service:${{ needs.release-versions.outputs.gh-docker-tag }} + + until echo "$(docker logs presence)" | grep -q "NetworkBroker started successfully"; do + echo "Waiting 'presence' to start up" + ((c++)) && ((c==10)) && exit 1 + sleep 10 + done - docker build -t $IMAGE_NAME . - docker push $IMAGE_NAME + - name: E2E Test API + run: | + cd ./apps/meteor + for i in $(seq 1 5); do + docker stop rocketchat + docker stop stream-hub + docker stop account + docker stop authorization + docker stop ddp-streamer + docker stop presence + + docker exec mongo mongo rocketchat --eval 'db.dropDatabase()' + + NOW=$(date "+%Y-%m-%dT%H:%M:%SZ") + echo $NOW + + docker start rocketchat + docker start stream-hub + docker start account + docker start authorization + docker start ddp-streamer + docker start presence + + until echo "$(docker logs rocketchat --since $NOW)" | grep -q "SERVER RUNNING"; do + echo "Waiting Rocket.Chat to start up" + ((c++)) && ((c==10)) && exit 1 + sleep 10 + done + + docker logs rocketchat --tail=50 + docker logs stream-hub --tail=50 + docker logs account --tail=50 + docker logs authorization --tail=50 + docker logs ddp-streamer --tail=50 + docker logs presence --tail=50 + + npm run testapi && s=0 && break || s=$? && docker logs rocketchat --tail=100 && docker logs authorization --tail=50; + done; + exit $s - services-image-build-check: - runs-on: ubuntu-20.04 - if: github.event.pull_request.head.repo.full_name == github.repository + - name: Install Playwright + run: | + cd ./apps/meteor + npx playwright install --with-deps - strategy: - matrix: - service: ['ddp-streamer'] + - name: E2E Test UI + run: | + echo -e 'pcm.!default {\n type hw\n card 0\n}\n\nctl.!default {\n type hw\n card 0\n}' > ~/.asoundrc + Xvfb -screen 0 1024x768x24 :99 & - steps: - - uses: actions/checkout@v3 + docker logs rocketchat --tail=50 - - name: Use Node.js 14.18.3 - uses: actions/setup-node@v3 - with: - node-version: '14.18.3' + docker stop rocketchat + docker stop stream-hub + docker stop account + docker stop authorization + docker stop ddp-streamer + docker stop presence - - uses: c-hive/gha-yarn-cache@v2 - - name: Cache turbo - id: cache-turbo - uses: actions/cache@v2 - with: - path: | - ./node_modules/.turbo - key: ${{ runner.OS }}-turbo-${{ hashFiles('**/yarn.lock') }} - restore-keys: | - ${{ runner.os }}-turbo- - ${{ runner.os }}- + docker exec mongo mongo rocketchat --eval 'db.dropDatabase()' - - name: Build Docker images - env: - IMAGE_TAG: check - run: | - yarn - yarn build + NOW=$(date "+%Y-%m-%dT%H:%M:%SZ") + echo $NOW - echo "Building Docker image for service: ${{ matrix.service }}:${IMAGE_TAG}" + docker start rocketchat + docker start stream-hub + docker start account + docker start authorization + docker start ddp-streamer + docker start presence - docker build \ - --build-arg SERVICE=${{ matrix.service }} \ - -t rocketchat/${{ matrix.service }}-service:${IMAGE_TAG} \ - -f ./ee/apps/ddp-streamer/Dockerfile \ - . + until echo "$(docker logs rocketchat --since $NOW)" | grep -q "SERVER RUNNING"; do + echo "Waiting Rocket.Chat to start up" + ((c++)) && ((c==10)) && exit 1 + sleep 10 + done - release-versions: - runs-on: ubuntu-latest - outputs: - release: ${{ steps.by-tag.outputs.release }} - latest-release: ${{ steps.latest.outputs.latest-release }} - steps: - - id: by-tag - run: | - if echo "$GITHUB_REF_NAME" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+$' ; then - RELEASE="latest" - elif echo "$GITHUB_REF_NAME" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+-rc\.[0-9]+$' ; then - RELEASE="release-candidate" - fi - echo "RELEASE: ${RELEASE}" - echo "::set-output name=release::${RELEASE}" + docker logs rocketchat --tail=50 + docker logs stream-hub --tail=50 + docker logs account --tail=50 + docker logs authorization --tail=50 + docker logs ddp-streamer --tail=50 + docker logs presence --tail=50 - - id: latest - run: | - LATEST_RELEASE="$( - git -c 'versionsort.suffix=-' ls-remote -t --exit-code --refs --sort=-v:refname "https://github.com/$GITHUB_REPOSITORY" '*' | - sed -En '1!q;s/^[[:xdigit:]]+[[:space:]]+refs\/tags\/(.+)/\1/gp' - )" - echo "LATEST_RELEASE: ${LATEST_RELEASE}" - echo "::set-output name=latest-release::${LATEST_RELEASE}" + cd ./apps/meteor + npm run test:playwright + + - name: Store playwright test trace + uses: actions/upload-artifact@v2 + if: failure() + with: + name: playwright-test-trace + path: ./apps/meteor/tests/e2e/test-failures* deploy: runs-on: ubuntu-20.04 @@ -661,114 +769,88 @@ jobs: -d '{"tag":"'$GIT_TAG'"}' fi - image-build: + docker-image-publish: runs-on: ubuntu-20.04 - needs: [deploy, release-versions] + needs: [deploy, build-docker-preview, release-versions] strategy: matrix: - # this is current a mix of variants and different images + # this is currently a mix of variants and different images release: ['official', 'preview', 'alpine'] env: IMAGE_NAME: 'rocketchat/rocket.chat' steps: - - uses: actions/checkout@v3 - - name: Login to DockerHub - uses: docker/login-action@v1 + uses: docker/login-action@v2 with: username: ${{ secrets.DOCKER_USER }} password: ${{ secrets.DOCKER_PASS }} - - name: Restore build - uses: actions/download-artifact@v2 + - name: Login to GitHub Container Registry + uses: docker/login-action@v2 with: - name: build - path: /tmp/build + registry: ghcr.io + username: ${{ secrets.CR_USER }} + password: ${{ secrets.CR_PAT }} - - name: Unpack build and prepare Docker files + - name: Get Docker image name + id: gh-docker run: | - cd /tmp/build - tar xzf Rocket.Chat.tar.gz - rm Rocket.Chat.tar.gz - - DOCKER_PATH="${GITHUB_WORKSPACE}/apps/meteor/.docker" - if [[ '${{ matrix.release }}' = 'preview' ]]; then - DOCKER_PATH="${DOCKER_PATH}-mongo" - fi; + LOWERCASE_REPOSITORY=$(echo "${{ github.repository_owner }}" | tr "[:upper:]" "[:lower:]") - DOCKERFILE_PATH="${DOCKER_PATH}/Dockerfile" - if [[ '${{ matrix.release }}' = 'alpine' ]]; then - DOCKERFILE_PATH="${DOCKERFILE_PATH}.${{ matrix.release }}" - fi; + GH_IMAGE_NAME="ghcr.io/${LOWERCASE_REPOSITORY}/rocket.chat:${{ needs.release-versions.outputs.gh-docker-tag }}.${{ matrix.release }}" - echo "Copy Dockerfile for release: ${{ matrix.release }}" - cp $DOCKERFILE_PATH ./Dockerfile - if [ -e ${DOCKER_PATH}/entrypoint.sh ]; then - cp ${DOCKER_PATH}/entrypoint.sh . - fi; + echo "GH_IMAGE_NAME: $GH_IMAGE_NAME" - - name: Build Docker image for tag - if: github.event_name == 'release' - run: | - cd /tmp/build + echo "::set-output name=gh-image-name::${GH_IMAGE_NAME}" - DOCKER_TAG=$GITHUB_REF_NAME + - name: Pull Docker image + run: docker pull ${{ steps.gh-docker.outputs.gh-image-name }} + - name: Publish Docker image + run: | if [[ '${{ matrix.release }}' = 'preview' ]]; then IMAGE_NAME="${IMAGE_NAME}.preview" fi; + # 'develop' or 'tag' + DOCKER_TAG=$GITHUB_REF_NAME + # append the variant name to docker tag if [[ '${{ matrix.release }}' = 'alpine' ]]; then DOCKER_TAG="${DOCKER_TAG}-${{ matrix.release }}" fi; - RELEASE="${{ needs.release-versions.outputs.release }}" - - if [[ '${{ matrix.release }}' = 'alpine' ]]; then - RELEASE="${RELEASE}-${{ matrix.release }}" - fi; - echo "IMAGE_NAME: $IMAGE_NAME" echo "DOCKER_TAG: $DOCKER_TAG" - echo "RELEASE: $RELEASE" - # build and push the specific tag version - docker build -t $IMAGE_NAME:$DOCKER_TAG . + # tag and push the specific tag version + docker tag ${{ steps.gh-docker.outputs.gh-image-name }} $IMAGE_NAME:$DOCKER_TAG docker push $IMAGE_NAME:$DOCKER_TAG - if [[ $RELEASE == 'latest' ]]; then - if [[ '${{ needs.release-versions.outputs.latest-release }}' == $GITHUB_REF_NAME ]]; then - docker tag $IMAGE_NAME:$DOCKER_TAG $IMAGE_NAME:$RELEASE - docker push $IMAGE_NAME:$RELEASE - fi - else - docker tag $IMAGE_NAME:$DOCKER_TAG $IMAGE_NAME:$RELEASE - docker push $IMAGE_NAME:$RELEASE - fi - - - name: Build Docker image for develop - if: github.ref == 'refs/heads/develop' - run: | - cd /tmp/build + if [[ $GITHUB_REF == refs/tags/* ]]; then + RELEASE="${{ needs.release-versions.outputs.release }}" - DOCKER_TAG=develop + if [[ '${{ matrix.release }}' = 'alpine' ]]; then + RELEASE="${RELEASE}-${{ matrix.release }}" + fi; - if [[ '${{ matrix.release }}' = 'preview' ]]; then - IMAGE_NAME="${IMAGE_NAME}.preview" - fi; + echo "RELEASE: $RELEASE" - if [[ '${{ matrix.release }}' = 'alpine' ]]; then - DOCKER_TAG="${DOCKER_TAG}-${{ matrix.release }}" - fi; - - docker build -t $IMAGE_NAME:$DOCKER_TAG . - docker push $IMAGE_NAME:$DOCKER_TAG + if [[ $RELEASE == 'latest' ]]; then + if [[ '${{ needs.release-versions.outputs.latest-release }}' == $GITHUB_REF_NAME ]]; then + docker tag ${{ steps.gh-docker.outputs.gh-image-name }} $IMAGE_NAME:$RELEASE + docker push $IMAGE_NAME:$RELEASE + fi + else + docker tag ${{ steps.gh-docker.outputs.gh-image-name }} $IMAGE_NAME:$RELEASE + docker push $IMAGE_NAME:$RELEASE + fi + fi - services-image-build: + services-docker-image-publish: runs-on: ubuntu-20.04 needs: [deploy, release-versions] @@ -777,57 +859,35 @@ jobs: service: ['account', 'authorization', 'ddp-streamer', 'presence', 'stream-hub'] steps: - - uses: actions/checkout@v3 - - - name: Use Node.js 14.18.3 - uses: actions/setup-node@v3 - with: - node-version: '14.18.3' - - uses: c-hive/gha-yarn-cache@v2 - - name: Cache turbo - id: cache-turbo - uses: actions/cache@v2 - with: - path: | - ./node_modules/.turbo - key: ${{ runner.OS }}-turbo-${{ hashFiles('**/yarn.lock') }} - restore-keys: | - ${{ runner.os }}-turbo- - ${{ runner.os }}- - - name: Login to DockerHub - uses: docker/login-action@v1 + uses: docker/login-action@v2 with: username: ${{ secrets.DOCKER_USER }} password: ${{ secrets.DOCKER_PASS }} - - name: Build Docker images + - name: Login to GitHub Container Registry + uses: docker/login-action@v2 + with: + registry: ghcr.io + username: ${{ secrets.CR_USER }} + password: ${{ secrets.CR_PAT }} + + - name: Publish Docker images run: | - # defines image tag - if [[ $GITHUB_REF == refs/tags/* ]]; then - IMAGE_TAG="${GITHUB_REF#refs/tags/}" - else - IMAGE_TAG="${GITHUB_REF#refs/heads/}" - fi + LOWERCASE_REPOSITORY=$(echo "${{ github.repository_owner }}" | tr "[:upper:]" "[:lower:]") - # first install repo dependencies - yarn - yarn build + IMAGE_TAG="${{ needs.release-versions.outputs.gh-docker-tag }}" - echo "Building Docker image for service: ${{ matrix.service }}:${IMAGE_TAG}" + GH_IMAGE_NAME="ghcr.io/${LOWERCASE_REPOSITORY}/${{ matrix.service }}-service:${IMAGE_TAG}" - if [[ "${{ matrix.service }}" == "ddp-streamer" ]]; then - DOCKERFILE_PATH="./ee/apps/ddp-streamer/Dockerfile" - else - DOCKERFILE_PATH="./apps/meteor/ee/server/services/Dockerfile" - fi + echo "GH_IMAGE_NAME: $GH_IMAGE_NAME" + + docker pull $GH_IMAGE_NAME - docker build \ - --build-arg SERVICE=${{ matrix.service }} \ - -t rocketchat/${{ matrix.service }}-service:${IMAGE_TAG} \ - -f ${DOCKERFILE_PATH} \ - . + # 'develop' or 'tag' + DOCKER_TAG=$GITHUB_REF_NAME + docker tag $GH_IMAGE_NAME rocketchat/${{ matrix.service }}-service:${IMAGE_TAG} docker push rocketchat/${{ matrix.service }}-service:${IMAGE_TAG} if [[ $GITHUB_REF == refs/tags/* ]]; then diff --git a/.yarnrc.yml b/.yarnrc.yml index 1d00b70c6e57..18948c0be5b9 100644 --- a/.yarnrc.yml +++ b/.yarnrc.yml @@ -11,4 +11,5 @@ plugins: spec: '@yarnpkg/plugin-typescript' yarnPath: .yarn/releases/yarn-3.2.0.cjs -checksumBehavior: 'ignore' +checksumBehavior: 'update' +enableImmutableInstalls: false diff --git a/_templates/package/new/package.json.ejs.t b/_templates/package/new/package.json.ejs.t index 827f44a01ad6..948e2ded4be4 100644 --- a/_templates/package/new/package.json.ejs.t +++ b/_templates/package/new/package.json.ejs.t @@ -17,7 +17,8 @@ to: packages/<%= name %>/package.json "lint": "eslint --ext .js,.jsx,.ts,.tsx .", "lint:fix": "eslint --ext .js,.jsx,.ts,.tsx . --fix", "jest": "jest", - "build": "rm -rf dist && tsc -p tsconfig.json" + "build": "rm -rf dist && tsc -p tsconfig.json", + "dev": "tsc -p --watch --preserveWatchOutput tsconfig.json" }, "main": "./dist/index.js", "typings": "./dist/index.d.ts", diff --git a/apps/meteor/.docker/Dockerfile.alpine b/apps/meteor/.docker/Dockerfile.alpine index 0c7bd994e8f5..47d5dacd6960 100644 --- a/apps/meteor/.docker/Dockerfile.alpine +++ b/apps/meteor/.docker/Dockerfile.alpine @@ -12,7 +12,7 @@ RUN set -x \ && npm install --production \ # Start hack for sharp... && rm -rf npm/node_modules/sharp \ - && npm install sharp@0.29.3 \ + && npm install sharp@0.30.4 \ && mv node_modules/sharp npm/node_modules/sharp \ # End hack for sharp && cd npm \ diff --git a/apps/meteor/.eslintignore b/apps/meteor/.eslintignore index cb0aed399c3b..9b8e18053149 100644 --- a/apps/meteor/.eslintignore +++ b/apps/meteor/.eslintignore @@ -5,7 +5,6 @@ packages/autoupdate/ packages/meteor-streams/ packages/meteor-timesync/ app/emoji-emojione/generateEmojiIndex.js -app/favico/favico.js packages/rocketchat-livechat/assets/rocketchat-livechat.min.js packages/rocketchat-livechat/assets/rocket-livechat.js app/theme/client/vendor/ diff --git a/apps/meteor/.gitignore b/apps/meteor/.gitignore index 3fc755f6bb5b..e8a861e58324 100644 --- a/apps/meteor/.gitignore +++ b/apps/meteor/.gitignore @@ -84,3 +84,4 @@ coverage /data tests/e2e/test-failures/ out.txt +dist diff --git a/apps/meteor/.meteorignore b/apps/meteor/.meteorignore index 9b77b349e3fd..7b2dc6e71c31 100644 --- a/apps/meteor/.meteorignore +++ b/apps/meteor/.meteorignore @@ -1,3 +1,4 @@ ee/server/services coverage data +dist diff --git a/apps/meteor/.scripts/start.js b/apps/meteor/.scripts/start.js deleted file mode 100644 index a29e7bb1dbf1..000000000000 --- a/apps/meteor/.scripts/start.js +++ /dev/null @@ -1,196 +0,0 @@ -#!/usr/bin/env node - -const path = require('path'); -const fs = require('fs'); -const { spawn } = require('child_process'); -const net = require('net'); - -const processes = []; - -const baseDir = path.resolve(__dirname, '..'); -const srcDir = path.resolve(baseDir); - -const isPortTaken = (port) => - new Promise((resolve, reject) => { - const tester = net - .createServer() - .once('error', (err) => (err.code === 'EADDRINUSE' ? resolve(true) : reject(err))) - .once('listening', () => tester.once('close', () => resolve(false)).close()) - .listen(port); - }); - -const waitPortRelease = (port, count = 0) => - new Promise((resolve, reject) => { - isPortTaken(port).then((taken) => { - if (!taken) { - return resolve(); - } - if (count > 60) { - return reject(); - } - console.log('Port', port, 'not released, waiting 1s...'); - setTimeout(() => { - waitPortRelease(port, ++count) - .then(resolve) - .catch(reject); - }, 1000); - }); - }); - -const appOptions = { - env: { - PORT: 3000, - ROOT_URL: 'http://localhost:3000', - }, -}; - -let killingAllProcess = false; -function killAllProcesses(mainExitCode) { - if (killingAllProcess) { - return; - } - killingAllProcess = true; - - processes.forEach((p) => { - console.log('Killing process', p.pid); - p.kill(); - }); - - waitPortRelease(appOptions.env.PORT) - .then(() => { - console.log(`Port ${appOptions.env.PORT} was released, exiting with code ${mainExitCode}`); - process.exit(mainExitCode); - }) - .catch((error) => { - console.error(`Error waiting port ${appOptions.env.PORT} to be released, exiting with code ${mainExitCode}`); - console.error(error); - process.exit(mainExitCode); - }); -} - -function startProcess(opts) { - console.log('Starting process', opts.name, opts.command, opts.params, opts.options.cwd); - const proc = spawn(opts.command, opts.params, opts.options); - processes.push(proc); - - if (opts.onData) { - proc.stdout.on('data', opts.onData); - } - - if (!opts.silent) { - proc.stdout.pipe(process.stdout); - proc.stderr.pipe(process.stderr); - } - - if (opts.logFile) { - const logStream = fs.createWriteStream(opts.logFile, { flags: 'a' }); - proc.stdout.pipe(logStream); - proc.stderr.pipe(logStream); - } - - proc.on('exit', function (code, signal) { - processes.splice(processes.indexOf(proc), 1); - - if (code != null) { - console.log(opts.name, `exited with code ${code}`); - } else { - console.log(opts.name, `exited with signal ${signal}`); - } - - killAllProcesses(code); - }); -} - -function startRocketChat() { - return new Promise((resolve) => { - const waitServerRunning = (message) => { - if (message.toString().match('SERVER RUNNING')) { - return resolve(); - } - }; - - startProcess({ - name: 'Meteor App', - command: 'node', - params: ['/tmp/build-test/bundle/main.js'], - onData: waitServerRunning, - options: { - cwd: srcDir, - env: { - ...appOptions.env, - ...process.env, - }, - }, - }); - }); -} - -async function startMicroservices() { - const waitStart = (resolve) => (message) => { - if (message.toString().match('NetworkBroker started successfully')) { - return resolve(); - } - }; - const startService = (name) => { - return new Promise((resolve) => { - const cwd = - name === 'ddp-streamer' - ? path.resolve(srcDir, '..', '..', 'ee', 'apps', name, 'dist', 'ee', 'apps', name) - : path.resolve(srcDir, 'ee', 'server', 'services', 'dist', 'ee', 'server', 'services', name); - - startProcess({ - name: `${name} service`, - command: 'node', - params: [name === 'ddp-streamer' ? 'src/service.js' : 'service.js'], - onData: waitStart(resolve), - options: { - cwd, - env: { - ...appOptions.env, - ...process.env, - PORT: 4000, - }, - }, - }); - }); - }; - - await Promise.all([ - startService('account'), - startService('authorization'), - startService('ddp-streamer'), - startService('presence'), - startService('stream-hub'), - ]); -} - -function startTests(options = []) { - const testOption = options.find((i) => i.startsWith('--test=')); - const testParam = testOption ? testOption.replace('--test=', '') : 'test'; - - console.log(`Running test "npm run ${testParam}"`); - - startProcess({ - name: 'Tests', - command: 'npm', - params: ['run', testParam], - options: { - env: { - ...process.env, - NODE_PATH: `${process.env.NODE_PATH + path.delimiter + srcDir + path.delimiter + srcDir}/node_modules`, - }, - }, - }); -} - -(async () => { - const [, , ...options] = process.argv; - - await startRocketChat(); - - if (options.includes('--enterprise')) { - await startMicroservices(); - } - - startTests(options); -})(); diff --git a/apps/meteor/app/api/server/api.d.ts b/apps/meteor/app/api/server/api.d.ts index a1dd63713747..ebc4871610e3 100644 --- a/apps/meteor/app/api/server/api.d.ts +++ b/apps/meteor/app/api/server/api.d.ts @@ -9,7 +9,7 @@ import type { } from '@rocket.chat/rest-typings'; import type { IUser, IMethodConnection, IRoom } from '@rocket.chat/core-typings'; import type { ValidateFunction } from 'ajv'; -import type { Request } from 'express'; +import type { Request, Response } from 'express'; import { ITwoFactorOptions } from '../../2fa/server/code'; @@ -73,11 +73,13 @@ type Options = ( type PartialThis = { readonly request: Request & { query: Record }; + readonly response: Response; }; type ActionThis = { readonly requestIp: string; urlParams: UrlParams; + readonly response: Response; // TODO make it unsafe readonly queryParams: TMethod extends 'GET' ? TOptions extends { validateParams: ValidateFunction } @@ -91,6 +93,9 @@ type ActionThis>; readonly request: Request; + + readonly queryOperations: TOptions extends { queryOperations: infer T } ? T : never; + /* @deprecated */ requestParams(): OperationParams; getLoggedInUser(): TOptions extends { authRequired: true } ? IUser : IUser | undefined; @@ -106,6 +111,8 @@ type ActionThis declare class APIClass { fieldSeparator: string; + updateRateLimiterDictionaryForRoute(route: string, rateLimiterDictionary: number): void; + limitedUserFieldsToExclude(fields: { [x: string]: unknown }, limitedUserFieldsToExclude: unknown): { [x: string]: unknown }; limitedUserFieldsToExcludeIfIsPrivilegedUser( diff --git a/apps/meteor/app/api/server/lib/users.ts b/apps/meteor/app/api/server/lib/users.ts index 8ff1737cc692..7762b9b20a18 100644 --- a/apps/meteor/app/api/server/lib/users.ts +++ b/apps/meteor/app/api/server/lib/users.ts @@ -16,7 +16,7 @@ export async function findUsersToAutocomplete({ term: string; }; }): Promise<{ - items: IUser[]; + items: Required>[]; }> { if (!(await hasPermissionAsync(uid, 'view-outside-room'))) { return { items: [] }; @@ -69,16 +69,7 @@ export function getInclusiveFields(query: { [k: string]: 1 }): {} { * get the default fields if **fields** are empty (`{}`) or `undefined`/`null` * @param {Object|null|undefined} fields the fields from parsed jsonQuery */ -export function getNonEmptyFields(fields: {}): { - name: number; - username: number; - emails: number; - roles: number; - status: number; - active: number; - avatarETag: number; - lastLogin: number; -} { +export function getNonEmptyFields(fields: { [k: string]: 1 | 0 }): { [k: string]: 1 } { const defaultFields = { name: 1, username: 1, @@ -88,7 +79,7 @@ export function getNonEmptyFields(fields: {}): { active: 1, avatarETag: 1, lastLogin: 1, - }; + } as const; if (!fields || Object.keys(fields).length === 0) { return defaultFields; diff --git a/apps/meteor/app/api/server/v1/assets.js b/apps/meteor/app/api/server/v1/assets.ts similarity index 73% rename from apps/meteor/app/api/server/v1/assets.js rename to apps/meteor/app/api/server/v1/assets.ts index 7138d565034b..655d4fa49e23 100644 --- a/apps/meteor/app/api/server/v1/assets.js +++ b/apps/meteor/app/api/server/v1/assets.ts @@ -1,4 +1,5 @@ import { Meteor } from 'meteor/meteor'; +import { isAssetsUnsetAssetProps } from '@rocket.chat/rest-typings'; import { RocketChatAssets } from '../../../assets/server'; import { API } from '../api'; @@ -8,12 +9,10 @@ API.v1.addRoute( 'assets.setAsset', { authRequired: true }, { - post() { - const [asset, { refreshAllClients }, assetName] = Promise.await( - getUploadFormData({ - request: this.request, - }), - ); + async post() { + const [asset, { refreshAllClients }, assetName] = await getUploadFormData({ + request: this.request, + }); const assetsKeys = Object.keys(RocketChatAssets.assets); @@ -36,7 +35,10 @@ API.v1.addRoute( API.v1.addRoute( 'assets.unsetAsset', - { authRequired: true }, + { + authRequired: true, + validateParams: isAssetsUnsetAssetProps, + }, { post() { const { assetName, refreshAllClients } = this.bodyParams; @@ -44,12 +46,10 @@ API.v1.addRoute( if (!isValidAsset) { throw new Meteor.Error('error-invalid-asset', 'Invalid asset'); } - Meteor.runAsUser(this.userId, () => { - Meteor.call('unsetAsset', assetName); - if (refreshAllClients) { - Meteor.call('refreshClients'); - } - }); + Meteor.call('unsetAsset', assetName); + if (refreshAllClients) { + Meteor.call('refreshClients'); + } return API.v1.success(); }, }, diff --git a/apps/meteor/app/api/server/v1/teams.ts b/apps/meteor/app/api/server/v1/teams.ts index 157f1ac6d894..5e82084e6095 100644 --- a/apps/meteor/app/api/server/v1/teams.ts +++ b/apps/meteor/app/api/server/v1/teams.ts @@ -141,11 +141,9 @@ API.v1.addRoute( }); } - await Promise.all([ - Team.unsetTeamIdOfRooms(this.userId, team._id), - Team.removeAllMembersFromTeam(team._id), - Team.deleteById(team._id), - ]); + await Promise.all([Team.unsetTeamIdOfRooms(this.userId, team._id), Team.removeAllMembersFromTeam(team._id)]); + + await Team.deleteById(team._id); return API.v1.success(); }, diff --git a/apps/meteor/app/api/server/v1/users.js b/apps/meteor/app/api/server/v1/users.ts similarity index 66% rename from apps/meteor/app/api/server/v1/users.js rename to apps/meteor/app/api/server/v1/users.ts index 094570518e2d..8e6808fd34b4 100644 --- a/apps/meteor/app/api/server/v1/users.js +++ b/apps/meteor/app/api/server/v1/users.ts @@ -1,57 +1,236 @@ +import { + isUserCreateParamsPOST, + isUserSetActiveStatusParamsPOST, + isUserDeactivateIdleParamsPOST, + isUsersInfoParamsGetProps, + isUserRegisterParamsPOST, + isUserLogoutParamsPOST, + isUsersListTeamsProps, + isUsersAutocompleteProps, + isUsersSetAvatarProps, + isUsersUpdateParamsPOST, + isUsersUpdateOwnBasicInfoParamsPOST, + isUsersSetPreferencesParamsPOST, +} from '@rocket.chat/rest-typings'; import { Meteor } from 'meteor/meteor'; import { Accounts } from 'meteor/accounts-base'; import { Match, check } from 'meteor/check'; import { TAPi18n } from 'meteor/rocketchat:tap-i18n'; -import _ from 'underscore'; +import { IExportOperation, IPersonalAccessToken, IUser } from '@rocket.chat/core-typings'; import { Users, Subscriptions } from '../../../models/server'; import { Users as UsersRaw } from '../../../models/server/raw'; -import { hasPermission } from '../../../authorization'; +import { hasPermission } from '../../../authorization/server'; import { settings } from '../../../settings/server'; -import { getURL } from '../../../utils'; import { validateCustomFields, saveUser, saveCustomFieldsWithoutValidation, checkUsernameAvailability, + setStatusText, setUserAvatar, saveCustomFields, - setStatusText, } from '../../../lib/server'; import { getFullUserDataByIdOrUsername } from '../../../lib/server/functions/getFullUserData'; import { API } from '../api'; -import { getUploadFormData } from '../lib/getUploadFormData'; import { findUsersToAutocomplete, getInclusiveFields, getNonEmptyFields, getNonEmptyQuery } from '../lib/users'; import { getUserForCheck, emailCheck } from '../../../2fa/server/code'; import { resetUserE2EEncriptionKey } from '../../../../server/lib/resetUserE2EKey'; -import { setUserStatus } from '../../../../imports/users-presence/server/activeUsers'; import { resetTOTP } from '../../../2fa/server/functions/resetTOTP'; import { Team } from '../../../../server/sdk'; import { isValidQuery } from '../lib/isValidQuery'; +import { setUserStatus } from '../../../../imports/users-presence/server/activeUsers'; +import { getURL } from '../../../utils/server'; +import { getUploadFormData } from '../lib/getUploadFormData'; API.v1.addRoute( - 'users.create', - { authRequired: true }, + 'users.getAvatar', + { authRequired: false }, + { + get() { + const user = this.getUserFromParams(); + + const url = getURL(`/avatar/${user.username}`, { cdn: false, full: true }); + this.response.setHeader('Location', url); + + return { + statusCode: 307, + body: url, + }; + }, + }, +); + +API.v1.addRoute( + 'users.update', + { authRequired: true, twoFactorRequired: true, validateParams: isUsersUpdateParamsPOST }, + { + post() { + const userData = { _id: this.bodyParams.userId, ...this.bodyParams.data }; + + Meteor.runAsUser(this.userId, () => saveUser(this.userId, userData)); + + if (this.bodyParams.data.customFields) { + saveCustomFields(this.bodyParams.userId, this.bodyParams.data.customFields); + } + + if (typeof this.bodyParams.data.active !== 'undefined') { + const { + userId, + data: { active }, + confirmRelinquish, + } = this.bodyParams; + + Meteor.call('setUserActiveStatus', userId, active, Boolean(confirmRelinquish)); + } + const { fields } = this.parseJsonQuery(); + + return API.v1.success({ user: Users.findOneById(this.bodyParams.userId, { fields }) }); + }, + }, +); + +API.v1.addRoute( + 'users.updateOwnBasicInfo', + { authRequired: true, validateParams: isUsersUpdateOwnBasicInfoParamsPOST }, + { + post() { + const userData = { + email: this.bodyParams.data.email, + realname: this.bodyParams.data.name, + username: this.bodyParams.data.username, + nickname: this.bodyParams.data.nickname, + statusText: this.bodyParams.data.statusText, + newPassword: this.bodyParams.data.newPassword, + typedPassword: this.bodyParams.data.currentPassword, + }; + + // saveUserProfile now uses the default two factor authentication procedures, so we need to provide that + const twoFactorOptions = !userData.typedPassword + ? null + : { + twoFactorCode: userData.typedPassword, + twoFactorMethod: 'password', + }; + + Meteor.call('saveUserProfile', userData, this.bodyParams.customFields, twoFactorOptions); + + return API.v1.success({ + user: Users.findOneById(this.userId, { fields: API.v1.defaultFieldsToExclude }), + }); + }, + }, +); + +API.v1.addRoute( + 'users.setPreferences', + { authRequired: true, validateParams: isUsersSetPreferencesParamsPOST }, { post() { - check(this.bodyParams, { - email: String, - name: String, - password: String, - username: String, - active: Match.Maybe(Boolean), - bio: Match.Maybe(String), - nickname: Match.Maybe(String), - statusText: Match.Maybe(String), - roles: Match.Maybe(Array), - joinDefaultChannels: Match.Maybe(Boolean), - requirePasswordChange: Match.Maybe(Boolean), - setRandomPassword: Match.Maybe(Boolean), - sendWelcomeEmail: Match.Maybe(Boolean), - verified: Match.Maybe(Boolean), - customFields: Match.Maybe(Object), + if (this.bodyParams.userId && this.bodyParams.userId !== this.userId && !hasPermission(this.userId, 'edit-other-user-info')) { + throw new Meteor.Error('error-action-not-allowed', 'Editing user is not allowed'); + } + const userId = this.bodyParams.userId ? this.bodyParams.userId : this.userId; + if (!Users.findOneById(userId)) { + throw new Meteor.Error('error-invalid-user', 'The optional "userId" param provided does not match any users'); + } + + Meteor.runAsUser(userId, () => Meteor.call('saveUserPreferences', this.bodyParams.data)); + const user = Users.findOneById(userId, { + fields: { + 'settings.preferences': 1, + 'language': 1, + }, }); + return API.v1.success({ + user: { + _id: user._id, + settings: { + preferences: { + ...user.settings.preferences, + language: user.language, + }, + }, + }, + }); + }, + }, +); + +API.v1.addRoute( + 'users.setAvatar', + { authRequired: true, validateParams: isUsersSetAvatarProps }, + { + async post() { + const canEditOtherUserAvatar = hasPermission(this.userId, 'edit-other-user-avatar'); + + if (!settings.get('Accounts_AllowUserAvatarChange') && !canEditOtherUserAvatar) { + throw new Meteor.Error('error-not-allowed', 'Change avatar is not allowed', { + method: 'users.setAvatar', + }); + } + + let user = ((): IUser | undefined => { + if (this.isUserFromParams()) { + return Meteor.users.findOne(this.userId) as IUser | undefined; + } + if (canEditOtherUserAvatar) { + return this.getUserFromParams(); + } + })(); + + if (!user) { + return API.v1.unauthorized(); + } + + if (this.bodyParams.avatarUrl) { + setUserAvatar(user, this.bodyParams.avatarUrl, '', 'url'); + return API.v1.success(); + } + + const [image, fields] = await getUploadFormData( + { + request: this.request, + }, + { + field: 'image', + }, + ); + + if (!image) { + return API.v1.failure("The 'image' param is required"); + } + + const sentTheUserByFormData = fields.userId || fields.username; + if (sentTheUserByFormData) { + if (fields.userId) { + user = Users.findOneById(fields.userId, { fields: { username: 1 } }); + } else if (fields.username) { + user = Users.findOneByUsernameIgnoringCase(fields.username, { fields: { username: 1 } }); + } + + if (!user) { + throw new Meteor.Error('error-invalid-user', 'The optional "userId" or "username" param provided does not match any users'); + } + + const isAnotherUser = this.userId !== user._id; + if (isAnotherUser && !hasPermission(this.userId, 'edit-other-user-avatar')) { + throw new Meteor.Error('error-not-allowed', 'Not allowed'); + } + } + setUserAvatar(user, image.fileBuffer, image.mimetype, 'rest'); + + return API.v1.success(); + }, + }, +); + +API.v1.addRoute( + 'users.create', + { authRequired: true, validateParams: isUserCreateParamsPOST }, + { + post() { // New change made by pull request #5152 if (typeof this.bodyParams.joinDefaultChannels === 'undefined') { this.bodyParams.joinDefaultChannels = true; @@ -68,9 +247,7 @@ API.v1.addRoute( } if (typeof this.bodyParams.active !== 'undefined') { - Meteor.runAsUser(this.userId, () => { - Meteor.call('setUserActiveStatus', newUserId, this.bodyParams.active); - }); + Meteor.call('setUserActiveStatus', newUserId, this.bodyParams.active); } const { fields } = this.parseJsonQuery(); @@ -92,9 +269,7 @@ API.v1.addRoute( const user = this.getUserFromParams(); const { confirmRelinquish = false } = this.requestParams(); - Meteor.runAsUser(this.userId, () => { - Meteor.call('deleteUser', user._id, confirmRelinquish); - }); + Meteor.call('deleteUser', user._id, confirmRelinquish); return API.v1.success(); }, @@ -116,52 +291,24 @@ API.v1.addRoute( const { confirmRelinquish = false } = this.requestParams(); - Meteor.runAsUser(this.userId, () => { - Meteor.call('deleteUserOwnAccount', password, confirmRelinquish); - }); + Meteor.call('deleteUserOwnAccount', password, confirmRelinquish); return API.v1.success(); }, }, ); -API.v1.addRoute( - 'users.getAvatar', - { authRequired: false }, - { - get() { - const user = this.getUserFromParams(); - - const url = getURL(`/avatar/${user.username}`, { cdn: false, full: true }); - this.response.setHeader('Location', url); - - return { - statusCode: 307, - body: url, - }; - }, - }, -); - API.v1.addRoute( 'users.setActiveStatus', - { authRequired: true }, + { authRequired: true, validateParams: isUserSetActiveStatusParamsPOST }, { post() { - check(this.bodyParams, { - userId: String, - activeStatus: Boolean, - confirmRelinquish: Match.Maybe(Boolean), - }); - if (!hasPermission(this.userId, 'edit-other-user-active-status')) { return API.v1.unauthorized(); } - Meteor.runAsUser(this.userId, () => { - const { userId, activeStatus, confirmRelinquish = false } = this.bodyParams; - Meteor.call('setUserActiveStatus', userId, activeStatus, confirmRelinquish); - }); + const { userId, activeStatus, confirmRelinquish = false } = this.bodyParams; + Meteor.call('setUserActiveStatus', userId, activeStatus, confirmRelinquish); return API.v1.success({ user: Users.findOneById(this.bodyParams.userId, { fields: { active: 1 } }), }); @@ -171,14 +318,9 @@ API.v1.addRoute( API.v1.addRoute( 'users.deactivateIdle', - { authRequired: true }, + { authRequired: true, validateParams: isUserDeactivateIdleParamsPOST }, { post() { - check(this.bodyParams, { - daysIdle: Match.Integer, - role: Match.Optional(String), - }); - if (!hasPermission(this.userId, 'edit-other-user-active-status')) { return API.v1.unauthorized(); } @@ -197,68 +339,41 @@ API.v1.addRoute( }, ); -API.v1.addRoute( - 'users.getPresence', - { authRequired: true }, - { - get() { - if (this.isUserFromParams()) { - const user = Users.findOneById(this.userId); - return API.v1.success({ - presence: user.status, - connectionStatus: user.statusConnection, - lastLogin: user.lastLogin, - }); - } - - const user = this.getUserFromParams(); - - return API.v1.success({ - presence: user.status, - }); - }, - }, -); - API.v1.addRoute( 'users.info', - { authRequired: true }, + { authRequired: true, validateParams: isUsersInfoParamsGetProps }, { - get() { - const { username, userId } = this.requestParams(); + async get() { const { fields } = this.parseJsonQuery(); - check(userId, Match.Maybe(String)); - check(username, Match.Maybe(String)); - - if (userId !== undefined && username !== undefined) { - throw new Meteor.Error('invalid-filter', 'Cannot filter by id and username at once'); - } - - if (!userId && !username) { - throw new Meteor.Error('invalid-filter', 'Must filter by id or username'); - } - - const user = getFullUserDataByIdOrUsername({ userId: this.userId, filterId: userId, filterUsername: username }); + const user = await getFullUserDataByIdOrUsername(this.userId, { + filterId: (this.queryParams as any).userId, + filterUsername: (this.queryParams as any).username, + }); if (!user) { return API.v1.failure('User not found.'); } const myself = user._id === this.userId; if (fields.userRooms === 1 && (myself || hasPermission(this.userId, 'view-other-user-channels'))) { - user.rooms = Subscriptions.findByUserId(user._id, { - fields: { - rid: 1, - name: 1, - t: 1, - roles: 1, - unread: 1, - }, - sort: { - t: 1, - name: 1, + return API.v1.success({ + user: { + ...user, + rooms: Subscriptions.findByUserId(user._id, { + projection: { + rid: 1, + name: 1, + t: 1, + roles: 1, + unread: 1, + }, + sort: { + t: 1, + name: 1, + }, + }).fetch(), }, - }).fetch(); + }); } return API.v1.success({ @@ -298,14 +413,14 @@ API.v1.addRoute( inclusiveFieldsKeys.includes('emails') && 'emails.address.*', inclusiveFieldsKeys.includes('username') && 'username.*', inclusiveFieldsKeys.includes('name') && 'name.*', - ].filter(Boolean), + ].filter(Boolean) as string[], this.queryOperations, ) ) { throw new Meteor.Error('error-invalid-query', isValidQuery.errors.join('\n')); } - const actualSort = sort && sort.name ? { nameInsensitive: sort.name, ...sort } : sort || { username: 1 }; + const actualSort = sort?.name ? { nameInsensitive: sort.name, ...sort } : sort || { username: 1 }; const limit = count !== 0 @@ -373,6 +488,7 @@ API.v1.addRoute( numRequestsAllowed: settings.get('Rate_Limiter_Limit_RegisterUser'), intervalTimeInMS: settings.get('API_Enable_Rate_Limiter_Limit_Time_Default'), }, + validateParams: isUserRegisterParamsPOST, }, { post() { @@ -380,306 +496,40 @@ API.v1.addRoute( return API.v1.failure('Logged in users can not register again.'); } - // We set their username here, so require it - // The `registerUser` checks for the other requirements - check( - this.bodyParams, - Match.ObjectIncluding({ - username: String, - }), - ); - if (!checkUsernameAvailability(this.bodyParams.username)) { return API.v1.failure('Username is already in use'); } // Register the user - const userId = Meteor.call('registerUser', this.bodyParams); - - // Now set their username - Meteor.runAsUser(userId, () => Meteor.call('setUsername', this.bodyParams.username)); - const { fields } = this.parseJsonQuery(); - - return API.v1.success({ user: Users.findOneById(userId, { fields }) }); - }, - }, -); - -API.v1.addRoute( - 'users.resetAvatar', - { authRequired: true }, - { - post() { - const user = this.getUserFromParams(); - - if (settings.get('Accounts_AllowUserAvatarChange') && user._id === this.userId) { - Meteor.runAsUser(this.userId, () => Meteor.call('resetAvatar')); - } else if (hasPermission(this.userId, 'edit-other-user-avatar')) { - Meteor.runAsUser(this.userId, () => Meteor.call('resetAvatar', user._id)); - } else { - throw new Meteor.Error('error-not-allowed', 'Reset avatar is not allowed', { - method: 'users.resetAvatar', - }); - } - - return API.v1.success(); - }, - }, -); - -API.v1.addRoute( - 'users.setAvatar', - { authRequired: true }, - { - async post() { - check( - this.bodyParams, - Match.ObjectIncluding({ - avatarUrl: Match.Maybe(String), - userId: Match.Maybe(String), - username: Match.Maybe(String), - }), - ); - const canEditOtherUserAvatar = hasPermission(this.userId, 'edit-other-user-avatar'); - - if (!settings.get('Accounts_AllowUserAvatarChange') && !canEditOtherUserAvatar) { - throw new Meteor.Error('error-not-allowed', 'Change avatar is not allowed', { - method: 'users.setAvatar', - }); - } - - let user; - if (this.isUserFromParams()) { - user = Meteor.users.findOne(this.userId); - } else if (canEditOtherUserAvatar) { - user = this.getUserFromParams(); - } else { - return API.v1.unauthorized(); - } - - if (this.bodyParams.avatarUrl) { - setUserAvatar(user, this.bodyParams.avatarUrl, '', 'url'); - return API.v1.success(); - } - - const [image, fields] = await getUploadFormData( - { - request: this.request, - }, - { field: 'image' }, - ); - - if (!image) { - return API.v1.failure("The 'image' param is required"); - } - - const sentTheUserByFormData = fields.userId || fields.username; - if (sentTheUserByFormData) { - if (fields.userId) { - user = Users.findOneById(fields.userId, { fields: { username: 1 } }); - } else if (fields.username) { - user = Users.findOneByUsernameIgnoringCase(fields.username, { fields: { username: 1 } }); - } - - if (!user) { - throw new Meteor.Error('error-invalid-user', 'The optional "userId" or "username" param provided does not match any users'); - } - - const isAnotherUser = this.userId !== user._id; - if (isAnotherUser && !hasPermission(this.userId, 'edit-other-user-avatar')) { - throw new Meteor.Error('error-not-allowed', 'Not allowed'); - } - } - - setUserAvatar(user, image.fileBuffer, image.mimetype, 'rest'); - - return API.v1.success(); - }, - }, -); - -API.v1.addRoute( - 'users.getStatus', - { authRequired: true }, - { - get() { - if (this.isUserFromParams()) { - const user = Users.findOneById(this.userId); - return API.v1.success({ - _id: user._id, - message: user.statusText, - connectionStatus: user.statusConnection, - status: user.status, - }); - } - - const user = this.getUserFromParams(); - - return API.v1.success({ - _id: user._id, - message: user.statusText, - status: user.status, - }); - }, - }, -); - -API.v1.addRoute( - 'users.setStatus', - { authRequired: true }, - { - post() { - check( - this.bodyParams, - Match.ObjectIncluding({ - status: Match.Maybe(String), - message: Match.Maybe(String), - }), - ); - - if (!settings.get('Accounts_AllowUserStatusMessageChange')) { - throw new Meteor.Error('error-not-allowed', 'Change status is not allowed', { - method: 'users.setStatus', - }); - } - - let user; - if (this.isUserFromParams()) { - user = Meteor.users.findOne(this.userId); - } else if (hasPermission(this.userId, 'edit-other-user-info')) { - user = this.getUserFromParams(); - } else { - return API.v1.unauthorized(); - } - - Meteor.runAsUser(user._id, () => { - if (this.bodyParams.message || this.bodyParams.message === '') { - setStatusText(user._id, this.bodyParams.message); - } - if (this.bodyParams.status) { - const validStatus = ['online', 'away', 'offline', 'busy']; - if (validStatus.includes(this.bodyParams.status)) { - const { status } = this.bodyParams; - - if (status === 'offline' && !settings.get('Accounts_AllowInvisibleStatusOption')) { - throw new Meteor.Error('error-status-not-allowed', 'Invisible status is disabled', { - method: 'users.setStatus', - }); - } - - Meteor.users.update(user._id, { - $set: { - status, - statusDefault: status, - }, - }); - - setUserStatus(user, status); - } else { - throw new Meteor.Error('error-invalid-status', 'Valid status types include online, away, offline, and busy.', { - method: 'users.setStatus', - }); - } - } - }); - - return API.v1.success(); - }, - }, -); - -API.v1.addRoute( - 'users.update', - { authRequired: true, twoFactorRequired: true }, - { - post() { - check(this.bodyParams, { - userId: String, - data: Match.ObjectIncluding({ - email: Match.Maybe(String), - name: Match.Maybe(String), - password: Match.Maybe(String), - username: Match.Maybe(String), - bio: Match.Maybe(String), - nickname: Match.Maybe(String), - statusText: Match.Maybe(String), - active: Match.Maybe(Boolean), - roles: Match.Maybe(Array), - joinDefaultChannels: Match.Maybe(Boolean), - requirePasswordChange: Match.Maybe(Boolean), - sendWelcomeEmail: Match.Maybe(Boolean), - verified: Match.Maybe(Boolean), - customFields: Match.Maybe(Object), - }), - }); - - const userData = _.extend({ _id: this.bodyParams.userId }, this.bodyParams.data); - - Meteor.runAsUser(this.userId, () => saveUser(this.userId, userData)); - - if (this.bodyParams.data.customFields) { - saveCustomFields(this.bodyParams.userId, this.bodyParams.data.customFields); - } - - if (typeof this.bodyParams.data.active !== 'undefined') { - const { - userId, - data: { active }, - confirmRelinquish = false, - } = this.bodyParams; + const userId = Meteor.call('registerUser', this.bodyParams); - Meteor.runAsUser(this.userId, () => { - Meteor.call('setUserActiveStatus', userId, active, confirmRelinquish); - }); - } + // Now set their username + Meteor.runAsUser(userId, () => Meteor.call('setUsername', this.bodyParams.username)); const { fields } = this.parseJsonQuery(); - return API.v1.success({ user: Users.findOneById(this.bodyParams.userId, { fields }) }); + return API.v1.success({ user: Users.findOneById(userId, { fields }) }); }, }, ); API.v1.addRoute( - 'users.updateOwnBasicInfo', + 'users.resetAvatar', { authRequired: true }, { post() { - check(this.bodyParams, { - data: Match.ObjectIncluding({ - email: Match.Maybe(String), - name: Match.Maybe(String), - username: Match.Maybe(String), - nickname: Match.Maybe(String), - statusText: Match.Maybe(String), - currentPassword: Match.Maybe(String), - newPassword: Match.Maybe(String), - }), - customFields: Match.Maybe(Object), - }); - - const userData = { - email: this.bodyParams.data.email, - realname: this.bodyParams.data.name, - username: this.bodyParams.data.username, - nickname: this.bodyParams.data.nickname, - statusText: this.bodyParams.data.statusText, - newPassword: this.bodyParams.data.newPassword, - typedPassword: this.bodyParams.data.currentPassword, - }; - - // saveUserProfile now uses the default two factor authentication procedures, so we need to provide that - const twoFactorOptions = !userData.typedPassword - ? null - : { - twoFactorCode: userData.typedPassword, - twoFactorMethod: 'password', - }; + const user = this.getUserFromParams(); - Meteor.runAsUser(this.userId, () => Meteor.call('saveUserProfile', userData, this.bodyParams.customFields, twoFactorOptions)); + if (settings.get('Accounts_AllowUserAvatarChange') && user._id === this.userId) { + Meteor.runAsUser(this.userId, () => Meteor.call('resetAvatar')); + } else if (hasPermission(this.userId, 'edit-other-user-avatar')) { + Meteor.runAsUser(this.userId, () => Meteor.call('resetAvatar', user._id)); + } else { + throw new Meteor.Error('error-not-allowed', 'Reset avatar is not allowed', { + method: 'users.resetAvatar', + }); + } - return API.v1.success({ - user: Users.findOneById(this.userId, { fields: API.v1.defaultFieldsToExclude }), - }); + return API.v1.success(); }, }, ); @@ -690,10 +540,7 @@ API.v1.addRoute( { post() { const user = this.getUserFromParams(); - let data; - Meteor.runAsUser(this.userId, () => { - data = Meteor.call('createToken', user._id); - }); + const data = Meteor.call('createToken', user._id); return data ? API.v1.success({ data }) : API.v1.unauthorized(); }, }, @@ -718,77 +565,6 @@ API.v1.addRoute( }, ); -API.v1.addRoute( - 'users.setPreferences', - { authRequired: true }, - { - post() { - check(this.bodyParams, { - userId: Match.Maybe(String), - data: Match.ObjectIncluding({ - newRoomNotification: Match.Maybe(String), - newMessageNotification: Match.Maybe(String), - clockMode: Match.Maybe(Number), - useEmojis: Match.Maybe(Boolean), - convertAsciiEmoji: Match.Maybe(Boolean), - saveMobileBandwidth: Match.Maybe(Boolean), - collapseMediaByDefault: Match.Maybe(Boolean), - autoImageLoad: Match.Maybe(Boolean), - emailNotificationMode: Match.Maybe(String), - unreadAlert: Match.Maybe(Boolean), - notificationsSoundVolume: Match.Maybe(Number), - desktopNotifications: Match.Maybe(String), - pushNotifications: Match.Maybe(String), - enableAutoAway: Match.Maybe(Boolean), - highlights: Match.Maybe(Array), - desktopNotificationRequireInteraction: Match.Maybe(Boolean), - messageViewMode: Match.Maybe(Number), - showMessageInMainThread: Match.Maybe(Boolean), - hideUsernames: Match.Maybe(Boolean), - hideRoles: Match.Maybe(Boolean), - displayAvatars: Match.Maybe(Boolean), - hideFlexTab: Match.Maybe(Boolean), - sendOnEnter: Match.Maybe(String), - language: Match.Maybe(String), - sidebarShowFavorites: Match.Optional(Boolean), - sidebarShowUnread: Match.Optional(Boolean), - sidebarSortby: Match.Optional(String), - sidebarViewMode: Match.Optional(String), - sidebarDisplayAvatar: Match.Optional(Boolean), - sidebarGroupByType: Match.Optional(Boolean), - muteFocusedConversations: Match.Optional(Boolean), - }), - }); - if (this.bodyParams.userId && this.bodyParams.userId !== this.userId && !hasPermission(this.userId, 'edit-other-user-info')) { - throw new Meteor.Error('error-action-not-allowed', 'Editing user is not allowed'); - } - const userId = this.bodyParams.userId ? this.bodyParams.userId : this.userId; - if (!Users.findOneById(userId)) { - throw new Meteor.Error('error-invalid-user', 'The optional "userId" param provided does not match any users'); - } - - Meteor.runAsUser(userId, () => Meteor.call('saveUserPreferences', this.bodyParams.data)); - const user = Users.findOneById(userId, { - fields: { - 'settings.preferences': 1, - 'language': 1, - }, - }); - return API.v1.success({ - user: { - _id: user._id, - settings: { - preferences: { - ...user.settings.preferences, - language: user.language, - }, - }, - }, - }); - }, - }, -); - API.v1.addRoute( 'users.forgotPassword', { authRequired: false }, @@ -810,7 +586,7 @@ API.v1.addRoute( { authRequired: true }, { get() { - const result = Meteor.runAsUser(this.userId, () => Meteor.call('getUsernameSuggestion')); + const result = Meteor.call('getUsernameSuggestion'); return API.v1.success({ result }); }, @@ -826,7 +602,7 @@ API.v1.addRoute( if (!tokenName) { return API.v1.failure("The 'tokenName' param is required"); } - const token = Meteor.runAsUser(this.userId, () => Meteor.call('personalAccessTokens:generateToken', { tokenName, bypassTwoFactor })); + const token = Meteor.call('personalAccessTokens:generateToken', { tokenName, bypassTwoFactor }); return API.v1.success({ token }); }, @@ -842,7 +618,7 @@ API.v1.addRoute( if (!tokenName) { return API.v1.failure("The 'tokenName' param is required"); } - const token = Meteor.runAsUser(this.userId, () => Meteor.call('personalAccessTokens:regenerateToken', { tokenName })); + const token = Meteor.call('personalAccessTokens:regenerateToken', { tokenName }); return API.v1.success({ token }); }, @@ -857,19 +633,19 @@ API.v1.addRoute( if (!hasPermission(this.userId, 'create-personal-access-tokens')) { throw new Meteor.Error('not-authorized', 'Not Authorized'); } - const loginTokens = Users.getLoginTokensByUserId(this.userId).fetch()[0]; - const getPersonalAccessTokens = () => - loginTokens.services.resume.loginTokens - .filter((loginToken) => loginToken.type && loginToken.type === 'personalAccessToken') - .map((loginToken) => ({ - name: loginToken.name, - createdAt: loginToken.createdAt, - lastTokenPart: loginToken.lastTokenPart, - bypassTwoFactor: loginToken.bypassTwoFactor, - })); + + const user = Users.getLoginTokensByUserId(this.userId).fetch()[0] as IUser | undefined; return API.v1.success({ - tokens: loginTokens ? getPersonalAccessTokens() : [], + tokens: + user?.services?.resume?.loginTokens + ?.filter((loginToken: any) => loginToken.type === 'personalAccessToken') + .map((loginToken: IPersonalAccessToken) => ({ + name: loginToken.name, + createdAt: loginToken.createdAt.toISOString(), + lastTokenPart: loginToken.lastTokenPart, + bypassTwoFactor: Boolean(loginToken.bypassTwoFactor), + })) || [], }); }, }, @@ -884,11 +660,9 @@ API.v1.addRoute( if (!tokenName) { return API.v1.failure("The 'tokenName' param is required"); } - Meteor.runAsUser(this.userId, () => - Meteor.call('personalAccessTokens:removeToken', { - tokenName, - }), - ); + Meteor.call('personalAccessTokens:removeToken', { + tokenName, + }); return API.v1.success(); }, @@ -931,7 +705,7 @@ API.v1.addRoute('users.2fa.sendEmailCode', { const userId = this.userId || Users[method](emailOrUsername, { fields: { _id: 1 } })?._id; if (!userId) { - this.logger.error('[2fa] User was not found when requesting 2fa email code'); + // this.logger.error('[2fa] User was not found when requesting 2fa email code'); return API.v1.success(); } @@ -968,7 +742,7 @@ API.v1.addRoute( if (from) { const ts = new Date(from); - const diff = (Date.now() - ts) / 1000 / 60; + const diff = (Date.now() - Number(ts)) / 1000 / 60; if (diff < 10) { return API.v1.success({ @@ -992,10 +766,13 @@ API.v1.addRoute( { get() { const { fullExport = false } = this.queryParams; - const result = Meteor.runAsUser(this.userId, () => Meteor.call('requestDataDownload', { fullExport: fullExport === 'true' })); + const result = Meteor.call('requestDataDownload', { fullExport: fullExport === 'true' }) as { + requested: boolean; + exportOperation: IExportOperation; + }; return API.v1.success({ - requested: result.requested, + requested: Boolean(result.requested), exportOperation: result.exportOperation, }); }, @@ -1007,48 +784,43 @@ API.v1.addRoute( { authRequired: true }, { async post() { - try { - const hashedToken = Accounts._hashLoginToken(this.request.headers['x-auth-token']); + const xAuthToken = this.request.headers['x-auth-token'] as string; - if (!(await UsersRaw.removeNonPATLoginTokensExcept(this.userId, hashedToken))) { - throw new Meteor.Error('error-invalid-user-id', 'Invalid user id'); - } + if (!xAuthToken) { + throw new Meteor.Error('error-parameter-required', 'x-auth-token is required'); + } + const hashedToken = Accounts._hashLoginToken(xAuthToken); - const me = await UsersRaw.findOneById(this.userId, { projection: { 'services.resume.loginTokens': 1 } }); + if (!(await UsersRaw.removeNonPATLoginTokensExcept(this.userId, hashedToken))) { + throw new Meteor.Error('error-invalid-user-id', 'Invalid user id'); + } - const token = me.services.resume.loginTokens.find((token) => token.hashedToken === hashedToken); + const me = (await UsersRaw.findOneById(this.userId, { projection: { 'services.resume.loginTokens': 1 } })) as Pick; - const tokenExpires = new Date(token.when.getTime() + settings.get('Accounts_LoginExpiration') * 1000); + const token = me.services?.resume?.loginTokens?.find((token) => token.hashedToken === hashedToken); - return API.v1.success({ - token: this.request.headers['x-auth-token'], - tokenExpires, - }); - } catch (error) { - return API.v1.failure(error); - } + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const tokenExpires = new Date(token!.when.getTime() + settings.get('Accounts_LoginExpiration') * 1000); + + return API.v1.success({ + token: xAuthToken, + tokenExpires: tokenExpires.toISOString() || '', + }); }, }, ); API.v1.addRoute( 'users.autocomplete', - { authRequired: true }, + { authRequired: true, validateParams: isUsersAutocompleteProps }, { - get() { + async get() { const { selector } = this.queryParams; - - if (!selector) { - return API.v1.failure("The 'selector' param is required"); - } - return API.v1.success( - Promise.await( - findUsersToAutocomplete({ - uid: this.userId, - selector: JSON.parse(selector), - }), - ), + await findUsersToAutocomplete({ + uid: this.userId, + selector: JSON.parse(selector), + }), ); }, }, @@ -1059,7 +831,7 @@ API.v1.addRoute( { authRequired: true }, { post() { - API.v1.success(Meteor.call('removeOtherTokens')); + return API.v1.success(Meteor.call('removeOtherTokens')); }, }, ); @@ -1069,30 +841,28 @@ API.v1.addRoute( { authRequired: true, twoFactorRequired: true, twoFactorOptions: { disableRememberMe: true } }, { post() { - // reset own keys - if (this.isUserFromParams()) { - resetUserE2EEncriptionKey(this.userId, false); - return API.v1.success(); - } + if ('userId' in this.bodyParams || 'username' in this.bodyParams || 'user' in this.bodyParams) { + // reset other user keys + const user = this.getUserFromParams(); + if (!user) { + throw new Meteor.Error('error-invalid-user-id', 'Invalid user id'); + } - // reset other user keys - const user = this.getUserFromParams(); - if (!user) { - throw new Meteor.Error('error-invalid-user-id', 'Invalid user id'); - } + if (!settings.get('Accounts_TwoFactorAuthentication_Enforce_Password_Fallback')) { + throw new Meteor.Error('error-not-allowed', 'Not allowed'); + } - if (!settings.get('Accounts_TwoFactorAuthentication_Enforce_Password_Fallback')) { - throw new Meteor.Error('error-not-allowed', 'Not allowed'); - } + if (!hasPermission(this.userId, 'edit-other-user-e2ee')) { + throw new Meteor.Error('error-not-allowed', 'Not allowed'); + } - if (!hasPermission(Meteor.userId(), 'edit-other-user-e2ee')) { - throw new Meteor.Error('error-not-allowed', 'Not allowed'); - } + if (!resetUserE2EEncriptionKey(user._id, true)) { + return API.v1.failure(); + } - if (!resetUserE2EEncriptionKey(user._id, true)) { - return API.v1.failure(); + return API.v1.success(); } - + resetUserE2EEncriptionKey(this.userId, false); return API.v1.success(); }, }, @@ -1102,29 +872,28 @@ API.v1.addRoute( 'users.resetTOTP', { authRequired: true, twoFactorRequired: true, twoFactorOptions: { disableRememberMe: true } }, { - post() { - // reset own keys - if (this.isUserFromParams()) { - Promise.await(resetTOTP(this.userId, false)); - return API.v1.success(); - } - - // reset other user keys - const user = this.getUserFromParams(); - if (!user) { - throw new Meteor.Error('error-invalid-user-id', 'Invalid user id'); - } + async post() { + // // reset own keys + if ('userId' in this.bodyParams || 'username' in this.bodyParams || 'user' in this.bodyParams) { + // reset other user keys + if (!hasPermission(this.userId, 'edit-other-user-totp')) { + throw new Meteor.Error('error-not-allowed', 'Not allowed'); + } - if (!settings.get('Accounts_TwoFactorAuthentication_Enforce_Password_Fallback')) { - throw new Meteor.Error('error-not-allowed', 'Not allowed'); - } + if (!settings.get('Accounts_TwoFactorAuthentication_Enforce_Password_Fallback')) { + throw new Meteor.Error('error-not-allowed', 'Not allowed'); + } - if (!hasPermission(Meteor.userId(), 'edit-other-user-totp')) { - throw new Meteor.Error('error-not-allowed', 'Not allowed'); - } + const user = this.getUserFromParams(); + if (!user) { + throw new Meteor.Error('error-invalid-user-id', 'Invalid user id'); + } - Promise.await(resetTOTP(user._id, true)); + await resetTOTP(user._id, true); + return API.v1.success(); + } + await resetTOTP(this.userId, false); return API.v1.success(); }, }, @@ -1132,25 +901,22 @@ API.v1.addRoute( API.v1.addRoute( 'users.listTeams', - { authRequired: true }, + { authRequired: true, validateParams: isUsersListTeamsProps }, { - get() { + async get() { check( this.queryParams, Match.ObjectIncluding({ userId: Match.Maybe(String), }), ); - const { userId } = this.queryParams; - if (!userId) { - throw new Meteor.Error('error-invalid-user-id', 'Invalid user id'); - } + const { userId } = this.queryParams; // If the caller has permission to view all teams, there's no need to filter the teams const adminId = hasPermission(this.userId, 'view-all-teams') ? undefined : this.userId; - const teams = Promise.await(Team.findBySubscribedUserIds(userId, adminId)); + const teams = await Team.findBySubscribedUserIds(userId, adminId); return API.v1.success({ teams, @@ -1161,7 +927,7 @@ API.v1.addRoute( API.v1.addRoute( 'users.logout', - { authRequired: true }, + { authRequired: true, validateParams: isUserLogoutParamsPOST }, { post() { const userId = this.bodyParams.userId || this.userId; @@ -1182,7 +948,130 @@ API.v1.addRoute( }, ); -settings.watch('Rate_Limiter_Limit_RegisterUser', (value) => { +API.v1.addRoute( + 'users.getPresence', + { authRequired: true }, + { + get() { + if (this.isUserFromParams()) { + const user = Users.findOneById(this.userId); + return API.v1.success({ + presence: user.status || 'offline', + connectionStatus: user.statusConnection || 'offline', + ...(user.lastLogin && { lastLogin: user.lastLogin }), + }); + } + + const user = this.getUserFromParams(); + + return API.v1.success({ + presence: user.status || 'offline', + }); + }, + }, +); + +API.v1.addRoute( + 'users.setStatus', + { authRequired: true }, + { + post() { + check( + this.bodyParams, + Match.ObjectIncluding({ + status: Match.Maybe(String), + message: Match.Maybe(String), + }), + ); + + if (!settings.get('Accounts_AllowUserStatusMessageChange')) { + throw new Meteor.Error('error-not-allowed', 'Change status is not allowed', { + method: 'users.setStatus', + }); + } + + const user = ((): IUser | undefined => { + if (this.isUserFromParams()) { + return Meteor.users.findOne(this.userId) as IUser; + } + if (hasPermission(this.userId, 'edit-other-user-info')) { + return this.getUserFromParams(); + } + })(); + + if (user === undefined) { + return API.v1.unauthorized(); + } + + Meteor.runAsUser(user._id, () => { + if (this.bodyParams.message || this.bodyParams.message === '') { + setStatusText(user._id, this.bodyParams.message); + } + if (this.bodyParams.status) { + const validStatus = ['online', 'away', 'offline', 'busy']; + if (validStatus.includes(this.bodyParams.status)) { + const { status } = this.bodyParams; + + if (status === 'offline' && !settings.get('Accounts_AllowInvisibleStatusOption')) { + throw new Meteor.Error('error-status-not-allowed', 'Invisible status is disabled', { + method: 'users.setStatus', + }); + } + + Meteor.users.update(user._id, { + $set: { + status, + statusDefault: status, + }, + }); + + setUserStatus(user, status); + } else { + throw new Meteor.Error('error-invalid-status', 'Valid status types include online, away, offline, and busy.', { + method: 'users.setStatus', + }); + } + } + }); + + return API.v1.success(); + }, + }, +); + +// status: 'online' | 'offline' | 'away' | 'busy'; +// message?: string; +// _id: string; +// connectionStatus?: 'online' | 'offline' | 'away' | 'busy'; +// }; + +API.v1.addRoute( + 'users.getStatus', + { authRequired: true }, + { + get() { + if (this.isUserFromParams()) { + const user = Users.findOneById(this.userId); + return API.v1.success({ + _id: user._id, + // message: user.statusText, + connectionStatus: (user.statusConnection || 'offline') as 'online' | 'offline' | 'away' | 'busy', + status: (user.status || 'offline') as 'online' | 'offline' | 'away' | 'busy', + }); + } + + const user = this.getUserFromParams(); + + return API.v1.success({ + _id: user._id, + // message: user.statusText, + status: (user.status || 'offline') as 'online' | 'offline' | 'away' | 'busy', + }); + }, + }, +); + +settings.watch('Rate_Limiter_Limit_RegisterUser', (value) => { const userRegisterRoute = '/api/v1/users.registerpost'; API.v1.updateRateLimiterDictionaryForRoute(userRegisterRoute, value); diff --git a/apps/meteor/app/assets/server/assets.js b/apps/meteor/app/assets/server/assets.ts similarity index 63% rename from apps/meteor/app/assets/server/assets.js rename to apps/meteor/app/assets/server/assets.ts index 79ced079ea60..84ed75f8480e 100644 --- a/apps/meteor/app/assets/server/assets.js +++ b/apps/meteor/app/assets/server/assets.ts @@ -1,4 +1,5 @@ import crypto from 'crypto'; +import { ServerResponse, IncomingMessage } from 'http'; import { Meteor } from 'meteor/meteor'; import { WebApp, WebAppInternals } from 'meteor/webapp'; @@ -6,19 +7,20 @@ import { WebAppHashing } from 'meteor/webapp-hashing'; import _ from 'underscore'; import sizeOf from 'image-size'; import sharp from 'sharp'; +import { NextHandleFunction } from 'connect'; +import { IRocketChatAssets, IRocketChatAsset } from '@rocket.chat/core-typings'; import { settings, settingsRegistry } from '../../settings/server'; import { getURL } from '../../utils/lib/getURL'; -import { mime } from '../../utils/lib/mimeTypes'; -import { hasPermission } from '../../authorization'; +import { getExtension } from '../../utils/lib/mimeTypes'; +import { hasPermission } from '../../authorization/server'; import { RocketChatFile } from '../../file'; import { Settings } from '../../models/server'; const RocketChatAssetsInstance = new RocketChatFile.GridFS({ name: 'assets', }); - -const assets = { +const assets: IRocketChatAssets = { logo: { label: 'logo (svg, png, jpg)', defaultUrl: 'images/logo/logo.svg', @@ -38,6 +40,7 @@ const assets = { extensions: ['svg', 'png', 'jpg', 'jpeg'], }, }, + // eslint-disable-next-line @typescript-eslint/camelcase favicon_ico: { label: 'favicon (ico)', defaultUrl: 'favicon.ico', @@ -54,6 +57,7 @@ const assets = { extensions: ['svg'], }, }, + // eslint-disable-next-line @typescript-eslint/camelcase favicon_16: { label: 'favicon 16x16 (png)', defaultUrl: 'images/logo/favicon-16x16.png', @@ -64,6 +68,7 @@ const assets = { height: 16, }, }, + // eslint-disable-next-line @typescript-eslint/camelcase favicon_32: { label: 'favicon 32x32 (png)', defaultUrl: 'images/logo/favicon-32x32.png', @@ -74,6 +79,7 @@ const assets = { height: 32, }, }, + // eslint-disable-next-line @typescript-eslint/camelcase favicon_192: { label: 'android-chrome 192x192 (png)', defaultUrl: 'images/logo/android-chrome-192x192.png', @@ -84,6 +90,7 @@ const assets = { height: 192, }, }, + // eslint-disable-next-line @typescript-eslint/camelcase favicon_512: { label: 'android-chrome 512x512 (png)', defaultUrl: 'images/logo/android-chrome-512x512.png', @@ -94,6 +101,7 @@ const assets = { height: 512, }, }, + // eslint-disable-next-line @typescript-eslint/camelcase touchicon_180: { label: 'apple-touch-icon 180x180 (png)', defaultUrl: 'images/logo/apple-touch-icon.png', @@ -104,6 +112,7 @@ const assets = { height: 180, }, }, + // eslint-disable-next-line @typescript-eslint/camelcase touchicon_180_pre: { label: 'apple-touch-icon-precomposed 180x180 (png)', defaultUrl: 'images/logo/apple-touch-icon-precomposed.png', @@ -114,6 +123,7 @@ const assets = { height: 180, }, }, + // eslint-disable-next-line @typescript-eslint/camelcase tile_70: { label: 'mstile 70x70 (png)', defaultUrl: 'images/logo/mstile-70x70.png', @@ -124,6 +134,7 @@ const assets = { height: 70, }, }, + // eslint-disable-next-line @typescript-eslint/camelcase tile_144: { label: 'mstile 144x144 (png)', defaultUrl: 'images/logo/mstile-144x144.png', @@ -134,6 +145,7 @@ const assets = { height: 144, }, }, + // eslint-disable-next-line @typescript-eslint/camelcase tile_150: { label: 'mstile 150x150 (png)', defaultUrl: 'images/logo/mstile-150x150.png', @@ -144,6 +156,7 @@ const assets = { height: 150, }, }, + // eslint-disable-next-line @typescript-eslint/camelcase tile_310_square: { label: 'mstile 310x310 (png)', defaultUrl: 'images/logo/mstile-310x310.png', @@ -154,6 +167,7 @@ const assets = { height: 310, }, }, + // eslint-disable-next-line @typescript-eslint/camelcase tile_310_wide: { label: 'mstile 310x150 (png)', defaultUrl: 'images/logo/mstile-310x150.png', @@ -164,6 +178,7 @@ const assets = { height: 150, }, }, + // eslint-disable-next-line @typescript-eslint/camelcase safari_pinned: { label: 'safari pinned tab (svg)', defaultUrl: 'images/logo/safari-pinned-tab.svg', @@ -174,24 +189,25 @@ const assets = { }, }; -export const RocketChatAssets = new (class { - get mime() { - return mime; - } +function getAssetByKey(key: string): IRocketChatAsset { + return assets[key as keyof IRocketChatAssets]; +} - get assets() { +class RocketChatAssetsClass { + get assets(): IRocketChatAssets { return assets; } - setAsset(binaryContent, contentType, asset) { - if (!assets[asset]) { + public setAsset(binaryContent: BufferEncoding, contentType: string, asset: string): void { + const assetInstance = getAssetByKey(asset); + if (!assetInstance) { throw new Meteor.Error('error-invalid-asset', 'Invalid asset', { function: 'RocketChat.Assets.setAsset', }); } - const extension = mime.extension(contentType); - if (assets[asset].constraints.extensions.includes(extension) === false) { + const extension = getExtension(contentType); + if (assetInstance.constraints.extensions.includes(extension) === false) { throw new Meteor.Error(contentType, `Invalid file type: ${contentType}`, { function: 'RocketChat.Assets.setAsset', errorTitle: 'error-invalid-file-type', @@ -199,14 +215,14 @@ export const RocketChatAssets = new (class { } const file = Buffer.from(binaryContent, 'binary'); - if (assets[asset].constraints.width || assets[asset].constraints.height) { + if (assetInstance.constraints.width || assetInstance.constraints.height) { const dimensions = sizeOf(file); - if (assets[asset].constraints.width && assets[asset].constraints.width !== dimensions.width) { + if (assetInstance.constraints.width && assetInstance.constraints.width !== dimensions.width) { throw new Meteor.Error('error-invalid-file-width', 'Invalid file width', { function: 'Invalid file width', }); } - if (assets[asset].constraints.height && assets[asset].constraints.height !== dimensions.height) { + if (assetInstance.constraints.height && assetInstance.constraints.height !== dimensions.height) { throw new Meteor.Error('error-invalid-file-height'); } } @@ -222,10 +238,11 @@ export const RocketChatAssets = new (class { const key = `Assets_${asset}`; const value = { url: `assets/${asset}.${extension}`, - defaultUrl: assets[asset].defaultUrl, + defaultUrl: assetInstance.defaultUrl, }; Settings.updateValueById(key, value); + // eslint-disable-next-line @typescript-eslint/no-use-before-define return RocketChatAssets.processAsset(key, value); }, 200); }), @@ -234,8 +251,8 @@ export const RocketChatAssets = new (class { rs.pipe(ws); } - unsetAsset(asset) { - if (!assets[asset]) { + public unsetAsset(asset: string): void { + if (!getAssetByKey(asset)) { throw new Meteor.Error('error-invalid-asset', 'Invalid asset', { function: 'RocketChat.Assets.unsetAsset', }); @@ -244,26 +261,27 @@ export const RocketChatAssets = new (class { RocketChatAssetsInstance.deleteFile(asset); const key = `Assets_${asset}`; const value = { - defaultUrl: assets[asset].defaultUrl, + defaultUrl: getAssetByKey(asset).defaultUrl, }; Settings.updateValueById(key, value); + // eslint-disable-next-line @typescript-eslint/no-use-before-define RocketChatAssets.processAsset(key, value); } - refreshClients() { - return process.emit('message', { + public refreshClients(): boolean { + return (process.emit as Function)('message', { refresh: 'client', }); } - processAsset(settingKey, settingValue) { + public processAsset(settingKey: string, settingValue: any): Record | undefined { if (settingKey.indexOf('Assets_') !== 0) { return; } const assetKey = settingKey.replace(/^Assets_/, ''); - const assetValue = assets[assetKey]; + const assetValue = getAssetByKey(assetKey); if (!assetValue) { return; @@ -301,23 +319,25 @@ export const RocketChatAssets = new (class { return assetValue.cache; } - getURL(assetName, options = { cdn: false, full: true }) { - const asset = settings.get(assetName); + public getURL(assetName: string, options = { cdn: false, full: true }): string { + const asset = settings.get(assetName); const url = asset.url || asset.defaultUrl; return getURL(url, options); } -})(); +} -settingsRegistry.addGroup('Assets'); +export const RocketChatAssets = new RocketChatAssetsClass(); -settingsRegistry.add('Assets_SvgFavicon_Enable', true, { - type: 'boolean', - group: 'Assets', - i18nLabel: 'Enable_Svg_Favicon', +settingsRegistry.addGroup('Assets', function () { + this.add('Assets_SvgFavicon_Enable', true, { + type: 'boolean', + group: 'Assets', + i18nLabel: 'Enable_Svg_Favicon', + }); }); -function addAssetToSetting(asset, value) { +function addAssetToSetting(asset: string, value: IRocketChatAsset): void { const key = `Assets_${asset}`; settingsRegistry.add( @@ -336,16 +356,16 @@ function addAssetToSetting(asset, value) { }, ); - const currentValue = settings.get(key); + const currentValue = settings.get(key); - if (typeof currentValue === 'object' && currentValue.defaultUrl !== assets[asset].defaultUrl) { - currentValue.defaultUrl = assets[asset].defaultUrl; + if (typeof currentValue === 'object' && currentValue.defaultUrl !== getAssetByKey(asset).defaultUrl) { + currentValue.defaultUrl = getAssetByKey(asset).defaultUrl; Settings.updateValueById(key, currentValue); } } for (const key of Object.keys(assets)) { - const value = assets[key]; + const value = getAssetByKey(key); addAssetToSetting(key, value); } @@ -353,7 +373,7 @@ settings.watchByRegex(/^Assets_/, (key, value) => RocketChatAssets.processAsset( Meteor.startup(function () { return Meteor.setTimeout(function () { - return process.emit('message', { + return (process.emit as Function)('message', { refresh: 'client', }); }, 200); @@ -361,9 +381,9 @@ Meteor.startup(function () { const { calculateClientHash } = WebAppHashing; -WebAppHashing.calculateClientHash = function (manifest, includeFilter, runtimeConfigOverride) { +WebAppHashing.calculateClientHash = function (manifest: Record, includeFilter: Function, runtimeConfigOverride: any): string { for (const key of Object.keys(assets)) { - const value = assets[key]; + const value = getAssetByKey(key); if (!value.cache && !value.defaultUrl) { continue; } @@ -381,7 +401,7 @@ WebAppHashing.calculateClientHash = function (manifest, includeFilter, runtimeCo hash: value.cache.hash, }; } else { - const extension = value.defaultUrl.split('.').pop(); + const extension = value.defaultUrl?.split('.').pop(); cache = { path: `assets/${key}.${extension}`, cacheable: false, @@ -416,7 +436,7 @@ Meteor.methods({ }); } - const _hasPermission = hasPermission(Meteor.userId(), 'manage-assets'); + const _hasPermission = hasPermission(Meteor.userId() as string, 'manage-assets'); if (!_hasPermission) { throw new Meteor.Error('error-action-not-allowed', 'Managing assets not allowed', { method: 'refreshClients', @@ -434,7 +454,7 @@ Meteor.methods({ }); } - const _hasPermission = hasPermission(Meteor.userId(), 'manage-assets'); + const _hasPermission = hasPermission(Meteor.userId() as string, 'manage-assets'); if (!_hasPermission) { throw new Meteor.Error('error-action-not-allowed', 'Managing assets not allowed', { method: 'unsetAsset', @@ -452,7 +472,7 @@ Meteor.methods({ }); } - const _hasPermission = hasPermission(Meteor.userId(), 'manage-assets'); + const _hasPermission = hasPermission(Meteor.userId() as string, 'manage-assets'); if (!_hasPermission) { throw new Meteor.Error('error-action-not-allowed', 'Managing assets not allowed', { method: 'setAsset', @@ -464,62 +484,63 @@ Meteor.methods({ }, }); -WebApp.connectHandlers.use( - '/assets/', - Meteor.bindEnvironment(function (req, res, next) { - const params = { - asset: decodeURIComponent(req.url.replace(/^\//, '').replace(/\?.*$/, '')).replace(/\.[^.]*$/, ''), - }; +const listener = Meteor.bindEnvironment((req: IncomingMessage, res: ServerResponse, next: NextHandleFunction) => { + if (!req.url) { + return; + } + const params = { + asset: decodeURIComponent(req.url.replace(/^\//, '').replace(/\?.*$/, '')).replace(/\.[^.]*$/, ''), + }; - const file = assets[params.asset] && assets[params.asset].cache; + const asset = getAssetByKey(params.asset); + const file = asset?.cache; - const format = req.url.replace(/.*\.([a-z]+)(?:$|\?.*)/i, '$1'); + const format = req.url.split('.').pop() || ''; - if ( - assets[params.asset] && - Array.isArray(assets[params.asset].constraints.extensions) && - !assets[params.asset].constraints.extensions.includes(format) - ) { - res.writeHead(403); - return res.end(); + if (asset && Array.isArray(asset.constraints.extensions) && !asset.constraints.extensions.includes(format)) { + res.writeHead(403); + return res.end(); + } + if (!file) { + const defaultUrl = asset?.defaultUrl; + if (defaultUrl) { + const assetUrl = format && ['png', 'svg'].includes(format) ? defaultUrl.replace(/(svg|png)$/, format) : defaultUrl; + req.url = `/${assetUrl}`; + WebAppInternals.staticFilesMiddleware((WebAppInternals as Record).staticFilesByArch, req, res, next); + } else { + res.writeHead(404); + res.end(); } - if (!file) { - const defaultUrl = assets[params.asset] && assets[params.asset].defaultUrl; - if (defaultUrl) { - const assetUrl = format && ['png', 'svg'].includes(format) ? defaultUrl.replace(/(svg|png)$/, format) : defaultUrl; - req.url = `/${assetUrl}`; - WebAppInternals.staticFilesMiddleware(WebAppInternals.staticFilesByArch, req, res, next); - } else { - res.writeHead(404); - res.end(); - } + return; + } + + const reqModifiedHeader = req.headers['if-modified-since']; + if (reqModifiedHeader) { + if (reqModifiedHeader === (file.uploadDate && file.uploadDate.toUTCString())) { + res.setHeader('Last-Modified', reqModifiedHeader); + res.writeHead(304); + res.end(); return; } + } - const reqModifiedHeader = req.headers['if-modified-since']; - if (reqModifiedHeader) { - if (reqModifiedHeader === (file.uploadDate && file.uploadDate.toUTCString())) { - res.setHeader('Last-Modified', reqModifiedHeader); - res.writeHead(304); - res.end(); - return; - } - } + res.setHeader('Cache-Control', 'public, max-age=0'); + res.setHeader('Expires', '-1'); - res.setHeader('Cache-Control', 'public, max-age=0'); - res.setHeader('Expires', '-1'); + if (format && format !== file.extension && ['png', 'jpg', 'jpeg'].includes(format)) { + res.setHeader('Content-Type', `image/${format}`); + sharp(file.content) + .toFormat(format as any) + .pipe(res); + return; + } - if (format && format !== file.extension && ['png', 'jpg', 'jpeg'].includes(format)) { - res.setHeader('Content-Type', `image/${format}`); - sharp(file.content).toFormat(format).pipe(res); - return; - } + res.setHeader('Last-Modified', (file.uploadDate && file.uploadDate.toUTCString()) || new Date().toUTCString()); + res.setHeader('Content-Type', file.contentType); + res.setHeader('Content-Length', file.size); + res.writeHead(200); + res.end(file.content); +}); - res.setHeader('Last-Modified', (file.uploadDate && file.uploadDate.toUTCString()) || new Date().toUTCString()); - res.setHeader('Content-Type', file.contentType); - res.setHeader('Content-Length', file.size); - res.writeHead(200); - res.end(file.content); - }), -); +WebApp.connectHandlers.use('/assets/', listener); diff --git a/apps/meteor/app/assets/server/index.js b/apps/meteor/app/assets/server/index.ts similarity index 100% rename from apps/meteor/app/assets/server/index.js rename to apps/meteor/app/assets/server/index.ts diff --git a/apps/meteor/app/favico/client/favico.js b/apps/meteor/app/favico/client/favico.js deleted file mode 100644 index b85f9b4a4ee1..000000000000 --- a/apps/meteor/app/favico/client/favico.js +++ /dev/null @@ -1,844 +0,0 @@ -/** - * @license MIT - * @fileOverview Favico animations - * @author Miroslav Magda, http://blog.ejci.net - * @version 0.3.10 - */ - -/** - * Create new favico instance - * @param {Object} Options - * @return {Object} Favico object - * @example - * var favico = new Favico({ - * bgColor : '#d00', - * textColor : '#fff', - * fontFamily : 'sans-serif', - * fontStyle : 'bold', - * position : 'down', - * type : 'circle', - * animation : 'slide', - * dataUrl: function(url){}, - * win: top - * }); - */ -/* eslint-disable */ - - export const Favico = (function(opt) { - 'use strict'; - opt = (opt) ? opt : {}; - var _def = { - bgColor: '#d00', - textColor: '#fff', - fontFamily: 'sans-serif', //Arial,Verdana,Times New Roman,serif,sans-serif,... - fontStyle: 'bold', //normal,italic,oblique,bold,bolder,lighter,100,200,300,400,500,600,700,800,900 - type: 'circle', - position: 'down', // down, up, left, leftup (upleft) - animation: 'slide', - elementId: false, - dataUrl: false, - win: window - }; - var _opt, _orig, _h, _w, _canvas, _context, _img, _ready, _lastBadge, _running, _readyCb, _stop, _browser, _animTimeout, _drawTimeout, _doc; - - _browser = {}; - _browser.ff = typeof InstallTrigger !== 'undefined'; - _browser.chrome = !!window.chrome; - _browser.opera = !!window.opera || navigator.userAgent.indexOf('Opera') >= 0; - _browser.ie = /*@cc_on!@*/ false; - _browser.safari = Object.prototype.toString.call(window.HTMLElement).indexOf('Constructor') > 0; - _browser.supported = (_browser.chrome || _browser.ff || _browser.opera); - - var _queue = []; - _readyCb = function() {}; - _ready = _stop = false; - /** - * Initialize favico - */ - var init = function() { - //merge initial options - _opt = merge(_def, opt); - _opt.bgColor = hexToRgb(_opt.bgColor); - _opt.textColor = hexToRgb(_opt.textColor); - _opt.position = _opt.position.toLowerCase(); - _opt.animation = (animation.types['' + _opt.animation]) ? _opt.animation : _def.animation; - - _doc = _opt.win.document; - - var isUp = _opt.position.indexOf('up') > -1; - var isLeft = _opt.position.indexOf('left') > -1; - - //transform the animations - if (isUp || isLeft) { - for (var a in animation.types) { - for (var i = 0; i < animation.types[a].length; i++) { - var step = animation.types[a][i]; - - if (isUp) { - if (step.y < 0.6) { - step.y = step.y - 0.4; - } else { - step.y = step.y - 2 * step.y + (1 - step.w); - } - } - - if (isLeft) { - if (step.x < 0.6) { - step.x = step.x - 0.4; - } else { - step.x = step.x - 2 * step.x + (1 - step.h); - } - } - - animation.types[a][i] = step; - } - } - } - _opt.type = (type['' + _opt.type]) ? _opt.type : _def.type; - - _orig = link.getIcons(); - //create temp canvas - _canvas = document.createElement('canvas'); - //create temp image - _img = document.createElement('img'); - var lastIcon = _orig[_orig.length - 1]; - if (lastIcon.hasAttribute('href')) { - _img.setAttribute('crossOrigin', 'anonymous'); - //get width/height - _img.onload = function() { - _h = (_img.height > 0) ? _img.height : 32; - _w = (_img.width > 0) ? _img.width : 32; - _canvas.height = _h; - _canvas.width = _w; - _context = _canvas.getContext('2d'); - icon.ready(); - }; - _img.setAttribute('src', lastIcon.getAttribute('href')); - } else { - _img.onload = function() { - _h = 32; - _w = 32; - _img.height = _h; - _img.width = _w; - _canvas.height = _h; - _canvas.width = _w; - _context = _canvas.getContext('2d'); - icon.ready(); - }; - _img.setAttribute('src', ''); - } - - }; - /** - * Icon namespace - */ - var icon = {}; - /** - * Icon is ready (reset icon) and start animation (if ther is any) - */ - icon.ready = function() { - _ready = true; - icon.reset(); - _readyCb(); - }; - /** - * Reset icon to default state - */ - icon.reset = function() { - //reset - if (!_ready) { - return; - } - _queue = []; - _lastBadge = false; - _running = false; - _context.clearRect(0, 0, _w, _h); - _context.drawImage(_img, 0, 0, _w, _h); - //_stop=true; - link.setIcon(_canvas); - //webcam('stop'); - //video('stop'); - window.clearTimeout(_animTimeout); - window.clearTimeout(_drawTimeout); - }; - /** - * Start animation - */ - icon.start = function() { - if (!_ready || _running) { - return; - } - var finished = function() { - _lastBadge = _queue[0]; - _running = false; - if (_queue.length > 0) { - _queue.shift(); - icon.start(); - } - }; - if (_queue.length > 0) { - _running = true; - var run = function() { - // apply options for this animation - ['type', 'animation', 'bgColor', 'textColor', 'fontFamily', 'fontStyle'].forEach(function(a) { - if (a in _queue[0].options) { - _opt[a] = _queue[0].options[a]; - } - }); - animation.run(_queue[0].options, function() { - finished(); - }, false); - }; - if (_lastBadge) { - animation.run(_lastBadge.options, function() { - run(); - }, true); - } else { - run(); - } - } - }; - - /** - * Badge types - */ - var type = {}; - var options = function(opt) { - opt.n = ((typeof opt.n) === 'number') ? Math.abs(opt.n | 0) : opt.n; - opt.x = _w * opt.x; - opt.y = _h * opt.y; - opt.w = _w * opt.w; - opt.h = _h * opt.h; - opt.len = ('' + opt.n).length; - return opt; - }; - /** - * Generate circle - * @param {Object} opt Badge options - */ - type.circle = function(opt) { - opt = options(opt); - var more = false; - if (opt.len === 2) { - opt.x = opt.x - opt.w * 0.4; - opt.w = opt.w * 1.4; - more = true; - } else if (opt.len >= 3) { - opt.x = opt.x - opt.w * 0.65; - opt.w = opt.w * 1.65; - more = true; - } - _context.clearRect(0, 0, _w, _h); - _context.drawImage(_img, 0, 0, _w, _h); - _context.beginPath(); - _context.font = _opt.fontStyle + ' ' + Math.floor(opt.h * (opt.n > 99 ? 0.85 : 1)) + 'px ' + _opt.fontFamily; - _context.textAlign = 'center'; - if (more) { - _context.moveTo(opt.x + opt.w / 2, opt.y); - _context.lineTo(opt.x + opt.w - opt.h / 2, opt.y); - _context.quadraticCurveTo(opt.x + opt.w, opt.y, opt.x + opt.w, opt.y + opt.h / 2); - _context.lineTo(opt.x + opt.w, opt.y + opt.h - opt.h / 2); - _context.quadraticCurveTo(opt.x + opt.w, opt.y + opt.h, opt.x + opt.w - opt.h / 2, opt.y + opt.h); - _context.lineTo(opt.x + opt.h / 2, opt.y + opt.h); - _context.quadraticCurveTo(opt.x, opt.y + opt.h, opt.x, opt.y + opt.h - opt.h / 2); - _context.lineTo(opt.x, opt.y + opt.h / 2); - _context.quadraticCurveTo(opt.x, opt.y, opt.x + opt.h / 2, opt.y); - } else { - _context.arc(opt.x + opt.w / 2, opt.y + opt.h / 2, opt.h / 2, 0, 2 * Math.PI); - } - _context.fillStyle = 'rgba(' + _opt.bgColor.r + ',' + _opt.bgColor.g + ',' + _opt.bgColor.b + ',' + opt.o + ')'; - _context.fill(); - _context.closePath(); - _context.beginPath(); - _context.stroke(); - _context.fillStyle = 'rgba(' + _opt.textColor.r + ',' + _opt.textColor.g + ',' + _opt.textColor.b + ',' + opt.o + ')'; - //_context.fillText((more) ? '9+' : opt.n, Math.floor(opt.x + opt.w / 2), Math.floor(opt.y + opt.h - opt.h * 0.15)); - if ((typeof opt.n) === 'number' && opt.n > 999) { - _context.fillText(((opt.n > 9999) ? 9 : Math.floor(opt.n / 1000)) + 'k+', Math.floor(opt.x + opt.w / 2), Math.floor(opt.y + opt.h - opt.h * 0.2)); - } else { - _context.fillText(opt.n, Math.floor(opt.x + opt.w / 2), Math.floor(opt.y + opt.h - opt.h * 0.15)); - } - _context.closePath(); - }; - /** - * Generate rectangle - * @param {Object} opt Badge options - */ - type.rectangle = function(opt) { - opt = options(opt); - var more = false; - if (opt.len === 2) { - opt.x = opt.x - opt.w * 0.4; - opt.w = opt.w * 1.4; - more = true; - } else if (opt.len >= 3) { - opt.x = opt.x - opt.w * 0.65; - opt.w = opt.w * 1.65; - more = true; - } - _context.clearRect(0, 0, _w, _h); - _context.drawImage(_img, 0, 0, _w, _h); - _context.beginPath(); - _context.font = _opt.fontStyle + ' ' + Math.floor(opt.h * (opt.n > 99 ? 0.9 : 1)) + 'px ' + _opt.fontFamily; - _context.textAlign = 'center'; - _context.fillStyle = 'rgba(' + _opt.bgColor.r + ',' + _opt.bgColor.g + ',' + _opt.bgColor.b + ',' + opt.o + ')'; - _context.fillRect(opt.x, opt.y, opt.w, opt.h); - _context.fillStyle = 'rgba(' + _opt.textColor.r + ',' + _opt.textColor.g + ',' + _opt.textColor.b + ',' + opt.o + ')'; - //_context.fillText((more) ? '9+' : opt.n, Math.floor(opt.x + opt.w / 2), Math.floor(opt.y + opt.h - opt.h * 0.15)); - if ((typeof opt.n) === 'number' && opt.n > 999) { - _context.fillText(((opt.n > 9999) ? 9 : Math.floor(opt.n / 1000)) + 'k+', Math.floor(opt.x + opt.w / 2), Math.floor(opt.y + opt.h - opt.h * 0.2)); - } else { - _context.fillText(opt.n, Math.floor(opt.x + opt.w / 2), Math.floor(opt.y + opt.h - opt.h * 0.15)); - } - _context.closePath(); - }; - - /** - * Set badge - */ - var badge = function(number, opts) { - opts = ((typeof opts) === 'string' ? { - animation: opts - } : opts) || {}; - _readyCb = function() { - try { - if (typeof(number) === 'number' ? (number > 0) : (number !== '')) { - var q = { - type: 'badge', - options: { - n: number - } - }; - if ('animation' in opts && animation.types['' + opts.animation]) { - q.options.animation = '' + opts.animation; - } - if ('type' in opts && type['' + opts.type]) { - q.options.type = '' + opts.type; - } - ['bgColor', 'textColor'].forEach(function(o) { - if (o in opts) { - q.options[o] = hexToRgb(opts[o]); - } - }); - ['fontStyle', 'fontFamily'].forEach(function(o) { - if (o in opts) { - q.options[o] = opts[o]; - } - }); - _queue.push(q); - if (_queue.length > 100) { - throw new Error('Too many badges requests in queue.'); - } - icon.start(); - } else { - icon.reset(); - } - } catch (e) { - throw new Error('Error setting badge. Message: ' + e.message); - } - }; - if (_ready) { - _readyCb(); - } - }; - - /** - * Set image as icon - */ - var image = function(imageElement) { - _readyCb = function() { - try { - var w = imageElement.width; - var h = imageElement.height; - var newImg = document.createElement('img'); - var ratio = (w / _w < h / _h) ? (w / _w) : (h / _h); - newImg.setAttribute('crossOrigin', 'anonymous'); - newImg.onload = function() { - _context.clearRect(0, 0, _w, _h); - _context.drawImage(newImg, 0, 0, _w, _h); - link.setIcon(_canvas); - }; - newImg.setAttribute('src', imageElement.getAttribute('src')); - newImg.height = (h / ratio); - newImg.width = (w / ratio); - } catch (e) { - throw new Error('Error setting image. Message: ' + e.message); - } - }; - if (_ready) { - _readyCb(); - } - }; - /** - * Set video as icon - */ - var video = function(videoElement) { - _readyCb = function() { - try { - if (videoElement === 'stop') { - _stop = true; - icon.reset(); - _stop = false; - return; - } - //var w = videoElement.width; - //var h = videoElement.height; - //var ratio = (w / _w < h / _h) ? (w / _w) : (h / _h); - videoElement.addEventListener('play', function() { - drawVideo(this); - }, false); - - } catch (e) { - throw new Error('Error setting video. Message: ' + e.message); - } - }; - if (_ready) { - _readyCb(); - } - }; - /** - * Set video as icon - */ - var webcam = function(action) { - //UR - if (!window.URL || !window.URL.createObjectURL) { - window.URL = window.URL || {}; - window.URL.createObjectURL = function(obj) { - return obj; - }; - } - if (_browser.supported) { - var newVideo = false; - navigator.getUserMedia = navigator.getUserMedia || navigator.oGetUserMedia || navigator.msGetUserMedia || navigator.mozGetUserMedia || navigator.webkitGetUserMedia; - _readyCb = function() { - try { - if (action === 'stop') { - _stop = true; - icon.reset(); - _stop = false; - return; - } - newVideo = document.createElement('video'); - newVideo.width = _w; - newVideo.height = _h; - navigator.getUserMedia({ - video: true, - audio: false - }, function(stream) { - newVideo.src = URL.createObjectURL(stream); - newVideo.play(); - drawVideo(newVideo); - }, function() {}); - } catch (e) { - throw new Error('Error setting webcam. Message: ' + e.message); - } - }; - if (_ready) { - _readyCb(); - } - } - - }; - - /** - * Draw video to context and repeat :) - */ - function drawVideo(video) { - if (video.paused || video.ended || _stop) { - return false; - } - //nasty hack for FF webcam (Thanks to Julian Ćwirko, kontakt@redsunmedia.pl) - try { - _context.clearRect(0, 0, _w, _h); - _context.drawImage(video, 0, 0, _w, _h); - } catch (e) { - - } - _drawTimeout = setTimeout(function() { - drawVideo(video); - }, animation.duration); - link.setIcon(_canvas); - } - - var link = {}; - /** - * Get icons from HEAD tag or create a new element - */ - link.getIcons = function() { - var elms = []; - //get link element - var getLinks = function() { - var icons = []; - var links = _doc.getElementsByTagName('head')[0].getElementsByTagName('link'); - for (var i = 0; i < links.length; i++) { - if ((/(^|\s)icon(\s|$)/i).test(links[i].getAttribute('rel'))) { - icons.push(links[i]); - } - } - return icons; - }; - if (_opt.element) { - elms = [_opt.element]; - } else if (_opt.elementId) { - //if img element identified by elementId - elms = [_doc.getElementById(_opt.elementId)]; - elms[0].setAttribute('href', elms[0].getAttribute('src')); - } else { - //if link element - elms = getLinks(); - if (elms.length === 0) { - elms = [_doc.createElement('link')]; - elms[0].setAttribute('rel', 'icon'); - _doc.getElementsByTagName('head')[0].appendChild(elms[0]); - } - } - elms.forEach(function(item) { - item.setAttribute('type', 'image/png'); - }); - return elms; - }; - link.setIcon = function(canvas) { - var url = canvas.toDataURL('image/png'); - if (_opt.dataUrl) { - //if using custom exporter - _opt.dataUrl(url); - } - if (_opt.element) { - _opt.element.setAttribute('href', url); - _opt.element.setAttribute('src', url); - } else if (_opt.elementId) { - //if is attached to element (image) - var elm = _doc.getElementById(_opt.elementId); - elm.setAttribute('href', url); - elm.setAttribute('src', url); - } else { - //if is attached to fav icon - if (_browser.ff || _browser.opera) { - //for FF we need to "recreate" element, atach to dom and remove old - //var originalType = _orig.getAttribute('rel'); - var old = _orig[_orig.length - 1]; - var newIcon = _doc.createElement('link'); - _orig = [newIcon]; - //_orig.setAttribute('rel', originalType); - if (_browser.opera) { - newIcon.setAttribute('rel', 'icon'); - } - newIcon.setAttribute('rel', 'icon'); - newIcon.setAttribute('type', 'image/png'); - _doc.getElementsByTagName('head')[0].appendChild(newIcon); - newIcon.setAttribute('href', url); - if (old.parentNode) { - old.parentNode.removeChild(old); - } - } else { - _orig.forEach(function(icon) { - icon.setAttribute('href', url); - }); - } - } - }; - - //http://stackoverflow.com/questions/5623838/rgb-to-hex-and-hex-to-rgb#answer-5624139 - //HEX to RGB convertor - function hexToRgb(hex) { - var shorthandRegex = /^#?([a-f\d])([a-f\d])([a-f\d])$/i; - hex = hex.replace(shorthandRegex, function(m, r, g, b) { - return r + r + g + g + b + b; - }); - var result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex); - return result ? { - r: parseInt(result[1], 16), - g: parseInt(result[2], 16), - b: parseInt(result[3], 16) - } : false; - } - - /** - * Merge options - */ - function merge(def, opt) { - var mergedOpt = {}; - var attrname; - for (attrname in def) { - mergedOpt[attrname] = def[attrname]; - } - for (attrname in opt) { - mergedOpt[attrname] = opt[attrname]; - } - return mergedOpt; - } - - /** - * Cross-browser page visibility shim - * http://stackoverflow.com/questions/12536562/detect-whether-a-window-is-visible - */ - function isPageHidden() { - return _doc.hidden || _doc.msHidden || _doc.webkitHidden || _doc.mozHidden; - } - - /** - * @namespace animation - */ - var animation = {}; - /** - * Animation "frame" duration - */ - animation.duration = 40; - /** - * Animation types (none,fade,pop,slide) - */ - animation.types = {}; - animation.types.fade = [{ - x: 0.4, - y: 0.4, - w: 0.6, - h: 0.6, - o: 0.0 - }, { - x: 0.4, - y: 0.4, - w: 0.6, - h: 0.6, - o: 0.1 - }, { - x: 0.4, - y: 0.4, - w: 0.6, - h: 0.6, - o: 0.2 - }, { - x: 0.4, - y: 0.4, - w: 0.6, - h: 0.6, - o: 0.3 - }, { - x: 0.4, - y: 0.4, - w: 0.6, - h: 0.6, - o: 0.4 - }, { - x: 0.4, - y: 0.4, - w: 0.6, - h: 0.6, - o: 0.5 - }, { - x: 0.4, - y: 0.4, - w: 0.6, - h: 0.6, - o: 0.6 - }, { - x: 0.4, - y: 0.4, - w: 0.6, - h: 0.6, - o: 0.7 - }, { - x: 0.4, - y: 0.4, - w: 0.6, - h: 0.6, - o: 0.8 - }, { - x: 0.4, - y: 0.4, - w: 0.6, - h: 0.6, - o: 0.9 - }, { - x: 0.4, - y: 0.4, - w: 0.6, - h: 0.6, - o: 1.0 - }]; - animation.types.none = [{ - x: 0.4, - y: 0.4, - w: 0.6, - h: 0.6, - o: 1 - }]; - animation.types.pop = [{ - x: 1, - y: 1, - w: 0, - h: 0, - o: 1 - }, { - x: 0.9, - y: 0.9, - w: 0.1, - h: 0.1, - o: 1 - }, { - x: 0.8, - y: 0.8, - w: 0.2, - h: 0.2, - o: 1 - }, { - x: 0.7, - y: 0.7, - w: 0.3, - h: 0.3, - o: 1 - }, { - x: 0.6, - y: 0.6, - w: 0.4, - h: 0.4, - o: 1 - }, { - x: 0.5, - y: 0.5, - w: 0.5, - h: 0.5, - o: 1 - }, { - x: 0.4, - y: 0.4, - w: 0.6, - h: 0.6, - o: 1 - }]; - animation.types.popFade = [{ - x: 0.75, - y: 0.75, - w: 0, - h: 0, - o: 0 - }, { - x: 0.65, - y: 0.65, - w: 0.1, - h: 0.1, - o: 0.2 - }, { - x: 0.6, - y: 0.6, - w: 0.2, - h: 0.2, - o: 0.4 - }, { - x: 0.55, - y: 0.55, - w: 0.3, - h: 0.3, - o: 0.6 - }, { - x: 0.50, - y: 0.50, - w: 0.4, - h: 0.4, - o: 0.8 - }, { - x: 0.45, - y: 0.45, - w: 0.5, - h: 0.5, - o: 0.9 - }, { - x: 0.4, - y: 0.4, - w: 0.6, - h: 0.6, - o: 1 - }]; - animation.types.slide = [{ - x: 0.4, - y: 1, - w: 0.6, - h: 0.6, - o: 1 - }, { - x: 0.4, - y: 0.9, - w: 0.6, - h: 0.6, - o: 1 - }, { - x: 0.4, - y: 0.9, - w: 0.6, - h: 0.6, - o: 1 - }, { - x: 0.4, - y: 0.8, - w: 0.6, - h: 0.6, - o: 1 - }, { - x: 0.4, - y: 0.7, - w: 0.6, - h: 0.6, - o: 1 - }, { - x: 0.4, - y: 0.6, - w: 0.6, - h: 0.6, - o: 1 - }, { - x: 0.4, - y: 0.5, - w: 0.6, - h: 0.6, - o: 1 - }, { - x: 0.4, - y: 0.4, - w: 0.6, - h: 0.6, - o: 1 - }]; - /** - * Run animation - * @param {Object} opt Animation options - * @param {Object} cb Callabak after all steps are done - * @param {Object} revert Reverse order? true|false - * @param {Object} step Optional step number (frame bumber) - */ - animation.run = function(opt, cb, revert, step) { - var animationType = animation.types[isPageHidden() ? 'none' : _opt.animation]; - if (revert === true) { - step = (typeof step !== 'undefined') ? step : animationType.length - 1; - } else { - step = (typeof step !== 'undefined') ? step : 0; - } - cb = (cb) ? cb : function() {}; - if ((step < animationType.length) && (step >= 0)) { - type[_opt.type](merge(opt, animationType[step])); - _animTimeout = setTimeout(function() { - if (revert) { - step = step - 1; - } else { - step = step + 1; - } - animation.run(opt, cb, revert, step); - }, animation.duration); - - link.setIcon(_canvas); - } else { - cb(); - return; - } - }; - //auto init - init(); - return { - badge: badge, - video: video, - image: image, - webcam: webcam, - reset: icon.reset, - browser: { - supported: _browser.supported - } - }; - }); diff --git a/apps/meteor/app/favico/client/index.js b/apps/meteor/app/favico/client/index.js deleted file mode 100644 index 239a252e455c..000000000000 --- a/apps/meteor/app/favico/client/index.js +++ /dev/null @@ -1,3 +0,0 @@ -import { Favico } from './favico'; - -export { Favico }; diff --git a/apps/meteor/app/favico/index.js b/apps/meteor/app/favico/index.js deleted file mode 100644 index 40a7340d3887..000000000000 --- a/apps/meteor/app/favico/index.js +++ /dev/null @@ -1 +0,0 @@ -export * from './client/index'; diff --git a/apps/meteor/app/lib/server/functions/getFullUserData.js b/apps/meteor/app/lib/server/functions/getFullUserData.ts similarity index 65% rename from apps/meteor/app/lib/server/functions/getFullUserData.js rename to apps/meteor/app/lib/server/functions/getFullUserData.ts index d328f3b05832..74bf410aee9b 100644 --- a/apps/meteor/app/lib/server/functions/getFullUserData.js +++ b/apps/meteor/app/lib/server/functions/getFullUserData.ts @@ -1,7 +1,9 @@ -import { Logger } from '../../../logger'; +import { IUser } from '@rocket.chat/core-typings'; + +import { Logger } from '../../../logger/server'; import { settings } from '../../../settings/server'; import { Users } from '../../../models/server'; -import { hasPermission } from '../../../authorization'; +import { hasPermission } from '../../../authorization/server'; const logger = new Logger('getFullUserData'); @@ -18,7 +20,7 @@ const defaultFields = { statusText: 1, avatarETag: 1, extension: 1, -}; +} as const; const fullFields = { emails: 1, @@ -31,12 +33,12 @@ const fullFields = { requirePasswordChange: 1, requirePasswordChangeReason: 1, roles: 1, -}; +} as const; -let publicCustomFields = {}; -let customFields = {}; +let publicCustomFields: Record = {}; +let customFields: Record = {}; -settings.watch('Accounts_CustomFields', (value) => { +settings.watch('Accounts_CustomFields', (value) => { publicCustomFields = {}; customFields = {}; @@ -58,29 +60,23 @@ settings.watch('Accounts_CustomFields', (value) => { } }); -const getCustomFields = (canViewAllInfo) => (canViewAllInfo ? customFields : publicCustomFields); +const getCustomFields = (canViewAllInfo: boolean): Record => (canViewAllInfo ? customFields : publicCustomFields); -const getFields = (canViewAllInfo) => ({ +const getFields = (canViewAllInfo: boolean): Record => ({ ...defaultFields, ...(canViewAllInfo && fullFields), ...getCustomFields(canViewAllInfo), }); -const removePasswordInfo = (user) => { - if (user && user.services) { - delete user.services.password; - delete user.services.email; - delete user.services.resume; - delete user.services.emailCode; - delete user.services.cloud; - delete user.services.email2fa; - delete user.services.totp; - } - - return user; +const removePasswordInfo = (user: IUser): Omit => { + const { services, ...result } = user; + return result; }; -export function getFullUserDataByIdOrUsername({ userId, filterId, filterUsername }) { +export async function getFullUserDataByIdOrUsername( + userId: string, + { filterId, filterUsername }: { filterId: string; filterUsername?: undefined } | { filterId?: undefined; filterUsername: string }, +): Promise { const caller = Users.findOneById(userId, { fields: { username: 1 } }); const targetUser = filterId || filterUsername; const myself = (filterId && targetUser === userId) || (filterUsername && targetUser === caller.username); diff --git a/apps/meteor/app/lib/server/functions/setUserAvatar.ts b/apps/meteor/app/lib/server/functions/setUserAvatar.ts index e72b847c5cee..4718303b21ca 100644 --- a/apps/meteor/app/lib/server/functions/setUserAvatar.ts +++ b/apps/meteor/app/lib/server/functions/setUserAvatar.ts @@ -8,12 +8,26 @@ import { SystemLogger } from '../../../../server/lib/logger/system'; import { api } from '../../../../server/sdk/api'; import { fetch } from '../../../../server/lib/http/fetch'; -export const setUserAvatar = function ( +export function setUserAvatar( + user: Pick, + dataURI: Buffer, + contentType: string, + service: 'rest', + etag?: string, +): void; +export function setUserAvatar( user: Pick, dataURI: string, contentType: string, service: 'initials' | 'url' | 'rest' | string, etag?: string, +): void; +export function setUserAvatar( + user: Pick, + dataURI: string | Buffer, + contentType: string, + service: 'initials' | 'url' | 'rest' | string, + etag?: string, ): void { if (service === 'initials') { Users.setAvatarData(user._id, service, null); @@ -22,7 +36,7 @@ export const setUserAvatar = function ( const { buffer, type } = Promise.await( (async (): Promise<{ buffer: Buffer; type: string }> => { - if (service === 'url') { + if (service === 'url' && typeof dataURI === 'string') { let response: Response; try { response = await fetch(dataURI); @@ -69,7 +83,7 @@ export const setUserAvatar = function ( if (service === 'rest') { return { - buffer: Buffer.from(dataURI, 'binary'), + buffer: dataURI instanceof Buffer ? dataURI : Buffer.from(dataURI, 'binary'), type: contentType, }; } @@ -103,4 +117,4 @@ export const setUserAvatar = function ( avatarETag, }); }, 500); -}; +} diff --git a/apps/meteor/app/livechat/client/lib/chartHandler.js b/apps/meteor/app/livechat/client/lib/chartHandler.js deleted file mode 100644 index b49d09002fa9..000000000000 --- a/apps/meteor/app/livechat/client/lib/chartHandler.js +++ /dev/null @@ -1,221 +0,0 @@ -import { TAPi18n } from 'meteor/rocketchat:tap-i18n'; - -const lineChartConfiguration = ({ legends = false, anim = false, smallTicks = false, displayColors = false, tooltipCallbacks = {} }) => { - const config = { - layout: { - padding: { - top: 10, - bottom: 0, - }, - }, - legend: { - display: false, - }, - title: { - display: false, - }, - tooltips: { - enabled: true, - mode: 'point', - displayColors, - ...tooltipCallbacks, - }, - scales: { - xAxes: [ - { - scaleLabel: { - display: false, - }, - gridLines: { - display: true, - color: 'rgba(0, 0, 0, 0.03)', - }, - }, - ], - yAxes: [ - { - scaleLabel: { - display: false, - }, - gridLines: { - display: true, - color: 'rgba(0, 0, 0, 0.03)', - }, - ticks: { - beginAtZero: true, - }, - }, - ], - }, - hover: { - animationDuration: 0, // duration of animations when hovering an item - }, - responsive: true, - maintainAspectRatio: false, - responsiveAnimationDuration: 0, // animation duration after a resize - }; - - if (!anim) { - config.animation = { - duration: 0, // general animation time - }; - } - - if (legends) { - config.legend = { - display: true, - labels: { - boxWidth: 20, - fontSize: 8, - }, - }; - } - - if (smallTicks) { - config.scales.xAxes[0].ticks = { - fontSize: 8, - }; - - config.scales.yAxes[0].ticks = { - beginAtZero: true, - fontSize: 8, - }; - } - - return config; -}; - -const doughnutChartConfiguration = (title, tooltipCallbacks = {}) => ({ - layout: { - padding: { - top: 0, - bottom: 0, - }, - }, - legend: { - display: true, - position: 'right', - labels: { - boxWidth: 20, - fontSize: 8, - }, - }, - title: { - display: 'true', - text: title, - }, - tooltips: { - enabled: true, - mode: 'point', - displayColors: false, // hide color box - ...tooltipCallbacks, - }, - // animation: { - // duration: 0 // general animation time - // }, - hover: { - animationDuration: 0, // duration of animations when hovering an item - }, - responsive: true, - maintainAspectRatio: false, - responsiveAnimationDuration: 0, // animation duration after a resize -}); - -/** - * - * @param {Object} chart - chart element - * @param {Object} chartContext - Context of chart - * @param {Array(String)} chartLabel - * @param {Array(String)} dataLabels - * @param {Array(Array(Double))} dataPoints - */ -export const drawLineChart = async (chart, chartContext, chartLabels, dataLabels, dataSets, options = {}) => { - if (!chart) { - console.log('No chart element'); - return; - } - if (chartContext) { - chartContext.destroy(); - } - const colors = ['#2de0a5', '#ffd21f', '#f5455c', '#cbced1']; - - const datasets = []; - - chartLabels.forEach(function (chartLabel, index) { - datasets.push({ - label: TAPi18n.__(chartLabel), // chart label - data: dataSets[index], // data points corresponding to data labels, x-axis points - backgroundColor: [colors[index]], - borderColor: [colors[index]], - borderWidth: 3, - fill: false, - }); - }); - const chartImport = await import('chart.js'); - const Chart = chartImport.default; - return new Chart(chart, { - type: 'line', - data: { - labels: dataLabels, // data labels, y-axis points - datasets, - }, - options: lineChartConfiguration(options), - }); -}; - -/** - * - * @param {Object} chart - chart element - * @param {Object} chartContext - Context of chart - * @param {Array(String)} dataLabels - * @param {Array(Double)} dataPoints - */ -export const drawDoughnutChart = async (chart, title, chartContext, dataLabels, dataPoints) => { - if (!chart) { - return; - } - if (chartContext) { - chartContext.destroy(); - } - const chartImport = await import('chart.js'); - const Chart = chartImport.default; - return new Chart(chart, { - type: 'doughnut', - data: { - labels: dataLabels, // data labels, y-axis points - datasets: [ - { - data: dataPoints, // data points corresponding to data labels, x-axis points - backgroundColor: ['#2de0a5', '#cbced1', '#f5455c', '#ffd21f'], - borderWidth: 0, - }, - ], - }, - options: doughnutChartConfiguration(title), - }); -}; - -/** - * Update chart - * @param {Object} chart [Chart context] - * @param {String} label [chart label] - * @param {Array(Double)} data [updated data] - */ -export const updateChart = async (c, label, data) => { - const chart = await c; - if (chart.data.labels.indexOf(label) === -1) { - // insert data - chart.data.labels.push(label); - chart.data.datasets.forEach((dataset, idx) => { - dataset.data.push(data[idx]); - }); - } else { - // update data - const index = chart.data.labels.indexOf(label); - chart.data.datasets.forEach((dataset, idx) => { - dataset.data[index] = data[idx]; - }); - } - - chart.update(); -}; diff --git a/apps/meteor/app/livechat/client/lib/chartHandler.ts b/apps/meteor/app/livechat/client/lib/chartHandler.ts new file mode 100644 index 000000000000..7f9b6f1a6f4f --- /dev/null +++ b/apps/meteor/app/livechat/client/lib/chartHandler.ts @@ -0,0 +1,232 @@ +import type { ChartItem, Chart as ChartType, ChartConfiguration } from 'chart.js'; +import { TAPi18n } from 'meteor/rocketchat:tap-i18n'; + +type LineChartConfigOptions = Partial<{ + legends: boolean; + anim: boolean; + displayColors: boolean; + tooltipCallbacks: any; +}>; + +const lineChartConfiguration = ({ + legends = false, + anim = false, + tooltipCallbacks = {}, +}: LineChartConfigOptions): Partial['options']> => { + const config: ChartConfiguration<'line', number, string>['options'] = { + layout: { + padding: { + top: 10, + bottom: 0, + }, + }, + legend: { + display: false, + }, + plugins: { + tooltip: { + usePointStyle: true, + enabled: true, + mode: 'point', + yAlign: 'bottom', + displayColors: true, + ...tooltipCallbacks, + }, + }, + scales: { + xAxis: { + title: { + display: false, + }, + grid: { + display: true, + color: 'rgba(0, 0, 0, 0.03)', + }, + }, + yAxis: { + title: { + display: false, + }, + grid: { + display: true, + color: 'rgba(0, 0, 0, 0.03)', + }, + }, + }, + hover: { + intersect: false, // duration of animations when hovering an item + mode: 'index', + }, + responsive: true, + maintainAspectRatio: false, + ...(!anim ? { animation: { duration: 0 } } : {}), + ...(legends ? { legend: { display: true, labels: { boxWidth: 20, fontSize: 8 } } } : {}), + }; + + return config; +}; + +const doughnutChartConfiguration = ( + title: string, + tooltipCallbacks = {}, +): Partial['options']> => ({ + layout: { + padding: { + top: 0, + bottom: 0, + }, + }, + plugins: { + legend: { + display: true, + position: 'right', + labels: { + boxWidth: 20, + }, + }, + title: { + display: true, + text: title, + }, + tooltip: { + enabled: true, + mode: 'point', + displayColors: true, // hide color box + ...tooltipCallbacks, + }, + }, + // animation: { + // duration: 0 // general animation time + // }, + hover: { + intersect: true, // duration of animations when hovering an item + }, + responsive: true, + maintainAspectRatio: false, +}); + +type ChartDataSet = { + label: string; + data: number; + backgroundColor: string; + borderColor: string; + borderWidth: number; + fill: boolean; +}; + +/** + * + * @param {Object} chart - chart element + * @param {Object} chartContext - Context of chart + * @param {Array(String)} chartLabel + * @param {Array(String)} dataLabels + * @param {Array(Array(Double))} dataPoints + */ +export const drawLineChart = async ( + chart: HTMLCanvasElement, + chartContext: { destroy: () => void } | undefined, + chartLabels: string[], + dataLabels: string[], + dataSets: number[], + options: LineChartConfigOptions = {}, +): Promise | void> => { + if (!chart) { + console.error('No chart element'); + return; + } + if (chartContext) { + chartContext.destroy(); + } + const colors = ['#2de0a5', '#ffd21f', '#f5455c', '#cbced1']; + + const datasets: ChartDataSet[] = []; + + chartLabels.forEach(function (chartLabel: string, index: number) { + datasets.push({ + label: TAPi18n.__(chartLabel), // chart label + data: dataSets[index], // data points corresponding to data labels, x-axis points + backgroundColor: colors[index], + borderColor: colors[index], + borderWidth: 3, + fill: false, + }); + }); + const chartjs = await import('chart.js/auto'); + const Chart = chartjs.default; + return new Chart(chart, { + type: 'line', + data: { + labels: dataLabels, // data labels, y-axis points + datasets, + }, + options: lineChartConfiguration(options), + }); +}; + +/** + * + * @param {Object} chart - chart element + * @param {Object} chartContext - Context of chart + * @param {Array(String)} dataLabels + * @param {Array(Double)} dataPoints + */ +export const drawDoughnutChart = async ( + chart: ChartItem, + title: string, + chartContext: { destroy: () => void } | undefined, + dataLabels: string[], + dataPoints: number[], +): Promise | void> => { + if (!chart) { + console.error('No chart element'); + return; + } + if (chartContext) { + chartContext.destroy(); + } + const chartjs = await import('chart.js/auto'); + const Chart = chartjs.default; + return new Chart(chart, { + type: 'doughnut', + data: { + labels: dataLabels, // data labels, y-axis points + datasets: [ + { + data: dataPoints, // data points corresponding to data labels, x-axis points + backgroundColor: ['#2de0a5', '#cbced1', '#f5455c', '#ffd21f'], + borderWidth: 0, + }, + ], + }, + options: doughnutChartConfiguration(title), + }); +}; + +/** + * Update chart + * @param {Object} chart [Chart context] + * @param {String} label [chart label] + * @param {Array(Double)} data [updated data] + */ +export const updateChart = async (c: ChartType, label: string, data: { [x: string]: number }): Promise => { + const chart = await c; + if (chart.data?.labels?.indexOf(label) === -1) { + // insert data + chart.data.labels.push(label); + chart.data.datasets.forEach((dataset: { data: any[] }, idx: string | number) => { + dataset.data.push(data[idx]); + }); + } else { + // update data + const index = chart.data?.labels?.indexOf(label); + if (typeof index === 'undefined') { + return; + } + + chart.data.datasets.forEach((dataset: { data: { [x: string]: any } }, idx: string | number) => { + dataset.data[index] = data[idx]; + }); + } + + chart.update(); +}; diff --git a/apps/meteor/app/models/server/raw/Users.js b/apps/meteor/app/models/server/raw/Users.js index 97b6da84167e..b0ea06bf1028 100644 --- a/apps/meteor/app/models/server/raw/Users.js +++ b/apps/meteor/app/models/server/raw/Users.js @@ -162,6 +162,15 @@ export class UsersRaw extends BaseRaw { return this.find(query, options); } + findActiveByIdsOrUsernames(userIds, options = {}) { + const query = { + $or: [{ _id: { $in: userIds } }, { username: { $in: userIds } }], + active: true, + }; + + return this.find(query, options); + } + findByIds(userIds, options = {}) { const query = { _id: { $in: userIds }, diff --git a/apps/meteor/app/settings/server/SettingsRegistry.ts b/apps/meteor/app/settings/server/SettingsRegistry.ts index d6799596ed9a..0c5c00ff2c10 100644 --- a/apps/meteor/app/settings/server/SettingsRegistry.ts +++ b/apps/meteor/app/settings/server/SettingsRegistry.ts @@ -135,8 +135,10 @@ export class SettingsRegistry { throw new Error(`Enterprise setting ${_id} is missing the invalidValue option`); } + const settingFromCodeOverwritten = overwriteSetting(settingFromCode); + const settingStored = this.store.getSetting(_id); - const settingOverwritten = overwriteSetting(settingFromCode); + const settingStoredOverwritten = settingStored && overwriteSetting(settingStored); try { validateSetting(settingFromCode._id, settingFromCode.type, settingFromCode.value); @@ -144,14 +146,14 @@ export class SettingsRegistry { IS_DEVELOPMENT && SystemLogger.error(`Invalid setting code ${_id}: ${(e as Error).message}`); } - const isOverwritten = settingFromCode !== settingOverwritten; + const isOverwritten = settingFromCode !== settingFromCodeOverwritten || (settingStored && settingStored !== settingStoredOverwritten); - const { _id: _, ...settingProps } = settingOverwritten; + const { _id: _, ...settingProps } = settingFromCodeOverwritten; - if (settingStored && !compareSettings(settingStored, settingOverwritten)) { - const { value: _value, ...settingOverwrittenProps } = settingOverwritten; + if (settingStored && !compareSettings(settingStored, settingFromCodeOverwritten)) { + const { value: _value, ...settingOverwrittenProps } = settingFromCodeOverwritten; - const overwrittenKeys = Object.keys(settingOverwritten); + const overwrittenKeys = Object.keys(settingFromCodeOverwritten); const removedKeys = Object.keys(settingStored).filter((key) => !['_updatedAt'].includes(key) && !overwrittenKeys.includes(key)); this.model.upsert( @@ -168,7 +170,7 @@ export class SettingsRegistry { } if (settingStored && isOverwritten) { - if (settingStored.value !== settingOverwritten.value) { + if (settingStored.value !== settingFromCodeOverwritten.value) { this.model.upsert({ _id }, settingProps); } return; @@ -185,7 +187,7 @@ export class SettingsRegistry { const settingOverwrittenDefault = overrideSetting(settingFromCode); - const setting = isOverwritten ? settingOverwritten : settingOverwrittenDefault; + const setting = isOverwritten ? settingFromCodeOverwritten : settingOverwrittenDefault; this.model.insert(setting); // no need to emit unless we remove the oplog diff --git a/apps/meteor/app/utils/lib/mimeTypes.js b/apps/meteor/app/utils/lib/mimeTypes.js deleted file mode 100644 index 70cd99776e43..000000000000 --- a/apps/meteor/app/utils/lib/mimeTypes.js +++ /dev/null @@ -1,8 +0,0 @@ -import mime from 'mime-type/with-db'; - -mime.types.wav = 'audio/wav'; -mime.define('image/vnd.microsoft.icon', { extensions: ['ico'] }, mime.dupAppend); -mime.define('image/x-icon', { extensions: ['ico'] }, mime.dupAppend); -mime.types.ico = 'image/x-icon'; - -export { mime }; diff --git a/apps/meteor/app/utils/lib/mimeTypes.ts b/apps/meteor/app/utils/lib/mimeTypes.ts new file mode 100644 index 000000000000..dd166f17bed3 --- /dev/null +++ b/apps/meteor/app/utils/lib/mimeTypes.ts @@ -0,0 +1,14 @@ +import mime from 'mime-type/with-db'; + +mime.types.wav = 'audio/wav'; +mime.define('image/vnd.microsoft.icon', { source: '', extensions: ['ico'] }, mime.dupAppend); +mime.define('image/x-icon', { source: '', extensions: ['ico'] }, mime.dupAppend); +mime.types.ico = 'image/x-icon'; + +const getExtension = (param: string): string => { + const extension = mime.extension(param); + + return !extension || typeof extension === 'boolean' ? '' : extension; +}; + +export { mime, getExtension }; diff --git a/apps/meteor/client/components/Header/Header.stories.tsx b/apps/meteor/client/components/Header/Header.stories.tsx index 0f7905c3d0a6..a8742be2b807 100644 --- a/apps/meteor/client/components/Header/Header.stories.tsx +++ b/apps/meteor/client/components/Header/Header.stories.tsx @@ -30,8 +30,9 @@ export default { value={{ hasPrivateAccess: true, isLoading: false, - querySetting: (_id) => ({ - getCurrentValue: () => ({ + querySetting: (_id) => [ + () => () => undefined, + () => ({ _id, type: 'action', value: '', @@ -44,12 +45,8 @@ export default { sorter: 1, ts: new Date(), }), - subscribe: () => () => undefined, - }), - querySettings: () => ({ - getCurrentValue: () => [], - subscribe: () => () => undefined, - }), + ], + querySettings: () => [() => () => undefined, () => []], dispatch: async () => undefined, }} > diff --git a/apps/meteor/client/components/Omnichannel/Tags.tsx b/apps/meteor/client/components/Omnichannel/Tags.tsx index ad2d515f4373..6887bfe07e4b 100644 --- a/apps/meteor/client/components/Omnichannel/Tags.tsx +++ b/apps/meteor/client/components/Omnichannel/Tags.tsx @@ -2,11 +2,10 @@ import { Field, TextInput, Chip, Button } from '@rocket.chat/fuselage'; import { useMutableCallback } from '@rocket.chat/fuselage-hooks'; import { useToastMessageDispatch, useTranslation } from '@rocket.chat/ui-contexts'; import React, { ChangeEvent, ReactElement, useState } from 'react'; -import { useSubscription } from 'use-subscription'; import { AsyncStatePhase } from '../../hooks/useAsyncState'; import { useEndpointData } from '../../hooks/useEndpointData'; -import { formsSubscription } from '../../views/omnichannel/additionalForms'; +import { useFormsSubscription } from '../../views/omnichannel/additionalForms'; import { FormSkeleton } from './Skeleton'; const Tags = ({ @@ -21,7 +20,7 @@ const Tags = ({ tagRequired?: boolean; }): ReactElement => { const t = useTranslation(); - const forms = useSubscription(formsSubscription); + const forms = useFormsSubscription() as any; const { value: tagsResult, phase: stateTags } = useEndpointData('/v1/livechat/tags.list'); diff --git a/apps/meteor/client/components/RoomIcon/OmnichannelRoomIcon/context/OmnichannelRoomIconContext.tsx b/apps/meteor/client/components/RoomIcon/OmnichannelRoomIcon/context/OmnichannelRoomIconContext.tsx index 7c825e1d54f9..b51a962d8e92 100644 --- a/apps/meteor/client/components/RoomIcon/OmnichannelRoomIcon/context/OmnichannelRoomIconContext.tsx +++ b/apps/meteor/client/components/RoomIcon/OmnichannelRoomIcon/context/OmnichannelRoomIconContext.tsx @@ -1,31 +1,26 @@ import { createContext, useMemo, useContext } from 'react'; -import { useSubscription, Unsubscribe } from 'use-subscription'; +import { useSyncExternalStore } from 'use-sync-external-store/shim'; import { AsyncState } from '../../../../lib/asyncState/AsyncState'; import { AsyncStatePhase } from '../../../../lib/asyncState/AsyncStatePhase'; type IOmnichannelRoomIconContext = { - queryIcon( - app: string, - icon: string, - ): { - getCurrentValue: () => AsyncState; - subscribe: (callback: () => void) => Unsubscribe; - }; + queryIcon(app: string, icon: string): [subscribe: (onStoreChange: () => void) => () => void, getSnapshot: () => AsyncState]; }; export const OmnichannelRoomIconContext = createContext({ - queryIcon: () => ({ - getCurrentValue: (): AsyncState => ({ + queryIcon: () => [ + (): (() => void) => (): void => undefined, + (): AsyncState => ({ phase: AsyncStatePhase.LOADING, value: undefined, error: undefined, }), - subscribe: (): Unsubscribe => (): void => undefined, - }), + ], }); export const useOmnichannelRoomIcon = (app: string, icon: string): AsyncState => { const { queryIcon } = useContext(OmnichannelRoomIconContext); - return useSubscription(useMemo(() => queryIcon(app, icon), [app, queryIcon, icon])); + const [subscribe, getSnapshot] = useMemo(() => queryIcon(app, icon), [app, queryIcon, icon]); + return useSyncExternalStore(subscribe, getSnapshot); }; diff --git a/apps/meteor/client/components/RoomIcon/OmnichannelRoomIcon/provider/OmnichannelRoomIconProvider.tsx b/apps/meteor/client/components/RoomIcon/OmnichannelRoomIcon/provider/OmnichannelRoomIconProvider.tsx index 0e3ca29581d3..3171ebf34759 100644 --- a/apps/meteor/client/components/RoomIcon/OmnichannelRoomIcon/provider/OmnichannelRoomIconProvider.tsx +++ b/apps/meteor/client/components/RoomIcon/OmnichannelRoomIcon/provider/OmnichannelRoomIconProvider.tsx @@ -1,28 +1,37 @@ -import React, { FC, useMemo } from 'react'; +import React, { FC, useCallback, useMemo } from 'react'; import { createPortal } from 'react-dom'; -import { useSubscription, Subscription } from 'use-subscription'; +import { useSyncExternalStore } from 'use-sync-external-store/shim'; import { AsyncState } from '../../../../lib/asyncState/AsyncState'; import { AsyncStatePhase } from '../../../../lib/asyncState/AsyncStatePhase'; import { OmnichannelRoomIconContext } from '../context/OmnichannelRoomIconContext'; import OmnichannelRoomIcon from '../lib/OmnichannelRoomIcon'; +let icons = Array.from(OmnichannelRoomIcon.icons.values()); + export const OmnichannelRoomIconProvider: FC = ({ children }) => { - const svgIcons = useSubscription( - useMemo( - () => ({ - getCurrentValue: (): string[] => Array.from(OmnichannelRoomIcon.icons.values()), - subscribe: (callback): (() => void) => OmnichannelRoomIcon.on('change', callback), - }), + const svgIcons = useSyncExternalStore( + useCallback( + (callback): (() => void) => + OmnichannelRoomIcon.on('change', () => { + icons = Array.from(OmnichannelRoomIcon.icons.values()); + callback(); + }), [], ), + (): string[] => icons, ); + return ( ({ - queryIcon: (app: string, iconName: string): Subscription> => ({ - getCurrentValue: (): AsyncState => { + queryIcon: ( + app: string, + iconName: string, + ): [subscribe: (onStoreChange: () => void) => () => void, getSnapshot: () => AsyncState] => [ + (callback): (() => void) => OmnichannelRoomIcon.on(`${app}-${iconName}`, callback), + (): AsyncState => { const icon = OmnichannelRoomIcon.get(app, iconName); if (!icon) { @@ -39,8 +48,7 @@ export const OmnichannelRoomIconProvider: FC = ({ children }) => { error: undefined, }; }, - subscribe: (callback): (() => void) => OmnichannelRoomIcon.on(`${app}-${iconName}`, callback), - }), + ], }), [], )} diff --git a/apps/meteor/client/components/VerticalBar/VerticalBarActionBack.tsx b/apps/meteor/client/components/VerticalBar/VerticalBarActionBack.tsx deleted file mode 100644 index 427dbd1d7fc6..000000000000 --- a/apps/meteor/client/components/VerticalBar/VerticalBarActionBack.tsx +++ /dev/null @@ -1,9 +0,0 @@ -import React, { ReactElement, memo, ComponentProps } from 'react'; - -import VerticalBarAction from './VerticalBarAction'; - -const VerticalBarActionBack = (props: ComponentProps): ReactElement => ( - -); - -export default memo(VerticalBarActionBack); diff --git a/apps/meteor/client/components/VerticalBar/VerticalBarBack.tsx b/apps/meteor/client/components/VerticalBar/VerticalBarBack.tsx new file mode 100644 index 000000000000..ecfcc9716be2 --- /dev/null +++ b/apps/meteor/client/components/VerticalBar/VerticalBarBack.tsx @@ -0,0 +1,13 @@ +import { useTranslation } from '@rocket.chat/ui-contexts'; +import React, { ReactElement, memo, ComponentProps } from 'react'; + +import VerticalBarAction from './VerticalBarAction'; + +type VerticalBarBackProps = Partial>; + +const VerticalBarBack = (props: VerticalBarBackProps): ReactElement => { + const t = useTranslation(); + return ; +}; + +export default memo(VerticalBarBack); diff --git a/apps/meteor/client/components/VerticalBar/index.ts b/apps/meteor/client/components/VerticalBar/index.ts index 9d5d37e9246d..e970ab61915a 100644 --- a/apps/meteor/client/components/VerticalBar/index.ts +++ b/apps/meteor/client/components/VerticalBar/index.ts @@ -1,7 +1,7 @@ import VerticalBar from './VerticalBar'; import VerticalBarAction from './VerticalBarAction'; -import VerticalBarActionBack from './VerticalBarActionBack'; import VerticalBarActions from './VerticalBarActions'; +import VerticalBarBack from './VerticalBarBack'; import VerticalBarButton from './VerticalBarButton'; import VerticalBarClose from './VerticalBarClose'; import VerticalBarContent from './VerticalBarContent'; @@ -26,5 +26,5 @@ export default Object.assign(VerticalBar, { ScrollableContent: VerticalBarScrollableContent, Skeleton: VerticalBarSkeleton, Button: VerticalBarButton, - Back: VerticalBarActionBack, + Back: VerticalBarBack, }); diff --git a/apps/meteor/client/components/voip/modal/WrapUpCallModal.tsx b/apps/meteor/client/components/voip/modal/WrapUpCallModal.tsx index 104fd5427070..31a4d07a294f 100644 --- a/apps/meteor/client/components/voip/modal/WrapUpCallModal.tsx +++ b/apps/meteor/client/components/voip/modal/WrapUpCallModal.tsx @@ -3,7 +3,6 @@ import { useSetModal, useTranslation } from '@rocket.chat/ui-contexts'; import React, { ReactElement, useEffect } from 'react'; import { useForm, SubmitHandler } from 'react-hook-form'; -import { useCallCloseRoom } from '../../../contexts/CallContext'; import Tags from '../../Omnichannel/Tags'; type WrapUpCallPayload = { @@ -11,9 +10,12 @@ type WrapUpCallPayload = { tags?: string[]; }; -export const WrapUpCallModal = (): ReactElement => { +type WrapUpCallModalProps = { + closeRoom: (data?: { comment?: string; tags?: string[] }) => void; +}; + +export const WrapUpCallModal = ({ closeRoom }: WrapUpCallModalProps): ReactElement => { const setModal = useSetModal(); - const closeRoom = useCallCloseRoom(); const closeModal = (): void => setModal(null); const t = useTranslation(); diff --git a/apps/meteor/client/contexts/CallContext.ts b/apps/meteor/client/contexts/CallContext.ts index f5c02d0292a9..b28a325e97cf 100644 --- a/apps/meteor/client/contexts/CallContext.ts +++ b/apps/meteor/client/contexts/CallContext.ts @@ -1,7 +1,7 @@ import type { IVoipRoom } from '@rocket.chat/core-typings'; import { ICallerInfo, VoIpCallerInfo } from '@rocket.chat/core-typings'; -import { createContext, useContext, useMemo } from 'react'; -import { useSubscription } from 'use-subscription'; +import { createContext, useCallback, useContext } from 'react'; +import { useSyncExternalStore } from 'use-sync-external-store/shim'; import { VoIPUser } from '../lib/voip/VoIPUser'; @@ -62,10 +62,16 @@ export const useIsCallEnabled = (): boolean => { return enabled; }; +let callerInfo: VoIpCallerInfo; + export const useIsCallReady = (): boolean => { - const { ready } = useContext(CallContext); + const context = useContext(CallContext); + + if (isCallContextReady(context)) { + callerInfo = context.voipClient.callerInfo; + } - return Boolean(ready); + return !!context.ready; }; export const useIsCallError = (): boolean => { @@ -89,20 +95,20 @@ export const useCallerInfo = (): VoIpCallerInfo => { throw new Error('useCallerInfo only if Calls are enabled and ready'); } const { voipClient } = context; - const subscription = useMemo( - () => ({ - getCurrentValue: (): VoIpCallerInfo => voipClient.callerInfo, - subscribe: (callback: () => void): (() => void) => { - voipClient.on('stateChanged', callback); - - return (): void => { - voipClient.off('stateChanged', callback); - }; - }, - }), + const subscribe = useCallback( + (callback: () => void): (() => void) => { + voipClient.on('stateChanged', callback); + + return (): void => { + voipClient.off('stateChanged', callback); + }; + }, [voipClient], ); - return useSubscription(subscription); + + const getSnapshot = (): VoIpCallerInfo => callerInfo; + + return useSyncExternalStore(subscribe, getSnapshot); }; export const useCallCreateRoom = (): CallContextReady['createRoom'] => { diff --git a/apps/meteor/client/hooks/usePresence.ts b/apps/meteor/client/hooks/usePresence.ts index e02fd9c2cc32..488ae58cab86 100644 --- a/apps/meteor/client/hooks/usePresence.ts +++ b/apps/meteor/client/hooks/usePresence.ts @@ -1,5 +1,5 @@ -import { useMemo } from 'react'; -import { useSubscription } from 'use-subscription'; +import { useCallback } from 'react'; +import { useSyncExternalStore } from 'use-sync-external-store/shim'; import { Presence, UserPresence } from '../lib/presence'; @@ -13,18 +13,17 @@ type Presence = 'online' | 'offline' | 'busy' | 'away' | 'loading'; * @public */ export const usePresence = (uid: string | undefined): UserPresence | undefined => { - const subscription = useMemo( - () => ({ - getCurrentValue: (): UserPresence | undefined => (uid ? Presence.store.get(uid) : undefined), - subscribe: (callback: any): any => { - uid && Presence.listen(uid, callback); - return (): void => { - uid && Presence.stop(uid, callback); - }; - }, - }), + const subscribe = useCallback( + (callback: any): any => { + uid && Presence.listen(uid, callback); + return (): void => { + uid && Presence.stop(uid, callback); + }; + }, [uid], ); - return useSubscription(subscription); + const getSnapshot = (): UserPresence | undefined => (uid ? Presence.store.get(uid) : undefined); + + return useSyncExternalStore(subscribe, getSnapshot); }; diff --git a/apps/meteor/client/hooks/useReactiveValue.ts b/apps/meteor/client/hooks/useReactiveValue.ts index 1ffb609429d3..8c50037121c1 100644 --- a/apps/meteor/client/hooks/useReactiveValue.ts +++ b/apps/meteor/client/hooks/useReactiveValue.ts @@ -1,10 +1,10 @@ import { Tracker } from 'meteor/tracker'; import { useMemo } from 'react'; -import { Subscription, Unsubscribe, useSubscription } from 'use-subscription'; +import { useSyncExternalStore } from 'use-sync-external-store/shim'; export const useReactiveValue = (computeCurrentValue: () => T): T => { - const subscription: Subscription = useMemo(() => { - const callbacks = new Set(); + const [subscribe, getSnapshot] = useMemo(() => { + const callbacks = new Set<() => void>(); let currentValue: T; @@ -15,9 +15,8 @@ export const useReactiveValue = (computeCurrentValue: () => T): T => { }); }); - return { - getCurrentValue: (): T => currentValue, - subscribe: (callback): Unsubscribe => { + return [ + (callback: () => void): (() => void) => { callbacks.add(callback); return (): void => { @@ -28,8 +27,9 @@ export const useReactiveValue = (computeCurrentValue: () => T): T => { } }; }, - }; + (): T => currentValue, + ]; }, [computeCurrentValue]); - return useSubscription(subscription); + return useSyncExternalStore(subscribe, getSnapshot); }; diff --git a/apps/meteor/client/hooks/useUserData.ts b/apps/meteor/client/hooks/useUserData.ts index 15e2e040741d..4621cf895122 100644 --- a/apps/meteor/client/hooks/useUserData.ts +++ b/apps/meteor/client/hooks/useUserData.ts @@ -1,5 +1,5 @@ -import { useMemo } from 'react'; -import { useSubscription } from 'use-subscription'; +import { useCallback } from 'react'; +import { useSyncExternalStore } from 'use-sync-external-store/shim'; import { UserPresence, Presence } from '../lib/presence'; @@ -11,18 +11,17 @@ import { UserPresence, Presence } from '../lib/presence'; * @public */ export const useUserData = (uid: string): UserPresence | undefined => { - const subscription = useMemo( - () => ({ - getCurrentValue: (): UserPresence | undefined => Presence.store.get(uid), - subscribe: (callback: any): any => { - Presence.listen(uid, callback); - return (): void => { - Presence.stop(uid, callback); - }; - }, - }), + const subscription = useCallback( + (callback: () => void): (() => void) => { + Presence.listen(uid, callback); + return (): void => { + Presence.stop(uid, callback); + }; + }, [uid], ); - return useSubscription(subscription); + const getSnapshot = (): UserPresence | undefined => Presence.store.get(uid); + + return useSyncExternalStore(subscription, getSnapshot); }; diff --git a/apps/meteor/client/importPackages.ts b/apps/meteor/client/importPackages.ts index 554c3e280396..08c95cf98ea1 100644 --- a/apps/meteor/client/importPackages.ts +++ b/apps/meteor/client/importPackages.ts @@ -14,7 +14,6 @@ import '../app/drupal/client'; import '../app/emoji/client'; import '../app/emoji-emojione/client'; import '../app/emoji-custom/client'; -import '../app/favico'; import '../app/file-upload'; import '../app/github-enterprise/client'; import '../app/gitlab/client'; diff --git a/apps/meteor/client/lib/RoomManager.ts b/apps/meteor/client/lib/RoomManager.ts index 31a0d5f0b78a..f05bde279d42 100644 --- a/apps/meteor/client/lib/RoomManager.ts +++ b/apps/meteor/client/lib/RoomManager.ts @@ -1,8 +1,8 @@ import type { IRoom } from '@rocket.chat/core-typings'; import { Emitter } from '@rocket.chat/emitter'; import { useUserId, useUserRoom, useUserSubscription } from '@rocket.chat/ui-contexts'; -import { useEffect, useMemo } from 'react'; -import { useSubscription, Subscription, Unsubscribe } from 'use-subscription'; +import { useCallback, useEffect } from 'react'; +import { useSyncExternalStore } from 'use-sync-external-store/shim'; import { RoomHistoryManager } from '../../app/ui-utils/client/lib/RoomHistoryManager'; import { useAsyncState } from '../hooks/useAsyncState'; @@ -129,19 +129,15 @@ export const RoomManager = new (class RoomManager extends Emitter<{ } })(); -const subscribeVisitedRooms: Subscription = { - getCurrentValue: () => RoomManager.visitedRooms(), - subscribe(callback) { - return RoomManager.on('changed', callback); - }, -}; +const subscribeVisitedRooms = [ + (callback: () => void): (() => void) => RoomManager.on('changed', callback), + (): IRoom['_id'][] => RoomManager.visitedRooms(), +] as const; -const subscribeOpenedRoom: Subscription = { - getCurrentValue: () => RoomManager.opened, - subscribe(callback) { - return RoomManager.on('opened', callback); - }, -}; +const subscribeOpenedRoom = [ + (callback: () => void): (() => void) => RoomManager.on('opened', callback), + (): IRoom['_id'] | undefined => RoomManager.opened, +] as const; const fields = {}; @@ -165,25 +161,19 @@ export const useHandleRoom = (rid: IRoom['_id']): AsyncState return state; }; -export const useVisitedRooms = (): IRoom['_id'][] => useSubscription(subscribeVisitedRooms); +export const useVisitedRooms = (): IRoom['_id'][] => useSyncExternalStore(...subscribeVisitedRooms); -export const useOpenedRoom = (): IRoom['_id'] | undefined => useSubscription(subscribeOpenedRoom); +export const useOpenedRoom = (): IRoom['_id'] | undefined => useSyncExternalStore(...subscribeOpenedRoom); export const useRoomStore = (rid: IRoom['_id']): RoomStore => { - const subscribeStore: Subscription = useMemo( - () => ({ - getCurrentValue: (): RoomStore | undefined => RoomManager.getStore(rid), - subscribe(callback): Unsubscribe { - return RoomManager.on('changed', callback); - }, - }), - [rid], - ); - - const store = useSubscription(subscribeStore); + const subscribe = useCallback((callback: () => void): (() => void) => RoomManager.on('changed', callback), []); + const getSnapshot = (): RoomStore | undefined => RoomManager.getStore(rid); + + const store = useSyncExternalStore(subscribe, getSnapshot); if (!store) { throw new Error('Something wrong'); } + return store; }; diff --git a/apps/meteor/client/lib/appLayout.ts b/apps/meteor/client/lib/appLayout.ts index 4f2f3a519b7b..a8a8be725401 100644 --- a/apps/meteor/client/lib/appLayout.ts +++ b/apps/meteor/client/lib/appLayout.ts @@ -1,15 +1,14 @@ import { Emitter } from '@rocket.chat/emitter'; import { ReactElement } from 'react'; -import { Subscription, Unsubscribe } from 'use-subscription'; type AppLayoutDescriptor = ReactElement | null; -class AppLayoutSubscription extends Emitter<{ update: void }> implements Subscription { +class AppLayoutSubscription extends Emitter<{ update: void }> { private descriptor: AppLayoutDescriptor = null; - getCurrentValue = (): AppLayoutDescriptor => this.descriptor; + getSnapshot = (): AppLayoutDescriptor => this.descriptor; - subscribe = (callback: () => void): Unsubscribe => this.on('update', callback); + subscribe = (onStoreChange: () => void): (() => void) => this.on('update', onStoreChange); setCurrentValue(descriptor: AppLayoutDescriptor): void { this.descriptor = descriptor; diff --git a/apps/meteor/client/lib/banners.ts b/apps/meteor/client/lib/banners.ts index eb8a06850fa9..02df87e95eca 100644 --- a/apps/meteor/client/lib/banners.ts +++ b/apps/meteor/client/lib/banners.ts @@ -2,7 +2,6 @@ import { UiKitBannerPayload } from '@rocket.chat/core-typings'; import { Emitter } from '@rocket.chat/emitter'; import { Icon } from '@rocket.chat/fuselage'; import { ComponentProps } from 'react'; -import { Subscription } from 'use-subscription'; export type LegacyBannerPayload = { id: string; @@ -27,10 +26,10 @@ const emitter = new Emitter<{ 'update-first': undefined; }>(); -export const firstSubscription: Subscription = { - getCurrentValue: () => queue[0] ?? null, - subscribe: (callback) => emitter.on('update-first', callback), -}; +export const firstSubscription = [ + (callback: () => void): (() => void) => emitter.on('update-first', callback), + (): BannerPayload | null => queue[0] ?? null, +] as const; export const open = (payload: BannerPayload): void => { let index = queue.findIndex((_payload) => { diff --git a/apps/meteor/client/lib/createSidebarItems.ts b/apps/meteor/client/lib/createSidebarItems.ts index 8b1c7db9e928..b773e733a586 100644 --- a/apps/meteor/client/lib/createSidebarItems.ts +++ b/apps/meteor/client/lib/createSidebarItems.ts @@ -1,5 +1,4 @@ import { IconProps } from '@rocket.chat/fuselage'; -import type { Subscription } from 'use-subscription'; export type SidebarItem = { i18nLabel: string; @@ -17,19 +16,19 @@ export const createSidebarItems = ( ): { registerSidebarItem: (item: SidebarItem) => void; unregisterSidebarItem: (i18nLabel: SidebarItem['i18nLabel']) => void; - itemsSubscription: Subscription; + getSidebarItems: () => SidebarItem[]; + subscribeToSidebarItems: (callback: () => void) => () => void; } => { const items = initialItems; let updateCb: () => void = () => undefined; - const itemsSubscription: Subscription = { - subscribe: (cb) => { - updateCb = cb; - return (): void => { - updateCb = (): void => undefined; - }; - }, - getCurrentValue: () => items, + const getSidebarItems = (): SidebarItem[] => items; + + const subscribeToSidebarItems = (cb: () => void): (() => void) => { + updateCb = cb; + return (): void => { + updateCb = (): void => undefined; + }; }; const registerSidebarItem = (item: SidebarItem): void => { @@ -46,6 +45,7 @@ export const createSidebarItems = ( return { registerSidebarItem, unregisterSidebarItem, - itemsSubscription, + getSidebarItems, + subscribeToSidebarItems, }; }; diff --git a/apps/meteor/client/lib/createValueSubscription.ts b/apps/meteor/client/lib/createValueSubscription.ts deleted file mode 100644 index 9702f748e93a..000000000000 --- a/apps/meteor/client/lib/createValueSubscription.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { Emitter } from '@rocket.chat/emitter'; -import { Subscription, Unsubscribe } from 'use-subscription'; - -type ValueSubscription = Subscription & { - setCurrentValue: (value: T) => void; -}; - -export const createValueSubscription = (initialValue: T): ValueSubscription => { - let value: T = initialValue; - const emitter = new Emitter<{ - update: undefined; - }>(); - - return { - getCurrentValue: (): T => value, - setCurrentValue: (_value: T): void => { - value = _value; - emitter.emit('update'); - }, - subscribe: (callback): Unsubscribe => emitter.on('update', callback), - }; -}; diff --git a/apps/meteor/client/lib/portals/blazePortals.ts b/apps/meteor/client/lib/portals/blazePortals.ts index 0ddf73026744..ab30c96b634b 100644 --- a/apps/meteor/client/lib/portals/blazePortals.ts +++ b/apps/meteor/client/lib/portals/blazePortals.ts @@ -1,25 +1,27 @@ import { Emitter } from '@rocket.chat/emitter'; import { Random } from 'meteor/random'; import type { ReactNode } from 'react'; -import type { Subscription, Unsubscribe } from 'use-subscription'; type BlazePortalEntry = { key: string; node: ReactNode; }; -class BlazePortalsSubscriptions extends Emitter<{ update: void }> implements Subscription { +class BlazePortalsSubscriptions extends Emitter<{ update: void }> { private map = new Map(); - getCurrentValue = (): BlazePortalEntry[] => Array.from(this.map.values()); + private cache = Array.from(this.map.values()); - subscribe = (callback: () => void): Unsubscribe => this.on('update', callback); + getSnapshot = (): BlazePortalEntry[] => this.cache; + + subscribe = (onStoreChange: () => void): (() => void) => this.on('update', onStoreChange); register = (template: Blaze.TemplateInstance, node: ReactNode): void => { const entry = this.map.get(template); if (!entry) { this.map.set(template, { key: Random.id(), node }); + this.cache = Array.from(this.map.values()); this.emit('update'); return; } @@ -29,11 +31,13 @@ class BlazePortalsSubscriptions extends Emitter<{ update: void }> implements Sub } this.map.set(template, { ...entry, node }); + this.cache = Array.from(this.map.values()); this.emit('update'); }; unregister = (template: Blaze.TemplateInstance): void => { if (this.map.delete(template)) { + this.cache = Array.from(this.map.values()); this.emit('update'); } }; diff --git a/apps/meteor/client/lib/portals/portalsSubscription.ts b/apps/meteor/client/lib/portals/portalsSubscription.ts index c17af505c4fc..b72aa8f3ded6 100644 --- a/apps/meteor/client/lib/portals/portalsSubscription.ts +++ b/apps/meteor/client/lib/portals/portalsSubscription.ts @@ -1,14 +1,15 @@ import { Emitter } from '@rocket.chat/emitter'; import { Random } from 'meteor/random'; import type { ReactElement } from 'react'; -import type { Subscription, Unsubscribe } from 'use-subscription'; type SubscribedPortal = { portal: ReactElement; key: string; }; -type PortalsSubscription = Subscription & { +type PortalsSubscription = { + subscribe: (callback: () => void) => () => void; + getSnapshot: () => SubscribedPortal[]; has: (key: unknown) => boolean; set: (key: unknown, portal: ReactElement) => void; delete: (key: unknown) => void; @@ -16,17 +17,20 @@ type PortalsSubscription = Subscription & { const createPortalsSubscription = (): PortalsSubscription => { const portalsMap = new Map(); + let portals = Array.from(portalsMap.values()); const emitter = new Emitter<{ update: void }>(); return { - getCurrentValue: (): SubscribedPortal[] => Array.from(portalsMap.values()), - subscribe: (callback): Unsubscribe => emitter.on('update', callback), + getSnapshot: (): SubscribedPortal[] => portals, + subscribe: (callback): (() => void) => emitter.on('update', callback), delete: (key): void => { portalsMap.delete(key); + portals = Array.from(portalsMap.values()); emitter.emit('update'); }, set: (key, portal): void => { portalsMap.set(key, { portal, key: Random.id() }); + portals = Array.from(portalsMap.values()); emitter.emit('update'); }, has: (key): boolean => portalsMap.has(key), diff --git a/apps/meteor/client/lib/presence.ts b/apps/meteor/client/lib/presence.ts index 5c81820e7e4e..4a14c1478b28 100644 --- a/apps/meteor/client/lib/presence.ts +++ b/apps/meteor/client/lib/presence.ts @@ -27,11 +27,6 @@ export type UserPresence = Readonly< Partial> & Required> >; -type UsersPresencePayload = { - users: UserPresence[]; - full: boolean; -}; - const isUid = (eventType: keyof Events): eventType is UserPresence['_id'] => Boolean(eventType) && typeof eventType === 'string' && !['reset', 'restart', 'remove'].includes(eventType); @@ -51,15 +46,6 @@ const notify = (presence: UserPresence): void => { } }; -declare module '@rocket.chat/rest-typings' { - // eslint-disable-next-line @typescript-eslint/interface-name-prefix - export interface Endpoints { - '/v1/users.presence': { - GET: (params: { ids: string[] }) => UsersPresencePayload; - }; - } -} - const getPresence = ((): ((uid: UserPresence['_id']) => void) => { let timer: ReturnType; diff --git a/apps/meteor/client/providers/CallProvider/CallProvider.tsx b/apps/meteor/client/providers/CallProvider/CallProvider.tsx index fc6c9120b807..ba1e359e4acd 100644 --- a/apps/meteor/client/providers/CallProvider/CallProvider.tsx +++ b/apps/meteor/client/providers/CallProvider/CallProvider.tsx @@ -12,7 +12,7 @@ import { isVoipEventCallAbandoned, } from '@rocket.chat/core-typings'; import { useMutableCallback } from '@rocket.chat/fuselage-hooks'; -import { useRoute, useUser, useSetting, useEndpoint, useStream } from '@rocket.chat/ui-contexts'; +import { useRoute, useUser, useSetting, useEndpoint, useStream, useSetModal } from '@rocket.chat/ui-contexts'; import { Random } from 'meteor/random'; import React, { useMemo, FC, useRef, useCallback, useEffect, useState } from 'react'; import { createPortal } from 'react-dom'; @@ -21,8 +21,7 @@ import { OutgoingByeRequest } from 'sip.js/lib/core'; import { CustomSounds } from '../../../app/custom-sounds/client'; import { getUserPreference } from '../../../app/utils/client'; import { WrapUpCallModal } from '../../components/voip/modal/WrapUpCallModal'; -import { CallContext, CallContextValue } from '../../contexts/CallContext'; -import { imperativeModal } from '../../lib/imperativeModal'; +import { CallContext, CallContextValue, useCallCloseRoom } from '../../contexts/CallContext'; import { roomCoordinator } from '../../lib/rooms/roomCoordinator'; import { QueueAggregator } from '../../lib/voip/QueueAggregator'; import { useVoipClient } from './hooks/useVoipClient'; @@ -45,6 +44,7 @@ export const CallProvider: FC = ({ children }) => { const voipEnabled = useSetting('VoIP_Enabled'); const subscribeToNotifyUser = useStream('notify-user'); const dispatchEvent = useEndpoint('POST', '/v1/voip/events'); + const setModal = useSetModal(); const result = useVoipClient(); const user = useUser(); @@ -56,8 +56,8 @@ export const CallProvider: FC = ({ children }) => { const [queueName, setQueueName] = useState(''); const openWrapUpModal = useCallback((): void => { - imperativeModal.open({ component: WrapUpCallModal }); - }, []); + setModal(() => ); + }, [setModal]); const [queueAggregator, setQueueAggregator] = useState(); diff --git a/apps/meteor/client/providers/MeteorProvider.tsx b/apps/meteor/client/providers/MeteorProvider.tsx index 8504a59b8b15..ef0e5885cf7c 100644 --- a/apps/meteor/client/providers/MeteorProvider.tsx +++ b/apps/meteor/client/providers/MeteorProvider.tsx @@ -32,13 +32,13 @@ const MeteorProvider: FC = ({ children }) => ( - - - + + + {children} - - - + + + diff --git a/apps/meteor/client/providers/RouterProvider.tsx b/apps/meteor/client/providers/RouterProvider.tsx index b6d4b83cf260..91f9711b4587 100644 --- a/apps/meteor/client/providers/RouterProvider.tsx +++ b/apps/meteor/client/providers/RouterProvider.tsx @@ -2,13 +2,11 @@ import { RouterContext, RouterContextValue } from '@rocket.chat/ui-contexts'; import { FlowRouter } from 'meteor/kadira:flow-router'; import { Tracker } from 'meteor/tracker'; import React, { FC } from 'react'; -import { Subscription, Unsubscribe } from 'use-subscription'; -const createSubscription = function (getValue: () => T): Subscription { +const createSubscription = function (getValue: () => T): [subscribe: (onStoreChange: () => void) => () => void, getSnapshot: () => T] { let currentValue = Tracker.nonreactive(getValue); - return { - getCurrentValue: (): T => currentValue, - subscribe: (callback: () => void): Unsubscribe => { + return [ + (callback: () => void): (() => void) => { const computation = Tracker.autorun(() => { currentValue = getValue(); callback(); @@ -18,7 +16,8 @@ const createSubscription = function (getValue: () => T): Subscription { computation.stop(); }; }, - }; + (): T => currentValue, + ]; }; const queryRoutePath = ( diff --git a/apps/meteor/client/providers/createReactiveSubscriptionFactory.ts b/apps/meteor/client/providers/createReactiveSubscriptionFactory.ts index cf9e92695b2b..4dead8e2b29f 100644 --- a/apps/meteor/client/providers/createReactiveSubscriptionFactory.ts +++ b/apps/meteor/client/providers/createReactiveSubscriptionFactory.ts @@ -1,16 +1,15 @@ import { Tracker } from 'meteor/tracker'; -import { Subscription, Unsubscribe } from 'use-subscription'; interface ISubscriptionFactory { - (...args: any[]): Subscription; + (...args: any[]): [subscribe: (onStoreChange: () => void) => () => void, getSnapshot: () => T]; } export const createReactiveSubscriptionFactory = (computeCurrentValueWith: (...args: any[]) => T): ISubscriptionFactory => - (...args: any[]): Subscription => { + (...args: any[]): [subscribe: (onStoreChange: () => void) => () => void, getSnapshot: () => T] => { const computeCurrentValue = (): T => computeCurrentValueWith(...args); - const callbacks = new Set(); + const callbacks = new Set<() => void>(); let currentValue = computeCurrentValue(); @@ -24,9 +23,8 @@ export const createReactiveSubscriptionFactory = }); }, 0); - return { - getCurrentValue: (): T => currentValue, - subscribe: (callback): Unsubscribe => { + return [ + (callback): (() => void) => { callbacks.add(callback); return (): void => { @@ -39,5 +37,6 @@ export const createReactiveSubscriptionFactory = } }; }, - }; + (): T => currentValue, + ]; }; diff --git a/apps/meteor/client/sidebar/RoomList/SideBarItemTemplateWithData.tsx b/apps/meteor/client/sidebar/RoomList/SideBarItemTemplateWithData.tsx index 8a01dfce72ce..104a0b97496f 100644 --- a/apps/meteor/client/sidebar/RoomList/SideBarItemTemplateWithData.tsx +++ b/apps/meteor/client/sidebar/RoomList/SideBarItemTemplateWithData.tsx @@ -66,9 +66,9 @@ type RoomListRowProps = { /* @deprecated */ style?: AllHTMLAttributes['style']; - selected: boolean; + selected?: boolean; - sidebarViewMode: unknown; + sidebarViewMode?: unknown; }; function SideBarItemTemplateWithData({ diff --git a/apps/meteor/client/sidebar/RoomMenu.js b/apps/meteor/client/sidebar/RoomMenu.tsx similarity index 76% rename from apps/meteor/client/sidebar/RoomMenu.js rename to apps/meteor/client/sidebar/RoomMenu.tsx index 5e703f011e23..0457fb5096b7 100644 --- a/apps/meteor/client/sidebar/RoomMenu.js +++ b/apps/meteor/client/sidebar/RoomMenu.tsx @@ -1,3 +1,4 @@ +import { RoomType } from '@rocket.chat/core-typings'; import { Option, Menu } from '@rocket.chat/fuselage'; import { useMutableCallback } from '@rocket.chat/fuselage-hooks'; import { @@ -9,8 +10,10 @@ import { usePermission, useMethod, useTranslation, + TranslationKey, } from '@rocket.chat/ui-contexts'; -import React, { memo, useMemo } from 'react'; +import { Fields } from '@rocket.chat/ui-contexts/dist/UserContext'; +import React, { memo, ReactElement, useMemo } from 'react'; import { RoomManager } from '../../app/ui-utils/client/lib/RoomManager'; import { UiTextContext } from '../../definition/IRoomTypeConfig'; @@ -19,13 +22,24 @@ import { useDontAskAgain } from '../hooks/useDontAskAgain'; import { roomCoordinator } from '../lib/rooms/roomCoordinator'; import WarningModal from '../views/admin/apps/WarningModal'; -const fields = { - f: 1, - t: 1, - name: 1, +const fields: Fields = { + f: true, + t: true, + name: true, }; -const RoomMenu = ({ rid, unread, threadUnread, alert, roomOpen, type, cl, name = '' }) => { +type RoomMenuProps = { + rid: string; + unread?: boolean; + threadUnread?: boolean; + alert?: boolean; + roomOpen?: boolean; + type: RoomType; + cl?: boolean; + name?: string; +}; + +const RoomMenu = ({ rid, unread, threadUnread, alert, roomOpen, type, cl, name = '' }: RoomMenuProps): ReactElement => { const t = useTranslation(); const dispatchToastMessage = useToastMessageDispatch(); const setModal = useSetModal(); @@ -36,7 +50,7 @@ const RoomMenu = ({ rid, unread, threadUnread, alert, roomOpen, type, cl, name = const subscription = useUserSubscription(rid, fields); const canFavorite = useSetting('Favorite_Rooms'); - const isFavorite = (subscription != null ? subscription.f : undefined) != null && subscription.f; + const isFavorite = Boolean(subscription?.f); const dontAskHideRoom = useDontAskAgain('hideRoom'); @@ -51,7 +65,7 @@ const RoomMenu = ({ rid, unread, threadUnread, alert, roomOpen, type, cl, name = const canLeaveChannel = usePermission('leave-c'); const canLeavePrivate = usePermission('leave-p'); - const canLeave = (() => { + const canLeave = ((): boolean => { if (type === 'c' && !canLeaveChannel) { return false; } @@ -62,7 +76,7 @@ const RoomMenu = ({ rid, unread, threadUnread, alert, roomOpen, type, cl, name = })(); const handleLeave = useMutableCallback(() => { - const leave = async () => { + const leave = async (): Promise => { try { await leaveRoom(rid); if (roomOpen) { @@ -70,7 +84,7 @@ const RoomMenu = ({ rid, unread, threadUnread, alert, roomOpen, type, cl, name = } RoomManager.close(rid); } catch (error) { - dispatchToastMessage({ type: 'error', message: error }); + dispatchToastMessage({ type: 'error', message: String(error) }); } closeModal(); }; @@ -79,7 +93,7 @@ const RoomMenu = ({ rid, unread, threadUnread, alert, roomOpen, type, cl, name = setModal( { - const hide = async () => { + const hide = async (): Promise => { try { await hideRoom(rid); } catch (error) { - dispatchToastMessage({ type: 'error', message: error }); + dispatchToastMessage({ type: 'error', message: String(error) }); } closeModal(); }; @@ -118,7 +132,7 @@ const RoomMenu = ({ rid, unread, threadUnread, alert, roomOpen, type, cl, name = label: t('Hide_room'), }} > - {t(warnText, name)} + {t(warnText as TranslationKey, name)} , ); }); @@ -137,7 +151,7 @@ const RoomMenu = ({ rid, unread, threadUnread, alert, roomOpen, type, cl, name = router.push({}); } catch (error) { - dispatchToastMessage({ type: 'error', message: error }); + dispatchToastMessage({ type: 'error', message: String(error) }); } }); @@ -145,7 +159,7 @@ const RoomMenu = ({ rid, unread, threadUnread, alert, roomOpen, type, cl, name = try { await toggleFavorite(rid, !isFavorite); } catch (error) { - dispatchToastMessage({ type: 'error', message: error }); + dispatchToastMessage({ type: 'error', message: String(error) }); } }); @@ -159,15 +173,17 @@ const RoomMenu = ({ rid, unread, threadUnread, alert, roomOpen, type, cl, name = label: { label: isUnread ? t('Mark_read') : t('Mark_unread'), icon: 'flag' }, action: handleToggleRead, }, - ...(canFavorite && { - toggleFavorite: { - label: { - label: isFavorite ? t('Unfavorite') : t('Favorite'), - icon: isFavorite ? 'star-filled' : 'star', - }, - action: handleToggleFavorite, - }, - }), + ...(canFavorite + ? { + toggleFavorite: { + label: { + label: isFavorite ? t('Unfavorite') : t('Favorite'), + icon: isFavorite ? 'star-filled' : 'star', + }, + action: handleToggleFavorite, + }, + } + : {}), ...(canLeave && { leaveRoom: { label: { label: t('Leave_room'), icon: 'sign-out' }, diff --git a/apps/meteor/client/sidebar/Sidebar.stories.tsx b/apps/meteor/client/sidebar/Sidebar.stories.tsx index 8a9e70d9c20e..6db4e4243519 100644 --- a/apps/meteor/client/sidebar/Sidebar.stories.tsx +++ b/apps/meteor/client/sidebar/Sidebar.stories.tsx @@ -31,14 +31,8 @@ const settings: Record = { const settingContextValue: ContextType = { hasPrivateAccess: true, isLoading: false, - querySetting: (_id) => ({ - getCurrentValue: () => settings[_id], - subscribe: () => () => undefined, - }), - querySettings: () => ({ - getCurrentValue: () => [], - subscribe: () => () => undefined, - }), + querySetting: (_id) => [() => () => undefined, () => settings[_id]], + querySettings: () => [() => () => undefined, () => []], dispatch: async () => undefined, }; @@ -87,24 +81,15 @@ const userContextValue: ContextType = { roles: ['admin'], type: 'user', }, - queryPreference: (pref: string | ObjectId, defaultValue: T) => ({ - getCurrentValue: () => (typeof pref === 'string' ? (userPreferences[pref] as T) : defaultValue), - subscribe: () => () => undefined, - }), - querySubscriptions: () => ({ - getCurrentValue: () => subscriptions, - subscribe: () => () => undefined, - }), - querySubscription: () => ({ - getCurrentValue: () => undefined, - subscribe: () => () => undefined, - }), + queryPreference: (pref: string | ObjectId, defaultValue: T) => [ + () => () => undefined, + () => (typeof pref === 'string' ? (userPreferences[pref] as T) : defaultValue), + ], + querySubscriptions: () => [() => () => undefined, () => subscriptions], + querySubscription: () => [() => () => undefined, () => undefined], loginWithPassword: () => Promise.resolve(undefined), logout: () => Promise.resolve(undefined), - queryRoom: () => ({ - getCurrentValue: () => undefined, - subscribe: () => () => undefined, - }), + queryRoom: () => [() => () => undefined, () => undefined], }; export const Sidebar: Story = () => ( diff --git a/apps/meteor/client/sidebar/search/Row.js b/apps/meteor/client/sidebar/search/Row.tsx similarity index 73% rename from apps/meteor/client/sidebar/search/Row.js rename to apps/meteor/client/sidebar/search/Row.tsx index c1134cd5e45e..475eae9e2a47 100644 --- a/apps/meteor/client/sidebar/search/Row.js +++ b/apps/meteor/client/sidebar/search/Row.tsx @@ -1,9 +1,15 @@ -import React, { memo } from 'react'; +import { IRoom, ISubscription } from '@rocket.chat/core-typings'; +import React, { memo, ReactElement } from 'react'; import SideBarItemTemplateWithData from '../RoomList/SideBarItemTemplateWithData'; import UserItem from './UserItem'; -const Row = ({ item, data }) => { +type RowProps = { + item: ISubscription & IRoom; + data: Record; +}; + +const Row = ({ item, data }: RowProps): ReactElement => { const { t, SideBarItemTemplate, avatarTemplate: AvatarTemplate, useRealName, extended } = data; if (item.t === 'd' && !item.u) { @@ -21,7 +27,6 @@ const Row = ({ item, data }) => { return (
} - renderTrackHorizontal={(props) =>
} - /> - ); -}); - -export default ScrollerWithCustomProps; diff --git a/apps/meteor/client/sidebar/search/ScrollerWithCustomProps.tsx b/apps/meteor/client/sidebar/search/ScrollerWithCustomProps.tsx new file mode 100644 index 000000000000..3066c0d218e6 --- /dev/null +++ b/apps/meteor/client/sidebar/search/ScrollerWithCustomProps.tsx @@ -0,0 +1,16 @@ +import React, { forwardRef, ReactElement } from 'react'; + +import ScrollableContentWrapper from '../../components/ScrollableContentWrapper'; + +const ScrollerWithCustomProps = forwardRef(function ScrollerWithCustomProps(props, ref: React.Ref) { + return ( +
} + renderTrackHorizontal={(props): ReactElement =>
} + /> + ); +}); + +export default ScrollerWithCustomProps; diff --git a/apps/meteor/client/sidebar/search/SearchList.js b/apps/meteor/client/sidebar/search/SearchList.tsx similarity index 60% rename from apps/meteor/client/sidebar/search/SearchList.js rename to apps/meteor/client/sidebar/search/SearchList.tsx index 33f677ed8b39..80be10d7ac62 100644 --- a/apps/meteor/client/sidebar/search/SearchList.js +++ b/apps/meteor/client/sidebar/search/SearchList.tsx @@ -1,11 +1,31 @@ +import { RoomType } from '@rocket.chat/core-typings'; import { css } from '@rocket.chat/css-in-js'; import { Sidebar, TextInput, Box, Icon } from '@rocket.chat/fuselage'; -import { useMutableCallback, useDebouncedValue, useStableArray, useAutoFocus, useUniqueId } from '@rocket.chat/fuselage-hooks'; +import { + useMutableCallback, + useDebouncedValue, + useStableArray, + useAutoFocus, + useUniqueId, + useMergedRefs, +} from '@rocket.chat/fuselage-hooks'; import { escapeRegExp } from '@rocket.chat/string-helpers'; import { useUserPreference, useUserSubscriptions, useSetting, useTranslation } from '@rocket.chat/ui-contexts'; import { Meteor } from 'meteor/meteor'; -import React, { forwardRef, useState, useMemo, useEffect, useRef } from 'react'; -import { Virtuoso } from 'react-virtuoso'; +import React, { + forwardRef, + useState, + useMemo, + useEffect, + useRef, + ReactElement, + MutableRefObject, + SetStateAction, + Dispatch, + FormEventHandler, + Ref, +} from 'react'; +import { Virtuoso, VirtuosoHandle } from 'react-virtuoso'; import tinykeys from 'tinykeys'; import { AsyncStatePhase } from '../../hooks/useAsyncState'; @@ -15,8 +35,8 @@ import { useTemplateByViewMode } from '../hooks/useTemplateByViewMode'; import Row from './Row'; import ScrollerWithCustomProps from './ScrollerWithCustomProps'; -const shortcut = (() => { - if (!Meteor.Device.isDesktop()) { +const shortcut = ((): string => { + if (!(Meteor as any).Device.isDesktop()) { return ''; } if (window.navigator.platform.toLowerCase().includes('mac')) { @@ -25,9 +45,9 @@ const shortcut = (() => { return '(\u2303+K)'; })(); -const useSpotlight = (filterText = '', usernames) => { +const useSpotlight = (filterText: string, usernames: string[]) => { const expression = /(@|#)?(.*)/i; - const [, mention, name] = filterText.match(expression); + const [, mention, name] = filterText.match(expression) || []; const searchForChannels = mention === '#'; const searchForDMs = mention === '@'; @@ -41,9 +61,10 @@ const useSpotlight = (filterText = '', usernames) => { } return { users: true, rooms: true }; }, [searchForChannels, searchForDMs]); + const args = useMemo(() => [name, usernames, type], [type, name, usernames]); - const { value: data = { users: [], rooms: [] }, phase: status } = useMethodData('spotlight', args); + const { value: data, phase: status } = useMethodData('spotlight', args); return useMemo(() => { if (!data) { @@ -60,11 +81,10 @@ const options = { }, }; -const useSearchItems = (filterText) => { +const useSearchItems = (filterText: string): any => { const expression = /(@|#)?(.*)/i; - const teste = filterText.match(expression); + const [, type, name] = filterText.match(expression) || []; - const [, type, name] = teste; const query = useMemo(() => { const filterRegex = new RegExp(escapeRegExp(name), 'i'); @@ -76,23 +96,36 @@ const useSearchItems = (filterText) => { }; }, [name, type]); - const localRooms = useUserSubscriptions(query, options); + const localRooms: { rid: string; t: RoomType; _id: string; name: string; uids?: string }[] = useUserSubscriptions(query, options); - const usernamesFromClient = useStableArray([...localRooms?.map(({ t, name }) => (t === 'd' ? name : null))].filter(Boolean)); + const usernamesFromClient = useStableArray([...localRooms?.map(({ t, name }) => (t === 'd' ? name : null))].filter(Boolean)) as string[]; const { data: spotlight, status } = useSpotlight(filterText, usernamesFromClient); return useMemo(() => { - const resultsFromServer = []; + const filterUsersUnique = ({ _id }: { _id: string }, index: number, arr: { _id: string }[]): boolean => + index === arr.findIndex((user) => _id === user._id); - const filterUsersUnique = ({ _id }, index, arr) => index === arr.findIndex((user) => _id === user._id); - const roomFilter = (room) => + const roomFilter = (room: { t: string; uids?: string[]; _id: string; name?: string }): boolean => !localRooms.find( - (item) => (room.t === 'd' && room.uids?.length > 1 && room.uids.includes(item._id)) || [item.rid, item._id].includes(room._id), + (item) => + (room.t === 'd' && room.uids && room.uids.length > 1 && room.uids?.includes(item._id)) || [item.rid, item._id].includes(room._id), ); - const usersfilter = (user) => !localRooms.find((room) => room.t === 'd' && room.uids?.length === 2 && room.uids.includes(user._id)); - - const userMap = (user) => ({ + const usersfilter = (user: { _id: string }): boolean => + !localRooms.find((room) => room.t === 'd' && room.uids && room.uids?.length === 2 && room.uids.includes(user._id)); + + const userMap = (user: { + _id: string; + name: string; + username: string; + avatarETag?: string; + }): { + _id: string; + t: string; + name: string; + fname: string; + avatarETag?: string; + } => ({ _id: user._id, t: 'd', name: user.username, @@ -100,17 +133,27 @@ const useSearchItems = (filterText) => { avatarETag: user.avatarETag, }); - const exact = resultsFromServer.filter((item) => [item.usernamame, item.name, item.fname].includes(name)); + type resultsFromServerType = { + _id: string; + t: string; + name: string; + fname?: string; + avatarETag?: string | undefined; + uids?: string[] | undefined; + }[]; + const resultsFromServer: resultsFromServerType = []; resultsFromServer.push(...spotlight.users.filter(filterUsersUnique).filter(usersfilter).map(userMap)); resultsFromServer.push(...spotlight.rooms.filter(roomFilter)); + const exact = resultsFromServer?.filter((item) => [item.name, item.fname].includes(name)); + return { data: Array.from(new Set([...exact, ...localRooms, ...resultsFromServer])), status }; // eslint-disable-next-line react-hooks/exhaustive-deps }, [localRooms, name, spotlight]); }; -const useInput = (initial) => { +const useInput = (initial: string): { value: string; onChange: FormEventHandler; setValue: Dispatch> } => { const [value, setValue] = useState(initial); const onChange = useMutableCallback((e) => { setValue(e.currentTarget.value); @@ -118,12 +161,12 @@ const useInput = (initial) => { return { value, onChange, setValue }; }; -const toggleSelectionState = (next, current, input) => { - input.setAttribute('aria-activedescendant', next.id); - next.setAttribute('aria-selected', true); +const toggleSelectionState = (next: HTMLElement, current: HTMLElement | undefined, input: HTMLElement | undefined): void => { + input?.setAttribute('aria-activedescendant', next.id); + next.setAttribute('aria-selected', 'true'); next.classList.add('rcx-sidebar-item--selected'); if (current) { - current.setAttribute('aria-selected', false); + current.removeAttribute('aria-selected'); current.classList.remove('rcx-sidebar-item--selected'); } }; @@ -131,17 +174,23 @@ const toggleSelectionState = (next, current, input) => { /** * @type import('react').ForwardRefExoticComponent<{ onClose: unknown } & import('react').RefAttributes> */ -const SearchList = forwardRef(function SearchList({ onClose }, ref) { + +type SearchListProps = { + onClose: () => void; +}; + +const SearchList = forwardRef(function SearchList({ onClose }: SearchListProps, ref): ReactElement { const listId = useUniqueId(); const t = useTranslation(); const { setValue: setFilterValue, ...filter } = useInput(''); - const autofocus = useAutoFocus(); + const cursorRef = useRef(null); + const autofocus: Ref = useMergedRefs(useAutoFocus(), cursorRef); - const listRef = useRef(); - const boxRef = useRef(); + const listRef = useRef(null); + const boxRef = useRef(null); - const selectedElement = useRef(); + const selectedElement: MutableRefObject = useRef(null); const itemIndexRef = useRef(0); const sidebarViewMode = useUserPreference('sidebarViewMode'); @@ -175,13 +224,13 @@ const SearchList = forwardRef(function SearchList({ onClose }, ref) { let nextSelectedElement = null; if (dir === 'up') { - nextSelectedElement = selectedElement.current.parentElement.previousSibling.querySelector('a'); + nextSelectedElement = (selectedElement.current?.parentElement?.previousSibling as HTMLElement).querySelector('a'); } else { - nextSelectedElement = selectedElement.current.parentElement.nextSibling.querySelector('a'); + nextSelectedElement = (selectedElement.current?.parentElement?.nextSibling as HTMLElement).querySelector('a'); } if (nextSelectedElement) { - toggleSelectionState(nextSelectedElement, selectedElement.current, autofocus.current); + toggleSelectionState(nextSelectedElement, selectedElement.current || undefined, cursorRef?.current || undefined); return nextSelectedElement; } return selectedElement.current; @@ -189,12 +238,12 @@ const SearchList = forwardRef(function SearchList({ onClose }, ref) { const resetCursor = useMutableCallback(() => { itemIndexRef.current = 0; - listRef.current.scrollToIndex({ index: itemIndexRef.current }); + listRef.current?.scrollToIndex({ index: itemIndexRef.current }); selectedElement.current = boxRef.current?.querySelector('a.rcx-sidebar-item'); if (selectedElement.current) { - toggleSelectionState(selectedElement.current, undefined, autofocus.current); + toggleSelectionState(selectedElement.current, undefined, cursorRef?.current || undefined); } }); @@ -207,10 +256,10 @@ const SearchList = forwardRef(function SearchList({ onClose }, ref) { }, [filterText, resetCursor]); useEffect(() => { - if (!autofocus.current) { + if (!cursorRef?.current) { return; } - const unsubscribe = tinykeys(autofocus.current, { + const unsubscribe = tinykeys(cursorRef?.current, { Escape: (event) => { event.preventDefault(); setFilterValue((value) => { @@ -225,13 +274,13 @@ const SearchList = forwardRef(function SearchList({ onClose }, ref) { ArrowUp: () => { const currentElement = changeSelection('up'); itemIndexRef.current = Math.max(itemIndexRef.current - 1, 0); - listRef.current.scrollToIndex({ index: itemIndexRef.current }); + listRef.current?.scrollToIndex({ index: itemIndexRef.current }); selectedElement.current = currentElement; }, ArrowDown: () => { const currentElement = changeSelection('down'); itemIndexRef.current = Math.min(itemIndexRef.current + 1, items?.length + 1); - listRef.current.scrollToIndex({ index: itemIndexRef.current }); + listRef.current?.scrollToIndex({ index: itemIndexRef.current }); selectedElement.current = currentElement; }, Enter: () => { @@ -240,10 +289,10 @@ const SearchList = forwardRef(function SearchList({ onClose }, ref) { } }, }); - return () => { + return (): void => { unsubscribe(); }; - }, [autofocus, changeSelection, items.length, onClose, resetCursor, setFilterValue]); + }, [cursorRef, changeSelection, items.length, onClose, resetCursor, setFilterValue]); return ( - + } + itemContent={(_, data): ReactElement => } ref={listRef} /> diff --git a/apps/meteor/client/sidebar/search/UserItem.js b/apps/meteor/client/sidebar/search/UserItem.tsx similarity index 59% rename from apps/meteor/client/sidebar/search/UserItem.js rename to apps/meteor/client/sidebar/search/UserItem.tsx index febaa41717c6..21840bcc414a 100644 --- a/apps/meteor/client/sidebar/search/UserItem.js +++ b/apps/meteor/client/sidebar/search/UserItem.tsx @@ -1,13 +1,28 @@ +import { IUser } from '@rocket.chat/core-typings'; import { Sidebar } from '@rocket.chat/fuselage'; -import React, { memo } from 'react'; +import React, { memo, ReactElement } from 'react'; import { ReactiveUserStatus } from '../../components/UserStatus'; import { roomCoordinator } from '../../lib/rooms/roomCoordinator'; -const UserItem = ({ item, id, style, t, SideBarItemTemplate, AvatarTemplate, useRealName }) => { +type UserItemProps = { + item: { + name?: string; + fname?: string; + _id: IUser['_id']; + t: string; + }; + t: (value: string) => string; + SideBarItemTemplate: any; + AvatarTemplate: any; + id: string; + style?: CSSStyleRule; + useRealName?: boolean; +}; +const UserItem = ({ item, id, style, t, SideBarItemTemplate, AvatarTemplate, useRealName }: UserItemProps): ReactElement => { const title = useRealName ? item.fname || item.name : item.name || item.fname; const icon = ( - + ); @@ -16,14 +31,13 @@ const UserItem = ({ item, id, style, t, SideBarItemTemplate, AvatarTemplate, use return ( } icon={icon} - style={style} /> ); }; diff --git a/apps/meteor/client/startup/unread.ts b/apps/meteor/client/startup/unread.ts index 15c40de2d9c4..6c076e4ba107 100644 --- a/apps/meteor/client/startup/unread.ts +++ b/apps/meteor/client/startup/unread.ts @@ -1,9 +1,9 @@ import type { ISubscription } from '@rocket.chat/core-typings'; +import { manageFavicon } from '@rocket.chat/favicon'; import { Meteor } from 'meteor/meteor'; import { Session } from 'meteor/session'; import { Tracker } from 'meteor/tracker'; -import { Favico } from '../../app/favico/client'; import { ChatSubscription, ChatRoom } from '../../app/models/client'; import { settings } from '../../app/settings/client'; import { getUserPreference } from '../../app/utils/client'; @@ -75,12 +75,7 @@ Meteor.startup(() => { }); Meteor.startup(() => { - const favicon = new (Favico as any)({ - position: 'up', - animation: 'none', - }); - - window.favico = favicon; + const updateFavicon = manageFavicon(); Tracker.autorun(() => { const siteName = settings.get('Site_Name') ?? ''; @@ -88,11 +83,7 @@ Meteor.startup(() => { const unread = Session.get('unread'); fireGlobalEvent('unread-changed', unread); - if (favicon) { - favicon.badge(unread, { - bgColor: typeof unread !== 'number' ? '#3d8a3a' : '#ac1b1b', - }); - } + updateFavicon(unread); document.title = unread === '' ? siteName : `(${unread}) ${siteName}`; }); diff --git a/apps/meteor/client/views/account/AccountSidebar.tsx b/apps/meteor/client/views/account/AccountSidebar.tsx index aff5500e00f8..37662e5e8b82 100644 --- a/apps/meteor/client/views/account/AccountSidebar.tsx +++ b/apps/meteor/client/views/account/AccountSidebar.tsx @@ -1,17 +1,17 @@ import { useRoutePath, useCurrentRoute, useTranslation } from '@rocket.chat/ui-contexts'; import React, { memo, ReactElement, useCallback, useEffect } from 'react'; -import { useSubscription } from 'use-subscription'; +import { useSyncExternalStore } from 'use-sync-external-store/shim'; -import { itemsSubscription } from '.'; import { menu, SideNav } from '../../../app/ui-utils/client'; import Sidebar from '../../components/Sidebar'; import { isLayoutEmbedded } from '../../lib/utils/isLayoutEmbedded'; import SettingsProvider from '../../providers/SettingsProvider'; +import { getAccountSidebarItems, subscribeToAccountSidebarItems } from './sidebarItems'; const AccountSidebar = (): ReactElement => { const t = useTranslation(); - const items = useSubscription(itemsSubscription); + const items = useSyncExternalStore(subscribeToAccountSidebarItems, getAccountSidebarItems); const closeFlex = useCallback(() => { if (isLayoutEmbedded()) { diff --git a/apps/meteor/client/views/account/index.ts b/apps/meteor/client/views/account/index.ts index 01c25a32a809..8fc7e4da2521 100644 --- a/apps/meteor/client/views/account/index.ts +++ b/apps/meteor/client/views/account/index.ts @@ -1,2 +1,2 @@ export { registerAccountRoute } from './routes'; -export { registerAccountSidebarItem, unregisterSidebarItem, itemsSubscription } from './sidebarItems'; +export { registerAccountSidebarItem, unregisterSidebarItem } from './sidebarItems'; diff --git a/apps/meteor/client/views/account/sidebarItems.ts b/apps/meteor/client/views/account/sidebarItems.ts index 8ce98bc85e86..77ed0fa01267 100644 --- a/apps/meteor/client/views/account/sidebarItems.ts +++ b/apps/meteor/client/views/account/sidebarItems.ts @@ -5,7 +5,8 @@ import { createSidebarItems } from '../../lib/createSidebarItems'; export const { registerSidebarItem: registerAccountSidebarItem, unregisterSidebarItem, - itemsSubscription, + getSidebarItems: getAccountSidebarItems, + subscribeToSidebarItems: subscribeToAccountSidebarItems, } = createSidebarItems([ { href: 'preferences', diff --git a/apps/meteor/client/views/admin/EditableSettingsContext.ts b/apps/meteor/client/views/admin/EditableSettingsContext.ts index 2c74b679fc9e..c8898f09a93d 100644 --- a/apps/meteor/client/views/admin/EditableSettingsContext.ts +++ b/apps/meteor/client/views/admin/EditableSettingsContext.ts @@ -1,7 +1,7 @@ import { ISettingBase, SectionName, SettingId, GroupId, TabId, ISettingColor } from '@rocket.chat/core-typings'; import { SettingsContextQuery } from '@rocket.chat/ui-contexts'; import { createContext, useContext, useMemo } from 'react'; -import { useSubscription, Subscription, Unsubscribe } from 'use-subscription'; +import { useSyncExternalStore } from 'use-sync-external-store/shim'; export type EditableSetting = (ISettingBase | ISettingColor) & { disabled: boolean; @@ -14,58 +14,53 @@ export type EditableSettingsContextQuery = SettingsContextQuery & { }; export type EditableSettingsContextValue = { - readonly queryEditableSetting: (_id: SettingId) => Subscription; - readonly queryEditableSettings: (query: EditableSettingsContextQuery) => Subscription; - readonly queryGroupSections: (_id: GroupId, tab?: TabId) => Subscription; - readonly queryGroupTabs: (_id: GroupId) => Subscription; + readonly queryEditableSetting: ( + _id: SettingId, + ) => [subscribe: (onStoreChange: () => void) => () => void, getSnapshot: () => EditableSetting | undefined]; + readonly queryEditableSettings: ( + query: EditableSettingsContextQuery, + ) => [subscribe: (onStoreChange: () => void) => () => void, getSnapshot: () => EditableSetting[]]; + readonly queryGroupSections: ( + _id: GroupId, + tab?: TabId, + ) => [subscribe: (onStoreChange: () => void) => () => void, getSnapshot: () => SectionName[]]; + readonly queryGroupTabs: (_id: GroupId) => [subscribe: (onStoreChange: () => void) => () => void, getSnapshot: () => TabId[]]; readonly dispatch: (changes: Partial[]) => void; }; export const EditableSettingsContext = createContext({ - queryEditableSetting: () => ({ - getCurrentValue: (): undefined => undefined, - subscribe: (): Unsubscribe => (): void => undefined, - }), - queryEditableSettings: () => ({ - getCurrentValue: (): EditableSetting[] => [], - subscribe: (): Unsubscribe => (): void => undefined, - }), - queryGroupSections: () => ({ - getCurrentValue: (): SectionName[] => [], - subscribe: (): Unsubscribe => (): void => undefined, - }), - queryGroupTabs: () => ({ - getCurrentValue: (): TabId[] => [], - subscribe: (): Unsubscribe => (): void => undefined, - }), + queryEditableSetting: () => [(): (() => void) => (): void => undefined, (): undefined => undefined], + queryEditableSettings: () => [(): (() => void) => (): void => undefined, (): EditableSetting[] => []], + queryGroupSections: () => [(): (() => void) => (): void => undefined, (): SectionName[] => []], + queryGroupTabs: () => [(): (() => void) => (): void => undefined, (): TabId[] => []], dispatch: () => undefined, }); export const useEditableSetting = (_id: SettingId): EditableSetting | undefined => { const { queryEditableSetting } = useContext(EditableSettingsContext); - const subscription = useMemo(() => queryEditableSetting(_id), [queryEditableSetting, _id]); - return useSubscription(subscription); + const [subscribe, getSnapshot] = useMemo(() => queryEditableSetting(_id), [queryEditableSetting, _id]); + return useSyncExternalStore(subscribe, getSnapshot); }; export const useEditableSettings = (query?: EditableSettingsContextQuery): EditableSetting[] => { const { queryEditableSettings } = useContext(EditableSettingsContext); - const subscription = useMemo(() => queryEditableSettings(query ?? {}), [queryEditableSettings, query]); - return useSubscription(subscription); + const [subscribe, getSnapshot] = useMemo(() => queryEditableSettings(query ?? {}), [queryEditableSettings, query]); + return useSyncExternalStore(subscribe, getSnapshot); }; export const useEditableSettingsGroupSections = (_id: SettingId, tab?: TabId): SectionName[] => { const { queryGroupSections } = useContext(EditableSettingsContext); - const subscription = useMemo(() => queryGroupSections(_id, tab), [queryGroupSections, _id, tab]); - return useSubscription(subscription); + const [subscribe, getSnapshot] = useMemo(() => queryGroupSections(_id, tab), [queryGroupSections, _id, tab]); + return useSyncExternalStore(subscribe, getSnapshot); }; export const useEditableSettingsGroupTabs = (_id: SettingId): TabId[] => { const { queryGroupTabs } = useContext(EditableSettingsContext); - const subscription = useMemo(() => queryGroupTabs(_id), [queryGroupTabs, _id]); - return useSubscription(subscription); + const [subscribe, getSnapshot] = useMemo(() => queryGroupTabs(_id), [queryGroupTabs, _id]); + return useSyncExternalStore(subscribe, getSnapshot); }; export const useEditableSettingsDispatch = (): ((changes: Partial[]) => void) => diff --git a/apps/meteor/client/views/admin/apps/AppMenu.js b/apps/meteor/client/views/admin/apps/AppMenu.js index 7daf4505c9b5..ed4bbf9b0e76 100644 --- a/apps/meteor/client/views/admin/apps/AppMenu.js +++ b/apps/meteor/client/views/admin/apps/AppMenu.js @@ -1,5 +1,5 @@ import { Box, Icon, Menu } from '@rocket.chat/fuselage'; -import { useSetModal, useMethod, useEndpoint, useTranslation } from '@rocket.chat/ui-contexts'; +import { useSetModal, useMethod, useEndpoint, useTranslation, useRoute, useRouteParameter } from '@rocket.chat/ui-contexts'; import React, { useMemo, useCallback } from 'react'; import CloudLoginModal from './CloudLoginModal'; @@ -11,6 +11,8 @@ function AppMenu({ app, ...props }) { const t = useTranslation(); const setModal = useSetModal(); const checkUserLoggedIn = useMethod('cloud:checkUserLoggedIn'); + const appsRoute = useRoute('admin-apps'); + const context = useRouteParameter('context'); const setAppStatus = useEndpoint('POST', `/apps/${app.id}/status`); const buildExternalUrl = useEndpoint('GET', '/apps'); @@ -64,6 +66,10 @@ function AppMenu({ app, ...props }) { setModal(); }, [checkUserLoggedIn, setModal, closeModal, buildExternalUrl, app.id, app.purchaseType, syncApp]); + const handleViewLogs = useCallback(() => { + appsRoute.push({ context: 'details', id: app.id, version: app.version, tab: 'logs' }); + }, [app.id, app.version, appsRoute]); + const handleDisable = useCallback(() => { const confirm = async () => { closeModal(); @@ -124,6 +130,17 @@ function AppMenu({ app, ...props }) { action: handleSubscription, }, }), + ...(context !== 'details' && { + viewLogs: { + label: ( + + + {t('View_Logs')} + + ), + action: handleViewLogs, + }, + }), ...(app.installed && isAppEnabled && { disable: { @@ -160,7 +177,18 @@ function AppMenu({ app, ...props }) { }, }), }), - [canAppBeSubscribed, t, handleSubscription, app.installed, isAppEnabled, handleDisable, handleEnable, handleUninstall], + [ + canAppBeSubscribed, + t, + handleSubscription, + context, + handleViewLogs, + app.installed, + isAppEnabled, + handleDisable, + handleEnable, + handleUninstall, + ], ); return ; diff --git a/apps/meteor/client/views/admin/sidebar/AdminSidebarPages.tsx b/apps/meteor/client/views/admin/sidebar/AdminSidebarPages.tsx index e188e396bd7d..664c18838c63 100644 --- a/apps/meteor/client/views/admin/sidebar/AdminSidebarPages.tsx +++ b/apps/meteor/client/views/admin/sidebar/AdminSidebarPages.tsx @@ -1,10 +1,10 @@ import { Box } from '@rocket.chat/fuselage'; import React, { memo, FC } from 'react'; -import { useSubscription } from 'use-subscription'; +import { useSyncExternalStore } from 'use-sync-external-store/shim'; import SidebarItemsAssembler from '../../../components/Sidebar/SidebarItemsAssembler'; import { useUpgradeTabParams } from '../../hooks/useUpgradeTabParams'; -import { itemsSubscription } from '../sidebarItems'; +import { subscribeToAdminSidebarItems, getAdminSidebarItems } from '../sidebarItems'; import UpgradeTab from './UpgradeTab'; type AdminSidebarPagesProps = { @@ -12,7 +12,8 @@ type AdminSidebarPagesProps = { }; const AdminSidebarPages: FC = ({ currentPath }) => { - const items = useSubscription(itemsSubscription); + const items = useSyncExternalStore(subscribeToAdminSidebarItems, getAdminSidebarItems); + const { tabType, trialEndDate, isLoading } = useUpgradeTabParams(); return ( diff --git a/apps/meteor/client/views/admin/sidebarItems.ts b/apps/meteor/client/views/admin/sidebarItems.ts index a9dc4bf53514..d3f195069647 100644 --- a/apps/meteor/client/views/admin/sidebarItems.ts +++ b/apps/meteor/client/views/admin/sidebarItems.ts @@ -4,7 +4,8 @@ import { createSidebarItems } from '../../lib/createSidebarItems'; export const { registerSidebarItem: registerAdminSidebarItem, unregisterSidebarItem, - itemsSubscription, + getSidebarItems: getAdminSidebarItems, + subscribeToSidebarItems: subscribeToAdminSidebarItems, } = createSidebarItems([ { href: 'admin-info', diff --git a/apps/meteor/client/views/admin/users/EditUser.js b/apps/meteor/client/views/admin/users/EditUser.js index a8130ca64484..9c5ccbf6807a 100644 --- a/apps/meteor/client/views/admin/users/EditUser.js +++ b/apps/meteor/client/views/admin/users/EditUser.js @@ -14,7 +14,6 @@ const getInitialValue = (data) => ({ name: data.name ?? '', password: '', username: data.username, - status: data.status, bio: data.bio ?? '', nickname: data.nickname ?? '', email: (data.emails && data.emails.length && data.emails[0].address) || '', diff --git a/apps/meteor/client/views/banners/BannerRegion.tsx b/apps/meteor/client/views/banners/BannerRegion.tsx index c87667e5f3de..a840540482b4 100644 --- a/apps/meteor/client/views/banners/BannerRegion.tsx +++ b/apps/meteor/client/views/banners/BannerRegion.tsx @@ -1,12 +1,12 @@ import React, { FC } from 'react'; -import { useSubscription } from 'use-subscription'; +import { useSyncExternalStore } from 'use-sync-external-store/shim'; import * as banners from '../../lib/banners'; import LegacyBanner from './LegacyBanner'; import UiKitBanner from './UiKitBanner'; const BannerRegion: FC = () => { - const payload = useSubscription(banners.firstSubscription); + const payload = useSyncExternalStore(...banners.firstSubscription); if (!payload) { return null; diff --git a/apps/meteor/client/views/hooks/useActionSpread.ts b/apps/meteor/client/views/hooks/useActionSpread.ts index 21a089ba1443..b1c2fddc9e9b 100644 --- a/apps/meteor/client/views/hooks/useActionSpread.ts +++ b/apps/meteor/client/views/hooks/useActionSpread.ts @@ -1,14 +1,14 @@ -import { useMemo } from 'react'; +import { useMemo, ReactNode } from 'react'; -type Action = { - label: string; - icon: string; - action: () => any; +export type Action = { + label: ReactNode; + icon?: string; + action: () => void; }; type MenuOption = { - label: { label: string; icon: string }; - action: Function; + label: { label: ReactNode; icon?: string }; + action: () => void; }; const mapOptions = ([key, { action, label, icon }]: [string, Action]): [string, MenuOption] => [ diff --git a/apps/meteor/client/views/omnichannel/additionalForms.tsx b/apps/meteor/client/views/omnichannel/additionalForms.tsx index a8fab3c41a2c..800458471eba 100644 --- a/apps/meteor/client/views/omnichannel/additionalForms.tsx +++ b/apps/meteor/client/views/omnichannel/additionalForms.tsx @@ -1,28 +1,29 @@ -/* eslint-disable @typescript-eslint/no-empty-interface */ import { ReactElement } from 'react'; -import { Unsubscribe, useSubscription, Subscription } from 'use-subscription'; +import { useSyncExternalStore } from 'use-sync-external-store/shim'; -// eslint-disable-next-line @typescript-eslint/interface-name-prefix +/* eslint-disable @typescript-eslint/no-empty-interface */ +/* eslint-disable @typescript-eslint/interface-name-prefix */ export interface EEFormHooks {} const createFormSubscription = (): { registerForm: (form: EEFormHooks) => void; unregisterForm: (form: keyof EEFormHooks) => void; - formsSubscription: Subscription; + formsSubscription: readonly [subscribe: (onStoreChange: () => void) => () => void, getSnapshot: () => EEFormHooks]; getForm: (form: keyof EEFormHooks) => () => ReactElement; } => { let forms = {} as EEFormHooks; let updateCb = (): void => undefined; - const formsSubscription: Subscription = { - subscribe: (cb: () => void): Unsubscribe => { + const formsSubscription = [ + (cb: () => void): (() => void) => { updateCb = cb; return (): void => { updateCb = (): void => undefined; }; }, - getCurrentValue: (): EEFormHooks => forms, - }; + (): EEFormHooks => forms, + ] as const; + const registerForm = (newForm: EEFormHooks): void => { forms = { ...forms, ...newForm }; updateCb(); @@ -37,6 +38,8 @@ const createFormSubscription = (): { return { registerForm, unregisterForm, formsSubscription, getForm }; }; -export const { registerForm, unregisterForm, formsSubscription, getForm } = createFormSubscription(); +const { registerForm, unregisterForm, formsSubscription, getForm } = createFormSubscription(); + +export { registerForm, unregisterForm, getForm }; -export const useFormsSubscription = (): EEFormHooks => useSubscription(formsSubscription); +export const useFormsSubscription = (): EEFormHooks => useSyncExternalStore(...formsSubscription); diff --git a/apps/meteor/client/views/omnichannel/agents/AgentEdit.tsx b/apps/meteor/client/views/omnichannel/agents/AgentEdit.tsx index b9ed52e54b9b..cb3721baec75 100644 --- a/apps/meteor/client/views/omnichannel/agents/AgentEdit.tsx +++ b/apps/meteor/client/views/omnichannel/agents/AgentEdit.tsx @@ -3,13 +3,12 @@ import { Field, TextInput, Button, Margins, Box, MultiSelect, Icon, Select } fro import { useMutableCallback } from '@rocket.chat/fuselage-hooks'; import { useToastMessageDispatch, useRoute, useSetting, useMethod, useTranslation } from '@rocket.chat/ui-contexts'; import React, { useMemo, useRef, useState, FC, ReactElement } from 'react'; -import { useSubscription } from 'use-subscription'; import { getUserEmailAddress } from '../../../../lib/getUserEmailAddress'; import VerticalBar from '../../../components/VerticalBar'; import { useForm } from '../../../hooks/useForm'; import UserInfo from '../../room/contextualBar/UserInfo'; -import { formsSubscription } from '../additionalForms'; +import { useFormsSubscription } from '../additionalForms'; // TODO: TYPE: // Department @@ -46,7 +45,7 @@ const AgentEdit: FC = ({ data, userDepartments, availableDepartm () => (userDepartments.departments ? userDepartments.departments.map(({ departmentId }) => departmentId) : []), [userDepartments], ); - const eeForms = useSubscription(formsSubscription); + const eeForms = useFormsSubscription(); const saveRef = useRef({ values: {}, diff --git a/apps/meteor/client/views/omnichannel/analytics/InterchangeableChart.js b/apps/meteor/client/views/omnichannel/analytics/InterchangeableChart.js index 59c2cc3fadd3..524fe89fdb5c 100644 --- a/apps/meteor/client/views/omnichannel/analytics/InterchangeableChart.js +++ b/apps/meteor/client/views/omnichannel/analytics/InterchangeableChart.js @@ -15,11 +15,13 @@ const getChartTooltips = (chartName) => { case 'Avg_reaction_time': return { callbacks: { - title(tooltipItem, data) { - return data.labels[tooltipItem[0].index]; + title([ctx]) { + const { dataset } = ctx; + return dataset.label; }, - label(tooltipItem, data) { - return secondsToHHMMSS(data.datasets[0].data[tooltipItem.index]); + label(ctx) { + const { dataset, dataIndex } = ctx; + return secondsToHHMMSS(dataset.data[dataIndex]); }, }, }; @@ -53,7 +55,7 @@ const InterchangeableChart = ({ departmentId, dateRange, chartName, ...props }) tooltipCallbacks, }); } catch (error) { - dispatchToastMessage({ type: 'error', message: error }); + dispatchToastMessage({ type: 'error', message: error.message }); } }); diff --git a/apps/meteor/client/views/omnichannel/businessHours/BusinessHoursFormContainer.js b/apps/meteor/client/views/omnichannel/businessHours/BusinessHoursFormContainer.js index 7eae87457fbc..c274ebee272d 100644 --- a/apps/meteor/client/views/omnichannel/businessHours/BusinessHoursFormContainer.js +++ b/apps/meteor/client/views/omnichannel/businessHours/BusinessHoursFormContainer.js @@ -1,12 +1,11 @@ import { FieldGroup, Box } from '@rocket.chat/fuselage'; import { useMutableCallback } from '@rocket.chat/fuselage-hooks'; import React, { useEffect, useState } from 'react'; -import { useSubscription } from 'use-subscription'; import { businessHourManager } from '../../../../app/livechat/client/views/app/business-hours/BusinessHours'; import { useForm } from '../../../hooks/useForm'; import { useReactiveValue } from '../../../hooks/useReactiveValue'; -import { formsSubscription } from '../additionalForms'; +import { useFormsSubscription } from '../additionalForms'; import BusinessHourForm from './BusinessHoursForm'; const useChangeHandler = (name, ref) => @@ -25,7 +24,7 @@ const getInitalData = ({ workHours }) => ({ const cleanFunc = () => {}; const BusinessHoursFormContainer = ({ data, saveRef, onChange = () => {} }) => { - const forms = useSubscription(formsSubscription); + const forms = useFormsSubscription(); const [hasChangesMultiple, setHasChangesMultiple] = useState(false); const [hasChangesTimeZone, setHasChangesTimeZone] = useState(false); diff --git a/apps/meteor/client/views/omnichannel/currentChats/FilterByText.tsx b/apps/meteor/client/views/omnichannel/currentChats/FilterByText.tsx index d7b92cb3463d..d5432f10eb23 100644 --- a/apps/meteor/client/views/omnichannel/currentChats/FilterByText.tsx +++ b/apps/meteor/client/views/omnichannel/currentChats/FilterByText.tsx @@ -3,13 +3,12 @@ import { useMutableCallback, useLocalStorage } from '@rocket.chat/fuselage-hooks import { useSetModal, useToastMessageDispatch, useMethod, useTranslation } from '@rocket.chat/ui-contexts'; import moment from 'moment'; import React, { Dispatch, FC, SetStateAction, useEffect, useMemo } from 'react'; -import { useSubscription } from 'use-subscription'; import AutoCompleteAgent from '../../../components/AutoCompleteAgent'; import AutoCompleteDepartment from '../../../components/AutoCompleteDepartment'; import GenericModal from '../../../components/GenericModal'; import { useEndpointData } from '../../../hooks/useEndpointData'; -import { formsSubscription } from '../additionalForms'; +import { useFormsSubscription } from '../additionalForms'; import Label from './Label'; import RemoveAllClosed from './RemoveAllClosed'; @@ -64,7 +63,7 @@ const FilterByText: FilterByTextType = ({ setFilter, reload, ...props }) => { setCustomFields([]); }); - const forms = useSubscription(formsSubscription); + const forms = useFormsSubscription() as any; // TODO: Refactor the formsSubscription to use components instead of hooks (since the only thing the hook does is return a component) // Conditional hook was required since the whole formSubscription uses hooks in an incorrect manner diff --git a/apps/meteor/client/views/omnichannel/customFields/EditCustomFieldsPage.js b/apps/meteor/client/views/omnichannel/customFields/EditCustomFieldsPage.js index 852d31215bf0..89fdf1cff012 100644 --- a/apps/meteor/client/views/omnichannel/customFields/EditCustomFieldsPage.js +++ b/apps/meteor/client/views/omnichannel/customFields/EditCustomFieldsPage.js @@ -2,11 +2,10 @@ import { Box, Button, Icon, ButtonGroup, FieldGroup } from '@rocket.chat/fuselag import { useMutableCallback } from '@rocket.chat/fuselage-hooks'; import { useToastMessageDispatch, useRoute, useMethod, useTranslation } from '@rocket.chat/ui-contexts'; import React, { useCallback, useState } from 'react'; -import { useSubscription } from 'use-subscription'; import Page from '../../../components/Page'; import { useForm } from '../../../hooks/useForm'; -import { formsSubscription } from '../additionalForms'; +import { useFormsSubscription } from '../additionalForms'; import CustomFieldsForm from './CustomFieldsForm'; const getInitialValues = (cf) => ({ @@ -24,7 +23,7 @@ const EditCustomFieldsPage = ({ customField, id, reload }) => { const [additionalValues, setAdditionalValues] = useState({}); - const { useCustomFieldsAdditionalForm = () => {} } = useSubscription(formsSubscription); + const { useCustomFieldsAdditionalForm = () => {} } = useFormsSubscription(); const AdditionalForm = useCustomFieldsAdditionalForm(); const router = useRoute('omnichannel-customfields'); diff --git a/apps/meteor/client/views/omnichannel/customFields/NewCustomFieldsPage.js b/apps/meteor/client/views/omnichannel/customFields/NewCustomFieldsPage.js index 65b02030cfec..b4d4dcd4a447 100644 --- a/apps/meteor/client/views/omnichannel/customFields/NewCustomFieldsPage.js +++ b/apps/meteor/client/views/omnichannel/customFields/NewCustomFieldsPage.js @@ -2,11 +2,10 @@ import { Box, Button, Icon, FieldGroup, ButtonGroup } from '@rocket.chat/fuselag import { useMutableCallback } from '@rocket.chat/fuselage-hooks'; import { useToastMessageDispatch, useRoute, useMethod, useTranslation } from '@rocket.chat/ui-contexts'; import React, { useCallback, useState } from 'react'; -import { useSubscription } from 'use-subscription'; import Page from '../../../components/Page'; import { useForm } from '../../../hooks/useForm'; -import { formsSubscription } from '../additionalForms'; +import { useFormsSubscription } from '../additionalForms'; import CustomFieldsForm from './CustomFieldsForm'; const initialValues = { @@ -23,7 +22,7 @@ const NewCustomFieldsPage = ({ reload }) => { const [additionalValues, setAdditionalValues] = useState({}); - const { useCustomFieldsAdditionalForm = () => {} } = useSubscription(formsSubscription); + const { useCustomFieldsAdditionalForm = () => {} } = useFormsSubscription(); const AdditionalForm = useCustomFieldsAdditionalForm(); const router = useRoute('omnichannel-customfields'); diff --git a/apps/meteor/client/views/omnichannel/departments/EditDepartment.js b/apps/meteor/client/views/omnichannel/departments/EditDepartment.js index ccde330932d0..25a62e664262 100644 --- a/apps/meteor/client/views/omnichannel/departments/EditDepartment.js +++ b/apps/meteor/client/views/omnichannel/departments/EditDepartment.js @@ -15,7 +15,6 @@ import { import { useMutableCallback, useUniqueId } from '@rocket.chat/fuselage-hooks'; import { useToastMessageDispatch, useRoute, useMethod, useEndpoint, useTranslation } from '@rocket.chat/ui-contexts'; import React, { useMemo, useState, useRef } from 'react'; -import { useSubscription } from 'use-subscription'; import { validateEmail } from '../../../../lib/emailValidator'; import Page from '../../../components/Page'; @@ -24,7 +23,7 @@ import { useRecordList } from '../../../hooks/lists/useRecordList'; import { useComponentDidUpdate } from '../../../hooks/useComponentDidUpdate'; import { useForm } from '../../../hooks/useForm'; import { AsyncStatePhase } from '../../../lib/asyncState'; -import { formsSubscription } from '../additionalForms'; +import { useFormsSubscription } from '../additionalForms'; import DepartmentsAgentsTable from './DepartmentsAgentsTable'; function withDefault(key, defaultValue) { @@ -42,7 +41,7 @@ function EditDepartment({ data, id, title, reload, allowedToForwardData }) { useDepartmentForwarding = () => {}, useDepartmentBusinessHours = () => {}, useSelectForwardDepartment = () => {}, - } = useSubscription(formsSubscription); + } = useFormsSubscription(); const initialAgents = useRef((data && data.agents) || []); diff --git a/apps/meteor/client/views/omnichannel/directory/chats/contextualBar/RoomEdit.js b/apps/meteor/client/views/omnichannel/directory/chats/contextualBar/RoomEdit.js index 0f4a5578c4b3..fdcdeeaabc6a 100644 --- a/apps/meteor/client/views/omnichannel/directory/chats/contextualBar/RoomEdit.js +++ b/apps/meteor/client/views/omnichannel/directory/chats/contextualBar/RoomEdit.js @@ -2,7 +2,6 @@ import { Field, TextInput, ButtonGroup, Button } from '@rocket.chat/fuselage'; import { useMutableCallback } from '@rocket.chat/fuselage-hooks'; import { useToastMessageDispatch, useMethod, useTranslation } from '@rocket.chat/ui-contexts'; import React, { useState, useMemo } from 'react'; -import { useSubscription } from 'use-subscription'; import { hasAtLeastOnePermission } from '../../../../../../app/authorization/client'; import CustomFieldsForm from '../../../../../components/CustomFieldsForm'; @@ -11,7 +10,7 @@ import VerticalBar from '../../../../../components/VerticalBar'; import { AsyncStatePhase } from '../../../../../hooks/useAsyncState'; import { useEndpointData } from '../../../../../hooks/useEndpointData'; import { useForm } from '../../../../../hooks/useForm'; -import { formsSubscription } from '../../../additionalForms'; +import { useFormsSubscription } from '../../../additionalForms'; import { FormSkeleton } from '../../Skeleton'; const initialValuesRoom = { @@ -45,7 +44,7 @@ function RoomEdit({ room, visitor, reload, reloadInfo, close }) { const { handleTopic, handleTags, handlePriorityId } = handlersRoom; const { topic, tags, priorityId } = valuesRoom; - const forms = useSubscription(formsSubscription); + const forms = useFormsSubscription(); const { usePrioritiesSelect = () => {} } = forms; diff --git a/apps/meteor/client/views/omnichannel/directory/contacts/contextualBar/ContactNewEdit.js b/apps/meteor/client/views/omnichannel/directory/contacts/contextualBar/ContactNewEdit.js index 6380f16ab565..fda1dc2e8dcc 100644 --- a/apps/meteor/client/views/omnichannel/directory/contacts/contextualBar/ContactNewEdit.js +++ b/apps/meteor/client/views/omnichannel/directory/contacts/contextualBar/ContactNewEdit.js @@ -2,7 +2,6 @@ import { Field, TextInput, ButtonGroup, Button } from '@rocket.chat/fuselage'; import { useMutableCallback } from '@rocket.chat/fuselage-hooks'; import { useToastMessageDispatch, useEndpoint, useTranslation } from '@rocket.chat/ui-contexts'; import React, { useState, useMemo } from 'react'; -import { useSubscription } from 'use-subscription'; import { hasAtLeastOnePermission } from '../../../../../../app/authorization/client'; import { validateEmail } from '../../../../../../lib/emailValidator'; @@ -13,7 +12,7 @@ import { useComponentDidUpdate } from '../../../../../hooks/useComponentDidUpdat import { useEndpointData } from '../../../../../hooks/useEndpointData'; import { useForm } from '../../../../../hooks/useForm'; import { createToken } from '../../../../../lib/utils/createToken'; -import { formsSubscription } from '../../../additionalForms'; +import { useFormsSubscription } from '../../../additionalForms'; import { FormSkeleton } from '../../Skeleton'; const initialValues = { @@ -50,7 +49,7 @@ function ContactNewEdit({ id, data, close }) { const { values, handlers, hasUnsavedChanges: hasUnsavedChangesContact } = useForm(getInitialValues(data)); - const eeForms = useSubscription(formsSubscription); + const eeForms = useFormsSubscription(); const { useContactManager = () => {} } = eeForms; diff --git a/apps/meteor/client/views/omnichannel/realTimeMonitoring/charts/AgentStatusChart.js b/apps/meteor/client/views/omnichannel/realTimeMonitoring/charts/AgentStatusChart.js index 71c2edee9c54..c1cf1b438f8b 100644 --- a/apps/meteor/client/views/omnichannel/realTimeMonitoring/charts/AgentStatusChart.js +++ b/apps/meteor/client/views/omnichannel/realTimeMonitoring/charts/AgentStatusChart.js @@ -52,7 +52,7 @@ const AgentStatusChart = ({ params, reloadRef, ...props }) => { }, [t]); useEffect(() => { - if (state === AsyncStatePhase.RESOLVED) { + if (state === AsyncStatePhase.RESOLVED && context.current) { updateChartData(t('Offline'), [offline]); updateChartData(t('Available'), [available]); updateChartData(t('Away'), [away]); diff --git a/apps/meteor/client/views/omnichannel/realTimeMonitoring/charts/ChatDurationChart.js b/apps/meteor/client/views/omnichannel/realTimeMonitoring/charts/ChatDurationChart.js index f1dc8f1072d9..f049480b789a 100644 --- a/apps/meteor/client/views/omnichannel/realTimeMonitoring/charts/ChatDurationChart.js +++ b/apps/meteor/client/views/omnichannel/realTimeMonitoring/charts/ChatDurationChart.js @@ -13,13 +13,13 @@ import { useUpdateChartData } from './useUpdateChartData'; const [labels, initialData] = getMomentChartLabelsAndData(); const tooltipCallbacks = { callbacks: { - title(tooltipItem, data) { - return data.labels[tooltipItem[0].index]; + title([ctx]) { + const { dataset } = ctx; + return dataset.label; }, - label(tooltipItem, data) { - const { datasetIndex, index } = tooltipItem; - const { data: datasetData, label } = data.datasets[datasetIndex]; - return `${label}: ${secondsToHHMMSS(datasetData[index])}`; + label(ctx) { + const { dataset, dataIndex } = ctx; + return `${dataset.label}: ${secondsToHHMMSS(dataset.data[dataIndex])}`; }, }, }; diff --git a/apps/meteor/client/views/omnichannel/realTimeMonitoring/charts/ChatsPerAgentChart.js b/apps/meteor/client/views/omnichannel/realTimeMonitoring/charts/ChatsPerAgentChart.js index 4a2bbccab60e..a2e6837e9486 100644 --- a/apps/meteor/client/views/omnichannel/realTimeMonitoring/charts/ChatsPerAgentChart.js +++ b/apps/meteor/client/views/omnichannel/realTimeMonitoring/charts/ChatsPerAgentChart.js @@ -9,6 +9,7 @@ import { useUpdateChartData } from './useUpdateChartData'; const initialData = { agents: {}, + success: true, }; const init = (canvas, context, t) => diff --git a/apps/meteor/client/views/omnichannel/realTimeMonitoring/charts/ResponseTimesChart.js b/apps/meteor/client/views/omnichannel/realTimeMonitoring/charts/ResponseTimesChart.js index 04a2cf1af0b7..c94b59ee077e 100644 --- a/apps/meteor/client/views/omnichannel/realTimeMonitoring/charts/ResponseTimesChart.js +++ b/apps/meteor/client/views/omnichannel/realTimeMonitoring/charts/ResponseTimesChart.js @@ -13,13 +13,13 @@ import { useUpdateChartData } from './useUpdateChartData'; const [labels, initialData] = getMomentChartLabelsAndData(); const tooltipCallbacks = { callbacks: { - title(tooltipItem, data) { - return data.labels[tooltipItem[0].index]; + title([ctx]) { + const { dataset } = ctx; + return dataset.label; }, - label(tooltipItem, data) { - const { datasetIndex, index } = tooltipItem; - const { data: datasetData, label } = data.datasets[datasetIndex]; - return `${label}: ${secondsToHHMMSS(datasetData[index])}`; + label(ctx) { + const { dataset, dataIndex } = ctx; + return `${dataset.label}: ${secondsToHHMMSS(dataset.data[dataIndex])}`; }, }, }; diff --git a/apps/meteor/client/views/omnichannel/sidebar/OmnichannelSidebar.tsx b/apps/meteor/client/views/omnichannel/sidebar/OmnichannelSidebar.tsx index ea646b6b7452..c7def16dd3be 100644 --- a/apps/meteor/client/views/omnichannel/sidebar/OmnichannelSidebar.tsx +++ b/apps/meteor/client/views/omnichannel/sidebar/OmnichannelSidebar.tsx @@ -1,16 +1,16 @@ import { useRoutePath, useCurrentRoute, useTranslation } from '@rocket.chat/ui-contexts'; import React, { useCallback, useEffect, FC, memo } from 'react'; -import { useSubscription } from 'use-subscription'; +import { useSyncExternalStore } from 'use-sync-external-store/shim'; import { menu, SideNav } from '../../../../app/ui-utils/client'; import Sidebar from '../../../components/Sidebar'; import SidebarItemsAssemblerProps from '../../../components/Sidebar/SidebarItemsAssembler'; import { isLayoutEmbedded } from '../../../lib/utils/isLayoutEmbedded'; import SettingsProvider from '../../../providers/SettingsProvider'; -import { itemsSubscription } from '../sidebarItems'; +import { getOmnichannelSidebarItems, subscribeToOmnichannelSidebarItems } from '../sidebarItems'; const OmnichannelSidebar: FC = () => { - const items = useSubscription(itemsSubscription); + const items = useSyncExternalStore(subscribeToOmnichannelSidebarItems, getOmnichannelSidebarItems); const t = useTranslation(); const closeOmnichannelFlex = useCallback(() => { diff --git a/apps/meteor/client/views/omnichannel/sidebarItems.ts b/apps/meteor/client/views/omnichannel/sidebarItems.ts index 34e815a3effb..2cda8c2c9e33 100644 --- a/apps/meteor/client/views/omnichannel/sidebarItems.ts +++ b/apps/meteor/client/views/omnichannel/sidebarItems.ts @@ -4,7 +4,8 @@ import { createSidebarItems } from '../../lib/createSidebarItems'; export const { registerSidebarItem: registerOmnichannelSidebarItem, unregisterSidebarItem, - itemsSubscription, + getSidebarItems: getOmnichannelSidebarItems, + subscribeToSidebarItems: subscribeToOmnichannelSidebarItems, } = createSidebarItems([ { href: 'omnichannel/current', diff --git a/apps/meteor/client/views/room/MessageList/contexts/SelectedMessagesContext.tsx b/apps/meteor/client/views/room/MessageList/contexts/SelectedMessagesContext.tsx index c96c6a92075d..f9fda919d88c 100644 --- a/apps/meteor/client/views/room/MessageList/contexts/SelectedMessagesContext.tsx +++ b/apps/meteor/client/views/room/MessageList/contexts/SelectedMessagesContext.tsx @@ -1,6 +1,5 @@ -import { OffCallbackHandler } from '@rocket.chat/emitter'; -import { createContext, useCallback, useContext, useMemo } from 'react'; -import { useSubscription } from 'use-subscription'; +import { createContext, useCallback, useContext } from 'react'; +import { useSyncExternalStore } from 'use-sync-external-store/shim'; import { selectedMessageStore } from '../../providers/SelectedMessagesProvider'; @@ -14,28 +13,28 @@ export const SelectedMessageContext = createContext({ export const useIsSelectedMessage = (mid: string): boolean => { const { selectedMessageStore } = useContext(SelectedMessageContext); - const subscription = useMemo( - () => ({ - getCurrentValue: (): boolean => selectedMessageStore.isSelected(mid), - subscribe: (callback: () => void): OffCallbackHandler => selectedMessageStore.on(mid, callback), - }), - [mid, selectedMessageStore], + + const subscribe = useCallback( + (callback: () => void): (() => void) => selectedMessageStore.on(mid, callback), + [selectedMessageStore, mid], ); - return useSubscription(subscription); + + const getSnapshot = (): boolean => selectedMessageStore.isSelected(mid); + + return useSyncExternalStore(subscribe, getSnapshot); }; export const useIsSelecting = (): boolean => { const { selectedMessageStore } = useContext(SelectedMessageContext); - return useSubscription( - useMemo( - () => ({ - getCurrentValue: (): boolean => selectedMessageStore.getIsSelecting(), - subscribe: (callback: () => void): OffCallbackHandler => selectedMessageStore.on('toggleIsSelecting', callback), - }), - [selectedMessageStore], - ), + const subscribe = useCallback( + (callback: () => void): (() => void) => selectedMessageStore.on('toggleIsSelecting', callback), + [selectedMessageStore], ); + + const getSnapshot = (): boolean => selectedMessageStore.getIsSelecting(); + + return useSyncExternalStore(subscribe, getSnapshot); }; export const useToggleSelect = (mid: string): (() => void) => { @@ -48,13 +47,12 @@ export const useToggleSelect = (mid: string): (() => void) => { export const useCountSelected = (): number => { const { selectedMessageStore } = useContext(SelectedMessageContext); - return useSubscription( - useMemo( - () => ({ - getCurrentValue: (): number => selectedMessageStore.count(), - subscribe: (callback: () => void): OffCallbackHandler => selectedMessageStore.on('change', callback), - }), - [selectedMessageStore], - ), + const subscribe = useCallback( + (callback: () => void): (() => void) => selectedMessageStore.on('change', callback), + [selectedMessageStore], ); + + const getSnapshot = (): number => selectedMessageStore.count(); + + return useSyncExternalStore(subscribe, getSnapshot); }; diff --git a/apps/meteor/client/views/room/MessageList/providers/MessageHighlightProvider.tsx b/apps/meteor/client/views/room/MessageList/providers/MessageHighlightProvider.tsx index 3d23ca9972ec..a9aec5439b62 100644 --- a/apps/meteor/client/views/room/MessageList/providers/MessageHighlightProvider.tsx +++ b/apps/meteor/client/views/room/MessageList/providers/MessageHighlightProvider.tsx @@ -1,11 +1,11 @@ import React, { ReactElement, ContextType, useMemo, ReactNode } from 'react'; -import { useSubscription } from 'use-subscription'; +import { useSyncExternalStore } from 'use-sync-external-store/shim'; import MessageHighlightContext from '../contexts/MessageHighlightContext'; -import { messageHighlightSubscription } from './messageHighlightSubscription'; +import * as messageHighlightSubscription from './messageHighlightSubscription'; const MessageHighlightProvider = ({ children }: { children: ReactNode }): ReactElement => { - const highlightMessageId = useSubscription(messageHighlightSubscription); + const highlightMessageId = useSyncExternalStore(messageHighlightSubscription.subscribe, messageHighlightSubscription.getSnapshot); const contextValue = useMemo>( () => ({ diff --git a/apps/meteor/client/views/room/MessageList/providers/messageHighlightSubscription.ts b/apps/meteor/client/views/room/MessageList/providers/messageHighlightSubscription.ts index 31fcb3bab269..dff96e0b0105 100644 --- a/apps/meteor/client/views/room/MessageList/providers/messageHighlightSubscription.ts +++ b/apps/meteor/client/views/room/MessageList/providers/messageHighlightSubscription.ts @@ -1,31 +1,29 @@ import { IMessage } from '@rocket.chat/core-typings'; -import { Subscription, Unsubscribe } from 'use-subscription'; type SetHighlightFn = (_id: IMessage['_id']) => void; type ClearHighlightFn = (_id: IMessage['_id']) => void; type MessageHighlightSubscription = { - subscription: Subscription; + subscribe: (callback: () => void) => () => void; + getSnapshot: () => IMessage['_id'] | undefined; setHighlight: SetHighlightFn; clearHighlight: ClearHighlightFn; }; const createMessageHighlightSubscription = (): MessageHighlightSubscription => { - let updateCb: Unsubscribe = () => undefined; + let updateCb: () => void = () => undefined; let highlightMessageId: IMessage['_id'] | undefined; - const subscription: Subscription = { - subscribe: (cb) => { - updateCb = cb; - return (): void => { - updateCb = (): void => undefined; - }; - }, - - getCurrentValue: (): typeof highlightMessageId => highlightMessageId, + const subscribe = (cb: () => void): (() => void) => { + updateCb = cb; + return (): void => { + updateCb = (): void => undefined; + }; }; + const getSnapshot = (): typeof highlightMessageId => highlightMessageId; + const setHighlight = (_id: IMessage['_id']): void => { highlightMessageId = _id; updateCb(); @@ -36,11 +34,12 @@ const createMessageHighlightSubscription = (): MessageHighlightSubscription => { updateCb(); }; - return { subscription, setHighlight, clearHighlight }; + return { subscribe, getSnapshot, setHighlight, clearHighlight }; }; export const { - subscription: messageHighlightSubscription, + getSnapshot, + subscribe, setHighlight: setHighlightMessage, clearHighlight: clearHighlightMessage, } = createMessageHighlightSubscription(); diff --git a/apps/meteor/client/views/room/contextualBar/RoomMembers/AddUsers/AddUsers.stories.tsx b/apps/meteor/client/views/room/contextualBar/RoomMembers/AddUsers/AddUsers.stories.tsx index 0ee902a32686..e45f0f55158d 100644 --- a/apps/meteor/client/views/room/contextualBar/RoomMembers/AddUsers/AddUsers.stories.tsx +++ b/apps/meteor/client/views/room/contextualBar/RoomMembers/AddUsers/AddUsers.stories.tsx @@ -17,5 +17,5 @@ export default { export const Default: ComponentStory = (args) => ; Default.storyName = 'AddUsers'; Default.args = { - value: 'rocket.cat', + users: ['rocket.cat'], }; diff --git a/apps/meteor/client/views/room/contextualBar/RoomMembers/AddUsers/AddUsers.js b/apps/meteor/client/views/room/contextualBar/RoomMembers/AddUsers/AddUsers.tsx similarity index 61% rename from apps/meteor/client/views/room/contextualBar/RoomMembers/AddUsers/AddUsers.js rename to apps/meteor/client/views/room/contextualBar/RoomMembers/AddUsers/AddUsers.tsx index a6f3db83a52e..ae39742aac61 100644 --- a/apps/meteor/client/views/room/contextualBar/RoomMembers/AddUsers/AddUsers.js +++ b/apps/meteor/client/views/room/contextualBar/RoomMembers/AddUsers/AddUsers.tsx @@ -1,11 +1,20 @@ +import { IUser } from '@rocket.chat/core-typings'; import { Field, Button } from '@rocket.chat/fuselage'; import { useTranslation } from '@rocket.chat/ui-contexts'; -import React from 'react'; +import React, { ReactElement } from 'react'; import UserAutoCompleteMultiple from '../../../../../components/UserAutoCompleteMultiple'; import VerticalBar from '../../../../../components/VerticalBar'; -const AddUsers = ({ onClickClose, onClickBack, onClickSave, value, onChange }) => { +type AddUsersProps = { + onClickClose?: () => void; + onClickBack?: () => void; + onClickSave: () => Promise; + users: IUser['username'][]; + onChange: (value: IUser['username'][], action?: string) => void; +}; + +const AddUsers = ({ onClickClose, onClickBack, onClickSave, users, onChange }: AddUsersProps): ReactElement => { const t = useTranslation(); return ( @@ -18,11 +27,11 @@ const AddUsers = ({ onClickClose, onClickBack, onClickSave, value, onChange }) = {t('Choose_users')} - + - diff --git a/apps/meteor/client/views/room/contextualBar/RoomMembers/AddUsers/AddUsersWithData.js b/apps/meteor/client/views/room/contextualBar/RoomMembers/AddUsers/AddUsersWithData.tsx similarity index 67% rename from apps/meteor/client/views/room/contextualBar/RoomMembers/AddUsers/AddUsersWithData.js rename to apps/meteor/client/views/room/contextualBar/RoomMembers/AddUsers/AddUsersWithData.tsx index 90bc58d23dfd..e088874417c5 100644 --- a/apps/meteor/client/views/room/contextualBar/RoomMembers/AddUsers/AddUsersWithData.js +++ b/apps/meteor/client/views/room/contextualBar/RoomMembers/AddUsers/AddUsersWithData.tsx @@ -1,20 +1,31 @@ +import { IRoom, IUser } from '@rocket.chat/core-typings'; import { useMutableCallback } from '@rocket.chat/fuselage-hooks'; import { useToastMessageDispatch, useMethod, useTranslation } from '@rocket.chat/ui-contexts'; -import React from 'react'; +import React, { ReactElement } from 'react'; import { useForm } from '../../../../../hooks/useForm'; import { useTabBarClose } from '../../../providers/ToolboxProvider'; import AddUsers from './AddUsers'; -const AddUsersWithData = ({ rid, onClickBack, reload }) => { +type AddUsersWithDataProps = { + rid: IRoom['_id']; + onClickBack: () => void; + reload: () => void; +}; + +type AddUsersInitialProps = { + users: IUser['username'][]; +}; + +const AddUsersWithData = ({ rid, onClickBack, reload }: AddUsersWithDataProps): ReactElement => { const t = useTranslation(); const dispatchToastMessage = useToastMessageDispatch(); const onClickClose = useTabBarClose(); const saveAction = useMethod('addUsersToRoom'); - const { values, handlers } = useForm({ users: [] }); - const { users } = values; + const { values, handlers } = useForm({ users: [] as IUser['username'][] }); + const { users } = values as AddUsersInitialProps; const { handleUsers } = handlers; const onChangeUsers = useMutableCallback((value, action) => { @@ -38,7 +49,7 @@ const AddUsersWithData = ({ rid, onClickBack, reload }) => { } }); - return ; + return ; }; export default AddUsersWithData; diff --git a/apps/meteor/client/views/room/hooks/useUserHasRoomRole.ts b/apps/meteor/client/views/room/hooks/useUserHasRoomRole.ts new file mode 100644 index 000000000000..cc9e93bdf318 --- /dev/null +++ b/apps/meteor/client/views/room/hooks/useUserHasRoomRole.ts @@ -0,0 +1,8 @@ +import { IRole, IRoom, IUser } from '@rocket.chat/core-typings'; +import { useCallback } from 'react'; + +import { RoomRoles } from '../../../../app/models/client'; +import { useReactiveValue } from '../../../hooks/useReactiveValue'; + +export const useUserHasRoomRole = (uid: IUser['_id'], rid: IRoom['_id'], role: IRole['name']): boolean => + useReactiveValue(useCallback(() => !!RoomRoles.findOne({ rid, 'u._id': uid, 'roles': role }), [uid, rid, role])); diff --git a/apps/meteor/client/views/room/hooks/useUserInfoActions.js b/apps/meteor/client/views/room/hooks/useUserInfoActions.js deleted file mode 100644 index fc6fdabd4a1f..000000000000 --- a/apps/meteor/client/views/room/hooks/useUserInfoActions.js +++ /dev/null @@ -1,415 +0,0 @@ -import { Button, ButtonGroup, Icon, Modal, Box } from '@rocket.chat/fuselage'; -import { useAutoFocus, useMutableCallback } from '@rocket.chat/fuselage-hooks'; -import { escapeHTML } from '@rocket.chat/string-helpers'; -import { - useSetModal, - useToastMessageDispatch, - useRoute, - useUserId, - useUserSubscription, - useUserRoom, - useUserSubscriptionByName, - usePermission, - useAllPermissions, - useMethod, - useTranslation, -} from '@rocket.chat/ui-contexts'; -import React, { useCallback, useMemo } from 'react'; - -import { RoomRoles } from '../../../../app/models/client'; -import { RoomMemberActions } from '../../../../definition/IRoomTypeConfig'; -import { useEndpointActionExperimental } from '../../../hooks/useEndpointActionExperimental'; -import { useReactiveValue } from '../../../hooks/useReactiveValue'; -import { roomCoordinator } from '../../../lib/rooms/roomCoordinator'; -import RemoveUsersModal from '../../teams/contextualBar/members/RemoveUsersModal'; -import { useWebRTC } from './useWebRTC'; - -const useUserHasRoomRole = (uid, rid, role) => - useReactiveValue(useCallback(() => !!RoomRoles.findOne({ rid, 'u._id': uid, 'roles': role }), [uid, rid, role])); - -const getShouldOpenDirectMessage = (currentSubscription, usernameSubscription, canOpenDirectMessage, username) => { - const canOpenDm = canOpenDirectMessage || usernameSubscription; - const directMessageIsNotAlreadyOpen = currentSubscription && currentSubscription.name !== username; - return canOpenDm && directMessageIsNotAlreadyOpen; -}; - -const getUserIsMuted = (room, user, userCanPostReadonly) => { - if (room && room.ro) { - if (Array.isArray(room.unmuted) && room.unmuted.indexOf(user && user.username) !== -1) { - return false; - } - - if (userCanPostReadonly) { - return Array.isArray(room.muted) && room.muted.indexOf(user && user.username) !== -1; - } - - return true; - } - - return room && Array.isArray(room.muted) && room.muted.indexOf(user && user.username) > -1; -}; - -const WarningModal = ({ text, confirmText, close, confirm, ...props }) => { - const refAutoFocus = useAutoFocus(true); - const t = useTranslation(); - return ( - - - - {t('Are_you_sure')} - - - {text} - - - - - - - - ); -}; -// TODO: Remove endpoint concatenation -export const useUserInfoActions = (user = {}, rid, reload) => { - const t = useTranslation(); - const dispatchToastMessage = useToastMessageDispatch(); - const directRoute = useRoute('direct'); - - const setModal = useSetModal(); - - const { _id: uid } = user; - const ownUserId = useUserId(); - - const closeModal = useMutableCallback(() => setModal(null)); - - const room = useUserRoom(rid); - const currentSubscription = useUserSubscription(rid); - const usernameSubscription = useUserSubscriptionByName(user.username); - - const isLeader = useUserHasRoomRole(uid, rid, 'leader'); - const isModerator = useUserHasRoomRole(uid, rid, 'moderator'); - const isOwner = useUserHasRoomRole(uid, rid, 'owner'); - - const otherUserCanPostReadonly = useAllPermissions('post-readonly', rid); - - const isIgnored = currentSubscription && currentSubscription.ignored && currentSubscription.ignored.indexOf(uid) > -1; - const isMuted = getUserIsMuted(room, user, otherUserCanPostReadonly); - - const endpointPrefix = room.t === 'p' ? '/v1/groups' : '/v1/channels'; - - const roomDirectives = room && room.t && roomCoordinator.getRoomDirectives(room.t); - - const [roomCanSetOwner, roomCanSetLeader, roomCanSetModerator, roomCanIgnore, roomCanBlock, roomCanMute, roomCanRemove] = [ - ...(roomDirectives && [ - roomDirectives.allowMemberAction(room, RoomMemberActions.SET_AS_OWNER), - roomDirectives.allowMemberAction(room, RoomMemberActions.SET_AS_LEADER), - roomDirectives.allowMemberAction(room, RoomMemberActions.SET_AS_MODERATOR), - roomDirectives.allowMemberAction(room, RoomMemberActions.IGNORE), - roomDirectives.allowMemberAction(room, RoomMemberActions.BLOCK), - roomDirectives.allowMemberAction(room, RoomMemberActions.MUTE), - roomDirectives.allowMemberAction(room, RoomMemberActions.REMOVE_USER), - ]), - ]; - - const roomName = room && room.t && escapeHTML(roomCoordinator.getRoomName(room.t, room)); - - const userCanSetOwner = usePermission('set-owner', rid); - const userCanSetLeader = usePermission('set-leader', rid); - const userCanSetModerator = usePermission('set-moderator', rid); - const userCanMute = usePermission('mute-user', rid); - const userCanRemove = usePermission('remove-user', rid); - const userCanDirectMessage = usePermission('create-d'); - const { shouldAllowCalls, callInProgress, joinCall, startCall } = useWebRTC(rid); - - const shouldOpenDirectMessage = getShouldOpenDirectMessage( - currentSubscription, - usernameSubscription, - userCanDirectMessage, - user.username, - ); - - const openDirectDm = useMutableCallback(() => - directRoute.push({ - rid: user.username, - }), - ); - - const openDirectMessageOption = useMemo( - () => - shouldOpenDirectMessage && { - label: t('Direct_Message'), - icon: 'balloon', - action: openDirectDm, - }, - [openDirectDm, shouldOpenDirectMessage, t], - ); - - const videoCallOption = useMemo(() => { - const handleJoinCall = () => { - joinCall({ audio: true, video: true }); - }; - const handleStartCall = () => { - startCall({ audio: true, video: true }); - }; - const action = callInProgress ? handleJoinCall : handleStartCall; - - return ( - shouldAllowCalls && { - label: t(callInProgress ? 'Join_video_call' : 'Start_video_call'), - icon: 'video', - action, - } - ); - }, [callInProgress, shouldAllowCalls, t, joinCall, startCall]); - - const audioCallOption = useMemo(() => { - const handleJoinCall = () => { - joinCall({ audio: true, video: false }); - }; - const handleStartCall = () => { - startCall({ audio: true, video: false }); - }; - const action = callInProgress ? handleJoinCall : handleStartCall; - - return ( - shouldAllowCalls && { - label: t(callInProgress ? 'Join_audio_call' : 'Start_audio_call'), - icon: 'mic', - action, - } - ); - }, [callInProgress, shouldAllowCalls, t, joinCall, startCall]); - - const changeOwnerEndpoint = isOwner ? 'removeOwner' : 'addOwner'; - const changeOwnerMessage = isOwner ? 'User__username__removed_from__room_name__owners' : 'User__username__is_now_a_owner_of__room_name_'; - const changeOwner = useEndpointActionExperimental( - 'POST', - `${endpointPrefix}.${changeOwnerEndpoint}`, - t(changeOwnerMessage, { username: user.username, room_name: roomName }), - ); - const changeOwnerAction = useMutableCallback(async () => changeOwner({ roomId: rid, userId: uid })); - const changeOwnerOption = useMemo( - () => - roomCanSetOwner && - userCanSetOwner && { - label: t(isOwner ? 'Remove_as_owner' : 'Set_as_owner'), - icon: 'shield-check', - action: changeOwnerAction, - }, - [changeOwnerAction, isOwner, t, roomCanSetOwner, userCanSetOwner], - ); - - const changeLeaderEndpoint = isLeader ? 'removeLeader' : 'addLeader'; - const changeLeaderMessage = isLeader - ? 'User__username__removed_from__room_name__leaders' - : 'User__username__is_now_a_leader_of__room_name_'; - const changeLeader = useEndpointActionExperimental( - 'POST', - `${endpointPrefix}.${changeLeaderEndpoint}`, - t(changeLeaderMessage, { username: user.username, room_name: roomName }), - ); - const changeLeaderAction = useMutableCallback(() => changeLeader({ roomId: rid, userId: uid })); - const changeLeaderOption = useMemo( - () => - roomCanSetLeader && - userCanSetLeader && { - label: t(isLeader ? 'Remove_as_leader' : 'Set_as_leader'), - icon: 'shield-alt', - action: changeLeaderAction, - }, - [isLeader, roomCanSetLeader, t, userCanSetLeader, changeLeaderAction], - ); - - const changeModeratorEndpoint = isModerator ? 'removeModerator' : 'addModerator'; - const changeModeratorMessage = isModerator - ? 'User__username__removed_from__room_name__moderators' - : 'User__username__is_now_a_moderator_of__room_name_'; - const changeModerator = useEndpointActionExperimental( - 'POST', - `${endpointPrefix}.${changeModeratorEndpoint}`, - t(changeModeratorMessage, { username: user.username, room_name: roomName }), - ); - const changeModeratorAction = useMutableCallback(() => changeModerator({ roomId: rid, userId: uid })); - const changeModeratorOption = useMemo( - () => - roomCanSetModerator && - userCanSetModerator && { - label: t(isModerator ? 'Remove_as_moderator' : 'Set_as_moderator'), - icon: 'shield', - action: changeModeratorAction, - }, - [changeModeratorAction, isModerator, roomCanSetModerator, t, userCanSetModerator], - ); - - const ignoreUser = useMethod('ignoreUser'); - const ignoreUserAction = useMutableCallback(async () => { - try { - await ignoreUser({ rid, userId: uid, ignore: !isIgnored }); - if (isIgnored) { - dispatchToastMessage({ type: 'success', message: t('User_has_been_unignored') }); - } else { - dispatchToastMessage({ type: 'success', message: t('User_has_been_ignored') }); - } - } catch (error) { - dispatchToastMessage({ type: 'error', message: error }); - } - }); - const ignoreUserOption = useMemo( - () => - roomCanIgnore && - uid !== ownUserId && { - label: t(isIgnored ? 'Unignore' : 'Ignore'), - icon: 'ban', - action: ignoreUserAction, - }, - [ignoreUserAction, isIgnored, ownUserId, roomCanIgnore, t, uid], - ); - - const isUserBlocked = currentSubscription && currentSubscription.blocker; - const toggleBlock = useMethod(isUserBlocked ? 'unblockUser' : 'blockUser'); - const toggleBlockUserAction = useMutableCallback(async () => { - try { - await toggleBlock({ rid, blocked: uid }); - dispatchToastMessage({ - type: 'success', - message: t(isUserBlocked ? 'User_is_unblocked' : 'User_is_blocked'), - }); - } catch (error) { - dispatchToastMessage({ type: 'error', message: error }); - } - }); - const toggleBlockUserOption = useMemo( - () => - roomCanBlock && - uid !== ownUserId && { - label: t(isUserBlocked ? 'Unblock' : 'Block'), - icon: 'ban', - action: toggleBlockUserAction, - }, - [isUserBlocked, ownUserId, roomCanBlock, t, toggleBlockUserAction, uid], - ); - - const muteFn = useMethod(isMuted ? 'unmuteUserInRoom' : 'muteUserInRoom'); - const muteUserOption = useMemo(() => { - const action = () => { - const onConfirm = async () => { - try { - await muteFn({ rid, username: user.username }); - closeModal(); - dispatchToastMessage({ - type: 'success', - message: t(isMuted ? 'User__username__unmuted_in_room__roomName__' : 'User__username__muted_in_room__roomName__', { - username: user.username, - roomName, - }), - }); - } catch (error) { - dispatchToastMessage({ type: 'error', message: error }); - } - }; - - if (isMuted) { - return onConfirm(); - } - - setModal( - , - ); - }; - - return ( - roomCanMute && - userCanMute && { - label: t(isMuted ? 'Unmute_user' : 'Mute_user'), - icon: isMuted ? 'mic' : 'mic-off', - action, - } - ); - }, [closeModal, dispatchToastMessage, isMuted, muteFn, rid, roomCanMute, roomName, setModal, t, user.username, userCanMute]); - - const removeFromTeam = useEndpointActionExperimental('POST', 'teams.removeMember', t('User_has_been_removed_from_team')); - - const removeUserAction = useEndpointActionExperimental('POST', `${endpointPrefix}.kick`, t('User_has_been_removed_from_s', roomName)); - const removeUserOptionAction = useMutableCallback(() => { - if (room.teamMain && room.teamId) { - return setModal( - { - const roomKeys = Object.keys(rooms); - await removeFromTeam({ - teamId: room.teamId, - userId: uid, - ...(roomKeys.length && { rooms: roomKeys }), - }); - closeModal(); - reload && reload(); - }} - />, - ); - } - - setModal( - { - await removeUserAction({ roomId: rid, userId: uid }); - closeModal(); - reload && reload(); - }} - />, - ); - }); - - const removeUserOption = useMemo( - () => - roomCanRemove && - userCanRemove && { - label: {room.teamMain ? t('Remove_from_team') : t('Remove_from_room')}, - icon: 'sign-out', - action: removeUserOptionAction, - }, - [room, roomCanRemove, userCanRemove, removeUserOptionAction, t], - ); - - return useMemo( - () => ({ - ...(openDirectMessageOption && { openDirectMessage: openDirectMessageOption }), - ...(videoCallOption && { video: videoCallOption }), - ...(audioCallOption && { audio: audioCallOption }), - ...(changeOwnerOption && { changeOwner: changeOwnerOption }), - ...(changeLeaderOption && { changeLeader: changeLeaderOption }), - ...(changeModeratorOption && { changeModerator: changeModeratorOption }), - ...(ignoreUserOption && { ignoreUser: ignoreUserOption }), - ...(muteUserOption && { muteUser: muteUserOption }), - ...(removeUserOption && { removeUser: removeUserOption }), - ...(toggleBlockUserOption && { toggleBlock: toggleBlockUserOption }), - }), - [ - audioCallOption, - changeLeaderOption, - changeModeratorOption, - changeOwnerOption, - ignoreUserOption, - muteUserOption, - openDirectMessageOption, - removeUserOption, - videoCallOption, - toggleBlockUserOption, - ], - ); -}; diff --git a/apps/meteor/client/views/room/hooks/useUserInfoActions/actions/useAudioCallAction.ts b/apps/meteor/client/views/room/hooks/useUserInfoActions/actions/useAudioCallAction.ts new file mode 100644 index 000000000000..db92ce2f6895 --- /dev/null +++ b/apps/meteor/client/views/room/hooks/useUserInfoActions/actions/useAudioCallAction.ts @@ -0,0 +1,33 @@ +import { IRoom } from '@rocket.chat/core-typings'; +import { useTranslation } from '@rocket.chat/ui-contexts'; +import { useMemo } from 'react'; + +import { Action } from '../../../../hooks/useActionSpread'; +import { useWebRTC } from '../../useWebRTC'; + +export const useAudioCallAction = (rid: IRoom['_id']): Action | undefined => { + const t = useTranslation(); + const { shouldAllowCalls, callInProgress, joinCall, startCall } = useWebRTC(rid); + + const audioCallOption = useMemo(() => { + const handleJoinCall = (): void => { + joinCall({ audio: true, video: false }); + }; + + const handleStartCall = (): void => { + startCall({ audio: true, video: false }); + }; + + const action = callInProgress ? handleJoinCall : handleStartCall; + + return shouldAllowCalls + ? { + label: t(callInProgress ? 'Join_audio_call' : 'Start_audio_call'), + icon: 'mic', + action, + } + : undefined; + }, [callInProgress, shouldAllowCalls, t, joinCall, startCall]); + + return audioCallOption; +}; diff --git a/apps/meteor/client/views/room/hooks/useUserInfoActions/actions/useBlockUserAction.ts b/apps/meteor/client/views/room/hooks/useUserInfoActions/actions/useBlockUserAction.ts new file mode 100644 index 000000000000..0a0574d6b762 --- /dev/null +++ b/apps/meteor/client/views/room/hooks/useUserInfoActions/actions/useBlockUserAction.ts @@ -0,0 +1,51 @@ +import { IRoom, IUser } from '@rocket.chat/core-typings'; +import { useMutableCallback } from '@rocket.chat/fuselage-hooks'; +import { useTranslation, useMethod, useToastMessageDispatch, useUserId, useUserSubscription, useUserRoom } from '@rocket.chat/ui-contexts'; +import { useMemo } from 'react'; + +import { Action } from '../../../../hooks/useActionSpread'; +import { getRoomDirectives } from '../../../lib/getRoomDirectives'; + +export const useBlockUserAction = (user: Pick, rid: IRoom['_id']): Action | undefined => { + const t = useTranslation(); + const dispatchToastMessage = useToastMessageDispatch(); + const currentSubscription = useUserSubscription(rid); + const ownUserId = useUserId(); + const { _id: uid } = user; + const room = useUserRoom(rid); + + if (!room) { + throw Error('Room not provided'); + } + + const { roomCanBlock } = getRoomDirectives(room); + + const isUserBlocked = currentSubscription?.blocker; + const toggleBlock = useMethod(isUserBlocked ? 'unblockUser' : 'blockUser'); + + const toggleBlockUserAction = useMutableCallback(async () => { + try { + await toggleBlock({ rid, blocked: uid }); + dispatchToastMessage({ + type: 'success', + message: t(isUserBlocked ? 'User_is_unblocked' : 'User_is_blocked'), + }); + } catch (error) { + dispatchToastMessage({ type: 'error', message: error }); + } + }); + + const toggleBlockUserOption = useMemo( + () => + roomCanBlock && uid !== ownUserId + ? { + label: t(isUserBlocked ? 'Unblock' : 'Block'), + icon: 'ban', + action: toggleBlockUserAction, + } + : undefined, + [isUserBlocked, ownUserId, roomCanBlock, t, toggleBlockUserAction, uid], + ); + + return toggleBlockUserOption; +}; diff --git a/apps/meteor/client/views/room/hooks/useUserInfoActions/actions/useChangeLeaderAction.ts b/apps/meteor/client/views/room/hooks/useUserInfoActions/actions/useChangeLeaderAction.ts new file mode 100644 index 000000000000..dddfc28397e3 --- /dev/null +++ b/apps/meteor/client/views/room/hooks/useUserInfoActions/actions/useChangeLeaderAction.ts @@ -0,0 +1,53 @@ +import { IRoom, IUser } from '@rocket.chat/core-typings'; +import { useMutableCallback } from '@rocket.chat/fuselage-hooks'; +import { escapeHTML } from '@rocket.chat/string-helpers'; +import { useTranslation, usePermission, useUserRoom } from '@rocket.chat/ui-contexts'; +import { useMemo } from 'react'; + +import { useEndpointActionExperimental } from '../../../../../hooks/useEndpointActionExperimental'; +import { roomCoordinator } from '../../../../../lib/rooms/roomCoordinator'; +import { Action } from '../../../../hooks/useActionSpread'; +import { getRoomDirectives } from '../../../lib/getRoomDirectives'; +import { useUserHasRoomRole } from '../../useUserHasRoomRole'; + +// TODO: Remove endpoint concatenation +export const useChangeLeaderAction = (user: Pick, rid: IRoom['_id']): Action | undefined => { + const t = useTranslation(); + const room = useUserRoom(rid); + const { _id: uid } = user; + const userCanSetLeader = usePermission('set-leader', rid); + + if (!room) { + throw Error('Room not provided'); + } + + const endpointPrefix = room.t === 'p' ? '/v1/groups' : '/v1/channels'; + const { roomCanSetLeader } = getRoomDirectives(room); + const isLeader = useUserHasRoomRole(uid, rid, 'leader'); + const roomName = room?.t && escapeHTML(roomCoordinator.getRoomName(room.t, room)); + + const changeLeaderEndpoint = isLeader ? 'removeLeader' : 'addLeader'; + const changeLeaderMessage = isLeader + ? 'User__username__removed_from__room_name__leaders' + : 'User__username__is_now_a_leader_of__room_name_'; + const changeLeader = useEndpointActionExperimental( + 'POST', + `${endpointPrefix}.${changeLeaderEndpoint}`, + // eslint-disable-next-line @typescript-eslint/camelcase + t(changeLeaderMessage, { username: user.username, room_name: roomName }), + ); + const changeLeaderAction = useMutableCallback(() => changeLeader({ roomId: rid, userId: uid })); + const changeLeaderOption = useMemo( + () => + roomCanSetLeader && userCanSetLeader + ? { + label: t(isLeader ? 'Remove_as_leader' : 'Set_as_leader'), + icon: 'shield-alt', + action: changeLeaderAction, + } + : undefined, + [isLeader, roomCanSetLeader, t, userCanSetLeader, changeLeaderAction], + ); + + return changeLeaderOption; +}; diff --git a/apps/meteor/client/views/room/hooks/useUserInfoActions/actions/useChangeModeratorAction.ts b/apps/meteor/client/views/room/hooks/useUserInfoActions/actions/useChangeModeratorAction.ts new file mode 100644 index 000000000000..0b32928789aa --- /dev/null +++ b/apps/meteor/client/views/room/hooks/useUserInfoActions/actions/useChangeModeratorAction.ts @@ -0,0 +1,54 @@ +import { IRoom, IUser } from '@rocket.chat/core-typings'; +import { useMutableCallback } from '@rocket.chat/fuselage-hooks'; +import { escapeHTML } from '@rocket.chat/string-helpers'; +import { useTranslation, usePermission, useUserRoom } from '@rocket.chat/ui-contexts'; +import { useMemo } from 'react'; + +import { useEndpointActionExperimental } from '../../../../../hooks/useEndpointActionExperimental'; +import { roomCoordinator } from '../../../../../lib/rooms/roomCoordinator'; +import { Action } from '../../../../hooks/useActionSpread'; +import { getRoomDirectives } from '../../../lib/getRoomDirectives'; +import { useUserHasRoomRole } from '../../useUserHasRoomRole'; + +// TODO: Remove endpoint concatenation +export const useChangeModeratorAction = (user: Pick, rid: IRoom['_id']): Action | undefined => { + const t = useTranslation(); + const room = useUserRoom(rid); + const { _id: uid } = user; + + const userCanSetModerator = usePermission('set-moderator', rid); + const isModerator = useUserHasRoomRole(uid, rid, 'moderator'); + + if (!room) { + throw Error('Room not provided'); + } + + const endpointPrefix = room.t === 'p' ? '/v1/groups' : '/v1/channels'; + const { roomCanSetModerator } = getRoomDirectives(room); + const roomName = room?.t && escapeHTML(roomCoordinator.getRoomName(room.t, room)); + + const changeModeratorEndpoint = isModerator ? 'removeModerator' : 'addModerator'; + const changeModeratorMessage = isModerator + ? 'User__username__removed_from__room_name__moderators' + : 'User__username__is_now_a_moderator_of__room_name_'; + const changeModerator = useEndpointActionExperimental( + 'POST', + `${endpointPrefix}.${changeModeratorEndpoint}`, + // eslint-disable-next-line @typescript-eslint/camelcase + t(changeModeratorMessage, { username: user.username, room_name: roomName }), + ); + const changeModeratorAction = useMutableCallback(() => changeModerator({ roomId: rid, userId: uid })); + const changeModeratorOption = useMemo( + () => + roomCanSetModerator && userCanSetModerator + ? { + label: t(isModerator ? 'Remove_as_moderator' : 'Set_as_moderator'), + icon: 'shield-blank', + action: changeModeratorAction, + } + : undefined, + [changeModeratorAction, isModerator, roomCanSetModerator, t, userCanSetModerator], + ); + + return changeModeratorOption; +}; diff --git a/apps/meteor/client/views/room/hooks/useUserInfoActions/actions/useChangeOwnerAction.tsx b/apps/meteor/client/views/room/hooks/useUserInfoActions/actions/useChangeOwnerAction.tsx new file mode 100644 index 000000000000..4e027c0c150d --- /dev/null +++ b/apps/meteor/client/views/room/hooks/useUserInfoActions/actions/useChangeOwnerAction.tsx @@ -0,0 +1,53 @@ +import { IRoom, IUser } from '@rocket.chat/core-typings'; +import { useMutableCallback } from '@rocket.chat/fuselage-hooks'; +import { escapeHTML } from '@rocket.chat/string-helpers'; +import { useTranslation, usePermission, useUserRoom } from '@rocket.chat/ui-contexts'; +import { useMemo } from 'react'; + +import { useEndpointActionExperimental } from '../../../../../hooks/useEndpointActionExperimental'; +import { roomCoordinator } from '../../../../../lib/rooms/roomCoordinator'; +import { Action } from '../../../../hooks/useActionSpread'; +import { getRoomDirectives } from '../../../lib/getRoomDirectives'; +import { useUserHasRoomRole } from '../../useUserHasRoomRole'; + +// TODO: Remove endpoint concatenation +export const useChangeOwnerAction = (user: Pick, rid: IRoom['_id']): Action | undefined => { + const t = useTranslation(); + const room = useUserRoom(rid); + const { _id: uid } = user; + const userCanSetOwner = usePermission('set-owner', rid); + const isOwner = useUserHasRoomRole(uid, rid, 'owner'); + + if (!room) { + throw Error('Room not provided'); + } + + const endpointPrefix = room.t === 'p' ? '/v1/groups' : '/v1/channels'; + const { roomCanSetOwner } = getRoomDirectives(room); + const roomName = room?.t && escapeHTML(roomCoordinator.getRoomName(room.t, room)); + + const changeOwnerEndpoint = isOwner ? 'removeOwner' : 'addOwner'; + const changeOwnerMessage = isOwner ? 'User__username__removed_from__room_name__owners' : 'User__username__is_now_a_owner_of__room_name_'; + + const changeOwner = useEndpointActionExperimental( + 'POST', + `${endpointPrefix}.${changeOwnerEndpoint}`, + // eslint-disable-next-line @typescript-eslint/camelcase + t(changeOwnerMessage, { username: user.username, room_name: roomName }), + ); + + const changeOwnerAction = useMutableCallback(async () => changeOwner({ roomId: rid, userId: uid })); + const changeOwnerOption = useMemo( + () => + roomCanSetOwner && userCanSetOwner + ? { + label: t(isOwner ? 'Remove_as_owner' : 'Set_as_owner'), + icon: 'shield-check', + action: changeOwnerAction, + } + : undefined, + [changeOwnerAction, roomCanSetOwner, userCanSetOwner, isOwner, t], + ); + + return changeOwnerOption; +}; diff --git a/apps/meteor/client/views/room/hooks/useUserInfoActions/actions/useDirectMessageAction.ts b/apps/meteor/client/views/room/hooks/useUserInfoActions/actions/useDirectMessageAction.ts new file mode 100644 index 000000000000..07f07a78d328 --- /dev/null +++ b/apps/meteor/client/views/room/hooks/useUserInfoActions/actions/useDirectMessageAction.ts @@ -0,0 +1,54 @@ +import { IRoom, IUser, ISubscription } from '@rocket.chat/core-typings'; +import { useMutableCallback } from '@rocket.chat/fuselage-hooks'; +import { useTranslation, usePermission, useRoute, useUserSubscription, useUserSubscriptionByName } from '@rocket.chat/ui-contexts'; +import { useMemo } from 'react'; + +import { Action } from '../../../../hooks/useActionSpread'; + +const getShouldOpenDirectMessage = ( + currentSubscription?: ISubscription, + usernameSubscription?: ISubscription, + canOpenDirectMessage?: boolean, + username?: IUser['username'], +): boolean => { + const canOpenDm = canOpenDirectMessage || usernameSubscription; + const directMessageIsNotAlreadyOpen = currentSubscription && currentSubscription.name !== username; + return (canOpenDm && directMessageIsNotAlreadyOpen) ?? false; +}; + +export const useDirectMessageAction = (user: Pick, rid: IRoom['_id']): Action | undefined => { + const t = useTranslation(); + const usernameSubscription = useUserSubscriptionByName(user.username ?? ''); + const currentSubscription = useUserSubscription(rid); + const canOpenDirectMessage = usePermission('create-d'); + const directRoute = useRoute('direct'); + + const shouldOpenDirectMessage = getShouldOpenDirectMessage( + currentSubscription, + usernameSubscription, + canOpenDirectMessage, + user.username, + ); + + const openDirectMessage = useMutableCallback( + () => + user.username && + directRoute.push({ + rid: user.username, + }), + ); + + const openDirectMessageOption = useMemo( + () => + shouldOpenDirectMessage + ? { + label: t('Direct_Message'), + icon: 'balloon', + action: openDirectMessage, + } + : undefined, + [openDirectMessage, shouldOpenDirectMessage, t], + ); + + return openDirectMessageOption; +}; diff --git a/apps/meteor/client/views/room/hooks/useUserInfoActions/actions/useIgnoreUserAction.ts b/apps/meteor/client/views/room/hooks/useUserInfoActions/actions/useIgnoreUserAction.ts new file mode 100644 index 000000000000..8147c461ef8d --- /dev/null +++ b/apps/meteor/client/views/room/hooks/useUserInfoActions/actions/useIgnoreUserAction.ts @@ -0,0 +1,52 @@ +import { IRoom, IUser } from '@rocket.chat/core-typings'; +import { useMutableCallback } from '@rocket.chat/fuselage-hooks'; +import { useTranslation, useMethod, useUserSubscription, useUserRoom, useUserId, useToastMessageDispatch } from '@rocket.chat/ui-contexts'; +import { useMemo } from 'react'; + +import { Action } from '../../../../hooks/useActionSpread'; +import { getRoomDirectives } from '../../../lib/getRoomDirectives'; + +export const useIgnoreUserAction = (user: Pick, rid: IRoom['_id']): Action | undefined => { + const t = useTranslation(); + const room = useUserRoom(rid); + const { _id: uid } = user; + const ownUserId = useUserId(); + const dispatchToastMessage = useToastMessageDispatch(); + const currentSubscription = useUserSubscription(rid); + const ignoreUser = useMethod('ignoreUser'); + + const isIgnored = currentSubscription?.ignored && currentSubscription.ignored.indexOf(uid) > -1; + + if (!room) { + throw Error('Room not provided'); + } + + const { roomCanIgnore } = getRoomDirectives(room); + + const ignoreUserAction = useMutableCallback(async () => { + try { + await ignoreUser({ rid, userId: uid, ignore: !isIgnored }); + if (isIgnored) { + dispatchToastMessage({ type: 'success', message: t('User_has_been_unignored') }); + } else { + dispatchToastMessage({ type: 'success', message: t('User_has_been_ignored') }); + } + } catch (error) { + dispatchToastMessage({ type: 'error', message: error }); + } + }); + + const ignoreUserOption = useMemo( + () => + roomCanIgnore && uid !== ownUserId + ? { + label: t(isIgnored ? 'Unignore' : 'Ignore'), + icon: 'ban', + action: ignoreUserAction, + } + : undefined, + [ignoreUserAction, isIgnored, ownUserId, roomCanIgnore, t, uid], + ); + + return ignoreUserOption; +}; diff --git a/apps/meteor/client/views/room/hooks/useUserInfoActions/actions/useMuteUserAction.tsx b/apps/meteor/client/views/room/hooks/useUserInfoActions/actions/useMuteUserAction.tsx new file mode 100644 index 000000000000..b3fc6fe926b4 --- /dev/null +++ b/apps/meteor/client/views/room/hooks/useUserInfoActions/actions/useMuteUserAction.tsx @@ -0,0 +1,119 @@ +import { IRoom, IUser } from '@rocket.chat/core-typings'; +import { useMutableCallback } from '@rocket.chat/fuselage-hooks'; +import { escapeHTML } from '@rocket.chat/string-helpers'; +import { + useAllPermissions, + usePermission, + useSetModal, + useMethod, + useToastMessageDispatch, + useTranslation, + useUserRoom, +} from '@rocket.chat/ui-contexts'; +import React, { useMemo } from 'react'; + +import GenericModal from '../../../../../components/GenericModal'; +import { roomCoordinator } from '../../../../../lib/rooms/roomCoordinator'; +import { Action } from '../../../../hooks/useActionSpread'; +import { getRoomDirectives } from '../../../lib/getRoomDirectives'; + +const getUserIsMuted = ( + user: Pick, + room: IRoom | undefined, + userCanPostReadonly: boolean, +): boolean | undefined => { + if (room?.ro) { + if (Array.isArray(room.unmuted) && room.unmuted.indexOf(user.username ?? '') !== -1) { + return false; + } + + if (userCanPostReadonly) { + return Array.isArray(room.muted) && room.muted.indexOf(user.username ?? '') !== -1; + } + + return true; + } + + return room && Array.isArray(room.muted) && room.muted.indexOf(user.username ?? '') > -1; +}; + +export const useMuteUserAction = (user: Pick, rid: IRoom['_id']): Action | undefined => { + const t = useTranslation(); + const room = useUserRoom(rid); + const userCanMute = usePermission('mute-user', rid); + const dispatchToastMessage = useToastMessageDispatch(); + const setModal = useSetModal(); + const closeModal = useMutableCallback(() => setModal(null)); + const otherUserCanPostReadonly = useAllPermissions( + useMemo(() => ['post-readonly'], []), + rid, + ); + + const isMuted = getUserIsMuted(user, room, otherUserCanPostReadonly); + const roomName = room?.t && escapeHTML(roomCoordinator.getRoomName(room.t, room)); + + if (!room) { + throw Error('Room not provided'); + } + + const { roomCanMute } = getRoomDirectives(room); + + const mutedMessage = isMuted ? 'User__username__unmuted_in_room__roomName__' : 'User__username__muted_in_room__roomName__'; + + const muteUser = useMethod(isMuted ? 'unmuteUserInRoom' : 'muteUserInRoom'); + + const muteUserOption = useMemo(() => { + const action = (): Promise | void => { + const onConfirm = async (): Promise => { + try { + await muteUser({ rid, username: user.username }); + + return dispatchToastMessage({ + type: 'success', + message: t(mutedMessage, { + username: user.username, + roomName, + }), + }); + } catch (error) { + dispatchToastMessage({ type: 'error', message: error }); + } finally { + closeModal(); + } + }; + + if (isMuted) { + return onConfirm(); + } + + return setModal( + + {t('The_user_wont_be_able_to_type_in_s', roomName)} + , + ); + }; + + return roomCanMute && userCanMute + ? { + label: t(isMuted ? 'Unmute_user' : 'Mute_user'), + icon: isMuted ? 'mic' : 'mic-off', + action, + } + : undefined; + }, [ + closeModal, + mutedMessage, + dispatchToastMessage, + isMuted, + muteUser, + rid, + roomCanMute, + roomName, + setModal, + t, + user.username, + userCanMute, + ]); + + return muteUserOption; +}; diff --git a/apps/meteor/client/views/room/hooks/useUserInfoActions/actions/useRemoveUserAction.tsx b/apps/meteor/client/views/room/hooks/useUserInfoActions/actions/useRemoveUserAction.tsx new file mode 100644 index 000000000000..6bc41e267db0 --- /dev/null +++ b/apps/meteor/client/views/room/hooks/useUserInfoActions/actions/useRemoveUserAction.tsx @@ -0,0 +1,92 @@ +import { IRoom, IUser } from '@rocket.chat/core-typings'; +import { Box, Icon } from '@rocket.chat/fuselage'; +import { useMutableCallback } from '@rocket.chat/fuselage-hooks'; +import { escapeHTML } from '@rocket.chat/string-helpers'; +import { usePermission, useSetModal, useTranslation, useUserRoom } from '@rocket.chat/ui-contexts'; +import React, { useMemo } from 'react'; + +import GenericModal from '../../../../../components/GenericModal'; +import { useEndpointActionExperimental } from '../../../../../hooks/useEndpointActionExperimental'; +import { roomCoordinator } from '../../../../../lib/rooms/roomCoordinator'; +import { Action } from '../../../../hooks/useActionSpread'; +import RemoveUsersModal from '../../../../teams/contextualBar/members/RemoveUsersModal'; +import { getRoomDirectives } from '../../../lib/getRoomDirectives'; + +// TODO: Remove endpoint concatenation +export const useRemoveUserAction = (user: Pick, rid: IRoom['_id'], reload?: () => void): Action | undefined => { + const t = useTranslation(); + const room = useUserRoom(rid); + const { _id: uid } = user; + + const userCanRemove = usePermission('remove-user', rid); + const setModal = useSetModal(); + const closeModal = useMutableCallback(() => setModal(null)); + const roomName = room?.t && escapeHTML(roomCoordinator.getRoomName(room.t, room)); + + if (!room) { + throw Error('Room not provided'); + } + + const endpointPrefix = room.t === 'p' ? '/v1/groups' : '/v1/channels'; + const { roomCanRemove } = getRoomDirectives(room); + + const removeFromTeam = useEndpointActionExperimental('POST', '/v1/teams.removeMember', t('User_has_been_removed_from_team')); + const removeFromRoom = useEndpointActionExperimental('POST', `${endpointPrefix}.kick`, t('User_has_been_removed_from_s', roomName)); + + const removeUserOptionAction = useMutableCallback(() => { + const handleRemoveFromTeam = async (rooms: IRoom[]): Promise => { + if (room.teamId) { + const roomKeys = Object.keys(rooms); + await removeFromTeam({ + teamId: room.teamId, + userId: uid, + ...(roomKeys.length && { rooms: roomKeys }), + }); + closeModal(); + reload?.(); + } + }; + + const handleRemoveFromRoom = async (rid: IRoom['_id'], uid: IUser['_id']): Promise => { + await removeFromRoom({ roomId: rid, userId: uid }); + closeModal(); + reload?.(); + }; + + if (room.teamMain && room.teamId) { + return setModal( + , + ); + } + + setModal( + => handleRemoveFromRoom(rid, uid)} + > + {t('The_user_will_be_removed_from_s', roomName)} + , + ); + }); + + const removeUserOption = useMemo( + () => + roomCanRemove && userCanRemove + ? { + label: ( + + + {room?.teamMain ? t('Remove_from_team') : t('Remove_from_room')} + + ), + action: removeUserOptionAction, + } + : undefined, + [room, roomCanRemove, userCanRemove, removeUserOptionAction, t], + ); + + return removeUserOption; +}; diff --git a/apps/meteor/client/views/room/hooks/useUserInfoActions/actions/useVideoCallAction.tsx b/apps/meteor/client/views/room/hooks/useUserInfoActions/actions/useVideoCallAction.tsx new file mode 100644 index 000000000000..6a3fafcd8dbe --- /dev/null +++ b/apps/meteor/client/views/room/hooks/useUserInfoActions/actions/useVideoCallAction.tsx @@ -0,0 +1,33 @@ +import { IRoom } from '@rocket.chat/core-typings'; +import { useTranslation } from '@rocket.chat/ui-contexts'; +import { useMemo } from 'react'; + +import { Action } from '../../../../hooks/useActionSpread'; +import { useWebRTC } from '../../useWebRTC'; + +export const useVideoCallAction = (rid: IRoom['_id']): Action | undefined => { + const t = useTranslation(); + const { shouldAllowCalls, callInProgress, joinCall, startCall } = useWebRTC(rid); + + const videoCallOption = useMemo(() => { + const handleJoinCall = (): void => { + joinCall({ audio: true, video: true }); + }; + + const handleStartCall = (): void => { + startCall({ audio: true, video: true }); + }; + + const action = callInProgress ? handleJoinCall : handleStartCall; + + return shouldAllowCalls + ? { + label: t(callInProgress ? 'Join_video_call' : 'Start_video_call'), + icon: 'video', + action, + } + : undefined; + }, [callInProgress, shouldAllowCalls, t, joinCall, startCall]); + + return videoCallOption; +}; diff --git a/apps/meteor/client/views/room/hooks/useUserInfoActions/index.ts b/apps/meteor/client/views/room/hooks/useUserInfoActions/index.ts new file mode 100644 index 000000000000..44ce5ef6c2da --- /dev/null +++ b/apps/meteor/client/views/room/hooks/useUserInfoActions/index.ts @@ -0,0 +1 @@ +export { useUserInfoActions } from './useUserInfoActions'; diff --git a/apps/meteor/client/views/room/hooks/useUserInfoActions/useUserInfoActions.ts b/apps/meteor/client/views/room/hooks/useUserInfoActions/useUserInfoActions.ts new file mode 100644 index 000000000000..f54586c0a18e --- /dev/null +++ b/apps/meteor/client/views/room/hooks/useUserInfoActions/useUserInfoActions.ts @@ -0,0 +1,60 @@ +import { IRoom, IUser } from '@rocket.chat/core-typings'; +import { useMemo } from 'react'; + +import { Action } from '../../../hooks/useActionSpread'; +import { useAudioCallAction } from './actions/useAudioCallAction'; +import { useBlockUserAction } from './actions/useBlockUserAction'; +import { useChangeLeaderAction } from './actions/useChangeLeaderAction'; +import { useChangeModeratorAction } from './actions/useChangeModeratorAction'; +import { useChangeOwnerAction } from './actions/useChangeOwnerAction'; +import { useDirectMessageAction } from './actions/useDirectMessageAction'; +import { useIgnoreUserAction } from './actions/useIgnoreUserAction'; +import { useMuteUserAction } from './actions/useMuteUserAction'; +import { useRemoveUserAction } from './actions/useRemoveUserAction'; +import { useVideoCallAction } from './actions/useVideoCallAction'; + +export const useUserInfoActions = ( + user: Pick, + rid: IRoom['_id'], + reload?: () => void, +): { + [key: string]: Action; +} => { + const audioCallOption = useAudioCallAction(rid); + const blockUserOption = useBlockUserAction(user, rid); + const changeLeaderOption = useChangeLeaderAction(user, rid); + const changeModeratorOption = useChangeModeratorAction(user, rid); + const changeOwnerOption = useChangeOwnerAction(user, rid); + const openDirectMessageOption = useDirectMessageAction(user, rid); + const ignoreUserOption = useIgnoreUserAction(user, rid); + const muteUserOption = useMuteUserAction(user, rid); + const removeUserOption = useRemoveUserAction(user, rid, reload); + const videoCallOption = useVideoCallAction(rid); + + return useMemo( + () => ({ + ...(openDirectMessageOption && { openDirectMessage: openDirectMessageOption }), + ...(videoCallOption && { video: videoCallOption }), + ...(audioCallOption && { audio: audioCallOption }), + ...(changeOwnerOption && { changeOwner: changeOwnerOption }), + ...(changeLeaderOption && { changeLeader: changeLeaderOption }), + ...(changeModeratorOption && { changeModerator: changeModeratorOption }), + ...(ignoreUserOption && { ignoreUser: ignoreUserOption }), + ...(muteUserOption && { muteUser: muteUserOption }), + ...(blockUserOption && { toggleBlock: blockUserOption }), + ...(removeUserOption && { removeUser: removeUserOption }), + }), + [ + audioCallOption, + changeLeaderOption, + changeModeratorOption, + changeOwnerOption, + ignoreUserOption, + muteUserOption, + openDirectMessageOption, + removeUserOption, + videoCallOption, + blockUserOption, + ], + ); +}; diff --git a/apps/meteor/client/views/room/lib/getRoomDirectives.ts b/apps/meteor/client/views/room/lib/getRoomDirectives.ts new file mode 100644 index 000000000000..2f5602f02d2b --- /dev/null +++ b/apps/meteor/client/views/room/lib/getRoomDirectives.ts @@ -0,0 +1,33 @@ +import { IRoom } from '@rocket.chat/core-typings'; + +import { RoomMemberActions } from '../../../../definition/IRoomTypeConfig'; +import { roomCoordinator } from '../../../lib/rooms/roomCoordinator'; + +type getRoomDirectiesType = { + roomCanSetOwner: boolean; + roomCanSetLeader: boolean; + roomCanSetModerator: boolean; + roomCanIgnore: boolean; + roomCanBlock: boolean; + roomCanMute: boolean; + roomCanRemove: boolean; +}; + +export const getRoomDirectives = (room: IRoom): getRoomDirectiesType => { + const roomDirectives = room?.t && roomCoordinator.getRoomDirectives(room.t); + + const [roomCanSetOwner, roomCanSetLeader, roomCanSetModerator, roomCanIgnore, roomCanBlock, roomCanMute, roomCanRemove] = [ + ...((roomDirectives && [ + roomDirectives.allowMemberAction(room, RoomMemberActions.SET_AS_OWNER), + roomDirectives.allowMemberAction(room, RoomMemberActions.SET_AS_LEADER), + roomDirectives.allowMemberAction(room, RoomMemberActions.SET_AS_MODERATOR), + roomDirectives.allowMemberAction(room, RoomMemberActions.IGNORE), + roomDirectives.allowMemberAction(room, RoomMemberActions.BLOCK), + roomDirectives.allowMemberAction(room, RoomMemberActions.MUTE), + roomDirectives.allowMemberAction(room, RoomMemberActions.REMOVE_USER), + ]) ?? + []), + ]; + + return { roomCanSetOwner, roomCanSetLeader, roomCanSetModerator, roomCanIgnore, roomCanBlock, roomCanMute, roomCanRemove }; +}; diff --git a/apps/meteor/client/views/root/AppLayout.tsx b/apps/meteor/client/views/root/AppLayout.tsx index f883e61f1dac..137b542fcdb6 100644 --- a/apps/meteor/client/views/root/AppLayout.tsx +++ b/apps/meteor/client/views/root/AppLayout.tsx @@ -1,5 +1,5 @@ import React, { FC, Fragment, Suspense } from 'react'; -import { useSubscription } from 'use-subscription'; +import { useSyncExternalStore } from 'use-sync-external-store/shim'; import { appLayout } from '../../lib/appLayout'; import { blazePortals } from '../../lib/portals/blazePortals'; @@ -9,8 +9,8 @@ import { useTooltipHandling } from './useTooltipHandling'; const AppLayout: FC = () => { useTooltipHandling(); - const layout = useSubscription(appLayout); - const portals = useSubscription(blazePortals); + const layout = useSyncExternalStore(appLayout.subscribe, appLayout.getSnapshot); + const portals = useSyncExternalStore(blazePortals.subscribe, blazePortals.getSnapshot); return ( <> diff --git a/apps/meteor/client/views/root/PortalsWrapper.tsx b/apps/meteor/client/views/root/PortalsWrapper.tsx index 99c51a455a16..cc31f762e303 100644 --- a/apps/meteor/client/views/root/PortalsWrapper.tsx +++ b/apps/meteor/client/views/root/PortalsWrapper.tsx @@ -1,11 +1,11 @@ import React, { FC } from 'react'; -import { useSubscription } from 'use-subscription'; +import { useSyncExternalStore } from 'use-sync-external-store/shim'; import { portalsSubscription } from '../../lib/portals/portalsSubscription'; import PortalWrapper from './PortalWrapper'; const PortalsWrapper: FC = () => { - const portals = useSubscription(portalsSubscription); + const portals = useSyncExternalStore(portalsSubscription.subscribe, portalsSubscription.getSnapshot); return ( <> diff --git a/apps/meteor/client/views/teams/CreateTeamModal/CreateTeamModal.tsx b/apps/meteor/client/views/teams/CreateTeamModal/CreateTeamModal.tsx index c035fa4a7134..d131b403d925 100644 --- a/apps/meteor/client/views/teams/CreateTeamModal/CreateTeamModal.tsx +++ b/apps/meteor/client/views/teams/CreateTeamModal/CreateTeamModal.tsx @@ -1,207 +1,13 @@ -import type { IUser } from '@rocket.chat/core-typings'; import { Box, Modal, ButtonGroup, Button, TextInput, Field, ToggleSwitch, FieldGroup } from '@rocket.chat/fuselage'; -import { useMutableCallback, useDebouncedCallback, useAutoFocus } from '@rocket.chat/fuselage-hooks'; -import { useSetting, usePermission, useMethod, useTranslation } from '@rocket.chat/ui-contexts'; -import React, { FC, memo, useCallback, useEffect, useMemo, useState } from 'react'; +import { useAutoFocus } from '@rocket.chat/fuselage-hooks'; +import { useTranslation } from '@rocket.chat/ui-contexts'; +import React, { memo, ReactElement } from 'react'; -import { useEndpointActionExperimental } from '../../../hooks/useEndpointActionExperimental'; -import { useForm } from '../../../hooks/useForm'; -import { goToRoomById } from '../../../lib/utils/goToRoomById'; +import UserAutoCompleteMultiple from '../../../components/UserAutoCompleteMultiple'; import TeamNameInput from './TeamNameInput'; -import UsersInput from './UsersInput'; +import { useCreateTeamModalState } from './useCreateTeamModalState'; -type CreateTeamModalState = { - name: any; - nameError: any; - onChangeName: any; - description: any; - onChangeDescription: any; - type: any; - onChangeType: any; - readOnly: any; - canChangeReadOnly: any; - onChangeReadOnly: any; - encrypted: any; - canChangeEncrypted: any; - onChangeEncrypted: any; - broadcast: any; - onChangeBroadcast: any; - members: any; - onChangeMembers: any; - hasUnsavedChanges: any; - isCreateButtonEnabled: any; - onCreate: any; -}; - -const useCreateTeamModalState = (onClose: () => void): CreateTeamModalState => { - const e2eEnabled = useSetting('E2E_Enable'); - 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 { values, handlers, hasUnsavedChanges } = useForm({ - members: [], - name: '', - description: '', - type: true, - readOnly: false, - encrypted: e2eEnabledForPrivateByDefault ?? false, - broadcast: false, - }); - - const { name, description, type, readOnly, broadcast, encrypted, members } = values as { - name: string; - description: string; - type: boolean; - readOnly: boolean; - broadcast: boolean; - encrypted: boolean; - members: Exclude[]; - }; - - const { handleMembers, handleEncrypted, handleType, handleBroadcast, handleReadOnly } = handlers; - - const t = useTranslation(); - - const teamNameRegex = useMemo(() => { - if (allowSpecialNames) { - return null; - } - - return new RegExp(`^${namesValidation}$`); - }, [allowSpecialNames, namesValidation]); - - const [nameError, setNameError] = useState(); - - const teamNameExists = useMethod('roomNameExists'); - - const checkName = useDebouncedCallback( - async (name: string) => { - setNameError(undefined); - - if (!hasUnsavedChanges) { - return; - } - - if (!name || name.length === 0) { - setNameError(t('Field_required')); - return; - } - - if (teamNameRegex && !teamNameRegex.test(name)) { - setNameError(t('error-invalid-name')); - return; - } - - const isNotAvailable = await teamNameExists(name); - if (isNotAvailable) { - setNameError(t('Teams_Errors_team_name', { name })); - } - }, - 230, - [name], - ); - - useEffect(() => { - checkName(name); - }, [checkName, name]); - - const canChangeReadOnly = !broadcast; - - const canChangeEncrypted = type && !broadcast && e2eEnabled && !e2eEnabledForPrivateByDefault; - - const onChangeName = handlers.handleName; - - const onChangeDescription = handlers.handleDescription; - - const onChangeType = useMutableCallback((value) => { - handleEncrypted(!value); - return handleType(value); - }); - - const onChangeReadOnly = handlers.handleReadOnly; - - const onChangeEncrypted = handlers.handleEncrypted; - - const onChangeBroadcast = useCallback( - (value) => { - handleEncrypted(!value); - handleReadOnly(value); - return handleBroadcast(value); - }, - [handleBroadcast, handleEncrypted, handleReadOnly], - ); - - const onChangeMembers = useCallback( - (value, action) => { - if (!action) { - if (members.includes(value)) { - return; - } - return handleMembers([...members, value]); - } - handleMembers(members.filter((current) => current !== value)); - }, - [handleMembers, members], - ); - - const canSave = hasUnsavedChanges && !nameError; - const canCreateTeam = usePermission('create-team'); - const isCreateButtonEnabled = canSave && canCreateTeam; - - const createTeam = useEndpointActionExperimental('POST', '/v1/teams.create'); - - const onCreate = useCallback(async () => { - const params = { - name, - members, - type: type ? 1 : 0, - room: { - readOnly, - extraData: { - description, - broadcast, - encrypted, - }, - }, - }; - - const data = await createTeam(params); - - goToRoomById(data.team.roomId); - - onClose(); - }, [name, members, type, readOnly, description, broadcast, encrypted, createTeam, onClose]); - - return { - name, - nameError, - onChangeName, - description, - onChangeDescription, - type, - onChangeType, - readOnly, - canChangeReadOnly, - onChangeReadOnly, - encrypted, - canChangeEncrypted, - onChangeEncrypted, - broadcast, - onChangeBroadcast, - members, - onChangeMembers, - hasUnsavedChanges, - isCreateButtonEnabled, - onCreate, - }; -}; - -type CreateTeamModalProps = { - onClose: () => void; -}; - -const CreateTeamModal: FC = ({ onClose }) => { +const CreateTeamModal = ({ onClose }: { onClose: () => void }): ReactElement => { const { name, nameError, @@ -226,14 +32,13 @@ const CreateTeamModal: FC = ({ onClose }) => { } = useCreateTeamModalState(onClose); const t = useTranslation(); - const focusRef = useAutoFocus(); return ( {t('Teams_New_Title')} - + @@ -310,7 +115,7 @@ const CreateTeamModal: FC = ({ onClose }) => { ({t('optional')}) - + diff --git a/apps/meteor/client/views/teams/CreateTeamModal/UsersInput.tsx b/apps/meteor/client/views/teams/CreateTeamModal/UsersInput.tsx deleted file mode 100644 index 13126e2c6838..000000000000 --- a/apps/meteor/client/views/teams/CreateTeamModal/UsersInput.tsx +++ /dev/null @@ -1,90 +0,0 @@ -import { AutoComplete, Box, Option, Options, Chip, AutoCompleteProps } from '@rocket.chat/fuselage'; -import { useDebouncedValue } from '@rocket.chat/fuselage-hooks'; -import React, { FC, memo, useCallback, useMemo, useState } from 'react'; - -import UserAvatar from '../../../components/avatar/UserAvatar'; -import { useEndpointData } from '../../../hooks/useEndpointData'; - -type UsersInputProps = { - value: unknown[]; - onChange: (value: unknown, action: 'remove' | undefined) => void; -}; - -type AutocompleteData = [AutoCompleteProps['options'], { [key: string]: string | undefined }]; - -const useUsersAutoComplete = (term: string): AutocompleteData => { - const params = useMemo( - () => ({ - selector: JSON.stringify({ term }), - }), - [term], - ); - const { value: data } = useEndpointData('/v1/users.autocomplete', params); - - return useMemo(() => { - if (!data) { - return [[], {}]; - } - - const options = - data.items.map((user) => ({ - label: user.name ?? '', - value: user._id ?? '', - })) || []; - - const labelData = Object.fromEntries(data.items.map((user) => [user._id, user.username]) || []); - - return [options, labelData]; - }, [data]); -}; - -const UsersInput: FC = ({ onChange, ...props }) => { - const [filter, setFilter] = useState(''); - const [options, labelData] = useUsersAutoComplete(useDebouncedValue(filter, 1000)); - - const onClickSelected = useCallback( - (e) => { - e.stopPropagation(); - e.preventDefault(); - onChange(e.currentTarget.value, 'remove'); - }, - [onChange], - ); - - const renderSelected = useCallback>( - ({ value: selected }) => ( - <> - {selected?.map((value) => ( - - - - {labelData[value]} - - - ))} - - ), - [onClickSelected, props, labelData], - ); - - const renderItem = useCallback>( - ({ value, ...props }) => ( -