diff --git a/.eslintrc.js b/.eslintrc.js index 26865d55ec..7f8d0adb9c 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -1,8 +1,16 @@ +/* +Copyright 2025 Element Creations Ltd. + +SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial +Please see LICENSE files in the repository root for full details. +*/ + module.exports = { plugins: ["matrix-org", "eslint-plugin-react-compiler"], extends: ["plugin:matrix-org/babel", "plugin:matrix-org/react", "plugin:matrix-org/a11y"], parserOptions: { project: ["./tsconfig.json"], + tsconfigRootDir: __dirname, }, env: { browser: true, @@ -160,6 +168,10 @@ module.exports = { group: ["@vector-im/compound-design-tokens/icons/*"], message: "Please use @vector-im/compound-design-tokens/assets/web/icons/* instead", }, + { + group: ["**/packages/shared-components/**", "../packages/shared-components/**"], + message: "Please use @element-hq/web-shared-components", + }, ], }, ], diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 34431799f4..ae2cf6294d 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -17,9 +17,16 @@ /playwright/e2e/crypto/ @element-hq/element-crypto-web-reviewers /playwright/e2e/settings/encryption-user-tab/ @element-hq/element-crypto-web-reviewers + +/src/models/Call.ts @element-hq/element-call-reviewers +/src/call-types.ts @element-hq/element-call-reviewers +/src/components/views/voip @element-hq/element-call-reviewers +/playwright/e2e/voip/element-call.spec.ts @element-hq/element-call-reviewers + # Ignore translations as those will be updated by GHA for Localazy download /src/i18n/strings /src/i18n/strings/en_EN.json @element-hq/element-web-reviewers -# Ignore the synapse plugin as this is updated by GHA for docker image updating +# Ignore the synapse & mas plugins as this is updated by GHA for docker image updating /playwright/testcontainers/synapse.ts +/playwright/testcontainers/mas.ts diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index ebde627fde..759266d4e0 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -2,6 +2,7 @@ ## Checklist +- [ ] I have read through [review guidelines](../docs/review.md) and [CONTRIBUTING.md](../CONTRIBUTING.md). - [ ] Tests written for new code (and old code if feasible). - [ ] New or updated `public`/`exported` symbols have accurate [TSDoc](https://tsdoc.org/) documentation. - [ ] Linter and other CI checks pass. diff --git a/.github/labels.yml b/.github/labels.yml index f8adbe8e53..649e1a7407 100644 --- a/.github/labels.yml +++ b/.github/labels.yml @@ -279,3 +279,6 @@ - name: "Z-Flaky-Test-Disabled" description: "The flaking test has been disabled" color: "ededed" +- name: "Z-Skip-Coverage" + description: "Skip SonarQube coverage for this PR" + color: "ededed" diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 68b9f4e703..74425f25b4 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -10,8 +10,7 @@ concurrency: cancel-in-progress: ${{ github.event_name == 'pull_request' }} # develop pushes and repository_dispatch handled in build_develop.yaml env: - # These must be set for fetchdep.sh to get the right branch - REPOSITORY: ${{ github.repository }} + # This must be set for fetchdep.sh to get the right branch PR_NUMBER: ${{ github.event.pull_request.number }} permissions: {} # No permissions required jobs: @@ -43,9 +42,9 @@ jobs: run: shell: bash steps: - - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 + - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6 - - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 + - uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6 with: # Disable cache on Windows as it is slower than not caching # https://github.com/actions/setup-node/issues/975 @@ -56,15 +55,7 @@ jobs: - run: yarn config set network-timeout 300000 - name: Fetch layered build - id: layered_build - env: - # tell layered.sh to check out the right sha of the JS-SDK & EW, if they were given one - JS_SDK_GITHUB_BASE_REF: ${{ inputs.matrix-js-sdk-sha }} - run: | - scripts/layered.sh - JSSDK_SHA=$(git -C matrix-js-sdk rev-parse --short=12 HEAD) - VECTOR_SHA=$(git rev-parse --short=12 HEAD) - echo "VERSION=$VECTOR_SHA--js-$JSSDK_SHA" >> $GITHUB_OUTPUT + run: ./scripts/layered.sh - name: Copy config run: cp element.io/develop/config.json config.json @@ -72,12 +63,10 @@ jobs: - name: Build env: CI_PACKAGE: true - VERSION: "${{ steps.layered_build.outputs.VERSION }}" - run: | - yarn build + run: VERSION=$(scripts/get-version-from-git.sh) yarn build - name: Upload Artifact - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5 with: name: webapp-${{ matrix.image }} path: webapp diff --git a/.github/workflows/build_debian.yaml b/.github/workflows/build_debian.yaml index 247e5604ee..48c21c9dc5 100644 --- a/.github/workflows/build_debian.yaml +++ b/.github/workflows/build_debian.yaml @@ -14,7 +14,7 @@ jobs: R2_URL: ${{ vars.CF_R2_S3_API }} VERSION: ${{ github.ref_name }} steps: - - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 + - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6 - name: Download package run: | @@ -62,7 +62,7 @@ jobs: dpkg-gencontrol -v"$VERSION" -ldebian/tmp/DEBIAN/changelog dpkg-deb -Zxz --root-owner-group --build debian/tmp element-web.deb - - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + - uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5 with: name: element-web.deb path: element-web.deb diff --git a/.github/workflows/build_develop.yml b/.github/workflows/build_develop.yml index 9550bf9139..a923e1db1d 100644 --- a/.github/workflows/build_develop.yml +++ b/.github/workflows/build_develop.yml @@ -26,9 +26,9 @@ jobs: R2_URL: ${{ vars.CF_R2_S3_API }} R2_PUBLIC_URL: "https://element-web-develop.element.io" steps: - - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 + - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6 - - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 + - uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6 with: cache: "yarn" node-version: "lts/*" @@ -53,7 +53,7 @@ jobs: - run: mv dist/element-*.tar.gz dist/develop.tar.gz - - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + - uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5 with: name: webapp path: dist/develop.tar.gz diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 8b52e6764c..7abba47247 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -34,7 +34,7 @@ jobs: env: SITE: ${{ inputs.site || 'staging.element.io' }} steps: - - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 + - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6 - name: Load GPG key run: | diff --git a/.github/workflows/docker.yaml b/.github/workflows/docker.yaml index 75a00af7aa..5c2e989e28 100644 --- a/.github/workflows/docker.yaml +++ b/.github/workflows/docker.yaml @@ -20,31 +20,31 @@ jobs: env: TEST_TAG: vectorim/element-web:test steps: - - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 + - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6 with: fetch-depth: 0 # needed for docker-package to be able to calculate the version - name: Install Cosign - uses: sigstore/cosign-installer@3454372f43399081ed03b604cb2d021dabca52bb # v3 + uses: sigstore/cosign-installer@398d4b0eeef1380460a10c8013a76f728fb906ac # v3 if: github.event_name != 'pull_request' - name: Set up QEMU - uses: docker/setup-qemu-action@29109295f81e9208d7d86ff1c6c12d2833863392 # v3 + uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3 - name: Set up Docker Buildx - uses: docker/setup-buildx-action@b5ca514318bd6ebac0fb2aedd5d36ec1b5c232a2 # v3 + uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3 with: install: true - name: Login to Docker Hub - uses: docker/login-action@74a5d142397b4f367a81961eba4e8cd7edddf772 # v3 + uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3 if: github.event_name != 'pull_request' with: username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} - name: Login to GitHub Container Registry - uses: docker/login-action@74a5d142397b4f367a81961eba4e8cd7edddf772 # v3 + uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3 if: github.event_name != 'pull_request' with: registry: ghcr.io @@ -53,7 +53,7 @@ jobs: - name: Build and load id: test-build - uses: docker/build-push-action@471d1dc4e07e5cdedd4c2171150001c434f0b7a4 # v6 + uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6 with: context: . load: true @@ -96,7 +96,7 @@ jobs: - name: Docker meta id: meta - uses: docker/metadata-action@902fa8ec7d6ecbf8d84d538b9b233a880e428804 # v5 + uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5 if: github.event_name != 'pull_request' with: images: | @@ -110,7 +110,7 @@ jobs: - name: Build and push id: build-and-push - uses: docker/build-push-action@471d1dc4e07e5cdedd4c2171150001c434f0b7a4 # v6 + uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6 if: github.event_name != 'pull_request' with: context: . @@ -132,10 +132,23 @@ jobs: cosign sign --yes ${images} - name: Update repo description - uses: peter-evans/dockerhub-description@432a30c9e07499fd01da9f8a49f0faf9e0ca5b77 # v4 + uses: peter-evans/dockerhub-description@1b9a80c056b620d92cedb9d9b5a223409c68ddfa # v5 if: github.event_name != 'pull_request' continue-on-error: true with: username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} repository: vectorim/element-web + + - name: Repository Dispatch + uses: peter-evans/repository-dispatch@28959ce8df70de7be546dd1250a005dd32156697 # v4 + if: github.event_name != 'pull_request' + with: + repository: element-hq/element-web-pro + token: ${{ secrets.ELEMENT_BOT_TOKEN }} + event-type: image-built + # Stable way to determine the :version + client-payload: |- + { + "base-ref": "${{ fromJSON(steps.meta.outputs.json).labels['org.opencontainers.image.version'] }}" + } diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index e7d69cf477..9020f7914b 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -17,23 +17,23 @@ jobs: runs-on: ubuntu-24.04 steps: - name: Fetch element-desktop - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6 with: repository: element-hq/element-desktop path: element-desktop - name: Fetch element-web - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6 with: path: element-web - name: Fetch matrix-js-sdk - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6 with: repository: matrix-org/matrix-js-sdk path: matrix-js-sdk - - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 + - uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6 with: cache: "yarn" cache-dependency-path: element-web/yarn.lock @@ -43,13 +43,13 @@ jobs: working-directory: element-web run: | yarn install --frozen-lockfile - yarn ts-node ./scripts/gen-workflow-mermaid.ts ../element-desktop ../element-web ../matrix-js-sdk > docs/automations.md + yarn node ./scripts/gen-workflow-mermaid.ts ../element-desktop ../element-web ../matrix-js-sdk > docs/automations.md echo "- [Automations](automations.md)" >> docs/SUMMARY.md - name: Setup mdBook uses: peaceiris/actions-mdbook@ee69d230fe19748b7abf22df32acaa93833fad08 # v2 with: - mdbook-version: "0.4.10" + mdbook-version: "0.5.1" - name: Install mdbook extensions run: cargo install mdbook-combiner mdbook-mermaid @@ -88,7 +88,7 @@ jobs: run: mdbook build - name: Upload artifact - uses: actions/upload-pages-artifact@56afc609e74202658d3ffba0e8f6dda462b719fa # v3 + uses: actions/upload-pages-artifact@7b1f4a764d45c48632c6b24a0339c27f5614fb0b # v4 with: path: ./book diff --git a/.github/workflows/end-to-end-tests-netlify.yaml b/.github/workflows/end-to-end-tests-netlify.yaml index 90a8c3d24b..049f4ea343 100644 --- a/.github/workflows/end-to-end-tests-netlify.yaml +++ b/.github/workflows/end-to-end-tests-netlify.yaml @@ -25,7 +25,7 @@ jobs: actions: read steps: - name: Download HTML report - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 + uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6 with: github-token: ${{ secrets.GITHUB_TOKEN }} run-id: ${{ github.event.workflow_run.id }} diff --git a/.github/workflows/end-to-end-tests.yaml b/.github/workflows/end-to-end-tests.yaml index b2d49d7703..1b366333b6 100644 --- a/.github/workflows/end-to-end-tests.yaml +++ b/.github/workflows/end-to-end-tests.yaml @@ -50,25 +50,20 @@ jobs: runners-matrix: ${{ steps.runner-vars.outputs.matrix }} steps: - name: Checkout code - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6 with: repository: element-hq/element-web - - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 + - uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6 with: cache: "yarn" node-version: "lts/*" - name: Fetch layered build - id: layered_build env: # tell layered.sh to check out the right sha of the JS-SDK & EW, if they were given one JS_SDK_GITHUB_BASE_REF: ${{ inputs.matrix-js-sdk-sha }} - run: | - scripts/layered.sh - JSSDK_SHA=$(git -C matrix-js-sdk rev-parse --short=12 HEAD) - VECTOR_SHA=$(git rev-parse --short=12 HEAD) - echo "VERSION=$VECTOR_SHA--js-$JSSDK_SHA" >> $GITHUB_OUTPUT + run: scripts/layered.sh - name: Copy config run: cp element.io/develop/config.json config.json @@ -76,12 +71,10 @@ jobs: - name: Build env: CI_PACKAGE: true - VERSION: "${{ steps.layered_build.outputs.VERSION }}" - run: | - yarn build + run: VERSION=$(scripts/get-version-from-git.sh) yarn build - name: Upload Artifact - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5 with: name: webapp path: webapp @@ -89,7 +82,7 @@ jobs: - name: Calculate runner variables id: runner-vars - uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7 + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 with: script: | const numRunners = parseInt(process.env.NUM_RUNNERS, 10); @@ -129,18 +122,18 @@ jobs: - runAllTests: false project: Pinecone steps: - - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 + - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6 with: persist-credentials: false repository: element-hq/element-web - name: 📥 Download artifact - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 + uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6 with: name: webapp path: webapp - - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 + - uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6 with: cache: "yarn" cache-dependency-path: yarn.lock @@ -154,7 +147,7 @@ jobs: run: echo "version=$(yarn list --pattern @playwright/test --depth=0 --json --non-interactive --no-progress | jq -r '.data.trees[].name')" >> $GITHUB_OUTPUT - name: Cache playwright binaries - uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4 + uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4 id: playwright-cache with: path: ~/.cache/ms-playwright @@ -179,7 +172,7 @@ jobs: - name: Upload blob report to GitHub Actions Artifacts if: always() - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5 with: name: all-blob-reports-${{ matrix.project }}-${{ matrix.runner }} path: blob-report @@ -201,13 +194,13 @@ jobs: if: always() runs-on: ubuntu-24.04 steps: - - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 + - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6 if: inputs.skip != true with: persist-credentials: false repository: element-hq/element-web - - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 + - uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6 if: inputs.skip != true with: cache: "yarn" @@ -219,7 +212,7 @@ jobs: - name: Download blob reports from GitHub Actions Artifacts if: inputs.skip != true - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 + uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6 with: pattern: all-blob-reports-* path: all-blob-reports @@ -227,7 +220,7 @@ jobs: - name: Merge into HTML Report if: inputs.skip != true - run: yarn playwright merge-reports --reporter=html,./playwright/flaky-reporter.ts,./playwright/stale-screenshot-reporter.ts ./all-blob-reports + run: yarn playwright merge-reports --reporter=html,./playwright/flaky-reporter.ts,@element-hq/element-web-playwright-common/lib/stale-screenshot-reporter.js ./all-blob-reports env: # Only pass creds to the flaky-reporter on main branch runs GITHUB_TOKEN: ${{ github.ref_name == 'develop' && secrets.ELEMENT_BOT_TOKEN || '' }} @@ -235,7 +228,7 @@ jobs: # Upload the HTML report even if one of our reporters fails, this can happen when stale screenshots are detected - name: Upload HTML report if: always() && inputs.skip != true - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5 with: name: html-report path: playwright-report diff --git a/.github/workflows/issue_closed.yml b/.github/workflows/issue_closed.yml index 249f1eb342..375c2e7184 100644 --- a/.github/workflows/issue_closed.yml +++ b/.github/workflows/issue_closed.yml @@ -10,7 +10,7 @@ jobs: name: Tidy closed issues runs-on: ubuntu-24.04 steps: - - uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7 + - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 id: main with: # PAT needed as the GITHUB_TOKEN won't be able to see cross-references from other orgs (matrix-org) @@ -142,7 +142,7 @@ jobs: }); } } - - uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7 + - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 name: Close duplicate as Not Planned if: steps.main.outputs.closeAsNotPlanned with: diff --git a/.github/workflows/netlify.yaml b/.github/workflows/netlify.yaml index a7909265c7..ab2433c267 100644 --- a/.github/workflows/netlify.yaml +++ b/.github/workflows/netlify.yaml @@ -28,7 +28,7 @@ jobs: Exercise caution. Use test accounts. - name: 📥 Download artifact - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 + uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6 with: github-token: ${{ secrets.GITHUB_TOKEN }} run-id: ${{ github.event.workflow_run.id }} diff --git a/.github/workflows/pending-reviews.yaml b/.github/workflows/pending-reviews.yaml index 199eb60daa..0474e60aa0 100644 --- a/.github/workflows/pending-reviews.yaml +++ b/.github/workflows/pending-reviews.yaml @@ -16,7 +16,7 @@ jobs: URL: "https://github.com/pulls?q=is%3Apr+is%3Aopen+repo%3Amatrix-org%2Fmatrix-js-sdk+repo%3Amatrix-org%2Fmatrix-react-sdk+repo%3Aelement-hq%2Felement-web+repo%3Aelement-hq%2Felement-desktop+review-requested%3A%40me+sort%3Aupdated-desc+" RELEASE_BLOCKERS_URL: "https://github.com/pulls?q=is%3Aopen+repo%3Amatrix-org%2Fmatrix-js-sdk+repo%3Amatrix-org%2Fmatrix-react-sdk+repo%3Aelement-hq%2Felement-web+repo%3Aelement-hq%2Felement-desktop+sort%3Aupdated-desc+label%3AX-Release-Blocker+" steps: - - uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7 + - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 env: HS_URL: ${{ secrets.BETABOT_HS_URL }} ROOM_ID: ${{ secrets.ROOM_ID }} diff --git a/.github/workflows/playwright-image-updates.yaml b/.github/workflows/playwright-image-updates.yaml index 4cbdb17bbd..1ce3d767a7 100644 --- a/.github/workflows/playwright-image-updates.yaml +++ b/.github/workflows/playwright-image-updates.yaml @@ -10,7 +10,7 @@ jobs: permissions: pull-requests: write steps: - - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 + - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6 - name: Update synapse image run: | @@ -21,9 +21,18 @@ jobs: env: IMAGE: ghcr.io/element-hq/synapse:develop + - name: Update MAS image + run: | + docker pull "$IMAGE" + INSPECT=$(docker inspect --format='{{index .RepoDigests 0}}' "$IMAGE") + DIGEST=${INSPECT#*@} + sed -i "s/const TAG.*/const TAG = \"main@$DIGEST\";/" playwright/testcontainers/mas.ts + env: + IMAGE: ghcr.io/element-hq/matrix-authentication-service:main + - name: Create Pull Request id: cpr - uses: peter-evans/create-pull-request@271a8d0340265f705b14b6d32b9829c1cb33d45e # v7 + uses: peter-evans/create-pull-request@22a9089034f40e5a961c8808d113e2c98fb63676 # v7 with: token: ${{ secrets.ELEMENT_BOT_TOKEN }} branch: actions/playwright-image-updates diff --git a/.github/workflows/pull_request_base_branch.yaml b/.github/workflows/pull_request_base_branch.yaml index fbdebfbed0..e79c37783b 100644 --- a/.github/workflows/pull_request_base_branch.yaml +++ b/.github/workflows/pull_request_base_branch.yaml @@ -8,7 +8,7 @@ jobs: name: Check PR base branch runs-on: ubuntu-24.04 steps: - - uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7 + - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 with: script: | const baseBranch = context.payload.pull_request.base.ref; diff --git a/.github/workflows/release_prepare.yml b/.github/workflows/release_prepare.yml index 2f36644c2e..7be70e6b00 100644 --- a/.github/workflows/release_prepare.yml +++ b/.github/workflows/release_prepare.yml @@ -41,7 +41,7 @@ jobs: REPOS: matrix-js-sdk element-web element-desktop steps: - name: Checkout Element Desktop - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6 if: inputs.element-desktop with: repository: element-hq/element-desktop @@ -51,7 +51,7 @@ jobs: fetch-tags: true token: ${{ secrets.ELEMENT_BOT_TOKEN }} - name: Checkout Element Web - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6 if: inputs.element-web with: repository: element-hq/element-web @@ -61,7 +61,7 @@ jobs: fetch-tags: true token: ${{ secrets.ELEMENT_BOT_TOKEN }} - name: Checkout Matrix JS SDK - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6 if: inputs.matrix-js-sdk with: repository: matrix-org/matrix-js-sdk diff --git a/.github/workflows/shared-component-publish.yaml b/.github/workflows/shared-component-publish.yaml new file mode 100644 index 0000000000..6869c17660 --- /dev/null +++ b/.github/workflows/shared-component-publish.yaml @@ -0,0 +1,40 @@ +name: Publish shared component npm package +on: + workflow_dispatch: {} + +concurrency: release +jobs: + publish: + name: "Publish" + runs-on: ubuntu-latest + permissions: + contents: write + id-token: write + + steps: + - name: 🧮 Checkout code + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6 + + - name: 🔧 Set up node environment + uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6 + with: + cache: "yarn" + node-version-file: ".node-version" + registry-url: "https://registry.npmjs.org" + + # Ensure npm 11.5.1 or later is installed + - name: Update npm + run: npm install -g npm@latest + + # Need to setup element web too as it needs the translations + - name: 🛠️ Setup EW + run: yarn install --pure-lockfile + + - name: 🛠️ Setup + # When running `install` it also calls the `prepare` step which generates + # a build + run: yarn --cwd packages/shared-components install --pure-lockfile + + - name: 🚀 Publish to npm + working-directory: packages/shared-components + run: npm publish --access public --tag test --provenance diff --git a/.github/workflows/shared-component-visual-tests-netlify.yaml b/.github/workflows/shared-component-visual-tests-netlify.yaml new file mode 100644 index 0000000000..816d899836 --- /dev/null +++ b/.github/workflows/shared-component-visual-tests-netlify.yaml @@ -0,0 +1,51 @@ +# Triggers after the shared component tests have finished, +# It uploads the received images and diffs to netlify, printing the URLs to the console +name: Upload Shared Component Visual Test Diffs +on: + workflow_run: + workflows: ["Shared Component Visual Tests"] + types: + - completed + +concurrency: + group: ${{ github.workflow }}-${{ github.event.workflow_run.head_branch || github.run_id }} + cancel-in-progress: ${{ github.event.workflow_run.event == 'pull_request' }} + +permissions: {} + +jobs: + report: + if: github.event.workflow_run.conclusion == 'failure' + name: Upload Diffs + runs-on: ubuntu-24.04 + environment: Netlify + permissions: + actions: read + deployments: write + steps: + - name: Install tree + run: "sudo apt-get install -y tree" + + - name: Download Diffs + uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6 + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + run-id: ${{ github.event.workflow_run.id }} + name: received-images + path: received-images + + - name: Generate Index + run: "cd received-images && tree -L 1 --noreport -H '' -o index.html ." + + - name: 📤 Deploy to Netlify + uses: matrix-org/netlify-pr-preview@9805cd123fc9a7e421e35340a05e1ebc5dee46b5 # v3 + with: + path: received-images + owner: ${{ github.event.workflow_run.head_repository.owner.login }} + branch: ${{ github.event.workflow_run.head_branch }} + revision: ${{ github.event.workflow_run.head_sha }} + token: ${{ secrets.NETLIFY_AUTH_TOKEN }} + site_id: ${{ vars.NETLIFY_SITE_ID }} + desc: Shared Component Visual Diffs + deployment_env: SharedComponentDiffs + prefix: "diffs-" diff --git a/.github/workflows/shared-component-visual-tests.yaml b/.github/workflows/shared-component-visual-tests.yaml new file mode 100644 index 0000000000..8f9cbdae31 --- /dev/null +++ b/.github/workflows/shared-component-visual-tests.yaml @@ -0,0 +1,66 @@ +name: Shared Component Visual Tests +on: + pull_request: {} + merge_group: + types: [checks_requested] + push: + branches: [develop, master] + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }}-${{ github.event_name }} + cancel-in-progress: true + +permissions: {} # No permissions required + +jobs: + testStorybook: + name: "Run Visual Tests" + runs-on: ubuntu-24.04 + permissions: + actions: read + issues: read + pull-requests: read + steps: + - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6 + with: + persist-credentials: false + repository: element-hq/element-web + + - uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6 + with: + cache: "yarn" + node-version: "lts/*" + + - name: Install element web dependencies + run: yarn install --frozen-lockfile + + - name: Install dependencies + working-directory: packages/shared-components + run: yarn install --frozen-lockfile + + - name: Get installed Playwright version + working-directory: packages/shared-components + id: playwright + run: echo "version=$(yarn list --pattern @playwright/test --depth=0 --json --non-interactive --no-progress | jq -r '.data.trees[].name')" >> $GITHUB_OUTPUT + + - name: Cache playwright binaries + uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4 + id: playwright-cache + with: + path: ~/.cache/ms-playwright + key: ${{ runner.os }}-${{ runner.arch }}-playwright-${{ steps.playwright.outputs.version }}-onlyshell + + - name: Install Playwright browsers + working-directory: packages/shared-components + if: steps.playwright-cache.outputs.cache-hit != 'true' + run: "yarn playwright install --with-deps --only-shell" + + - name: Run Visual tests + run: "yarn --cwd packages/shared-components test:storybook:ci" + + - name: Upload received images & diffs + if: always() + uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5 + with: + name: received-images + path: packages/shared-components/playwright/shared-component-received diff --git a/.github/workflows/static_analysis.yaml b/.github/workflows/static_analysis.yaml index d63e0da8ed..9f90572371 100644 --- a/.github/workflows/static_analysis.yaml +++ b/.github/workflows/static_analysis.yaml @@ -12,8 +12,7 @@ concurrency: cancel-in-progress: true env: - # These must be set for fetchdep.sh to get the right branch - REPOSITORY: ${{ github.repository }} + # This must be set for fetchdep.sh to get the right branch PR_NUMBER: ${{ github.event.pull_request.number }} permissions: {} # No permissions required @@ -23,9 +22,9 @@ jobs: name: "Typescript Syntax Check" runs-on: ubuntu-24.04 steps: - - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 + - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6 - - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 + - uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6 with: cache: "yarn" node-version: "lts/*" @@ -36,6 +35,12 @@ jobs: - name: Typecheck run: "yarn run lint:types" + - name: Install Shared Component Dependencies + run: "yarn --cwd packages/shared-components install" + + - name: Typecheck Shared Components + run: "yarn --cwd packages/shared-components run lint:types" + i18n_lint: name: "i18n Check" uses: matrix-org/matrix-web-i18n/.github/workflows/i18n_check.yml@main @@ -52,12 +57,13 @@ jobs: error|misconfigured welcome_to_element devtools|settings|elementCallUrl + labs|sliding_sync_description rethemendex_lint: name: "Rethemendex Check" runs-on: ubuntu-24.04 steps: - - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 + - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6 - run: ./res/css/rethemendex.sh @@ -67,9 +73,9 @@ jobs: name: "ESLint" runs-on: ubuntu-24.04 steps: - - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 + - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6 - - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 + - uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6 with: cache: "yarn" node-version: "lts/*" @@ -81,13 +87,19 @@ jobs: - name: Run Linter run: "yarn run lint:js" + - name: Install Shared Component Deps + run: "yarn --cwd packages/shared-components install --frozen-lockfile" + + - name: Run Linter + run: "yarn --cwd packages/shared-components run lint:js" + style_lint: name: "Style Lint" runs-on: ubuntu-24.04 steps: - - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 + - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6 - - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 + - uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6 with: cache: "yarn" node-version: "lts/*" @@ -103,9 +115,9 @@ jobs: name: "Workflow Lint" runs-on: ubuntu-24.04 steps: - - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 + - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6 - - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 + - uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6 with: cache: "yarn" node-version: "lts/*" @@ -121,9 +133,9 @@ jobs: name: "Analyse Dead Code" runs-on: ubuntu-24.04 steps: - - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 + - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6 - - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 + - uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6 with: cache: "yarn" node-version: "lts/*" diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 276c53c098..95382b180c 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -29,8 +29,8 @@ env: permissions: {} jobs: - jest: - name: Jest + jest_ew: + name: Jest (Element Web) runs-on: ubuntu-24.04 strategy: fail-fast: false @@ -39,12 +39,12 @@ jobs: runner: [1, 2] steps: - name: Checkout code - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6 with: repository: ${{ inputs.matrix-js-sdk-sha && 'element-hq/element-web' || github.repository }} - name: Yarn cache - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 + uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6 with: node-version: "lts/*" cache: "yarn" @@ -55,7 +55,7 @@ jobs: JS_SDK_GITHUB_BASE_REF: ${{ inputs.matrix-js-sdk-sha }} - name: Jest Cache - uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4 + uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4 with: path: /tmp/jest_cache key: ${{ hashFiles('**/yarn.lock') }} @@ -84,7 +84,7 @@ jobs: - name: Upload Artifact if: env.ENABLE_COVERAGE == 'true' - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5 with: name: coverage-${{ matrix.runner }} path: | @@ -93,18 +93,18 @@ jobs: complete: name: jest-tests - needs: jest + needs: jest_ew if: always() runs-on: ubuntu-24.04 permissions: statuses: write steps: - - if: needs.jest.result != 'skipped' && needs.jest.result != 'success' + - if: needs.jest_ew.result != 'skipped' && needs.jest_ew.result != 'success' run: exit 1 - name: Skip SonarCloud in merge queue if: github.event_name == 'merge_group' || inputs.disable_coverage == 'true' - uses: guibranco/github-status-action-v2@5f2b01ce1394109f70954ae6b69ef41cf7928e63 + uses: guibranco/github-status-action-v2@5530c593759f489bba08272e96986ffc571c1ea1 with: authToken: ${{ secrets.GITHUB_TOKEN }} state: success @@ -112,3 +112,56 @@ jobs: context: SonarCloud Code Analysis sha: ${{ github.sha }} target_url: https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }} + + jest_sc: + name: Jest (Shared Components) + runs-on: ubuntu-24.04 + steps: + - name: Checkout code + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6 + with: + repository: ${{ inputs.matrix-js-sdk-sha && 'element-hq/element-web' || github.repository }} + + - name: Yarn cache + uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6 + with: + node-version: "lts/*" + cache: "yarn" + + - name: Install EW Deps + run: "yarn install" + + - name: Install Shared Component Deps + working-directory: "packages/shared-components" + run: "yarn install" + + - name: Jest Cache + uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4 + with: + path: /tmp/jest_cache + key: ${{ hashFiles('**/yarn.lock') }} + + - name: Get number of CPU cores + id: cpu-cores + uses: SimenB/github-actions-cpu-cores@97ba232459a8e02ff6121db9362b09661c875ab8 # v2 + + - name: Run tests + working-directory: "packages/shared-components" + run: | + yarn test \ + --coverage=${{ env.ENABLE_COVERAGE }} \ + --ci \ + --max-workers ${{ steps.cpu-cores.outputs.count }} \ + --cacheDirectory /tmp/jest_cache + env: + # tell jest to use coloured output + FORCE_COLOR: true + + - name: Upload Artifact + if: env.ENABLE_COVERAGE == 'true' + uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5 + with: + name: coverage-sharedcomponents + path: | + packages/shared-components/coverage + !packages/shared-components/coverage/lcov-report diff --git a/.github/workflows/triage-assigned.yml b/.github/workflows/triage-assigned.yml index f190122a1c..b16f626c15 100644 --- a/.github/workflows/triage-assigned.yml +++ b/.github/workflows/triage-assigned.yml @@ -15,7 +15,7 @@ jobs: contains(github.event.issue.assignees.*.login, 'dbkr') || contains(github.event.issue.assignees.*.login, 'MidhunSureshR') steps: - - uses: actions/add-to-project@main + - uses: actions/add-to-project@244f685bbc3b7adfa8466e08b698b5577571133e # v1.0.2 with: project-url: https://github.com/orgs/element-hq/projects/67 github-token: ${{ secrets.ELEMENT_BOT_TOKEN }} diff --git a/.github/workflows/triage-incoming.yml b/.github/workflows/triage-incoming.yml index b084b4d55e..d81322bc8c 100644 --- a/.github/workflows/triage-incoming.yml +++ b/.github/workflows/triage-incoming.yml @@ -10,7 +10,7 @@ jobs: automate-project-columns: runs-on: ubuntu-24.04 steps: - - uses: actions/add-to-project@main + - uses: actions/add-to-project@244f685bbc3b7adfa8466e08b698b5577571133e # v1.0.2 with: project-url: https://github.com/orgs/element-hq/projects/120 github-token: ${{ secrets.ELEMENT_BOT_TOKEN }} diff --git a/.github/workflows/triage-labelled.yml b/.github/workflows/triage-labelled.yml index e1849e0efc..496cfd53df 100644 --- a/.github/workflows/triage-labelled.yml +++ b/.github/workflows/triage-labelled.yml @@ -27,7 +27,7 @@ jobs: contains(github.event.issue.labels.*.name, 'A-Rich-Text-Editor') || contains(github.event.issue.labels.*.name, 'A-Element-Call') steps: - - uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7 + - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 with: script: | github.rest.issues.addLabels({ @@ -44,7 +44,7 @@ jobs: contains(github.event.issue.labels.*.name, 'good first issue') || contains(github.event.issue.labels.*.name, 'Hacktoberfest') steps: - - uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7 + - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 with: script: | github.rest.issues.addLabels({ @@ -112,7 +112,7 @@ jobs: contains(github.event.issue.labels.*.name, 'O-Frequent') || contains(github.event.issue.labels.*.name, 'A11y')) steps: - - uses: actions/add-to-project@main + - uses: actions/add-to-project@244f685bbc3b7adfa8466e08b698b5577571133e # v1.0.2 with: project-url: https://github.com/orgs/element-hq/projects/18 github-token: ${{ secrets.ELEMENT_BOT_TOKEN }} @@ -123,7 +123,7 @@ jobs: if: > contains(github.event.issue.labels.*.name, 'X-Needs-Product') steps: - - uses: actions/add-to-project@main + - uses: actions/add-to-project@244f685bbc3b7adfa8466e08b698b5577571133e # v1.0.2 with: project-url: https://github.com/orgs/element-hq/projects/28 github-token: ${{ secrets.ELEMENT_BOT_TOKEN }} @@ -134,7 +134,7 @@ jobs: if: > contains(github.event.issue.labels.*.name, 'A-New-Search-Experience') steps: - - uses: actions/add-to-project@main + - uses: actions/add-to-project@244f685bbc3b7adfa8466e08b698b5577571133e # v1.0.2 with: project-url: https://github.com/orgs/element-hq/projects/48 github-token: ${{ secrets.ELEMENT_BOT_TOKEN }} @@ -145,7 +145,7 @@ jobs: if: > contains(github.event.issue.labels.*.name, 'Team: VoIP') steps: - - uses: actions/add-to-project@main + - uses: actions/add-to-project@244f685bbc3b7adfa8466e08b698b5577571133e # v1.0.2 with: project-url: https://github.com/orgs/element-hq/projects/41 github-token: ${{ secrets.ELEMENT_BOT_TOKEN }} @@ -156,7 +156,7 @@ jobs: if: > contains(github.event.issue.labels.*.name, 'Team: Crypto') steps: - - uses: actions/add-to-project@main + - uses: actions/add-to-project@244f685bbc3b7adfa8466e08b698b5577571133e # v1.0.2 with: project-url: https://github.com/orgs/element-hq/projects/76 github-token: ${{ secrets.ELEMENT_BOT_TOKEN }} @@ -172,7 +172,7 @@ jobs: contains(github.event.issue.labels.*.name, 'A-Testing') || contains(github.event.issue.labels.*.name, 'Z-Flaky-Test') steps: - - uses: actions/add-to-project@main + - uses: actions/add-to-project@244f685bbc3b7adfa8466e08b698b5577571133e # v1.0.2 with: project-url: https://github.com/orgs/element-hq/projects/101 github-token: ${{ secrets.ELEMENT_BOT_TOKEN }} diff --git a/.github/workflows/triage-move-review-requests.yml b/.github/workflows/triage-move-review-requests.yml index d3bcda270b..4d8745f4bd 100644 --- a/.github/workflows/triage-move-review-requests.yml +++ b/.github/workflows/triage-move-review-requests.yml @@ -9,7 +9,7 @@ jobs: name: Move PRs asking for design review to the design board runs-on: ubuntu-24.04 steps: - - uses: octokit/graphql-action@v2.x + - uses: octokit/graphql-action@abaeca7ba4f0325d63b8de7ef943c2418d161b93 # v3.0.0 id: find_team_members with: headers: '{"GraphQL-Features": "projects_next_graphql"}' @@ -52,7 +52,7 @@ jobs: fi env: TEAM: "design" - - uses: octokit/graphql-action@v2.x + - uses: octokit/graphql-action@abaeca7ba4f0325d63b8de7ef943c2418d161b93 # v3.0.0 id: add_to_project if: steps.any_matching_reviewers.outputs.match == 'true' with: @@ -76,7 +76,7 @@ jobs: name: Move PRs asking for design review to the design board runs-on: ubuntu-24.04 steps: - - uses: octokit/graphql-action@v2.x + - uses: octokit/graphql-action@abaeca7ba4f0325d63b8de7ef943c2418d161b93 # v3.0.0 id: find_team_members with: headers: '{"GraphQL-Features": "projects_next_graphql"}' @@ -119,7 +119,7 @@ jobs: fi env: TEAM: "product" - - uses: octokit/graphql-action@v2.x + - uses: octokit/graphql-action@abaeca7ba4f0325d63b8de7ef943c2418d161b93 # v3.0.0 id: add_to_project if: steps.any_matching_reviewers.outputs.match == 'true' with: diff --git a/.github/workflows/triage-stale.yml b/.github/workflows/triage-stale.yml index f76cd299cc..c7a9d1ba31 100644 --- a/.github/workflows/triage-stale.yml +++ b/.github/workflows/triage-stale.yml @@ -12,15 +12,17 @@ jobs: issues: write pull-requests: write steps: - - uses: actions/stale@5bef64f19d7facfb25b37b414482c7164d639639 # v9 + - uses: actions/stale@997185467fa4f803885201cee163a9f38240193d # v10 with: operations-per-run: 100 + # Flaky test issue closing - only-issue-labels: "Z-Flaky-Test" + any-of-issue-labels: "Z-Flaky-Test-Chrome,Z-Flaky-Test-Firefox,Z-Flaky-Test-Webkit" days-before-issue-stale: 14 days-before-issue-close: 0 close-issue-message: "This flaky test issue has not been updated in 14 days. It is being closed as presumed resolved." exempt-issue-labels: "Z-Flaky-Test-Disabled" + # Stale PR closing days-before-pr-stale: 180 days-before-pr-close: 0 diff --git a/.github/workflows/triage-unlabelled.yml b/.github/workflows/triage-unlabelled.yml index d3bda6df1f..71396be804 100644 --- a/.github/workflows/triage-unlabelled.yml +++ b/.github/workflows/triage-unlabelled.yml @@ -5,44 +5,25 @@ on: types: [unlabeled] permissions: {} jobs: - Move_Unabeled_Issue_On_Project_Board: + move_no_longer_needs_info_issues: name: Move no longer X-Needs-Info issues to Triaged runs-on: ubuntu-24.04 - permissions: - repository-projects: read if: > - ${{ - !contains(github.event.issue.labels.*.name, 'X-Needs-Info') }} - env: - BOARD_NAME: "Issue triage" - OWNER: ${{ github.repository_owner }} - REPO: ${{ github.event.repository.name }} - ISSUE: ${{ github.event.issue.number }} + !contains(github.event.issue.labels.*.name, 'X-Needs-Info') steps: - - name: Check if issue is already in "${{ env.BOARD_NAME }}" - run: | - json=$(curl -s -H 'Content-Type: application/json' -H "Authorization: bearer ${{ secrets.GITHUB_TOKEN }}" -X POST -d '{"query": "query($issue: Int!, $owner: String!, $repo: String!) { repository(owner: $owner, name: $repo) { issue(number: $issue) { projectCards { nodes { project { name } isArchived } } } } } ", "variables" : "{ \"issue\": '${ISSUE}', \"owner\": \"'${OWNER}'\", \"repo\": \"'${REPO}'\" }" }' https://api.github.com/graphql) - if echo $json | jq '.data.repository.issue.projectCards.nodes | length'; then - if [[ $(echo $json | jq '.data.repository.issue.projectCards.nodes[0].project.name') =~ "${BOARD_NAME}" ]]; then - if [[ $(echo $json | jq '.data.repository.issue.projectCards.nodes[0].isArchived') == 'true' ]]; then - echo "Issue is already in Project '$BOARD_NAME', but is archived - skipping workflow"; - echo "SKIP_ACTION=true" >> $GITHUB_ENV - else - echo "Issue is already in Project '$BOARD_NAME', proceeding"; - echo "ALREADY_IN_BOARD=true" >> $GITHUB_ENV - fi - else - echo "Issue is not in project '$BOARD_NAME', cancelling this workflow" - echo "ALREADY_IN_BOARD=false" >> $GITHUB_ENV - fi - fi - - name: Move issue - uses: alex-page/github-project-automation-plus@303f24a24c67ce7adf565a07e96720faf126fe36 - if: ${{ env.ALREADY_IN_BOARD == 'true' && env.SKIP_ACTION != 'true' }} + - id: set_fields + uses: nipe0324/update-project-v2-item-field@c4af58452d1c5a788c1ea4f20e073fa722ec4a6b #v2.0.2 with: - project: Issue triage - column: Triaged - repo-token: ${{ secrets.ELEMENT_BOT_TOKEN }} + project-url: ${{ env.PROJECT_URL }} + github-token: ${{ secrets.ELEMENT_BOT_TOKEN }} + skip-update-script: | + const isIssue = item.type === 'ISSUE' + const status = item.fieldValues['Status'] + return !isIssue || status !== 'Needs info' + field-name: Status + field-value: "Triaged" + env: + PROJECT_URL: https://github.com/orgs/element-hq/projects/120 remove_Z-Labs_label: name: Remove Z-Labs label when features behind labs flags are removed @@ -62,7 +43,7 @@ jobs: contains(github.event.issue.labels.*.name, 'A-Element-Call')) && contains(github.event.issue.labels.*.name, 'Z-Labs') steps: - - uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7 + - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 with: script: | github.rest.issues.removeLabel({ diff --git a/.github/workflows/update-jitsi.yml b/.github/workflows/update-jitsi.yml index da386c544d..67e3fb19c5 100644 --- a/.github/workflows/update-jitsi.yml +++ b/.github/workflows/update-jitsi.yml @@ -9,9 +9,9 @@ jobs: update: runs-on: ubuntu-24.04 steps: - - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 + - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6 - - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 + - uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6 with: cache: "yarn" node-version: "lts/*" @@ -23,7 +23,7 @@ jobs: run: "yarn update:jitsi" - name: Create Pull Request - uses: peter-evans/create-pull-request@271a8d0340265f705b14b6d32b9829c1cb33d45e # v7 + uses: peter-evans/create-pull-request@22a9089034f40e5a961c8808d113e2c98fb63676 # v7 with: token: ${{ secrets.ELEMENT_BOT_TOKEN }} branch: actions/jitsi-update diff --git a/.github/workflows/update-topics.yaml b/.github/workflows/update-topics.yaml index 5ee9f2b608..c1fb78e3b8 100644 --- a/.github/workflows/update-topics.yaml +++ b/.github/workflows/update-topics.yaml @@ -22,17 +22,17 @@ jobs: runs-on: ubuntu-24.04 environment: Matrix steps: - - uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7 + - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 env: HS_URL: ${{ secrets.BETABOT_HS_URL }} LOBBY_ROOM_ID: ${{ secrets.ROOM_ID }} - PUBLIC_ROOM_ID: "!YTvKGNlinIzlkMTVRl:matrix.org" - ANNOUNCEMENT_ROOM_ID: "!bijaLdadorKgNGtHdA:matrix.org" + PUBLIC_DISCUSSION_ROOM_ID: "!xUW4PpAe1CmThA3r2wI8IrgwwsK006-zqWdJCljpd10" + ANNOUNCEMENT_ROOM_ID: "!ars5ndgI6IIYZXECiJ-u8YljHNzShJn3nHdB-3rYI2M" TOKEN: ${{ secrets.BETABOT_ACCESS_TOKEN }} RELEASE_STATUS: "Release status: ${{ inputs.expected_status }} expected ${{ inputs.expected_date }}" with: script: | - const { HS_URL, TOKEN, RELEASE_STATUS, LOBBY_ROOM_ID, PUBLIC_ROOM_ID, ANNOUNCEMENT_ROOM_ID } = process.env; + const { HS_URL, TOKEN, RELEASE_STATUS, LOBBY_ROOM_ID, PUBLIC_DISCUSSION_ROOM_ID, ANNOUNCEMENT_ROOM_ID } = process.env; const repo = context.repo; const { data } = await github.rest.repos.getLatestRelease({ @@ -71,13 +71,23 @@ jobs: const data = await res.json(); console.log(roomId, "got event", data); + if (!regex.test(data.topic)) { + core.setFailed("Topic format is incorrect for room " + roomId); + return; + } + const topic = data.topic.replace(regex, releaseTopic); if (topic === data.topic) { console.log(roomId, "nothing to do"); return; } if (data["org.matrix.msc3765.topic"]) { - data["org.matrix.msc3765.topic"].forEach(d => { + data["org.matrix.msc3765.topic"]?.["m.text"].forEach(d => { + d.body = d.body.replace(regex, releaseTopic); + }); + } + if (data["m.topic"]) { + data["m.topic"]?.["m.text"].forEach(d => { d.body = d.body.replace(regex, releaseTopic); }); } @@ -92,12 +102,18 @@ jobs: }); if (res.ok) { - console.log(roomId, "topic updated:", topic); + const resJson = res.json(); + if (resJson.errcode) { + core.setFailed(`Error updating ${roomId}: ${resJson.error}`); + } else { + console.log(roomId, "topic updated:", topic); + } } else { - console.log(roomId, await res.text()); + const errText = await res.text(); + core.setFailed(`Error updating ${roomId}: ${errText}`); } } await updateReleaseInTopic(LOBBY_ROOM_ID); - await updateReleaseInTopic(PUBLIC_ROOM_ID); + await updateReleaseInTopic(PUBLIC_DISCUSSION_ROOM_ID); await updateReleaseInTopic(ANNOUNCEMENT_ROOM_ID); diff --git a/.gitignore b/.gitignore index 429b317a4f..3d6d723ac3 100644 --- a/.gitignore +++ b/.gitignore @@ -4,7 +4,6 @@ /key.pem /lib /node_modules -/packages/ /webapp /.npmrc /*.log @@ -31,3 +30,10 @@ electron/pub /index.html # version file and tarball created by `npm pack` / `yarn pack` /git-revision.txt + +*storybook.log +storybook-static + +/packages/shared-components/node_modules +/packages/shared-components/dist +/packages/shared-components/src/i18nKeys.d.ts diff --git a/.node-version b/.node-version index 2bd5a0a98a..a45fd52cc5 100644 --- a/.node-version +++ b/.node-version @@ -1 +1 @@ -22 +24 diff --git a/.prettierignore b/.prettierignore index 46b1ac5b54..abaa4d3df3 100644 --- a/.prettierignore +++ b/.prettierignore @@ -2,7 +2,6 @@ /dist /lib /node_modules -/packages/ /webapp /*.log yarn.lock diff --git a/.stylelintrc.js b/.stylelintrc.js index ffc6c345b9..3244d122c5 100644 --- a/.stylelintrc.js +++ b/.stylelintrc.js @@ -70,5 +70,13 @@ module.exports = { ], }, ], + "property-no-deprecated": [ + true, + { + ignoreProperties: ["-webkit-box-orient", "word-wrap"], + }, + ], + "nesting-selector-no-missing-scoping-root": null, + "no-invalid-position-declaration": null, }, }; diff --git a/AUTHORS.rst b/AUTHORS.rst index d027b59c99..a3f3be6586 100644 --- a/AUTHORS.rst +++ b/AUTHORS.rst @@ -19,3 +19,6 @@ include: * Thom Cleary (https://github.com/thomcatdotrocks) Small update for tarball deployment + +* Alexander (https://github.com/ioalexander) + Save image on CTRL + S shortcut diff --git a/CHANGELOG.md b/CHANGELOG.md index 48a721c013..43546c1817 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,444 @@ +Changes in [1.12.7](https://github.com/element-hq/element-web/releases/tag/v1.12.7) (2025-12-16) +================================================================================================ +## ✨ Features + +* Replace legacy icons with compound ([#31424](https://github.com/element-hq/element-web/pull/31424)). Contributed by @t3chguy. +* Update polls UX to match EX Mobile and improve accessibility ([#31245](https://github.com/element-hq/element-web/pull/31245)). Contributed by @langleyd. +* Add option to enable read receipt and marker when user interact with UI ([#31353](https://github.com/element-hq/element-web/pull/31353)). Contributed by @florianduros. +* Introduce a hook to auto dispose view models ([#31178](https://github.com/element-hq/element-web/pull/31178)). Contributed by @MidhunSureshR. +* Update settings toggles to use consistent design across app. ([#30169](https://github.com/element-hq/element-web/pull/30169)). Contributed by @Half-Shot. +* Add ability to the room view to hide widgets ([#31400](https://github.com/element-hq/element-web/pull/31400)). Contributed by @langleyd. +* call: Pass the echo cancellation and noise suppression settings to EC ([#31317](https://github.com/element-hq/element-web/pull/31317)). Contributed by @BillCarsonFr. +* Tweak rendering of icons for a11y ([#31358](https://github.com/element-hq/element-web/pull/31358)). Contributed by @t3chguy. +* Implement new `renderNotificationDecoration` from module API ([#31389](https://github.com/element-hq/element-web/pull/31389)). Contributed by @MidhunSureshR. +* Replace more icons with compound ([#31381](https://github.com/element-hq/element-web/pull/31381)). Contributed by @t3chguy. +* Replace more icons with compound ([#31378](https://github.com/element-hq/element-web/pull/31378)). Contributed by @t3chguy. +* ``: Hide `Dismiss` button if `onClose` handler is not provided. ([#31362](https://github.com/element-hq/element-web/pull/31362)). Contributed by @kaylendog. +* Replace batch of legacy icons with compound design tokens ([#31360](https://github.com/element-hq/element-web/pull/31360)). Contributed by @t3chguy. +* MSC4380: Invite blocking ([#31268](https://github.com/element-hq/element-web/pull/31268)). Contributed by @richvdh. +* Tweak rendering of icons for accessibility ([#31346](https://github.com/element-hq/element-web/pull/31346)). Contributed by @t3chguy. +* Implement a shared `Banner` component. ([#31266](https://github.com/element-hq/element-web/pull/31266)). Contributed by @kaylendog. +* Allow the Login screen to use the dark theme ([#31293](https://github.com/element-hq/element-web/pull/31293)). Contributed by @richvdh. + +## 🐛 Bug Fixes + +* [Backport staging] Amend e2e normal icon from lock-solid to info ([#31559](https://github.com/element-hq/element-web/pull/31559)). Contributed by @t3chguy. +* [Backport staging] Fix CSS specificity causing icon issues in e2e verification ([#31548](https://github.com/element-hq/element-web/pull/31548)). Contributed by @RiotRobot. +* [Backport staging] Fix e2e icons in CompleteSecurity \& SetupEncryptionBody ([#31522](https://github.com/element-hq/element-web/pull/31522)). Contributed by @RiotRobot. +* [Backport staging] Remove an extra paragraph in advanced room settings ([#31511](https://github.com/element-hq/element-web/pull/31511)). Contributed by @RiotRobot. +* [Backport staging] Don't show the key storage out of sync toast when backup disabled ([#31507](https://github.com/element-hq/element-web/pull/31507)). Contributed by @RiotRobot. +* Fix composer button visibility in contrast colour mode ([#31255](https://github.com/element-hq/element-web/pull/31255)). Contributed by @t3chguy. +* Ensure correct room version is used and permissions are appropriately sert when creating rooms ([#31464](https://github.com/element-hq/element-web/pull/31464)). Contributed by @Half-Shot. +* Fix e2e icon rendering ([#31454](https://github.com/element-hq/element-web/pull/31454)). Contributed by @t3chguy. +* EventIndexer: ensure we add initial checkpoints when the db is first opened ([#31448](https://github.com/element-hq/element-web/pull/31448)). Contributed by @richvdh. +* Fix `/join ` command failing due to race condition ([#31433](https://github.com/element-hq/element-web/pull/31433)). Contributed by @MidhunSureshR. +* MessageEventIndexDialog: distinguish indexed rooms ([#31436](https://github.com/element-hq/element-web/pull/31436)). Contributed by @richvdh. +* Move `EditInPlace` out of `Form` (Fixes: reloading EW on EC url update) ([#31434](https://github.com/element-hq/element-web/pull/31434)). Contributed by @toger5. +* Fixes issue where cursor would jump to the beginning of the input field after converting Japanese text and pressing Tab ([#31432](https://github.com/element-hq/element-web/pull/31432)). Contributed by @shinaoka. +* Fix widgets getting stuck in loading states ([#31314](https://github.com/element-hq/element-web/pull/31314)). Contributed by @robintown. +* Room list: fix room options remaining on room item after mouse leaving ([#31414](https://github.com/element-hq/element-web/pull/31414)). Contributed by @florianduros. +* Make `RoomList.showMessagePreview` configurable by `config.json` ([#31419](https://github.com/element-hq/element-web/pull/31419)). Contributed by @florianduros. +* Fix bug which caused app not to load correctly when `force_verification` is enabled ([#31265](https://github.com/element-hq/element-web/pull/31265)). Contributed by @richvdh. +* Room list: display the menu option on the room list item when clicked/opened ([#31380](https://github.com/element-hq/element-web/pull/31380)). Contributed by @florianduros. +* Fix handling of SVGs ([#31359](https://github.com/element-hq/element-web/pull/31359)). Contributed by @t3chguy. +* Fix word wrapping in expanded left panel buttons ([#31377](https://github.com/element-hq/element-web/pull/31377)). Contributed by @t3chguy. +* Fix aspect ratio on error view background ([#31361](https://github.com/element-hq/element-web/pull/31361)). Contributed by @t3chguy. +* Fix failure to request persistent storage perms ([#31299](https://github.com/element-hq/element-web/pull/31299)). Contributed by @richvdh. +* Fix calls sometimes not knowing that they're presented ([#31313](https://github.com/element-hq/element-web/pull/31313)). Contributed by @robintown. + + +Changes in [1.12.6](https://github.com/element-hq/element-web/releases/tag/v1.12.6) (2025-12-03) +================================================================================================ +This release fixes a bug where 1:1 calling was incorrectly not available if no Element Call focus was set. + +## 🐛 Bug Fixes + +* Add option to pick call options for voice calls. ([#31413](https://github.com/element-hq/element-web/pull/31413)). + +Changes in [1.12.5](https://github.com/element-hq/element-web/releases/tag/v1.12.5) (2025-12-02) +================================================================================================ +## ✨ Features + +* Update Emojibase to v17 ([#31307](https://github.com/element-hq/element-web/pull/31307)). Contributed by @t3chguy. +* Adds tooltip for compose menu ([#31122](https://github.com/element-hq/element-web/pull/31122)). Contributed by @byteplow. +* Add option to hide pinned message banner in room view ([#31296](https://github.com/element-hq/element-web/pull/31296)). Contributed by @florianduros. +* update twemoji to not monochromise emoji with BLACK in their name ([#31281](https://github.com/element-hq/element-web/pull/31281)). Contributed by @ara4n. +* upgrade to twemoji 17.0.2 and fix #14695 ([#31267](https://github.com/element-hq/element-web/pull/31267)). Contributed by @ara4n. +* Add options to hide right panel in room view ([#31252](https://github.com/element-hq/element-web/pull/31252)). Contributed by @florianduros. +* Delayed event management: split endpoints, no auth ([#31183](https://github.com/element-hq/element-web/pull/31183)). Contributed by @AndrewFerr. +* Support using Element Call for voice calls in DMs ([#30817](https://github.com/element-hq/element-web/pull/30817)). Contributed by @Half-Shot. +* Improve screen reader accessibility of auth pages ([#31236](https://github.com/element-hq/element-web/pull/31236)). Contributed by @t3chguy. +* Add posthog tracking for key backup toasts ([#31195](https://github.com/element-hq/element-web/pull/31195)). Contributed by @Half-Shot. + +## 🐛 Bug Fixes + +* Return to using Fira Code as the default monospace font ([#31302](https://github.com/element-hq/element-web/pull/31302)). Contributed by @ara4n. +* Fix case of home screen being displayed erroneously ([#31301](https://github.com/element-hq/element-web/pull/31301)). Contributed by @langleyd. +* Fix message edition and reply when multiple rooms at displayed the same moment ([#31280](https://github.com/element-hq/element-web/pull/31280)). Contributed by @florianduros. +* Key storage out of sync: reset key backup when needed ([#31279](https://github.com/element-hq/element-web/pull/31279)). Contributed by @uhoreg. +* Fix invalid events crashing entire room rather than just their tile ([#31256](https://github.com/element-hq/element-web/pull/31256)). Contributed by @t3chguy. +* Fix expand button of space panel getting cut off at the edges ([#31259](https://github.com/element-hq/element-web/pull/31259)). Contributed by @MidhunSureshR. +* Fix pill buttons in dialogs ([#31246](https://github.com/element-hq/element-web/pull/31246)). Contributed by @dbkr. +* Fix blank sections at the top and bottom of the member list when scrolling ([#31198](https://github.com/element-hq/element-web/pull/31198)). Contributed by @langleyd. +* Fix emoji category selection with keyboard ([#31162](https://github.com/element-hq/element-web/pull/31162)). Contributed by @langleyd. + + +Changes in [1.12.4](https://github.com/element-hq/element-web/releases/tag/v1.12.4) (2025-11-18) +================================================================================================ +## ✨ Features + +* Apply aria-hidden to emoji in SAS verification ([#31204](https://github.com/element-hq/element-web/pull/31204)). Contributed by @t3chguy. +* Add options to hide header and composer of room view for the module api ([#31095](https://github.com/element-hq/element-web/pull/31095)). Contributed by @florianduros. +* Experimental Module API Additions ([#30863](https://github.com/element-hq/element-web/pull/30863)). Contributed by @dbkr. +* Change polls to use fieldset/legend markup ([#31160](https://github.com/element-hq/element-web/pull/31160)). Contributed by @langleyd. +* Use compound Button styles for Jitsi button ([#31159](https://github.com/element-hq/element-web/pull/31159)). Contributed by @Half-Shot. +* Add FocusLock to emoji picker ([#31146](https://github.com/element-hq/element-web/pull/31146)). Contributed by @langleyd. +* Move room name, avatar, and topic to IOpts. ([#30981](https://github.com/element-hq/element-web/pull/30981)). Contributed by @kaylendog. +* Add a devtool for looking at users and their devices ([#30983](https://github.com/element-hq/element-web/pull/30983)). Contributed by @uhoreg. + +## 🐛 Bug Fixes + +* Fix room list handling of membership changes ([#31197](https://github.com/element-hq/element-web/pull/31197)). Contributed by @t3chguy. +* Fix room list unable to be resized when displayed after a module ([#31186](https://github.com/element-hq/element-web/pull/31186)). Contributed by @florianduros. +* Inhibit keyboard highlights in dialogs when effector is not in focus ([#31181](https://github.com/element-hq/element-web/pull/31181)). Contributed by @t3chguy. +* Strip mentions from forwarded messages ([#30884](https://github.com/element-hq/element-web/pull/30884)). Contributed by @twassman. +* Don't allow pin or edit of messages with a send status ([#31158](https://github.com/element-hq/element-web/pull/31158)). Contributed by @langleyd. +* Hide room header buttons if the room hasn't been created yet. ([#31092](https://github.com/element-hq/element-web/pull/31092)). Contributed by @Half-Shot. +* Fix screen readers not indicating the emoji picker search field is focused. ([#31128](https://github.com/element-hq/element-web/pull/31128)). Contributed by @langleyd. +* Fix emoji picker highlight missing when not active element ([#31148](https://github.com/element-hq/element-web/pull/31148)). Contributed by @t3chguy. +* Add relevant aria attribute for selected emoji in the emoji picker ([#31125](https://github.com/element-hq/element-web/pull/31125)). Contributed by @t3chguy. +* Fix tooltips within context menu portals being unreliable ([#31129](https://github.com/element-hq/element-web/pull/31129)). Contributed by @t3chguy. +* Avoid excessive re-render of room list and member list ([#31131](https://github.com/element-hq/element-web/pull/31131)). Contributed by @florianduros. +* Make emoji picker height responsive. ([#31130](https://github.com/element-hq/element-web/pull/31130)). Contributed by @langleyd. +* Emoji Picker: Focused emoji does not move with the arrow keys ([#30893](https://github.com/element-hq/element-web/pull/30893)). Contributed by @langleyd. +* Fix audio player seek bar position ([#31127](https://github.com/element-hq/element-web/pull/31127)). Contributed by @florianduros. +* Add aria label to emoji picker search ([#31126](https://github.com/element-hq/element-web/pull/31126)). Contributed by @langleyd. + + +Changes in [1.12.3](https://github.com/element-hq/element-web/releases/tag/v1.12.3) (2025-11-04) +================================================================================================ +## 🦖 Deprecations + +* Remove allowVoipWithNoMedia feature flag ([#31087](https://github.com/element-hq/element-web/pull/31087)). Contributed by @Half-Shot. + +## ✨ Features + +* Change module API to be an instance getter ([#31025](https://github.com/element-hq/element-web/pull/31025)). Contributed by @dbkr. + +## 🐛 Bug Fixes + +* Show hover elements when keyboard focus is within an event tile ([#31078](https://github.com/element-hq/element-web/pull/31078)). Contributed by @t3chguy. +* Ensure toolbar navigation pattern works in MessageActionBar ([#31080](https://github.com/element-hq/element-web/pull/31080)). Contributed by @t3chguy. +* Ensure sent markers are hidden when showing thread summary. ([#31076](https://github.com/element-hq/element-web/pull/31076)). Contributed by @Half-Shot. +* Fix translation in dev mode ([#31045](https://github.com/element-hq/element-web/pull/31045)). Contributed by @florianduros. +* Fix sort order in space hierarchy ([#30975](https://github.com/element-hq/element-web/pull/30975)). Contributed by @t3chguy. +* New Room list: don't display message preview of thread ([#31043](https://github.com/element-hq/element-web/pull/31043)). Contributed by @florianduros. +* Revert "A11y: move focus to right panel when opened" ([#30999](https://github.com/element-hq/element-web/pull/30999)). Contributed by @florianduros. +* Fix highlights in messages (or search results) breaking links ([#30264](https://github.com/element-hq/element-web/pull/30264)). Contributed by @bojidar-bg. +* Add prepare script ([#31030](https://github.com/element-hq/element-web/pull/31030)). Contributed by @dbkr. +* Fix html exports by adding SDKContext ([#30987](https://github.com/element-hq/element-web/pull/30987)). Contributed by @t3chguy. + + +Changes in [1.12.2](https://github.com/element-hq/element-web/releases/tag/v1.12.2) (2025-10-21) +================================================================================================ +## ✨ Features + +* Room List: Extend the viewport to avoid so many black spots when scrolling the room list ([#30867](https://github.com/element-hq/element-web/pull/30867)). Contributed by @langleyd. +* Hide calling buttons in room header before a room is created ([#30816](https://github.com/element-hq/element-web/pull/30816)). Contributed by @Half-Shot. +* Improve invite dialog ui - Part 2 ([#30836](https://github.com/element-hq/element-web/pull/30836)). Contributed by @florianduros. + +## 🐛 Bug Fixes + +* Fix platform settings race condition and make auto-launch tri-state ([#30977](https://github.com/element-hq/element-web/pull/30977)). Contributed by @t3chguy. +* Fix: member count in header and member list ([#30982](https://github.com/element-hq/element-web/pull/30982)). Contributed by @florianduros. +* Fix duration of voice message in timeline ([#30973](https://github.com/element-hq/element-web/pull/30973)). Contributed by @florianduros. +* Fix voice notes rendering at 00:00 when playback had not begun. ([#30961](https://github.com/element-hq/element-web/pull/30961)). Contributed by @Half-Shot. +* Improve handling of animated images, add support for AVIF animations ([#30932](https://github.com/element-hq/element-web/pull/30932)). Contributed by @t3chguy. +* Update key storage toggle when key storage status changes ([#30934](https://github.com/element-hq/element-web/pull/30934)). Contributed by @uhoreg. +* Fix jitsi widget popout ([#30908](https://github.com/element-hq/element-web/pull/30908)). Contributed by @dbkr. +* Improve keyboard navigation on invite dialog ([#30930](https://github.com/element-hq/element-web/pull/30930)). Contributed by @florianduros. +* Prefer UIA flows with supported UIA stages ([#30926](https://github.com/element-hq/element-web/pull/30926)). Contributed by @richvdh. +* Enhance accessibility of dropdown ([#30928](https://github.com/element-hq/element-web/pull/30928)). Contributed by @florianduros. +* Improve accessibility of the `\ component ([#30907](https://github.com/element-hq/element-web/pull/30907)). Contributed by @MidhunSureshR. + + +Changes in [1.12.1](https://github.com/element-hq/element-web/releases/tag/v1.12.1) (2025-10-07) +================================================================================================ +## ✨ Features + +* New Room List: Change the order of filters to match those on mobile ([#30905](https://github.com/element-hq/element-web/pull/30905)). Contributed by @langleyd. +* New Room List: Don't clear filters on space change ([#30903](https://github.com/element-hq/element-web/pull/30903)). Contributed by @langleyd. +* Add release announcement for the sounds ([#30900](https://github.com/element-hq/element-web/pull/30900)). Contributed by @langleyd. +* Rich Text Editor: Add emoji suggestion support ([#30873](https://github.com/element-hq/element-web/pull/30873)). Contributed by @langleyd. +* feat: Disable session lock when running in element-desktop ([#30643](https://github.com/element-hq/element-web/pull/30643)). Contributed by @kaylendog. +* Improve invite dialog ui - Part 1 ([#30764](https://github.com/element-hq/element-web/pull/30764)). Contributed by @florianduros. +* Update Message Sound for Element ([#30804](https://github.com/element-hq/element-web/pull/30804)). Contributed by @beatdemon. +* Add new and improved ringtone ([#30761](https://github.com/element-hq/element-web/pull/30761)). Contributed by @Half-Shot. +* Disable RTE formatting buttons when the content contains a slash command ([#30802](https://github.com/element-hq/element-web/pull/30802)). Contributed by @langleyd. + +## 🐛 Bug Fixes + +* New Room List: Improve robustness of keyboard navigation ([#30888](https://github.com/element-hq/element-web/pull/30888)). Contributed by @langleyd. +* Fix a11y issue on list in invite dialog ([#30878](https://github.com/element-hq/element-web/pull/30878)). Contributed by @florianduros. +* Switch Export and Import Icons to match intuition ([#30805](https://github.com/element-hq/element-web/pull/30805)). Contributed by @micartey. +* Hide breadcrumb option when new room list is enabled ([#30869](https://github.com/element-hq/element-web/pull/30869)). Contributed by @florianduros. +* Avoid creating multiple call objects for the same widget ([#30839](https://github.com/element-hq/element-web/pull/30839)). Contributed by @robintown. +* Add a test for #29882, which is fixed by matrix-org/matrix-js-sdk#5016 ([#30835](https://github.com/element-hq/element-web/pull/30835)). Contributed by @andybalaam. +* fix: use `help_encryption_url` of config instead of hardcoded `https://element.io/help#encryption5` ([#30746](https://github.com/element-hq/element-web/pull/30746)). Contributed by @florianduros. +* Fix html export when feature\_jump\_to\_date is enabled ([#30828](https://github.com/element-hq/element-web/pull/30828)). Contributed by @langleyd. +* Fix #30439: "Forgot recovery key" should go to "reset" ([#30771](https://github.com/element-hq/element-web/pull/30771)). Contributed by @andybalaam. + + +Changes in [1.12.0](https://github.com/element-hq/element-web/releases/tag/v1.12.0) (2025-09-23) +================================================================================================ +## 🦖 Deprecations + +* Remove remaining support for outdated .well-known settings ([#30702](https://github.com/element-hq/element-web/pull/30702)). Contributed by @richvdh. + +## ✨ Features + +* Add decline button to call notification toast (use new notification event) ([#30729](https://github.com/element-hq/element-web/pull/30729)). Contributed by @toger5. +* Use the new room list by default ([#30640](https://github.com/element-hq/element-web/pull/30640)). Contributed by @langleyd. +* "Verify this device" redesign ([#30596](https://github.com/element-hq/element-web/pull/30596)). Contributed by @uhoreg. +* Set Element Call "intents" when starting and answering DM calls. ([#30730](https://github.com/element-hq/element-web/pull/30730)). Contributed by @Half-Shot. +* Add axe compliance for new room list ([#30700](https://github.com/element-hq/element-web/pull/30700)). Contributed by @langleyd. +* Stop ringing and remove toast if another device answers a RTC call. ([#30728](https://github.com/element-hq/element-web/pull/30728)). Contributed by @Half-Shot. +* Automatically adjust history visibility when making a room private ([#30713](https://github.com/element-hq/element-web/pull/30713)). Contributed by @Half-Shot. +* Release announcement for new room list ([#30675](https://github.com/element-hq/element-web/pull/30675)). Contributed by @dbkr. + +## 🐛 Bug Fixes + +* [Backport staging] Room list: make the filter resize correctly ([#30795](https://github.com/element-hq/element-web/pull/30795)). Contributed by @RiotRobot. +* [Backport staging] Avoid flicker of the room list filter on resize ([#30794](https://github.com/element-hq/element-web/pull/30794)). Contributed by @RiotRobot. +* Don't show release announcements while toasts are displayed ([#30770](https://github.com/element-hq/element-web/pull/30770)). Contributed by @dbkr. +* Fix enabling key backup not working if there is an untrusted key backup ([#30707](https://github.com/element-hq/element-web/pull/30707)). Contributed by @Half-Shot. +* Force `preload` to be false when setting an intent on an Element Call. ([#30759](https://github.com/element-hq/element-web/pull/30759)). Contributed by @Half-Shot. +* Fix handling of 413 server response when uploading media ([#30737](https://github.com/element-hq/element-web/pull/30737)). Contributed by @hughns. +* Make landmark navigation work with new room list ([#30747](https://github.com/element-hq/element-web/pull/30747)). Contributed by @dbkr. +* Prevent voice message from displaying spurious errors ([#30736](https://github.com/element-hq/element-web/pull/30736)). Contributed by @florianduros. +* Align default avatar and fix colors in composer pills ([#30739](https://github.com/element-hq/element-web/pull/30739)). Contributed by @florianduros. +* Use configured URL for link to desktop app in message search settings ([#30742](https://github.com/element-hq/element-web/pull/30742)). Contributed by @t3chguy. +* Fix history visibility when creating space rooms ([#30745](https://github.com/element-hq/element-web/pull/30745)). Contributed by @dbkr. +* Check HTML-encoded quotes when handling translations for embedded pages (such as welcome.html) ([#30743](https://github.com/element-hq/element-web/pull/30743)). Contributed by @Half-Shot. +* Fix local room encryption status always not enabled ([#30461](https://github.com/element-hq/element-web/pull/30461)). Contributed by @BillCarsonFr. +* fix: make url in topic in room intro clickable ([#30686](https://github.com/element-hq/element-web/pull/30686)). Contributed by @florianduros. +* Block change recovery key button while a change is ongoing. ([#30664](https://github.com/element-hq/element-web/pull/30664)). Contributed by @Half-Shot. +* Hide advanced settings during room creation when `UIFeature.advancedSettings=false` ([#30684](https://github.com/element-hq/element-web/pull/30684)). Contributed by @florianduros. +* A11y: improve accessibility of pinned messages ([#30558](https://github.com/element-hq/element-web/pull/30558)). Contributed by @florianduros. + + +Changes in [1.11.112](https://github.com/element-hq/element-web/releases/tag/v1.11.112) (2025-09-16) +==================================================================================================== +Fix [CVE-2025-59161](https://www.cve.org/CVERecord?id=CVE-2025-59161) / [GHSA-m6c8-98f4-75rr](https://github.com/element-hq/element-web/security/advisories/GHSA-m6c8-98f4-75rr) + + +Changes in [1.11.111](https://github.com/element-hq/element-web/releases/tag/v1.11.111) (2025-09-10) +==================================================================================================== +## ✨ Features + +* Do not hide media from your own user by default ([#29797](https://github.com/element-hq/element-web/pull/29797)). Contributed by @Half-Shot. +* Remember whether sidebar is shown for calls when switching rooms ([#30262](https://github.com/element-hq/element-web/pull/30262)). Contributed by @bojidar-bg. +* Open the proper integration settings on integrations disabled error ([#30538](https://github.com/element-hq/element-web/pull/30538)). Contributed by @Half-Shot. +* Show a "progress" dialog while invites are being sent ([#30561](https://github.com/element-hq/element-web/pull/30561)). Contributed by @richvdh. +* Move the room list to the new ListView(backed by react-virtuoso) ([#30515](https://github.com/element-hq/element-web/pull/30515)). Contributed by @langleyd. + +## 🐛 Bug Fixes + +* [Backport staging] Ensure container starts if it is mounted with an empty /modules directory. ([#30705](https://github.com/element-hq/element-web/pull/30705)). Contributed by @RiotRobot. +* Fix room joining over federation not specifying vias or using aliases ([#30641](https://github.com/element-hq/element-web/pull/30641)). Contributed by @t3chguy. +* Fix stable-suffixed MSC4133 support ([#30649](https://github.com/element-hq/element-web/pull/30649)). Contributed by @dbkr. +* Fix i18n of message when a setting is disabled ([#30646](https://github.com/element-hq/element-web/pull/30646)). Contributed by @dbkr. +* ListView should not handle the arrow keys if there is a modifier applied ([#30633](https://github.com/element-hq/element-web/pull/30633)). Contributed by @langleyd. +* Make BaseDialog's div keyboard focusable and fix test. ([#30631](https://github.com/element-hq/element-web/pull/30631)). Contributed by @langleyd. +* Fix: Allow triple-click text selection to flow around pills ([#30349](https://github.com/element-hq/element-web/pull/30349)). Contributed by @AlirezaMrtz. +* Watch for a 'join' action to know when the call is connected ([#29492](https://github.com/element-hq/element-web/pull/29492)). Contributed by @robintown. +* Fix: add missing tooltip and aria-label to lock icon next to composer ([#30623](https://github.com/element-hq/element-web/pull/30623)). Contributed by @florianduros. +* Don't render context menu when scrolling ([#30613](https://github.com/element-hq/element-web/pull/30613)). Contributed by @langleyd. + + +Changes in [1.11.110](https://github.com/element-hq/element-web/releases/tag/v1.11.110) (2025-08-27) +==================================================================================================== +## ✨ Features + +* Hide recovery key when re-entering it while creating or changing it ([#30499](https://github.com/element-hq/element-web/pull/30499)). Contributed by @andybalaam. +* Add `?no_universal_links=true` to OIDC url so EX doesn't try to handle it ([#29439](https://github.com/element-hq/element-web/pull/29439)). Contributed by @t3chguy. +* Show a blue lock for unencrypted rooms and hide the grey shield for encrypted rooms ([#30440](https://github.com/element-hq/element-web/pull/30440)). Contributed by @langleyd. +* Add support for Module API 1.4 ([#30185](https://github.com/element-hq/element-web/pull/30185)). Contributed by @t3chguy. +* MVVM - Introduce some helpers for snapshot management ([#30398](https://github.com/element-hq/element-web/pull/30398)). Contributed by @MidhunSureshR. + +## 🐛 Bug Fixes + +* A11y: move focus to right panel when opened ([#30553](https://github.com/element-hq/element-web/pull/30553)). Contributed by @florianduros. +* Fix e2e warning icon should be white ([#30539](https://github.com/element-hq/element-web/pull/30539)). Contributed by @florianduros. +* Remove NoOneHere disabled reason. ([#30524](https://github.com/element-hq/element-web/pull/30524)). Contributed by @toger5. +* Fix downloading files with authenticated media API ([#30520](https://github.com/element-hq/element-web/pull/30520)). Contributed by @t3chguy. +* Fix call permissions check confusion around element call ([#30521](https://github.com/element-hq/element-web/pull/30521)). Contributed by @t3chguy. +* Fix line wrap around emoji verification ([#30523](https://github.com/element-hq/element-web/pull/30523)). Contributed by @t3chguy. +* Don't highlight redacted events ([#30519](https://github.com/element-hq/element-web/pull/30519)). Contributed by @t3chguy. +* Fix matrix.to links not being handled in the app ([#30522](https://github.com/element-hq/element-web/pull/30522)). Contributed by @t3chguy. +* Fix issue of new room list taking up the full width ([#30459](https://github.com/element-hq/element-web/pull/30459)). Contributed by @langleyd. +* Fix widget persistence in React development mode ([#30509](https://github.com/element-hq/element-web/pull/30509)). Contributed by @robintown. +* Fix widget initialization in React development mode ([#30463](https://github.com/element-hq/element-web/pull/30463)). Contributed by @robintown. + + +Changes in [1.11.109](https://github.com/element-hq/element-web/releases/tag/v1.11.109) (2025-08-11) +==================================================================================================== +This release supports the upcoming v12 ("hydra") Matrix room version and is necessary to view and participate in these rooms. + +## ✨ Features + +* [Backport staging] Allow /upgraderoom command without developer mode enabled ([#30529](https://github.com/element-hq/element-web/pull/30529)). Contributed by @RiotRobot. +* [Backport staging] Support for creator/owner power level ([#30526](https://github.com/element-hq/element-web/pull/30526)). Contributed by @RiotRobot. +* New room list: change icon and label of menu item for to start a DM ([#30470](https://github.com/element-hq/element-web/pull/30470)). Contributed by @florianduros. +* Implement the member list with virtuoso ([#29869](https://github.com/element-hq/element-web/pull/29869)). Contributed by @langleyd. +* Add labs option for history sharing on invite ([#30313](https://github.com/element-hq/element-web/pull/30313)). Contributed by @richvdh. +* Bump wysiwyg to 2.39.0 adding support for pasting rich text content in the Rich Text Edtior ([#30421](https://github.com/element-hq/element-web/pull/30421)). Contributed by @langleyd. +* Support `EventShieldReason.MISMATCHED_SENDER` ([#30403](https://github.com/element-hq/element-web/pull/30403)). Contributed by @richvdh. +* Change unencrypted and public pills to blue ([#30399](https://github.com/element-hq/element-web/pull/30399)). Contributed by @florianduros. +* Change color of public room icon ([#30390](https://github.com/element-hq/element-web/pull/30390)). Contributed by @florianduros. +* Script for updating storybook screenshots ([#30340](https://github.com/element-hq/element-web/pull/30340)). Contributed by @dbkr. +* Add toggle to hide empty state in devtools ([#30352](https://github.com/element-hq/element-web/pull/30352)). Contributed by @toger5. + +## 🐛 Bug Fixes + +* [Backport staging] Use userId to filter users in non-federated rooms when showing the InviteDialog ([#30537](https://github.com/element-hq/element-web/pull/30537)). Contributed by @RiotRobot. +* [Backport staging] Catch error when encountering invalid m.room.pinned\_events event ([#30536](https://github.com/element-hq/element-web/pull/30536)). Contributed by @RiotRobot. +* Update for compatibility with v12 rooms ([#30452](https://github.com/element-hq/element-web/pull/30452)). Contributed by @dbkr. +* New room list: fix tooltip on presence ([#30474](https://github.com/element-hq/element-web/pull/30474)). Contributed by @florianduros. +* New room list: add tooltip for presence and room status ([#30472](https://github.com/element-hq/element-web/pull/30472)). Contributed by @florianduros. +* Fix: Clicking on an item in the member list causes it to scroll to the top rather than show the profile view ([#30455](https://github.com/element-hq/element-web/pull/30455)). Contributed by @langleyd. +* Put the 'decrypting' tooltip back ([#30446](https://github.com/element-hq/element-web/pull/30446)). Contributed by @dbkr. +* Use server name explicitly for via. ([#30362](https://github.com/element-hq/element-web/pull/30362)). Contributed by @Half-Shot. +* fix: replace hardcoded string in poll history dialog ([#30402](https://github.com/element-hq/element-web/pull/30402)). Contributed by @florianduros. +* fix: replace hardcoded string on qr code back button ([#30401](https://github.com/element-hq/element-web/pull/30401)). Contributed by @florianduros. +* Fix color of icon button with outline ([#30361](https://github.com/element-hq/element-web/pull/30361)). Contributed by @florianduros. + + +Changes in [1.11.108](https://github.com/element-hq/element-web/releases/tag/v1.11.108) (2025-07-30) +==================================================================================================== +## 🐛 Bug Fixes + +* [Backport staging] Fix downloaded attachments not being decrypted ([#30434](https://github.com/element-hq/element-web/pull/30434)). Contributed by @RiotRobot. + + +Changes in [1.11.107](https://github.com/element-hq/element-web/releases/tag/v1.11.107) (2025-07-29) +==================================================================================================== +## ✨ Features + +* Message preview should show tooltip with the full message on hover ([#30265](https://github.com/element-hq/element-web/pull/30265)). Contributed by @MidhunSureshR. +* Support rendering notification badges on platforms that do their own icon overlays ([#30315](https://github.com/element-hq/element-web/pull/30315)). Contributed by @Half-Shot. +* Add SubscriptionViewModel base class ([#30297](https://github.com/element-hq/element-web/pull/30297)). Contributed by @dbkr. +* Enhancement: Save image on CTRL+S ([#30330](https://github.com/element-hq/element-web/pull/30330)). Contributed by @ioalexander. +* Add quote functionality to MessageContextMenu (#29893) ([#30323](https://github.com/element-hq/element-web/pull/30323)). Contributed by @AlirezaMrtz. +* Initial structure for shared component views ([#30216](https://github.com/element-hq/element-web/pull/30216)). Contributed by @dbkr. + +## 🐛 Bug Fixes + +* [Backport staging] Fix e2e shield being invisible in white mode for encrypted room ([#30411](https://github.com/element-hq/element-web/pull/30411)). Contributed by @RiotRobot. +* Force ED titlebar color for new room list ([#30332](https://github.com/element-hq/element-web/pull/30332)). Contributed by @florianduros. +* Add a background color to left panel for macos titlebar in element desktop ([#30328](https://github.com/element-hq/element-web/pull/30328)). Contributed by @florianduros. +* Fix: Prevent page refresh on Enter key in right panel member search ([#30312](https://github.com/element-hq/element-web/pull/30312)). Contributed by @AlirezaMrtz. + + +Changes in [1.11.106](https://github.com/element-hq/element-web/releases/tag/v1.11.106) (2025-07-15) +==================================================================================================== +## ✨ Features + +* [Backport staging] Fix e2e icon colour ([#30304](https://github.com/element-hq/element-web/pull/30304)). Contributed by @RiotRobot. +* Add support for module message hint `allowDownloadingMedia` ([#30252](https://github.com/element-hq/element-web/pull/30252)). Contributed by @Half-Shot. +* Update the mobile\_guide page to the new design and link out to Element X by default. ([#30172](https://github.com/element-hq/element-web/pull/30172)). Contributed by @pixlwave. +* Filter settings exported when rageshaking ([#30236](https://github.com/element-hq/element-web/pull/30236)). Contributed by @Half-Shot. +* Allow Element Call to learn the room name ([#30213](https://github.com/element-hq/element-web/pull/30213)). Contributed by @robintown. + +## 🐛 Bug Fixes + +* [Backport staging] Fix missing image download button ([#30322](https://github.com/element-hq/element-web/pull/30322)). Contributed by @RiotRobot. +* Fix transparent verification checkmark in dark mode ([#30235](https://github.com/element-hq/element-web/pull/30235)). Contributed by @Banbuii. +* Fix logic in DeviceListener ([#30230](https://github.com/element-hq/element-web/pull/30230)). Contributed by @uhoreg. +* Disable file drag-and-drop if insufficient permissions ([#30186](https://github.com/element-hq/element-web/pull/30186)). Contributed by @t3chguy. + + +Changes in [1.11.105](https://github.com/element-hq/element-web/releases/tag/v1.11.105) (2025-07-01) +==================================================================================================== +## ✨ Features + +* New room list: add context menu to room list item ([#29952](https://github.com/element-hq/element-web/pull/29952)). Contributed by @florianduros. +* Support for custom message components via Module API ([#30074](https://github.com/element-hq/element-web/pull/30074)). Contributed by @Half-Shot. +* Prompt users to set up recovery ([#30075](https://github.com/element-hq/element-web/pull/30075)). Contributed by @uhoreg. +* Update `IconButton` colors ([#30124](https://github.com/element-hq/element-web/pull/30124)). Contributed by @florianduros. +* New room list: filter list can be collapsed ([#29992](https://github.com/element-hq/element-web/pull/29992)). Contributed by @florianduros. +* Show `EmptyRoomListView` when low priority filter matches zero rooms ([#30122](https://github.com/element-hq/element-web/pull/30122)). Contributed by @MidhunSureshR. + +## 🐛 Bug Fixes + +* Fix untranslatable string "People" in notifications beta ([#30165](https://github.com/element-hq/element-web/pull/30165)). Contributed by @t3chguy. +* Force verification even after logging in via delegate ([#30141](https://github.com/element-hq/element-web/pull/30141)). Contributed by @andybalaam. +* Hide add integrations button based on UIComponent.AddIntegrations ([#30140](https://github.com/element-hq/element-web/pull/30140)). Contributed by @t3chguy. +* Use nav for new room list and label sections ([#30134](https://github.com/element-hq/element-web/pull/30134)). Contributed by @dbkr. +* Spacestore should emit event after rebuilding home space ([#30132](https://github.com/element-hq/element-web/pull/30132)). Contributed by @MidhunSureshR. +* Handle m.room.pinned\_events being invalid ([#30129](https://github.com/element-hq/element-web/pull/30129)). Contributed by @t3chguy. + + +Changes in [1.11.104](https://github.com/element-hq/element-web/releases/tag/v1.11.104) (2025-06-17) +==================================================================================================== +## ✨ Features + +* Update the mobile\_guide page to the new design. ([#30006](https://github.com/element-hq/element-web/pull/30006)). Contributed by @pixlwave. +* Provide a devtool for manually verifying other devices ([#30094](https://github.com/element-hq/element-web/pull/30094)). Contributed by @andybalaam. +* Implement MSC4155: Invite filtering ([#29603](https://github.com/element-hq/element-web/pull/29603)). Contributed by @Half-Shot. +* Add low priority avatar decoration to room tile ([#30065](https://github.com/element-hq/element-web/pull/30065)). Contributed by @MidhunSureshR. +* Add ability to prevent window content being captured by other apps (Desktop) ([#30098](https://github.com/element-hq/element-web/pull/30098)). Contributed by @t3chguy. +* New room list: move message preview in user settings ([#30023](https://github.com/element-hq/element-web/pull/30023)). Contributed by @florianduros. +* New room list: change room options icon ([#30029](https://github.com/element-hq/element-web/pull/30029)). Contributed by @florianduros. +* RoomListStore: Sort low priority rooms to the bottom of the list ([#30070](https://github.com/element-hq/element-web/pull/30070)). Contributed by @MidhunSureshR. +* Add low priority filter pill to the room list UI ([#30060](https://github.com/element-hq/element-web/pull/30060)). Contributed by @MidhunSureshR. +* New room list: remove color gradient in space panel ([#29721](https://github.com/element-hq/element-web/pull/29721)). Contributed by @florianduros. +* /share?msg=foo endpoint using forward message dialog ([#29874](https://github.com/element-hq/element-web/pull/29874)). Contributed by @ara4n. + +## 🐛 Bug Fixes + +* Do not send empty auth when setting up cross-signing keys ([#29914](https://github.com/element-hq/element-web/pull/29914)). Contributed by @gnieto. +* Settings: flip local video feed by default ([#29501](https://github.com/element-hq/element-web/pull/29501)). Contributed by @jbtrystram. +* AccessSecretStorageDialog: various fixes ([#30093](https://github.com/element-hq/element-web/pull/30093)). Contributed by @richvdh. +* AccessSecretStorageDialog: fix inability to enter recovery key ([#30090](https://github.com/element-hq/element-web/pull/30090)). Contributed by @richvdh. +* Fix failure to upload thumbnail causing image to send as file ([#30086](https://github.com/element-hq/element-web/pull/30086)). Contributed by @t3chguy. +* Low priority menu item should be a toggle ([#30071](https://github.com/element-hq/element-web/pull/30071)). Contributed by @MidhunSureshR. +* Add sanity checks to prevent users from ignoring themselves ([#30079](https://github.com/element-hq/element-web/pull/30079)). Contributed by @MidhunSureshR. +* Fix issue with duplicate images ([#30073](https://github.com/element-hq/element-web/pull/30073)). Contributed by @fatlewis. +* Handle errors returned from Seshat ([#30083](https://github.com/element-hq/element-web/pull/30083)). Contributed by @richvdh. + + +Changes in [1.11.103](https://github.com/element-hq/element-web/releases/tag/v1.11.103) (2025-06-10) +==================================================================================================== +## 🐛 Bug Fixes + ++ Check the sender of an event matches owner of session, preventing sender spoofing by homeserver owners. +[13c1d20](https://github.com/matrix-org/matrix-rust-sdk/commit/13c1d2048286bbabf5e7bc6b015aafee98f04d55) (High, [GHSA-x958-rvg6-956w](https://github.com/matrix-org/matrix-rust-sdk/security/advisories/GHSA-x958-rvg6-956w)). + +Changes in [1.11.102](https://github.com/element-hq/element-web/releases/tag/v1.11.102) (2025-06-03) +==================================================================================================== +## ✨ Features + +* EW: Modernize the recovery key input modal ([#29819](https://github.com/element-hq/element-web/pull/29819)). Contributed by @uhoreg. +* New room list: move secondary filters into primary filters ([#29972](https://github.com/element-hq/element-web/pull/29972)). Contributed by @florianduros. +* Prompt the user when key storage is unexpectedly off ([#29912](https://github.com/element-hq/element-web/pull/29912)). Contributed by @andybalaam. +* New room list: move sort menu in room list header ([#29983](https://github.com/element-hq/element-web/pull/29983)). Contributed by @florianduros. +* New room list: rework spacing of room list item ([#29965](https://github.com/element-hq/element-web/pull/29965)). Contributed by @florianduros. +* RLS: Remove forgotten room from skiplist ([#29933](https://github.com/element-hq/element-web/pull/29933)). Contributed by @MidhunSureshR. +* Add room list sorting ([#29951](https://github.com/element-hq/element-web/pull/29951)). Contributed by @dbkr. +* Don't use the minimised width(68px) on the new room list ([#29778](https://github.com/element-hq/element-web/pull/29778)). Contributed by @langleyd. + +## 🐛 Bug Fixes + +* [Backport staging] Close call options popup menu when option has been selected ([#30054](https://github.com/element-hq/element-web/pull/30054)). Contributed by @RiotRobot. +* RoomListStoreV3: Only add new rooms that pass `VisibilityProvider` check ([#29974](https://github.com/element-hq/element-web/pull/29974)). Contributed by @MidhunSureshR. +* Re-order primary filters ([#29957](https://github.com/element-hq/element-web/pull/29957)). Contributed by @dbkr. +* Fix leaky CSS adding `!` to all H1 elements ([#29964](https://github.com/element-hq/element-web/pull/29964)). Contributed by @t3chguy. +* Fix extensions panel style ([#29273](https://github.com/element-hq/element-web/pull/29273)). Contributed by @langleyd. +* Fix state events being hidden from widgets in read\_events actions ([#29954](https://github.com/element-hq/element-web/pull/29954)). Contributed by @robintown. +* Remove old filter test ([#29963](https://github.com/element-hq/element-web/pull/29963)). Contributed by @dbkr. + + Changes in [1.11.101](https://github.com/element-hq/element-web/releases/tag/v1.11.101) (2025-05-20) ==================================================================================================== ## ✨ Features diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index fd94f18f85..3ec59b2eff 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -2,6 +2,11 @@ Everyone is welcome to contribute code to Element Web, provided that they are willing to license their contributions to Element under a [Contributor License Agreement](https://cla-assistant.io/element-hq/element-web) (CLA). This ensures that their contribution will be made available under an OSI-approved open-source license, currently licensed under Affero General Public License v3 (AGPLv3) or General Public License v3 (GPLv3) at your choice. +If you're contributing, or thinking about contributing, please come & chat to +us in our development room, [#element-dev](https://matrix.to/#/#element-dev:matrix.org). +This is the best place to ask questions about the code, how to work on the project +or whether a change is likely to be accepted. + ## How to contribute The preferred and easiest way to contribute changes to the project is to fork diff --git a/Dockerfile b/Dockerfile index a1813cf66d..5ce43a7c4f 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,7 +1,7 @@ -# syntax=docker.io/docker/dockerfile:1.16-labs@sha256:bb5e2b225985193779991f3256d1901a0b3e6a0b284c7bffa0972064f4a6d458 +# syntax=docker.io/docker/dockerfile:1.20-labs@sha256:dbcde2ebc4abc8bb5c3c499b9c9a6876842bf5da243951cd2697f921a7aeb6a9 # Builder -FROM --platform=$BUILDPLATFORM node:22-bullseye@sha256:f16d8e8af67bb6361231e932b8b3e7afa040cbfed181719a450b02c3821b26c1 AS builder +FROM --platform=$BUILDPLATFORM node:24-bullseye@sha256:5583cbe5d3347db372d9a9100eba272b548ca1f53246b9b769334bcd0eef2c26 AS builder # Support custom branch of the js-sdk. This also helps us build images of element-web develop. ARG USE_CUSTOM_SDKS=false @@ -19,7 +19,7 @@ RUN /src/scripts/docker-package.sh RUN cp /src/config.sample.json /src/webapp/config.json # App -FROM nginxinc/nginx-unprivileged:alpine-slim@sha256:2acffd86b1bdefb8fa6b48b6e9aadf75430e8ab9c43c54c515ea7df77897f987 +FROM nginxinc/nginx-unprivileged:alpine-slim@sha256:a6bec37058b9047ece799c01d98dc6d5aa0542b6583cc69f187652f91331a752 # Need root user to install packages & manipulate the usr directory USER root diff --git a/README.md b/README.md index 0f8a721f90..6f6e3172fa 100644 --- a/README.md +++ b/README.md @@ -27,7 +27,7 @@ Element has several tiers of support for different environments: - Best effort - Definition: - Issues **accepted**, regressions **do not block** the release - - The wider Element Products(including Element Call and the Enterprise Server Suite) do still not officially support these browsers. + - The wider Element Products (including Element Call and the Enterprise Server Suite) do still not officially support these browsers. - The element web project and its contributors should keep the client functioning and gracefully degrade where other sibling features (E.g. Element Call) may not function. - Last major release of Firefox ESR and Chrome/Edge Extended Stable - Community Supported diff --git a/book.toml b/book.toml index 77fa4bfd5d..5139a801fb 100644 --- a/book.toml +++ b/book.toml @@ -4,7 +4,6 @@ title = "Element Web & Desktop" authors = ["New Vector Ltd.", "The Matrix.org Foundation C.I.C."] language = "en" -multilingual = false # The directory that documentation files are stored in src = "docs" diff --git a/code_style.md b/code_style.md index 9f0501ccd8..6e7289d22e 100644 --- a/code_style.md +++ b/code_style.md @@ -79,7 +79,7 @@ Unless otherwise specified, the following applies to all code: 11. If a variable is not receiving a value on declaration, its type must be defined. ```typescript - let errorMessage: Optional; + let errorMessage: string; ``` 12. Objects can use shorthand declarations, including mixing of types. @@ -127,7 +127,6 @@ Unless otherwise specified, the following applies to all code: 2. "Conflicted" typically refers to a getter which wants the same name as the underlying variable. 20. Prefer readonly members over getters backed by a variable, unless an internal setter is required. 21. Prefer Interfaces for object definitions, and types for parameter-value-only declarations. - 1. Note that an explicit type is optional if not expected to be used outside of the function call, unlike in this example: @@ -161,7 +160,6 @@ Unless otherwise specified, the following applies to all code: 28. Export only what can be reused. 29. Prefer a type like `Optional` (`type Optional = T | null | undefined`) instead of truly optional parameters. - 1. A notable exception is when the likelihood of a bug is minimal, such as when a function takes an argument that is more often not required than required. An example where the `?` operator is inappropriate is when taking a room ID: typically the caller should @@ -260,7 +258,6 @@ Inheriting all the rules of TypeScript, the following additionally apply: 12. Interdependence between stores should be kept to a minimum. Break functions and constants out to utilities if at all possible. 13. A component should only use CSS class names in line with the component name. - 1. When knowingly using a class name from another component, document it with a [comment](#comments). 14. Curly braces within JSX should be padded with a space, however properties on those components should not. @@ -388,7 +385,6 @@ Note: We use PostCSS + some plugins to process our styles. It looks like SCSS, b properties should be clearly documented. 4. Inside a function, there is no need to comment every line, but consider: - - before a particular multiline section of code within the function, give an overview of what it does, to make it easier for a reader to follow the flow through the function as a whole. - if it is anything less than obvious, explain _why_ we are doing a particular operation, with particular emphasis diff --git a/config.sample.json b/config.sample.json index 973bb67a02..1f8030080d 100644 --- a/config.sample.json +++ b/config.sample.json @@ -20,8 +20,7 @@ "https://scalar.vector.im/_matrix/integrations/v1", "https://scalar.vector.im/api", "https://scalar-staging.vector.im/_matrix/integrations/v1", - "https://scalar-staging.vector.im/api", - "https://scalar-staging.riot.im/scalar/api" + "https://scalar-staging.vector.im/api" ], "default_widget_container_height": 280, "default_country_code": "GB", diff --git a/declaration.d.ts b/declaration.d.ts new file mode 100644 index 0000000000..928c567c31 --- /dev/null +++ b/declaration.d.ts @@ -0,0 +1,8 @@ +/* + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial + * Please see LICENSE files in the repository root for full details. + */ + +declare module "*.module.css"; diff --git a/developer_guide.md b/developer_guide.md index fa4bb9a239..2185a43a63 100644 --- a/developer_guide.md +++ b/developer_guide.md @@ -109,7 +109,7 @@ yarn test ### End-to-End tests -See [matrix-react-sdk](https://github.com/matrix-org/matrix-react-sdk/#end-to-end-tests) for how to run the end-to-end tests. +See [`docs/playwright.md`](./docs/playwright.md) for how to run the end-to-end tests. ## General github guidelines diff --git a/docker/docker-entrypoint.d/18-load-element-modules.sh b/docker/docker-entrypoint.d/18-load-element-modules.sh index 235c4edcf2..2230fea33e 100755 --- a/docker/docker-entrypoint.d/18-load-element-modules.sh +++ b/docker/docker-entrypoint.d/18-load-element-modules.sh @@ -14,10 +14,9 @@ entrypoint_log() { mkdir -p /tmp/element-web-config cp /app/config*.json /tmp/element-web-config/ -# If there are modules to be loaded -if [ -d "/modules" ]; then +# If the module directory exists AND the module directory has modules in it +if [ -d "/modules" ] && [ "$( ls -A '/modules' )" ]; then cd /modules - for MODULE in * do # If the module has a package.json, use its main field as the entrypoint diff --git a/docs/MVVM-v1.md b/docs/MVVM-v1.md new file mode 100644 index 0000000000..bbd02ccb06 --- /dev/null +++ b/docs/MVVM-v1.md @@ -0,0 +1,69 @@ +# MVVM + +_Deprecated_, see [MVVM.md](./MVVM.md) for the current version. + +General description of the pattern can be found [here](https://en.wikipedia.org/wiki/Model%E2%80%93view%E2%80%93viewmodel). But the gist of it is that you divide your code into three sections: + +1. Model: This is where the business logic and data resides. +2. View Model: This code exists to provide the logic necessary for the UI. It directly uses the Model code. +3. View: This is the UI code itself and depends on the view model. + +If you do MVVM right, your view should be dumb i.e it gets data from the view model and merely displays it. + +### Practical guidelines for MVVM in element-web + +#### Model + +This is anywhere your data or business logic comes from. If your view model is accessing something simple exposed from `matrix-js-sdk`, then the sdk is your model. If you're using something more high level in element-web to get your data/logic (eg: `MemberListStore`), then that becomes your model. + +#### View Model + +1. View model is always a custom react hook named like `useFooViewModel()`. +2. The return type of your view model (known as view state) must be defined as a typescript interface: + ```ts + inteface FooViewState { + somethingUseful: string; + somethingElse: BarType; + update: () => Promise + ... + } + ``` +3. Any react state that your UI needs must be in the view model. + +#### View + +1. Views are simple react components (eg: `FooView`). +2. Views usually start by calling the view model hook, eg: + ```tsx + const FooView: React.FC = (props: IProps) => { + const vm = useFooViewModel(); + .... + return( +
+ {vm.somethingUseful} +
+ ); + } + ``` +3. Views are also allowed to accept the view model as a prop, eg: + ```tsx + const FooView: React.FC = ({ vm }: IProps) => { + .... + return( +
+ {vm.somethingUseful} +
+ ); + } + ``` +4. Multiple views can share the same view model if necessary. + +### Benefits + +1. MVVM forces a separation of concern i.e we will no longer have large react components that have a lot of state and rendering code mixed together. This improves code readability and makes it easier to introduce changes. +2. Introduces the possibility of code reuse. You can reuse an old view model with a new view or vice versa. +3. Adding to the point above, in future you could import element-web view models to your project and supply your own views thus creating something similar to the [hydrogen sdk](https://github.com/element-hq/hydrogen-web/blob/master/doc/SDK.md). + +### Example + +We started experimenting with MVVM in the redesigned memberlist, you can see the code [here](https://github.com/vector-im/element-web/blob/develop/src/components/views/rooms/MemberList/MemberListView.tsx). diff --git a/docs/MVVM.md b/docs/MVVM.md index 9dfb8e4776..6175e21853 100644 --- a/docs/MVVM.md +++ b/docs/MVVM.md @@ -10,58 +10,80 @@ If you do MVVM right, your view should be dumb i.e it gets data from the view mo ### Practical guidelines for MVVM in element-web +A first documentation and implementation of MVVM was done in [MVVM-v1.md](MVVM-v1.md). This v1 version is now deprecated and this document describes the current implementation. + #### Model This is anywhere your data or business logic comes from. If your view model is accessing something simple exposed from `matrix-js-sdk`, then the sdk is your model. If you're using something more high level in element-web to get your data/logic (eg: `MemberListStore`), then that becomes your model. -#### View Model - -1. View model is always a custom react hook named like `useFooViewModel()`. -2. The return type of your view model (known as view state) must be defined as a typescript interface: - ```ts - inteface FooViewState { - somethingUseful: string; - somethingElse: BarType; - update: () => Promise - ... - } - ``` -3. Any react state that your UI needs must be in the view model. - #### View -1. Views are simple react components (eg: `FooView`). -2. Views usually start by calling the view model hook, eg: +1. Located in [`shared-components`](https://github.com/element-hq/element-web/tree/develop/packages/shared-components). Develop it in storybook! +2. Views are simple react components (eg: `FooView`). +3. Views use [useSyncExternalStore](https://react.dev/reference/react/useSyncExternalStore) internally where the view model is the external store. +4. Views should define the interface of the view model they expect: + ```tsx - const FooView: React.FC = (props: IProps) => { - const vm = useFooViewModel(); - .... - return( -
- {vm.somethingUseful} -
- ); + // Snapshot is the return type of your view model + interface FooViewSnapshot { + value: string; + } + + // To call function on the view model + interface FooViewActions { + doSomething: () => void; + } + + // ViewModel is a type defining the methods needed for `useSyncExternalStore` + // https://github.com/element-hq/element-web/blob/develop/packages/shared-components/src/ViewModel.ts + type FooViewModel = ViewModel & FooViewActions; + + interface FooViewProps { + vm: FooViewModel; + } + + function FooView({ vm }: FooViewProps) { + // useViewModel is a helper function that uses useSyncExternalStore under the hood + const { value } = useViewModel(vm); + return ( + + ); } ``` -3. Views are also allowed to accept the view model as a prop, eg: - ```tsx - const FooView: React.FC = ({ vm }: IProps) => { - .... - return( -
- {vm.somethingUseful} -
- ); + +5. Multiple views can share the same view model if necessary. +6. A full example is available [here](https://github.com/element-hq/element-web/blob/develop/packages/shared-components/src/audio/AudioPlayerView/AudioPlayerView.tsx) + +#### View Model + +1. A View model is a class extending [`BaseViewModel`](https://github.com/element-hq/element-web/blob/develop/src/viewmodels/base/BaseViewModel.ts). +2. Implements the interface defined in the view (e.g `FooViewModel` in the example above). +3. View models define a snapshot type that defines the data the view will consume. The snapshot is immutable and can only be changed by calling `this.snapshot.set(...)` in the view model. This will trigger a re-render in the view. + + ```ts + interface Props { + propsValue: string; + } + + class FooViewModel extends BaseViewModel implements FooViewModel { + constructor(props: Props) { + // Call super with initial snapshot + super(props, { value: "initial" }); + } + + public doSomething() { + // Call this.snapshot.set to update the snapshot + this.snapshot.set({ value: "changed" }); + } } ``` -4. Multiple views can share the same view model if necessary. + +4. A full example is available [here](https://github.com/element-hq/element-web/blob/develop/src/viewmodels/audio/AudioPlayerViewModel.ts) ### Benefits 1. MVVM forces a separation of concern i.e we will no longer have large react components that have a lot of state and rendering code mixed together. This improves code readability and makes it easier to introduce changes. 2. Introduces the possibility of code reuse. You can reuse an old view model with a new view or vice versa. 3. Adding to the point above, in future you could import element-web view models to your project and supply your own views thus creating something similar to the [hydrogen sdk](https://github.com/element-hq/hydrogen-web/blob/master/doc/SDK.md). - -### Example - -We started experimenting with MVVM in the redesigned memberlist, you can see the code [here](https://github.com/vector-im/element-web/blob/develop/src/components/views/rooms/MemberList/MemberListView.tsx). diff --git a/docs/config.md b/docs/config.md index f1ced14fd2..6f5315d257 100644 --- a/docs/config.md +++ b/docs/config.md @@ -130,32 +130,37 @@ complete re-branding/private labeling, a more personalised experience can be ach 6. `mobile_builds`: Optional. Like `desktop_builds`, except for the mobile apps. Also described in more detail down below. 7. `mobile_guide_toast`: When `true` (default), users accessing the Element Web instance from a mobile device will be prompted to download the app instead. -8. `update_base_url`: For the desktop app only, the URL where to acquire update packages. If specified, must be a path to a directory +8. `mobile_guide_app_variant`: Optional. The mobile app that the user is prompted to download from the `/mobile_guide` page. When omitted + the mobile guide will be configured for the new Element X apps. Allowed values are as follows: + 1. `element`: Element X Android/iOS. + 2. `element-classic`: Element Classic Android/iOS. + 3. `element-pro`: Element Pro Android/iOS. +9. `update_base_url`: For the desktop app only, the URL where to acquire update packages. If specified, must be a path to a directory containing `macos` and `win32` directories, with the update packages within. Defaults to `https://packages.element.io/desktop/update/` in production. -9. `map_style_url`: Map tile server style URL for location sharing. e.g. `https://api.maptiler.com/maps/streets/style.json?key=YOUR_KEY_GOES_HERE` - This setting is ignored if your homeserver provides `/.well-known/matrix/client` in its well-known location, and the JSON file - at that location has a key `m.tile_server` (or the unstable version `org.matrix.msc3488.tile_server`). In this case, the - configuration found in the well-known location is used instead. -10. `welcome_user_id`: **DEPRECATED** An optional user ID to start a DM with after creating an account. Defaults to nothing (no DM created). -11. `custom_translations_url`: An optional URL to allow overriding of translatable strings. The JSON file must be in a format of +10. `map_style_url`: Map tile server style URL for location sharing. e.g. `https://api.maptiler.com/maps/streets/style.json?key=YOUR_KEY_GOES_HERE` + This setting is ignored if your homeserver provides `/.well-known/matrix/client` in its well-known location, and the JSON file + at that location has a key `m.tile_server` (or the unstable version `org.matrix.msc3488.tile_server`). In this case, the + configuration found in the well-known location is used instead. +11. `welcome_user_id`: **DEPRECATED** An optional user ID to start a DM with after creating an account. Defaults to nothing (no DM created). +12. `custom_translations_url`: An optional URL to allow overriding of translatable strings. The JSON file must be in a format of `{"affected|translation|key": {"languageCode": "new string"}}`. See https://github.com/matrix-org/matrix-react-sdk/pull/7886 for details. -12. `branding`: Options for configuring various assets used within the app. Described in more detail down below. -13. `embedded_pages`: Further optional URLs for various assets used within the app. Described in more detail down below. -14. `disable_3pid_login`: When `false` (default), **enables** the options to log in with email address or phone number. Set to +13. `branding`: Options for configuring various assets used within the app. Described in more detail down below. +14. `embedded_pages`: Further optional URLs for various assets used within the app. Described in more detail down below. +15. `disable_3pid_login`: When `false` (default), **enables** the options to log in with email address or phone number. Set to `true` to hide these options. -15. `disable_login_language_selector`: When `false` (default), **enables** the language selector on the login pages. Set to `true` +16. `disable_login_language_selector`: When `false` (default), **enables** the language selector on the login pages. Set to `true` to hide this dropdown. -16. `disable_guests`: When `false` (default), **enable** guest-related functionality (peeking/previewing rooms, etc) for unregistered +17. `disable_guests`: When `false` (default), **enable** guest-related functionality (peeking/previewing rooms, etc) for unregistered users. Set to `true` to disable this functionality. -17. `user_notice`: Optional notice to show to the user, e.g. for sunsetting a deployment and pushing users to move in their own time. +18. `user_notice`: Optional notice to show to the user, e.g. for sunsetting a deployment and pushing users to move in their own time. Takes a configuration object as below: 1. `title`: Required. Title to show at the top of the notice. 2. `description`: Required. The description to use for the notice. 3. `show_once`: Optional. If true then the notice will only be shown once per device. -18. `help_url`: The URL to point users to for help with the app, defaults to `https://element.io/help`. -19. `help_encryption_url`: The URL to point users to for help with encryption, defaults to `https://element.io/help#encryption`. -20. `force_verification`: If true, users must verify new logins (eg. with another device / their recovery key) +19. `help_url`: The URL to point users to for help with the app, defaults to `https://element.io/help`. +20. `help_encryption_url`: The URL to point users to for help with encryption, defaults to `https://element.io/help#encryption`. +21. `force_verification`: If true, users must verify new logins (eg. with another device / their recovery key) ### `desktop_builds` and `mobile_builds` @@ -402,7 +407,7 @@ The VoIP and Jitsi options are: If you run your own rageshake server to collect bug reports, the following options may be of interest: 1. `bug_report_endpoint_url`: URL for where to submit rageshake logs to. Rageshakes include feedback submissions and bug reports. When - not present in the config, the app will disable all rageshake functionality. Set to `https://element.io/bugreports/submit` to submit + not present in the config, the app will disable all rageshake functionality. Set to `https://rageshakes.element.io/api/submit` to submit rageshakes to us, or use your own rageshake server. 2. `uisi_autorageshake_app`: If a user has enabled the "automatically send debug logs on decryption errors" flag, this option will be sent alongside the rageshake so the rageshake server can filter them by app name. By default, this will be `element-auto-uisi` @@ -445,8 +450,7 @@ If you would like to use Scalar, the integration manager maintained by Element, "https://scalar.vector.im/_matrix/integrations/v1", "https://scalar.vector.im/api", "https://scalar-staging.vector.im/_matrix/integrations/v1", - "https://scalar-staging.vector.im/api", - "https://scalar-staging.riot.im/scalar/api" + "https://scalar-staging.vector.im/api" ] } ``` @@ -581,6 +585,8 @@ Currently, the following UI feature flags are supported: - `UIFeature.BulkUnverifiedSessionsReminder` - Display popup reminders to verify or remove unverified sessions. Defaults to true. - `UIFeature.locationSharing` - Whether or not location sharing menus will be shown. +- `UIFeature.allowCreatingPublicRooms` - Whether or not public rooms can be created. +- `UIFeature.allowCreatingPublicSpaces` - Whether or not public spaces can be created. ## Undocumented / developer options diff --git a/docs/e2ee.md b/docs/e2ee.md index 835c38a1d5..2f960416b8 100644 --- a/docs/e2ee.md +++ b/docs/e2ee.md @@ -38,45 +38,20 @@ When `force_disable` is true: Note: If the server is configured to forcibly enable encryption for some or all rooms, this behaviour will be overridden. -# Secure backup +# Setting up recovery By default, Element strongly encourages (but does not require) users to set up -Secure Backup so that cross-signing identity key and message keys can be -recovered in case of a disaster where you lose access to all active devices. +recovery so that you can access history on your new devices as well as retain access to your message history and cryptographic identity when you lose all of your devices. -## Requiring secure backup +## Removal of old settings -To require Secure Backup to be configured before Element can be used, set the -following on your homeserver's `/.well-known/matrix/client` config: +Support for the configuration options `secure_backup_required` and `secure_backup_setup_methods` +in the `/.well-known/matrix/client` config has been removed. -```json -{ - "io.element.e2ee": { - "secure_backup_required": true - } -} -``` - -## Preferring setup methods - -By default, Element offers users a choice of a random key or user-chosen -passphrase when setting up Secure Backup. If a homeserver admin would like to -only offer one of these, you can signal this via the -`/.well-known/matrix/client` config, for example: - -```json -{ - "io.element.e2ee": { - "secure_backup_setup_methods": ["passphrase"] - } -} -``` - -The field `secure_backup_setup_methods` is an array listing the methods the -client should display. Supported values currently include `key` and -`passphrase`. If the `secure_backup_setup_methods` field is not present or -exists but does not contain any supported methods, Element will fallback to the -default value of: `["key", "passphrase"]`. +Setting up recovery is now always recommended to all users by showing a one-off toast and a +permanent red dot on the _Encryption_ tab in the _Settings_ dialog. When creating a new +recovery key, the UI only supports auto-generated keys. Using an existing (custom) passphrase +still works, but is not exposed in the UI when setting up recovery. # Compatibility diff --git a/docs/install.md b/docs/install.md index 5f9e6ddd2e..d80a844778 100644 --- a/docs/install.md +++ b/docs/install.md @@ -11,7 +11,7 @@ There are some exceptions like when using localhost, which is considered a [secu 1. Download the latest version from 1. Untar the tarball on your web server 1. Move (or symlink) the `element-x.x.x` directory to an appropriate name -1. Configure the correct caching headers in your webserver (see below) +1. Configure the correct caching headers in your webserver (see [README.md](../README.md#caching-requirements)) 1. Configure the app by copying `config.sample.json` to `config.json` and modifying it. See the [configuration docs](config.md) for details. 1. Enter the URL into your browser and log into Element! diff --git a/docs/kubernetes.md b/docs/kubernetes.md index cae8526e9c..39c10d330b 100644 --- a/docs/kubernetes.md +++ b/docs/kubernetes.md @@ -55,10 +55,9 @@ Then you can deploy it to your cluster with something like `kubectl apply -f my- "https://scalar.vector.im/_matrix/integrations/v1", "https://scalar.vector.im/api", "https://scalar-staging.vector.im/_matrix/integrations/v1", - "https://scalar-staging.vector.im/api", - "https://scalar-staging.riot.im/scalar/api" + "https://scalar-staging.vector.im/api" ], - "bug_report_endpoint_url": "https://element.io/bugreports/submit", + "bug_report_endpoint_url": "https://rageshakes.element.io/api/submit", "defaultCountryCode": "GB", "show_labs_settings": false, "features": { }, diff --git a/docs/labs.md b/docs/labs.md index 60f35dd4a4..a4ab78b08b 100644 --- a/docs/labs.md +++ b/docs/labs.md @@ -112,3 +112,25 @@ Enables knock feature for rooms. This allows users to ask to join a room. ## New room list (`feature_new_room_list`) [In Development] Enable the new room list that is currently in development. + +## Exclude insecure devices when sending/receiving messages (`feature_exclude_insecure_devices`) + +Do not send or receive messages to/from devices that are not properly verified. Users with unverified devices will not +receive your messages at all on those devices, and if they send messages, you will not be able to read them, but you +will be aware that a message exists. + +## Share encrypted history with new members (`feature_share_history_on_invite`) [In Development] + +When inviting users to an encrypted room with shared history (i.e. a room with the "Who can read history?" setting set +to "Members only (since the point in time of selecting this option)"), send the keys for previous messages to the +invitee so they can read them. + +Both the inviter and the invitee must set this labs flag, before the invitation is sent. + +## Encrypted state events (MSC4362) (`feature_msc4362_encrypted_state_events`) + +Encrypt most of the state events in the room, including the room name and topic. + +WARNING: this means that users joining a room who do not have access to its history will not be able to see the name or +topic of the room, or any other room state information. It also means the room name and topic are not available before +joining a room. diff --git a/element.io/app/config.json b/element.io/app/config.json index 771df35091..4f7bde39fb 100644 --- a/element.io/app/config.json +++ b/element.io/app/config.json @@ -15,10 +15,9 @@ "https://scalar.vector.im/_matrix/integrations/v1", "https://scalar.vector.im/api", "https://scalar-staging.vector.im/_matrix/integrations/v1", - "https://scalar-staging.vector.im/api", - "https://scalar-staging.riot.im/scalar/api" + "https://scalar-staging.vector.im/api" ], - "bug_report_endpoint_url": "https://element.io/bugreports/submit", + "bug_report_endpoint_url": "https://rageshakes.element.io/api/submit", "uisi_autorageshake_app": "element-auto-uisi", "show_labs_settings": false, "room_directory": { diff --git a/element.io/develop/config.json b/element.io/develop/config.json index aaee51afd0..7cfe544478 100644 --- a/element.io/develop/config.json +++ b/element.io/develop/config.json @@ -15,10 +15,9 @@ "https://scalar.vector.im/_matrix/integrations/v1", "https://scalar.vector.im/api", "https://scalar-staging.vector.im/_matrix/integrations/v1", - "https://scalar-staging.vector.im/api", - "https://scalar-staging.riot.im/scalar/api" + "https://scalar-staging.vector.im/api" ], - "bug_report_endpoint_url": "https://element.io/bugreports/submit", + "bug_report_endpoint_url": "https://rageshakes.element.io/api/submit", "uisi_autorageshake_app": "element-auto-uisi", "show_labs_settings": true, "room_directory": { diff --git a/jest.config.ts b/jest.config.ts index ad31f2fecc..148d55c94c 100644 --- a/jest.config.ts +++ b/jest.config.ts @@ -22,6 +22,8 @@ const config: Config = { setupFiles: ["jest-canvas-mock", "web-streams-polyfill/polyfill"], setupFilesAfterEnv: ["/test/setupTests.ts"], moduleNameMapper: { + // Support CSS module + "\\.(module.css)$": "identity-obj-proxy", "\\.(css|scss|pcss)$": "/__mocks__/cssMock.js", "\\.(gif|png|ttf|woff2)$": "/__mocks__/imageMock.js", "\\.svg$": "/__mocks__/svg.js", @@ -38,12 +40,14 @@ const config: Config = { "^!!raw-loader!.*": "jest-raw-loader", "recorderWorkletFactory": "/__mocks__/empty.js", "^fetch-mock$": "/node_modules/fetch-mock", - // Requires ESM which is incompatible with our current Jest setup - "^@element-hq/element-web-module-api$": "/__mocks__/empty.js", + "counterpart": "/node_modules/counterpart", }, - transformIgnorePatterns: ["/node_modules/(?!(mime|matrix-js-sdk)).+$"], + transformIgnorePatterns: [ + "/node_modules/(?!(mime|matrix-js-sdk|uuid|p-retry|is-network-error|react-merge-refs|is-ip|ip-regex|super-regex|function-timeout|time-span|convert-hrtime|clone-regexp|is-regexp)).+$", + ], collectCoverageFrom: [ "/src/**/*.{js,ts,tsx}", + "/packages/**/*.{js,ts,tsx}", // getSessionLock is piped into a different JS context via stringification, and the coverage functionality is // not available in that contest. So, turn off coverage instrumentation for it. "!/src/utils/SessionLock.ts", diff --git a/knip.ts b/knip.ts index e129c11481..24f8b97343 100644 --- a/knip.ts +++ b/knip.ts @@ -2,18 +2,16 @@ import { KnipConfig } from "knip"; export default { entry: [ - "src/vector/index.ts", "src/serviceworker/index.ts", "src/workers/*.worker.ts", "src/utils/exportUtils/exportJS.js", + "src/vector/localstorage-fix.ts", "scripts/**", "playwright/**", "test/**", "res/decoder-ring/**", "res/jitsi_external_api.min.js", "docs/**", - // Used by jest - "__mocks__/maplibre-gl.js", ], project: ["**/*.{js,ts,jsx,tsx}"], ignore: [ @@ -22,6 +20,8 @@ export default { "src/hooks/useTimeout.ts", "src/components/views/elements/InfoTooltip.tsx", "src/components/views/elements/StyledCheckbox.tsx", + + "packages/**/*", ], ignoreDependencies: [ // Required for `action-validator` @@ -42,6 +42,18 @@ export default { "util", // Embedded into webapp "@element-hq/element-call-embedded", + // Transitive dep of jest + "jsdom", + + // Used by matrix-js-sdk, which means we have to include them as a + // dependency so that // we can run `tsc` (since we import the typescript + // source of js-sdk, rather than the transpiled and annotated JS like you + // would with a normal library). + "@types/content-type", + "@types/sdp-transform", + + // Used in EW but failed because of "link:" + "@element-hq/web-shared-components", ], ignoreBinaries: [ // Used in scripts & workflows diff --git a/module_system/BuildConfig.ts b/module_system/BuildConfig.ts index cfb805840d..b9454e8418 100644 --- a/module_system/BuildConfig.ts +++ b/module_system/BuildConfig.ts @@ -6,7 +6,7 @@ Please see LICENSE files in the repository root for full details. */ import * as YAML from "yaml"; -import * as fs from "fs"; +import * as fs from "node:fs"; export type BuildConfig = { // Dev note: make everything here optional for user safety. Invalid diff --git a/module_system/installer.ts b/module_system/installer.ts index 66df4e92b6..dad65459f4 100644 --- a/module_system/installer.ts +++ b/module_system/installer.ts @@ -5,11 +5,11 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com Please see LICENSE files in the repository root for full details. */ -import * as fs from "fs"; -import * as childProcess from "child_process"; +import * as fs from "node:fs"; +import * as childProcess from "node:child_process"; import * as semver from "semver"; -import { type BuildConfig } from "./BuildConfig"; +import { type BuildConfig } from "./BuildConfig.ts"; // This expects to be run from ./scripts/install.ts diff --git a/module_system/scripts/install.ts b/module_system/scripts/install.ts index 0b6d2e1c29..5f96af1cd3 100644 --- a/module_system/scripts/install.ts +++ b/module_system/scripts/install.ts @@ -5,8 +5,8 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com Please see LICENSE files in the repository root for full details. */ -import { readBuildConfig } from "../BuildConfig"; -import { installer } from "../installer"; +import { readBuildConfig } from "../BuildConfig.ts"; +import { installer } from "../installer.ts"; const buildConf = readBuildConfig(); installer(buildConf); diff --git a/package.json b/package.json index f5ff367e3d..0bbd0283dd 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "element-web", - "version": "1.11.101", + "version": "1.12.7", "description": "Element: the future of secure communication", "author": "New Vector Ltd.", "repository": { @@ -29,7 +29,7 @@ "UserFriendlyError" ], "scripts": { - "i18n": "matrix-gen-i18n && yarn i18n:sort && yarn i18n:lint", + "i18n": "matrix-gen-i18n src res packages/shared-components/src && yarn i18n:sort && yarn i18n:lint", "i18n:sort": "jq --sort-keys '.' src/i18n/strings/en_EN.json > src/i18n/strings/en_EN.json.tmp && mv src/i18n/strings/en_EN.json.tmp src/i18n/strings/en_EN.json", "i18n:lint": "matrix-i18n-lint && prettier --log-level=silent --write src/i18n/strings/ --ignore-path /dev/null", "i18n:diff": "cp src/i18n/strings/en_EN.json src/i18n/strings/en_EN_orig.json && yarn i18n && matrix-compare-i18n-files src/i18n/strings/en_EN_orig.json src/i18n/strings/en_EN.json", @@ -38,16 +38,16 @@ "clean": "rimraf lib webapp", "build": "yarn clean && yarn build:genfiles && yarn build:bundle", "build-stats": "yarn clean && yarn build:genfiles && yarn build:bundle-stats", - "build:res": "ts-node scripts/copy-res.ts", + "build:res": "node scripts/copy-res.ts", "build:genfiles": "yarn build:res && yarn build:module_system", "build:modernizr": "modernizr -c .modernizr.json -d src/vector/modernizr.js", "build:bundle": "webpack --progress --mode production", "build:bundle-stats": "webpack --progress --mode production --json > webpack-stats.json", - "build:module_system": "ts-node --project ./tsconfig.module_system.json module_system/scripts/install.ts", + "build:module_system": "node module_system/scripts/install.ts", "dist": "./scripts/package.sh", "start": "concurrently --kill-others-on-fail --prefix \"{time} [{name}]\" -n modules,res \"yarn build:module_system\" \"yarn build:res\" && concurrently --kill-others-on-fail --prefix \"{time} [{name}]\" -n res,element-js \"yarn start:res\" \"yarn start:js\"", "start:https": "concurrently --kill-others-on-fail --prefix \"{time} [{name}]\" -n res,element-js \"yarn start:res\" \"yarn start:js --server-type https\"", - "start:res": "ts-node scripts/copy-res.ts -w", + "start:res": "node scripts/copy-res.ts -w", "start:js": "webpack serve --output-path webapp --output-filename=bundles/_dev_/[name].js --output-chunk-filename=bundles/_dev_/[name].js --mode development", "lint": "yarn lint:types && yarn lint:js && yarn lint:style && yarn lint:workflows", "lint:js": "eslint --max-warnings 0 src test playwright module_system && prettier --check .", @@ -65,38 +65,36 @@ "coverage": "yarn test --coverage", "analyse:webpack-bundles": "webpack-bundle-analyzer webpack-stats.json webapp", "update:jitsi": "curl -s https://meet.element.io/libs/external_api.min.js > ./res/jitsi_external_api.min.js", + "install": "yarn --cwd packages/shared-components install --frozen-lockfile", "postinstall": "patch-package" }, "resolutions": { - "**/pretty-format/react-is": "19.1.0", - "@playwright/test": "1.52.0", - "@types/react": "19.1.6", - "@types/react-dom": "19.1.5", - "oidc-client-ts": "3.2.1", + "**/pretty-format/react-is": "19.2.1", + "@types/react": "19.2.7", + "@types/react-dom": "19.2.3", + "oidc-client-ts": "3.4.1", "jwt-decode": "4.0.0", - "caniuse-lite": "1.0.30001720", + "caniuse-lite": "1.0.30001759", "testcontainers": "^11.0.0", "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0", "wrap-ansi": "npm:wrap-ansi@^7.0.0" }, "dependencies": { "@babel/runtime": "^7.12.5", - "@element-hq/element-web-module-api": "1.0.0", - "@fontsource/inconsolata": "^5", + "@element-hq/element-web-module-api": "1.9.0", + "@element-hq/web-shared-components": "link:packages/shared-components", + "@fontsource/fira-code": "^5", "@fontsource/inter": "^5", "@formatjs/intl-segmenter": "^11.5.7", - "@giphy/js-fetch-api": "^5.6.0", - "@giphy/react-components": "^10.0.1", - "@matrix-org/analytics-events": "^0.29.2", - "@matrix-org/emojibase-bindings": "^1.3.4", + "@matrix-org/analytics-events": "^0.30.0", + "@matrix-org/emojibase-bindings": "^1.5.0", "@matrix-org/react-sdk-module-api": "^2.4.0", "@matrix-org/spec": "^1.7.0", - "@sentry/browser": "^9.0.0", + "@sentry/browser": "^10.0.0", "@types/png-chunks-extract": "^1.0.2", - "@types/react-virtualized": "^9.21.30", - "@vector-im/compound-design-tokens": "^4.0.0", - "@vector-im/compound-web": "^7.11.0", - "@vector-im/matrix-wysiwyg": "2.38.3", + "@vector-im/compound-design-tokens": "6.4.3", + "@vector-im/compound-web": "^8.3.1", + "@vector-im/matrix-wysiwyg": "2.40.0", "@zxcvbn-ts/core": "^3.0.4", "@zxcvbn-ts/language-common": "^3.0.4", "@zxcvbn-ts/language-en": "^3.0.2", @@ -106,41 +104,40 @@ "browserslist": "^4.23.2", "classnames": "^2.2.6", "commonmark": "^0.31.0", - "counterpart": "^0.18.6", "css-tree": "^3.0.0", "diff-dom": "^5.0.0", "diff-match-patch": "^1.0.5", "domutils": "^3.2.2", - "emojibase-regex": "15.3.2", + "emojibase-regex": "^17.0.0", "escape-html": "^1.0.3", "file-saver": "^2.0.5", - "filesize": "10.1.6", + "filesize": "11.0.13", "github-markdown-css": "^5.5.1", "glob-to-regexp": "^0.4.1", "highlight.js": "^11.3.1", "html-entities": "^2.0.0", "html-react-parser": "^5.2.2", - "is-ip": "^3.1.0", - "js-xxhash": "^4.0.0", + "is-ip": "^5.0.0", + "js-xxhash": "^5.0.0", "jsrsasign": "^11.0.0", "jszip": "^3.7.0", "katex": "^0.16.0", - "linkify-react": "4.3.1", - "linkify-string": "4.3.1", - "linkifyjs": "4.3.1", + "linkify-html": "4.3.2", + "linkify-react": "4.3.2", + "linkify-string": "4.3.2", + "linkifyjs": "4.3.2", "lodash": "^4.17.21", "maplibre-gl": "^5.0.0", "matrix-encrypt-attachment": "^1.0.3", - "matrix-events-sdk": "0.0.1", "matrix-js-sdk": "github:matrix-org/matrix-js-sdk#develop", - "matrix-widget-api": "^1.10.0", + "matrix-widget-api": "^1.15.0", "memoize-one": "^6.0.0", "mime": "^4.0.4", "oidc-client-ts": "^3.0.1", "opus-recorder": "^8.0.3", "pako": "^2.0.3", "png-chunks-extract": "^1.0.0", - "posthog-js": "1.248.1", + "posthog-js": "1.302.2", "qrcode": "1.5.4", "re-resizable": "6.11.2", "react": "^19.0.0", @@ -148,17 +145,17 @@ "react-blurhash": "^0.3.0", "react-dom": "^19.0.0", "react-focus-lock": "^2.5.1", + "react-merge-refs": "^3.0.2", "react-string-replace": "^1.1.1", "react-transition-group": "^4.4.1", - "react-virtualized": "^9.22.5", + "react-virtuoso": "^4.14.0", "rfc4648": "^1.4.0", "sanitize-filename": "^1.6.3", "sanitize-html": "2.17.0", - "styled-components": "^6.1.17", "tar-js": "^0.3.0", "temporal-polyfill": "^0.3.0", - "ua-parser-js": "^1.0.2", - "uuid": "^11.0.0", + "ua-parser-js": "1.0.40", + "uuid": "^13.0.0", "what-input": "^5.2.10" }, "devDependencies": { @@ -182,21 +179,22 @@ "@babel/preset-react": "^7.12.10", "@babel/preset-typescript": "^7.12.7", "@babel/runtime": "^7.12.5", - "@casualbot/jest-sonar-reporter": "2.2.7", - "@element-hq/element-call-embedded": "0.12.0", - "@element-hq/element-web-playwright-common": "^1.1.5", + "@casualbot/jest-sonar-reporter": "2.5.0", + "@element-hq/element-call-embedded": "0.16.3", + "@element-hq/element-web-playwright-common": "^2.0.0", "@peculiar/webcrypto": "^1.4.3", - "@playwright/test": "^1.50.1", + "@playwright/test": "1.57.0", "@principalstudio/html-webpack-inject-preload": "^1.2.7", - "@rrweb/types": "^2.0.0-alpha.18", - "@sentry/webpack-plugin": "^3.0.0", - "@stylistic/eslint-plugin": "^4.0.0", + "@sentry/webpack-plugin": "^4.0.0", + "@storybook/react-vite": "^10.0.7", + "@stylistic/eslint-plugin": "^5.0.0", "@svgr/webpack": "^8.0.0", "@testing-library/dom": "^10.4.0", "@testing-library/jest-dom": "^6.4.8", "@testing-library/react": "^16.0.0", "@testing-library/user-event": "^14.5.2", "@types/commonmark": "^0.27.4", + "@types/content-type": "^1.1.9", "@types/counterpart": "^0.18.1", "@types/css-tree": "^2.3.8", "@types/diff-match-patch": "^1.0.32", @@ -204,7 +202,7 @@ "@types/express": "^5.0.0", "@types/file-saver": "^2.0.3", "@types/glob-to-regexp": "^0.4.1", - "@types/jest": "29.5.12", + "@types/jest": "30.0.0", "@types/jitsi-meet": "^2.0.2", "@types/jsrsasign": "^10.5.4", "@types/katex": "^0.16.0", @@ -215,53 +213,53 @@ "@types/node-fetch": "^2.6.2", "@types/pako": "^2.0.0", "@types/qrcode": "^1.3.5", - "@types/react": "19.1.6", + "@types/react": "19.2.7", "@types/react-beautiful-dnd": "^13.0.0", - "@types/react-dom": "19.1.5", + "@types/react-dom": "19.2.3", "@types/react-transition-group": "^4.4.0", "@types/sanitize-html": "2.16.0", + "@types/sdp-transform": "^2.4.10", "@types/semver": "^7.5.8", "@types/tar-js": "^0.3.5", "@types/ua-parser-js": "^0.7.36", - "@types/uuid": "^10.0.0", "@typescript-eslint/eslint-plugin": "^8.19.0", "@typescript-eslint/parser": "^8.19.0", - "babel-jest": "^29.0.0", + "babel-jest": "^30.0.0", "babel-loader": "^10.0.0", "babel-plugin-jsx-remove-data-test-id": "^3.0.0", "blob-polyfill": "^9.0.0", - "chokidar": "^4.0.0", + "chokidar": "^5.0.0", "concurrently": "^9.0.0", "copy-webpack-plugin": "^13.0.0", "core-js": "^3.38.1", - "cronstrue": "^2.41.0", + "cronstrue": "^3.0.0", "css-loader": "^7.0.0", "css-minimizer-webpack-plugin": "^7.0.0", - "dotenv": "^16.0.2", + "dotenv": "^17.0.0", "eslint": "8.57.1", "eslint-config-google": "^0.14.0", "eslint-config-prettier": "^10.0.0", - "eslint-plugin-deprecate": "0.8.5", + "eslint-plugin-deprecate": "0.8.7", "eslint-plugin-import": "^2.25.4", - "eslint-plugin-jest": "^28.0.0", + "eslint-plugin-jest": "^29.0.0", "eslint-plugin-jsx-a11y": "^6.5.1", - "eslint-plugin-matrix-org": "^2.0.2", + "eslint-plugin-matrix-org": "^3.0.0", "eslint-plugin-react": "^7.28.0", "eslint-plugin-react-compiler": "^19.0.0-beta-df7b47d-20241124", - "eslint-plugin-react-hooks": "^5.0.0", + "eslint-plugin-react-hooks": "^7.0.0", "eslint-plugin-unicorn": "^56.0.0", "express": "^5.0.0", "fake-indexeddb": "^6.0.0", "fetch-mock": "9.11.0", "fetch-mock-jest": "^1.5.1", "file-loader": "^6.0.0", - "glob": "^11.0.0", "html-webpack-plugin": "^5.5.3", "husky": "^9.0.0", - "jest": "^29.6.2", + "identity-obj-proxy": "^3.0.0", + "jest": "^30.0.0", "jest-canvas-mock": "^2.5.2", - "jest-environment-jsdom": "^29.7.0", - "jest-mock": "^29.6.2", + "jest-environment-jsdom": "^30.0.0", + "jest-mock": "^30.0.0", "jest-raw-loader": "^1.0.1", "jsqr": "^1.4.0", "knip": "^5.36.2", @@ -278,29 +276,29 @@ "postcss-hexrgba": "2.1.0", "postcss-import": "16.1.0", "postcss-loader": "8.1.1", - "postcss-mixins": "^11.0.0", + "postcss-mixins": "^12.0.0", "postcss-nested": "^7.0.0", "postcss-preset-env": "^10.0.0", "postcss-scss": "^4.0.4", "postcss-simple-vars": "^7.0.1", - "prettier": "3.5.3", + "prettier": "3.7.4", "process": "^0.11.10", "raw-loader": "^4.0.2", "rimraf": "^6.0.0", "semver": "^7.5.2", "source-map-loader": "^5.0.0", - "stylelint": "^16.13.0", - "stylelint-config-standard": "^38.0.0", + "storybook": "^10.0.7", + "stylelint": "^16.23.0", + "stylelint-config-standard": "^39.0.0", "stylelint-scss": "^6.0.0", "stylelint-value-no-unknown-custom-properties": "^6.0.1", "terser-webpack-plugin": "^5.3.9", "testcontainers": "^11.0.0", - "ts-node": "^10.9.1", "typescript": "5.8.3", "util": "^0.12.5", "web-streams-polyfill": "^4.0.0", "webpack": "^5.89.0", - "webpack-bundle-analyzer": "^4.8.0", + "webpack-bundle-analyzer": "^5.0.0", "webpack-cli": "^6.0.0", "webpack-dev-server": "^5.0.0", "webpack-retry-chunk-load-plugin": "^3.1.1", @@ -313,7 +311,7 @@ "relativePaths": true }, "engines": { - "node": ">=20.0.0" + "node": ">=22.18" }, "packageManager": "yarn@1.22.22+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e" } diff --git a/packages/shared-components/.eslintrc.js b/packages/shared-components/.eslintrc.js new file mode 100644 index 0000000000..3cff936e9c --- /dev/null +++ b/packages/shared-components/.eslintrc.js @@ -0,0 +1,72 @@ +/* +Copyright 2025 Element Creations Ltd. + +SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial +Please see LICENSE files in the repository root for full details. +*/ + +module.exports = { + root: true, + plugins: ["matrix-org", "eslint-plugin-react-compiler"], + extends: [ + "plugin:matrix-org/babel", + "plugin:matrix-org/react", + "plugin:matrix-org/a11y", + "plugin:storybook/recommended", + ], + parserOptions: { + project: ["./tsconfig.json"], + tsconfigRootDir: __dirname, + }, + env: { + browser: true, + node: true, + }, + rules: { + // Bind or arrow functions in props causes performance issues (but we + // currently use them in some places). + // It's disabled here, but we should using it sparingly. + "react/jsx-no-bind": "off", + "react/jsx-key": ["error"], + "matrix-org/require-copyright-header": "error", + "react-compiler/react-compiler": "error", + }, + overrides: [ + { + files: ["src/**/*.{ts,tsx}", "test/**/*.{ts,tsx}"], + extends: ["plugin:matrix-org/typescript", "plugin:matrix-org/react"], + rules: { + "@typescript-eslint/explicit-function-return-type": [ + "error", + { + allowExpressions: true, + }, + ], + + // Remove Babel things manually due to override limitations + "@babel/no-invalid-this": ["off"], + + // We're okay being explicit at the moment + "@typescript-eslint/no-empty-interface": "off", + // We disable this while we're transitioning + "@typescript-eslint/no-explicit-any": "off", + // We'd rather not do this but we do + "@typescript-eslint/ban-ts-comment": "off", + // We're okay with assertion errors when we ask for them + "@typescript-eslint/no-non-null-assertion": "off", + "@typescript-eslint/no-empty-object-type": [ + "error", + { + // We do this sometimes to brand interfaces + allowInterfaces: "with-single-extends", + }, + ], + }, + }, + ], + settings: { + react: { + version: "detect", + }, + }, +}; diff --git a/packages/shared-components/.prettierignore b/packages/shared-components/.prettierignore new file mode 100644 index 0000000000..bbb6b1ef7f --- /dev/null +++ b/packages/shared-components/.prettierignore @@ -0,0 +1,2 @@ +dist/ +i18n/i18nKeys.d.ts diff --git a/packages/shared-components/.storybook/ElementTheme.ts b/packages/shared-components/.storybook/ElementTheme.ts new file mode 100644 index 0000000000..0967697621 --- /dev/null +++ b/packages/shared-components/.storybook/ElementTheme.ts @@ -0,0 +1,28 @@ +/* +Copyright 2025 New Vector Ltd. + +SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial +Please see LICENSE files in the repository root for full details. +*/ + +import { create } from "storybook/theming"; + +export default create({ + base: "light", + + // Colors + textColor: "#1b1d22", + colorSecondary: "#111111", + + // UI + appBg: "#ffffff", + appContentBg: "#ffffff", + + // Toolbar + barBg: "#ffffff", + + brandTitle: "Element Web", + brandUrl: "https://github.com/element-hq/element-web", + brandImage: "https://element.io/images/logo-ele-secondary.svg", + brandTarget: "_self", +}); diff --git a/packages/shared-components/.storybook/languageAddon.tsx b/packages/shared-components/.storybook/languageAddon.tsx new file mode 100644 index 0000000000..e1483738f5 --- /dev/null +++ b/packages/shared-components/.storybook/languageAddon.tsx @@ -0,0 +1,61 @@ +/* + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial + * Please see LICENSE files in the repository root for full details. + */ + +import { Addon, types, useGlobals } from "storybook/manager-api"; +import { WithTooltip, IconButton, TooltipLinkList } from "storybook/internal/components"; +import React from "react"; +import { GlobeIcon } from "@storybook/icons"; + +// We can't import `shared/i18n.tsx` directly here. +// The storybook addon doesn't seem to benefit the vite config of storybook and we can't resolve the alias in i18n.tsx. +import json from "../../../webapp/i18n/languages.json"; +const languages = Object.keys(json).filter((lang) => lang !== "default"); + +/** + * Returns the title of a language in the user's locale. + */ +function languageTitle(language: string): string { + return new Intl.DisplayNames([language], { type: "language", style: "short" }).of(language) || language; +} + +export const languageAddon: Addon = { + title: "Language Selector", + type: types.TOOL, + render: ({ active }) => { + const [globals, updateGlobals] = useGlobals(); + const selectedLanguage = globals.language || "en"; + + return ( + { + return ( + ({ + id: language, + title: languageTitle(language), + active: selectedLanguage === language, + onClick: async () => { + // Update the global state with the selected language + updateGlobals({ language }); + onHide(); + }, + }))} + /> + ); + }} + > + + + {languageTitle(selectedLanguage)} + + + ); + }, +}; diff --git a/packages/shared-components/.storybook/main.ts b/packages/shared-components/.storybook/main.ts new file mode 100644 index 0000000000..efc35752b5 --- /dev/null +++ b/packages/shared-components/.storybook/main.ts @@ -0,0 +1,46 @@ +/* +Copyright 2025 New Vector Ltd. + +SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial +Please see LICENSE files in the repository root for full details. +*/ + +import type { StorybookConfig } from "@storybook/react-vite"; +import path from "node:path"; +import { nodePolyfills } from "vite-plugin-node-polyfills"; +import { mergeConfig } from "vite"; + +const config: StorybookConfig = { + stories: ["../src/**/*.stories.@(js|jsx|mjs|ts|tsx)"], + staticDirs: ["../../../webapp"], + addons: ["@storybook/addon-docs", "@storybook/addon-designs", "@storybook/addon-a11y"], + framework: "@storybook/react-vite", + core: { + disableTelemetry: true, + }, + typescript: { + reactDocgen: "react-docgen-typescript", + }, + async viteFinal(config) { + return mergeConfig(config, { + resolve: { + alias: { + // Alias used by i18n.tsx + $webapp: path.resolve("../../webapp"), + }, + }, + // Needed for counterpart to work + plugins: [nodePolyfills({ include: ["process", "util"] })], + server: { + allowedHosts: ["localhost", ".docker.internal"], + }, + }); + }, + refs: { + "compound-web": { + title: "Compound Web", + url: "https://element-hq.github.io/compound-web/", + }, + }, +}; +export default config; diff --git a/packages/shared-components/.storybook/manager.js b/packages/shared-components/.storybook/manager.js new file mode 100644 index 0000000000..1b08ef7825 --- /dev/null +++ b/packages/shared-components/.storybook/manager.js @@ -0,0 +1,18 @@ +/* +Copyright 2025 New Vector Ltd. + +SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial +Please see LICENSE files in the repository root for full details. +*/ + +import React from "react"; + +import { addons } from "storybook/manager-api"; +import ElementTheme from "./ElementTheme"; +import { languageAddon } from "./languageAddon"; + +addons.setConfig({ + theme: ElementTheme, +}); + +addons.register("elementhq/language", () => addons.add("language", languageAddon)); diff --git a/packages/shared-components/.storybook/preview.css b/packages/shared-components/.storybook/preview.css new file mode 100644 index 0000000000..9f49585937 --- /dev/null +++ b/packages/shared-components/.storybook/preview.css @@ -0,0 +1,10 @@ +/* +Copyright 2025 New Vector Ltd. + +SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial +Please see LICENSE files in the repository root for full details. +*/ + +.docs-story { + background: var(--cpd-color-bg-canvas-default); +} diff --git a/packages/shared-components/.storybook/preview.tsx b/packages/shared-components/.storybook/preview.tsx new file mode 100644 index 0000000000..91a6df646e --- /dev/null +++ b/packages/shared-components/.storybook/preview.tsx @@ -0,0 +1,102 @@ +import type { ArgTypes, Preview, Decorator, ReactRenderer, StrictArgs } from "@storybook/react-vite"; + +import "../../../res/css/shared.pcss"; +import "./preview.css"; +import React, { useLayoutEffect } from "react"; +import { setLanguage } from "../src/utils/i18n"; +import { TooltipProvider } from "@vector-im/compound-web"; +import { StoryContext } from "storybook/internal/csf"; +import { I18nApi, I18nContext } from "../src"; + +export const globalTypes = { + theme: { + name: "Theme", + description: "Global theme for components", + toolbar: { + icon: "circlehollow", + title: "Theme", + items: [ + { title: "System", value: "system", icon: "browser" }, + { title: "Light", value: "light", icon: "sun" }, + { title: "Light (high contrast)", value: "light-hc", icon: "sun" }, + { title: "Dark", value: "dark", icon: "moon" }, + { title: "Dark (high contrast)", value: "dark-hc", icon: "moon" }, + ], + }, + }, + language: { + name: "Language", + description: "Global language for components", + }, + initialGlobals: { + theme: "system", + language: "en", + }, +} satisfies ArgTypes; + +const allThemesClasses = globalTypes.theme.toolbar.items.map(({ value }) => `cpd-theme-${value}`); + +const ThemeSwitcher: React.FC<{ + theme: string; +}> = ({ theme }) => { + useLayoutEffect(() => { + document.documentElement.classList.remove(...allThemesClasses); + if (theme !== "system") { + document.documentElement.classList.add(`cpd-theme-${theme}`); + } + return () => document.documentElement.classList.remove(...allThemesClasses); + }, [theme]); + + return null; +}; + +const withThemeProvider: Decorator = (Story, context) => { + return ( + <> + + + + ); +}; + +async function languageLoader(context: StoryContext): Promise { + await setLanguage(context.globals.language); +} + +const withTooltipProvider: Decorator = (Story) => { + return ( + + + + ); +}; + +const withI18nProvider: Decorator = (Story) => { + return ( + + + + ); +}; + +const preview: Preview = { + tags: ["autodocs"], + decorators: [withThemeProvider, withTooltipProvider, withI18nProvider], + parameters: { + options: { + storySort: { + method: "alphabetical", + }, + }, + a11y: { + /* + * Configure test behavior + * See: https://storybook.js.org/docs/next/writing-tests/accessibility-testing#test-behavior + */ + test: "error", + }, + }, + loaders: [languageLoader], +}; + +export default preview; diff --git a/packages/shared-components/.storybook/test-runner.ts b/packages/shared-components/.storybook/test-runner.ts new file mode 100644 index 0000000000..5f0748115a --- /dev/null +++ b/packages/shared-components/.storybook/test-runner.ts @@ -0,0 +1,34 @@ +/* +Copyright 2025 New Vector Ltd. + +SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial +Please see LICENSE files in the repository root for full details. +*/ + +import { waitForPageReady, TestRunnerConfig } from "@storybook/test-runner"; +import { toMatchImageSnapshot } from "jest-image-snapshot"; + +const customSnapshotsDir = `${process.cwd()}/playwright/snapshots/`; +const customReceivedDir = `${process.cwd()}/playwright/received/`; + +const config: TestRunnerConfig = { + setup() { + expect.extend({ toMatchImageSnapshot }); + }, + async postVisit(page, context) { + await waitForPageReady(page); + + // If you want to take screenshot of multiple browsers, use + // page.context().browser().browserType().name() to get the browser name to prefix the file name + const image = await page.screenshot(); + expect(image).toMatchImageSnapshot({ + customSnapshotsDir, + customSnapshotIdentifier: `${context.id}-${process.platform}`, + storeReceivedOnFailure: true, + customReceivedDir, + customDiffDir: customReceivedDir, + }); + }, +}; + +export default config; diff --git a/packages/shared-components/babel.config.js b/packages/shared-components/babel.config.js new file mode 100644 index 0000000000..02ff2e43fe --- /dev/null +++ b/packages/shared-components/babel.config.js @@ -0,0 +1,21 @@ +module.exports = { + sourceMaps: true, + presets: [ + [ + "@babel/preset-env", + { + include: ["@babel/plugin-transform-class-properties"], + }, + ], + ["@babel/preset-typescript", { allowDeclareFields: true }], + "@babel/preset-react", + ], + plugins: [ + "@babel/plugin-proposal-export-default-from", + "@babel/plugin-transform-numeric-separator", + "@babel/plugin-transform-object-rest-spread", + "@babel/plugin-transform-optional-chaining", + "@babel/plugin-transform-nullish-coalescing-operator", + "@babel/plugin-transform-runtime", + ], +}; diff --git a/packages/shared-components/jest.config.ts b/packages/shared-components/jest.config.ts new file mode 100644 index 0000000000..30a96a6cb1 --- /dev/null +++ b/packages/shared-components/jest.config.ts @@ -0,0 +1,58 @@ +/* +Copyright 2025 Element Creations Ltd. + +SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial +Please see LICENSE files in the repository root for full details. +*/ + +import { env } from "process"; + +import type { Config } from "jest"; + +const config: Config = { + testEnvironment: "jsdom", + testEnvironmentOptions: { + url: "http://localhost/", + }, + testMatch: ["/src/**/*.test.[tj]s?(x)"], + setupFilesAfterEnv: ["/src/test/setupTests.ts"], + moduleNameMapper: { + // Support CSS module + "\\.(module.css)$": "identity-obj-proxy", + "\\.(css|scss|pcss)$": "/__mocks__/cssMock.js", + "\\.(gif|png|ttf|woff2)$": "/__mocks__/imageMock.js", + "\\.svg$": "/__mocks__/svg.js", + "\\$webapp/i18n/languages.json": "/../../__mocks__/languages.json", + "^react$": "/node_modules/react", + "^react-dom$": "/node_modules/react-dom", + "waveWorker\\.min\\.js": "/__mocks__/empty.js", + "context-filter-polyfill": "/__mocks__/empty.js", + "workers/(.+)Factory": "/__mocks__/workerFactoryMock.js", + }, + transformIgnorePatterns: [ + "/node_modules/(?!(mime|matrix-js-sdk|uuid|p-retry|is-network-error|react-merge-refs|@storybook|storybook)).+$", + ], + collectCoverageFrom: [ + "/src/**/*.{js,ts,tsx}", + "/packages/**/*.{js,ts,tsx}", + // Coverage chokes on type definition files + "!/src/**/*.d.ts", + ], + coverageReporters: ["text-summary", "lcov"], + testResultsProcessor: "@casualbot/jest-sonar-reporter", + prettierPath: null, + moduleDirectories: ["node_modules", "./src/test/utils"], +}; + +// if we're running under GHA, enable the GHA reporter +if (env["GITHUB_ACTIONS"] !== undefined) { + const reporters: Config["reporters"] = [["github-actions", { silent: false }], "summary"]; + + // if we're running against the develop branch, also enable the slow test reporter + if (env["GITHUB_REF"] == "refs/heads/develop") { + reporters.push("/../../test/slowReporter.cjs"); + } + config.reporters = reporters; +} + +export default config; diff --git a/packages/shared-components/package.json b/packages/shared-components/package.json new file mode 100644 index 0000000000..38c186a243 --- /dev/null +++ b/packages/shared-components/package.json @@ -0,0 +1,98 @@ +{ + "name": "@element-hq/web-shared-components", + "version": "0.0.0-test.12", + "description": "Shared components for Element", + "author": "New Vector Ltd.", + "repository": { + "type": "git", + "url": "https://github.com/element-hq/element-web" + }, + "exports": { + ".": { + "require": { + "style": "./dist/element-web-shared-components.css", + "types": "./dist/element-web-shared-components.d.ts", + "default": "./dist/element-web-shared-components.umd.js" + }, + "import": { + "style": "./dist/element-web-shared-components.css", + "types": "./dist/element-web-shared-components.d.ts", + "default": "./dist/element-web-shared-components.mjs" + } + }, + "./dist/element-web-shared-components.css": { + "require": "./dist/element-web-shared-components.css", + "import": "./dist/element-web-shared-components.css" + } + }, + "types": "dist/element-web-shared-components.d.ts", + "files": [ + "dist", + "src", + "LICENSE", + "README.md", + "package.json" + ], + "scripts": { + "test": "jest", + "prepare": "patch-package && yarn --cwd ../.. build:res && node scripts/gatherTranslationKeys.ts && vite build", + "storybook": "storybook dev -p 6007", + "build-storybook": "storybook build", + "lint": "yarn lint:types && yarn lint:js", + "lint:js": "eslint --max-warnings 0 src && prettier --check .", + "lint:types": "tsc --noEmit --jsx react", + "test:storybook": "test-storybook --url http://localhost:6007/", + "test:storybook:ci": "concurrently -k -s first -n \"SB,TEST\" \"yarn storybook --no-open\" \"wait-on tcp:6007 && yarn test-storybook --url http://localhost:6007/ --ci --maxWorkers=2\"", + "test:storybook:update": "playwright-screenshots --entrypoint /work/scripts/storybook-screenshot-update.sh --with-node-modules" + }, + "resolutions": { + "playwright": "1.57.0" + }, + "dependencies": { + "@element-hq/element-web-module-api": "^1.8.0", + "@vector-im/compound-design-tokens": "^6.3.0", + "classnames": "^2.5.1", + "counterpart": "^0.18.6", + "lodash": "^4.17.21", + "matrix-web-i18n": "^3.4.0", + "patch-package": "^8.0.1", + "react-merge-refs": "^3.0.2", + "temporal-polyfill": "^0.3.0" + }, + "devDependencies": { + "@element-hq/element-web-playwright-common": "^2.0.0", + "@playwright/test": "1.57.0", + "@storybook/addon-a11y": "^10.0.7", + "@storybook/addon-designs": "^11.0.1", + "@storybook/addon-docs": "^10.0.7", + "@storybook/icons": "^2.0.0", + "@storybook/react-vite": "^10.0.7", + "@storybook/test-runner": "^0.24.1", + "@testing-library/dom": "^10.4.1", + "@testing-library/react": "^16.3.0", + "@types/counterpart": "^0.18.4", + "@types/jest-image-snapshot": "^6.4.0", + "@types/lodash": "^4.17.20", + "@types/react": "^19.2.2", + "concurrently": "^9.2.1", + "eslint": "8", + "eslint-plugin-matrix-org": "^3.0.0", + "eslint-plugin-storybook": "^10.0.7", + "jest": "^30.2.0", + "jest-image-snapshot": "^6.5.1", + "patch-package": "^8.0.1", + "prettier": "^3.6.2", + "storybook": "^10.0.7", + "typescript": "^5.9.3", + "vite": "^7.1.9", + "vite-plugin-dts": "^4.5.4", + "vite-plugin-node-polyfills": "^0.24.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "packageManager": "yarn@1.22.22+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e", + "peerDependencies": { + "@vector-im/compound-web": "^8.2.5" + } +} diff --git a/packages/shared-components/patches/@types+mdx+2.0.13.patch b/packages/shared-components/patches/@types+mdx+2.0.13.patch new file mode 100644 index 0000000000..d3d02974f7 --- /dev/null +++ b/packages/shared-components/patches/@types+mdx+2.0.13.patch @@ -0,0 +1,46 @@ +diff --git a/node_modules/@types/mdx/types.d.ts b/node_modules/@types/mdx/types.d.ts +index 498bb69..4e89216 100644 +--- a/node_modules/@types/mdx/types.d.ts ++++ b/node_modules/@types/mdx/types.d.ts +@@ -5,7 +5,7 @@ + */ + // @ts-ignore JSX runtimes may optionally define JSX.ElementType. The MDX types need to work regardless whether this is + // defined or not. +-type ElementType = any extends JSX.ElementType ? never : JSX.ElementType; ++type ElementType = any extends JSX.ElementType ? never : React.JSX.ElementType; + + /** + * This matches any function component types that ar part of `ElementType`. +@@ -20,12 +20,12 @@ type ClassElementType = Extract) => + /** + * A valid JSX string component. + */ +-type StringComponent = Extract; ++type StringComponent = Extract; + + /** + * A JSX element returned by MDX content. + */ +-export type Element = JSX.Element; ++export type Element = React.JSX.Element; + + /** + * A valid JSX function component. +@@ -44,7 +44,7 @@ type FunctionComponent = ElementType extends never + */ + type ClassComponent = ElementType extends never + // If JSX.ElementType isn’t defined, the valid return type is a constructor that returns JSX.ElementClass +- ? new(props: Props) => JSX.ElementClass ++ ? new(props: Props) => React.JSX.ElementClass + : ClassElementType extends never + // If JSX.ElementType is defined, but doesn’t allow constructors, function components are disallowed. + ? never +@@ -70,7 +70,7 @@ interface NestedMDXComponents { + export type MDXComponents = + & NestedMDXComponents + & { +- [Key in StringComponent]?: Component; ++ [Key in StringComponent]?: Component; + } + & { + /** diff --git a/packages/shared-components/playwright/snapshots/audio-audioplayerview--default-linux.png b/packages/shared-components/playwright/snapshots/audio-audioplayerview--default-linux.png new file mode 100644 index 0000000000..e4d6a84d23 Binary files /dev/null and b/packages/shared-components/playwright/snapshots/audio-audioplayerview--default-linux.png differ diff --git a/packages/shared-components/playwright/snapshots/audio-audioplayerview--has-error-linux.png b/packages/shared-components/playwright/snapshots/audio-audioplayerview--has-error-linux.png new file mode 100644 index 0000000000..36684675f7 Binary files /dev/null and b/packages/shared-components/playwright/snapshots/audio-audioplayerview--has-error-linux.png differ diff --git a/packages/shared-components/playwright/snapshots/audio-audioplayerview--no-media-name-linux.png b/packages/shared-components/playwright/snapshots/audio-audioplayerview--no-media-name-linux.png new file mode 100644 index 0000000000..c46e59ac21 Binary files /dev/null and b/packages/shared-components/playwright/snapshots/audio-audioplayerview--no-media-name-linux.png differ diff --git a/packages/shared-components/playwright/snapshots/audio-audioplayerview--no-size-linux.png b/packages/shared-components/playwright/snapshots/audio-audioplayerview--no-size-linux.png new file mode 100644 index 0000000000..928f6f0197 Binary files /dev/null and b/packages/shared-components/playwright/snapshots/audio-audioplayerview--no-size-linux.png differ diff --git a/packages/shared-components/playwright/snapshots/audio-clock--default-linux.png b/packages/shared-components/playwright/snapshots/audio-clock--default-linux.png new file mode 100644 index 0000000000..be66f4b70c Binary files /dev/null and b/packages/shared-components/playwright/snapshots/audio-clock--default-linux.png differ diff --git a/packages/shared-components/playwright/snapshots/audio-clock--lot-of-seconds-linux.png b/packages/shared-components/playwright/snapshots/audio-clock--lot-of-seconds-linux.png new file mode 100644 index 0000000000..b4879e1a0c Binary files /dev/null and b/packages/shared-components/playwright/snapshots/audio-clock--lot-of-seconds-linux.png differ diff --git a/packages/shared-components/playwright/snapshots/audio-playpausebutton--default-linux.png b/packages/shared-components/playwright/snapshots/audio-playpausebutton--default-linux.png new file mode 100644 index 0000000000..8c8baa7a65 Binary files /dev/null and b/packages/shared-components/playwright/snapshots/audio-playpausebutton--default-linux.png differ diff --git a/packages/shared-components/playwright/snapshots/audio-playpausebutton--playing-linux.png b/packages/shared-components/playwright/snapshots/audio-playpausebutton--playing-linux.png new file mode 100644 index 0000000000..53d58a4c2f Binary files /dev/null and b/packages/shared-components/playwright/snapshots/audio-playpausebutton--playing-linux.png differ diff --git a/packages/shared-components/playwright/snapshots/audio-seekbar--default-linux.png b/packages/shared-components/playwright/snapshots/audio-seekbar--default-linux.png new file mode 100644 index 0000000000..60e51020cf Binary files /dev/null and b/packages/shared-components/playwright/snapshots/audio-seekbar--default-linux.png differ diff --git a/packages/shared-components/playwright/snapshots/audio-seekbar--disabled-linux.png b/packages/shared-components/playwright/snapshots/audio-seekbar--disabled-linux.png new file mode 100644 index 0000000000..128f7e2ee5 Binary files /dev/null and b/packages/shared-components/playwright/snapshots/audio-seekbar--disabled-linux.png differ diff --git a/packages/shared-components/playwright/snapshots/avatar-avatarwithdetails--default-linux.png b/packages/shared-components/playwright/snapshots/avatar-avatarwithdetails--default-linux.png new file mode 100644 index 0000000000..ae339219e5 Binary files /dev/null and b/packages/shared-components/playwright/snapshots/avatar-avatarwithdetails--default-linux.png differ diff --git a/packages/shared-components/playwright/snapshots/composer-historyvisiblebannerview--default-linux.png b/packages/shared-components/playwright/snapshots/composer-historyvisiblebannerview--default-linux.png new file mode 100644 index 0000000000..7ab93a0cb8 Binary files /dev/null and b/packages/shared-components/playwright/snapshots/composer-historyvisiblebannerview--default-linux.png differ diff --git a/packages/shared-components/playwright/snapshots/event-textualevent--default-linux.png b/packages/shared-components/playwright/snapshots/event-textualevent--default-linux.png new file mode 100644 index 0000000000..16855e8448 Binary files /dev/null and b/packages/shared-components/playwright/snapshots/event-textualevent--default-linux.png differ diff --git a/packages/shared-components/playwright/snapshots/messagebody-mediabody--default-linux.png b/packages/shared-components/playwright/snapshots/messagebody-mediabody--default-linux.png new file mode 100644 index 0000000000..56b8072d2d Binary files /dev/null and b/packages/shared-components/playwright/snapshots/messagebody-mediabody--default-linux.png differ diff --git a/packages/shared-components/playwright/snapshots/pillinput-pill--default-linux.png b/packages/shared-components/playwright/snapshots/pillinput-pill--default-linux.png new file mode 100644 index 0000000000..cd8eb65ba1 Binary files /dev/null and b/packages/shared-components/playwright/snapshots/pillinput-pill--default-linux.png differ diff --git a/packages/shared-components/playwright/snapshots/pillinput-pill--without-close-button-linux.png b/packages/shared-components/playwright/snapshots/pillinput-pill--without-close-button-linux.png new file mode 100644 index 0000000000..451de895dc Binary files /dev/null and b/packages/shared-components/playwright/snapshots/pillinput-pill--without-close-button-linux.png differ diff --git a/packages/shared-components/playwright/snapshots/pillinput-pillinput--default-linux.png b/packages/shared-components/playwright/snapshots/pillinput-pillinput--default-linux.png new file mode 100644 index 0000000000..93bf6317a9 Binary files /dev/null and b/packages/shared-components/playwright/snapshots/pillinput-pillinput--default-linux.png differ diff --git a/packages/shared-components/playwright/snapshots/pillinput-pillinput--no-child-linux.png b/packages/shared-components/playwright/snapshots/pillinput-pillinput--no-child-linux.png new file mode 100644 index 0000000000..a303e726b5 Binary files /dev/null and b/packages/shared-components/playwright/snapshots/pillinput-pillinput--no-child-linux.png differ diff --git a/packages/shared-components/playwright/snapshots/richlist-richitem--default-linux.png b/packages/shared-components/playwright/snapshots/richlist-richitem--default-linux.png new file mode 100644 index 0000000000..9a5ad9eb0f Binary files /dev/null and b/packages/shared-components/playwright/snapshots/richlist-richitem--default-linux.png differ diff --git a/packages/shared-components/playwright/snapshots/richlist-richitem--hover-linux.png b/packages/shared-components/playwright/snapshots/richlist-richitem--hover-linux.png new file mode 100644 index 0000000000..9a5ad9eb0f Binary files /dev/null and b/packages/shared-components/playwright/snapshots/richlist-richitem--hover-linux.png differ diff --git a/packages/shared-components/playwright/snapshots/richlist-richitem--selected-linux.png b/packages/shared-components/playwright/snapshots/richlist-richitem--selected-linux.png new file mode 100644 index 0000000000..f9c92b066f Binary files /dev/null and b/packages/shared-components/playwright/snapshots/richlist-richitem--selected-linux.png differ diff --git a/packages/shared-components/playwright/snapshots/richlist-richitem--separator-linux.png b/packages/shared-components/playwright/snapshots/richlist-richitem--separator-linux.png new file mode 100644 index 0000000000..a405f35902 Binary files /dev/null and b/packages/shared-components/playwright/snapshots/richlist-richitem--separator-linux.png differ diff --git a/packages/shared-components/playwright/snapshots/richlist-richitem--without-timestamp-linux.png b/packages/shared-components/playwright/snapshots/richlist-richitem--without-timestamp-linux.png new file mode 100644 index 0000000000..de5ecda7c3 Binary files /dev/null and b/packages/shared-components/playwright/snapshots/richlist-richitem--without-timestamp-linux.png differ diff --git a/packages/shared-components/playwright/snapshots/richlist-richlist--default-linux.png b/packages/shared-components/playwright/snapshots/richlist-richlist--default-linux.png new file mode 100644 index 0000000000..7919f356df Binary files /dev/null and b/packages/shared-components/playwright/snapshots/richlist-richlist--default-linux.png differ diff --git a/packages/shared-components/playwright/snapshots/richlist-richlist--empty-linux.png b/packages/shared-components/playwright/snapshots/richlist-richlist--empty-linux.png new file mode 100644 index 0000000000..f655ecab96 Binary files /dev/null and b/packages/shared-components/playwright/snapshots/richlist-richlist--empty-linux.png differ diff --git a/packages/shared-components/playwright/snapshots/room-banner--critical-linux.png b/packages/shared-components/playwright/snapshots/room-banner--critical-linux.png new file mode 100644 index 0000000000..d920c59664 Binary files /dev/null and b/packages/shared-components/playwright/snapshots/room-banner--critical-linux.png differ diff --git a/packages/shared-components/playwright/snapshots/room-banner--default-linux.png b/packages/shared-components/playwright/snapshots/room-banner--default-linux.png new file mode 100644 index 0000000000..39079994d2 Binary files /dev/null and b/packages/shared-components/playwright/snapshots/room-banner--default-linux.png differ diff --git a/packages/shared-components/playwright/snapshots/room-banner--info-linux.png b/packages/shared-components/playwright/snapshots/room-banner--info-linux.png new file mode 100644 index 0000000000..7d93288e42 Binary files /dev/null and b/packages/shared-components/playwright/snapshots/room-banner--info-linux.png differ diff --git a/packages/shared-components/playwright/snapshots/room-banner--success-linux.png b/packages/shared-components/playwright/snapshots/room-banner--success-linux.png new file mode 100644 index 0000000000..8417f0aee3 Binary files /dev/null and b/packages/shared-components/playwright/snapshots/room-banner--success-linux.png differ diff --git a/packages/shared-components/playwright/snapshots/room-banner--with-action-linux.png b/packages/shared-components/playwright/snapshots/room-banner--with-action-linux.png new file mode 100644 index 0000000000..b3968f9db7 Binary files /dev/null and b/packages/shared-components/playwright/snapshots/room-banner--with-action-linux.png differ diff --git a/packages/shared-components/playwright/snapshots/room-banner--with-avatar-image-linux.png b/packages/shared-components/playwright/snapshots/room-banner--with-avatar-image-linux.png new file mode 100644 index 0000000000..d015a12a00 Binary files /dev/null and b/packages/shared-components/playwright/snapshots/room-banner--with-avatar-image-linux.png differ diff --git a/packages/shared-components/playwright/snapshots/room-banner--with-loads-of-content-linux.png b/packages/shared-components/playwright/snapshots/room-banner--with-loads-of-content-linux.png new file mode 100644 index 0000000000..85372e48fa Binary files /dev/null and b/packages/shared-components/playwright/snapshots/room-banner--with-loads-of-content-linux.png differ diff --git a/packages/shared-components/playwright/snapshots/room-banner--without-close-linux.png b/packages/shared-components/playwright/snapshots/room-banner--without-close-linux.png new file mode 100644 index 0000000000..9645bd1d8a Binary files /dev/null and b/packages/shared-components/playwright/snapshots/room-banner--without-close-linux.png differ diff --git a/packages/shared-components/playwright/snapshots/room-list-roomlistsearchview--all-buttons-linux.png b/packages/shared-components/playwright/snapshots/room-list-roomlistsearchview--all-buttons-linux.png new file mode 100644 index 0000000000..0d7f17d511 Binary files /dev/null and b/packages/shared-components/playwright/snapshots/room-list-roomlistsearchview--all-buttons-linux.png differ diff --git a/packages/shared-components/playwright/snapshots/room-list-roomlistsearchview--default-linux.png b/packages/shared-components/playwright/snapshots/room-list-roomlistsearchview--default-linux.png new file mode 100644 index 0000000000..2f41682551 Binary files /dev/null and b/packages/shared-components/playwright/snapshots/room-list-roomlistsearchview--default-linux.png differ diff --git a/packages/shared-components/playwright/snapshots/room-list-roomlistsearchview--with-dial-pad-linux.png b/packages/shared-components/playwright/snapshots/room-list-roomlistsearchview--with-dial-pad-linux.png new file mode 100644 index 0000000000..0d7f17d511 Binary files /dev/null and b/packages/shared-components/playwright/snapshots/room-list-roomlistsearchview--with-dial-pad-linux.png differ diff --git a/packages/shared-components/playwright/snapshots/room-list-roomlistsearchview--without-explore-linux.png b/packages/shared-components/playwright/snapshots/room-list-roomlistsearchview--without-explore-linux.png new file mode 100644 index 0000000000..e3e14ccfb4 Binary files /dev/null and b/packages/shared-components/playwright/snapshots/room-list-roomlistsearchview--without-explore-linux.png differ diff --git a/packages/shared-components/scripts/gatherTranslationKeys.ts b/packages/shared-components/scripts/gatherTranslationKeys.ts new file mode 100644 index 0000000000..37812df33b --- /dev/null +++ b/packages/shared-components/scripts/gatherTranslationKeys.ts @@ -0,0 +1,67 @@ +/* +Copyright 2025 Element Creations Ltd. + +SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial +Please see LICENSE files in the repository root for full details. +*/ + +// Gathers all the translation keys from element-web's en_EN.json into a TypeScript type definition file +// that exports a type `TranslationKey` which is a union of all supported translation keys. +// This prevents having to import the json file and make typescript do the work as this results in vite-dts +// generating an import to the json file in the .d.ts which doesn't work at runtime: this way, the type +// gets put into the bundle. +// XXX: It should *not* be in the 'src' directory, being a generated file, but if it isn't then the type +// bundler won't bundle the types and will leave the file as a relative import, which will break. + +import * as fs from "fs"; +import * as path from "path"; +import { dirname } from "node:path"; +import { fileURLToPath } from "node:url"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const i18nStringsPath = path.resolve(__dirname, "../../../src/i18n/strings/en_EN.json"); +const outPath = path.resolve(__dirname, "../src/i18nKeys.d.ts"); + +function gatherKeys(obj: any, prefix: string[] = []): string[] { + if (typeof obj !== "object" || obj === null) return []; + let keys: string[] = []; + for (const key of Object.keys(obj)) { + const value = obj[key]; + + // add the path (for both leaves and intermediates as then we include plurals) + keys.push([...prefix, key].join("|")); + if (typeof value === "object" && value !== null) { + // If the value is an object, recurse + keys = keys.concat(gatherKeys(value, [...prefix, key])); + } + } + return keys; +} + +function main() { + const json = JSON.parse(fs.readFileSync(i18nStringsPath, "utf8")); + const keys = gatherKeys(json); + const typeDef = + "/*\n" + + " * Copyright 2025 Element Creations Ltd.\n" + + " *\n" + + " * SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial\n" + + " * Please see LICENSE files in the repository root for full details.\n" + + " */\n" + + "\n" + + "// This file is auto-generated by gatherTranslationKeys.ts\n" + + "// Do not edit manually.\n\n" + + "export type TranslationKey =\n" + + keys.map((k) => ` | \"${k}\"`).join("\n") + + ";\n"; + fs.mkdirSync(path.dirname(outPath), { recursive: true }); + fs.writeFileSync(outPath, typeDef, "utf8"); + console.log(`Wrote ${keys.length} keys to ${outPath}`); +} + +if (import.meta.url.startsWith("file:")) { + const modulePath = fileURLToPath(import.meta.url); + if (process.argv[1] === modulePath) { + main(); + } +} diff --git a/packages/shared-components/scripts/storybook-screenshot-update.sh b/packages/shared-components/scripts/storybook-screenshot-update.sh new file mode 100755 index 0000000000..6310d6887f --- /dev/null +++ b/packages/shared-components/scripts/storybook-screenshot-update.sh @@ -0,0 +1,32 @@ +#!/bin/bash +# +# Update storybook screenshots +# +# This script should be used as the entrypoint parameter of the `playwright-screenshots` script. It +# installs the yarn dependencies, and then runs `test-storybook` to update the storybook screenshots. +# +# It expects to find a storybook instance running at :6007 on the host machine. It also requires that +# `playwright-screenshots` is given the `--with-node-modules` parameter. +# +# Example: +# +# test-storybook --url http://localhost:6007/ +# playwright-screenshots --entrypoint /work/scripts/storybook-screenshot-update.sh --with-node-modules +# +# +# Note: even though this script is small, it is important because the alternative is running +# `playwright-screenshots` twice in quick succession (once to do `yarn install`, a second to do the +# actual updates): and that fails, because running `playwright-screenshots` without actually starting +# Testcontainers leaves a ryuk container hanging around for up to 60s, which will block the second +# invocation. + +set -e + +# First install dependencies. We have to do this within the playwright container rather than the host, +# because we have which must be built for the right architecture (and some environments use a VM +# to run docker containers, meaning that things inside a container use a different architecture than +# those on the host). +yarn + +# Now run the screenshot update +/work/node_modules/.bin/test-storybook --url http://host.docker.internal:6007/ --updateSnapshot diff --git a/packages/shared-components/src/@types/global.d.ts b/packages/shared-components/src/@types/global.d.ts new file mode 100644 index 0000000000..1334c41737 --- /dev/null +++ b/packages/shared-components/src/@types/global.d.ts @@ -0,0 +1,8 @@ +/* +Copyright 2025 New Vector Ltd. + +SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial +Please see LICENSE files in the repository root for full details. +*/ + +declare module "*.css"; diff --git a/packages/shared-components/src/audio/AudioPlayerView/AudioPlayerView.module.css b/packages/shared-components/src/audio/AudioPlayerView/AudioPlayerView.module.css new file mode 100644 index 0000000000..fc8c26aef7 --- /dev/null +++ b/packages/shared-components/src/audio/AudioPlayerView/AudioPlayerView.module.css @@ -0,0 +1,36 @@ +/* + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial + * Please see LICENSE files in the repository root for full details. + */ + +.audioPlayer { + padding: var(--cpd-space-4x) var(--cpd-space-3x) var(--cpd-space-3x) var(--cpd-space-3x) !important; +} + +.mediaInfo { + /* Makes the ellipsis on the file name work */ + overflow: hidden; +} + +.mediaName { + color: var(--cpd-color-text-primary); + font: var(--cpd-font-body-md-regular); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + max-width: 100%; +} + +.byline { + font: var(--cpd-font-body-xs-regular); +} + +.clock { + white-space: nowrap; +} + +.error { + color: var(--cpd-color-text-critical-primary); +} diff --git a/packages/shared-components/src/audio/AudioPlayerView/AudioPlayerView.stories.tsx b/packages/shared-components/src/audio/AudioPlayerView/AudioPlayerView.stories.tsx new file mode 100644 index 0000000000..18782f25cb --- /dev/null +++ b/packages/shared-components/src/audio/AudioPlayerView/AudioPlayerView.stories.tsx @@ -0,0 +1,66 @@ +/* + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial + * Please see LICENSE files in the repository root for full details. + */ + +import React, { type JSX } from "react"; +import { fn } from "storybook/test"; + +import type { Meta, StoryFn } from "@storybook/react-vite"; +import { AudioPlayerView, type AudioPlayerViewActions, type AudioPlayerViewSnapshot } from "./AudioPlayerView"; +import { useMockedViewModel } from "../../useMockedViewModel"; + +type AudioPlayerProps = AudioPlayerViewSnapshot & AudioPlayerViewActions; +const AudioPlayerViewWrapper = ({ togglePlay, onKeyDown, onSeekbarChange, ...rest }: AudioPlayerProps): JSX.Element => { + const vm = useMockedViewModel(rest, { + togglePlay, + onKeyDown, + onSeekbarChange, + }); + return ; +}; + +export default { + title: "Audio/AudioPlayerView", + component: AudioPlayerViewWrapper, + tags: ["autodocs"], + argTypes: { + playbackState: { + options: ["stopped", "playing", "paused", "decoding"], + control: { type: "select" }, + }, + }, + args: { + mediaName: "Sample Audio", + durationSeconds: 300, + playedSeconds: 120, + percentComplete: 30, + playbackState: "stopped", + sizeBytes: 3500, + error: false, + togglePlay: fn(), + onKeyDown: fn(), + onSeekbarChange: fn(), + }, +} as Meta; + +const Template: StoryFn = (args) => ; + +export const Default = Template.bind({}); + +export const NoMediaName = Template.bind({}); +NoMediaName.args = { + mediaName: undefined, +}; + +export const NoSize = Template.bind({}); +NoSize.args = { + sizeBytes: undefined, +}; + +export const HasError = Template.bind({}); +HasError.args = { + error: true, +}; diff --git a/packages/shared-components/src/audio/AudioPlayerView/AudioPlayerView.test.tsx b/packages/shared-components/src/audio/AudioPlayerView/AudioPlayerView.test.tsx new file mode 100644 index 0000000000..55e159f110 --- /dev/null +++ b/packages/shared-components/src/audio/AudioPlayerView/AudioPlayerView.test.tsx @@ -0,0 +1,82 @@ +/* + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial + * Please see LICENSE files in the repository root for full details. + */ + +import { render, screen } from "jest-matrix-react"; +import { composeStories } from "@storybook/react-vite"; +import React from "react"; +import userEvent from "@testing-library/user-event"; +import { fireEvent } from "@testing-library/dom"; + +import * as stories from "./AudioPlayerView.stories.tsx"; +import { AudioPlayerView, type AudioPlayerViewActions, type AudioPlayerViewSnapshot } from "./AudioPlayerView"; +import { MockViewModel } from "../../viewmodel/MockViewModel.ts"; +import { I18nContext } from "../../utils/i18nContext.ts"; +import { I18nApi } from "../../index.ts"; + +const { Default, NoMediaName, NoSize, HasError } = composeStories(stories); + +describe("AudioPlayerView", () => { + afterEach(() => { + jest.clearAllMocks(); + }); + + it("renders the audio player in default state", () => { + const { container } = render(); + expect(container).toMatchSnapshot(); + }); + + it("renders the audio player without media name", () => { + const { container } = render(); + expect(container).toMatchSnapshot(); + }); + + it("renders the audio player without size", () => { + const { container } = render(); + expect(container).toMatchSnapshot(); + }); + + it("renders the audio player in error state", () => { + const { container } = render(); + expect(container).toMatchSnapshot(); + }); + + const onKeyDown = jest.fn(); + const togglePlay = jest.fn(); + const onSeekbarChange = jest.fn(); + + class AudioPlayerViewModel extends MockViewModel implements AudioPlayerViewActions { + public onKeyDown = onKeyDown; + public togglePlay = togglePlay; + public onSeekbarChange = onSeekbarChange; + } + + it("should attach vm methods", async () => { + const user = userEvent.setup(); + const vm = new AudioPlayerViewModel({ + playbackState: "stopped", + mediaName: "Test Audio", + durationSeconds: 300, + playedSeconds: 120, + percentComplete: 30, + sizeBytes: 3500, + error: false, + }); + + render(, { + wrapper: ({ children }) => {children}, + }); + await user.click(screen.getByRole("button", { name: "Play" })); + expect(togglePlay).toHaveBeenCalled(); + + // user event doesn't support change events on sliders, so we use fireEvent + fireEvent.change(screen.getByRole("slider", { name: "Audio seek bar" }), { target: { value: "50" } }); + expect(onSeekbarChange).toHaveBeenCalled(); + + await user.type(screen.getByLabelText("Audio player"), "{arrowup}"); + expect(onKeyDown).toHaveBeenCalledWith(expect.objectContaining({ key: "ArrowUp" })); + }); +}); diff --git a/packages/shared-components/src/audio/AudioPlayerView/AudioPlayerView.tsx b/packages/shared-components/src/audio/AudioPlayerView/AudioPlayerView.tsx new file mode 100644 index 0000000000..d3c9fb87ee --- /dev/null +++ b/packages/shared-components/src/audio/AudioPlayerView/AudioPlayerView.tsx @@ -0,0 +1,145 @@ +/* + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial + * Please see LICENSE files in the repository root for full details. + */ + +import React, { type ChangeEventHandler, type JSX, type KeyboardEventHandler, type MouseEventHandler } from "react"; + +import { type ViewModel } from "../../viewmodel/ViewModel"; +import { useViewModel } from "../../useViewModel"; +import { MediaBody } from "../../message-body/MediaBody"; +import { Flex } from "../../utils/Flex"; +import styles from "./AudioPlayerView.module.css"; +import { PlayPauseButton } from "../PlayPauseButton"; +import { type PlaybackState } from "../playback"; +import { useI18n } from "../../utils/i18nContext"; +import { formatBytes } from "../../utils/FormattingUtils"; +import { Clock } from "../Clock"; +import { SeekBar } from "../SeekBar"; + +export interface AudioPlayerViewSnapshot { + /** + * The playback state of the audio player. + */ + playbackState: PlaybackState; + /** + * Name of the media being played. + * @default Fallback to "timeline|m.audio|unnamed_audio" string if not provided. + */ + mediaName?: string; + /** + * Size of the audio file in bytes. + * Hided if not provided. + */ + sizeBytes?: number; + /** + * The duration of the audio clip in seconds. + */ + durationSeconds: number; + /** + * The percentage of the audio that has been played. + * Ranges from 0 to 100. + */ + percentComplete: number; + /** + * The number of seconds that have been played. + */ + playedSeconds: number; + /** + * Indicates if there was an error downloading the audio. + */ + error: boolean; +} + +export interface AudioPlayerViewActions { + /** + * Handles key down events for the audio player. + */ + onKeyDown: KeyboardEventHandler; + /** + * Toggles the play/pause state of the audio player. + */ + togglePlay: MouseEventHandler; + /** + * Handles changes to the seek bar. + */ + onSeekbarChange: ChangeEventHandler; +} + +/** + * The view model for the audio player. + */ +export type AudioPlayerViewModel = ViewModel & AudioPlayerViewActions; + +interface AudioPlayerViewProps { + /** + * The view model for the audio player. + */ + vm: AudioPlayerViewModel; +} + +/** + * AudioPlayer component displays an audio player with play/pause controls, seek bar, and media information. + * The component expects a view model that provides the current state of the audio playback, + * + * @example + * ```tsx + * + * ``` + */ +export function AudioPlayerView({ vm }: Readonly): JSX.Element { + const { translate: _t } = useI18n(); + + const { + playbackState, + mediaName = _t("timeline|m.audio|unnamed_audio"), + sizeBytes, + durationSeconds, + playedSeconds, + percentComplete, + error, + } = useViewModel(vm); + const fileSize = sizeBytes ? `(${formatBytes(sizeBytes)})` : null; + const disabled = playbackState === "decoding"; + + // tabIndex=0 to ensure that the whole component becomes a tab stop, where we handle keyboard + // events for accessibility + return ( + <> + + + + + + {mediaName} + + + + {fileSize} + + + + + + + + + {error && {_t("timeline|m.audio|error_downloading_audio")}} + + ); +} diff --git a/packages/shared-components/src/audio/AudioPlayerView/__snapshots__/AudioPlayerView.test.tsx.snap b/packages/shared-components/src/audio/AudioPlayerView/__snapshots__/AudioPlayerView.test.tsx.snap new file mode 100644 index 0000000000..85004e4ba5 --- /dev/null +++ b/packages/shared-components/src/audio/AudioPlayerView/__snapshots__/AudioPlayerView.test.tsx.snap @@ -0,0 +1,369 @@ +// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing + +exports[`AudioPlayerView renders the audio player in default state 1`] = ` +
+
+
+ +
+ + Sample Audio + + +
+
+
+ + +
+
+
+`; + +exports[`AudioPlayerView renders the audio player in error state 1`] = ` +
+
+
+ +
+ + Sample Audio + + +
+
+
+ + +
+
+ + Error downloading audio + +
+`; + +exports[`AudioPlayerView renders the audio player without media name 1`] = ` +
+
+
+ +
+ + Unnamed audio + + +
+
+
+ + +
+
+
+`; + +exports[`AudioPlayerView renders the audio player without size 1`] = ` +
+
+
+ +
+ + Sample Audio + + +
+
+
+ + +
+
+
+`; diff --git a/packages/shared-components/src/audio/AudioPlayerView/index.ts b/packages/shared-components/src/audio/AudioPlayerView/index.ts new file mode 100644 index 0000000000..4075f81dde --- /dev/null +++ b/packages/shared-components/src/audio/AudioPlayerView/index.ts @@ -0,0 +1,9 @@ +/* + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial + * Please see LICENSE files in the repository root for full details. + */ + +export type { AudioPlayerViewModel, AudioPlayerViewSnapshot } from "./AudioPlayerView"; +export { AudioPlayerView } from "./AudioPlayerView"; diff --git a/packages/shared-components/src/audio/Clock/Clock.stories.tsx b/packages/shared-components/src/audio/Clock/Clock.stories.tsx new file mode 100644 index 0000000000..e05ee086d9 --- /dev/null +++ b/packages/shared-components/src/audio/Clock/Clock.stories.tsx @@ -0,0 +1,29 @@ +/* + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial + * Please see LICENSE files in the repository root for full details. + */ + +import React from "react"; + +import type { Meta, StoryFn } from "@storybook/react-vite"; +import { Clock } from "./Clock"; + +export default { + title: "Audio/Clock", + component: Clock, + tags: ["autodocs"], + args: { + seconds: 20, + }, +} as Meta; + +const Template: StoryFn = (args) => ; + +export const Default = Template.bind({}); + +export const LotOfSeconds = Template.bind({}); +LotOfSeconds.args = { + seconds: 99999999999999, +}; diff --git a/packages/shared-components/src/audio/Clock/Clock.test.tsx b/packages/shared-components/src/audio/Clock/Clock.test.tsx new file mode 100644 index 0000000000..fdbbf49518 --- /dev/null +++ b/packages/shared-components/src/audio/Clock/Clock.test.tsx @@ -0,0 +1,26 @@ +/* + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial + * Please see LICENSE files in the repository root for full details. + */ + +import { composeStories } from "@storybook/react-vite"; +import { render } from "jest-matrix-react"; +import React from "react"; + +import * as stories from "./Clock.stories.tsx"; + +const { Default, LotOfSeconds } = composeStories(stories); + +describe("Clock", () => { + it("renders the clock", () => { + const { container } = render(); + expect(container).toMatchSnapshot(); + }); + + it("renders the clock with a lot of seconds", () => { + const { container } = render(); + expect(container).toMatchSnapshot(); + }); +}); diff --git a/src/components/views/audio_messages/Clock.tsx b/packages/shared-components/src/audio/Clock/Clock.tsx similarity index 73% rename from src/components/views/audio_messages/Clock.tsx rename to packages/shared-components/src/audio/Clock/Clock.tsx index d9f1a3d6c5..176044269d 100644 --- a/src/components/views/audio_messages/Clock.tsx +++ b/packages/shared-components/src/audio/Clock/Clock.tsx @@ -1,32 +1,26 @@ /* Copyright 2024 New Vector Ltd. Copyright 2021-2023 The Matrix.org Foundation C.I.C. - SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial Please see LICENSE files in the repository root for full details. */ import React, { type HTMLProps } from "react"; import { Temporal } from "temporal-polyfill"; +import classNames from "classnames"; -import { formatSeconds } from "../../../DateUtils"; +import { formatSeconds } from "../../utils/DateUtils"; -interface Props extends Pick, "aria-live" | "role"> { +export interface Props extends Pick, "aria-live" | "role" | "className"> { seconds: number; - formatFn: (seconds: number) => string; } /** * Clock which represents time periods rather than absolute time. - * Simply converts seconds using formatFn. - * Defaulting to formatSeconds(). + * Simply converts seconds using formatSeconds(). * Note that in this case hours will not be displayed, making it possible to see "82:29". */ -export default class Clock extends React.Component { - public static defaultProps = { - formatFn: formatSeconds, - }; - +export class Clock extends React.Component { public shouldComponentUpdate(nextProps: Readonly): boolean { const currentFloor = Math.floor(this.props.seconds); const nextFloor = Math.floor(nextProps.seconds); @@ -47,9 +41,10 @@ export default class Clock extends React.Component { dateTime={this.calculateDuration(seconds)} aria-live={this.props["aria-live"]} role={role} - className="mx_Clock" + /* Keep class for backward compatibility with parent component */ + className={classNames("mx_Clock", this.props.className)} > - {this.props.formatFn(seconds)} + {formatSeconds(seconds)} ); } diff --git a/packages/shared-components/src/audio/Clock/__snapshots__/Clock.test.tsx.snap b/packages/shared-components/src/audio/Clock/__snapshots__/Clock.test.tsx.snap new file mode 100644 index 0000000000..5ab0799d28 --- /dev/null +++ b/packages/shared-components/src/audio/Clock/__snapshots__/Clock.test.tsx.snap @@ -0,0 +1,23 @@ +// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing + +exports[`Clock renders the clock 1`] = ` +
+ +
+`; + +exports[`Clock renders the clock with a lot of seconds 1`] = ` +
+ +
+`; diff --git a/packages/shared-components/src/audio/Clock/index.tsx b/packages/shared-components/src/audio/Clock/index.tsx new file mode 100644 index 0000000000..bc261bb283 --- /dev/null +++ b/packages/shared-components/src/audio/Clock/index.tsx @@ -0,0 +1,8 @@ +/* + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial + * Please see LICENSE files in the repository root for full details. + */ + +export { Clock } from "./Clock"; diff --git a/packages/shared-components/src/audio/PlayPauseButton/PlayPauseButton.module.css b/packages/shared-components/src/audio/PlayPauseButton/PlayPauseButton.module.css new file mode 100644 index 0000000000..859f84bba9 --- /dev/null +++ b/packages/shared-components/src/audio/PlayPauseButton/PlayPauseButton.module.css @@ -0,0 +1,11 @@ +/* + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial + * Please see LICENSE files in the repository root for full details. + */ + +.button { + border-radius: 32px !important; + background-color: var(--cpd-color-bg-subtle-primary) !important; +} diff --git a/packages/shared-components/src/audio/PlayPauseButton/PlayPauseButton.stories.tsx b/packages/shared-components/src/audio/PlayPauseButton/PlayPauseButton.stories.tsx new file mode 100644 index 0000000000..5bbcfbbf39 --- /dev/null +++ b/packages/shared-components/src/audio/PlayPauseButton/PlayPauseButton.stories.tsx @@ -0,0 +1,26 @@ +/* + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial + * Please see LICENSE files in the repository root for full details. + */ + +import { fn } from "storybook/test"; + +import { PlayPauseButton } from "./PlayPauseButton"; +import type { Meta, StoryObj } from "@storybook/react-vite"; + +const meta = { + title: "Audio/PlayPauseButton", + component: PlayPauseButton, + tags: ["autodocs"], + args: { + togglePlay: fn(), + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Default: Story = {}; +export const Playing: Story = { args: { playing: true } }; diff --git a/packages/shared-components/src/audio/PlayPauseButton/PlayPauseButton.test.tsx b/packages/shared-components/src/audio/PlayPauseButton/PlayPauseButton.test.tsx new file mode 100644 index 0000000000..3a032f5e02 --- /dev/null +++ b/packages/shared-components/src/audio/PlayPauseButton/PlayPauseButton.test.tsx @@ -0,0 +1,37 @@ +/* + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial + * Please see LICENSE files in the repository root for full details. + */ + +import { composeStories } from "@storybook/react-vite"; +import { render } from "jest-matrix-react"; +import React from "react"; +import userEvent from "@testing-library/user-event"; +import { fn } from "storybook/test"; + +import * as stories from "./PlayPauseButton.stories.tsx"; + +const { Default, Playing } = composeStories(stories); + +describe("PlayPauseButton", () => { + it("renders the button in default state", () => { + const { container } = render(); + expect(container).toMatchSnapshot(); + }); + + it("renders the button in playing state", () => { + const { container } = render(); + expect(container).toMatchSnapshot(); + }); + + it("calls togglePlay when clicked", async () => { + const user = userEvent.setup(); + const togglePlay = fn(); + + const { getByRole } = render(); + await user.click(getByRole("button")); + expect(togglePlay).toHaveBeenCalled(); + }); +}); diff --git a/packages/shared-components/src/audio/PlayPauseButton/PlayPauseButton.tsx b/packages/shared-components/src/audio/PlayPauseButton/PlayPauseButton.tsx new file mode 100644 index 0000000000..cc2ab5bb65 --- /dev/null +++ b/packages/shared-components/src/audio/PlayPauseButton/PlayPauseButton.tsx @@ -0,0 +1,66 @@ +/* + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial + * Please see LICENSE files in the repository root for full details. + */ + +import React, { type HTMLAttributes, type JSX, type MouseEventHandler } from "react"; +import { IconButton } from "@vector-im/compound-web"; +import Play from "@vector-im/compound-design-tokens/assets/web/icons/play-solid"; +import Pause from "@vector-im/compound-design-tokens/assets/web/icons/pause-solid"; + +import styles from "./PlayPauseButton.module.css"; +import { useI18n } from "../../utils/i18nContext"; + +export interface PlayPauseButtonProps extends HTMLAttributes { + /** + * Whether the button is disabled. + * @default false + */ + disabled?: boolean; + + /** + * Whether the audio is currently playing. + * @default false + */ + playing?: boolean; + + /** + * Function to toggle play/pause state. + */ + togglePlay: MouseEventHandler; +} + +/** + * A button component that toggles between play and pause states for audio playback. + * + * @example + * ```tsx + * {}} /> + * ``` + */ +export function PlayPauseButton({ + disabled = false, + playing = false, + togglePlay, + ...rest +}: Readonly): JSX.Element { + const { translate: _t } = useI18n(); + + const label = playing ? _t("action|pause") : _t("action|play"); + + return ( + + {playing ? : } + + ); +} diff --git a/packages/shared-components/src/audio/PlayPauseButton/__snapshots__/PlayPauseButton.test.tsx.snap b/packages/shared-components/src/audio/PlayPauseButton/__snapshots__/PlayPauseButton.test.tsx.snap new file mode 100644 index 0000000000..3b98652cd1 --- /dev/null +++ b/packages/shared-components/src/audio/PlayPauseButton/__snapshots__/PlayPauseButton.test.tsx.snap @@ -0,0 +1,65 @@ +// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing + +exports[`PlayPauseButton renders the button in default state 1`] = ` +
+ +
+`; + +exports[`PlayPauseButton renders the button in playing state 1`] = ` +
+ +
+`; diff --git a/packages/shared-components/src/audio/PlayPauseButton/index.ts b/packages/shared-components/src/audio/PlayPauseButton/index.ts new file mode 100644 index 0000000000..93a71cd739 --- /dev/null +++ b/packages/shared-components/src/audio/PlayPauseButton/index.ts @@ -0,0 +1,8 @@ +/* + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial + * Please see LICENSE files in the repository root for full details. + */ + +export { PlayPauseButton } from "./PlayPauseButton"; diff --git a/packages/shared-components/src/audio/SeekBar/SeekBar.module.css b/packages/shared-components/src/audio/SeekBar/SeekBar.module.css new file mode 100644 index 0000000000..54e3b7479f --- /dev/null +++ b/packages/shared-components/src/audio/SeekBar/SeekBar.module.css @@ -0,0 +1,99 @@ +/* +Copyright 2024 New Vector Ltd. +Copyright 2021 The Matrix.org Foundation C.I.C. + +SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial +Please see LICENSE files in the repository root for full details. +*/ + +/* CSS inspiration from: */ +/* * https://www.w3schools.com/howto/howto_js_rangeslider.asp */ +/* * https://stackoverflow.com/a/28283806 */ +/* * https://css-tricks.com/styling-cross-browser-compatible-range-inputs-css/ */ + +.seekBar { + /* default, overridden in JS */ + --fillTo: 1; + + /* Dev note: we deliberately do not have the -ms-track (and friends) selectors because we don't */ + /* need to support IE. */ + + appearance: none; /* default style override */ + + width: 100%; + height: 1px; + background: var(--cpd-color-gray-600); + outline: none; /* remove blue selection border */ + position: relative; /* for before+after pseudo elements later on */ + + cursor: pointer; + + &::-webkit-slider-thumb { + appearance: none; /* default style override */ + + /* Dev note: This needs to be duplicated with the -moz-range-thumb selector */ + /* because otherwise Edge (webkit) will fail to see the styles and just refuse */ + /* to apply them. */ + width: 8px; + height: 8px; + border-radius: 8px; + background-color: var(--cpd-color-gray-800); + cursor: pointer; + } + + &::-moz-range-thumb { + width: 8px; + height: 8px; + border-radius: 8px; + background-color: var(--cpd-color-gray-800); + cursor: pointer; + + /* Firefox adds a border on the thumb */ + border: none; + } + + /* This is for webkit support, but we can't limit the functionality of it to just webkit */ + /* browsers. Firefox responds to webkit-prefixed values now, which means we can't use media */ + /* or support queries to selectively apply the rule. An upside is that this CSS doesn't work */ + /* in firefox, so it's just wasted CPU/GPU time. */ + &::before { + /* ::before to ensure it ends up under the thumb */ + content: ""; + background-color: var(--cpd-color-gray-800); + + /* Absolute positioning to ensure it overlaps with the existing bar */ + position: absolute; + top: 0; + left: 0; + + /* Sizing to match the bar */ + width: 100%; + height: 1px; + + /* And finally dynamic width without overly hurting the rendering engine. */ + transform-origin: 0 100%; + transform: scaleX(var(--fillTo)); + } + + /* This is firefox's built-in support for the above, with 100% less hacks. */ + &::-moz-range-progress { + background-color: var(--cpd-color-gray-800); + height: 1px; + } + + &:disabled { + opacity: 0.5; + } + + /* Increase clickable area for the slider (approximately same size as browser default) */ + /* We do it this way to keep the same padding and margins of the element, avoiding margin math. */ + /* Source: https://front-back.com/expand-clickable-areas-for-a-better-touch-experience/ */ + &::after { + content: ""; + position: absolute; + top: -6px; + bottom: -6px; + left: 0; + right: 0; + } +} diff --git a/packages/shared-components/src/audio/SeekBar/SeekBar.stories.tsx b/packages/shared-components/src/audio/SeekBar/SeekBar.stories.tsx new file mode 100644 index 0000000000..6a3ec48666 --- /dev/null +++ b/packages/shared-components/src/audio/SeekBar/SeekBar.stories.tsx @@ -0,0 +1,38 @@ +/* + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial + * Please see LICENSE files in the repository root for full details. + */ + +import React from "react"; +import { useArgs } from "storybook/preview-api"; + +import { SeekBar } from "./SeekBar"; +import type { Meta, StoryFn } from "@storybook/react-vite"; + +export default { + title: "Audio/SeekBar", + component: SeekBar, + tags: ["autodocs"], + argTypes: { + value: { + control: { type: "range", min: 0, max: 100, step: 1 }, + }, + }, + args: { + value: 50, + }, +} as Meta; + +const Template: StoryFn = (args) => { + const [, updateArgs] = useArgs(); + return updateArgs({ value: parseInt(evt.target.value, 10) })} {...args} />; +}; + +export const Default = Template.bind({}); + +export const Disabled = Template.bind({}); +Disabled.args = { + disabled: true, +}; diff --git a/packages/shared-components/src/audio/SeekBar/SeekBar.test.tsx b/packages/shared-components/src/audio/SeekBar/SeekBar.test.tsx new file mode 100644 index 0000000000..0d52ab3993 --- /dev/null +++ b/packages/shared-components/src/audio/SeekBar/SeekBar.test.tsx @@ -0,0 +1,20 @@ +/* + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial + * Please see LICENSE files in the repository root for full details. + */ + +import { render } from "jest-matrix-react"; +import React from "react"; +import { composeStories } from "@storybook/react-vite"; + +import * as stories from "./SeekBar.stories.tsx"; +const { Default } = composeStories(stories); + +describe("Seekbar", () => { + it("renders the clock", () => { + const { container } = render(); + expect(container).toMatchSnapshot(); + }); +}); diff --git a/packages/shared-components/src/audio/SeekBar/SeekBar.tsx b/packages/shared-components/src/audio/SeekBar/SeekBar.tsx new file mode 100644 index 0000000000..a30e527eee --- /dev/null +++ b/packages/shared-components/src/audio/SeekBar/SeekBar.tsx @@ -0,0 +1,60 @@ +/* + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial + * Please see LICENSE files in the repository root for full details. + */ + +import React, { type CSSProperties, type JSX, useEffect, useMemo, useState } from "react"; +import { throttle } from "lodash"; +import classNames from "classnames"; + +import style from "./SeekBar.module.css"; +import { useI18n } from "../../utils/i18nContext"; + +export interface SeekBarProps extends React.InputHTMLAttributes { + /** + * The current value of the seek bar, between 0 and 100. + * @default 0 + */ + value?: number; +} + +interface ISeekCSS extends CSSProperties { + "--fillTo": number; +} + +/** + * A seek bar component for audio playback. + * + * @example + * ```tsx + * console.log("New value", e.target.value)} /> + * ``` + */ +export function SeekBar({ value = 0, className, ...rest }: Readonly): JSX.Element { + const { translate: _t } = useI18n(); + + const [newValue, setNewValue] = useState(value); + // Throttle the value setting to avoid excessive re-renders + const setThrottledValue = useMemo(() => throttle(setNewValue, 10), []); + + useEffect(() => { + setThrottledValue(value); + }, [value, setThrottledValue]); + + return ( + e.stopPropagation()} + min={0} + max={100} + value={newValue} + step={1} + style={{ "--fillTo": newValue / 100 } as ISeekCSS} + aria-label={_t("a11y|seek_bar_label")} + {...rest} + /> + ); +} diff --git a/packages/shared-components/src/audio/SeekBar/__snapshots__/SeekBar.test.tsx.snap b/packages/shared-components/src/audio/SeekBar/__snapshots__/SeekBar.test.tsx.snap new file mode 100644 index 0000000000..e3cc92279e --- /dev/null +++ b/packages/shared-components/src/audio/SeekBar/__snapshots__/SeekBar.test.tsx.snap @@ -0,0 +1,16 @@ +// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing + +exports[`Seekbar renders the clock 1`] = ` +
+ +
+`; diff --git a/packages/shared-components/src/audio/SeekBar/index.ts b/packages/shared-components/src/audio/SeekBar/index.ts new file mode 100644 index 0000000000..710310198e --- /dev/null +++ b/packages/shared-components/src/audio/SeekBar/index.ts @@ -0,0 +1,8 @@ +/* + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial + * Please see LICENSE files in the repository root for full details. + */ + +export { SeekBar } from "./SeekBar"; diff --git a/packages/shared-components/src/audio/playback.ts b/packages/shared-components/src/audio/playback.ts new file mode 100644 index 0000000000..d7b0fa7634 --- /dev/null +++ b/packages/shared-components/src/audio/playback.ts @@ -0,0 +1,16 @@ +/* + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial + * Please see LICENSE files in the repository root for full details. + */ + +/** + * Represents the possible states of playback. + * - "preparing": The audio is being prepared for playback (e.g., loading or buffering). + * - "decoding": The audio is being decoded and is not ready for playback. + * - "stopped": The playback has been stopped, with no progress on the timeline. + * - "paused": The playback is paused, with some progress on the timeline. + * - "playing": The playback is actively progressing through the timeline. + */ +export type PlaybackState = "decoding" | "stopped" | "paused" | "playing" | "preparing"; diff --git a/packages/shared-components/src/avatar/AvatarWithDetails/AvatarWithDetails.module.css b/packages/shared-components/src/avatar/AvatarWithDetails/AvatarWithDetails.module.css new file mode 100644 index 0000000000..62e7a569bf --- /dev/null +++ b/packages/shared-components/src/avatar/AvatarWithDetails/AvatarWithDetails.module.css @@ -0,0 +1,31 @@ +/* + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial + * Please see LICENSE files in the repository root for full details. + */ + +.avatarWithDetails { + display: flex; + align-items: center; + + border-radius: 12px; + background-color: var(--cpd-color-gray-200); + padding: var(--cpd-space-2x); + gap: var(--cpd-space-2x); + + .title { + display: inline-block; + + font-weight: var(--cpd-font-weight-semibold); + font-size: var(--cpd-font-size-body-md); + + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + .details { + font-size: var(--cpd-font-size-body-sm); + } +} diff --git a/packages/shared-components/src/avatar/AvatarWithDetails/AvatarWithDetails.stories.tsx b/packages/shared-components/src/avatar/AvatarWithDetails/AvatarWithDetails.stories.tsx new file mode 100644 index 0000000000..9d8a7f5c30 --- /dev/null +++ b/packages/shared-components/src/avatar/AvatarWithDetails/AvatarWithDetails.stories.tsx @@ -0,0 +1,26 @@ +/* + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial + * Please see LICENSE files in the repository root for full details. + */ + +import React from "react"; +import { type Meta, type StoryObj } from "@storybook/react-vite"; + +import { AvatarWithDetails } from "./AvatarWithDetails"; + +const meta = { + title: "Avatar/AvatarWithDetails", + component: AvatarWithDetails, + tags: ["autodocs"], + args: { + avatar:
, + details: "Details about the avatar go here", + title: "Room Name", + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; +export const Default: Story = {}; diff --git a/packages/shared-components/src/avatar/AvatarWithDetails/AvatarWithDetails.test.tsx b/packages/shared-components/src/avatar/AvatarWithDetails/AvatarWithDetails.test.tsx new file mode 100644 index 0000000000..f5d482613f --- /dev/null +++ b/packages/shared-components/src/avatar/AvatarWithDetails/AvatarWithDetails.test.tsx @@ -0,0 +1,21 @@ +/* +Copyright 2025 New Vector Ltd. + +SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial +Please see LICENSE files in the repository root for full details. +*/ + +import { composeStories } from "@storybook/react-vite"; +import { render } from "jest-matrix-react"; +import React from "react"; + +import * as stories from "./AvatarWithDetails.stories.tsx"; + +const { Default } = composeStories(stories); + +describe("AvatarWithDetails", () => { + it("renders a textual event", () => { + const { container } = render(); + expect(container).toMatchSnapshot(); + }); +}); diff --git a/packages/shared-components/src/avatar/AvatarWithDetails/AvatarWithDetails.tsx b/packages/shared-components/src/avatar/AvatarWithDetails/AvatarWithDetails.tsx new file mode 100644 index 0000000000..aab5729d5f --- /dev/null +++ b/packages/shared-components/src/avatar/AvatarWithDetails/AvatarWithDetails.tsx @@ -0,0 +1,65 @@ +/* + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial + * Please see LICENSE files in the repository root for full details. + */ + +import { type ComponentProps, type ElementType, type JSX, type PropsWithChildren } from "react"; +import React from "react"; +import classNames from "classnames"; + +import styles from "./AvatarWithDetails.module.css"; +import { Flex } from "../../utils/Flex"; + +export type AvatarWithDetailsProps = { + /** + * The HTML tag. + * @default "div" + */ + as?: C; + /** + * The CSS class name. + */ + className?: string; + /** + * The title/label next to the avatar. Usually the user or room name. + */ + title: string; + /** + * A label with details to display under the avatar title. + * Commonly used to display the number of participants in a room. + */ + details: React.ReactNode; + /** The avatar to display. */ + avatar: React.ReactNode; +} & ComponentProps; + +/** + * A component to display an avatar with a title next to it in a grey box. + * + * @example + * ```tsx + * + * ``` + */ +export function AvatarWithDetails({ + as, + className, + details, + avatar, + title, + ...props +}: PropsWithChildren>): JSX.Element { + const Component = as || "div"; + + return ( + + {avatar} + + {title} + {details} + + + ); +} diff --git a/packages/shared-components/src/avatar/AvatarWithDetails/__snapshots__/AvatarWithDetails.test.tsx.snap b/packages/shared-components/src/avatar/AvatarWithDetails/__snapshots__/AvatarWithDetails.test.tsx.snap new file mode 100644 index 0000000000..7b927f2c3b --- /dev/null +++ b/packages/shared-components/src/avatar/AvatarWithDetails/__snapshots__/AvatarWithDetails.test.tsx.snap @@ -0,0 +1,28 @@ +// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing + +exports[`AvatarWithDetails renders a textual event 1`] = ` +
+
+
+
+ + Room Name + + + Details about the avatar go here + +
+
+
+`; diff --git a/packages/shared-components/src/avatar/AvatarWithDetails/index.tsx b/packages/shared-components/src/avatar/AvatarWithDetails/index.tsx new file mode 100644 index 0000000000..a54f416d82 --- /dev/null +++ b/packages/shared-components/src/avatar/AvatarWithDetails/index.tsx @@ -0,0 +1,8 @@ +/* + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial + * Please see LICENSE files in the repository root for full details. + */ + +export { AvatarWithDetails } from "./AvatarWithDetails"; diff --git a/packages/shared-components/src/composer/Banner/Banner.module.css b/packages/shared-components/src/composer/Banner/Banner.module.css new file mode 100644 index 0000000000..7bb48a9cc2 --- /dev/null +++ b/packages/shared-components/src/composer/Banner/Banner.module.css @@ -0,0 +1,93 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial + * Please see LICENSE files in the repository root for full details. + */ + +:root { + --cpd-color-gradient-critical-linear: linear-gradient( + 180deg, + var(--cpd-color-alpha-red-500) 0%, + var(--cpd-color-alpha-red-400) 20%, + var(--cpd-color-alpha-red-300) 40%, + var(--cpd-color-alpha-red-200) 60%, + var(--cpd-color-alpha-red-100) 80%, + var(--cpd-color-transparent) 100% + ); +} + +.banner { + container-type: inline-size; + container-name: banner; + display: flex; + align-items: center; + justify-content: start; + gap: var(--cpd-space-3x); + padding: var(--cpd-space-4x); + + border-top: 1px solid var(--cpd-color-gray-400); +} + +.banner[data-type="success"] { + background: var(--cpd-color-gradient-subtle-linear); + border-color: var(--cpd-color-green-900); +} + +.banner[data-type="critical"] { + background: var(--cpd-color-gradient-critical-linear); + border-color: var(--cpd-color-border-critical-primary); +} + +.banner[data-type="info"] { + background: var(--cpd-color-gradient-info-linear); + border-color: var(--cpd-color-blue-900); +} + +.banner[data-type="info"] :is(svg) { + color: var(--cpd-color-blue-900); +} + +.banner[data-type="success"] :is(.content, svg) { + color: var(--cpd-color-green-900); +} + +.banner[data-type="critical"] :is(.content, svg) { + color: var(--cpd-color-red-900); +} + +.banner p { + margin: 0; +} + +.icon { + /* lock icon dimensions */ + min-width: 32px; + min-height: 32px; + max-width: 32px; + max-height: 32px; + + margin: 4px; + + /* centre svg icons, as they are not full width */ + flex: 0; + display: flex; + align-items: center; + justify-content: center; +} + +.icon img { + border-radius: 50%; +} + +.actions { + margin-left: auto; + + flex: 0; + display: flex; + flex-direction: row; + gap: var(--cpd-space-1x); + align-self: center; + + white-space: nowrap; +} diff --git a/packages/shared-components/src/composer/Banner/Banner.stories.tsx b/packages/shared-components/src/composer/Banner/Banner.stories.tsx new file mode 100644 index 0000000000..e1e3e110fb --- /dev/null +++ b/packages/shared-components/src/composer/Banner/Banner.stories.tsx @@ -0,0 +1,85 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial + * Please see LICENSE files in the repository root for full details. + */ + +import React from "react"; +import { fn } from "storybook/test"; +import { type Meta, type StoryObj } from "@storybook/react-vite"; +import { Button } from "@vector-im/compound-web"; + +import { Banner } from "./Banner"; + +const meta = { + title: "room/Banner", + component: Banner, + tags: ["autodocs"], + args: { + children:

Hello! This is a status banner.

, + onClose: fn(), + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Default: Story = {}; +export const Info: Story = { + args: { + type: "info", + }, +}; +export const Success: Story = { + args: { + type: "success", + }, +}; +export const Critical: Story = { + args: { + type: "critical", + }, +}; +export const WithAction: Story = { + args: { + children: ( +

+ Alice's (@alice:example.com) identity was reset. Learn more +

+ ), + actions: ( + + ), + }, +}; + +export const WithAvatarImage: Story = { + args: { + avatar: Example, + }, +}; + +export const WithoutClose: Story = { + args: { + onClose: undefined, + }, +}; + +export const WithLoadsOfContent: Story = { + args: { + type: "info", + children: ( +

+ Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed quis massa facilisis, venenatis risus + consectetur, sagittis libero. Aenean et scelerisque justo. Nunc luctus, mi sed facilisis suscipit, magna + ante pharetra sem, eu rutrum purus quam quis arcu. Sed eleifend arcu vitae magna sodales, sit amet + fermentum urna dictum. Mauris vel velit pulvinar enim mollis tincidunt. Vivamus egestas rhoncus + sagittis. Curabitur auctor vehicula massa, et cursus lacus laoreet a. Maecenas et sollicitudin lectus, + in ligula. +

+ ), + }, +}; diff --git a/packages/shared-components/src/composer/Banner/Banner.test.tsx b/packages/shared-components/src/composer/Banner/Banner.test.tsx new file mode 100644 index 0000000000..0f33fb452b --- /dev/null +++ b/packages/shared-components/src/composer/Banner/Banner.test.tsx @@ -0,0 +1,41 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial + * Please see LICENSE files in the repository root for full details. + */ + +import React from "react"; +import { render } from "jest-matrix-react"; +import { composeStories } from "@storybook/react-vite"; + +import * as stories from "./Banner.stories.tsx"; + +const { Default, Info, Success, WithAction, WithAvatarImage, Critical } = composeStories(stories); + +describe("AvatarWithDetails", () => { + it("renders a default banner", () => { + const { container } = render(); + expect(container).toMatchSnapshot(); + }); + it("renders a info banner", () => { + const { container } = render(); + expect(container).toMatchSnapshot(); + }); + it("renders a success banner", () => { + const { container } = render(); + expect(container).toMatchSnapshot(); + }); + it("renders a critical banner", () => { + const { container } = render(); + expect(container).toMatchSnapshot(); + }); + it("renders a banner with an action", () => { + const { container } = render(); + expect(container).toMatchSnapshot(); + }); + it("renders a banner with an avatar iamge", () => { + const { container } = render(); + expect(container).toMatchSnapshot(); + }); +}); diff --git a/packages/shared-components/src/composer/Banner/Banner.tsx b/packages/shared-components/src/composer/Banner/Banner.tsx new file mode 100644 index 0000000000..7781442ed9 --- /dev/null +++ b/packages/shared-components/src/composer/Banner/Banner.tsx @@ -0,0 +1,93 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial + * Please see LICENSE files in the repository root for full details. + */ + +import classNames from "classnames"; +import React, { + type MouseEventHandler, + type ReactElement, + type ReactNode, + type PropsWithChildren, + useMemo, +} from "react"; +import { Button } from "@vector-im/compound-web"; +import CheckCircleIcon from "@vector-im/compound-design-tokens/assets/web/icons/check-circle"; +import ErrorIcon from "@vector-im/compound-design-tokens/assets/web/icons/error-solid"; +import InfoIcon from "@vector-im/compound-design-tokens/assets/web/icons/info"; + +import styles from "./Banner.module.css"; +import { _t } from "../../utils/i18n"; + +interface BannerProps { + /** + * The type of the status banner. + */ + type?: "success" | "info" | "critical"; + + /** + * The banner avatar. + */ + avatar?: React.ReactNode; + + className?: string; + + /** + * Actions presented to the user in the right-hand side of the banner alongside the dismiss button. + */ + actions?: ReactNode; + /** + * Called when the user presses the "dismiss" button. + */ + onClose?: MouseEventHandler; +} + +/** + * A banner component used for displaying user-facing information above the message composer. + * + * @example + * ```tsx + * + * ``` + */ +export function Banner({ + type, + children, + avatar, + className, + actions, + onClose, + ...props +}: PropsWithChildren): ReactElement { + const classes = classNames(styles.banner, className); + + const icon = useMemo(() => { + switch (type) { + case "critical": + return ; + case "info": + return ; + case "success": + return ; + default: + return ; + } + }, [type, props]); + + return ( +
+
{avatar ?? icon}
+
{children}
+
+ {actions} + {onClose && ( + + )} +
+
+ ); +} diff --git a/packages/shared-components/src/composer/Banner/__snapshots__/Banner.test.tsx.snap b/packages/shared-components/src/composer/Banner/__snapshots__/Banner.test.tsx.snap new file mode 100644 index 0000000000..ebb8df0a3d --- /dev/null +++ b/packages/shared-components/src/composer/Banner/__snapshots__/Banner.test.tsx.snap @@ -0,0 +1,299 @@ +// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing + +exports[`AvatarWithDetails renders a banner with an action 1`] = ` +
+ +
+`; + +exports[`AvatarWithDetails renders a banner with an avatar iamge 1`] = ` +
+ +
+`; + +exports[`AvatarWithDetails renders a critical banner 1`] = ` +
+ +
+`; + +exports[`AvatarWithDetails renders a default banner 1`] = ` +
+ +
+`; + +exports[`AvatarWithDetails renders a info banner 1`] = ` +
+ +
+`; + +exports[`AvatarWithDetails renders a success banner 1`] = ` +
+ +
+`; diff --git a/packages/shared-components/src/composer/Banner/index.ts b/packages/shared-components/src/composer/Banner/index.ts new file mode 100644 index 0000000000..5945ff8fd1 --- /dev/null +++ b/packages/shared-components/src/composer/Banner/index.ts @@ -0,0 +1,8 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial + * Please see LICENSE files in the repository root for full details. + */ + +export * from "./Banner"; diff --git a/packages/shared-components/src/composer/HistoryVisibleBannerView/HistoryVisibleBannerView.stories.tsx b/packages/shared-components/src/composer/HistoryVisibleBannerView/HistoryVisibleBannerView.stories.tsx new file mode 100644 index 0000000000..5bd95e0113 --- /dev/null +++ b/packages/shared-components/src/composer/HistoryVisibleBannerView/HistoryVisibleBannerView.stories.tsx @@ -0,0 +1,42 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial + * Please see LICENSE files in the repository root for full details. + */ +import { type Meta, type StoryFn } from "@storybook/react-vite"; +import React, { type JSX } from "react"; +import { fn } from "storybook/test"; + +import { useMockedViewModel } from "../../useMockedViewModel"; +import { + HistoryVisibleBannerView, + type HistoryVisibleBannerViewActions, + type HistoryVisibleBannerViewSnapshot, +} from "./HistoryVisibleBannerView"; + +type HistoryVisibleBannerProps = HistoryVisibleBannerViewSnapshot & HistoryVisibleBannerViewActions; + +const HistoryVisibleBannerViewWrapper = ({ onClose, ...rest }: HistoryVisibleBannerProps): JSX.Element => { + const vm = useMockedViewModel(rest, { + onClose, + }); + return ; +}; + +export default { + title: "composer/HistoryVisibleBannerView", + component: HistoryVisibleBannerViewWrapper, + tags: ["autodocs"], + argTypes: {}, + args: { + visible: true, + onClose: fn(), + }, +} as Meta; + +const Template: StoryFn = (args) => ( + +); + +export const Default = Template.bind({}); diff --git a/packages/shared-components/src/composer/HistoryVisibleBannerView/HistoryVisibleBannerView.test.tsx b/packages/shared-components/src/composer/HistoryVisibleBannerView/HistoryVisibleBannerView.test.tsx new file mode 100644 index 0000000000..04d1ca40e6 --- /dev/null +++ b/packages/shared-components/src/composer/HistoryVisibleBannerView/HistoryVisibleBannerView.test.tsx @@ -0,0 +1,28 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial + * Please see LICENSE files in the repository root for full details. + */ + +import React from "react"; +import { render } from "jest-matrix-react"; +import { composeStories } from "@storybook/react-vite"; + +import * as stories from "./HistoryVisibleBannerView.stories.tsx"; + +const { Default } = composeStories(stories); + +describe("HistoryVisibleBannerView", () => { + it("renders a history visible banner", () => { + const dismissFn = jest.fn(); + + const { container } = render(); + expect(container).toMatchSnapshot(); + + const button = container.querySelector("button"); + expect(button).not.toBeNull(); + button?.click(); + expect(dismissFn).toHaveBeenCalled(); + }); +}); diff --git a/packages/shared-components/src/composer/HistoryVisibleBannerView/HistoryVisibleBannerView.tsx b/packages/shared-components/src/composer/HistoryVisibleBannerView/HistoryVisibleBannerView.tsx new file mode 100644 index 0000000000..fcd00327d6 --- /dev/null +++ b/packages/shared-components/src/composer/HistoryVisibleBannerView/HistoryVisibleBannerView.tsx @@ -0,0 +1,79 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial + * Please see LICENSE files in the repository root for full details. + */ + +import { Link } from "@vector-im/compound-web"; +import React, { type JSX } from "react"; + +import { useViewModel } from "../../useViewModel"; +import { _t } from "../../utils/i18n"; +import { type ViewModel } from "../../viewmodel"; +import { Banner } from "../Banner"; + +export interface HistoryVisibleBannerViewActions { + /** + * Called when the user dismisses the banner. + */ + onClose: () => void; +} + +export interface HistoryVisibleBannerViewSnapshot { + /** + * Whether the banner is currently visible. + */ + visible: boolean; +} + +/** + * The view model for the banner. + */ +export type HistoryVisibleBannerViewModel = ViewModel & + HistoryVisibleBannerViewActions; + +interface HistoryVisibleBannerViewProps { + /** + * The view model for the banner. + */ + vm: HistoryVisibleBannerViewModel; +} + +/** + * A component to alert that history is shared to new members of the room. + * + * @example + * ```tsx + * + * ``` + */ +export function HistoryVisibleBannerView({ vm }: Readonly): JSX.Element { + const { visible } = useViewModel(vm); + + const contents = _t( + "room|status_bar|history_visible", + {}, + { + a: substituteATag, + }, + ); + + return ( + <> + {visible && ( + vm.onClose()}> + {contents} + + )} + + ); +} + +function substituteATag(sub: string): JSX.Element { + return ( + + {sub} + + ); +} diff --git a/packages/shared-components/src/composer/HistoryVisibleBannerView/__snapshots__/HistoryVisibleBannerView.test.tsx.snap b/packages/shared-components/src/composer/HistoryVisibleBannerView/__snapshots__/HistoryVisibleBannerView.test.tsx.snap new file mode 100644 index 0000000000..1ee67f9a37 --- /dev/null +++ b/packages/shared-components/src/composer/HistoryVisibleBannerView/__snapshots__/HistoryVisibleBannerView.test.tsx.snap @@ -0,0 +1,62 @@ +// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing + +exports[`HistoryVisibleBannerView renders a history visible banner 1`] = ` +
+ +
+`; diff --git a/packages/shared-components/src/composer/HistoryVisibleBannerView/index.ts b/packages/shared-components/src/composer/HistoryVisibleBannerView/index.ts new file mode 100644 index 0000000000..96bf208bea --- /dev/null +++ b/packages/shared-components/src/composer/HistoryVisibleBannerView/index.ts @@ -0,0 +1,8 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial + * Please see LICENSE files in the repository root for full details. + */ + +export * from "./HistoryVisibleBannerView"; diff --git a/packages/shared-components/src/event-tiles/TextualEventView/TextualEventView.stories.tsx b/packages/shared-components/src/event-tiles/TextualEventView/TextualEventView.stories.tsx new file mode 100644 index 0000000000..0c89ce24d6 --- /dev/null +++ b/packages/shared-components/src/event-tiles/TextualEventView/TextualEventView.stories.tsx @@ -0,0 +1,25 @@ +/* +Copyright 2025 New Vector Ltd. + +SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +Please see LICENSE files in the repository root for full details. +*/ + +import React from "react"; +import { type Meta, type StoryFn } from "@storybook/react-vite"; + +import { TextualEventView as TextualEventComponent } from "./TextualEventView"; +import { MockViewModel } from "../../viewmodel/MockViewModel"; + +export default { + title: "Event/TextualEvent", + component: TextualEventComponent, + tags: ["autodocs"], + args: { + vm: new MockViewModel({ content: "Dummy textual event text" }), + }, +} as Meta; + +const Template: StoryFn = (args) => ; + +export const Default = Template.bind({}); diff --git a/packages/shared-components/src/event-tiles/TextualEventView/TextualEventView.test.tsx b/packages/shared-components/src/event-tiles/TextualEventView/TextualEventView.test.tsx new file mode 100644 index 0000000000..5d2dd912ef --- /dev/null +++ b/packages/shared-components/src/event-tiles/TextualEventView/TextualEventView.test.tsx @@ -0,0 +1,21 @@ +/* +Copyright 2025 New Vector Ltd. + +SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial +Please see LICENSE files in the repository root for full details. +*/ + +import { composeStories } from "@storybook/react-vite"; +import { render } from "jest-matrix-react"; +import React from "react"; + +import * as stories from "./TextualEventView.stories.tsx"; + +const { Default } = composeStories(stories); + +describe("TextualEventView", () => { + it("renders a textual event", () => { + const { container } = render(); + expect(container).toMatchSnapshot(); + }); +}); diff --git a/packages/shared-components/src/event-tiles/TextualEventView/TextualEventView.tsx b/packages/shared-components/src/event-tiles/TextualEventView/TextualEventView.tsx new file mode 100644 index 0000000000..3d9e4ac109 --- /dev/null +++ b/packages/shared-components/src/event-tiles/TextualEventView/TextualEventView.tsx @@ -0,0 +1,24 @@ +/* +Copyright 2025 New Vector Ltd. + +SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial +Please see LICENSE files in the repository root for full details. +*/ + +import React, { type ReactNode, type JSX } from "react"; + +import { type ViewModel } from "../../viewmodel/ViewModel"; +import { useViewModel } from "../../useViewModel"; + +export type TextualEventViewSnapshot = { + content: string | ReactNode; +}; + +export interface Props { + vm: ViewModel; +} + +export function TextualEventView({ vm }: Props): JSX.Element { + const snapshot = useViewModel(vm); + return
{snapshot.content}
; +} diff --git a/packages/shared-components/src/event-tiles/TextualEventView/__snapshots__/TextualEventView.test.tsx.snap b/packages/shared-components/src/event-tiles/TextualEventView/__snapshots__/TextualEventView.test.tsx.snap new file mode 100644 index 0000000000..37c53e56bd --- /dev/null +++ b/packages/shared-components/src/event-tiles/TextualEventView/__snapshots__/TextualEventView.test.tsx.snap @@ -0,0 +1,11 @@ +// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing + +exports[`TextualEventView renders a textual event 1`] = ` +
+
+ Dummy textual event text +
+
+`; diff --git a/packages/shared-components/src/event-tiles/TextualEventView/index.ts b/packages/shared-components/src/event-tiles/TextualEventView/index.ts new file mode 100644 index 0000000000..589130bd28 --- /dev/null +++ b/packages/shared-components/src/event-tiles/TextualEventView/index.ts @@ -0,0 +1,8 @@ +/* +Copyright 2025 New Vector Ltd. + +SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial +Please see LICENSE files in the repository root for full details. +*/ + +export { TextualEventView, type TextualEventViewSnapshot } from "./TextualEventView"; diff --git a/packages/shared-components/src/hooks/useListKeyboardNavigation.test.ts b/packages/shared-components/src/hooks/useListKeyboardNavigation.test.ts new file mode 100644 index 0000000000..9381619edc --- /dev/null +++ b/packages/shared-components/src/hooks/useListKeyboardNavigation.test.ts @@ -0,0 +1,155 @@ +/* + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial + * Please see LICENSE files in the repository root for full details. + */ + +import { type KeyboardEvent } from "react"; +import { renderHook } from "jest-matrix-react"; + +import { useListKeyboardNavigation } from "./useListKeyboardNavigation"; + +describe("useListKeyDown", () => { + let mockList: HTMLUListElement; + let mockItems: HTMLElement[]; + let mockEvent: Partial>; + + beforeEach(() => { + // Create mock DOM elements + mockList = document.createElement("ul"); + mockItems = [document.createElement("li"), document.createElement("li"), document.createElement("li")]; + + // Set up the DOM structure + mockItems.forEach((item, index) => { + item.setAttribute("tabindex", "0"); + item.setAttribute("data-testid", `item-${index}`); + mockList.appendChild(item); + }); + + document.body.appendChild(mockList); + + // Mock event object + mockEvent = { + preventDefault: jest.fn(), + key: "", + }; + + // Mock focus methods + mockItems.forEach((item) => { + item.focus = jest.fn(); + item.click = jest.fn(); + }); + }); + + afterEach(() => { + document.body.removeChild(mockList); + jest.clearAllMocks(); + }); + + function render(): { + current: { + listRef: React.RefObject; + onKeyDown: React.KeyboardEventHandler; + onFocus: React.FocusEventHandler; + }; + } { + const { result } = renderHook(() => useListKeyboardNavigation()); + result.current.listRef.current = mockList; + return result; + } + + it.each([ + ["Enter", "Enter"], + ["Space", " "], + ])("should handle %s key to click active element", (name, key) => { + const result = render(); + + // Mock document.activeElement + Object.defineProperty(document, "activeElement", { + value: mockItems[1], + configurable: true, + }); + + // Simulate key press + result.current.onKeyDown({ + ...mockEvent, + key, + } as KeyboardEvent); + + expect(mockItems[1].click).toHaveBeenCalledTimes(1); + expect(mockEvent.preventDefault).toHaveBeenCalledTimes(1); + }); + + it.each( + // key, finalPosition, startPosition + [ + ["ArrowDown", 1, 0], + ["ArrowUp", 1, 2], + ["Home", 0, 1], + ["End", 2, 1], + ], + )("should handle %s to focus the %inth element", (key, finalPosition, startPosition) => { + const result = render(); + mockList.contains = jest.fn().mockReturnValue(true); + + Object.defineProperty(document, "activeElement", { + value: mockItems[startPosition], + configurable: true, + }); + + result.current.onKeyDown({ + ...mockEvent, + key, + } as KeyboardEvent); + + expect(mockItems[finalPosition].focus).toHaveBeenCalledTimes(1); + expect(mockEvent.preventDefault).toHaveBeenCalledTimes(1); + }); + + it.each([["ArrowDown"], ["ArrowUp"]])("should not handle %s when active element is not in list", (key) => { + const result = render(); + mockList.contains = jest.fn().mockReturnValue(false); + + const outsideElement = document.createElement("button"); + + Object.defineProperty(document, "activeElement", { + value: outsideElement, + configurable: true, + }); + + result.current.onKeyDown({ + ...mockEvent, + key, + } as KeyboardEvent); + + // No item should be focused + mockItems.forEach((item) => expect(item.focus).not.toHaveBeenCalled()); + expect(mockEvent.preventDefault).toHaveBeenCalledTimes(1); + }); + + it("should not prevent default for unhandled keys", () => { + const result = render(); + + result.current.onKeyDown({ + ...mockEvent, + key: "Tab", + } as KeyboardEvent); + + expect(mockEvent.preventDefault).not.toHaveBeenCalled(); + }); + + it("should focus the first item if list itself is focused", () => { + const result = render(); + result.current.onFocus({ target: mockList } as React.FocusEvent); + expect(mockItems[0].focus).toHaveBeenCalledTimes(1); + }); + + it("should focus the selected item if list itself is focused", () => { + mockItems[1].setAttribute("aria-selected", "true"); + const result = render(); + + result.current.onFocus({ target: mockList } as React.FocusEvent); + expect(mockItems[1].focus).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/shared-components/src/hooks/useListKeyboardNavigation.ts b/packages/shared-components/src/hooks/useListKeyboardNavigation.ts new file mode 100644 index 0000000000..71d87aa12a --- /dev/null +++ b/packages/shared-components/src/hooks/useListKeyboardNavigation.ts @@ -0,0 +1,92 @@ +/* + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial + * Please see LICENSE files in the repository root for full details. + */ + +import { + useCallback, + useRef, + type RefObject, + type KeyboardEvent, + type KeyboardEventHandler, + type FocusEventHandler, + type FocusEvent, +} from "react"; + +/** + * A hook that provides keyboard navigation for a list of options. + */ +export function useListKeyboardNavigation(): { + listRef: RefObject; + onKeyDown: KeyboardEventHandler; + onFocus: FocusEventHandler; +} { + const listRef = useRef(null); + + const onFocus = useCallback((evt: FocusEvent) => { + if (!listRef.current) return; + + if (evt.target === listRef.current) { + // By default, focus the selected item + let selectedChild = listRef.current?.firstElementChild; + + // If there is a selected item, focus that instead + for (const child of listRef.current.children) { + if (child.getAttribute("aria-selected") === "true") { + selectedChild = child; + break; + } + } + + (selectedChild as HTMLElement)?.focus(); + } + }, []); + + const onKeyDown = useCallback((evt: KeyboardEvent) => { + const { key } = evt; + + let handled = false; + + switch (key) { + case "Enter": + case " ": { + handled = true; + (document.activeElement as HTMLElement).click(); + break; + } + case "ArrowDown": { + handled = true; + const currentFocus = document.activeElement; + if (listRef.current?.contains(currentFocus) && currentFocus) { + (currentFocus.nextElementSibling as HTMLElement)?.focus(); + } + break; + } + case "ArrowUp": { + handled = true; + const currentFocus = document.activeElement; + if (listRef.current?.contains(currentFocus) && currentFocus) { + (currentFocus.previousElementSibling as HTMLElement)?.focus(); + } + break; + } + case "Home": { + handled = true; + (listRef.current?.firstElementChild as HTMLElement)?.focus(); + break; + } + case "End": { + handled = true; + (listRef.current?.lastElementChild as HTMLElement)?.focus(); + break; + } + } + + if (handled) { + evt.preventDefault(); + } + }, []); + return { listRef, onKeyDown, onFocus }; +} diff --git a/packages/shared-components/src/index.ts b/packages/shared-components/src/index.ts new file mode 100644 index 0000000000..849634e9da --- /dev/null +++ b/packages/shared-components/src/index.ts @@ -0,0 +1,42 @@ +/* + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial + * Please see LICENSE files in the repository root for full details. + */ + +// Components +export * from "./audio/AudioPlayerView"; +export * from "./audio/Clock"; +export * from "./audio/PlayPauseButton"; +export * from "./audio/SeekBar"; +export * from "./avatar/AvatarWithDetails"; +export * from "./composer/Banner"; +export * from "./composer/HistoryVisibleBannerView"; +export * from "./event-tiles/TextualEventView"; +export * from "./message-body/MediaBody"; +export * from "./pill-input/Pill"; +export * from "./pill-input/PillInput"; +export * from "./rich-list/RichItem"; +export * from "./rich-list/RichList"; +export * from "./room-list/RoomListSearchView"; +export * from "./utils/Box"; +export * from "./utils/Flex"; + +// Utils +export * from "./utils/i18n"; +export * from "./utils/i18nContext"; +export * from "./utils/humanize"; +export * from "./utils/DateUtils"; +export * from "./utils/numbers"; +export * from "./utils/FormattingUtils"; +export * from "./utils/I18nApi"; + +// MVVM +export * from "./viewmodel"; +export * from "./useMockedViewModel"; +export * from "./useViewModel"; + +// i18n (we must export this directly in order to not confuse the type bundler, it seems, +// otherwise it will leave it as a relative import rather than bundling it) +export type * from "./i18nKeys.d.ts"; diff --git a/packages/shared-components/src/message-body/MediaBody/MediaBody.module.css b/packages/shared-components/src/message-body/MediaBody/MediaBody.module.css new file mode 100644 index 0000000000..49d8ed8f4a --- /dev/null +++ b/packages/shared-components/src/message-body/MediaBody/MediaBody.module.css @@ -0,0 +1,17 @@ +/* + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial + * Please see LICENSE files in the repository root for full details. + */ + +.mediaBody { + background-color: var(--cpd-color-bg-subtle-secondary); + border-radius: var(--cpd-space-2x) !important; + max-width: 243px; /* use max-width instead of width so it fits within right panels */ + + font: var(--cpd-font-body-md-regular); + color: var(--cpd-color-text-secondary); + + padding: var(--cpd-space-1-5x) var(--cpd-space-3x); +} diff --git a/packages/shared-components/src/message-body/MediaBody/MediaBody.stories.tsx b/packages/shared-components/src/message-body/MediaBody/MediaBody.stories.tsx new file mode 100644 index 0000000000..ee90a37943 --- /dev/null +++ b/packages/shared-components/src/message-body/MediaBody/MediaBody.stories.tsx @@ -0,0 +1,24 @@ +/* + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial + * Please see LICENSE files in the repository root for full details. + */ + +import React from "react"; + +import { MediaBody } from "./MediaBody"; +import type { Meta, StoryFn } from "@storybook/react-vite"; + +export default { + title: "MessageBody/MediaBody", + component: MediaBody, + tags: ["autodocs"], + args: { + children: "Media content goes here", + }, +} as Meta; + +const Template: StoryFn = (args) => ; + +export const Default = Template.bind({}); diff --git a/packages/shared-components/src/message-body/MediaBody/MediaBody.test.tsx b/packages/shared-components/src/message-body/MediaBody/MediaBody.test.tsx new file mode 100644 index 0000000000..9d405e1af3 --- /dev/null +++ b/packages/shared-components/src/message-body/MediaBody/MediaBody.test.tsx @@ -0,0 +1,21 @@ +/* + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial + * Please see LICENSE files in the repository root for full details. + */ + +import { composeStories } from "@storybook/react-vite"; +import { render } from "jest-matrix-react"; +import React from "react"; + +import * as stories from "./MediaBody.stories"; + +const { Default } = composeStories(stories); + +describe("MediaBody", () => { + it("renders the media body", () => { + const { container } = render(); + expect(container).toMatchSnapshot(); + }); +}); diff --git a/packages/shared-components/src/message-body/MediaBody/MediaBody.tsx b/packages/shared-components/src/message-body/MediaBody/MediaBody.tsx new file mode 100644 index 0000000000..86545db67a --- /dev/null +++ b/packages/shared-components/src/message-body/MediaBody/MediaBody.tsx @@ -0,0 +1,48 @@ +/* + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial + * Please see LICENSE files in the repository root for full details. + */ + +import { type ComponentProps, type ElementType, type JSX, type PropsWithChildren } from "react"; +import React from "react"; +import classNames from "classnames"; + +import styles from "./MediaBody.module.css"; + +export type MediaBodyProps = { + /** + * The HTML tag. + * @default "div" + */ + as?: C; + /** + * The CSS class name. + */ + className?: string; +} & ComponentProps; + +/** + * A component to display the body of a media message. + * + * @example + * ```tsx + * Media body content + * ``` + */ +export function MediaBody({ + as, + className, + children, + ...props +}: PropsWithChildren>): JSX.Element { + const Component = as || "div"; + + // Keep Mx_MediaBody to support the compatibility with existing timeline and the all the layout + return ( + + {children} + + ); +} diff --git a/packages/shared-components/src/message-body/MediaBody/__snapshots__/MediaBody.test.tsx.snap b/packages/shared-components/src/message-body/MediaBody/__snapshots__/MediaBody.test.tsx.snap new file mode 100644 index 0000000000..d86c677390 --- /dev/null +++ b/packages/shared-components/src/message-body/MediaBody/__snapshots__/MediaBody.test.tsx.snap @@ -0,0 +1,11 @@ +// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing + +exports[`MediaBody renders the media body 1`] = ` +
+
+ Media content goes here +
+
+`; diff --git a/packages/shared-components/src/message-body/MediaBody/index.tsx b/packages/shared-components/src/message-body/MediaBody/index.tsx new file mode 100644 index 0000000000..e90b1756d3 --- /dev/null +++ b/packages/shared-components/src/message-body/MediaBody/index.tsx @@ -0,0 +1,8 @@ +/* + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial + * Please see LICENSE files in the repository root for full details. + */ + +export { MediaBody } from "./MediaBody"; diff --git a/packages/shared-components/src/pill-input/Pill/Pill.module.css b/packages/shared-components/src/pill-input/Pill/Pill.module.css new file mode 100644 index 0000000000..59df489e06 --- /dev/null +++ b/packages/shared-components/src/pill-input/Pill/Pill.module.css @@ -0,0 +1,17 @@ +/* + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial + * Please see LICENSE files in the repository root for full details. + */ + +.pill { + background-color: var(--cpd-color-bg-action-primary-rest); + padding: var(--cpd-space-1x) var(--cpd-space-1-5x) var(--cpd-space-1x) var(--cpd-space-1x); + border-radius: 99px; +} + +.label { + color: var(--cpd-color-text-on-solid-primary); + font: var(--cpd-font-body-sm-medium); +} diff --git a/packages/shared-components/src/pill-input/Pill/Pill.stories.tsx b/packages/shared-components/src/pill-input/Pill/Pill.stories.tsx new file mode 100644 index 0000000000..1ed233adf8 --- /dev/null +++ b/packages/shared-components/src/pill-input/Pill/Pill.stories.tsx @@ -0,0 +1,33 @@ +/* + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial + * Please see LICENSE files in the repository root for full details. + */ + +import React from "react"; +import { fn } from "storybook/test"; + +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { Pill } from "./Pill"; + +const meta = { + title: "PillInput/Pill", + component: Pill, + tags: ["autodocs"], + args: { + label: "Pill", + children:
, + onClick: fn(), + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Default: Story = {}; +export const WithoutCloseButton: Story = { + args: { + onClick: undefined, + }, +}; diff --git a/packages/shared-components/src/pill-input/Pill/Pill.test.tsx b/packages/shared-components/src/pill-input/Pill/Pill.test.tsx new file mode 100644 index 0000000000..a539f6c295 --- /dev/null +++ b/packages/shared-components/src/pill-input/Pill/Pill.test.tsx @@ -0,0 +1,26 @@ +/* + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial + * Please see LICENSE files in the repository root for full details. + */ + +import { composeStories } from "@storybook/react-vite"; +import { render } from "jest-matrix-react"; +import React from "react"; + +import * as stories from "./Pill.stories"; + +const { Default, WithoutCloseButton } = composeStories(stories); + +describe("Pill", () => { + it("renders the pill", () => { + const { container } = render(); + expect(container).toMatchSnapshot(); + }); + + it("renders the pill without close button", () => { + const { container } = render(); + expect(container).toMatchSnapshot(); + }); +}); diff --git a/packages/shared-components/src/pill-input/Pill/Pill.tsx b/packages/shared-components/src/pill-input/Pill/Pill.tsx new file mode 100644 index 0000000000..64fdac3d2d --- /dev/null +++ b/packages/shared-components/src/pill-input/Pill/Pill.tsx @@ -0,0 +1,69 @@ +/* + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial + * Please see LICENSE files in the repository root for full details. + */ + +import React, { type MouseEventHandler, type JSX, type PropsWithChildren, type HTMLAttributes, useId } from "react"; +import classNames from "classnames"; +import { IconButton } from "@vector-im/compound-web"; +import CloseIcon from "@vector-im/compound-design-tokens/assets/web/icons/close"; + +import { Flex } from "../../utils/Flex"; +import styles from "./Pill.module.css"; +import { useI18n } from "../../utils/i18nContext"; + +export interface PillProps extends Omit, "onClick"> { + /** + * The text label to display inside the pill. + */ + label: string; + /** + * Optional click handler for a close button. + * If provided, a close button will be rendered. + */ + onClick?: MouseEventHandler; +} + +/** + * A pill component that can display a label and an optional close button. + * The badge can also contain child elements, such as icons or avatars. + * + * @example + * ```tsx + * console.log("Closed")}> + * + * + * ``` + */ +export function Pill({ className, children, label, onClick, ...props }: PropsWithChildren): JSX.Element { + const id = useId(); + const { translate: _t } = useI18n(); + + return ( + + {children} + + {label} + + {onClick && ( + + + + )} + + ); +} diff --git a/packages/shared-components/src/pill-input/Pill/__snapshots__/Pill.test.tsx.snap b/packages/shared-components/src/pill-input/Pill/__snapshots__/Pill.test.tsx.snap new file mode 100644 index 0000000000..2ef6575b20 --- /dev/null +++ b/packages/shared-components/src/pill-input/Pill/__snapshots__/Pill.test.tsx.snap @@ -0,0 +1,66 @@ +// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing + +exports[`Pill renders the pill 1`] = ` +
+
+
+ + Pill + + +
+
+`; + +exports[`Pill renders the pill without close button 1`] = ` +
+
+
+ + Pill + +
+
+`; diff --git a/packages/shared-components/src/pill-input/Pill/index.ts b/packages/shared-components/src/pill-input/Pill/index.ts new file mode 100644 index 0000000000..1e0a4b6b66 --- /dev/null +++ b/packages/shared-components/src/pill-input/Pill/index.ts @@ -0,0 +1,8 @@ +/* + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial + * Please see LICENSE files in the repository root for full details. + */ + +export { Pill } from "./Pill"; diff --git a/packages/shared-components/src/pill-input/PillInput/PillInput.module.css b/packages/shared-components/src/pill-input/PillInput/PillInput.module.css new file mode 100644 index 0000000000..888da57746 --- /dev/null +++ b/packages/shared-components/src/pill-input/PillInput/PillInput.module.css @@ -0,0 +1,34 @@ +/* + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial + * Please see LICENSE files in the repository root for full details. + */ + +.pillInput { + background-color: var(--cpd-color-bg-subtle-secondary); + border-radius: 20px; + padding: var(--cpd-space-2x) var(--cpd-space-3x) var(--cpd-space-2x) var(--cpd-space-3x); + /* To match pill height in order to avoid the PillInput to grow when a pill is inserted */ + min-height: 28px; +} + +.pillInput:has(.input:focus) { + outline: var(--cpd-border-width-1) solid var(--cpd-color-gray-1400); +} + +.input { + all: unset; + width: 100%; + flex: 1; + color: var(--cpd-color-text-primary); +} + +.input::placeholder { + color: var(--cpd-color-text-secondary); + font: var(--cpd-font-body-md-regular); +} + +.largerInput { + padding: var(--cpd-space-2x) 0; +} diff --git a/packages/shared-components/src/pill-input/PillInput/PillInput.stories.tsx b/packages/shared-components/src/pill-input/PillInput/PillInput.stories.tsx new file mode 100644 index 0000000000..3bb119dc75 --- /dev/null +++ b/packages/shared-components/src/pill-input/PillInput/PillInput.stories.tsx @@ -0,0 +1,38 @@ +/* + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial + * Please see LICENSE files in the repository root for full details. + */ + +import React from "react"; +import { fn } from "storybook/test"; + +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { PillInput } from "./PillInput"; + +const meta = { + title: "PillInput/PillInput", + component: PillInput, + tags: ["autodocs"], + args: { + children: ( + <> +
+
+ + ), + onChange: fn(), + onRemoveChildren: fn(), + inputProps: { + "placeholder": "Type something...", + "aria-label": "pill input", + }, + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Default: Story = {}; +export const NoChild: Story = { args: { children: undefined } }; diff --git a/packages/shared-components/src/pill-input/PillInput/PillInput.test.tsx b/packages/shared-components/src/pill-input/PillInput/PillInput.test.tsx new file mode 100644 index 0000000000..b20b53c6e4 --- /dev/null +++ b/packages/shared-components/src/pill-input/PillInput/PillInput.test.tsx @@ -0,0 +1,43 @@ +/* + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial + * Please see LICENSE files in the repository root for full details. + */ + +import { render, screen } from "jest-matrix-react"; +import React from "react"; +import { composeStories } from "@storybook/react-vite"; +import userEvent from "@testing-library/user-event"; + +import * as stories from "./PillInput.stories"; +import { PillInput } from "./PillInput"; + +const { Default, NoChild } = composeStories(stories); + +describe("PillInput", () => { + it("renders the pill input", () => { + const { container } = render(); + expect(container).toMatchSnapshot(); + }); + + it("renders only the input without children", () => { + const { container } = render(); + expect(container).toMatchSnapshot(); + }); + + it("calls onRemoveChildren when backspace is pressed and input is empty", async () => { + const user = userEvent.setup(); + const mockOnRemoveChildren = jest.fn(); + + render(); + + const input = screen.getByRole("textbox"); + + // Focus the input and press backspace (input should be empty by default) + await user.click(input); + await user.keyboard("{Backspace}"); + + expect(mockOnRemoveChildren).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/shared-components/src/pill-input/PillInput/PillInput.tsx b/packages/shared-components/src/pill-input/PillInput/PillInput.tsx new file mode 100644 index 0000000000..bcb2cb1932 --- /dev/null +++ b/packages/shared-components/src/pill-input/PillInput/PillInput.tsx @@ -0,0 +1,96 @@ +/* + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial + * Please see LICENSE files in the repository root for full details. + */ + +import React, { + type PropsWithChildren, + type JSX, + useRef, + type KeyboardEventHandler, + type HTMLAttributes, + type HTMLProps, + Children, +} from "react"; +import classNames from "classnames"; +import { omit } from "lodash"; +import { useMergeRefs } from "react-merge-refs"; + +import styles from "./PillInput.module.css"; +import { Flex } from "../../utils/Flex"; + +export interface PillInputProps extends HTMLAttributes { + /** + * Callback for when the user presses backspace on an empty input. + */ + onRemoveChildren?: KeyboardEventHandler; + /** + * Props to pass to the input element. + */ + inputProps?: HTMLProps & { "data-testid"?: string }; +} + +/** + * An input component that can contain multiple child elements and an input field. + * + * @example + * ```tsx + * + *
Child 1
+ *
Child 2
+ *
+ * ``` + */ +export function PillInput({ + className, + children, + onRemoveChildren, + inputProps, + ...props +}: PropsWithChildren): JSX.Element { + const inputRef = useRef(null); + const inputAttributes = omit(inputProps, ["onKeyDown", "ref"]); + const ref = useMergeRefs([inputRef, inputProps?.ref]); + + const hasChildren = Children.toArray(children).length > 0; + + return ( + { + evt.preventDefault(); + evt.stopPropagation(); + inputRef.current?.focus(); + }} + > + {hasChildren && ( + + {children} + + )} + { + const value = evt.currentTarget.value.trim(); + + // If the input is empty and the user presses backspace, we call the onRemoveChildren handler + if (evt.key === "Backspace" && !value) { + evt.preventDefault(); + onRemoveChildren?.(evt); + return; + } + + inputProps?.onKeyDown?.(evt); + }} + {...inputAttributes} + /> + + ); +} diff --git a/packages/shared-components/src/pill-input/PillInput/__snapshots__/PillInput.test.tsx.snap b/packages/shared-components/src/pill-input/PillInput/__snapshots__/PillInput.test.tsx.snap new file mode 100644 index 0000000000..37a1d08c8b --- /dev/null +++ b/packages/shared-components/src/pill-input/PillInput/__snapshots__/PillInput.test.tsx.snap @@ -0,0 +1,44 @@ +// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing + +exports[`PillInput renders only the input without children 1`] = ` +
+
+ +
+
+`; + +exports[`PillInput renders the pill input 1`] = ` +
+
+
+
+
+
+ +
+
+`; diff --git a/packages/shared-components/src/pill-input/PillInput/index.ts b/packages/shared-components/src/pill-input/PillInput/index.ts new file mode 100644 index 0000000000..76cb6e4625 --- /dev/null +++ b/packages/shared-components/src/pill-input/PillInput/index.ts @@ -0,0 +1,8 @@ +/* + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial + * Please see LICENSE files in the repository root for full details. + */ + +export { PillInput } from "./PillInput"; diff --git a/packages/shared-components/src/rich-list/RichItem/RichItem.module.css b/packages/shared-components/src/rich-list/RichItem/RichItem.module.css new file mode 100644 index 0000000000..d1028f33e4 --- /dev/null +++ b/packages/shared-components/src/rich-list/RichItem/RichItem.module.css @@ -0,0 +1,76 @@ +/* + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial + * Please see LICENSE files in the repository root for full details. + */ + +.richItem { + /* Remove browser button style */ + background: transparent; + border: none; + padding: var(--cpd-space-2x) var(--cpd-space-4x) var(--cpd-space-2x) var(--cpd-space-4x); + width: 100%; + box-sizing: border-box; + cursor: pointer; + text-align: start; + + display: grid; + column-gap: var(--cpd-space-3x); + grid-template-columns: max-content 1fr max-content; + grid-template-areas: + "avatar title time" + "avatar description time"; +} + +.richItem:hover, +.richItem:focus { + background-color: var(--cpd-color-bg-subtle-secondary); + border-radius: 12px; +} + +.richItem:not(:last-child) { + border-bottom: var(--cpd-border-width-1) solid var(--cpd-color-gray-300); +} + +.avatar { + grid-area: avatar; + align-self: center; +} + +.title { + grid-area: title; + font: var(--cpd-font-body-sm-semibold); + color: var(--cpd-color-text-primary); +} + +.description { + grid-area: description; +} + +.timestamp { + grid-area: time; + align-self: center; +} + +.title, +.description { + text-overflow: ellipsis; + overflow: hidden; + white-space: nowrap; +} + +.description, +.timestamp { + font: var(--cpd-font-body-sm-regular); + color: var(--cpd-color-text-secondary); +} + +.checkmark { + grid-area: avatar; + align-self: center; + background-color: var(--cpd-color-icon-accent-primary); + width: 32px; + height: 32px; + border-radius: 100%; +} diff --git a/packages/shared-components/src/rich-list/RichItem/RichItem.stories.tsx b/packages/shared-components/src/rich-list/RichItem/RichItem.stories.tsx new file mode 100644 index 0000000000..11834bf6c4 --- /dev/null +++ b/packages/shared-components/src/rich-list/RichItem/RichItem.stories.tsx @@ -0,0 +1,64 @@ +/* + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial + * Please see LICENSE files in the repository root for full details. + */ + +import React from "react"; +import { fn } from "storybook/test"; + +import type { Meta, StoryFn } from "@storybook/react-vite"; +import { RichItem } from "./RichItem"; + +const currentTimestamp = new Date("2025-03-09T12:00:00Z").getTime(); + +export default { + title: "RichList/RichItem", + component: RichItem, + tags: ["autodocs"], + args: { + avatar:
, + title: "Rich Item Title", + description: "This is a description of the rich item.", + timestamp: currentTimestamp, + onClick: fn(), + }, + beforeEach: () => { + Date.now = () => new Date("2025-08-01T12:00:00Z").getTime(); + }, + parameters: { + a11y: { + context: "button", + }, + }, +} as Meta; + +const Template: StoryFn = (args) => ( +
    + +
+); + +export const Default = Template.bind({}); + +export const Selected = Template.bind({}); +Selected.args = { + selected: true, +}; + +export const WithoutTimestamp = Template.bind({}); +WithoutTimestamp.args = { + timestamp: undefined, +}; + +export const Hover = Template.bind({}); +Hover.parameters = { pseudo: { hover: true } }; + +const TemplateSeparator: StoryFn = (args) => ( +
    + + +
+); +export const Separator = TemplateSeparator.bind({}); diff --git a/packages/shared-components/src/rich-list/RichItem/RichItem.test.tsx b/packages/shared-components/src/rich-list/RichItem/RichItem.test.tsx new file mode 100644 index 0000000000..b5322d1fa5 --- /dev/null +++ b/packages/shared-components/src/rich-list/RichItem/RichItem.test.tsx @@ -0,0 +1,35 @@ +/* + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial + * Please see LICENSE files in the repository root for full details. + */ + +import { composeStories } from "@storybook/react-vite"; +import { render } from "jest-matrix-react"; +import React from "react"; + +import * as stories from "./RichItem.stories"; + +const { Default, Selected, WithoutTimestamp } = composeStories(stories); + +describe("RichItem", () => { + beforeAll(() => { + jest.useFakeTimers().setSystemTime(new Date("2025-08-01T12:00:00Z")); + }); + + it("renders the item in default state", () => { + const { container } = render(); + expect(container).toMatchSnapshot(); + }); + + it("renders the item in selected state", () => { + const { container } = render(); + expect(container).toMatchSnapshot(); + }); + + it("renders the item without timestamp", () => { + const { container } = render(); + expect(container).toMatchSnapshot(); + }); +}); diff --git a/packages/shared-components/src/rich-list/RichItem/RichItem.tsx b/packages/shared-components/src/rich-list/RichItem/RichItem.tsx new file mode 100644 index 0000000000..9aeff4c924 --- /dev/null +++ b/packages/shared-components/src/rich-list/RichItem/RichItem.tsx @@ -0,0 +1,98 @@ +/* + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial + * Please see LICENSE files in the repository root for full details. + */ + +import React, { type HTMLAttributes, type JSX, memo } from "react"; +import CheckIcon from "@vector-im/compound-design-tokens/assets/web/icons/check"; + +import styles from "./RichItem.module.css"; +import { Flex } from "../../utils/Flex"; +import { useI18n } from "../../utils/i18nContext"; + +export interface RichItemProps extends HTMLAttributes { + /** + * Avatar to display at the start of the item + */ + avatar: React.ReactNode; + /** + * Title to display at the top of the item + */ + title: string; + /** + * Description to display below the title + */ + description: string; + /** + * Timestamp to display at the end of the item + * The value is humanized (e.g. "5 minutes ago") + */ + timestamp?: number; + /** + * Whether the item is selected + * This will replace the avatar with a checkmark + * @default false + */ + selected?: boolean; +} + +/** + * A rich item to display in a list, with an avatar, title, description and optional timestamp. + * If selected, the avatar is replaced with a checkmark. + * A separator is added between items in a list. + * + * @example + * ```tsx + * } + * title="Rich Item Title" + * description="This is a description of the rich item." + * timestamp={Date.now() - 5 * 60 * 1000} // 5 minutes ago + * selected={true} + * onClick={() => console.log("Item clicked")} + * /> + * ``` + */ +export const RichItem = memo(function RichItem({ + avatar, + title, + description, + timestamp, + selected, + ...props +}: RichItemProps): JSX.Element { + const i18n = useI18n(); + + return ( +
  • + {selected ? : {avatar}} + {title} + {description} + {timestamp && ( + + {i18n.humanizeTime(timestamp)} + + )} +
  • + ); +}); + +/** + * A checkmark icon inside a circle, used to indicate selection. + */ +function Checkmark(): JSX.Element { + return ( + + ); +} diff --git a/packages/shared-components/src/rich-list/RichItem/__snapshots__/RichItem.test.tsx.snap b/packages/shared-components/src/rich-list/RichItem/__snapshots__/RichItem.test.tsx.snap new file mode 100644 index 0000000000..6ccc190d8c --- /dev/null +++ b/packages/shared-components/src/rich-list/RichItem/__snapshots__/RichItem.test.tsx.snap @@ -0,0 +1,129 @@ +// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing + +exports[`RichItem renders the item in default state 1`] = ` +
    +
      +
    • +
      +
      +
      + + Rich Item Title + + + This is a description of the rich item. + + + 145 days ago + +
    • +
    +
    +`; + +exports[`RichItem renders the item in selected state 1`] = ` +
    +
      +
    • + + + Rich Item Title + + + This is a description of the rich item. + + + 145 days ago + +
    • +
    +
    +`; + +exports[`RichItem renders the item without timestamp 1`] = ` +
    +
      +
    • +
      +
      +
      + + Rich Item Title + + + This is a description of the rich item. + +
    • +
    +
    +`; diff --git a/packages/shared-components/src/rich-list/RichItem/index.ts b/packages/shared-components/src/rich-list/RichItem/index.ts new file mode 100644 index 0000000000..0301144246 --- /dev/null +++ b/packages/shared-components/src/rich-list/RichItem/index.ts @@ -0,0 +1,8 @@ +/* + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial + * Please see LICENSE files in the repository root for full details. + */ + +export { RichItem } from "./RichItem"; diff --git a/packages/shared-components/src/rich-list/RichList/RichList.module.css b/packages/shared-components/src/rich-list/RichList/RichList.module.css new file mode 100644 index 0000000000..9fd59ef103 --- /dev/null +++ b/packages/shared-components/src/rich-list/RichList/RichList.module.css @@ -0,0 +1,30 @@ +/* + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial + * Please see LICENSE files in the repository root for full details. + */ + +.richList { + height: inherit; +} + +.title { + font: var(--cpd-font-body-sm-semibold); + color: var(--cpd-color-text-secondary); + padding: var(--cpd-space-2x) var(--cpd-space-4x) var(--cpd-space-2x) var(--cpd-space-4x); +} + +.content { + width: 100%; + overflow: auto; + /* remove browser default ul padding/margin */ + padding: 0; + margin: 0; +} + +.empty { + margin-left: var(--cpd-space-6x); + font: var(--cpd-font-body-sm-regular); + color: var(--cpd-color-text-secondary); +} diff --git a/packages/shared-components/src/rich-list/RichList/RichList.stories.tsx b/packages/shared-components/src/rich-list/RichList/RichList.stories.tsx new file mode 100644 index 0000000000..e4a9406e71 --- /dev/null +++ b/packages/shared-components/src/rich-list/RichList/RichList.stories.tsx @@ -0,0 +1,50 @@ +/* + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial + * Please see LICENSE files in the repository root for full details. + */ + +import React from "react"; + +import { RichList } from "./RichList"; +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { RichItem } from "../RichItem"; + +const avatar =
    ; + +const meta = { + title: "RichList/RichList", + component: RichList, + tags: ["autodocs"], + decorators: [ + (Story) => ( +
    + +
    + ), + ], + args: { + title: "Rich List Title", + children: ( + <> + + + + + + + ), + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Default: Story = {}; +export const Empty: Story = { + args: { + isEmpty: true, + children: "No items available", + }, +}; diff --git a/packages/shared-components/src/rich-list/RichList/RichList.test.tsx b/packages/shared-components/src/rich-list/RichList/RichList.test.tsx new file mode 100644 index 0000000000..625511f68e --- /dev/null +++ b/packages/shared-components/src/rich-list/RichList/RichList.test.tsx @@ -0,0 +1,26 @@ +/* + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial + * Please see LICENSE files in the repository root for full details. + */ + +import { composeStories } from "@storybook/react-vite"; +import { render } from "jest-matrix-react"; +import React from "react"; + +import * as stories from "./RichList.stories"; + +const { Default, Empty } = composeStories(stories); + +describe("RichItem", () => { + it("renders the list", () => { + const { container } = render(); + expect(container).toMatchSnapshot(); + }); + + it("renders the list with isEmpty=true", () => { + const { container } = render(); + expect(container).toMatchSnapshot(); + }); +}); diff --git a/packages/shared-components/src/rich-list/RichList/RichList.tsx b/packages/shared-components/src/rich-list/RichList/RichList.tsx new file mode 100644 index 0000000000..549491e299 --- /dev/null +++ b/packages/shared-components/src/rich-list/RichList/RichList.tsx @@ -0,0 +1,80 @@ +/* + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial + * Please see LICENSE files in the repository root for full details. + */ + +import React, { type HTMLProps, type JSX, type PropsWithChildren, useId } from "react"; +import classNames from "classnames"; + +import styles from "./RichList.module.css"; +import { Flex } from "../../utils/Flex"; +import { useListKeyboardNavigation } from "../../hooks/useListKeyboardNavigation"; + +export interface RichListProps extends HTMLProps { + /** + * Title to display at the top of the list + */ + title: string; + /** + * Attributes to pass to the title element + * This can be used to set accessibility attributes like `aria-level` or `role` + * @example + * ```tsx + * + * ``` + */ + titleAttributes?: HTMLProps; + /** + * Indicates if the list should show an empty state. + * The list renders its children in a span instead of an ul. + */ + isEmpty?: boolean; +} + +/** + * A list component with a title and children. + * + * @example + * ```tsx + * + * + * + * + * ``` + */ +export function RichList({ + children, + title, + className, + titleAttributes, + isEmpty = false, + ...props +}: PropsWithChildren): JSX.Element { + const id = useId(); + const { listRef, onKeyDown, onFocus } = useListKeyboardNavigation(); + + return ( + + + {title} + + {isEmpty ? ( + {children} + ) : ( +
      + {children} +
    + )} +
    + ); +} diff --git a/packages/shared-components/src/rich-list/RichList/__snapshots__/RichList.test.tsx.snap b/packages/shared-components/src/rich-list/RichList/__snapshots__/RichList.test.tsx.snap new file mode 100644 index 0000000000..41f5407d3c --- /dev/null +++ b/packages/shared-components/src/rich-list/RichList/__snapshots__/RichList.test.tsx.snap @@ -0,0 +1,189 @@ +// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing + +exports[`RichItem renders the list 1`] = ` +
    +
    +
    + + Rich List Title + +
      +
    • +
      +
      +
      + + First Item + + + description + +
    • +
    • + + + Second Item + + + description + +
    • +
    • +
      +
      +
      + + Third Item + + + description + +
    • +
    • +
      +
      +
      + + Fourth Item + + + description + +
    • +
    • +
      +
      +
      + + Fifth Item + + + description + +
    • +
    +
    +
    +
    +`; + +exports[`RichItem renders the list with isEmpty=true 1`] = ` +
    +
    +
    + + Rich List Title + + + No items available + +
    +
    +
    +`; diff --git a/packages/shared-components/src/rich-list/RichList/index.ts b/packages/shared-components/src/rich-list/RichList/index.ts new file mode 100644 index 0000000000..88999fed3f --- /dev/null +++ b/packages/shared-components/src/rich-list/RichList/index.ts @@ -0,0 +1,8 @@ +/* + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial + * Please see LICENSE files in the repository root for full details. + */ + +export { RichList } from "./RichList"; diff --git a/packages/shared-components/src/room-list/RoomListSearchView/RoomListSearchView.module.css b/packages/shared-components/src/room-list/RoomListSearchView/RoomListSearchView.module.css new file mode 100644 index 0000000000..9b7097373c --- /dev/null +++ b/packages/shared-components/src/room-list/RoomListSearchView/RoomListSearchView.module.css @@ -0,0 +1,47 @@ +/* + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial + * Please see LICENSE files in the repository root for full details. + */ + +.view { + /* From figma, this should be aligned with the room header */ + min-height: 64px; + box-sizing: border-box; + border-bottom: var(--cpd-border-width-1) solid var(--cpd-color-bg-subtle-primary); + padding: 0 var(--cpd-space-3x); +} + +.search { + /* The search button should take all the remaining space */ + flex: 1; + /* !important is needed to override compound button in EW */ + font: var(--cpd-font-body-md-regular) !important; + color: var(--cpd-color-text-secondary) !important; + min-width: 0; + + svg { + fill: var(--cpd-color-icon-secondary); + } +} + +.search_container { + flex: 1; + + /* Shrink and truncate the search text */ + white-space: nowrap; + overflow: hidden; + + kbd { + font-family: inherit; + } +} + +.search_text { + min-width: 0; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + text-align: start; +} diff --git a/packages/shared-components/src/room-list/RoomListSearchView/RoomListSearchView.stories.tsx b/packages/shared-components/src/room-list/RoomListSearchView/RoomListSearchView.stories.tsx new file mode 100644 index 0000000000..66f5af461c --- /dev/null +++ b/packages/shared-components/src/room-list/RoomListSearchView/RoomListSearchView.stories.tsx @@ -0,0 +1,74 @@ +/* + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial + * Please see LICENSE files in the repository root for full details. + */ + +import React, { type JSX } from "react"; +import { fn } from "storybook/test"; + +import type { Meta, StoryFn } from "@storybook/react-vite"; +import { + RoomListSearchView, + type RoomListSearchViewActions, + type RoomListSearchViewSnapshot, +} from "./RoomListSearchView"; +import { useMockedViewModel } from "../../useMockedViewModel"; + +type RoomListSearchProps = RoomListSearchViewSnapshot & RoomListSearchViewActions; + +const RoomListSearchViewWrapper = ({ + onSearchClick, + onDialPadClick, + onExploreClick, + ...rest +}: RoomListSearchProps): JSX.Element => { + const vm = useMockedViewModel(rest, { + onSearchClick, + onDialPadClick, + onExploreClick, + }); + return ; +}; + +export default { + title: "Room List/RoomListSearchView", + component: RoomListSearchViewWrapper, + tags: ["autodocs"], + args: { + displayExploreButton: true, + displayDialButton: false, + searchShortcut: "⌘ K", + onSearchClick: fn(), + onDialPadClick: fn(), + onExploreClick: fn(), + }, + parameters: { + design: { + type: "figma", + url: "https://www.figma.com/design/vlmt46QDdE4dgXDiyBJXqp/ER-33-Left-Panel-2025?node-id=98-1979&t=vafb4zoYMNLRuAbh-4", + }, + }, +} as Meta; + +const Template: StoryFn = (args) => ; + +export const Default = Template.bind({}); + +export const WithDialPad = Template.bind({}); +WithDialPad.args = { + displayDialButton: true, +}; + +export const WithoutExplore = Template.bind({}); +WithoutExplore.args = { + displayExploreButton: false, +}; + +export const AllButtons = Template.bind({}); +AllButtons.args = { + displayExploreButton: true, + displayDialButton: true, + searchShortcut: "⌘ K", +}; diff --git a/packages/shared-components/src/room-list/RoomListSearchView/RoomListSearchView.test.tsx b/packages/shared-components/src/room-list/RoomListSearchView/RoomListSearchView.test.tsx new file mode 100644 index 0000000000..1a256d63b1 --- /dev/null +++ b/packages/shared-components/src/room-list/RoomListSearchView/RoomListSearchView.test.tsx @@ -0,0 +1,103 @@ +/* + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial + * Please see LICENSE files in the repository root for full details. + */ + +import { render, screen } from "jest-matrix-react"; +import { composeStories } from "@storybook/react-vite"; +import React from "react"; +import userEvent from "@testing-library/user-event"; + +import * as stories from "./RoomListSearchView.stories"; +import { + RoomListSearchView, + type RoomListSearchViewActions, + type RoomListSearchViewSnapshot, +} from "./RoomListSearchView"; +import { MockViewModel } from "../../viewmodel/MockViewModel"; + +const { Default, WithDialPad, WithoutExplore, AllButtons } = composeStories(stories); + +describe("RoomListSearchView", () => { + afterEach(() => { + jest.clearAllMocks(); + }); + + describe("Storybook snapshots", () => { + it("renders the default state", () => { + const { container } = render(); + expect(container).toMatchSnapshot(); + }); + + it("renders with dial pad button", () => { + const { container } = render(); + expect(container).toMatchSnapshot(); + }); + + it("renders without explore button", () => { + const { container } = render(); + expect(container).toMatchSnapshot(); + }); + + it("renders with all buttons visible", () => { + const { container } = render(); + expect(container).toMatchSnapshot(); + }); + }); + + describe("User interactions", () => { + const onSearchClick = jest.fn(); + const onDialPadClick = jest.fn(); + const onExploreClick = jest.fn(); + + class TestViewModel extends MockViewModel implements RoomListSearchViewActions { + public onSearchClick = onSearchClick; + public onDialPadClick = onDialPadClick; + public onExploreClick = onExploreClick; + } + + it("should call onSearchClick when search button is clicked", async () => { + const user = userEvent.setup(); + const vm = new TestViewModel({ + displayExploreButton: false, + displayDialButton: false, + searchShortcut: "⌘ K", + }); + + render(); + + await user.click(screen.getByRole("button", { name: "Search ⌘ K" })); + expect(onSearchClick).toHaveBeenCalledTimes(1); + }); + + it("should call onDialPadClick when dial pad button is clicked", async () => { + const user = userEvent.setup(); + const vm = new TestViewModel({ + displayExploreButton: false, + displayDialButton: true, + searchShortcut: "⌘ K", + }); + + render(); + + await user.click(screen.getByRole("button", { name: "Open dial pad" })); + expect(onDialPadClick).toHaveBeenCalledTimes(1); + }); + + it("should call onExploreClick when explore button is clicked", async () => { + const user = userEvent.setup(); + const vm = new TestViewModel({ + displayExploreButton: true, + displayDialButton: false, + searchShortcut: "⌘ K", + }); + + render(); + + await user.click(screen.getByRole("button", { name: "Explore rooms" })); + expect(onExploreClick).toHaveBeenCalledTimes(1); + }); + }); +}); diff --git a/packages/shared-components/src/room-list/RoomListSearchView/RoomListSearchView.tsx b/packages/shared-components/src/room-list/RoomListSearchView/RoomListSearchView.tsx new file mode 100644 index 0000000000..0c652328ec --- /dev/null +++ b/packages/shared-components/src/room-list/RoomListSearchView/RoomListSearchView.tsx @@ -0,0 +1,119 @@ +/* + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial + * Please see LICENSE files in the repository root for full details. + */ + +import React, { type JSX, type MouseEventHandler } from "react"; +import { Button } from "@vector-im/compound-web"; +import ExploreIcon from "@vector-im/compound-design-tokens/assets/web/icons/explore"; +import SearchIcon from "@vector-im/compound-design-tokens/assets/web/icons/search"; +import DialPadIcon from "@vector-im/compound-design-tokens/assets/web/icons/dial-pad"; + +import styles from "./RoomListSearchView.module.css"; +import { type ViewModel } from "../../viewmodel/ViewModel"; +import { useViewModel } from "../../useViewModel"; +import { Flex } from "../../utils/Flex"; +import { useI18n } from "../../utils/i18nContext"; + +export interface RoomListSearchViewSnapshot { + /** + * Whether to display the explore button. + */ + displayExploreButton: boolean; + /** + * Whether to display the dial pad button. + */ + displayDialButton: boolean; + /** + * The keyboard shortcut text to display for the search action. + * For example: "⌘ K" on macOS or "Ctrl K" on other platforms. + */ + searchShortcut: string; +} + +export interface RoomListSearchViewActions { + /** + * Handles the click event on the search button. + */ + onSearchClick: MouseEventHandler; + /** + * Handles the click event on the dial pad button. + */ + onDialPadClick: MouseEventHandler; + /** + * Handles the click event on the explore button. + */ + onExploreClick: MouseEventHandler; +} + +/** + * The view model for the room list search component. + */ +export type RoomListSearchViewModel = ViewModel & RoomListSearchViewActions; + +interface RoomListSearchViewProps { + /** + * The view model for the room list search component. + */ + vm: RoomListSearchViewModel; +} + +/** + * A search component to be displayed at the top of the room list. + * The component provides search functionality, optional dial pad access, and optional room exploration. + * + * @example + * ```tsx + * + * ``` + */ +export function RoomListSearchView({ vm }: Readonly): JSX.Element { + const { translate: _t } = useI18n(); + const { displayExploreButton, displayDialButton, searchShortcut } = useViewModel(vm); + + return ( + + + {displayDialButton && ( + + +
    +
    +`; + +exports[`RoomListSearchView Storybook snapshots renders with all buttons visible 1`] = ` +
    + +
    +`; + +exports[`RoomListSearchView Storybook snapshots renders with dial pad button 1`] = ` +
    + +
    +`; + +exports[`RoomListSearchView Storybook snapshots renders without explore button 1`] = ` +
    + +
    +`; diff --git a/packages/shared-components/src/room-list/RoomListSearchView/index.ts b/packages/shared-components/src/room-list/RoomListSearchView/index.ts new file mode 100644 index 0000000000..a750dca7db --- /dev/null +++ b/packages/shared-components/src/room-list/RoomListSearchView/index.ts @@ -0,0 +1,9 @@ +/* + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial + * Please see LICENSE files in the repository root for full details. + */ + +export type { RoomListSearchViewModel, RoomListSearchViewSnapshot } from "./RoomListSearchView"; +export { RoomListSearchView } from "./RoomListSearchView"; diff --git a/packages/shared-components/src/test/setupTests.ts b/packages/shared-components/src/test/setupTests.ts new file mode 100644 index 0000000000..43ffc0c071 --- /dev/null +++ b/packages/shared-components/src/test/setupTests.ts @@ -0,0 +1,22 @@ +/* +Copyright 2025 Element Creations Ltd. + +SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial +Please see LICENSE files in the repository root for full details. +*/ + +import fetchMock from "fetch-mock-jest"; + +import { setLanguage } from "../../src/utils/i18n"; +import en from "../../../../src/i18n/strings/en_EN.json"; + +export function setupLanguageMock(): void { + fetchMock + .get("/i18n/languages.json", { + en: "en_EN.json", + }) + .get("end:en_EN.json", en); +} +setupLanguageMock(); + +setLanguage("en"); diff --git a/packages/shared-components/src/test/utils/jest-matrix-react.tsx b/packages/shared-components/src/test/utils/jest-matrix-react.tsx new file mode 100644 index 0000000000..d610d87211 --- /dev/null +++ b/packages/shared-components/src/test/utils/jest-matrix-react.tsx @@ -0,0 +1,55 @@ +/* +Copyright 2025 Element Creations Ltd. +Copyright 2024 New Vector Ltd. +Copyright 2022 The Matrix.org Foundation C.I.C. + +SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial +Please see LICENSE files in the repository root for full details. +*/ + +// Copied from element-web/test/test-utils because, seemingly, if we +// set that as the modules directory to use it directly, it fails to +// actually put the right thing in the context somehow. + +import React, { type ReactElement } from "react"; +// eslint-disable-next-line no-restricted-imports +import { render, type RenderOptions } from "@testing-library/react"; +import { TooltipProvider } from "@vector-im/compound-web"; + +import { I18nApi, I18nContext } from "../.."; + +const wrapWithTooltipProvider = (Wrapper: RenderOptions["wrapper"]) => { + return ({ children }: { children: React.ReactNode }) => { + if (Wrapper) { + return ( + + + {children} + + + ); + } else { + return ( + + {children} + + ); + } + }; +}; + +const customRender = (ui: ReactElement, options: RenderOptions = {}): ReturnType => { + return render(ui, { + ...options, + wrapper: wrapWithTooltipProvider(options?.wrapper) as RenderOptions["wrapper"], + }) as ReturnType; +}; + +// eslint-disable-next-line no-restricted-imports +export * from "@testing-library/react"; + +/** + * This custom render function wraps your component with a TooltipProvider. + * See https://testing-library.com/docs/react-testing-library/setup/#custom-render + */ +export { customRender as render }; diff --git a/packages/shared-components/src/useMockedViewModel.ts b/packages/shared-components/src/useMockedViewModel.ts new file mode 100644 index 0000000000..6f13075351 --- /dev/null +++ b/packages/shared-components/src/useMockedViewModel.ts @@ -0,0 +1,25 @@ +/* + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial + * Please see LICENSE files in the repository root for full details. + */ + +import { useMemo } from "react"; + +import { MockViewModel, type ViewModel } from "./viewmodel"; + +/** + * Hook helper to return a mocked view model created with the given snapshot and actions. + * This is useful for testing components in isolation with a mocked view model and allows to use primitive types in stories. + * + * @param snapshot + * @param actions + */ +export function useMockedViewModel(snapshot: S, actions: A): ViewModel & A { + return useMemo(() => { + const vm = new MockViewModel(snapshot); + Object.assign(vm, actions); + return vm as unknown as ViewModel & A; + }, [snapshot, actions]); +} diff --git a/packages/shared-components/src/useViewModel.ts b/packages/shared-components/src/useViewModel.ts new file mode 100644 index 0000000000..20c7070bff --- /dev/null +++ b/packages/shared-components/src/useViewModel.ts @@ -0,0 +1,21 @@ +/* +Copyright 2025 New Vector Ltd. + +SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial +Please see LICENSE files in the repository root for full details. +*/ + +import { useSyncExternalStore } from "react"; + +import { type ViewModel } from "./viewmodel/ViewModel"; + +/** + * A small wrapper around useSyncExternalStore to use a view model in a shared component view + * @param vm The view model to use + * @returns The current snapshot + */ +export function useViewModel(vm: ViewModel): T { + // We need to pass the same getSnapshot function as getServerSnapshot as this + // is used when making the HTML chat export. + return useSyncExternalStore(vm.subscribe, vm.getSnapshot, vm.getSnapshot); +} diff --git a/res/css/components/views/utils/_Box.pcss b/packages/shared-components/src/utils/Box/Box.module.css similarity index 88% rename from res/css/components/views/utils/_Box.pcss rename to packages/shared-components/src/utils/Box/Box.module.css index 5721e32633..7daf7889b7 100644 --- a/res/css/components/views/utils/_Box.pcss +++ b/packages/shared-components/src/utils/Box/Box.module.css @@ -6,14 +6,14 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com Please see LICENSE files in the repository root for full details. */ -.mx_Box--flex { +.box-flex { flex: var(--mx-box-flex, unset); } -.mx_Box--shrink { +.box-shrink { flex-shrink: var(--mx-box-shrink, unset); } -.mx_Box--grow { +.box-grow { flex-grow: var(--mx-box-grow, unset); } diff --git a/src/components/utils/Box.tsx b/packages/shared-components/src/utils/Box/Box.tsx similarity index 82% rename from src/components/utils/Box.tsx rename to packages/shared-components/src/utils/Box/Box.tsx index 2c5dfa56d2..8f31984664 100644 --- a/src/components/utils/Box.tsx +++ b/packages/shared-components/src/utils/Box/Box.tsx @@ -9,7 +9,9 @@ Please see LICENSE files in the repository root for full details. import classNames from "classnames"; import React, { type JSX, useMemo } from "react"; -type FlexProps = { +import styles from "./Box.module.css"; + +type BoxProps = { /** * The type of the HTML element * @default div @@ -51,7 +53,7 @@ export function Box({ className, children, ...props -}: React.PropsWithChildren): JSX.Element { +}: React.PropsWithChildren): JSX.Element { const style = useMemo(() => { const style: Record = {}; if (flex) style["--mx-box-flex"] = flex; @@ -64,10 +66,10 @@ export function Box({ as, { ...props, - className: classNames("mx_Box", className, { - "mx_Box--flex": !!flex, - "mx_Box--shrink": !!shrink, - "mx_Box--grow": !!grow, + className: classNames(className, { + [styles["box-flex"]]: !!flex, + [styles["box-shrink"]]: !!shrink, + [styles["box-grow"]]: !!grow, }), style, }, diff --git a/packages/shared-components/src/utils/Box/index.ts b/packages/shared-components/src/utils/Box/index.ts new file mode 100644 index 0000000000..ad4f27e89d --- /dev/null +++ b/packages/shared-components/src/utils/Box/index.ts @@ -0,0 +1,8 @@ +/* + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial + * Please see LICENSE files in the repository root for full details. + */ + +export { Box } from "./Box"; diff --git a/packages/shared-components/src/utils/DateUtils.ts b/packages/shared-components/src/utils/DateUtils.ts new file mode 100644 index 0000000000..146aeecbd2 --- /dev/null +++ b/packages/shared-components/src/utils/DateUtils.ts @@ -0,0 +1,35 @@ +/* + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial + * Please see LICENSE files in the repository root for full details. + */ + +/** + * Formats a number of seconds into a human-readable string. + * @param inSeconds + */ +export function formatSeconds(inSeconds: number): string { + const isNegative = inSeconds < 0; + inSeconds = Math.abs(inSeconds); + + const hours = Math.floor(inSeconds / (60 * 60)) + .toFixed(0) + .padStart(2, "0"); + const minutes = Math.floor((inSeconds % (60 * 60)) / 60) + .toFixed(0) + .padStart(2, "0"); + const seconds = Math.floor((inSeconds % (60 * 60)) % 60) + .toFixed(0) + .padStart(2, "0"); + + let output = ""; + if (hours !== "00") output += `${hours}:`; + output += `${minutes}:${seconds}`; + + if (isNegative) { + output = "-" + output; + } + + return output; +} diff --git a/res/css/components/views/utils/_Flex.pcss b/packages/shared-components/src/utils/Flex/Flex.module.css similarity index 97% rename from res/css/components/views/utils/_Flex.pcss rename to packages/shared-components/src/utils/Flex/Flex.module.css index 9cfa6424f0..dba85c1faa 100644 --- a/res/css/components/views/utils/_Flex.pcss +++ b/packages/shared-components/src/utils/Flex/Flex.module.css @@ -6,7 +6,7 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com Please see LICENSE files in the repository root for full details. */ -.mx_Flex { +.flex { display: var(--mx-flex-display, unset); flex-direction: var(--mx-flex-direction, unset); align-items: var(--mx-flex-align, unset); diff --git a/src/components/utils/Flex.tsx b/packages/shared-components/src/utils/Flex/Flex.tsx similarity index 96% rename from src/components/utils/Flex.tsx rename to packages/shared-components/src/utils/Flex/Flex.tsx index 0a8c3c2fa4..7404bf1874 100644 --- a/src/components/utils/Flex.tsx +++ b/packages/shared-components/src/utils/Flex/Flex.tsx @@ -9,6 +9,8 @@ Please see LICENSE files in the repository root for full details. import classNames from "classnames"; import React, { type JSX, type ComponentProps, type JSXElementConstructor, useMemo } from "react"; +import styles from "./Flex.module.css"; + type FlexProps> = { /** * The type of the HTML element @@ -82,5 +84,5 @@ export function Flex 1.00 KB + */ +export function formatBytes(bytes: number, decimals = 2): string { + if (bytes === 0) return "0 Bytes"; + + const k = 1024; + const dm = decimals < 0 ? 0 : decimals; + const sizes = ["Bytes", "KB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB"]; + + const i = Math.floor(Math.log(bytes) / Math.log(k)); + + return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + " " + sizes[i]; +} diff --git a/packages/shared-components/src/utils/I18nApi.test.ts b/packages/shared-components/src/utils/I18nApi.test.ts new file mode 100644 index 0000000000..2b3431f07c --- /dev/null +++ b/packages/shared-components/src/utils/I18nApi.test.ts @@ -0,0 +1,22 @@ +/* + * Copyright 2025 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial + * Please see LICENSE files in the repository root for full details. + */ + +import { type TranslationKey } from "../i18nKeys"; +import { I18nApi } from "./I18nApi"; + +describe("I18nApi", () => { + it("can register a translation and use it", () => { + const i18n = new I18nApi(); + i18n.register({ + "hello.world": { + en: "Hello, World!", + }, + }); + + expect(i18n.translate("hello.world" as TranslationKey)).toBe("Hello, World!"); + }); +}); diff --git a/src/modules/I18nApi.ts b/packages/shared-components/src/utils/I18nApi.ts similarity index 79% rename from src/modules/I18nApi.ts rename to packages/shared-components/src/utils/I18nApi.ts index 43c101eca6..20d641f5ce 100644 --- a/src/modules/I18nApi.ts +++ b/packages/shared-components/src/utils/I18nApi.ts @@ -6,16 +6,17 @@ Please see LICENSE files in the repository root for full details. */ import { type I18nApi as II18nApi, type Variables, type Translations } from "@element-hq/element-web-module-api"; -import counterpart from "counterpart"; -import { _t, getCurrentLanguage, type TranslationKey } from "../languageHandler.tsx"; +import { humanizeTime } from "./humanize"; +import { _t, getLocale, registerTranslations } from "./i18n"; +import { type TranslationKey } from "../i18nKeys"; export class I18nApi implements II18nApi { /** * Read the current language of the user in IETF Language Tag format */ public get language(): string { - return getCurrentLanguage(); + return getLocale(); } /** @@ -32,7 +33,7 @@ export class I18nApi implements II18nApi { // Finally, tell counterpart about our translations for (const lang in langs) { - counterpart.registerTranslations(lang, langs[lang]); + registerTranslations(lang, langs[lang]); } } @@ -44,4 +45,8 @@ export class I18nApi implements II18nApi { public translate(key: TranslationKey, variables?: Variables): string { return _t(key, variables); } + + public humanizeTime(timeMillis: number): string { + return humanizeTime(timeMillis, this); + } } diff --git a/packages/shared-components/src/utils/humanize.test.ts b/packages/shared-components/src/utils/humanize.test.ts new file mode 100644 index 0000000000..1c07dd3d04 --- /dev/null +++ b/packages/shared-components/src/utils/humanize.test.ts @@ -0,0 +1,37 @@ +/* + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial + * Please see LICENSE files in the repository root for full details. + */ + +import { humanizeTime } from "./humanize"; + +describe("humanizeTime", () => { + const now = new Date("2025-08-01T12:00:00Z").getTime(); + + beforeAll(() => { + jest.useFakeTimers().setSystemTime(now); + }); + + it.each([ + // Past + ["returns 'a few seconds ago' for <15s ago", now - 5000, "a few seconds ago"], + ["returns 'about a minute ago' for <75s ago", now - 60000, "about a minute ago"], + ["returns '20 minutes ago' for <45min ago", now - 20 * 60000, "20 minutes ago"], + ["returns 'about an hour ago' for <75min ago", now - 70 * 60000, "about an hour ago"], + ["returns '5 hours ago' for <23h ago", now - 5 * 3600000, "5 hours ago"], + ["returns 'about a day ago' for <26h ago", now - 25 * 3600000, "about a day ago"], + ["returns '3 days ago' for >26h ago", now - 3 * 24 * 3600000, "3 days ago"], + // Future + ["returns 'a few seconds from now' for <15s ahead", now + 5000, "a few seconds from now"], + ["returns 'about a minute from now' for <75s ahead", now + 60000, "about a minute from now"], + ["returns '20 minutes from now' for <45min ahead", now + 20 * 60000, "20 minutes from now"], + ["returns 'about an hour from now' for <75min ahead", now + 70 * 60000, "about an hour from now"], + ["returns '5 hours from now' for <23h ahead", now + 5 * 3600000, "5 hours from now"], + ["returns 'about a day from now' for <26h ahead", now + 25 * 3600000, "about a day from now"], + ["returns '3 days from now' for >26h ahead", now + 3 * 24 * 3600000, "3 days from now"], + ])("%s", (_, date, expected) => { + expect(humanizeTime(date)).toBe(expected); + }); +}); diff --git a/src/utils/humanize.ts b/packages/shared-components/src/utils/humanize.ts similarity index 89% rename from src/utils/humanize.ts rename to packages/shared-components/src/utils/humanize.ts index 616ee93781..1e00e69a9d 100644 --- a/src/utils/humanize.ts +++ b/packages/shared-components/src/utils/humanize.ts @@ -6,7 +6,9 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com Please see LICENSE files in the repository root for full details. */ -import { _t } from "../languageHandler"; +import { type I18nApi } from "@element-hq/element-web-module-api"; + +import { _t as _tFromModule } from "./i18n"; // These are the constants we use for when to break the text const MILLISECONDS_RECENT = 15000; @@ -21,13 +23,15 @@ const HOURS_1_DAY = 26; * @param {number} timeMillis The time in millis to compare against. * @returns {string} The humanized time. */ -export function humanizeTime(timeMillis: number): string { +export function humanizeTime(timeMillis: number, i18nApi?: I18nApi): string { const now = Date.now(); let msAgo = now - timeMillis; const minutes = Math.abs(Math.ceil(msAgo / 60000)); const hours = Math.ceil(minutes / 60); const days = Math.ceil(hours / 24); + const _t = i18nApi?.translate ?? _tFromModule; + if (msAgo >= 0) { // Past if (msAgo <= MILLISECONDS_RECENT) return _t("time|few_seconds_ago"); diff --git a/packages/shared-components/src/utils/i18n.test.ts b/packages/shared-components/src/utils/i18n.test.ts new file mode 100644 index 0000000000..a578f7bcfe --- /dev/null +++ b/packages/shared-components/src/utils/i18n.test.ts @@ -0,0 +1,46 @@ +/* + * Copyright 2025 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial + * Please see LICENSE files in the repository root for full details. + */ + +import counterpart from "counterpart"; + +import { registerTranslations, setMissingEntryGenerator, getLocale, setLocale } from "./i18n"; + +describe("i18n utils", () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it("should wrap registerTranslations", () => { + jest.spyOn(counterpart, "registerTranslations"); + + registerTranslations("en", { test: "This is a test" }); + expect(counterpart.registerTranslations).toHaveBeenCalledWith("en", { test: "This is a test" }); + }); + + it("should wrap setMissingEntryGenerator", () => { + jest.spyOn(counterpart, "setMissingEntryGenerator"); + + const dummyFn = jest.fn(); + + setMissingEntryGenerator(dummyFn); + expect(counterpart.setMissingEntryGenerator).toHaveBeenCalledWith(dummyFn); + }); + + it("should wrap getLocale", () => { + jest.spyOn(counterpart, "getLocale"); + + getLocale(); + expect(counterpart.getLocale).toHaveBeenCalled(); + }); + + it("should wrap setLocale", () => { + jest.spyOn(counterpart, "setLocale"); + + setLocale("en"); + expect(counterpart.setLocale).toHaveBeenCalledWith("en"); + }); +}); diff --git a/packages/shared-components/src/utils/i18n.tsx b/packages/shared-components/src/utils/i18n.tsx new file mode 100644 index 0000000000..2ce1f78005 --- /dev/null +++ b/packages/shared-components/src/utils/i18n.tsx @@ -0,0 +1,439 @@ +/* + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial + * Please see LICENSE files in the repository root for full details. + */ + +/* + * Translates text and optionally also replaces XML-ish elements in the text with e.g. React components + * @param {string} text The untranslated text, e.g "click here now to %(foo)s". + * @param {object} variables Variable substitutions, e.g { foo: 'bar' } + * @param {object} tags Tag substitutions e.g. { 'a': (sub) => {sub} } + * + * In both variables and tags, the values to substitute with can be either simple strings, React components, + * or functions that return the value to use in the substitution (e.g. return a React component). In case of + * a tag replacement, the function receives as the argument the text inside the element corresponding to the tag. + * + * Use tag substitutions if you need to translate text between tags (e.g. "Click here!"), otherwise + * you will end up with literal "" in your output, rather than HTML. Note that you can also use variable + * substitution to insert React components, but you can't use it to translate text between tags. + * + * @return a React component if any non-strings were used in substitutions, otherwise a string + */ +import React from "react"; +import { KEY_SEPARATOR } from "matrix-web-i18n"; +import counterpart from "counterpart"; + +import type { TranslationKey } from "../index"; + +// @ts-ignore - $webapp is a webpack resolve alias pointing to the output directory, see webpack config +import webpackLangJsonUrl from "$webapp/i18n/languages.json"; + +export { KEY_SEPARATOR, normalizeLanguageKey, getNormalizedLanguageKeys } from "matrix-web-i18n"; + +const i18nFolder = "i18n/"; + +// Control whether to also return original, untranslated strings +// Useful for debugging and testing +const ANNOTATE_STRINGS = false; + +// We use english strings as keys, some of which contain full stops +counterpart.setSeparator(KEY_SEPARATOR); + +// see `translateWithFallback` for an explanation of fallback handling +const FALLBACK_LOCALE = "en"; +counterpart.setFallbackLocale(FALLBACK_LOCALE); + +// export wrappers around these functions because if we used counterpart directly from +// element-web, it operates on a different instance of counterpart +export function registerTranslations(locale: string, data: object): void { + counterpart.registerTranslations(locale, data); +} + +export function setMissingEntryGenerator(callback: (value: string) => void): void { + counterpart.setMissingEntryGenerator(callback); +} + +export function getLocale(): string { + return counterpart.getLocale(); +} + +export function setLocale(value: string): string { + return counterpart.setLocale(value); +} + +// Function which only purpose is to mark that a string is translatable +// Does not actually do anything. It's helpful for automatic extraction of translatable strings +export function _td(s: TranslationKey): TranslationKey { + return s; +} + +function isValidTranslation(translated: string): boolean { + return typeof translated === "string" && !translated.startsWith("missing translation:"); +} + +/** + * to improve screen reader experience translations that are not in the main page language + * eg a translation that fell back to english from another language + * should be wrapped with an appropriate `lang='en'` attribute + * counterpart's `translate` doesn't expose a way to determine if the resulting translation + * is in the target locale or a fallback locale + * for this reason, force fallbackLocale === locale in the first call to translate + * and fallback 'manually' so we can mark fallback strings appropriately + * */ +const translateWithFallback = (text: string, options?: IVariables): { translated: string; isFallback?: boolean } => { + const translated = counterpart.translate(text, { ...options, fallbackLocale: counterpart.getLocale() }); + if (isValidTranslation(translated)) { + return { translated }; + } + + const fallbackTranslated = counterpart.translate(text, { ...options, locale: FALLBACK_LOCALE }); + if (isValidTranslation(fallbackTranslated)) { + return { translated: fallbackTranslated, isFallback: true }; + } + + // Even the translation via FALLBACK_LOCALE failed; this can happen if + // + // 1. The string isn't in the translations dictionary, usually because you're in develop + // and haven't run yarn i18n + // 2. Loading the translation resources over the network failed, which can happen due to + // to network or if the client tried to load a translation that's been removed from the + // server. + // + // At this point, its the lesser evil to show the i18n key which will be in English but not human-friendly, + // so the user can still make out *something*, rather than an opaque possibly-untranslated "missing translation" error. + return { translated: text, isFallback: true }; +}; + +// Wrapper for counterpart's translation function so that it handles nulls and undefineds properly +// Takes the same arguments as counterpart.translate() +function safeCounterpartTranslate(text: string, variables?: IVariables): { translated: string; isFallback?: boolean } { + // Don't do substitutions in counterpart. We handle it ourselves so we can replace with React components + // However, still pass the variables to counterpart so that it can choose the correct plural if count is given + // It is enough to pass the count variable, but in the future counterpart might make use of other information too + const options: IVariables & { + interpolate: boolean; + } = { ...variables, interpolate: false }; + + // Horrible hack to avoid https://github.com/vector-im/element-web/issues/4191 + // The interpolation library that counterpart uses does not support undefined/null + // values and instead will throw an error. This is a problem since everywhere else + // in JS land passing undefined/null will simply stringify instead, and when converting + // valid ES6 template strings to i18n strings it's extremely easy to pass undefined/null + // if there are no existing null guards. To avoid this making the app completely inoperable, + // we'll check all the values for undefined/null and stringify them here. + if (options && typeof options === "object") { + Object.keys(options).forEach((k) => { + if (options[k] === undefined) { + console.warn("safeCounterpartTranslate called with undefined interpolation name: " + k); + options[k] = "undefined"; + } + if (options[k] === null) { + console.warn("safeCounterpartTranslate called with null interpolation name: " + k); + options[k] = "null"; + } + }); + } + return translateWithFallback(text, options); +} + +/** + * The value a variable or tag can take for a translation interpolation. + */ +type SubstitutionValue = number | string | React.ReactNode | ((sub: string) => React.ReactNode); + +export interface IVariables { + count?: number; + [key: string]: SubstitutionValue; +} + +export type Tags = Record; + +export type TranslatedString = string | React.ReactNode; + +// For development/testing purposes it is useful to also output the original string +// Don't do that for release versions +const annotateStrings = (result: TranslatedString, translationKey: TranslationKey): TranslatedString => { + if (!ANNOTATE_STRINGS) { + return result; + } + + if (typeof result === "string") { + return `@@${translationKey}##${result}@@`; + } else { + return ( + + {result} + + ); + } +}; + +export function _t(text: TranslationKey, variables?: IVariables): string; +export function _t(text: TranslationKey, variables: IVariables | undefined, tags: Tags): React.ReactNode; +export function _t(text: TranslationKey, variables?: IVariables, tags?: Tags): TranslatedString { + // The translation returns text so there's no XSS vector here (no unsafe HTML, no code execution) + const { translated } = safeCounterpartTranslate(text, variables); + const substituted = substitute(translated, variables, tags); + + return annotateStrings(substituted, text); +} + +/** + * Utility function to look up a string by its translation key without resolving variables & tags + * @param key - the translation key to return the value for + */ +export function lookupString(key: TranslationKey): string { + return safeCounterpartTranslate(key, {}).translated; +} + +/* + * Wraps normal _t function and adds atttribution for translations that used a fallback locale + * Wraps translations that fell back from active locale to fallback locale with a `>` + * @param {string} text The untranslated text, e.g "click here now to %(foo)s". + * @param {object} variables Variable substitutions, e.g { foo: 'bar' } + * @param {object} tags Tag substitutions e.g. { 'a': (sub) => {sub} } + * + * @return a React component if any non-strings were used in substitutions + * or translation used a fallback locale, otherwise a string + */ +// eslint-next-line @typescript-eslint/naming-convention +export function _tDom(text: TranslationKey, variables?: IVariables): TranslatedString; +export function _tDom(text: TranslationKey, variables: IVariables, tags: Tags): React.ReactNode; +export function _tDom(text: TranslationKey, variables?: IVariables, tags?: Tags): TranslatedString { + // The translation returns text so there's no XSS vector here (no unsafe HTML, no code execution) + const { translated, isFallback } = safeCounterpartTranslate(text, variables); + const substituted = substitute(translated, variables, tags); + + // wrap en fallback translation with lang attribute for screen readers + const result = isFallback ? {substituted} : substituted; + + return annotateStrings(result, text); +} + +/** + * Sanitizes unsafe text for the sanitizer, ensuring references to variables will not be considered + * replaceable by the translation functions. + * @param {string} text The text to sanitize. + * @returns {string} The sanitized text. + */ +export function sanitizeForTranslation(text: string): string { + // Add a non-breaking space so the regex doesn't trigger when translating. + return text.replace(/%\(([^)]*)\)/g, "%\xa0($1)"); +} + +/* + * Similar to _t(), except only does substitutions, and no translation + * @param {string} text The text, e.g "click here now to %(foo)s". + * @param {object} variables Variable substitutions, e.g { foo: 'bar' } + * @param {object} tags Tag substitutions e.g. { 'a': (sub) => {sub} } + * + * The values to substitute with can be either simple strings, or functions that return the value to use in + * the substitution (e.g. return a React component). In case of a tag replacement, the function receives as + * the argument the text inside the element corresponding to the tag. + * + * @return a React component if any non-strings were used in substitutions, otherwise a string + */ +export function substitute(text: string, variables?: IVariables): string; +export function substitute(text: string, variables: IVariables | undefined, tags: Tags | undefined): string; +export function substitute(text: string, variables?: IVariables, tags?: Tags): string | React.ReactNode { + let result: React.ReactNode | string = text; + + if (variables !== undefined) { + const regexpMapping: IVariables = {}; + for (const variable in variables) { + regexpMapping[`%\\(${variable}\\)s`] = variables[variable]; + } + result = replaceByRegexes(result as string, regexpMapping); + } + + if (tags !== undefined) { + const regexpMapping: Tags = {}; + for (const tag in tags) { + regexpMapping[`(<${tag}>(.*?)<\\/${tag}>|<${tag}>|<${tag}\\s*\\/>)`] = tags[tag]; + } + result = replaceByRegexes(result as string, regexpMapping); + } + + return result; +} + +/** + * Replace parts of a text using regular expressions + * @param text - The text on which to perform substitutions + * @param mapping - A mapping from regular expressions in string form to replacement string or a + * function which will receive as the argument the capture groups defined in the regexp. E.g. + * { 'Hello (.?) World': (sub) => sub.toUpperCase() } + * + * @return a React component if any non-strings were used in substitutions, otherwise a string + */ +export function replaceByRegexes(text: string, mapping: IVariables): string; +export function replaceByRegexes(text: string, mapping: Tags): React.ReactNode; +export function replaceByRegexes(text: string, mapping: IVariables | Tags): string | React.ReactNode { + // We initially store our output as an array of strings and objects (e.g. React components). + // This will then be converted to a string or a at the end + const output: SubstitutionValue[] = [text]; + + // If we insert any components we need to wrap the output in a span. React doesn't like just an array of components. + let shouldWrapInSpan = false; + + for (const regexpString in mapping) { + // TODO: Cache regexps + const regexp = new RegExp(regexpString, "g"); + + // Loop over what output we have so far and perform replacements + // We look for matches: if we find one, we get three parts: everything before the match, the replaced part, + // and everything after the match. Insert all three into the output. We need to do this because we can insert objects. + // Otherwise there would be no need for the splitting and we could do simple replacement. + let matchFoundSomewhere = false; // If we don't find a match anywhere we want to log it + for (let outputIndex = 0; outputIndex < output.length; outputIndex++) { + const inputText = output[outputIndex]; + if (typeof inputText !== "string") { + // We might have inserted objects earlier, don't try to replace them + continue; + } + + // process every match in the string + // starting with the first + let match = regexp.exec(inputText); + + if (!match) continue; + matchFoundSomewhere = true; + + // The textual part before the first match + const head = inputText.slice(0, match.index); + + const parts: SubstitutionValue[] = []; + // keep track of prevMatch + let prevMatch; + while (match) { + // store prevMatch + prevMatch = match; + const capturedGroups = match.slice(2); + + let replaced: SubstitutionValue; + // If substitution is a function, call it + if (mapping[regexpString] instanceof Function) { + replaced = ((mapping as Tags)[regexpString] as (...subs: string[]) => string)(...capturedGroups); + } else { + replaced = mapping[regexpString]; + } + + if (typeof replaced === "object") { + shouldWrapInSpan = true; + } + + // Here we also need to check that it actually is a string before comparing against one + // The head and tail are always strings + if (typeof replaced !== "string" || replaced !== "") { + parts.push(replaced); + } + + // try the next match + match = regexp.exec(inputText); + + // add the text between prevMatch and this one + // or the end of the string if prevMatch is the last match + let tail; + if (match) { + const startIndex = prevMatch.index + prevMatch[0].length; + tail = inputText.slice(startIndex, match.index); + } else { + tail = inputText.slice(prevMatch.index + prevMatch[0].length); + } + if (tail) { + parts.push(tail); + } + } + + // Insert in reverse order as splice does insert-before and this way we get the final order correct + // remove the old element at the same time + output.splice(outputIndex, 1, ...parts); + + if (head !== "") { + // Don't push empty nodes, they are of no use + output.splice(outputIndex, 0, head); + } + } + if (!matchFoundSomewhere) { + if ( + // The current regexp did not match anything in the input. Missing + // matches is entirely possible because you might choose to show some + // variables only in the case of e.g. plurals. It's still a bit + // suspicious, and could be due to an error, so log it. However, not + // showing count is so common that it's not worth logging. And other + // commonly unused variables here, if there are any. + regexpString !== "%\\(count\\)s" && + // Ignore the `locale` option which can be used to override the locale + // in counterpart + regexpString !== "%\\(locale\\)s" + ) { + console.log(`Could not find ${regexp} in ${text}`); + } + } + } + + if (shouldWrapInSpan) { + return React.createElement("span", null, ...(output as Array)); + } else { + // eslint-disable-next-line @typescript-eslint/no-base-to-string + return output.join(""); + } +} + +type Languages = { + [lang: string]: string; +}; + +/** + * Sets the language for the application. + * In Element web,`languageHandler.setLanguage` should be used instead. + * @param language + */ +export async function setLanguage(language: string): Promise { + const availableLanguages = await getLangsJson(); + const chosenLanguage = language in availableLanguages ? language : "en"; + + const languageData = await getLanguage(i18nFolder + availableLanguages[chosenLanguage]); + + counterpart.registerTranslations(chosenLanguage, languageData); + counterpart.setLocale(chosenLanguage); +} + +interface ICounterpartTranslation { + [key: string]: + | string + | { + [pluralisation: string]: string; + }; +} + +async function getLanguage(langPath: string): Promise { + console.log("Loading language from", langPath); + const res = await fetch(langPath, { method: "GET" }); + + if (!res.ok) { + throw new Error(`Failed to load ${langPath}, got ${res.status}`); + } + + return res.json(); +} + +export async function getLangsJson(): Promise { + let url: string; + if (typeof webpackLangJsonUrl === "string") { + // in Jest this 'url' isn't a URL, so just fall through + url = webpackLangJsonUrl; + } else { + url = i18nFolder + "languages.json"; + } + + const res = await fetch(url, { method: "GET" }); + + if (!res.ok) { + throw new Error(`Failed to load ${url}, got ${res.status}`); + } + + return res.json(); +} diff --git a/packages/shared-components/src/utils/i18nContext.ts b/packages/shared-components/src/utils/i18nContext.ts new file mode 100644 index 0000000000..46c4185329 --- /dev/null +++ b/packages/shared-components/src/utils/i18nContext.ts @@ -0,0 +1,27 @@ +/* +Copyright 2025 Element Creations Ltd. + +SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial +Please see LICENSE files in the repository root for full details. +*/ + +import { createContext, useContext } from "react"; +import { type I18nApi } from "@element-hq/element-web-module-api"; + +export const I18nContext = createContext(null); +I18nContext.displayName = "I18nContext"; + +/** + * A hook to get the i18n API from the context. Will throw if no i18n context is found. + * @throws If no i18n context is found + * @returns The i18n API from the context + */ +export function useI18n(): I18nApi { + const i18n = useContext(I18nContext); + + if (!i18n) { + throw new Error("useI18n must be used within an I18nContext.Provider"); + } + + return i18n; +} diff --git a/test/unit-tests/utils/numbers-test.ts b/packages/shared-components/src/utils/numbers.test.ts similarity index 99% rename from test/unit-tests/utils/numbers-test.ts rename to packages/shared-components/src/utils/numbers.test.ts index 1de68ce499..928fd67ae0 100644 --- a/test/unit-tests/utils/numbers-test.ts +++ b/packages/shared-components/src/utils/numbers.test.ts @@ -6,7 +6,7 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com Please see LICENSE files in the repository root for full details. */ -import { clamp, defaultNumber, percentageOf, percentageWithin, sum } from "../../../src/utils/numbers"; +import { clamp, defaultNumber, percentageOf, percentageWithin, sum } from "./numbers"; describe("numbers", () => { describe("defaultNumber", () => { diff --git a/src/utils/numbers.ts b/packages/shared-components/src/utils/numbers.ts similarity index 100% rename from src/utils/numbers.ts rename to packages/shared-components/src/utils/numbers.ts diff --git a/packages/shared-components/src/viewmodel/BaseViewModel.ts b/packages/shared-components/src/viewmodel/BaseViewModel.ts new file mode 100644 index 0000000000..ffb961d8e0 --- /dev/null +++ b/packages/shared-components/src/viewmodel/BaseViewModel.ts @@ -0,0 +1,51 @@ +/* +Copyright 2025 New Vector Ltd. + +SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial +Please see LICENSE files in the repository root for full details. +*/ + +import { type ViewModel } from "./ViewModel"; +import { Disposables } from "./Disposables"; +import { Snapshot } from "./Snapshot"; +import { ViewModelSubscriptions } from "./ViewModelSubscriptions"; + +export abstract class BaseViewModel implements ViewModel { + protected subs: ViewModelSubscriptions; + protected snapshot: Snapshot; + protected props: P; + protected disposables = new Disposables(); + + protected constructor(props: P, initialSnapshot: T) { + this.props = props; + this.subs = new ViewModelSubscriptions(); + this.snapshot = new Snapshot(initialSnapshot, () => { + this.subs.emit(); + }); + } + + public subscribe = (listener: () => void): (() => void) => { + return this.subs.add(listener); + }; + + /** + * Returns the current snapshot of the view model. + */ + public getSnapshot = (): T => { + return this.snapshot.current; + }; + + /** + * Relinquish any resources held by this view-model. + */ + public dispose(): void { + this.disposables.dispose(); + } + + /** + * Whether this view-model has been disposed. + */ + public get isDisposed(): boolean { + return this.disposables.isDisposed; + } +} diff --git a/packages/shared-components/src/viewmodel/Disposables.ts b/packages/shared-components/src/viewmodel/Disposables.ts new file mode 100644 index 0000000000..77df53d097 --- /dev/null +++ b/packages/shared-components/src/viewmodel/Disposables.ts @@ -0,0 +1,70 @@ +/* +Copyright 2025 New Vector Ltd. + +SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial +Please see LICENSE files in the repository root for full details. +*/ + +import type { EventEmitter } from "events"; + +/** + * Something that needs to be eventually disposed. This can be: + * - A function that does the disposing + * - An object containing a dispose method which does the disposing + */ +export type DisposableItem = { dispose: () => void } | (() => void); + +/** + * This class provides a way for the view-model to track any resource + * that it needs to eventually relinquish. + */ +export class Disposables { + private readonly disposables: DisposableItem[] = []; + private _isDisposed: boolean = false; + + /** + * Relinquish all tracked disposable values + */ + public dispose(): void { + if (this.isDisposed) return; + this._isDisposed = true; + for (const disposable of this.disposables) { + if (typeof disposable === "function") { + disposable(); + } else { + disposable.dispose(); + } + } + } + + /** + * Track a value that needs to be eventually relinquished + */ + public track(disposable: T): T { + this.throwIfDisposed(); + this.disposables.push(disposable); + return disposable; + } + + /** + * Add an event listener that will be removed on dispose + */ + public trackListener(emitter: EventEmitter, event: string, callback: (...args: unknown[]) => void): void { + this.throwIfDisposed(); + emitter.on(event, callback); + this.track(() => { + emitter.off(event, callback); + }); + } + + private throwIfDisposed(): void { + if (this.isDisposed) throw new Error("Disposable is already disposed"); + } + + /** + * Whether this disposable has been disposed + */ + public get isDisposed(): boolean { + return this._isDisposed; + } +} diff --git a/packages/shared-components/src/viewmodel/MockViewModel.ts b/packages/shared-components/src/viewmodel/MockViewModel.ts new file mode 100644 index 0000000000..28a1456a5d --- /dev/null +++ b/packages/shared-components/src/viewmodel/MockViewModel.ts @@ -0,0 +1,23 @@ +/* +Copyright 2025 New Vector Ltd. + +SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +Please see LICENSE files in the repository root for full details. +*/ + +import { type ViewModel } from "./ViewModel"; + +/** + * A mock view model that returns a static snapshot passed in the constructor, with no updates. + */ +export class MockViewModel implements ViewModel { + public constructor(private snapshot: T) {} + + public getSnapshot = (): T => { + return this.snapshot; + }; + + public subscribe(listener: () => void): () => void { + return () => undefined; + } +} diff --git a/packages/shared-components/src/viewmodel/Snapshot.ts b/packages/shared-components/src/viewmodel/Snapshot.ts new file mode 100644 index 0000000000..e8d0b7412c --- /dev/null +++ b/packages/shared-components/src/viewmodel/Snapshot.ts @@ -0,0 +1,43 @@ +/* +Copyright 2025 New Vector Ltd. + +SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial +Please see LICENSE files in the repository root for full details. +*/ + +/** + * This is the output of the viewmodel that the view consumes. + * Updating snapshot through this object will make react re-render + * components. + */ +export class Snapshot { + public constructor( + private snapshot: T, + private emit: () => void, + ) {} + + /** + * Replace current snapshot with a new snapshot value. + * @param snapshot New snapshot value + */ + public set(snapshot: T): void { + this.snapshot = snapshot; + this.emit(); + } + + /** + * Update a part of the current snapshot by merging into the existing snapshot. + * @param snapshot A subset of the snapshot to merge into the current snapshot. + */ + public merge(snapshot: Partial): void { + this.snapshot = { ...this.snapshot, ...snapshot }; + this.emit(); + } + + /** + * The current value of the snapshot. + */ + public get current(): T { + return this.snapshot; + } +} diff --git a/packages/shared-components/src/viewmodel/ViewModel.ts b/packages/shared-components/src/viewmodel/ViewModel.ts new file mode 100644 index 0000000000..9f088c4300 --- /dev/null +++ b/packages/shared-components/src/viewmodel/ViewModel.ts @@ -0,0 +1,23 @@ +/* +Copyright 2025 New Vector Ltd. + +SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial +Please see LICENSE files in the repository root for full details. +*/ + +/** + * The interface for a generic View Model passed to the shared components. + * The snapshot is of type T which is a type specifying a snapshot for the view in question. + */ +export interface ViewModel { + /** + * The current snapshot of the view model. + */ + getSnapshot: () => T; + + /** + * Subscribes to changes in the view model. + * The listener will be called whenever the snapshot changes. + */ + subscribe: (listener: () => void) => () => void; +} diff --git a/packages/shared-components/src/viewmodel/ViewModelSubscriptions.ts b/packages/shared-components/src/viewmodel/ViewModelSubscriptions.ts new file mode 100644 index 0000000000..a713782aec --- /dev/null +++ b/packages/shared-components/src/viewmodel/ViewModelSubscriptions.ts @@ -0,0 +1,34 @@ +/* +Copyright 2025 New Vector Ltd. + +SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial +Please see LICENSE files in the repository root for full details. +*/ + +/** + * Utility class for view models to manage subscriptions to their updates + */ +export class ViewModelSubscriptions { + private listeners = new Set<() => void>(); + + /** + * Subscribe to changes in the view model. + * @param listener Will be called whenever the snapshot changes. + * @returns A function to unsubscribe from the view model updates. + */ + public add = (listener: () => void): (() => void) => { + this.listeners.add(listener); + return () => { + this.listeners.delete(listener); + }; + }; + + /** + * Emit an update to all subscribed listeners. + */ + public emit = (): void => { + for (const listener of this.listeners) { + listener(); + } + }; +} diff --git a/packages/shared-components/src/viewmodel/index.ts b/packages/shared-components/src/viewmodel/index.ts new file mode 100644 index 0000000000..0267f7934d --- /dev/null +++ b/packages/shared-components/src/viewmodel/index.ts @@ -0,0 +1,14 @@ +/* + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial + * Please see LICENSE files in the repository root for full details. + */ + +export * from "./BaseViewModel"; +export * from "./Disposables"; +export * from "./Snapshot"; +export * from "./ViewModelSubscriptions"; +export type * from "./ViewModel"; +export * from "./MockViewModel"; +export * from "./useCreateAutoDisposedViewModel"; diff --git a/packages/shared-components/src/viewmodel/tests/Disposables.test.ts b/packages/shared-components/src/viewmodel/tests/Disposables.test.ts new file mode 100644 index 0000000000..5b71f1871d --- /dev/null +++ b/packages/shared-components/src/viewmodel/tests/Disposables.test.ts @@ -0,0 +1,57 @@ +/* +Copyright 2025 New Vector Ltd. +SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial +Please see LICENSE files in the repository root for full details. +*/ + +import { EventEmitter } from "events"; + +import { Disposables } from ".."; + +describe("Disposable", () => { + it("isDisposed is true after dispose() is called", () => { + const disposables = new Disposables(); + expect(disposables.isDisposed).toEqual(false); + disposables.dispose(); + expect(disposables.isDisposed).toEqual(true); + }); + + it("dispose() calls the correct disposing function", () => { + const disposables = new Disposables(); + + const item1 = { + foo: 5, + dispose: jest.fn(), + }; + disposables.track(item1); + + const item2 = jest.fn(); + disposables.track(item2); + + disposables.dispose(); + + expect(item1.dispose).toHaveBeenCalledTimes(1); + expect(item2).toHaveBeenCalledTimes(1); + }); + + it("Throws error if acting on already disposed disposables", () => { + const disposables = new Disposables(); + disposables.dispose(); + expect(() => { + disposables.track(jest.fn); + }).toThrow(); + }); + + it("Removes tracked event listeners on dispose", () => { + const disposables = new Disposables(); + const emitter = new EventEmitter(); + + const fn = jest.fn(); + disposables.trackListener(emitter, "FooEvent", fn); + emitter.emit("FooEvent"); + expect(fn).toHaveBeenCalled(); + + disposables.dispose(); + expect(emitter.listenerCount("FooEvent", fn)).toEqual(0); + }); +}); diff --git a/packages/shared-components/src/viewmodel/tests/Snapshot.test.ts b/packages/shared-components/src/viewmodel/tests/Snapshot.test.ts new file mode 100644 index 0000000000..82cacfc02e --- /dev/null +++ b/packages/shared-components/src/viewmodel/tests/Snapshot.test.ts @@ -0,0 +1,41 @@ +/* +Copyright 2025 New Vector Ltd. + +SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial +Please see LICENSE files in the repository root for full details. +*/ + +import { Snapshot } from ".."; + +interface TestSnapshot { + key1: string; + key2: number; + key3: boolean; +} + +describe("Snapshot", () => { + it("should accept an initial value", () => { + const snapshot = new Snapshot({ key1: "foo", key2: 5, key3: false }, jest.fn()); + expect(snapshot.current).toStrictEqual({ key1: "foo", key2: 5, key3: false }); + }); + + it("should call emit callback when state changes", () => { + const emit = jest.fn(); + const snapshot = new Snapshot({ key1: "foo", key2: 5, key3: false }, emit); + snapshot.merge({ key3: true }); + expect(emit).toHaveBeenCalledTimes(1); + }); + + it("should swap out entire snapshot on set call", () => { + const snapshot = new Snapshot({ key1: "foo", key2: 5, key3: false }, jest.fn()); + const newValue = { key1: "bar", key2: 8, key3: true }; + snapshot.set(newValue); + expect(snapshot.current).toStrictEqual(newValue); + }); + + it("should merge partial snapshot on merge call", () => { + const snapshot = new Snapshot({ key1: "foo", key2: 5, key3: false }, jest.fn()); + snapshot.merge({ key2: 10 }); + expect(snapshot.current).toStrictEqual({ key1: "foo", key2: 10, key3: false }); + }); +}); diff --git a/packages/shared-components/src/viewmodel/tests/useCreateAutoDisposedViewModel.test.ts b/packages/shared-components/src/viewmodel/tests/useCreateAutoDisposedViewModel.test.ts new file mode 100644 index 0000000000..867ee56825 --- /dev/null +++ b/packages/shared-components/src/viewmodel/tests/useCreateAutoDisposedViewModel.test.ts @@ -0,0 +1,47 @@ +/* +Copyright 2025 Element Creations Ltd. +SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial +Please see LICENSE files in the repository root for full details. +*/ + +import { renderHook } from "jest-matrix-react"; + +import { BaseViewModel } from "../BaseViewModel"; +import { useCreateAutoDisposedViewModel } from "../useCreateAutoDisposedViewModel"; + +class TestViewModel extends BaseViewModel<{ count: number }, { initial: number }> { + public constructor(props: { initial: number }) { + super(props, { count: props.initial }); + } + + public increment(): void { + const newCount = this.getSnapshot().count + 1; + this.snapshot.set({ count: newCount }); + } +} + +describe("useAutoDisposedViewModel", () => { + it("should return view-model", () => { + const vmCreator = (): TestViewModel => new TestViewModel({ initial: 0 }); + const { result } = renderHook(() => useCreateAutoDisposedViewModel(vmCreator)); + const vm = result.current; + expect(vm).toBeInstanceOf(TestViewModel); + expect(vm.isDisposed).toStrictEqual(false); + }); + + it("should dispose view-model on unmount", () => { + const vmCreator = (): TestViewModel => new TestViewModel({ initial: 0 }); + const { result, unmount } = renderHook(() => useCreateAutoDisposedViewModel(vmCreator)); + const vm = result.current; + vm.increment(); + unmount(); + expect(vm.isDisposed).toStrictEqual(true); + }); + + it("should recreate view-model on react strict mode", async () => { + const vmCreator = (): TestViewModel => new TestViewModel({ initial: 0 }); + const output = renderHook(() => useCreateAutoDisposedViewModel(vmCreator), { reactStrictMode: true }); + const vm = output.result.current; + expect(vm.isDisposed).toStrictEqual(false); + }); +}); diff --git a/packages/shared-components/src/viewmodel/useCreateAutoDisposedViewModel.ts b/packages/shared-components/src/viewmodel/useCreateAutoDisposedViewModel.ts new file mode 100644 index 0000000000..a3b4fce3eb --- /dev/null +++ b/packages/shared-components/src/viewmodel/useCreateAutoDisposedViewModel.ts @@ -0,0 +1,68 @@ +/* +Copyright 2025 Element Creations Ltd. +SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial +Please see LICENSE files in the repository root for full details. +*/ + +import { useEffect, useState } from "react"; + +import type { BaseViewModel } from "./BaseViewModel"; + +type VmCreator> = () => B; + +/** + * Instantiate a view-model that gets disposed when the calling react component unmounts. + * In other words, this hook ties the lifecycle of a view-model to the lifecycle of a + * react component. + * + * @param vmCreator A function that returns a view-model instance + * @returns view-model instance from vmCreator + * @example + * const vm = useCreateAutoDisposedViewModel(() => new FooViewModel({prop1, prop2, ...}); + */ +export function useCreateAutoDisposedViewModel>(vmCreator: VmCreator): B { + /** + * The view-model instance may be replaced by a different instance in some scenarios. + * We want to be sure that whichever react component called this hook gets re-rendered + * when this happens, hence the state. + */ + const [viewModel, setViewModel] = useState(vmCreator); + + /** + * Our intention here is to ensure that the dispose method of the view-model gets called + * when the component that uses this hook unmounts. + * We can do that by combining a useEffect cleanup with an empty dependency array. + */ + useEffect(() => { + let toDispose = viewModel; + + /** + * Because we use react strict mode, react will run our effects twice in dev mode to make + * sure that they are pure. + * This presents a complication - the vm instance that we created in our state initializer + * will get disposed on the first cleanup. + * So we'll recreate the view-model if it's already disposed. + */ + if (viewModel.isDisposed) { + const newViewModel = vmCreator(); + // Change toDispose so that we don't end up disposing the already disposed vm. + toDispose = newViewModel; + setViewModel(newViewModel); + } + return () => { + // Dispose the view-model when this component unmounts + toDispose.dispose(); + }; + + /** + * We explicitly provide an empty dependency array as we don't expect the viewModel/viewCreator to + * change. + * Or to put it in another way, the only reason to use this hook is to create/dispose the view-model + * and that is something that should only happen at the start/end of the lifecycle of this component. + */ + // eslint-disable-next-line react-compiler/react-compiler + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + return viewModel; +} diff --git a/packages/shared-components/tsconfig.json b/packages/shared-components/tsconfig.json new file mode 100644 index 0000000000..025901c97d --- /dev/null +++ b/packages/shared-components/tsconfig.json @@ -0,0 +1,25 @@ +{ + "compilerOptions": { + "allowImportingTsExtensions": true, + "experimentalDecorators": false, + "emitDecoratorMetadata": false, + "resolveJsonModule": true, + "esModuleInterop": true, + "useDefineForClassFields": true, + "module": "es2022", + "moduleResolution": "bundler", + "target": "es2022", + "noUnusedLocals": true, + "sourceMap": false, + "outDir": "./lib", + "declaration": true, + "jsx": "react", + "lib": ["es2022", "es2024.promise", "dom", "dom.iterable"], + "strict": true, + "paths": { + "jest-matrix-react": ["./src/test/utils/jest-matrix-react"], + "rollup/parseAst": ["./node_modules/rollup/dist/parseAst"] + } + }, + "include": ["./src/**/*.ts", "./src/**/*.tsx"] +} diff --git a/packages/shared-components/vite.config.js b/packages/shared-components/vite.config.js new file mode 100644 index 0000000000..83c999d87f --- /dev/null +++ b/packages/shared-components/vite.config.js @@ -0,0 +1,54 @@ +/* + * + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial + * Please see LICENSE files in the repository root for full details. + * / + */ + +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { defineConfig } from "vite"; +import dts from "vite-plugin-dts"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); + +export default defineConfig({ + build: { + lib: { + entry: resolve(__dirname, "src/index.ts"), + name: "Element Web Shared Components", + // the proper extensions will be added + fileName: "element-web-shared-components", + }, + outDir: "dist", + rollupOptions: { + // make sure to externalize deps that shouldn't be bundled + // into your library + external: ["react", "react-dom", "@vector-im/compound-design-tokens", "@vector-im/compound-web"], + output: { + // Provide global variables to use in the UMD build + // for externalized deps + globals: { + "react": "react", + "react-dom": "ReactDom", + }, + }, + }, + }, + resolve: { + alias: { + // Alias used by i18n.tsx + $webapp: resolve(__dirname, "..", "..", "webapp"), + }, + }, + plugins: [ + dts({ + rollupTypes: true, + include: ["src/**/*.{ts,tsx}"], + exclude: ["src/**/*.test.{ts,tsx}"], + copyDtsFiles: true, + }), + ], +}); diff --git a/packages/shared-components/yarn.lock b/packages/shared-components/yarn.lock new file mode 100644 index 0000000000..ceeb5ab89e --- /dev/null +++ b/packages/shared-components/yarn.lock @@ -0,0 +1,7411 @@ +# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. +# yarn lockfile v1 + + +"@adobe/css-tools@^4.4.0": + version "4.4.4" + resolved "https://registry.yarnpkg.com/@adobe/css-tools/-/css-tools-4.4.4.tgz#2856c55443d3d461693f32d2b96fb6ea92e1ffa9" + integrity sha512-Elp+iwUx5rN5+Y8xLt5/GRoG20WGoDCQ/1Fb+1LiGtvwbDavuSk0jhD/eZdckHAuzcDzccnkv+rEjyWfRx18gg== + +"@axe-core/playwright@^4.10.1": + version "4.11.0" + resolved "https://registry.yarnpkg.com/@axe-core/playwright/-/playwright-4.11.0.tgz#64beab80764c1f3f0ec4ac21f9b2c2d7df508958" + integrity sha512-70vBT/Ylqpm65RQz2iCG2o0JJCEG/WCNyefTr2xcOcr1CoSee60gNQYUMZZ7YukoKkFLv26I/jjlsvwwp532oQ== + dependencies: + axe-core "~4.11.0" + +"@babel/code-frame@^7.0.0", "@babel/code-frame@^7.10.4", "@babel/code-frame@^7.27.1": + version "7.27.1" + resolved "https://registry.yarnpkg.com/@babel/code-frame/-/code-frame-7.27.1.tgz#200f715e66d52a23b221a9435534a91cc13ad5be" + integrity sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg== + dependencies: + "@babel/helper-validator-identifier" "^7.27.1" + js-tokens "^4.0.0" + picocolors "^1.1.1" + +"@babel/compat-data@^7.27.2": + version "7.28.5" + resolved "https://registry.yarnpkg.com/@babel/compat-data/-/compat-data-7.28.5.tgz#a8a4962e1567121ac0b3b487f52107443b455c7f" + integrity sha512-6uFXyCayocRbqhZOB+6XcuZbkMNimwfVGFji8CTZnCzOHVGvDqzvitu1re2AU5LROliz7eQPhB8CpAMvnx9EjA== + +"@babel/core@^7.22.5", "@babel/core@^7.23.9", "@babel/core@^7.27.4", "@babel/core@^7.28.0", "@babel/core@^7.7.5": + version "7.28.5" + resolved "https://registry.yarnpkg.com/@babel/core/-/core-7.28.5.tgz#4c81b35e51e1b734f510c99b07dfbc7bbbb48f7e" + integrity sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw== + dependencies: + "@babel/code-frame" "^7.27.1" + "@babel/generator" "^7.28.5" + "@babel/helper-compilation-targets" "^7.27.2" + "@babel/helper-module-transforms" "^7.28.3" + "@babel/helpers" "^7.28.4" + "@babel/parser" "^7.28.5" + "@babel/template" "^7.27.2" + "@babel/traverse" "^7.28.5" + "@babel/types" "^7.28.5" + "@jridgewell/remapping" "^2.3.5" + convert-source-map "^2.0.0" + debug "^4.1.0" + gensync "^1.0.0-beta.2" + json5 "^2.2.3" + semver "^6.3.1" + +"@babel/generator@^7.22.5", "@babel/generator@^7.27.5", "@babel/generator@^7.28.3", "@babel/generator@^7.28.5": + version "7.28.5" + resolved "https://registry.yarnpkg.com/@babel/generator/-/generator-7.28.5.tgz#712722d5e50f44d07bc7ac9fe84438742dd61298" + integrity sha512-3EwLFhZ38J4VyIP6WNtt2kUdW9dokXA9Cr4IVIFHuCpZ3H8/YFOl5JjZHisrn1fATPBmKKqXzDFvh9fUwHz6CQ== + dependencies: + "@babel/parser" "^7.28.5" + "@babel/types" "^7.28.5" + "@jridgewell/gen-mapping" "^0.3.12" + "@jridgewell/trace-mapping" "^0.3.28" + jsesc "^3.0.2" + +"@babel/helper-compilation-targets@^7.27.2": + version "7.27.2" + resolved "https://registry.yarnpkg.com/@babel/helper-compilation-targets/-/helper-compilation-targets-7.27.2.tgz#46a0f6efab808d51d29ce96858dd10ce8732733d" + integrity sha512-2+1thGUUWWjLTYTHZWK1n8Yga0ijBz1XAhUXcKy81rd5g6yh7hGqMp45v7cadSbEHc9G3OTv45SyneRN3ps4DQ== + dependencies: + "@babel/compat-data" "^7.27.2" + "@babel/helper-validator-option" "^7.27.1" + browserslist "^4.24.0" + lru-cache "^5.1.1" + semver "^6.3.1" + +"@babel/helper-globals@^7.28.0": + version "7.28.0" + resolved "https://registry.yarnpkg.com/@babel/helper-globals/-/helper-globals-7.28.0.tgz#b9430df2aa4e17bc28665eadeae8aa1d985e6674" + integrity sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw== + +"@babel/helper-module-imports@^7.27.1": + version "7.27.1" + resolved "https://registry.yarnpkg.com/@babel/helper-module-imports/-/helper-module-imports-7.27.1.tgz#7ef769a323e2655e126673bb6d2d6913bbead204" + integrity sha512-0gSFWUPNXNopqtIPQvlD5WgXYI5GY2kP2cCvoT8kczjbfcfuIljTbcWrulD1CIPIX2gt1wghbDy08yE1p+/r3w== + dependencies: + "@babel/traverse" "^7.27.1" + "@babel/types" "^7.27.1" + +"@babel/helper-module-transforms@^7.28.3": + version "7.28.3" + resolved "https://registry.yarnpkg.com/@babel/helper-module-transforms/-/helper-module-transforms-7.28.3.tgz#a2b37d3da3b2344fe085dab234426f2b9a2fa5f6" + integrity sha512-gytXUbs8k2sXS9PnQptz5o0QnpLL51SwASIORY6XaBKF88nsOT0Zw9szLqlSGQDP/4TljBAD5y98p2U1fqkdsw== + dependencies: + "@babel/helper-module-imports" "^7.27.1" + "@babel/helper-validator-identifier" "^7.27.1" + "@babel/traverse" "^7.28.3" + +"@babel/helper-plugin-utils@^7.0.0", "@babel/helper-plugin-utils@^7.10.4", "@babel/helper-plugin-utils@^7.12.13", "@babel/helper-plugin-utils@^7.14.5", "@babel/helper-plugin-utils@^7.27.1", "@babel/helper-plugin-utils@^7.8.0": + version "7.27.1" + resolved "https://registry.yarnpkg.com/@babel/helper-plugin-utils/-/helper-plugin-utils-7.27.1.tgz#ddb2f876534ff8013e6c2b299bf4d39b3c51d44c" + integrity sha512-1gn1Up5YXka3YYAHGKpbideQ5Yjf1tDa9qYcgysz+cNCXukyLl6DjPXhD3VRwSb8c0J9tA4b2+rHEZtc6R0tlw== + +"@babel/helper-string-parser@^7.27.1": + version "7.27.1" + resolved "https://registry.yarnpkg.com/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz#54da796097ab19ce67ed9f88b47bb2ec49367687" + integrity sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA== + +"@babel/helper-validator-identifier@^7.27.1", "@babel/helper-validator-identifier@^7.28.5": + version "7.28.5" + resolved "https://registry.yarnpkg.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz#010b6938fab7cb7df74aa2bbc06aa503b8fe5fb4" + integrity sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q== + +"@babel/helper-validator-option@^7.27.1": + version "7.27.1" + resolved "https://registry.yarnpkg.com/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz#fa52f5b1e7db1ab049445b421c4471303897702f" + integrity sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg== + +"@babel/helpers@^7.28.4": + version "7.28.4" + resolved "https://registry.yarnpkg.com/@babel/helpers/-/helpers-7.28.4.tgz#fe07274742e95bdf7cf1443593eeb8926ab63827" + integrity sha512-HFN59MmQXGHVyYadKLVumYsA9dBFun/ldYxipEjzA4196jpLZd8UjEEBLkbEkvfYreDqJhZxYAWFPtrfhNpj4w== + dependencies: + "@babel/template" "^7.27.2" + "@babel/types" "^7.28.4" + +"@babel/parser@^7.1.0", "@babel/parser@^7.20.7", "@babel/parser@^7.23.9", "@babel/parser@^7.27.2", "@babel/parser@^7.28.4", "@babel/parser@^7.28.5": + version "7.28.5" + resolved "https://registry.yarnpkg.com/@babel/parser/-/parser-7.28.5.tgz#0b0225ee90362f030efd644e8034c99468893b08" + integrity sha512-KKBU1VGYR7ORr3At5HAtUQ+TV3SzRCXmA/8OdDZiLDBIZxVyzXuztPjfLd3BV1PRAQGCMWWSHYhL0F8d5uHBDQ== + dependencies: + "@babel/types" "^7.28.5" + +"@babel/parser@^7.18.5": + version "7.28.4" + resolved "https://registry.yarnpkg.com/@babel/parser/-/parser-7.28.4.tgz#da25d4643532890932cc03f7705fe19637e03fa8" + integrity sha512-yZbBqeM6TkpP9du/I2pUZnJsRMGGvOuIrhjzC1AwHwW+6he4mni6Bp/m8ijn0iOuZuPI2BfkCoSRunpyjnrQKg== + dependencies: + "@babel/types" "^7.28.4" + +"@babel/plugin-syntax-async-generators@^7.8.4": + version "7.8.4" + resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-async-generators/-/plugin-syntax-async-generators-7.8.4.tgz#a983fb1aeb2ec3f6ed042a210f640e90e786fe0d" + integrity sha512-tycmZxkGfZaxhMRbXlPXuVFpdWlXpir2W4AMhSJgRKzk/eDlIXOhb2LHWoLpDF7TEHylV5zNhykX6KAgHJmTNw== + dependencies: + "@babel/helper-plugin-utils" "^7.8.0" + +"@babel/plugin-syntax-bigint@^7.8.3": + version "7.8.3" + resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-bigint/-/plugin-syntax-bigint-7.8.3.tgz#4c9a6f669f5d0cdf1b90a1671e9a146be5300cea" + integrity sha512-wnTnFlG+YxQm3vDxpGE57Pj0srRU4sHE/mDkt1qv2YJJSeUAec2ma4WLUnUPeKjyrfntVwe/N6dCXpU+zL3Npg== + dependencies: + "@babel/helper-plugin-utils" "^7.8.0" + +"@babel/plugin-syntax-class-properties@^7.12.13": + version "7.12.13" + resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-class-properties/-/plugin-syntax-class-properties-7.12.13.tgz#b5c987274c4a3a82b89714796931a6b53544ae10" + integrity sha512-fm4idjKla0YahUNgFNLCB0qySdsoPiZP3iQE3rky0mBUtMZ23yDJ9SJdg6dXTSDnulOVqiF3Hgr9nbXvXTQZYA== + dependencies: + "@babel/helper-plugin-utils" "^7.12.13" + +"@babel/plugin-syntax-class-static-block@^7.14.5": + version "7.14.5" + resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-class-static-block/-/plugin-syntax-class-static-block-7.14.5.tgz#195df89b146b4b78b3bf897fd7a257c84659d406" + integrity sha512-b+YyPmr6ldyNnM6sqYeMWE+bgJcJpO6yS4QD7ymxgH34GBPNDM/THBh8iunyvKIZztiwLH4CJZ0RxTk9emgpjw== + dependencies: + "@babel/helper-plugin-utils" "^7.14.5" + +"@babel/plugin-syntax-import-attributes@^7.24.7": + version "7.27.1" + resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-import-attributes/-/plugin-syntax-import-attributes-7.27.1.tgz#34c017d54496f9b11b61474e7ea3dfd5563ffe07" + integrity sha512-oFT0FrKHgF53f4vOsZGi2Hh3I35PfSmVs4IBFLFj4dnafP+hIWDLg3VyKmUHfLoLHlyxY4C7DGtmHuJgn+IGww== + dependencies: + "@babel/helper-plugin-utils" "^7.27.1" + +"@babel/plugin-syntax-import-meta@^7.10.4": + version "7.10.4" + resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-import-meta/-/plugin-syntax-import-meta-7.10.4.tgz#ee601348c370fa334d2207be158777496521fd51" + integrity sha512-Yqfm+XDx0+Prh3VSeEQCPU81yC+JWZ2pDPFSS4ZdpfZhp4MkFMaDC1UqseovEKwSUpnIL7+vK+Clp7bfh0iD7g== + dependencies: + "@babel/helper-plugin-utils" "^7.10.4" + +"@babel/plugin-syntax-json-strings@^7.8.3": + version "7.8.3" + resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-json-strings/-/plugin-syntax-json-strings-7.8.3.tgz#01ca21b668cd8218c9e640cb6dd88c5412b2c96a" + integrity sha512-lY6kdGpWHvjoe2vk4WrAapEuBR69EMxZl+RoGRhrFGNYVK8mOPAW8VfbT/ZgrFbXlDNiiaxQnAtgVCZ6jv30EA== + dependencies: + "@babel/helper-plugin-utils" "^7.8.0" + +"@babel/plugin-syntax-jsx@^7.27.1": + version "7.27.1" + resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.27.1.tgz#2f9beb5eff30fa507c5532d107daac7b888fa34c" + integrity sha512-y8YTNIeKoyhGd9O0Jiyzyyqk8gdjnumGTQPsz0xOZOQ2RmkVJeZ1vmmfIvFEKqucBG6axJGBZDE/7iI5suUI/w== + dependencies: + "@babel/helper-plugin-utils" "^7.27.1" + +"@babel/plugin-syntax-logical-assignment-operators@^7.10.4": + version "7.10.4" + resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-logical-assignment-operators/-/plugin-syntax-logical-assignment-operators-7.10.4.tgz#ca91ef46303530448b906652bac2e9fe9941f699" + integrity sha512-d8waShlpFDinQ5MtvGU9xDAOzKH47+FFoney2baFIoMr952hKOLp1HR7VszoZvOsV/4+RRszNY7D17ba0te0ig== + dependencies: + "@babel/helper-plugin-utils" "^7.10.4" + +"@babel/plugin-syntax-nullish-coalescing-operator@^7.8.3": + version "7.8.3" + resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-nullish-coalescing-operator/-/plugin-syntax-nullish-coalescing-operator-7.8.3.tgz#167ed70368886081f74b5c36c65a88c03b66d1a9" + integrity sha512-aSff4zPII1u2QD7y+F8oDsz19ew4IGEJg9SVW+bqwpwtfFleiQDMdzA/R+UlWDzfnHFCxxleFT0PMIrR36XLNQ== + dependencies: + "@babel/helper-plugin-utils" "^7.8.0" + +"@babel/plugin-syntax-numeric-separator@^7.10.4": + version "7.10.4" + resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-numeric-separator/-/plugin-syntax-numeric-separator-7.10.4.tgz#b9b070b3e33570cd9fd07ba7fa91c0dd37b9af97" + integrity sha512-9H6YdfkcK/uOnY/K7/aA2xpzaAgkQn37yzWUMRK7OaPOqOpGS1+n0H5hxT9AUw9EsSjPW8SVyMJwYRtWs3X3ug== + dependencies: + "@babel/helper-plugin-utils" "^7.10.4" + +"@babel/plugin-syntax-object-rest-spread@^7.8.3": + version "7.8.3" + resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-object-rest-spread/-/plugin-syntax-object-rest-spread-7.8.3.tgz#60e225edcbd98a640332a2e72dd3e66f1af55871" + integrity sha512-XoqMijGZb9y3y2XskN+P1wUGiVwWZ5JmoDRwx5+3GmEplNyVM2s2Dg8ILFQm8rWM48orGy5YpI5Bl8U1y7ydlA== + dependencies: + "@babel/helper-plugin-utils" "^7.8.0" + +"@babel/plugin-syntax-optional-catch-binding@^7.8.3": + version "7.8.3" + resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-optional-catch-binding/-/plugin-syntax-optional-catch-binding-7.8.3.tgz#6111a265bcfb020eb9efd0fdfd7d26402b9ed6c1" + integrity sha512-6VPD0Pc1lpTqw0aKoeRTMiB+kWhAoT24PA+ksWSBrFtl5SIRVpZlwN3NNPQjehA2E/91FV3RjLWoVTglWcSV3Q== + dependencies: + "@babel/helper-plugin-utils" "^7.8.0" + +"@babel/plugin-syntax-optional-chaining@^7.8.3": + version "7.8.3" + resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-optional-chaining/-/plugin-syntax-optional-chaining-7.8.3.tgz#4f69c2ab95167e0180cd5336613f8c5788f7d48a" + integrity sha512-KoK9ErH1MBlCPxV0VANkXW2/dw4vlbGDrFgz8bmUsBGYkFRcbRwMh6cIJubdPrkxRwuGdtCk0v/wPTKbQgBjkg== + dependencies: + "@babel/helper-plugin-utils" "^7.8.0" + +"@babel/plugin-syntax-private-property-in-object@^7.14.5": + version "7.14.5" + resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-private-property-in-object/-/plugin-syntax-private-property-in-object-7.14.5.tgz#0dc6671ec0ea22b6e94a1114f857970cd39de1ad" + integrity sha512-0wVnp9dxJ72ZUJDV27ZfbSj6iHLoytYZmh3rFcxNnvsJF3ktkzLDZPy/mA17HGsaQT3/DQsWYX1f1QGWkCoVUg== + dependencies: + "@babel/helper-plugin-utils" "^7.14.5" + +"@babel/plugin-syntax-top-level-await@^7.14.5": + version "7.14.5" + resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-top-level-await/-/plugin-syntax-top-level-await-7.14.5.tgz#c1cfdadc35a646240001f06138247b741c34d94c" + integrity sha512-hx++upLv5U1rgYfwe1xBQUhRmU41NEvpUvrp8jkrSCdvGSnM5/qdRMtylJ6PG5OFkBaHkbTAKTnd3/YyESRHFw== + dependencies: + "@babel/helper-plugin-utils" "^7.14.5" + +"@babel/plugin-syntax-typescript@^7.27.1": + version "7.27.1" + resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-typescript/-/plugin-syntax-typescript-7.27.1.tgz#5147d29066a793450f220c63fa3a9431b7e6dd18" + integrity sha512-xfYCBMxveHrRMnAWl1ZlPXOZjzkN82THFvLhQhFXFt81Z5HnN+EtUkZhv/zcKpmT3fzmWZB0ywiBrbC3vogbwQ== + dependencies: + "@babel/helper-plugin-utils" "^7.27.1" + +"@babel/runtime@^7.12.5": + version "7.28.4" + resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.28.4.tgz#a70226016fabe25c5783b2f22d3e1c9bc5ca3326" + integrity sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ== + +"@babel/template@^7.22.5", "@babel/template@^7.27.2": + version "7.27.2" + resolved "https://registry.yarnpkg.com/@babel/template/-/template-7.27.2.tgz#fa78ceed3c4e7b63ebf6cb39e5852fca45f6809d" + integrity sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw== + dependencies: + "@babel/code-frame" "^7.27.1" + "@babel/parser" "^7.27.2" + "@babel/types" "^7.27.1" + +"@babel/traverse@^7.18.5": + version "7.28.4" + resolved "https://registry.yarnpkg.com/@babel/traverse/-/traverse-7.28.4.tgz#8d456101b96ab175d487249f60680221692b958b" + integrity sha512-YEzuboP2qvQavAcjgQNVgsvHIDv6ZpwXvcvjmyySP2DIMuByS/6ioU5G9pYrWHM6T2YDfc7xga9iNzYOs12CFQ== + dependencies: + "@babel/code-frame" "^7.27.1" + "@babel/generator" "^7.28.3" + "@babel/helper-globals" "^7.28.0" + "@babel/parser" "^7.28.4" + "@babel/template" "^7.27.2" + "@babel/types" "^7.28.4" + debug "^4.3.1" + +"@babel/traverse@^7.27.1", "@babel/traverse@^7.28.0", "@babel/traverse@^7.28.3", "@babel/traverse@^7.28.5": + version "7.28.5" + resolved "https://registry.yarnpkg.com/@babel/traverse/-/traverse-7.28.5.tgz#450cab9135d21a7a2ca9d2d35aa05c20e68c360b" + integrity sha512-TCCj4t55U90khlYkVV/0TfkJkAkUg3jZFA3Neb7unZT8CPok7iiRfaX0F+WnqWqt7OxhOn0uBKXCw4lbL8W0aQ== + dependencies: + "@babel/code-frame" "^7.27.1" + "@babel/generator" "^7.28.5" + "@babel/helper-globals" "^7.28.0" + "@babel/parser" "^7.28.5" + "@babel/template" "^7.27.2" + "@babel/types" "^7.28.5" + debug "^4.3.1" + +"@babel/types@^7.0.0", "@babel/types@^7.20.7", "@babel/types@^7.22.5", "@babel/types@^7.27.1", "@babel/types@^7.27.3", "@babel/types@^7.28.2", "@babel/types@^7.28.4", "@babel/types@^7.28.5": + version "7.28.5" + resolved "https://registry.yarnpkg.com/@babel/types/-/types-7.28.5.tgz#10fc405f60897c35f07e85493c932c7b5ca0592b" + integrity sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA== + dependencies: + "@babel/helper-string-parser" "^7.27.1" + "@babel/helper-validator-identifier" "^7.28.5" + +"@balena/dockerignore@^1.0.2": + version "1.0.2" + resolved "https://registry.yarnpkg.com/@balena/dockerignore/-/dockerignore-1.0.2.tgz#9ffe4726915251e8eb69f44ef3547e0da2c03e0d" + integrity sha512-wMue2Sy4GAVTk6Ic4tJVcnfdau+gx2EnG7S+uAEe+TWJFqE4YoWN4/H8MSLj4eYJKxGg26lZwboEniNiNwZQ6Q== + +"@bcoe/v8-coverage@^0.2.3": + version "0.2.3" + resolved "https://registry.yarnpkg.com/@bcoe/v8-coverage/-/v8-coverage-0.2.3.tgz#75a2e8b51cb758a7553d6804a5932d7aace75c39" + integrity sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw== + +"@element-hq/element-web-module-api@^1.8.0": + version "1.9.0" + resolved "https://registry.yarnpkg.com/@element-hq/element-web-module-api/-/element-web-module-api-1.9.0.tgz#2e4fcc8809418c8670d4f0576bc4a9a235bc6c50" + integrity sha512-Ao/V9w+wysZK4bh61LlKlznF10n2ZbD6KcUI46/zUMttXbmJn3ahvbzhEpwYcD+Cjy3ag5ycxLIIGkKV/fncXg== + +"@element-hq/element-web-playwright-common@^2.0.0": + version "2.1.0" + resolved "https://registry.yarnpkg.com/@element-hq/element-web-playwright-common/-/element-web-playwright-common-2.1.0.tgz#86e8a5632f8cc8bb393a1ec1b793a6278cd5b2c7" + integrity sha512-Ah9aioownR5OxAX7IDzys7wqyFmojruqgiRr2oUTLbPA5Y6jUSKWdAu5AqGvi+PYr0kG6zQfdsruhe5FXW9YuQ== + dependencies: + "@axe-core/playwright" "^4.10.1" + "@testcontainers/postgresql" "^11.0.0" + glob "^11.1.0" + lodash-es "^4.17.21" + mailpit-api "^1.2.0" + strip-ansi "^7.1.0" + testcontainers "^11.0.0" + yaml "^2.7.0" + +"@emnapi/core@^1.4.3": + version "1.7.1" + resolved "https://registry.yarnpkg.com/@emnapi/core/-/core-1.7.1.tgz#3a79a02dbc84f45884a1806ebb98e5746bdfaac4" + integrity sha512-o1uhUASyo921r2XtHYOHy7gdkGLge8ghBEQHMWmyJFoXlpU58kIrhhN3w26lpQb6dspetweapMn2CSNwQ8I4wg== + dependencies: + "@emnapi/wasi-threads" "1.1.0" + tslib "^2.4.0" + +"@emnapi/runtime@^1.4.3": + version "1.7.1" + resolved "https://registry.yarnpkg.com/@emnapi/runtime/-/runtime-1.7.1.tgz#a73784e23f5d57287369c808197288b52276b791" + integrity sha512-PVtJr5CmLwYAU9PZDMITZoR5iAOShYREoR45EyyLrbntV50mdePTgUn4AmOw90Ifcj+x2kRjdzr1HP3RrNiHGA== + dependencies: + tslib "^2.4.0" + +"@emnapi/wasi-threads@1.1.0": + version "1.1.0" + resolved "https://registry.yarnpkg.com/@emnapi/wasi-threads/-/wasi-threads-1.1.0.tgz#60b2102fddc9ccb78607e4a3cf8403ea69be41bf" + integrity sha512-WI0DdZ8xFSbgMjR1sFsKABJ/C5OnRrjT06JXbZKexJGrDuPTzZdDYfFlsgcCXCyf+suG5QU2e/y1Wo2V/OapLQ== + dependencies: + tslib "^2.4.0" + +"@esbuild/aix-ppc64@0.25.12": + version "0.25.12" + resolved "https://registry.yarnpkg.com/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz#80fcbe36130e58b7670511e888b8e88a259ed76c" + integrity sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA== + +"@esbuild/aix-ppc64@0.27.2": + version "0.27.2" + resolved "https://registry.yarnpkg.com/@esbuild/aix-ppc64/-/aix-ppc64-0.27.2.tgz#521cbd968dcf362094034947f76fa1b18d2d403c" + integrity sha512-GZMB+a0mOMZs4MpDbj8RJp4cw+w1WV5NYD6xzgvzUJ5Ek2jerwfO2eADyI6ExDSUED+1X8aMbegahsJi+8mgpw== + +"@esbuild/android-arm64@0.25.12": + version "0.25.12" + resolved "https://registry.yarnpkg.com/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz#8aa4965f8d0a7982dc21734bf6601323a66da752" + integrity sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg== + +"@esbuild/android-arm64@0.27.2": + version "0.27.2" + resolved "https://registry.yarnpkg.com/@esbuild/android-arm64/-/android-arm64-0.27.2.tgz#61ea550962d8aa12a9b33194394e007657a6df57" + integrity sha512-pvz8ZZ7ot/RBphf8fv60ljmaoydPU12VuXHImtAs0XhLLw+EXBi2BLe3OYSBslR4rryHvweW5gmkKFwTiFy6KA== + +"@esbuild/android-arm@0.25.12": + version "0.25.12" + resolved "https://registry.yarnpkg.com/@esbuild/android-arm/-/android-arm-0.25.12.tgz#300712101f7f50f1d2627a162e6e09b109b6767a" + integrity sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg== + +"@esbuild/android-arm@0.27.2": + version "0.27.2" + resolved "https://registry.yarnpkg.com/@esbuild/android-arm/-/android-arm-0.27.2.tgz#554887821e009dd6d853f972fde6c5143f1de142" + integrity sha512-DVNI8jlPa7Ujbr1yjU2PfUSRtAUZPG9I1RwW4F4xFB1Imiu2on0ADiI/c3td+KmDtVKNbi+nffGDQMfcIMkwIA== + +"@esbuild/android-x64@0.25.12": + version "0.25.12" + resolved "https://registry.yarnpkg.com/@esbuild/android-x64/-/android-x64-0.25.12.tgz#87dfb27161202bdc958ef48bb61b09c758faee16" + integrity sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg== + +"@esbuild/android-x64@0.27.2": + version "0.27.2" + resolved "https://registry.yarnpkg.com/@esbuild/android-x64/-/android-x64-0.27.2.tgz#a7ce9d0721825fc578f9292a76d9e53334480ba2" + integrity sha512-z8Ank4Byh4TJJOh4wpz8g2vDy75zFL0TlZlkUkEwYXuPSgX8yzep596n6mT7905kA9uHZsf/o2OJZubl2l3M7A== + +"@esbuild/darwin-arm64@0.25.12": + version "0.25.12" + resolved "https://registry.yarnpkg.com/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz#79197898ec1ff745d21c071e1c7cc3c802f0c1fd" + integrity sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg== + +"@esbuild/darwin-arm64@0.27.2": + version "0.27.2" + resolved "https://registry.yarnpkg.com/@esbuild/darwin-arm64/-/darwin-arm64-0.27.2.tgz#2cb7659bd5d109803c593cfc414450d5430c8256" + integrity sha512-davCD2Zc80nzDVRwXTcQP/28fiJbcOwvdolL0sOiOsbwBa72kegmVU0Wrh1MYrbuCL98Omp5dVhQFWRKR2ZAlg== + +"@esbuild/darwin-x64@0.25.12": + version "0.25.12" + resolved "https://registry.yarnpkg.com/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz#146400a8562133f45c4d2eadcf37ddd09718079e" + integrity sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA== + +"@esbuild/darwin-x64@0.27.2": + version "0.27.2" + resolved "https://registry.yarnpkg.com/@esbuild/darwin-x64/-/darwin-x64-0.27.2.tgz#e741fa6b1abb0cd0364126ba34ca17fd5e7bf509" + integrity sha512-ZxtijOmlQCBWGwbVmwOF/UCzuGIbUkqB1faQRf5akQmxRJ1ujusWsb3CVfk/9iZKr2L5SMU5wPBi1UWbvL+VQA== + +"@esbuild/freebsd-arm64@0.25.12": + version "0.25.12" + resolved "https://registry.yarnpkg.com/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz#1c5f9ba7206e158fd2b24c59fa2d2c8bb47ca0fe" + integrity sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg== + +"@esbuild/freebsd-arm64@0.27.2": + version "0.27.2" + resolved "https://registry.yarnpkg.com/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.2.tgz#2b64e7116865ca172d4ce034114c21f3c93e397c" + integrity sha512-lS/9CN+rgqQ9czogxlMcBMGd+l8Q3Nj1MFQwBZJyoEKI50XGxwuzznYdwcav6lpOGv5BqaZXqvBSiB/kJ5op+g== + +"@esbuild/freebsd-x64@0.25.12": + version "0.25.12" + resolved "https://registry.yarnpkg.com/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz#ea631f4a36beaac4b9279fa0fcc6ca29eaeeb2b3" + integrity sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ== + +"@esbuild/freebsd-x64@0.27.2": + version "0.27.2" + resolved "https://registry.yarnpkg.com/@esbuild/freebsd-x64/-/freebsd-x64-0.27.2.tgz#e5252551e66f499e4934efb611812f3820e990bb" + integrity sha512-tAfqtNYb4YgPnJlEFu4c212HYjQWSO/w/h/lQaBK7RbwGIkBOuNKQI9tqWzx7Wtp7bTPaGC6MJvWI608P3wXYA== + +"@esbuild/linux-arm64@0.25.12": + version "0.25.12" + resolved "https://registry.yarnpkg.com/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz#e1066bce58394f1b1141deec8557a5f0a22f5977" + integrity sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ== + +"@esbuild/linux-arm64@0.27.2": + version "0.27.2" + resolved "https://registry.yarnpkg.com/@esbuild/linux-arm64/-/linux-arm64-0.27.2.tgz#dc4acf235531cd6984f5d6c3b13dbfb7ddb303cb" + integrity sha512-hYxN8pr66NsCCiRFkHUAsxylNOcAQaxSSkHMMjcpx0si13t1LHFphxJZUiGwojB1a/Hd5OiPIqDdXONia6bhTw== + +"@esbuild/linux-arm@0.25.12": + version "0.25.12" + resolved "https://registry.yarnpkg.com/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz#452cd66b20932d08bdc53a8b61c0e30baf4348b9" + integrity sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw== + +"@esbuild/linux-arm@0.27.2": + version "0.27.2" + resolved "https://registry.yarnpkg.com/@esbuild/linux-arm/-/linux-arm-0.27.2.tgz#56a900e39240d7d5d1d273bc053daa295c92e322" + integrity sha512-vWfq4GaIMP9AIe4yj1ZUW18RDhx6EPQKjwe7n8BbIecFtCQG4CfHGaHuh7fdfq+y3LIA2vGS/o9ZBGVxIDi9hw== + +"@esbuild/linux-ia32@0.25.12": + version "0.25.12" + resolved "https://registry.yarnpkg.com/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz#b24f8acc45bcf54192c7f2f3be1b53e6551eafe0" + integrity sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA== + +"@esbuild/linux-ia32@0.27.2": + version "0.27.2" + resolved "https://registry.yarnpkg.com/@esbuild/linux-ia32/-/linux-ia32-0.27.2.tgz#d4a36d473360f6870efcd19d52bbfff59a2ed1cc" + integrity sha512-MJt5BRRSScPDwG2hLelYhAAKh9imjHK5+NE/tvnRLbIqUWa+0E9N4WNMjmp/kXXPHZGqPLxggwVhz7QP8CTR8w== + +"@esbuild/linux-loong64@0.25.12": + version "0.25.12" + resolved "https://registry.yarnpkg.com/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz#f9cfffa7fc8322571fbc4c8b3268caf15bd81ad0" + integrity sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng== + +"@esbuild/linux-loong64@0.27.2": + version "0.27.2" + resolved "https://registry.yarnpkg.com/@esbuild/linux-loong64/-/linux-loong64-0.27.2.tgz#fcf0ab8c3eaaf45891d0195d4961cb18b579716a" + integrity sha512-lugyF1atnAT463aO6KPshVCJK5NgRnU4yb3FUumyVz+cGvZbontBgzeGFO1nF+dPueHD367a2ZXe1NtUkAjOtg== + +"@esbuild/linux-mips64el@0.25.12": + version "0.25.12" + resolved "https://registry.yarnpkg.com/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz#575a14bd74644ffab891adc7d7e60d275296f2cd" + integrity sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw== + +"@esbuild/linux-mips64el@0.27.2": + version "0.27.2" + resolved "https://registry.yarnpkg.com/@esbuild/linux-mips64el/-/linux-mips64el-0.27.2.tgz#598b67d34048bb7ee1901cb12e2a0a434c381c10" + integrity sha512-nlP2I6ArEBewvJ2gjrrkESEZkB5mIoaTswuqNFRv/WYd+ATtUpe9Y09RnJvgvdag7he0OWgEZWhviS1OTOKixw== + +"@esbuild/linux-ppc64@0.25.12": + version "0.25.12" + resolved "https://registry.yarnpkg.com/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz#75b99c70a95fbd5f7739d7692befe60601591869" + integrity sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA== + +"@esbuild/linux-ppc64@0.27.2": + version "0.27.2" + resolved "https://registry.yarnpkg.com/@esbuild/linux-ppc64/-/linux-ppc64-0.27.2.tgz#3846c5df6b2016dab9bc95dde26c40f11e43b4c0" + integrity sha512-C92gnpey7tUQONqg1n6dKVbx3vphKtTHJaNG2Ok9lGwbZil6DrfyecMsp9CrmXGQJmZ7iiVXvvZH6Ml5hL6XdQ== + +"@esbuild/linux-riscv64@0.25.12": + version "0.25.12" + resolved "https://registry.yarnpkg.com/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz#2e3259440321a44e79ddf7535c325057da875cd6" + integrity sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w== + +"@esbuild/linux-riscv64@0.27.2": + version "0.27.2" + resolved "https://registry.yarnpkg.com/@esbuild/linux-riscv64/-/linux-riscv64-0.27.2.tgz#173d4475b37c8d2c3e1707e068c174bb3f53d07d" + integrity sha512-B5BOmojNtUyN8AXlK0QJyvjEZkWwy/FKvakkTDCziX95AowLZKR6aCDhG7LeF7uMCXEJqwa8Bejz5LTPYm8AvA== + +"@esbuild/linux-s390x@0.25.12": + version "0.25.12" + resolved "https://registry.yarnpkg.com/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz#17676cabbfe5928da5b2a0d6df5d58cd08db2663" + integrity sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg== + +"@esbuild/linux-s390x@0.27.2": + version "0.27.2" + resolved "https://registry.yarnpkg.com/@esbuild/linux-s390x/-/linux-s390x-0.27.2.tgz#f7a4790105edcab8a5a31df26fbfac1aa3dacfab" + integrity sha512-p4bm9+wsPwup5Z8f4EpfN63qNagQ47Ua2znaqGH6bqLlmJ4bx97Y9JdqxgGZ6Y8xVTixUnEkoKSHcpRlDnNr5w== + +"@esbuild/linux-x64@0.25.12": + version "0.25.12" + resolved "https://registry.yarnpkg.com/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz#0583775685ca82066d04c3507f09524d3cd7a306" + integrity sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw== + +"@esbuild/linux-x64@0.27.2": + version "0.27.2" + resolved "https://registry.yarnpkg.com/@esbuild/linux-x64/-/linux-x64-0.27.2.tgz#2ecc1284b1904aeb41e54c9ddc7fcd349b18f650" + integrity sha512-uwp2Tip5aPmH+NRUwTcfLb+W32WXjpFejTIOWZFw/v7/KnpCDKG66u4DLcurQpiYTiYwQ9B7KOeMJvLCu/OvbA== + +"@esbuild/netbsd-arm64@0.25.12": + version "0.25.12" + resolved "https://registry.yarnpkg.com/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz#f04c4049cb2e252fe96b16fed90f70746b13f4a4" + integrity sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg== + +"@esbuild/netbsd-arm64@0.27.2": + version "0.27.2" + resolved "https://registry.yarnpkg.com/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.2.tgz#e2863c2cd1501845995cb11adf26f7fe4be527b0" + integrity sha512-Kj6DiBlwXrPsCRDeRvGAUb/LNrBASrfqAIok+xB0LxK8CHqxZ037viF13ugfsIpePH93mX7xfJp97cyDuTZ3cw== + +"@esbuild/netbsd-x64@0.25.12": + version "0.25.12" + resolved "https://registry.yarnpkg.com/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz#77da0d0a0d826d7c921eea3d40292548b258a076" + integrity sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ== + +"@esbuild/netbsd-x64@0.27.2": + version "0.27.2" + resolved "https://registry.yarnpkg.com/@esbuild/netbsd-x64/-/netbsd-x64-0.27.2.tgz#93f7609e2885d1c0b5a1417885fba8d1fcc41272" + integrity sha512-HwGDZ0VLVBY3Y+Nw0JexZy9o/nUAWq9MlV7cahpaXKW6TOzfVno3y3/M8Ga8u8Yr7GldLOov27xiCnqRZf0tCA== + +"@esbuild/openbsd-arm64@0.25.12": + version "0.25.12" + resolved "https://registry.yarnpkg.com/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz#6296f5867aedef28a81b22ab2009c786a952dccd" + integrity sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A== + +"@esbuild/openbsd-arm64@0.27.2": + version "0.27.2" + resolved "https://registry.yarnpkg.com/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.2.tgz#a1985604a203cdc325fd47542e106fafd698f02e" + integrity sha512-DNIHH2BPQ5551A7oSHD0CKbwIA/Ox7+78/AWkbS5QoRzaqlev2uFayfSxq68EkonB+IKjiuxBFoV8ESJy8bOHA== + +"@esbuild/openbsd-x64@0.25.12": + version "0.25.12" + resolved "https://registry.yarnpkg.com/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz#f8d23303360e27b16cf065b23bbff43c14142679" + integrity sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw== + +"@esbuild/openbsd-x64@0.27.2": + version "0.27.2" + resolved "https://registry.yarnpkg.com/@esbuild/openbsd-x64/-/openbsd-x64-0.27.2.tgz#8209e46c42f1ffbe6e4ef77a32e1f47d404ad42a" + integrity sha512-/it7w9Nb7+0KFIzjalNJVR5bOzA9Vay+yIPLVHfIQYG/j+j9VTH84aNB8ExGKPU4AzfaEvN9/V4HV+F+vo8OEg== + +"@esbuild/openharmony-arm64@0.25.12": + version "0.25.12" + resolved "https://registry.yarnpkg.com/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz#49e0b768744a3924be0d7fd97dd6ce9b2923d88d" + integrity sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg== + +"@esbuild/openharmony-arm64@0.27.2": + version "0.27.2" + resolved "https://registry.yarnpkg.com/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.2.tgz#8fade4441893d9cc44cbd7dcf3776f508ab6fb2f" + integrity sha512-LRBbCmiU51IXfeXk59csuX/aSaToeG7w48nMwA6049Y4J4+VbWALAuXcs+qcD04rHDuSCSRKdmY63sruDS5qag== + +"@esbuild/sunos-x64@0.25.12": + version "0.25.12" + resolved "https://registry.yarnpkg.com/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz#a6ed7d6778d67e528c81fb165b23f4911b9b13d6" + integrity sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w== + +"@esbuild/sunos-x64@0.27.2": + version "0.27.2" + resolved "https://registry.yarnpkg.com/@esbuild/sunos-x64/-/sunos-x64-0.27.2.tgz#980d4b9703a16f0f07016632424fc6d9a789dfc2" + integrity sha512-kMtx1yqJHTmqaqHPAzKCAkDaKsffmXkPHThSfRwZGyuqyIeBvf08KSsYXl+abf5HDAPMJIPnbBfXvP2ZC2TfHg== + +"@esbuild/win32-arm64@0.25.12": + version "0.25.12" + resolved "https://registry.yarnpkg.com/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz#9ac14c378e1b653af17d08e7d3ce34caef587323" + integrity sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg== + +"@esbuild/win32-arm64@0.27.2": + version "0.27.2" + resolved "https://registry.yarnpkg.com/@esbuild/win32-arm64/-/win32-arm64-0.27.2.tgz#1c09a3633c949ead3d808ba37276883e71f6111a" + integrity sha512-Yaf78O/B3Kkh+nKABUF++bvJv5Ijoy9AN1ww904rOXZFLWVc5OLOfL56W+C8F9xn5JQZa3UX6m+IktJnIb1Jjg== + +"@esbuild/win32-ia32@0.25.12": + version "0.25.12" + resolved "https://registry.yarnpkg.com/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz#918942dcbbb35cc14fca39afb91b5e6a3d127267" + integrity sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ== + +"@esbuild/win32-ia32@0.27.2": + version "0.27.2" + resolved "https://registry.yarnpkg.com/@esbuild/win32-ia32/-/win32-ia32-0.27.2.tgz#1b1e3a63ad4bef82200fef4e369e0fff7009eee5" + integrity sha512-Iuws0kxo4yusk7sw70Xa2E2imZU5HoixzxfGCdxwBdhiDgt9vX9VUCBhqcwY7/uh//78A1hMkkROMJq9l27oLQ== + +"@esbuild/win32-x64@0.25.12": + version "0.25.12" + resolved "https://registry.yarnpkg.com/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz#9bdad8176be7811ad148d1f8772359041f46c6c5" + integrity sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA== + +"@esbuild/win32-x64@0.27.2": + version "0.27.2" + resolved "https://registry.yarnpkg.com/@esbuild/win32-x64/-/win32-x64-0.27.2.tgz#9e585ab6086bef994c6e8a5b3a0481219ada862b" + integrity sha512-sRdU18mcKf7F+YgheI/zGf5alZatMUTKj/jNS6l744f9u3WFu4v7twcUI9vu4mknF4Y9aDlblIie0IM+5xxaqQ== + +"@eslint-community/eslint-utils@^4.2.0", "@eslint-community/eslint-utils@^4.7.0": + version "4.9.0" + resolved "https://registry.yarnpkg.com/@eslint-community/eslint-utils/-/eslint-utils-4.9.0.tgz#7308df158e064f0dd8b8fdb58aa14fa2a7f913b3" + integrity sha512-ayVFHdtZ+hsq1t2Dy24wCmGXGe4q9Gu3smhLYALJrr473ZH27MsnSL+LKUlimp4BWJqMDMLmPpx/Q9R3OAlL4g== + dependencies: + eslint-visitor-keys "^3.4.3" + +"@eslint-community/regexpp@^4.6.1": + version "4.12.1" + resolved "https://registry.yarnpkg.com/@eslint-community/regexpp/-/regexpp-4.12.1.tgz#cfc6cffe39df390a3841cde2abccf92eaa7ae0e0" + integrity sha512-CCZCDJuduB9OUkFkY2IgppNZMi2lBQgD2qzwXkEia16cge2pijY/aXi96CJMquDMn3nJdlPV1A5KrJEXwfLNzQ== + +"@eslint/eslintrc@^2.1.4": + version "2.1.4" + resolved "https://registry.yarnpkg.com/@eslint/eslintrc/-/eslintrc-2.1.4.tgz#388a269f0f25c1b6adc317b5a2c55714894c70ad" + integrity sha512-269Z39MS6wVJtsoUl10L60WdkhJVdPG24Q4eZTH3nnF6lpvSShEK3wQjDX9JRWAUPvPh7COouPpU9IrqaZFvtQ== + dependencies: + ajv "^6.12.4" + debug "^4.3.2" + espree "^9.6.0" + globals "^13.19.0" + ignore "^5.2.0" + import-fresh "^3.2.1" + js-yaml "^4.1.0" + minimatch "^3.1.2" + strip-json-comments "^3.1.1" + +"@eslint/js@8.57.1": + version "8.57.1" + resolved "https://registry.yarnpkg.com/@eslint/js/-/js-8.57.1.tgz#de633db3ec2ef6a3c89e2f19038063e8a122e2c2" + integrity sha512-d9zaMRSTIKDLhctzH12MtXvJKSSUhaHcjV+2Z+GK+EEY7XKpP5yR4x+N3TAcHTcu963nIr+TMcCb4DBCYX1z6Q== + +"@figspec/components@^2.0.1": + version "2.0.5" + resolved "https://registry.yarnpkg.com/@figspec/components/-/components-2.0.5.tgz#0c953e367b2b9d7661cb3507fef61d371313d7a4" + integrity sha512-nJ2Ms3Ua8r3f18fWVC6GEAN1qchJ0KheZIV+lPaDjZa4KJW9FXi2Ora2tuPkp8SMFl6uaqibYk7/VwTq+zNQ7A== + +"@figspec/react@^2.0.0": + version "2.0.1" + resolved "https://registry.yarnpkg.com/@figspec/react/-/react-2.0.1.tgz#f5fb5126b3983df810a457e6497704740db977e3" + integrity sha512-xflqJ3XQZVzm8+7dsm8OFxVAmBNNA3Mg65sqwNHiq7VRSMSD7qwH4BPsBy07ZaX+9nHeaacBpFZd3Q0aIsISqw== + dependencies: + "@figspec/components" "^2.0.1" + "@lit-labs/react" "^2.0.0" + +"@grpc/grpc-js@^1.11.1": + version "1.14.2" + resolved "https://registry.yarnpkg.com/@grpc/grpc-js/-/grpc-js-1.14.2.tgz#d245069181a1a8057abd35522d6052482730cf19" + integrity sha512-QzVUtEFyu05UNx2xr0fCQmStUO17uVQhGNowtxs00IgTZT6/W2PBLfUkj30s0FKJ29VtTa3ArVNIhNP6akQhqA== + dependencies: + "@grpc/proto-loader" "^0.8.0" + "@js-sdsl/ordered-map" "^4.4.2" + +"@grpc/proto-loader@^0.7.13": + version "0.7.15" + resolved "https://registry.yarnpkg.com/@grpc/proto-loader/-/proto-loader-0.7.15.tgz#4cdfbf35a35461fc843abe8b9e2c0770b5095e60" + integrity sha512-tMXdRCfYVixjuFK+Hk0Q1s38gV9zDiDJfWL3h1rv4Qc39oILCu1TRTDt7+fGUI8K4G1Fj125Hx/ru3azECWTyQ== + dependencies: + lodash.camelcase "^4.3.0" + long "^5.0.0" + protobufjs "^7.2.5" + yargs "^17.7.2" + +"@grpc/proto-loader@^0.8.0": + version "0.8.0" + resolved "https://registry.yarnpkg.com/@grpc/proto-loader/-/proto-loader-0.8.0.tgz#b6c324dd909c458a0e4aa9bfd3d69cf78a4b9bd8" + integrity sha512-rc1hOQtjIWGxcxpb9aHAfLpIctjEnsDehj0DAiVfBlmT84uvR0uUtN2hEi/ecvWVjXUGf5qPF4qEgiLOx1YIMQ== + dependencies: + lodash.camelcase "^4.3.0" + long "^5.0.0" + protobufjs "^7.5.3" + yargs "^17.7.2" + +"@hapi/hoek@^9.0.0", "@hapi/hoek@^9.3.0": + version "9.3.0" + resolved "https://registry.yarnpkg.com/@hapi/hoek/-/hoek-9.3.0.tgz#8368869dcb735be2e7f5cb7647de78e167a251fb" + integrity sha512-/c6rf4UJlmHlC9b5BaNvzAcFv7HZ2QHaV0D4/HNlBdvFnvQq8RI4kYdhyPCl7Xj+oWvTWQ8ujhqS53LIgAe6KQ== + +"@hapi/topo@^5.1.0": + version "5.1.0" + resolved "https://registry.yarnpkg.com/@hapi/topo/-/topo-5.1.0.tgz#dc448e332c6c6e37a4dc02fd84ba8d44b9afb012" + integrity sha512-foQZKJig7Ob0BMAYBfcJk8d77QtOe7Wo4ox7ff1lQYoNNAb6jwcY1ncdoy2e9wQZzvNy7ODZCYJkK8kzmcAnAg== + dependencies: + "@hapi/hoek" "^9.0.0" + +"@humanwhocodes/config-array@^0.13.0": + version "0.13.0" + resolved "https://registry.yarnpkg.com/@humanwhocodes/config-array/-/config-array-0.13.0.tgz#fb907624df3256d04b9aa2df50d7aa97ec648748" + integrity sha512-DZLEEqFWQFiyK6h5YIeynKx7JlvCYWL0cImfSRXZ9l4Sg2efkFGTuFf6vzXjK1cq6IYkU+Eg/JizXw+TD2vRNw== + dependencies: + "@humanwhocodes/object-schema" "^2.0.3" + debug "^4.3.1" + minimatch "^3.0.5" + +"@humanwhocodes/module-importer@^1.0.1": + version "1.0.1" + resolved "https://registry.yarnpkg.com/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz#af5b2691a22b44be847b0ca81641c5fb6ad0172c" + integrity sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA== + +"@humanwhocodes/object-schema@^2.0.3": + version "2.0.3" + resolved "https://registry.yarnpkg.com/@humanwhocodes/object-schema/-/object-schema-2.0.3.tgz#4a2868d75d6d6963e423bcf90b7fd1be343409d3" + integrity sha512-93zYdMES/c1D69yZiKDBj0V24vqNzB/koF26KPaagAfd3P/4gUlh3Dys5ogAK+Exi9QyzlD8x/08Zt7wIKcDcA== + +"@isaacs/balanced-match@^4.0.1": + version "4.0.1" + resolved "https://registry.yarnpkg.com/@isaacs/balanced-match/-/balanced-match-4.0.1.tgz#3081dadbc3460661b751e7591d7faea5df39dd29" + integrity sha512-yzMTt9lEb8Gv7zRioUilSglI0c0smZ9k5D65677DLWLtWJaXIS3CqcGyUFByYKlnUj6TkjLVs54fBl6+TiGQDQ== + +"@isaacs/brace-expansion@^5.0.0": + version "5.0.0" + resolved "https://registry.yarnpkg.com/@isaacs/brace-expansion/-/brace-expansion-5.0.0.tgz#4b3dabab7d8e75a429414a96bd67bf4c1d13e0f3" + integrity sha512-ZT55BDLV0yv0RBm2czMiZ+SqCGO7AvmOM3G/w2xhVPH+te0aKgFjmBvGlL1dH+ql2tgGO3MVrbb3jCKyvpgnxA== + dependencies: + "@isaacs/balanced-match" "^4.0.1" + +"@isaacs/cliui@^8.0.2": + version "8.0.2" + resolved "https://registry.yarnpkg.com/@isaacs/cliui/-/cliui-8.0.2.tgz#b37667b7bc181c168782259bab42474fbf52b550" + integrity sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA== + dependencies: + string-width "^5.1.2" + string-width-cjs "npm:string-width@^4.2.0" + strip-ansi "^7.0.1" + strip-ansi-cjs "npm:strip-ansi@^6.0.1" + wrap-ansi "^8.1.0" + wrap-ansi-cjs "npm:wrap-ansi@^7.0.0" + +"@istanbuljs/load-nyc-config@^1.0.0": + version "1.1.0" + resolved "https://registry.yarnpkg.com/@istanbuljs/load-nyc-config/-/load-nyc-config-1.1.0.tgz#fd3db1d59ecf7cf121e80650bb86712f9b55eced" + integrity sha512-VjeHSlIzpv/NyD3N0YuHfXOPDIixcA1q2ZV98wsMqcYlPmv2n3Yb2lYP9XMElnaFVXg5A7YLTeLu6V84uQDjmQ== + dependencies: + camelcase "^5.3.1" + find-up "^4.1.0" + get-package-type "^0.1.0" + js-yaml "^3.13.1" + resolve-from "^5.0.0" + +"@istanbuljs/schema@^0.1.2", "@istanbuljs/schema@^0.1.3": + version "0.1.3" + resolved "https://registry.yarnpkg.com/@istanbuljs/schema/-/schema-0.1.3.tgz#e45e384e4b8ec16bce2fd903af78450f6bf7ec98" + integrity sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA== + +"@jest/console@30.2.0": + version "30.2.0" + resolved "https://registry.yarnpkg.com/@jest/console/-/console-30.2.0.tgz#c52fcd5b58fdd2e8eb66b2fd8ae56f2f64d05b28" + integrity sha512-+O1ifRjkvYIkBqASKWgLxrpEhQAAE7hY77ALLUufSk5717KfOShg6IbqLmdsLMPdUiFvA2kTs0R7YZy+l0IzZQ== + dependencies: + "@jest/types" "30.2.0" + "@types/node" "*" + chalk "^4.1.2" + jest-message-util "30.2.0" + jest-util "30.2.0" + slash "^3.0.0" + +"@jest/core@30.2.0": + version "30.2.0" + resolved "https://registry.yarnpkg.com/@jest/core/-/core-30.2.0.tgz#813d59faa5abd5510964a8b3a7b17cc77b775275" + integrity sha512-03W6IhuhjqTlpzh/ojut/pDB2LPRygyWX8ExpgHtQA8H/3K7+1vKmcINx5UzeOX1se6YEsBsOHQ1CRzf3fOwTQ== + dependencies: + "@jest/console" "30.2.0" + "@jest/pattern" "30.0.1" + "@jest/reporters" "30.2.0" + "@jest/test-result" "30.2.0" + "@jest/transform" "30.2.0" + "@jest/types" "30.2.0" + "@types/node" "*" + ansi-escapes "^4.3.2" + chalk "^4.1.2" + ci-info "^4.2.0" + exit-x "^0.2.2" + graceful-fs "^4.2.11" + jest-changed-files "30.2.0" + jest-config "30.2.0" + jest-haste-map "30.2.0" + jest-message-util "30.2.0" + jest-regex-util "30.0.1" + jest-resolve "30.2.0" + jest-resolve-dependencies "30.2.0" + jest-runner "30.2.0" + jest-runtime "30.2.0" + jest-snapshot "30.2.0" + jest-util "30.2.0" + jest-validate "30.2.0" + jest-watcher "30.2.0" + micromatch "^4.0.8" + pretty-format "30.2.0" + slash "^3.0.0" + +"@jest/create-cache-key-function@^30.0.0": + version "30.2.0" + resolved "https://registry.yarnpkg.com/@jest/create-cache-key-function/-/create-cache-key-function-30.2.0.tgz#86dbaf8cce43e8a0266180a5236b6f0b3be9d09b" + integrity sha512-44F4l4Enf+MirJN8X/NhdGkl71k5rBYiwdVlo4HxOwbu0sHV8QKrGEedb1VUU4K3W7fBKE0HGfbn7eZm0Ti3zg== + dependencies: + "@jest/types" "30.2.0" + +"@jest/diff-sequences@30.0.1": + version "30.0.1" + resolved "https://registry.yarnpkg.com/@jest/diff-sequences/-/diff-sequences-30.0.1.tgz#0ededeae4d071f5c8ffe3678d15f3a1be09156be" + integrity sha512-n5H8QLDJ47QqbCNn5SuFjCRDrOLEZ0h8vAHCK5RL9Ls7Xa8AQLa/YxAc9UjFqoEDM48muwtBGjtMY5cr0PLDCw== + +"@jest/environment@30.2.0": + version "30.2.0" + resolved "https://registry.yarnpkg.com/@jest/environment/-/environment-30.2.0.tgz#1e673cdb8b93ded707cf6631b8353011460831fa" + integrity sha512-/QPTL7OBJQ5ac09UDRa3EQes4gt1FTEG/8jZ/4v5IVzx+Cv7dLxlVIvfvSVRiiX2drWyXeBjkMSR8hvOWSog5g== + dependencies: + "@jest/fake-timers" "30.2.0" + "@jest/types" "30.2.0" + "@types/node" "*" + jest-mock "30.2.0" + +"@jest/expect-utils@30.2.0": + version "30.2.0" + resolved "https://registry.yarnpkg.com/@jest/expect-utils/-/expect-utils-30.2.0.tgz#4f95413d4748454fdb17404bf1141827d15e6011" + integrity sha512-1JnRfhqpD8HGpOmQp180Fo9Zt69zNtC+9lR+kT7NVL05tNXIi+QC8Csz7lfidMoVLPD3FnOtcmp0CEFnxExGEA== + dependencies: + "@jest/get-type" "30.1.0" + +"@jest/expect@30.2.0": + version "30.2.0" + resolved "https://registry.yarnpkg.com/@jest/expect/-/expect-30.2.0.tgz#9a5968499bb8add2bbb09136f69f7df5ddbf3185" + integrity sha512-V9yxQK5erfzx99Sf+7LbhBwNWEZ9eZay8qQ9+JSC0TrMR1pMDHLMY+BnVPacWU6Jamrh252/IKo4F1Xn/zfiqA== + dependencies: + expect "30.2.0" + jest-snapshot "30.2.0" + +"@jest/fake-timers@30.2.0": + version "30.2.0" + resolved "https://registry.yarnpkg.com/@jest/fake-timers/-/fake-timers-30.2.0.tgz#0941ddc28a339b9819542495b5408622dc9e94ec" + integrity sha512-HI3tRLjRxAbBy0VO8dqqm7Hb2mIa8d5bg/NJkyQcOk7V118ObQML8RC5luTF/Zsg4474a+gDvhce7eTnP4GhYw== + dependencies: + "@jest/types" "30.2.0" + "@sinonjs/fake-timers" "^13.0.0" + "@types/node" "*" + jest-message-util "30.2.0" + jest-mock "30.2.0" + jest-util "30.2.0" + +"@jest/get-type@30.1.0": + version "30.1.0" + resolved "https://registry.yarnpkg.com/@jest/get-type/-/get-type-30.1.0.tgz#4fcb4dc2ebcf0811be1c04fd1cb79c2dba431cbc" + integrity sha512-eMbZE2hUnx1WV0pmURZY9XoXPkUYjpc55mb0CrhtdWLtzMQPFvu/rZkTLZFTsdaVQa+Tr4eWAteqcUzoawq/uA== + +"@jest/globals@30.2.0": + version "30.2.0" + resolved "https://registry.yarnpkg.com/@jest/globals/-/globals-30.2.0.tgz#2f4b696d5862664b89c4ee2e49ae24d2bb7e0988" + integrity sha512-b63wmnKPaK+6ZZfpYhz9K61oybvbI1aMcIs80++JI1O1rR1vaxHUCNqo3ITu6NU0d4V34yZFoHMn/uoKr/Rwfw== + dependencies: + "@jest/environment" "30.2.0" + "@jest/expect" "30.2.0" + "@jest/types" "30.2.0" + jest-mock "30.2.0" + +"@jest/pattern@30.0.1": + version "30.0.1" + resolved "https://registry.yarnpkg.com/@jest/pattern/-/pattern-30.0.1.tgz#d5304147f49a052900b4b853dedb111d080e199f" + integrity sha512-gWp7NfQW27LaBQz3TITS8L7ZCQ0TLvtmI//4OwlQRx4rnWxcPNIYjxZpDcN4+UlGxgm3jS5QPz8IPTCkb59wZA== + dependencies: + "@types/node" "*" + jest-regex-util "30.0.1" + +"@jest/reporters@30.2.0": + version "30.2.0" + resolved "https://registry.yarnpkg.com/@jest/reporters/-/reporters-30.2.0.tgz#a36b28fcbaf0c4595250b108e6f20e363348fd91" + integrity sha512-DRyW6baWPqKMa9CzeiBjHwjd8XeAyco2Vt8XbcLFjiwCOEKOvy82GJ8QQnJE9ofsxCMPjH4MfH8fCWIHHDKpAQ== + dependencies: + "@bcoe/v8-coverage" "^0.2.3" + "@jest/console" "30.2.0" + "@jest/test-result" "30.2.0" + "@jest/transform" "30.2.0" + "@jest/types" "30.2.0" + "@jridgewell/trace-mapping" "^0.3.25" + "@types/node" "*" + chalk "^4.1.2" + collect-v8-coverage "^1.0.2" + exit-x "^0.2.2" + glob "^10.3.10" + graceful-fs "^4.2.11" + istanbul-lib-coverage "^3.0.0" + istanbul-lib-instrument "^6.0.0" + istanbul-lib-report "^3.0.0" + istanbul-lib-source-maps "^5.0.0" + istanbul-reports "^3.1.3" + jest-message-util "30.2.0" + jest-util "30.2.0" + jest-worker "30.2.0" + slash "^3.0.0" + string-length "^4.0.2" + v8-to-istanbul "^9.0.1" + +"@jest/schemas@30.0.5": + version "30.0.5" + resolved "https://registry.yarnpkg.com/@jest/schemas/-/schemas-30.0.5.tgz#7bdf69fc5a368a5abdb49fd91036c55225846473" + integrity sha512-DmdYgtezMkh3cpU8/1uyXakv3tJRcmcXxBOcO0tbaozPwpmh4YMsnWrQm9ZmZMfa5ocbxzbFk6O4bDPEc/iAnA== + dependencies: + "@sinclair/typebox" "^0.34.0" + +"@jest/snapshot-utils@30.2.0": + version "30.2.0" + resolved "https://registry.yarnpkg.com/@jest/snapshot-utils/-/snapshot-utils-30.2.0.tgz#387858eb90c2f98f67bff327435a532ac5309fbe" + integrity sha512-0aVxM3RH6DaiLcjj/b0KrIBZhSX1373Xci4l3cW5xiUWPctZ59zQ7jj4rqcJQ/Z8JuN/4wX3FpJSa3RssVvCug== + dependencies: + "@jest/types" "30.2.0" + chalk "^4.1.2" + graceful-fs "^4.2.11" + natural-compare "^1.4.0" + +"@jest/source-map@30.0.1": + version "30.0.1" + resolved "https://registry.yarnpkg.com/@jest/source-map/-/source-map-30.0.1.tgz#305ebec50468f13e658b3d5c26f85107a5620aaa" + integrity sha512-MIRWMUUR3sdbP36oyNyhbThLHyJ2eEDClPCiHVbrYAe5g3CHRArIVpBw7cdSB5fr+ofSfIb2Tnsw8iEHL0PYQg== + dependencies: + "@jridgewell/trace-mapping" "^0.3.25" + callsites "^3.1.0" + graceful-fs "^4.2.11" + +"@jest/test-result@30.2.0": + version "30.2.0" + resolved "https://registry.yarnpkg.com/@jest/test-result/-/test-result-30.2.0.tgz#9c0124377fb7996cdffb86eda3dbc56eacab363d" + integrity sha512-RF+Z+0CCHkARz5HT9mcQCBulb1wgCP3FBvl9VFokMX27acKphwyQsNuWH3c+ojd1LeWBLoTYoxF0zm6S/66mjg== + dependencies: + "@jest/console" "30.2.0" + "@jest/types" "30.2.0" + "@types/istanbul-lib-coverage" "^2.0.6" + collect-v8-coverage "^1.0.2" + +"@jest/test-sequencer@30.2.0": + version "30.2.0" + resolved "https://registry.yarnpkg.com/@jest/test-sequencer/-/test-sequencer-30.2.0.tgz#bf0066bc72e176d58f5dfa7f212b6e7eee44f221" + integrity sha512-wXKgU/lk8fKXMu/l5Hog1R61bL4q5GCdT6OJvdAFz1P+QrpoFuLU68eoKuVc4RbrTtNnTL5FByhWdLgOPSph+Q== + dependencies: + "@jest/test-result" "30.2.0" + graceful-fs "^4.2.11" + jest-haste-map "30.2.0" + slash "^3.0.0" + +"@jest/transform@30.2.0": + version "30.2.0" + resolved "https://registry.yarnpkg.com/@jest/transform/-/transform-30.2.0.tgz#54bef1a4510dcbd58d5d4de4fe2980a63077ef2a" + integrity sha512-XsauDV82o5qXbhalKxD7p4TZYYdwcaEXC77PPD2HixEFF+6YGppjrAAQurTl2ECWcEomHBMMNS9AH3kcCFx8jA== + dependencies: + "@babel/core" "^7.27.4" + "@jest/types" "30.2.0" + "@jridgewell/trace-mapping" "^0.3.25" + babel-plugin-istanbul "^7.0.1" + chalk "^4.1.2" + convert-source-map "^2.0.0" + fast-json-stable-stringify "^2.1.0" + graceful-fs "^4.2.11" + jest-haste-map "30.2.0" + jest-regex-util "30.0.1" + jest-util "30.2.0" + micromatch "^4.0.8" + pirates "^4.0.7" + slash "^3.0.0" + write-file-atomic "^5.0.1" + +"@jest/types@30.2.0", "@jest/types@^30.0.1": + version "30.2.0" + resolved "https://registry.yarnpkg.com/@jest/types/-/types-30.2.0.tgz#1c678a7924b8f59eafd4c77d56b6d0ba976d62b8" + integrity sha512-H9xg1/sfVvyfU7o3zMfBEjQ1gcsdeTMgqHoYdN79tuLqfTtuu7WckRA1R5whDwOzxaZAeMKTYWqP+WCAi0CHsg== + dependencies: + "@jest/pattern" "30.0.1" + "@jest/schemas" "30.0.5" + "@types/istanbul-lib-coverage" "^2.0.6" + "@types/istanbul-reports" "^3.0.4" + "@types/node" "*" + "@types/yargs" "^17.0.33" + chalk "^4.1.2" + +"@joshwooding/vite-plugin-react-docgen-typescript@0.6.1": + version "0.6.1" + resolved "https://registry.yarnpkg.com/@joshwooding/vite-plugin-react-docgen-typescript/-/vite-plugin-react-docgen-typescript-0.6.1.tgz#f630b93ed13d5d07483c0ead42db793053b364a9" + integrity sha512-J4BaTocTOYFkMHIra1JDWrMWpNmBl4EkplIwHEsV8aeUOtdWjwSnln9U7twjMFTAEB7mptNtSKyVi1Y2W9sDJw== + dependencies: + glob "^10.0.0" + magic-string "^0.30.0" + react-docgen-typescript "^2.2.2" + +"@jridgewell/gen-mapping@^0.3.12", "@jridgewell/gen-mapping@^0.3.5": + version "0.3.13" + resolved "https://registry.yarnpkg.com/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz#6342a19f44347518c93e43b1ac69deb3c4656a1f" + integrity sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA== + dependencies: + "@jridgewell/sourcemap-codec" "^1.5.0" + "@jridgewell/trace-mapping" "^0.3.24" + +"@jridgewell/remapping@^2.3.5": + version "2.3.5" + resolved "https://registry.yarnpkg.com/@jridgewell/remapping/-/remapping-2.3.5.tgz#375c476d1972947851ba1e15ae8f123047445aa1" + integrity sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ== + dependencies: + "@jridgewell/gen-mapping" "^0.3.5" + "@jridgewell/trace-mapping" "^0.3.24" + +"@jridgewell/resolve-uri@^3.1.0": + version "3.1.2" + resolved "https://registry.yarnpkg.com/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz#7a0ee601f60f99a20c7c7c5ff0c80388c1189bd6" + integrity sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw== + +"@jridgewell/sourcemap-codec@^1.4.14", "@jridgewell/sourcemap-codec@^1.5.0", "@jridgewell/sourcemap-codec@^1.5.5": + version "1.5.5" + resolved "https://registry.yarnpkg.com/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz#6912b00d2c631c0d15ce1a7ab57cd657f2a8f8ba" + integrity sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og== + +"@jridgewell/trace-mapping@^0.3.12", "@jridgewell/trace-mapping@^0.3.23", "@jridgewell/trace-mapping@^0.3.24", "@jridgewell/trace-mapping@^0.3.25", "@jridgewell/trace-mapping@^0.3.28": + version "0.3.31" + resolved "https://registry.yarnpkg.com/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz#db15d6781c931f3a251a3dac39501c98a6082fd0" + integrity sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw== + dependencies: + "@jridgewell/resolve-uri" "^3.1.0" + "@jridgewell/sourcemap-codec" "^1.4.14" + +"@js-sdsl/ordered-map@^4.4.2": + version "4.4.2" + resolved "https://registry.yarnpkg.com/@js-sdsl/ordered-map/-/ordered-map-4.4.2.tgz#9299f82874bab9e4c7f9c48d865becbfe8d6907c" + integrity sha512-iUKgm52T8HOE/makSxjqoWhe95ZJA1/G1sYsGev2JDKUSS14KAgg1LHb+Ba+IPow0xflbnSkOsZcO08C7w1gYw== + +"@lit-labs/react@^2.0.0": + version "2.1.3" + resolved "https://registry.yarnpkg.com/@lit-labs/react/-/react-2.1.3.tgz#5fca408c435f0b6297fc1314471dc2426206d413" + integrity sha512-OD9h2JynerBQUMNzb563jiVpxfvPF0HjQkKY2mx0lpVYvD7F+rtJpOGz6ek+6ufMidV3i+MPT9SX62OKWHFrQg== + dependencies: + "@lit/react" "^1.0.3" + +"@lit/react@^1.0.3": + version "1.0.8" + resolved "https://registry.yarnpkg.com/@lit/react/-/react-1.0.8.tgz#b3e229173b7b57d550909bf95d8f3da1a9510557" + integrity sha512-p2+YcF+JE67SRX3mMlJ1TKCSTsgyOVdAwd/nxp3NuV1+Cb6MWALbN6nT7Ld4tpmYofcE5kcaSY1YBB9erY+6fw== + +"@mdx-js/react@^3.0.0": + version "3.1.1" + resolved "https://registry.yarnpkg.com/@mdx-js/react/-/react-3.1.1.tgz#24bda7fffceb2fe256f954482123cda1be5f5fef" + integrity sha512-f++rKLQgUVYDAtECQ6fn/is15GkEH9+nZPM3MS0RcxVqoTfawHvDlSCH7JbMhAM6uJ32v3eXLvLmLvjGu7PTQw== + dependencies: + "@types/mdx" "^2.0.0" + +"@microsoft/api-extractor-model@7.31.1": + version "7.31.1" + resolved "https://registry.yarnpkg.com/@microsoft/api-extractor-model/-/api-extractor-model-7.31.1.tgz#b7667c0e4a842121fdbbf226498c5d4070ba3e33" + integrity sha512-Dhnip5OFKbl85rq/ICHBFGhV4RA5UQSl8AC/P/zoGvs+CBudPkatt5kIhMGiYgVPnUWmfR6fcp38+1AFLYNtUw== + dependencies: + "@microsoft/tsdoc" "~0.15.1" + "@microsoft/tsdoc-config" "~0.17.1" + "@rushstack/node-core-library" "5.17.0" + +"@microsoft/api-extractor@^7.50.1": + version "7.53.1" + resolved "https://registry.yarnpkg.com/@microsoft/api-extractor/-/api-extractor-7.53.1.tgz#896cbee189a0a3e9f302d5061a761d0205592e65" + integrity sha512-bul5eTNxijLdDBqLye74u9494sRmf+9QULtec9Od0uHnifahGeNt8CC4/xCdn7mVyEBrXIQyQ5+sc4Uc0QfBSA== + dependencies: + "@microsoft/api-extractor-model" "7.31.1" + "@microsoft/tsdoc" "~0.15.1" + "@microsoft/tsdoc-config" "~0.17.1" + "@rushstack/node-core-library" "5.17.0" + "@rushstack/rig-package" "0.6.0" + "@rushstack/terminal" "0.19.1" + "@rushstack/ts-command-line" "5.1.1" + lodash "~4.17.15" + minimatch "10.0.3" + resolve "~1.22.1" + semver "~7.5.4" + source-map "~0.6.1" + typescript "5.8.2" + +"@microsoft/tsdoc-config@~0.17.1": + version "0.17.1" + resolved "https://registry.yarnpkg.com/@microsoft/tsdoc-config/-/tsdoc-config-0.17.1.tgz#e0f0b50628f4ad7fe121ca616beacfe6a25b9335" + integrity sha512-UtjIFe0C6oYgTnad4q1QP4qXwLhe6tIpNTRStJ2RZEPIkqQPREAwE5spzVxsdn9UaEMUqhh0AqSx3X4nWAKXWw== + dependencies: + "@microsoft/tsdoc" "0.15.1" + ajv "~8.12.0" + jju "~1.4.0" + resolve "~1.22.2" + +"@microsoft/tsdoc@0.15.1", "@microsoft/tsdoc@~0.15.1": + version "0.15.1" + resolved "https://registry.yarnpkg.com/@microsoft/tsdoc/-/tsdoc-0.15.1.tgz#d4f6937353bc4568292654efb0a0e0532adbcba2" + integrity sha512-4aErSrCR/On/e5G2hDP0wjooqDdauzEbIq8hIkIe5pXV0rtWJZvdCEKL0ykZxex+IxIwBp0eGeV48hQN07dXtw== + +"@napi-rs/wasm-runtime@^0.2.11": + version "0.2.12" + resolved "https://registry.yarnpkg.com/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.12.tgz#3e78a8b96e6c33a6c517e1894efbd5385a7cb6f2" + integrity sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ== + dependencies: + "@emnapi/core" "^1.4.3" + "@emnapi/runtime" "^1.4.3" + "@tybys/wasm-util" "^0.10.0" + +"@nodelib/fs.scandir@2.1.5": + version "2.1.5" + resolved "https://registry.yarnpkg.com/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz#7619c2eb21b25483f6d167548b4cfd5a7488c3d5" + integrity sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g== + dependencies: + "@nodelib/fs.stat" "2.0.5" + run-parallel "^1.1.9" + +"@nodelib/fs.stat@2.0.5": + version "2.0.5" + resolved "https://registry.yarnpkg.com/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz#5bd262af94e9d25bd1e71b05deed44876a222e8b" + integrity sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A== + +"@nodelib/fs.walk@^1.2.8": + version "1.2.8" + resolved "https://registry.yarnpkg.com/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz#e95737e8bb6746ddedf69c556953494f196fe69a" + integrity sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg== + dependencies: + "@nodelib/fs.scandir" "2.1.5" + fastq "^1.6.0" + +"@pkgjs/parseargs@^0.11.0": + version "0.11.0" + resolved "https://registry.yarnpkg.com/@pkgjs/parseargs/-/parseargs-0.11.0.tgz#a77ea742fab25775145434eb1d2328cf5013ac33" + integrity sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg== + +"@pkgr/core@^0.2.9": + version "0.2.9" + resolved "https://registry.yarnpkg.com/@pkgr/core/-/core-0.2.9.tgz#d229a7b7f9dac167a156992ef23c7f023653f53b" + integrity sha512-QNqXyfVS2wm9hweSYD2O7F0G06uurj9kZ96TRQE5Y9hU7+tgdZwIkbAKc5Ocy1HxEY2kuDQa6cQ1WRs/O5LFKA== + +"@playwright/test@1.57.0": + version "1.57.0" + resolved "https://registry.yarnpkg.com/@playwright/test/-/test-1.57.0.tgz#a14720ffa9ed7ef7edbc1f60784fc6134acbb003" + integrity sha512-6TyEnHgd6SArQO8UO2OMTxshln3QMWBtPGrOCgs3wVEmQmwyuNtB10IZMfmYDE0riwNR1cu4q+pPcxMVtaG3TA== + dependencies: + playwright "1.57.0" + +"@protobufjs/aspromise@^1.1.1", "@protobufjs/aspromise@^1.1.2": + version "1.1.2" + resolved "https://registry.yarnpkg.com/@protobufjs/aspromise/-/aspromise-1.1.2.tgz#9b8b0cc663d669a7d8f6f5d0893a14d348f30fbf" + integrity sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ== + +"@protobufjs/base64@^1.1.2": + version "1.1.2" + resolved "https://registry.yarnpkg.com/@protobufjs/base64/-/base64-1.1.2.tgz#4c85730e59b9a1f1f349047dbf24296034bb2735" + integrity sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg== + +"@protobufjs/codegen@^2.0.4": + version "2.0.4" + resolved "https://registry.yarnpkg.com/@protobufjs/codegen/-/codegen-2.0.4.tgz#7ef37f0d010fb028ad1ad59722e506d9262815cb" + integrity sha512-YyFaikqM5sH0ziFZCN3xDC7zeGaB/d0IUb9CATugHWbd1FRFwWwt4ld4OYMPWu5a3Xe01mGAULCdqhMlPl29Jg== + +"@protobufjs/eventemitter@^1.1.0": + version "1.1.0" + resolved "https://registry.yarnpkg.com/@protobufjs/eventemitter/-/eventemitter-1.1.0.tgz#355cbc98bafad5978f9ed095f397621f1d066b70" + integrity sha512-j9ednRT81vYJ9OfVuXG6ERSTdEL1xVsNgqpkxMsbIabzSo3goCjDIveeGv5d03om39ML71RdmrGNjG5SReBP/Q== + +"@protobufjs/fetch@^1.1.0": + version "1.1.0" + resolved "https://registry.yarnpkg.com/@protobufjs/fetch/-/fetch-1.1.0.tgz#ba99fb598614af65700c1619ff06d454b0d84c45" + integrity sha512-lljVXpqXebpsijW71PZaCYeIcE5on1w5DlQy5WH6GLbFryLUrBD4932W/E2BSpfRJWseIL4v/KPgBFxDOIdKpQ== + dependencies: + "@protobufjs/aspromise" "^1.1.1" + "@protobufjs/inquire" "^1.1.0" + +"@protobufjs/float@^1.0.2": + version "1.0.2" + resolved "https://registry.yarnpkg.com/@protobufjs/float/-/float-1.0.2.tgz#5e9e1abdcb73fc0a7cb8b291df78c8cbd97b87d1" + integrity sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ== + +"@protobufjs/inquire@^1.1.0": + version "1.1.0" + resolved "https://registry.yarnpkg.com/@protobufjs/inquire/-/inquire-1.1.0.tgz#ff200e3e7cf2429e2dcafc1140828e8cc638f089" + integrity sha512-kdSefcPdruJiFMVSbn801t4vFK7KB/5gd2fYvrxhuJYg8ILrmn9SKSX2tZdV6V+ksulWqS7aXjBcRXl3wHoD9Q== + +"@protobufjs/path@^1.1.2": + version "1.1.2" + resolved "https://registry.yarnpkg.com/@protobufjs/path/-/path-1.1.2.tgz#6cc2b20c5c9ad6ad0dccfd21ca7673d8d7fbf68d" + integrity sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA== + +"@protobufjs/pool@^1.1.0": + version "1.1.0" + resolved "https://registry.yarnpkg.com/@protobufjs/pool/-/pool-1.1.0.tgz#09fd15f2d6d3abfa9b65bc366506d6ad7846ff54" + integrity sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw== + +"@protobufjs/utf8@^1.1.0": + version "1.1.0" + resolved "https://registry.yarnpkg.com/@protobufjs/utf8/-/utf8-1.1.0.tgz#a777360b5b39a1a2e5106f8e858f2fd2d060c570" + integrity sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw== + +"@rollup/plugin-inject@^5.0.5": + version "5.0.5" + resolved "https://registry.yarnpkg.com/@rollup/plugin-inject/-/plugin-inject-5.0.5.tgz#616f3a73fe075765f91c5bec90176608bed277a3" + integrity sha512-2+DEJbNBoPROPkgTDNe8/1YXWcqxbN5DTjASVIOx8HS+pITXushyNiBV56RB08zuptzz8gT3YfkqriTBVycepg== + dependencies: + "@rollup/pluginutils" "^5.0.1" + estree-walker "^2.0.2" + magic-string "^0.30.3" + +"@rollup/pluginutils@^5.0.1", "@rollup/pluginutils@^5.0.2", "@rollup/pluginutils@^5.1.4": + version "5.3.0" + resolved "https://registry.yarnpkg.com/@rollup/pluginutils/-/pluginutils-5.3.0.tgz#57ba1b0cbda8e7a3c597a4853c807b156e21a7b4" + integrity sha512-5EdhGZtnu3V88ces7s53hhfK5KSASnJZv8Lulpc04cWO3REESroJXg73DFsOmgbU2BhwV0E20bu2IDZb3VKW4Q== + dependencies: + "@types/estree" "^1.0.0" + estree-walker "^2.0.2" + picomatch "^4.0.2" + +"@rollup/rollup-android-arm-eabi@4.53.3": + version "4.53.3" + resolved "https://registry.yarnpkg.com/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.53.3.tgz#7e478b66180c5330429dd161bf84dad66b59c8eb" + integrity sha512-mRSi+4cBjrRLoaal2PnqH82Wqyb+d3HsPUN/W+WslCXsZsyHa9ZeQQX/pQsZaVIWDkPcpV6jJ+3KLbTbgnwv8w== + +"@rollup/rollup-android-arm64@4.53.3": + version "4.53.3" + resolved "https://registry.yarnpkg.com/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.53.3.tgz#2b025510c53a5e3962d3edade91fba9368c9d71c" + integrity sha512-CbDGaMpdE9sh7sCmTrTUyllhrg65t6SwhjlMJsLr+J8YjFuPmCEjbBSx4Z/e4SmDyH3aB5hGaJUP2ltV/vcs4w== + +"@rollup/rollup-darwin-arm64@4.53.3": + version "4.53.3" + resolved "https://registry.yarnpkg.com/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.53.3.tgz#3577c38af68ccf34c03e84f476bfd526abca10a0" + integrity sha512-Nr7SlQeqIBpOV6BHHGZgYBuSdanCXuw09hon14MGOLGmXAFYjx1wNvquVPmpZnl0tLjg25dEdr4IQ6GgyToCUA== + +"@rollup/rollup-darwin-x64@4.53.3": + version "4.53.3" + resolved "https://registry.yarnpkg.com/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.53.3.tgz#2bf5f2520a1f3b551723d274b9669ba5b75ed69c" + integrity sha512-DZ8N4CSNfl965CmPktJ8oBnfYr3F8dTTNBQkRlffnUarJ2ohudQD17sZBa097J8xhQ26AwhHJ5mvUyQW8ddTsQ== + +"@rollup/rollup-freebsd-arm64@4.53.3": + version "4.53.3" + resolved "https://registry.yarnpkg.com/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.53.3.tgz#4bb9cc80252564c158efc0710153c71633f1927c" + integrity sha512-yMTrCrK92aGyi7GuDNtGn2sNW+Gdb4vErx4t3Gv/Tr+1zRb8ax4z8GWVRfr3Jw8zJWvpGHNpss3vVlbF58DZ4w== + +"@rollup/rollup-freebsd-x64@4.53.3": + version "4.53.3" + resolved "https://registry.yarnpkg.com/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.53.3.tgz#2301289094d49415a380cf942219ae9d8b127440" + integrity sha512-lMfF8X7QhdQzseM6XaX0vbno2m3hlyZFhwcndRMw8fbAGUGL3WFMBdK0hbUBIUYcEcMhVLr1SIamDeuLBnXS+Q== + +"@rollup/rollup-linux-arm-gnueabihf@4.53.3": + version "4.53.3" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.53.3.tgz#1d03d776f2065e09fc141df7d143476e94acca88" + integrity sha512-k9oD15soC/Ln6d2Wv/JOFPzZXIAIFLp6B+i14KhxAfnq76ajt0EhYc5YPeX6W1xJkAdItcVT+JhKl1QZh44/qw== + +"@rollup/rollup-linux-arm-musleabihf@4.53.3": + version "4.53.3" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.53.3.tgz#8623de0e040b2fd52a541c602688228f51f96701" + integrity sha512-vTNlKq+N6CK/8UktsrFuc+/7NlEYVxgaEgRXVUVK258Z5ymho29skzW1sutgYjqNnquGwVUObAaxae8rZ6YMhg== + +"@rollup/rollup-linux-arm64-gnu@4.53.3": + version "4.53.3" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.53.3.tgz#ce2d1999bc166277935dde0301cde3dd0417fb6e" + integrity sha512-RGrFLWgMhSxRs/EWJMIFM1O5Mzuz3Xy3/mnxJp/5cVhZ2XoCAxJnmNsEyeMJtpK+wu0FJFWz+QF4mjCA7AUQ3w== + +"@rollup/rollup-linux-arm64-musl@4.53.3": + version "4.53.3" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.53.3.tgz#88c2523778444da952651a2219026416564a4899" + integrity sha512-kASyvfBEWYPEwe0Qv4nfu6pNkITLTb32p4yTgzFCocHnJLAHs+9LjUu9ONIhvfT/5lv4YS5muBHyuV84epBo/A== + +"@rollup/rollup-linux-loong64-gnu@4.53.3": + version "4.53.3" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.53.3.tgz#578ca2220a200ac4226c536c10c8cc6e4f276714" + integrity sha512-JiuKcp2teLJwQ7vkJ95EwESWkNRFJD7TQgYmCnrPtlu50b4XvT5MOmurWNrCj3IFdyjBQ5p9vnrX4JM6I8OE7g== + +"@rollup/rollup-linux-ppc64-gnu@4.53.3": + version "4.53.3" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.53.3.tgz#aa338d3effd4168a20a5023834a74ba2c3081293" + integrity sha512-EoGSa8nd6d3T7zLuqdojxC20oBfNT8nexBbB/rkxgKj5T5vhpAQKKnD+h3UkoMuTyXkP5jTjK/ccNRmQrPNDuw== + +"@rollup/rollup-linux-riscv64-gnu@4.53.3": + version "4.53.3" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.53.3.tgz#16ba582f9f6cff58119aa242782209b1557a1508" + integrity sha512-4s+Wped2IHXHPnAEbIB0YWBv7SDohqxobiiPA1FIWZpX+w9o2i4LezzH/NkFUl8LRci/8udci6cLq+jJQlh+0g== + +"@rollup/rollup-linux-riscv64-musl@4.53.3": + version "4.53.3" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.53.3.tgz#e404a77ebd6378483888b8064c703adb011340ab" + integrity sha512-68k2g7+0vs2u9CxDt5ktXTngsxOQkSEV/xBbwlqYcUrAVh6P9EgMZvFsnHy4SEiUl46Xf0IObWVbMvPrr2gw8A== + +"@rollup/rollup-linux-s390x-gnu@4.53.3": + version "4.53.3" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.53.3.tgz#92ad52d306227c56bec43d96ad2164495437ffe6" + integrity sha512-VYsFMpULAz87ZW6BVYw3I6sWesGpsP9OPcyKe8ofdg9LHxSbRMd7zrVrr5xi/3kMZtpWL/wC+UIJWJYVX5uTKg== + +"@rollup/rollup-linux-x64-gnu@4.53.3": + version "4.53.3" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.53.3.tgz#fd0dea3bb9aa07e7083579f25e1c2285a46cb9fa" + integrity sha512-3EhFi1FU6YL8HTUJZ51imGJWEX//ajQPfqWLI3BQq4TlvHy4X0MOr5q3D2Zof/ka0d5FNdPwZXm3Yyib/UEd+w== + +"@rollup/rollup-linux-x64-musl@4.53.3": + version "4.53.3" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.53.3.tgz#37a3efb09f18d555f8afc490e1f0444885de8951" + integrity sha512-eoROhjcc6HbZCJr+tvVT8X4fW3/5g/WkGvvmwz/88sDtSJzO7r/blvoBDgISDiCjDRZmHpwud7h+6Q9JxFwq1Q== + +"@rollup/rollup-openharmony-arm64@4.53.3": + version "4.53.3" + resolved "https://registry.yarnpkg.com/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.53.3.tgz#c489bec9f4f8320d42c9b324cca220c90091c1f7" + integrity sha512-OueLAWgrNSPGAdUdIjSWXw+u/02BRTcnfw9PN41D2vq/JSEPnJnVuBgw18VkN8wcd4fjUs+jFHVM4t9+kBSNLw== + +"@rollup/rollup-win32-arm64-msvc@4.53.3": + version "4.53.3" + resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.53.3.tgz#152832b5f79dc22d1606fac3db946283601b7080" + integrity sha512-GOFuKpsxR/whszbF/bzydebLiXIHSgsEUp6M0JI8dWvi+fFa1TD6YQa4aSZHtpmh2/uAlj/Dy+nmby3TJ3pkTw== + +"@rollup/rollup-win32-ia32-msvc@4.53.3": + version "4.53.3" + resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.53.3.tgz#54d91b2bb3bf3e9f30d32b72065a4e52b3a172a5" + integrity sha512-iah+THLcBJdpfZ1TstDFbKNznlzoxa8fmnFYK4V67HvmuNYkVdAywJSoteUszvBQ9/HqN2+9AZghbajMsFT+oA== + +"@rollup/rollup-win32-x64-gnu@4.53.3": + version "4.53.3" + resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.53.3.tgz#df9df03e61a003873efec8decd2034e7f135c71e" + integrity sha512-J9QDiOIZlZLdcot5NXEepDkstocktoVjkaKUtqzgzpt2yWjGlbYiKyp05rWwk4nypbYUNoFAztEgixoLaSETkg== + +"@rollup/rollup-win32-x64-msvc@4.53.3": + version "4.53.3" + resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.53.3.tgz#38ae84f4c04226c1d56a3b17296ef1e0460ecdfe" + integrity sha512-UhTd8u31dXadv0MopwGgNOBpUVROFKWVQgAg5N1ESyCz8AuBcMqm4AuTjrwgQKGDfoFuz02EuMRHQIw/frmYKQ== + +"@rushstack/node-core-library@5.17.0": + version "5.17.0" + resolved "https://registry.yarnpkg.com/@rushstack/node-core-library/-/node-core-library-5.17.0.tgz#30b9c7b1fe01f79e6448d0433d163ef4d3b290c9" + integrity sha512-24vt1GbHN6kyIglRMTVpyEiNRRRJK8uZHc1XoGAhmnTDKnrWet8OmOpImMswJIe6gM78eV8cMg1HXwuUHkSSgg== + dependencies: + ajv "~8.13.0" + ajv-draft-04 "~1.0.0" + ajv-formats "~3.0.1" + fs-extra "~11.3.0" + import-lazy "~4.0.0" + jju "~1.4.0" + resolve "~1.22.1" + semver "~7.5.4" + +"@rushstack/problem-matcher@0.1.1": + version "0.1.1" + resolved "https://registry.yarnpkg.com/@rushstack/problem-matcher/-/problem-matcher-0.1.1.tgz#db9303ef3c47010c8aba5841e8c9511e091159df" + integrity sha512-Fm5XtS7+G8HLcJHCWpES5VmeMyjAKaWeyZU5qPzZC+22mPlJzAsOxymHiWIfuirtPckX3aptWws+K2d0BzniJA== + +"@rushstack/rig-package@0.6.0": + version "0.6.0" + resolved "https://registry.yarnpkg.com/@rushstack/rig-package/-/rig-package-0.6.0.tgz#c80f93fe2c0d9d4977fc925ed9ce9decb75047a5" + integrity sha512-ZQmfzsLE2+Y91GF15c65L/slMRVhF6Hycq04D4TwtdGaUAbIXXg9c5pKA5KFU7M4QMaihoobp9JJYpYcaY3zOw== + dependencies: + resolve "~1.22.1" + strip-json-comments "~3.1.1" + +"@rushstack/terminal@0.19.1": + version "0.19.1" + resolved "https://registry.yarnpkg.com/@rushstack/terminal/-/terminal-0.19.1.tgz#56969818ee6511b4607a230db343cf0602c7389e" + integrity sha512-jsBuSad67IDVMO2yp0hDfs0OdE4z3mDIjIL2pclDT3aEJboeZXE85e1HjuD0F6JoW3XgHvDwoX+WOV+AVTDQeA== + dependencies: + "@rushstack/node-core-library" "5.17.0" + "@rushstack/problem-matcher" "0.1.1" + supports-color "~8.1.1" + +"@rushstack/ts-command-line@5.1.1": + version "5.1.1" + resolved "https://registry.yarnpkg.com/@rushstack/ts-command-line/-/ts-command-line-5.1.1.tgz#b7804bbe425e3baf8edf60bbd446db7ba13f3269" + integrity sha512-HPzFsUcr+wZ3oQI08Ec/E6cuiAVHKzrXZGHhwiwIGygAFiqN5QzX+ff30n70NU2WyE26CykgMwBZZSSyHCJrzA== + dependencies: + "@rushstack/terminal" "0.19.1" + "@types/argparse" "1.0.38" + argparse "~1.0.9" + string-argv "~0.3.1" + +"@sideway/address@^4.1.5": + version "4.1.5" + resolved "https://registry.yarnpkg.com/@sideway/address/-/address-4.1.5.tgz#4bc149a0076623ced99ca8208ba780d65a99b9d5" + integrity sha512-IqO/DUQHUkPeixNQ8n0JA6102hT9CmaljNTPmQ1u8MEhBo/R4Q8eKLN/vGZxuebwOroDB4cbpjheD4+/sKFK4Q== + dependencies: + "@hapi/hoek" "^9.0.0" + +"@sideway/formula@^3.0.1": + version "3.0.1" + resolved "https://registry.yarnpkg.com/@sideway/formula/-/formula-3.0.1.tgz#80fcbcbaf7ce031e0ef2dd29b1bfc7c3f583611f" + integrity sha512-/poHZJJVjx3L+zVD6g9KgHfYnb443oi7wLu/XKojDviHy6HOEOA6z1Trk5aR1dGcmPenJEgb2sK2I80LeS3MIg== + +"@sideway/pinpoint@^2.0.0": + version "2.0.0" + resolved "https://registry.yarnpkg.com/@sideway/pinpoint/-/pinpoint-2.0.0.tgz#cff8ffadc372ad29fd3f78277aeb29e632cc70df" + integrity sha512-RNiOoTPkptFtSVzQevY/yWtZwf/RxyVnPy/OcA9HBM3MlGDnBEYL5B41H0MTn0Uec8Hi+2qUtTfG2WWZBmMejQ== + +"@sinclair/typebox@^0.34.0": + version "0.34.41" + resolved "https://registry.yarnpkg.com/@sinclair/typebox/-/typebox-0.34.41.tgz#aa51a6c1946df2c5a11494a2cdb9318e026db16c" + integrity sha512-6gS8pZzSXdyRHTIqoqSVknxolr1kzfy4/CeDnrzsVz8TTIWUbOBr6gnzOmTYJ3eXQNh4IYHIGi5aIL7sOZ2G/g== + +"@sinonjs/commons@^3.0.1": + version "3.0.1" + resolved "https://registry.yarnpkg.com/@sinonjs/commons/-/commons-3.0.1.tgz#1029357e44ca901a615585f6d27738dbc89084cd" + integrity sha512-K3mCHKQ9sVh8o1C9cxkwxaOmXoAMlDxC1mYyHrjqOWEcBjYr76t96zL2zlj5dUGZ3HSw240X1qgH3Mjf1yJWpQ== + dependencies: + type-detect "4.0.8" + +"@sinonjs/fake-timers@^13.0.0": + version "13.0.5" + resolved "https://registry.yarnpkg.com/@sinonjs/fake-timers/-/fake-timers-13.0.5.tgz#36b9dbc21ad5546486ea9173d6bea063eb1717d5" + integrity sha512-36/hTbH2uaWuGVERyC6da9YwGWnzUZXuPro/F2LfsdOsLnCojz/iSH8MxUt/FD2S5XBSVPhmArFUXcpCQ2Hkiw== + dependencies: + "@sinonjs/commons" "^3.0.1" + +"@storybook/addon-a11y@^10.0.7": + version "10.1.5" + resolved "https://registry.yarnpkg.com/@storybook/addon-a11y/-/addon-a11y-10.1.5.tgz#9fc95df597243d0617a4d6f36d4534f949474988" + integrity sha512-dMUrkuQyvDfD6SdvV7F7cbjRrhHN0kqCNhRfg1i1IJuLuck6kiALpx8176KhWBcAkN/0J/1V75n7+F9YU/JlPA== + dependencies: + "@storybook/global" "^5.0.0" + axe-core "^4.2.0" + +"@storybook/addon-designs@^11.0.1": + version "11.1.0" + resolved "https://registry.yarnpkg.com/@storybook/addon-designs/-/addon-designs-11.1.0.tgz#c5d363d43b386a4fd6d9c1745b13e95a89a08761" + integrity sha512-i9lnUJ9x+UwThUpIjgg7QWvadhwmQ1ZuqcrTFe12giqyyYJKM6hdrUEuxGgSOrz3pkmDV/Bypq3G5ehwIDdKiw== + dependencies: + "@figspec/react" "^2.0.0" + +"@storybook/addon-docs@^10.0.7": + version "10.1.5" + resolved "https://registry.yarnpkg.com/@storybook/addon-docs/-/addon-docs-10.1.5.tgz#52bce4eed90240fc2b133caec1131962697c865a" + integrity sha512-2FfqFrfEeaKv8OerZCWt1b+dm7N/nizv1G2CnTZfWJ0TKxbPDH6kffAqC9lMnT3xAZjDWiBLdnVx2oouKdmSvw== + dependencies: + "@mdx-js/react" "^3.0.0" + "@storybook/csf-plugin" "10.1.5" + "@storybook/icons" "^2.0.0" + "@storybook/react-dom-shim" "10.1.5" + react "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + react-dom "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + ts-dedent "^2.0.0" + +"@storybook/builder-vite@10.1.5": + version "10.1.5" + resolved "https://registry.yarnpkg.com/@storybook/builder-vite/-/builder-vite-10.1.5.tgz#27b11a0fb583e342cd041ea1eded7d012a0c278a" + integrity sha512-5alpNa+TQXK1zp9MeovUK/yIUkZqpIFUScUer6cYgidI96Boovn7OXt5oXQ8CqqpzuEtgCvz44TzCmgZoGv41g== + dependencies: + "@storybook/csf-plugin" "10.1.5" + "@vitest/mocker" "3.2.4" + ts-dedent "^2.0.0" + +"@storybook/csf-plugin@10.1.5": + version "10.1.5" + resolved "https://registry.yarnpkg.com/@storybook/csf-plugin/-/csf-plugin-10.1.5.tgz#8d147fa82c19e348991bbad88c1af8ef56a9d2a3" + integrity sha512-v+D7PVRkNUHznfoQg8yqpLWZIIbPddqHDSi1oBGdegF0Kv/lVsGqTZGRLroApsMu7BLwLhpcMID6ofxlfftWKg== + dependencies: + unplugin "^2.3.5" + +"@storybook/global@^5.0.0": + version "5.0.0" + resolved "https://registry.yarnpkg.com/@storybook/global/-/global-5.0.0.tgz#b793d34b94f572c1d7d9e0f44fac4e0dbc9572ed" + integrity sha512-FcOqPAXACP0I3oJ/ws6/rrPT9WGhu915Cg8D02a9YxLo0DE9zI+a9A5gRGvmQ09fiWPukqI8ZAEoQEdWUKMQdQ== + +"@storybook/icons@^2.0.0": + version "2.0.1" + resolved "https://registry.yarnpkg.com/@storybook/icons/-/icons-2.0.1.tgz#1bd351db1d33bfccbbafa7b64fb413168f1a6616" + integrity sha512-/smVjw88yK3CKsiuR71vNgWQ9+NuY2L+e8X7IMrFjexjm6ZR8ULrV2DRkTA61aV6ryefslzHEGDInGpnNeIocg== + +"@storybook/react-dom-shim@10.1.5": + version "10.1.5" + resolved "https://registry.yarnpkg.com/@storybook/react-dom-shim/-/react-dom-shim-10.1.5.tgz#e39c169b093594de88dcb0c59b797ec23028ef0c" + integrity sha512-CsXcq26wINUgYP8KnfSuS60B10/Ag34YdcnWIEl9hM5UtTQ65WYJ9fVFqpzfnQrkpgRMd7iQjtmUhCe+4umnHg== + +"@storybook/react-vite@^10.0.7": + version "10.1.5" + resolved "https://registry.yarnpkg.com/@storybook/react-vite/-/react-vite-10.1.5.tgz#9da7bbb0b860526da51fa3d50993934b593588b3" + integrity sha512-27RiCVw5QZ/f9fXS8sGaPHuWkbHSoS66ifeakxHgbkbIXjVI4M6pWB7NUj49MwU1YUMOpB0T8KasvyMZzv/UPA== + dependencies: + "@joshwooding/vite-plugin-react-docgen-typescript" "0.6.1" + "@rollup/pluginutils" "^5.0.2" + "@storybook/builder-vite" "10.1.5" + "@storybook/react" "10.1.5" + empathic "^2.0.0" + magic-string "^0.30.0" + react-docgen "^8.0.0" + resolve "^1.22.8" + tsconfig-paths "^4.2.0" + +"@storybook/react@10.1.5": + version "10.1.5" + resolved "https://registry.yarnpkg.com/@storybook/react/-/react-10.1.5.tgz#0f6a1ec5c4338d3604d6e957d0f53f7ddf9a2e6a" + integrity sha512-M8fR7WVs79fPJHwRZxkz4XzIfzs/bN0heWdZX0D4iRjeIcY4nLM/tyalCcQDrGgrSJbgAAf4xd7KXaZzaZSAqA== + dependencies: + "@storybook/global" "^5.0.0" + "@storybook/react-dom-shim" "10.1.5" + react-docgen "^8.0.2" + +"@storybook/test-runner@^0.24.1": + version "0.24.2" + resolved "https://registry.yarnpkg.com/@storybook/test-runner/-/test-runner-0.24.2.tgz#59deba0619f7b896f92e186ac108244014859716" + integrity sha512-76DbflDTGAKq8Af6uHbWTGnKzKHhjLbJaZXRFhVnKqFocoXcej58C9DpM0BJ3addu7fSDJmPwfR97OINg16XFQ== + dependencies: + "@babel/core" "^7.22.5" + "@babel/generator" "^7.22.5" + "@babel/template" "^7.22.5" + "@babel/types" "^7.22.5" + "@jest/types" "^30.0.1" + "@swc/core" "^1.5.22" + "@swc/jest" "^0.2.38" + expect-playwright "^0.8.0" + jest "^30.0.4" + jest-circus "^30.0.4" + jest-environment-node "^30.0.4" + jest-junit "^16.0.0" + jest-process-manager "^0.4.0" + jest-runner "^30.0.4" + jest-serializer-html "^7.1.0" + jest-watch-typeahead "^3.0.1" + nyc "^15.1.0" + playwright "^1.14.0" + playwright-core ">=1.2.0" + rimraf "^3.0.2" + uuid "^8.3.2" + +"@swc/core-darwin-arm64@1.15.3": + version "1.15.3" + resolved "https://registry.yarnpkg.com/@swc/core-darwin-arm64/-/core-darwin-arm64-1.15.3.tgz#bd0bd3ab7730e3ffa64cf200c0ed7c572cbaba97" + integrity sha512-AXfeQn0CvcQ4cndlIshETx6jrAM45oeUrK8YeEY6oUZU/qzz0Id0CyvlEywxkWVC81Ajpd8TQQ1fW5yx6zQWkQ== + +"@swc/core-darwin-x64@1.15.3": + version "1.15.3" + resolved "https://registry.yarnpkg.com/@swc/core-darwin-x64/-/core-darwin-x64-1.15.3.tgz#502b1e1c680df6b962265ca81a0c1a23e6ff070f" + integrity sha512-p68OeCz1ui+MZYG4wmfJGvcsAcFYb6Sl25H9TxWl+GkBgmNimIiRdnypK9nBGlqMZAcxngNPtnG3kEMNnvoJ2A== + +"@swc/core-linux-arm-gnueabihf@1.15.3": + version "1.15.3" + resolved "https://registry.yarnpkg.com/@swc/core-linux-arm-gnueabihf/-/core-linux-arm-gnueabihf-1.15.3.tgz#e32cc6a2e06a75060d6f598ba2ca6f96c5c0cc43" + integrity sha512-Nuj5iF4JteFgwrai97mUX+xUOl+rQRHqTvnvHMATL/l9xE6/TJfPBpd3hk/PVpClMXG3Uvk1MxUFOEzM1JrMYg== + +"@swc/core-linux-arm64-gnu@1.15.3": + version "1.15.3" + resolved "https://registry.yarnpkg.com/@swc/core-linux-arm64-gnu/-/core-linux-arm64-gnu-1.15.3.tgz#9b9861bc44059e393d4baf98b3cd3d6c4ea6f521" + integrity sha512-2Nc/s8jE6mW2EjXWxO/lyQuLKShcmTrym2LRf5Ayp3ICEMX6HwFqB1EzDhwoMa2DcUgmnZIalesq2lG3krrUNw== + +"@swc/core-linux-arm64-musl@1.15.3": + version "1.15.3" + resolved "https://registry.yarnpkg.com/@swc/core-linux-arm64-musl/-/core-linux-arm64-musl-1.15.3.tgz#f6388743e5a159018bd468e8f710940b2614384b" + integrity sha512-j4SJniZ/qaZ5g8op+p1G9K1z22s/EYGg1UXIb3+Cg4nsxEpF5uSIGEE4mHUfA70L0BR9wKT2QF/zv3vkhfpX4g== + +"@swc/core-linux-x64-gnu@1.15.3": + version "1.15.3" + resolved "https://registry.yarnpkg.com/@swc/core-linux-x64-gnu/-/core-linux-x64-gnu-1.15.3.tgz#15fea551c7a3aeb1bdc3ad5c652d73c9321ddba8" + integrity sha512-aKttAZnz8YB1VJwPQZtyU8Uk0BfMP63iDMkvjhJzRZVgySmqt/apWSdnoIcZlUoGheBrcqbMC17GGUmur7OT5A== + +"@swc/core-linux-x64-musl@1.15.3": + version "1.15.3" + resolved "https://registry.yarnpkg.com/@swc/core-linux-x64-musl/-/core-linux-x64-musl-1.15.3.tgz#d3f17bab4ffcadbb47f135e6a14d6f3e401af289" + integrity sha512-oe8FctPu1gnUsdtGJRO2rvOUIkkIIaHqsO9xxN0bTR7dFTlPTGi2Fhk1tnvXeyAvCPxLIcwD8phzKg6wLv9yug== + +"@swc/core-win32-arm64-msvc@1.15.3": + version "1.15.3" + resolved "https://registry.yarnpkg.com/@swc/core-win32-arm64-msvc/-/core-win32-arm64-msvc-1.15.3.tgz#9da386df7fed00b3473bcf4281ff3fcd14726d2c" + integrity sha512-L9AjzP2ZQ/Xh58e0lTRMLvEDrcJpR7GwZqAtIeNLcTK7JVE+QineSyHp0kLkO1rttCHyCy0U74kDTj0dRz6raA== + +"@swc/core-win32-ia32-msvc@1.15.3": + version "1.15.3" + resolved "https://registry.yarnpkg.com/@swc/core-win32-ia32-msvc/-/core-win32-ia32-msvc-1.15.3.tgz#c398d4f0f10ffec2151a79733ee1ce86a945a1ea" + integrity sha512-B8UtogMzErUPDWUoKONSVBdsgKYd58rRyv2sHJWKOIMCHfZ22FVXICR4O/VwIYtlnZ7ahERcjayBHDlBZpR0aw== + +"@swc/core-win32-x64-msvc@1.15.3": + version "1.15.3" + resolved "https://registry.yarnpkg.com/@swc/core-win32-x64-msvc/-/core-win32-x64-msvc-1.15.3.tgz#715596b034a654c82b03ef734a9b44c29bcd3a68" + integrity sha512-SpZKMR9QBTecHeqpzJdYEfgw30Oo8b/Xl6rjSzBt1g0ZsXyy60KLXrp6IagQyfTYqNYE/caDvwtF2FPn7pomog== + +"@swc/core@^1.5.22": + version "1.15.3" + resolved "https://registry.yarnpkg.com/@swc/core/-/core-1.15.3.tgz#2d0a5c4ac4c180c3dbf2f6d5d958b9fcbaa9755f" + integrity sha512-Qd8eBPkUFL4eAONgGjycZXj1jFCBW8Fd+xF0PzdTlBCWQIV1xnUT7B93wUANtW3KGjl3TRcOyxwSx/u/jyKw/Q== + dependencies: + "@swc/counter" "^0.1.3" + "@swc/types" "^0.1.25" + optionalDependencies: + "@swc/core-darwin-arm64" "1.15.3" + "@swc/core-darwin-x64" "1.15.3" + "@swc/core-linux-arm-gnueabihf" "1.15.3" + "@swc/core-linux-arm64-gnu" "1.15.3" + "@swc/core-linux-arm64-musl" "1.15.3" + "@swc/core-linux-x64-gnu" "1.15.3" + "@swc/core-linux-x64-musl" "1.15.3" + "@swc/core-win32-arm64-msvc" "1.15.3" + "@swc/core-win32-ia32-msvc" "1.15.3" + "@swc/core-win32-x64-msvc" "1.15.3" + +"@swc/counter@^0.1.3": + version "0.1.3" + resolved "https://registry.yarnpkg.com/@swc/counter/-/counter-0.1.3.tgz#cc7463bd02949611c6329596fccd2b0ec782b0e9" + integrity sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ== + +"@swc/jest@^0.2.38": + version "0.2.39" + resolved "https://registry.yarnpkg.com/@swc/jest/-/jest-0.2.39.tgz#482bee0adb0726fab1487a4f902a278ec563a6b7" + integrity sha512-eyokjOwYd0Q8RnMHri+8/FS1HIrIUKK/sRrFp8c1dThUOfNeCWbLmBP1P5VsKdvmkd25JaH+OKYwEYiAYg9YAA== + dependencies: + "@jest/create-cache-key-function" "^30.0.0" + "@swc/counter" "^0.1.3" + jsonc-parser "^3.2.0" + +"@swc/types@^0.1.25": + version "0.1.25" + resolved "https://registry.yarnpkg.com/@swc/types/-/types-0.1.25.tgz#b517b2a60feb37dd933e542d93093719e4cf1078" + integrity sha512-iAoY/qRhNH8a/hBvm3zKj9qQ4oc2+3w1unPJa2XvTK3XjeLXtzcCingVPw/9e5mn1+0yPqxcBGp9Jf0pkfMb1g== + dependencies: + "@swc/counter" "^0.1.3" + +"@testcontainers/postgresql@^11.0.0": + version "11.9.0" + resolved "https://registry.yarnpkg.com/@testcontainers/postgresql/-/postgresql-11.9.0.tgz#9009e28208c4134c4cd6614827fa98614ea1d494" + integrity sha512-beLyLdLygFllktviM132Xd6tQ4i5FnuyZP+4BQEjUb5sJYHYnIrV/ZBzRRflIlF8gugt1GXgudkmr/HxM9vtKw== + dependencies: + testcontainers "^11.9.0" + +"@testing-library/dom@^10.4.1": + version "10.4.1" + resolved "https://registry.yarnpkg.com/@testing-library/dom/-/dom-10.4.1.tgz#d444f8a889e9a46e9a3b4f3b88e0fcb3efb6cf95" + integrity sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg== + dependencies: + "@babel/code-frame" "^7.10.4" + "@babel/runtime" "^7.12.5" + "@types/aria-query" "^5.0.1" + aria-query "5.3.0" + dom-accessibility-api "^0.5.9" + lz-string "^1.5.0" + picocolors "1.1.1" + pretty-format "^27.0.2" + +"@testing-library/jest-dom@^6.6.3": + version "6.9.1" + resolved "https://registry.yarnpkg.com/@testing-library/jest-dom/-/jest-dom-6.9.1.tgz#7613a04e146dd2976d24ddf019730d57a89d56c2" + integrity sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA== + dependencies: + "@adobe/css-tools" "^4.4.0" + aria-query "^5.0.0" + css.escape "^1.5.1" + dom-accessibility-api "^0.6.3" + picocolors "^1.1.1" + redent "^3.0.0" + +"@testing-library/react@^16.3.0": + version "16.3.0" + resolved "https://registry.yarnpkg.com/@testing-library/react/-/react-16.3.0.tgz#3a85bb9bdebf180cd76dba16454e242564d598a6" + integrity sha512-kFSyxiEDwv1WLl2fgsq6pPBbw5aWKrsY2/noi1Id0TK0UParSF62oFQFGHXIyaG4pp2tEub/Zlel+fjjZILDsw== + dependencies: + "@babel/runtime" "^7.12.5" + +"@testing-library/user-event@^14.6.1": + version "14.6.1" + resolved "https://registry.yarnpkg.com/@testing-library/user-event/-/user-event-14.6.1.tgz#13e09a32d7a8b7060fe38304788ebf4197cd2149" + integrity sha512-vq7fv0rnt+QTXgPxr5Hjc210p6YKq2kmdziLgnsZGgLJ9e6VAShx1pACLuRjd/AS/sr7phAR58OIIpf0LlmQNw== + +"@tybys/wasm-util@^0.10.0": + version "0.10.1" + resolved "https://registry.yarnpkg.com/@tybys/wasm-util/-/wasm-util-0.10.1.tgz#ecddd3205cf1e2d5274649ff0eedd2991ed7f414" + integrity sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg== + dependencies: + tslib "^2.4.0" + +"@types/argparse@1.0.38": + version "1.0.38" + resolved "https://registry.yarnpkg.com/@types/argparse/-/argparse-1.0.38.tgz#a81fd8606d481f873a3800c6ebae4f1d768a56a9" + integrity sha512-ebDJ9b0e702Yr7pWgB0jzm+CX4Srzz8RcXtLJDJB+BSccqMa36uyH/zUsSYao5+BD1ytv3k3rPYCq4mAE1hsXA== + +"@types/aria-query@^5.0.1": + version "5.0.4" + resolved "https://registry.yarnpkg.com/@types/aria-query/-/aria-query-5.0.4.tgz#1a31c3d378850d2778dabb6374d036dcba4ba708" + integrity sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw== + +"@types/babel__core@^7.20.5": + version "7.20.5" + resolved "https://registry.yarnpkg.com/@types/babel__core/-/babel__core-7.20.5.tgz#3df15f27ba85319caa07ba08d0721889bb39c017" + integrity sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA== + dependencies: + "@babel/parser" "^7.20.7" + "@babel/types" "^7.20.7" + "@types/babel__generator" "*" + "@types/babel__template" "*" + "@types/babel__traverse" "*" + +"@types/babel__generator@*": + version "7.27.0" + resolved "https://registry.yarnpkg.com/@types/babel__generator/-/babel__generator-7.27.0.tgz#b5819294c51179957afaec341442f9341e4108a9" + integrity sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg== + dependencies: + "@babel/types" "^7.0.0" + +"@types/babel__template@*": + version "7.4.4" + resolved "https://registry.yarnpkg.com/@types/babel__template/-/babel__template-7.4.4.tgz#5672513701c1b2199bc6dad636a9d7491586766f" + integrity sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A== + dependencies: + "@babel/parser" "^7.1.0" + "@babel/types" "^7.0.0" + +"@types/babel__traverse@*", "@types/babel__traverse@^7.20.7": + version "7.28.0" + resolved "https://registry.yarnpkg.com/@types/babel__traverse/-/babel__traverse-7.28.0.tgz#07d713d6cce0d265c9849db0cbe62d3f61f36f74" + integrity sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q== + dependencies: + "@babel/types" "^7.28.2" + +"@types/chai@^5.2.2": + version "5.2.3" + resolved "https://registry.yarnpkg.com/@types/chai/-/chai-5.2.3.tgz#8e9cd9e1c3581fa6b341a5aed5588eb285be0b4a" + integrity sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA== + dependencies: + "@types/deep-eql" "*" + assertion-error "^2.0.1" + +"@types/counterpart@^0.18.4": + version "0.18.4" + resolved "https://registry.yarnpkg.com/@types/counterpart/-/counterpart-0.18.4.tgz#e3e331b7e0d5496873d417839f3b2bbcf555bb73" + integrity sha512-aqBg5oAGo/qh/+wxUfuMadDu2WO0MEWOblyzwaM1Ske2xilUxBfgPqapAFVAfrVTDMVwa0UMarzGot8m64IAzA== + +"@types/deep-eql@*": + version "4.0.2" + resolved "https://registry.yarnpkg.com/@types/deep-eql/-/deep-eql-4.0.2.tgz#334311971d3a07121e7eb91b684a605e7eea9cbd" + integrity sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw== + +"@types/docker-modem@*": + version "3.0.6" + resolved "https://registry.yarnpkg.com/@types/docker-modem/-/docker-modem-3.0.6.tgz#1f9262fcf85425b158ca725699a03eb23cddbf87" + integrity sha512-yKpAGEuKRSS8wwx0joknWxsmLha78wNMe9R2S3UNsVOkZded8UqOrV8KoeDXoXsjndxwyF3eIhyClGbO1SEhEg== + dependencies: + "@types/node" "*" + "@types/ssh2" "*" + +"@types/dockerode@^3.3.47": + version "3.3.47" + resolved "https://registry.yarnpkg.com/@types/dockerode/-/dockerode-3.3.47.tgz#cf8c6b4efcd0bb28b0e6009e613e7faab1b96e75" + integrity sha512-ShM1mz7rCjdssXt7Xz0u1/R2BJC7piWa3SJpUBiVjCf2A3XNn4cP6pUVaD8bLanpPVVn4IKzJuw3dOvkJ8IbYw== + dependencies: + "@types/docker-modem" "*" + "@types/node" "*" + "@types/ssh2" "*" + +"@types/doctrine@^0.0.9": + version "0.0.9" + resolved "https://registry.yarnpkg.com/@types/doctrine/-/doctrine-0.0.9.tgz#d86a5f452a15e3e3113b99e39616a9baa0f9863f" + integrity sha512-eOIHzCUSH7SMfonMG1LsC2f8vxBFtho6NGBznK41R84YzPuvSBzrhEps33IsQiOW9+VL6NQ9DbjQJznk/S4uRA== + +"@types/estree@1.0.8", "@types/estree@^1.0.0": + version "1.0.8" + resolved "https://registry.yarnpkg.com/@types/estree/-/estree-1.0.8.tgz#958b91c991b1867ced318bedea0e215ee050726e" + integrity sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w== + +"@types/istanbul-lib-coverage@*", "@types/istanbul-lib-coverage@^2.0.1", "@types/istanbul-lib-coverage@^2.0.6": + version "2.0.6" + resolved "https://registry.yarnpkg.com/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.6.tgz#7739c232a1fee9b4d3ce8985f314c0c6d33549d7" + integrity sha512-2QF/t/auWm0lsy8XtKVPG19v3sSOQlJe/YHZgfjb/KBBHOGSV+J2q/S671rcq9uTBrLAXmZpqJiaQbMT+zNU1w== + +"@types/istanbul-lib-report@*": + version "3.0.3" + resolved "https://registry.yarnpkg.com/@types/istanbul-lib-report/-/istanbul-lib-report-3.0.3.tgz#53047614ae72e19fc0401d872de3ae2b4ce350bf" + integrity sha512-NQn7AHQnk/RSLOxrBbGyJM/aVQ+pjj5HCgasFxc0K/KhoATfQ/47AyUl15I2yBUpihjmas+a+VJBOqecrFH+uA== + dependencies: + "@types/istanbul-lib-coverage" "*" + +"@types/istanbul-reports@^3.0.4": + version "3.0.4" + resolved "https://registry.yarnpkg.com/@types/istanbul-reports/-/istanbul-reports-3.0.4.tgz#0f03e3d2f670fbdac586e34b433783070cc16f54" + integrity sha512-pk2B1NWalF9toCRu6gjBzR69syFjP4Od8WRAX+0mmf9lAjCRicLOWc+ZrxZHx/0XRjotgkF9t6iaMJ+aXcOdZQ== + dependencies: + "@types/istanbul-lib-report" "*" + +"@types/jest-image-snapshot@^6.4.0": + version "6.4.0" + resolved "https://registry.yarnpkg.com/@types/jest-image-snapshot/-/jest-image-snapshot-6.4.0.tgz#641054d2fa2ff130a49c844ee7a9a68f281b6017" + integrity sha512-8TQ/EgqFCX0UWSpH488zAc21fCkJNpZPnnp3xWFMqElxApoJV5QOoqajnVRV7AhfF0rbQWTVyc04KG7tXnzCPA== + dependencies: + "@types/jest" "*" + "@types/pixelmatch" "*" + ssim.js "^3.1.1" + +"@types/jest@*": + version "30.0.0" + resolved "https://registry.yarnpkg.com/@types/jest/-/jest-30.0.0.tgz#5e85ae568006712e4ad66f25433e9bdac8801f1d" + integrity sha512-XTYugzhuwqWjws0CVz8QpM36+T+Dz5mTEBKhNs/esGLnCIlGdRy+Dq78NRjd7ls7r8BC8ZRMOrKlkO1hU0JOwA== + dependencies: + expect "^30.0.0" + pretty-format "^30.0.0" + +"@types/lodash@^4.17.20": + version "4.17.21" + resolved "https://registry.yarnpkg.com/@types/lodash/-/lodash-4.17.21.tgz#b806831543d696b14f8112db600ea9d3a1df6ea4" + integrity sha512-FOvQ0YPD5NOfPgMzJihoT+Za5pdkDJWcbpuj1DjaKZIr/gxodQjY/uWEFlTNqW2ugXHUiL8lRQgw63dzKHZdeQ== + +"@types/mdx@^2.0.0": + version "2.0.13" + resolved "https://registry.yarnpkg.com/@types/mdx/-/mdx-2.0.13.tgz#68f6877043d377092890ff5b298152b0a21671bd" + integrity sha512-+OWZQfAYyio6YkJb3HLxDrvnx6SWWDbC0zVPfBRzUk0/nqoDyf6dNxQi3eArPe8rJ473nobTMQ/8Zk+LxJ+Yuw== + +"@types/node@*", "@types/node@>=13.7.0": + version "24.10.2" + resolved "https://registry.yarnpkg.com/@types/node/-/node-24.10.2.tgz#82a57476a19647d8f2c7750d0924788245e39b26" + integrity sha512-WOhQTZ4G8xZ1tjJTvKOpyEVSGgOTvJAfDK3FNFgELyaTpzhdgHVHeqW8V+UJvzF5BT+/B54T/1S2K6gd9c7bbA== + dependencies: + undici-types "~7.16.0" + +"@types/node@^18.11.18": + version "18.19.130" + resolved "https://registry.yarnpkg.com/@types/node/-/node-18.19.130.tgz#da4c6324793a79defb7a62cba3947ec5add00d59" + integrity sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg== + dependencies: + undici-types "~5.26.4" + +"@types/pixelmatch@*": + version "5.2.6" + resolved "https://registry.yarnpkg.com/@types/pixelmatch/-/pixelmatch-5.2.6.tgz#fba6de304ac958495f27d85989f5c6bb7499a686" + integrity sha512-wC83uexE5KGuUODn6zkm9gMzTwdY5L0chiK+VrKcDfEjzxh1uadlWTvOmAbCpnM9zx/Ww3f8uKlYQVnO/TrqVg== + dependencies: + "@types/node" "*" + +"@types/react@^19.2.2": + version "19.2.7" + resolved "https://registry.yarnpkg.com/@types/react/-/react-19.2.7.tgz#84e62c0f23e8e4e5ac2cadcea1ffeacccae7f62f" + integrity sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg== + dependencies: + csstype "^3.2.2" + +"@types/resolve@^1.20.2": + version "1.20.6" + resolved "https://registry.yarnpkg.com/@types/resolve/-/resolve-1.20.6.tgz#e6e60dad29c2c8c206c026e6dd8d6d1bdda850b8" + integrity sha512-A4STmOXPhMUtHH+S6ymgE2GiBSMqf4oTvcQZMcHzokuTLVYzXTB8ttjcgxOVaAp2lGwEdzZ0J+cRbbeevQj1UQ== + +"@types/ssh2-streams@*": + version "0.1.13" + resolved "https://registry.yarnpkg.com/@types/ssh2-streams/-/ssh2-streams-0.1.13.tgz#f8d34a22be50fb8dbafbb2bbc289add0d22daa51" + integrity sha512-faHyY3brO9oLEA0QlcO8N2wT7R0+1sHWZvQ+y3rMLwdY1ZyS1z0W3t65j9PqT4HmQ6ALzNe7RZlNuCNE0wBSWA== + dependencies: + "@types/node" "*" + +"@types/ssh2@*": + version "1.15.5" + resolved "https://registry.yarnpkg.com/@types/ssh2/-/ssh2-1.15.5.tgz#6d8f45db2f39519b8d9377268fa71ed77d969686" + integrity sha512-N1ASjp/nXH3ovBHddRJpli4ozpk6UdDYIX4RJWFa9L1YKnzdhTlVmiGHm4DZnj/jLbqZpes4aeR30EFGQtvhQQ== + dependencies: + "@types/node" "^18.11.18" + +"@types/ssh2@^0.5.48": + version "0.5.52" + resolved "https://registry.yarnpkg.com/@types/ssh2/-/ssh2-0.5.52.tgz#9dbd8084e2a976e551d5e5e70b978ed8b5965741" + integrity sha512-lbLLlXxdCZOSJMCInKH2+9V/77ET2J6NPQHpFI0kda61Dd1KglJs+fPQBchizmzYSOJBgdTajhPqBO1xxLywvg== + dependencies: + "@types/node" "*" + "@types/ssh2-streams" "*" + +"@types/stack-utils@^2.0.3": + version "2.0.3" + resolved "https://registry.yarnpkg.com/@types/stack-utils/-/stack-utils-2.0.3.tgz#6209321eb2c1712a7e7466422b8cb1fc0d9dd5d8" + integrity sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw== + +"@types/wait-on@^5.2.0": + version "5.3.4" + resolved "https://registry.yarnpkg.com/@types/wait-on/-/wait-on-5.3.4.tgz#5ee270b3e073fb01073f9f044922c6893de8c4d2" + integrity sha512-EBsPjFMrFlMbbUFf9D1Fp+PAB2TwmUn7a3YtHyD9RLuTIk1jDd8SxXVAoez2Ciy+8Jsceo2MYEYZzJ/DvorOKw== + dependencies: + "@types/node" "*" + +"@types/yargs-parser@*": + version "21.0.3" + resolved "https://registry.yarnpkg.com/@types/yargs-parser/-/yargs-parser-21.0.3.tgz#815e30b786d2e8f0dcd85fd5bcf5e1a04d008f15" + integrity sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ== + +"@types/yargs@^17.0.33": + version "17.0.35" + resolved "https://registry.yarnpkg.com/@types/yargs/-/yargs-17.0.35.tgz#07013e46aa4d7d7d50a49e15604c1c5340d4eb24" + integrity sha512-qUHkeCyQFxMXg79wQfTtfndEC+N9ZZg76HJftDJp+qH2tV7Gj4OJi7l+PiWwJ+pWtW8GwSmqsDj/oymhrTWXjg== + dependencies: + "@types/yargs-parser" "*" + +"@typescript-eslint/project-service@8.49.0": + version "8.49.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/project-service/-/project-service-8.49.0.tgz#ce220525c88cb2d23792b391c07e14cb9697651a" + integrity sha512-/wJN0/DKkmRUMXjZUXYZpD1NEQzQAAn9QWfGwo+Ai8gnzqH7tvqS7oNVdTjKqOcPyVIdZdyCMoqN66Ia789e7g== + dependencies: + "@typescript-eslint/tsconfig-utils" "^8.49.0" + "@typescript-eslint/types" "^8.49.0" + debug "^4.3.4" + +"@typescript-eslint/scope-manager@8.49.0": + version "8.49.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/scope-manager/-/scope-manager-8.49.0.tgz#a3496765b57fb48035d671174552e462e5bffa63" + integrity sha512-npgS3zi+/30KSOkXNs0LQXtsg9ekZ8OISAOLGWA/ZOEn0ZH74Ginfl7foziV8DT+D98WfQ5Kopwqb/PZOaIJGg== + dependencies: + "@typescript-eslint/types" "8.49.0" + "@typescript-eslint/visitor-keys" "8.49.0" + +"@typescript-eslint/tsconfig-utils@8.49.0", "@typescript-eslint/tsconfig-utils@^8.49.0": + version "8.49.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.49.0.tgz#857777c8e35dd1e564505833d8043f544442fbf4" + integrity sha512-8prixNi1/6nawsRYxet4YOhnbW+W9FK/bQPxsGB1D3ZrDzbJ5FXw5XmzxZv82X3B+ZccuSxo/X8q9nQ+mFecWA== + +"@typescript-eslint/types@8.49.0", "@typescript-eslint/types@^8.49.0": + version "8.49.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-8.49.0.tgz#c1bd3ebf956d9e5216396349ca23c58d74f06aee" + integrity sha512-e9k/fneezorUo6WShlQpMxXh8/8wfyc+biu6tnAqA81oWrEic0k21RHzP9uqqpyBBeBKu4T+Bsjy9/b8u7obXQ== + +"@typescript-eslint/typescript-estree@8.49.0": + version "8.49.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/typescript-estree/-/typescript-estree-8.49.0.tgz#99c5a53275197ccb4e849786dad68344e9924135" + integrity sha512-jrLdRuAbPfPIdYNppHJ/D0wN+wwNfJ32YTAm10eJVsFmrVpXQnDWBn8niCSMlWjvml8jsce5E/O+86IQtTbJWA== + dependencies: + "@typescript-eslint/project-service" "8.49.0" + "@typescript-eslint/tsconfig-utils" "8.49.0" + "@typescript-eslint/types" "8.49.0" + "@typescript-eslint/visitor-keys" "8.49.0" + debug "^4.3.4" + minimatch "^9.0.4" + semver "^7.6.0" + tinyglobby "^0.2.15" + ts-api-utils "^2.1.0" + +"@typescript-eslint/utils@^8.8.1": + version "8.49.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/utils/-/utils-8.49.0.tgz#43b3b91d30afd6f6114532cf0b228f1790f43aff" + integrity sha512-N3W7rJw7Rw+z1tRsHZbK395TWSYvufBXumYtEGzypgMUthlg0/hmCImeA8hgO2d2G4pd7ftpxxul2J8OdtdaFA== + dependencies: + "@eslint-community/eslint-utils" "^4.7.0" + "@typescript-eslint/scope-manager" "8.49.0" + "@typescript-eslint/types" "8.49.0" + "@typescript-eslint/typescript-estree" "8.49.0" + +"@typescript-eslint/visitor-keys@8.49.0": + version "8.49.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/visitor-keys/-/visitor-keys-8.49.0.tgz#8e450cc502c0d285cad9e84d400cf349a85ced6c" + integrity sha512-LlKaciDe3GmZFphXIc79THF/YYBugZ7FS1pO581E/edlVVNbZKDy93evqmrfQ9/Y4uN0vVhX4iuchq26mK/iiA== + dependencies: + "@typescript-eslint/types" "8.49.0" + eslint-visitor-keys "^4.2.1" + +"@ungap/structured-clone@^1.2.0", "@ungap/structured-clone@^1.3.0": + version "1.3.0" + resolved "https://registry.yarnpkg.com/@ungap/structured-clone/-/structured-clone-1.3.0.tgz#d06bbb384ebcf6c505fde1c3d0ed4ddffe0aaff8" + integrity sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g== + +"@unrs/resolver-binding-android-arm-eabi@1.11.1": + version "1.11.1" + resolved "https://registry.yarnpkg.com/@unrs/resolver-binding-android-arm-eabi/-/resolver-binding-android-arm-eabi-1.11.1.tgz#9f5b04503088e6a354295e8ea8fe3cb99e43af81" + integrity sha512-ppLRUgHVaGRWUx0R0Ut06Mjo9gBaBkg3v/8AxusGLhsIotbBLuRk51rAzqLC8gq6NyyAojEXglNjzf6R948DNw== + +"@unrs/resolver-binding-android-arm64@1.11.1": + version "1.11.1" + resolved "https://registry.yarnpkg.com/@unrs/resolver-binding-android-arm64/-/resolver-binding-android-arm64-1.11.1.tgz#7414885431bd7178b989aedc4d25cccb3865bc9f" + integrity sha512-lCxkVtb4wp1v+EoN+HjIG9cIIzPkX5OtM03pQYkG+U5O/wL53LC4QbIeazgiKqluGeVEeBlZahHalCaBvU1a2g== + +"@unrs/resolver-binding-darwin-arm64@1.11.1": + version "1.11.1" + resolved "https://registry.yarnpkg.com/@unrs/resolver-binding-darwin-arm64/-/resolver-binding-darwin-arm64-1.11.1.tgz#b4a8556f42171fb9c9f7bac8235045e82aa0cbdf" + integrity sha512-gPVA1UjRu1Y/IsB/dQEsp2V1pm44Of6+LWvbLc9SDk1c2KhhDRDBUkQCYVWe6f26uJb3fOK8saWMgtX8IrMk3g== + +"@unrs/resolver-binding-darwin-x64@1.11.1": + version "1.11.1" + resolved "https://registry.yarnpkg.com/@unrs/resolver-binding-darwin-x64/-/resolver-binding-darwin-x64-1.11.1.tgz#fd4d81257b13f4d1a083890a6a17c00de571f0dc" + integrity sha512-cFzP7rWKd3lZaCsDze07QX1SC24lO8mPty9vdP+YVa3MGdVgPmFc59317b2ioXtgCMKGiCLxJ4HQs62oz6GfRQ== + +"@unrs/resolver-binding-freebsd-x64@1.11.1": + version "1.11.1" + resolved "https://registry.yarnpkg.com/@unrs/resolver-binding-freebsd-x64/-/resolver-binding-freebsd-x64-1.11.1.tgz#d2513084d0f37c407757e22f32bd924a78cfd99b" + integrity sha512-fqtGgak3zX4DCB6PFpsH5+Kmt/8CIi4Bry4rb1ho6Av2QHTREM+47y282Uqiu3ZRF5IQioJQ5qWRV6jduA+iGw== + +"@unrs/resolver-binding-linux-arm-gnueabihf@1.11.1": + version "1.11.1" + resolved "https://registry.yarnpkg.com/@unrs/resolver-binding-linux-arm-gnueabihf/-/resolver-binding-linux-arm-gnueabihf-1.11.1.tgz#844d2605d057488d77fab09705f2866b86164e0a" + integrity sha512-u92mvlcYtp9MRKmP+ZvMmtPN34+/3lMHlyMj7wXJDeXxuM0Vgzz0+PPJNsro1m3IZPYChIkn944wW8TYgGKFHw== + +"@unrs/resolver-binding-linux-arm-musleabihf@1.11.1": + version "1.11.1" + resolved "https://registry.yarnpkg.com/@unrs/resolver-binding-linux-arm-musleabihf/-/resolver-binding-linux-arm-musleabihf-1.11.1.tgz#204892995cefb6bd1d017d52d097193bc61ddad3" + integrity sha512-cINaoY2z7LVCrfHkIcmvj7osTOtm6VVT16b5oQdS4beibX2SYBwgYLmqhBjA1t51CarSaBuX5YNsWLjsqfW5Cw== + +"@unrs/resolver-binding-linux-arm64-gnu@1.11.1": + version "1.11.1" + resolved "https://registry.yarnpkg.com/@unrs/resolver-binding-linux-arm64-gnu/-/resolver-binding-linux-arm64-gnu-1.11.1.tgz#023eb0c3aac46066a10be7a3f362e7b34f3bdf9d" + integrity sha512-34gw7PjDGB9JgePJEmhEqBhWvCiiWCuXsL9hYphDF7crW7UgI05gyBAi6MF58uGcMOiOqSJ2ybEeCvHcq0BCmQ== + +"@unrs/resolver-binding-linux-arm64-musl@1.11.1": + version "1.11.1" + resolved "https://registry.yarnpkg.com/@unrs/resolver-binding-linux-arm64-musl/-/resolver-binding-linux-arm64-musl-1.11.1.tgz#9e6f9abb06424e3140a60ac996139786f5d99be0" + integrity sha512-RyMIx6Uf53hhOtJDIamSbTskA99sPHS96wxVE/bJtePJJtpdKGXO1wY90oRdXuYOGOTuqjT8ACccMc4K6QmT3w== + +"@unrs/resolver-binding-linux-ppc64-gnu@1.11.1": + version "1.11.1" + resolved "https://registry.yarnpkg.com/@unrs/resolver-binding-linux-ppc64-gnu/-/resolver-binding-linux-ppc64-gnu-1.11.1.tgz#b111417f17c9d1b02efbec8e08398f0c5527bb44" + integrity sha512-D8Vae74A4/a+mZH0FbOkFJL9DSK2R6TFPC9M+jCWYia/q2einCubX10pecpDiTmkJVUH+y8K3BZClycD8nCShA== + +"@unrs/resolver-binding-linux-riscv64-gnu@1.11.1": + version "1.11.1" + resolved "https://registry.yarnpkg.com/@unrs/resolver-binding-linux-riscv64-gnu/-/resolver-binding-linux-riscv64-gnu-1.11.1.tgz#92ffbf02748af3e99873945c9a8a5ead01d508a9" + integrity sha512-frxL4OrzOWVVsOc96+V3aqTIQl1O2TjgExV4EKgRY09AJ9leZpEg8Ak9phadbuX0BA4k8U5qtvMSQQGGmaJqcQ== + +"@unrs/resolver-binding-linux-riscv64-musl@1.11.1": + version "1.11.1" + resolved "https://registry.yarnpkg.com/@unrs/resolver-binding-linux-riscv64-musl/-/resolver-binding-linux-riscv64-musl-1.11.1.tgz#0bec6f1258fc390e6b305e9ff44256cb207de165" + integrity sha512-mJ5vuDaIZ+l/acv01sHoXfpnyrNKOk/3aDoEdLO/Xtn9HuZlDD6jKxHlkN8ZhWyLJsRBxfv9GYM2utQ1SChKew== + +"@unrs/resolver-binding-linux-s390x-gnu@1.11.1": + version "1.11.1" + resolved "https://registry.yarnpkg.com/@unrs/resolver-binding-linux-s390x-gnu/-/resolver-binding-linux-s390x-gnu-1.11.1.tgz#577843a084c5952f5906770633ccfb89dac9bc94" + integrity sha512-kELo8ebBVtb9sA7rMe1Cph4QHreByhaZ2QEADd9NzIQsYNQpt9UkM9iqr2lhGr5afh885d/cB5QeTXSbZHTYPg== + +"@unrs/resolver-binding-linux-x64-gnu@1.11.1": + version "1.11.1" + resolved "https://registry.yarnpkg.com/@unrs/resolver-binding-linux-x64-gnu/-/resolver-binding-linux-x64-gnu-1.11.1.tgz#36fb318eebdd690f6da32ac5e0499a76fa881935" + integrity sha512-C3ZAHugKgovV5YvAMsxhq0gtXuwESUKc5MhEtjBpLoHPLYM+iuwSj3lflFwK3DPm68660rZ7G8BMcwSro7hD5w== + +"@unrs/resolver-binding-linux-x64-musl@1.11.1": + version "1.11.1" + resolved "https://registry.yarnpkg.com/@unrs/resolver-binding-linux-x64-musl/-/resolver-binding-linux-x64-musl-1.11.1.tgz#bfb9af75f783f98f6a22c4244214efe4df1853d6" + integrity sha512-rV0YSoyhK2nZ4vEswT/QwqzqQXw5I6CjoaYMOX0TqBlWhojUf8P94mvI7nuJTeaCkkds3QE4+zS8Ko+GdXuZtA== + +"@unrs/resolver-binding-wasm32-wasi@1.11.1": + version "1.11.1" + resolved "https://registry.yarnpkg.com/@unrs/resolver-binding-wasm32-wasi/-/resolver-binding-wasm32-wasi-1.11.1.tgz#752c359dd875684b27429500d88226d7cc72f71d" + integrity sha512-5u4RkfxJm+Ng7IWgkzi3qrFOvLvQYnPBmjmZQ8+szTK/b31fQCnleNl1GgEt7nIsZRIf5PLhPwT0WM+q45x/UQ== + dependencies: + "@napi-rs/wasm-runtime" "^0.2.11" + +"@unrs/resolver-binding-win32-arm64-msvc@1.11.1": + version "1.11.1" + resolved "https://registry.yarnpkg.com/@unrs/resolver-binding-win32-arm64-msvc/-/resolver-binding-win32-arm64-msvc-1.11.1.tgz#ce5735e600e4c2fbb409cd051b3b7da4a399af35" + integrity sha512-nRcz5Il4ln0kMhfL8S3hLkxI85BXs3o8EYoattsJNdsX4YUU89iOkVn7g0VHSRxFuVMdM4Q1jEpIId1Ihim/Uw== + +"@unrs/resolver-binding-win32-ia32-msvc@1.11.1": + version "1.11.1" + resolved "https://registry.yarnpkg.com/@unrs/resolver-binding-win32-ia32-msvc/-/resolver-binding-win32-ia32-msvc-1.11.1.tgz#72fc57bc7c64ec5c3de0d64ee0d1810317bc60a6" + integrity sha512-DCEI6t5i1NmAZp6pFonpD5m7i6aFrpofcp4LA2i8IIq60Jyo28hamKBxNrZcyOwVOZkgsRp9O2sXWBWP8MnvIQ== + +"@unrs/resolver-binding-win32-x64-msvc@1.11.1": + version "1.11.1" + resolved "https://registry.yarnpkg.com/@unrs/resolver-binding-win32-x64-msvc/-/resolver-binding-win32-x64-msvc-1.11.1.tgz#538b1e103bf8d9864e7b85cc96fa8d6fb6c40777" + integrity sha512-lrW200hZdbfRtztbygyaq/6jP6AKE8qQN2KvPcJ+x7wiD038YtnYtZ82IMNJ69GJibV7bwL3y9FgK+5w/pYt6g== + +"@vector-im/compound-design-tokens@^6.3.0": + version "6.4.2" + resolved "https://registry.yarnpkg.com/@vector-im/compound-design-tokens/-/compound-design-tokens-6.4.2.tgz#29189d6480c8ccf09ce143cb4618fb13a56a7583" + integrity sha512-LHLGZgnatH3mQXn9TF+m/SUinPS2nKvuCT/r2AQ7HAgEIG/S/Ck6e/iV4IFQLSZnd9gU0RlMsLkP2UQ/AKUEBA== + +"@vitest/expect@3.2.4": + version "3.2.4" + resolved "https://registry.yarnpkg.com/@vitest/expect/-/expect-3.2.4.tgz#8362124cd811a5ee11c5768207b9df53d34f2433" + integrity sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig== + dependencies: + "@types/chai" "^5.2.2" + "@vitest/spy" "3.2.4" + "@vitest/utils" "3.2.4" + chai "^5.2.0" + tinyrainbow "^2.0.0" + +"@vitest/mocker@3.2.4": + version "3.2.4" + resolved "https://registry.yarnpkg.com/@vitest/mocker/-/mocker-3.2.4.tgz#4471c4efbd62db0d4fa203e65cc6b058a85cabd3" + integrity sha512-46ryTE9RZO/rfDd7pEqFl7etuyzekzEhUbTW3BvmeO/BcCMEgq59BKhek3dXDWgAj4oMK6OZi+vRr1wPW6qjEQ== + dependencies: + "@vitest/spy" "3.2.4" + estree-walker "^3.0.3" + magic-string "^0.30.17" + +"@vitest/pretty-format@3.2.4": + version "3.2.4" + resolved "https://registry.yarnpkg.com/@vitest/pretty-format/-/pretty-format-3.2.4.tgz#3c102f79e82b204a26c7a5921bf47d534919d3b4" + integrity sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA== + dependencies: + tinyrainbow "^2.0.0" + +"@vitest/spy@3.2.4": + version "3.2.4" + resolved "https://registry.yarnpkg.com/@vitest/spy/-/spy-3.2.4.tgz#cc18f26f40f3f028da6620046881f4e4518c2599" + integrity sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw== + dependencies: + tinyspy "^4.0.3" + +"@vitest/utils@3.2.4": + version "3.2.4" + resolved "https://registry.yarnpkg.com/@vitest/utils/-/utils-3.2.4.tgz#c0813bc42d99527fb8c5b138c7a88516bca46fea" + integrity sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA== + dependencies: + "@vitest/pretty-format" "3.2.4" + loupe "^3.1.4" + tinyrainbow "^2.0.0" + +"@volar/language-core@2.4.23", "@volar/language-core@~2.4.11": + version "2.4.23" + resolved "https://registry.yarnpkg.com/@volar/language-core/-/language-core-2.4.23.tgz#deb6dbc5fdbafa9bb7ba691fc59cb196cdb856d3" + integrity sha512-hEEd5ET/oSmBC6pi1j6NaNYRWoAiDhINbT8rmwtINugR39loROSlufGdYMF9TaKGfz+ViGs1Idi3mAhnuPcoGQ== + dependencies: + "@volar/source-map" "2.4.23" + +"@volar/source-map@2.4.23": + version "2.4.23" + resolved "https://registry.yarnpkg.com/@volar/source-map/-/source-map-2.4.23.tgz#d476e11a3a669d89858a5eb38b02342be39b0e44" + integrity sha512-Z1Uc8IB57Lm6k7q6KIDu/p+JWtf3xsXJqAX/5r18hYOTpJyBn0KXUR8oTJ4WFYOcDzWC9n3IflGgHowx6U6z9Q== + +"@volar/typescript@^2.4.11": + version "2.4.23" + resolved "https://registry.yarnpkg.com/@volar/typescript/-/typescript-2.4.23.tgz#b9b114ea01ad0ad977139edda0239fdafdb21ad7" + integrity sha512-lAB5zJghWxVPqfcStmAP1ZqQacMpe90UrP5RJ3arDyrhy4aCUQqmxPPLB2PWDKugvylmO41ljK7vZ+t6INMTag== + dependencies: + "@volar/language-core" "2.4.23" + path-browserify "^1.0.1" + vscode-uri "^3.0.8" + +"@vue/compiler-core@3.5.22": + version "3.5.22" + resolved "https://registry.yarnpkg.com/@vue/compiler-core/-/compiler-core-3.5.22.tgz#bb8294a0dd31df540563cc6ffa0456f1f7687b97" + integrity sha512-jQ0pFPmZwTEiRNSb+i9Ow/I/cHv2tXYqsnHKKyCQ08irI2kdF5qmYedmF8si8mA7zepUFmJ2hqzS8CQmNOWOkQ== + dependencies: + "@babel/parser" "^7.28.4" + "@vue/shared" "3.5.22" + entities "^4.5.0" + estree-walker "^2.0.2" + source-map-js "^1.2.1" + +"@vue/compiler-dom@^3.5.0": + version "3.5.22" + resolved "https://registry.yarnpkg.com/@vue/compiler-dom/-/compiler-dom-3.5.22.tgz#6c9c2c9843520f6d3dbc685e5d0e1e12a2c04c56" + integrity sha512-W8RknzUM1BLkypvdz10OVsGxnMAuSIZs9Wdx1vzA3mL5fNMN15rhrSCLiTm6blWeACwUwizzPVqGJgOGBEN/hA== + dependencies: + "@vue/compiler-core" "3.5.22" + "@vue/shared" "3.5.22" + +"@vue/compiler-vue2@^2.7.16": + version "2.7.16" + resolved "https://registry.yarnpkg.com/@vue/compiler-vue2/-/compiler-vue2-2.7.16.tgz#2ba837cbd3f1b33c2bc865fbe1a3b53fb611e249" + integrity sha512-qYC3Psj9S/mfu9uVi5WvNZIzq+xnXMhOwbTFKKDD7b1lhpnn71jXSFdTQ+WsIEk0ONCd7VV2IMm7ONl6tbQ86A== + dependencies: + de-indent "^1.0.2" + he "^1.2.0" + +"@vue/language-core@2.2.0": + version "2.2.0" + resolved "https://registry.yarnpkg.com/@vue/language-core/-/language-core-2.2.0.tgz#e48c54584f889f78b120ce10a050dfb316c7fcdf" + integrity sha512-O1ZZFaaBGkKbsRfnVH1ifOK1/1BUkyK+3SQsfnh6PmMmD4qJcTU8godCeA96jjDRTL6zgnK7YzCHfaUlH2r0Mw== + dependencies: + "@volar/language-core" "~2.4.11" + "@vue/compiler-dom" "^3.5.0" + "@vue/compiler-vue2" "^2.7.16" + "@vue/shared" "^3.5.0" + alien-signals "^0.4.9" + minimatch "^9.0.3" + muggle-string "^0.4.1" + path-browserify "^1.0.1" + +"@vue/shared@3.5.22", "@vue/shared@^3.5.0": + version "3.5.22" + resolved "https://registry.yarnpkg.com/@vue/shared/-/shared-3.5.22.tgz#9d56a1644a3becb8af1e34655928b0e288d827f8" + integrity sha512-F4yc6palwq3TT0u+FYf0Ns4Tfl9GRFURDN2gWG7L1ecIaS/4fCIuFOjMTnCyjsu/OK6vaDKLCrGAa+KvvH+h4w== + +"@yarnpkg/lockfile@^1.1.0": + version "1.1.0" + resolved "https://registry.yarnpkg.com/@yarnpkg/lockfile/-/lockfile-1.1.0.tgz#e77a97fbd345b76d83245edcd17d393b1b41fb31" + integrity sha512-GpSwvyXOcOOlV70vbnzjj4fW5xW/FdUF6nQEt1ENy7m4ZCczi1+/buVUPAqmGfqznsORNFzUMjctTIp8a9tuCQ== + +abort-controller@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/abort-controller/-/abort-controller-3.0.0.tgz#eaf54d53b62bae4138e809ca225c8439a6efb392" + integrity sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg== + dependencies: + event-target-shim "^5.0.0" + +acorn-jsx@^5.3.2: + version "5.3.2" + resolved "https://registry.yarnpkg.com/acorn-jsx/-/acorn-jsx-5.3.2.tgz#7ed5bb55908b3b2f1bc55c6af1653bada7f07937" + integrity sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ== + +acorn@^8.15.0, acorn@^8.9.0: + version "8.15.0" + resolved "https://registry.yarnpkg.com/acorn/-/acorn-8.15.0.tgz#a360898bc415edaac46c8241f6383975b930b816" + integrity sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg== + +aggregate-error@^3.0.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/aggregate-error/-/aggregate-error-3.1.0.tgz#92670ff50f5359bdb7a3e0d40d0ec30c5737687a" + integrity sha512-4I7Td01quW/RpocfNayFdFVk1qSuoh0E7JrbRJ16nH01HhKFQ88INq9Sd+nd72zqRySlr9BmDA8xlEJ6vJMrYA== + dependencies: + clean-stack "^2.0.0" + indent-string "^4.0.0" + +ajv-draft-04@~1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/ajv-draft-04/-/ajv-draft-04-1.0.0.tgz#3b64761b268ba0b9e668f0b41ba53fce0ad77fc8" + integrity sha512-mv00Te6nmYbRp5DCwclxtt7yV/joXJPGS7nM+97GdxvuttCOfgI3K4U25zboyeX0O+myI8ERluxQe5wljMmVIw== + +ajv-formats@~3.0.1: + version "3.0.1" + resolved "https://registry.yarnpkg.com/ajv-formats/-/ajv-formats-3.0.1.tgz#3d5dc762bca17679c3c2ea7e90ad6b7532309578" + integrity sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ== + dependencies: + ajv "^8.0.0" + +ajv@^6.12.4: + version "6.12.6" + resolved "https://registry.yarnpkg.com/ajv/-/ajv-6.12.6.tgz#baf5a62e802b07d977034586f8c3baf5adf26df4" + integrity sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g== + dependencies: + fast-deep-equal "^3.1.1" + fast-json-stable-stringify "^2.0.0" + json-schema-traverse "^0.4.1" + uri-js "^4.2.2" + +ajv@^8.0.0: + version "8.17.1" + resolved "https://registry.yarnpkg.com/ajv/-/ajv-8.17.1.tgz#37d9a5c776af6bc92d7f4f9510eba4c0a60d11a6" + integrity sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g== + dependencies: + fast-deep-equal "^3.1.3" + fast-uri "^3.0.1" + json-schema-traverse "^1.0.0" + require-from-string "^2.0.2" + +ajv@~8.12.0: + version "8.12.0" + resolved "https://registry.yarnpkg.com/ajv/-/ajv-8.12.0.tgz#d1a0527323e22f53562c567c00991577dfbe19d1" + integrity sha512-sRu1kpcO9yLtYxBKvqfTeh9KzZEwO3STyX1HT+4CaDzC6HpTGYhIhPIzj9XuKU7KYDwnaeh5hcOwjy1QuJzBPA== + dependencies: + fast-deep-equal "^3.1.1" + json-schema-traverse "^1.0.0" + require-from-string "^2.0.2" + uri-js "^4.2.2" + +ajv@~8.13.0: + version "8.13.0" + resolved "https://registry.yarnpkg.com/ajv/-/ajv-8.13.0.tgz#a3939eaec9fb80d217ddf0c3376948c023f28c91" + integrity sha512-PRA911Blj99jR5RMeTunVbNXMF6Lp4vZXnk5GQjcnUWUTsrXtekg/pnmFFI2u/I36Y/2bITGS30GZCXei6uNkA== + dependencies: + fast-deep-equal "^3.1.3" + json-schema-traverse "^1.0.0" + require-from-string "^2.0.2" + uri-js "^4.4.1" + +alien-signals@^0.4.9: + version "0.4.14" + resolved "https://registry.yarnpkg.com/alien-signals/-/alien-signals-0.4.14.tgz#9ff8f72a272300a51692f54bd9bbbada78fbf539" + integrity sha512-itUAVzhczTmP2U5yX67xVpsbbOiquusbWVyA9N+sy6+r6YVbFkahXvNCeEPWEOMhwDYwbVbGHFkVL03N9I5g+Q== + +ansi-escapes@^4.3.2: + version "4.3.2" + resolved "https://registry.yarnpkg.com/ansi-escapes/-/ansi-escapes-4.3.2.tgz#6b2291d1db7d98b6521d5f1efa42d0f3a9feb65e" + integrity sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ== + dependencies: + type-fest "^0.21.3" + +ansi-escapes@^7.0.0: + version "7.2.0" + resolved "https://registry.yarnpkg.com/ansi-escapes/-/ansi-escapes-7.2.0.tgz#31b25afa3edd3efc09d98c2fee831d460ff06b49" + integrity sha512-g6LhBsl+GBPRWGWsBtutpzBYuIIdBkLEvad5C/va/74Db018+5TZiyA26cZJAr3Rft5lprVqOIPxf5Vid6tqAw== + dependencies: + environment "^1.0.0" + +ansi-regex@^5.0.1: + version "5.0.1" + resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-5.0.1.tgz#082cb2c89c9fe8659a311a53bd6a4dc5301db304" + integrity sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ== + +ansi-regex@^6.0.1: + version "6.2.2" + resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-6.2.2.tgz#60216eea464d864597ce2832000738a0589650c1" + integrity sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg== + +ansi-styles@^3.2.1: + version "3.2.1" + resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-3.2.1.tgz#41fbb20243e50b12be0f04b8dedbf07520ce841d" + integrity sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA== + dependencies: + color-convert "^1.9.0" + +ansi-styles@^4.0.0, ansi-styles@^4.1.0: + version "4.3.0" + resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-4.3.0.tgz#edd803628ae71c04c85ae7a0906edad34b648937" + integrity sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg== + dependencies: + color-convert "^2.0.1" + +ansi-styles@^5.0.0, ansi-styles@^5.2.0: + version "5.2.0" + resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-5.2.0.tgz#07449690ad45777d1924ac2abb2fc8895dba836b" + integrity sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA== + +ansi-styles@^6.1.0: + version "6.2.3" + resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-6.2.3.tgz#c044d5dcc521a076413472597a1acb1f103c4041" + integrity sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg== + +anymatch@^3.1.3: + version "3.1.3" + resolved "https://registry.yarnpkg.com/anymatch/-/anymatch-3.1.3.tgz#790c58b19ba1720a84205b57c618d5ad8524973e" + integrity sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw== + dependencies: + normalize-path "^3.0.0" + picomatch "^2.0.4" + +append-transform@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/append-transform/-/append-transform-2.0.0.tgz#99d9d29c7b38391e6f428d28ce136551f0b77e12" + integrity sha512-7yeyCEurROLQJFv5Xj4lEGTy0borxepjFv1g22oAdqFu//SrAlDl1O1Nxx15SH1RoliUml6p8dwJW9jvZughhg== + dependencies: + default-require-extensions "^3.0.0" + +archiver-utils@^5.0.0, archiver-utils@^5.0.2: + version "5.0.2" + resolved "https://registry.yarnpkg.com/archiver-utils/-/archiver-utils-5.0.2.tgz#63bc719d951803efc72cf961a56ef810760dd14d" + integrity sha512-wuLJMmIBQYCsGZgYLTy5FIB2pF6Lfb6cXMSF8Qywwk3t20zWnAi7zLcQFdKQmIB8wyZpY5ER38x08GbwtR2cLA== + dependencies: + glob "^10.0.0" + graceful-fs "^4.2.0" + is-stream "^2.0.1" + lazystream "^1.0.0" + lodash "^4.17.15" + normalize-path "^3.0.0" + readable-stream "^4.0.0" + +archiver@^7.0.1: + version "7.0.1" + resolved "https://registry.yarnpkg.com/archiver/-/archiver-7.0.1.tgz#c9d91c350362040b8927379c7aa69c0655122f61" + integrity sha512-ZcbTaIqJOfCc03QwD468Unz/5Ir8ATtvAHsK+FdXbDIbGfihqh9mrvdcYunQzqn4HrvWWaFyaxJhGZagaJJpPQ== + dependencies: + archiver-utils "^5.0.2" + async "^3.2.4" + buffer-crc32 "^1.0.0" + readable-stream "^4.0.0" + readdir-glob "^1.1.2" + tar-stream "^3.0.0" + zip-stream "^6.0.1" + +archy@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/archy/-/archy-1.0.0.tgz#f9c8c13757cc1dd7bc379ac77b2c62a5c2868c40" + integrity sha512-Xg+9RwCg/0p32teKdGMPTPnVXKD0w3DfHnFTficozsAgsvq2XenPJq/MYpzzQ/v8zrOyJn6Ds39VA4JIDwFfqw== + +argparse@^1.0.7, argparse@~1.0.9: + version "1.0.10" + resolved "https://registry.yarnpkg.com/argparse/-/argparse-1.0.10.tgz#bcd6791ea5ae09725e17e5ad988134cd40b3d911" + integrity sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg== + dependencies: + sprintf-js "~1.0.2" + +argparse@^2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/argparse/-/argparse-2.0.1.tgz#246f50f3ca78a3240f6c997e8a9bd1eac49e4b38" + integrity sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q== + +aria-query@5.3.0: + version "5.3.0" + resolved "https://registry.yarnpkg.com/aria-query/-/aria-query-5.3.0.tgz#650c569e41ad90b51b3d7df5e5eed1c7549c103e" + integrity sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A== + dependencies: + dequal "^2.0.3" + +aria-query@^5.0.0: + version "5.3.2" + resolved "https://registry.yarnpkg.com/aria-query/-/aria-query-5.3.2.tgz#93f81a43480e33a338f19163a3d10a50c01dcd59" + integrity sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw== + +asn1.js@^4.10.1: + version "4.10.1" + resolved "https://registry.yarnpkg.com/asn1.js/-/asn1.js-4.10.1.tgz#b9c2bf5805f1e64aadeed6df3a2bfafb5a73f5a0" + integrity sha512-p32cOF5q0Zqs9uBiONKYLm6BClCoBCM5O9JfeUSlnQLBTxYdTK+pW+nXflm8UkKd2UYlEbYz5qEi0JuZR9ckSw== + dependencies: + bn.js "^4.0.0" + inherits "^2.0.1" + minimalistic-assert "^1.0.0" + +asn1@^0.2.6: + version "0.2.6" + resolved "https://registry.yarnpkg.com/asn1/-/asn1-0.2.6.tgz#0d3a7bb6e64e02a90c0303b31f292868ea09a08d" + integrity sha512-ix/FxPn0MDjeyJ7i/yoHGFt/EX6LyNbxSEhPPXODPL+KB0VPk86UYfL0lMdy+KCnv+fmvIzySwaK5COwqVbWTQ== + dependencies: + safer-buffer "~2.1.0" + +assert@^2.0.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/assert/-/assert-2.1.0.tgz#6d92a238d05dc02e7427c881fb8be81c8448b2dd" + integrity sha512-eLHpSK/Y4nhMJ07gDaAzoX/XAKS8PSaojml3M0DM4JpV1LAi5JOJ/p6H/XWrl8L+DzVEvVCW1z3vWAaB9oTsQw== + dependencies: + call-bind "^1.0.2" + is-nan "^1.3.2" + object-is "^1.1.5" + object.assign "^4.1.4" + util "^0.12.5" + +assertion-error@^2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/assertion-error/-/assertion-error-2.0.1.tgz#f641a196b335690b1070bf00b6e7593fec190bf7" + integrity sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA== + +ast-types@^0.16.1: + version "0.16.1" + resolved "https://registry.yarnpkg.com/ast-types/-/ast-types-0.16.1.tgz#7a9da1617c9081bc121faafe91711b4c8bb81da2" + integrity sha512-6t10qk83GOG8p0vKmaCr8eiilZwO171AvbROMtvvNiwrTly62t+7XkA8RdIIVbpMhCASAsxgAzdRSwh6nw/5Dg== + dependencies: + tslib "^2.0.1" + +async-lock@^1.4.1: + version "1.4.1" + resolved "https://registry.yarnpkg.com/async-lock/-/async-lock-1.4.1.tgz#56b8718915a9b68b10fce2f2a9a3dddf765ef53f" + integrity sha512-Az2ZTpuytrtqENulXwO3GGv1Bztugx6TT37NIo7imr/Qo0gsYiGtSdBa2B6fsXhTpVZDNfu1Qn3pk531e3q+nQ== + +async@^3.2.4: + version "3.2.6" + resolved "https://registry.yarnpkg.com/async/-/async-3.2.6.tgz#1b0728e14929d51b85b449b7f06e27c1145e38ce" + integrity sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA== + +asynckit@^0.4.0: + version "0.4.0" + resolved "https://registry.yarnpkg.com/asynckit/-/asynckit-0.4.0.tgz#c79ed97f7f34cb8f2ba1bc9790bcc366474b4b79" + integrity sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q== + +available-typed-arrays@^1.0.7: + version "1.0.7" + resolved "https://registry.yarnpkg.com/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz#a5cc375d6a03c2efc87a553f3e0b1522def14846" + integrity sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ== + dependencies: + possible-typed-array-names "^1.0.0" + +axe-core@^4.2.0, axe-core@~4.11.0: + version "4.11.0" + resolved "https://registry.yarnpkg.com/axe-core/-/axe-core-4.11.0.tgz#16f74d6482e343ff263d4f4503829e9ee91a86b6" + integrity sha512-ilYanEU8vxxBexpJd8cWM4ElSQq4QctCLKih0TSfjIfCQTeyH/6zVrmIJfLPrKTKJRbiG+cfnZbQIjAlJmF1jQ== + +axios@^1.12.1, axios@^1.6.1: + version "1.13.2" + resolved "https://registry.yarnpkg.com/axios/-/axios-1.13.2.tgz#9ada120b7b5ab24509553ec3e40123521117f687" + integrity sha512-VPk9ebNqPcy5lRGuSlKx752IlDatOjT9paPlm8A7yOuW2Fbvp4X3JznJtT4f0GzGLLiWE9W8onz51SqLYwzGaA== + dependencies: + follow-redirects "^1.15.6" + form-data "^4.0.4" + proxy-from-env "^1.1.0" + +b4a@^1.6.4: + version "1.7.3" + resolved "https://registry.yarnpkg.com/b4a/-/b4a-1.7.3.tgz#24cf7ccda28f5465b66aec2bac69e32809bf112f" + integrity sha512-5Q2mfq2WfGuFp3uS//0s6baOJLMoVduPYVeNmDYxu5OUA1/cBfvr2RIS7vi62LdNj/urk1hfmj867I3qt6uZ7Q== + +babel-jest@30.2.0: + version "30.2.0" + resolved "https://registry.yarnpkg.com/babel-jest/-/babel-jest-30.2.0.tgz#fd44a1ec9552be35ead881f7381faa7d8f3b95ac" + integrity sha512-0YiBEOxWqKkSQWL9nNGGEgndoeL0ZpWrbLMNL5u/Kaxrli3Eaxlt3ZtIDktEvXt4L/R9r3ODr2zKwGM/2BjxVw== + dependencies: + "@jest/transform" "30.2.0" + "@types/babel__core" "^7.20.5" + babel-plugin-istanbul "^7.0.1" + babel-preset-jest "30.2.0" + chalk "^4.1.2" + graceful-fs "^4.2.11" + slash "^3.0.0" + +babel-plugin-istanbul@^7.0.1: + version "7.0.1" + resolved "https://registry.yarnpkg.com/babel-plugin-istanbul/-/babel-plugin-istanbul-7.0.1.tgz#d8b518c8ea199364cf84ccc82de89740236daf92" + integrity sha512-D8Z6Qm8jCvVXtIRkBnqNHX0zJ37rQcFJ9u8WOS6tkYOsRdHBzypCstaxWiu5ZIlqQtviRYbgnRLSoCEvjqcqbA== + dependencies: + "@babel/helper-plugin-utils" "^7.0.0" + "@istanbuljs/load-nyc-config" "^1.0.0" + "@istanbuljs/schema" "^0.1.3" + istanbul-lib-instrument "^6.0.2" + test-exclude "^6.0.0" + +babel-plugin-jest-hoist@30.2.0: + version "30.2.0" + resolved "https://registry.yarnpkg.com/babel-plugin-jest-hoist/-/babel-plugin-jest-hoist-30.2.0.tgz#94c250d36b43f95900f3a219241e0f4648191ce2" + integrity sha512-ftzhzSGMUnOzcCXd6WHdBGMyuwy15Wnn0iyyWGKgBDLxf9/s5ABuraCSpBX2uG0jUg4rqJnxsLc5+oYBqoxVaA== + dependencies: + "@types/babel__core" "^7.20.5" + +babel-preset-current-node-syntax@^1.2.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/babel-preset-current-node-syntax/-/babel-preset-current-node-syntax-1.2.0.tgz#20730d6cdc7dda5d89401cab10ac6a32067acde6" + integrity sha512-E/VlAEzRrsLEb2+dv8yp3bo4scof3l9nR4lrld+Iy5NyVqgVYUJnDAmunkhPMisRI32Qc4iRiz425d8vM++2fg== + dependencies: + "@babel/plugin-syntax-async-generators" "^7.8.4" + "@babel/plugin-syntax-bigint" "^7.8.3" + "@babel/plugin-syntax-class-properties" "^7.12.13" + "@babel/plugin-syntax-class-static-block" "^7.14.5" + "@babel/plugin-syntax-import-attributes" "^7.24.7" + "@babel/plugin-syntax-import-meta" "^7.10.4" + "@babel/plugin-syntax-json-strings" "^7.8.3" + "@babel/plugin-syntax-logical-assignment-operators" "^7.10.4" + "@babel/plugin-syntax-nullish-coalescing-operator" "^7.8.3" + "@babel/plugin-syntax-numeric-separator" "^7.10.4" + "@babel/plugin-syntax-object-rest-spread" "^7.8.3" + "@babel/plugin-syntax-optional-catch-binding" "^7.8.3" + "@babel/plugin-syntax-optional-chaining" "^7.8.3" + "@babel/plugin-syntax-private-property-in-object" "^7.14.5" + "@babel/plugin-syntax-top-level-await" "^7.14.5" + +babel-preset-jest@30.2.0: + version "30.2.0" + resolved "https://registry.yarnpkg.com/babel-preset-jest/-/babel-preset-jest-30.2.0.tgz#04717843e561347781d6d7f69c81e6bcc3ed11ce" + integrity sha512-US4Z3NOieAQumwFnYdUWKvUKh8+YSnS/gB3t6YBiz0bskpu7Pine8pPCheNxlPEW4wnUkma2a94YuW2q3guvCQ== + dependencies: + babel-plugin-jest-hoist "30.2.0" + babel-preset-current-node-syntax "^1.2.0" + +balanced-match@^1.0.0: + version "1.0.2" + resolved "https://registry.yarnpkg.com/balanced-match/-/balanced-match-1.0.2.tgz#e83e3a7e3f300b34cb9d87f615fa0cbf357690ee" + integrity sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw== + +bare-events@^2.5.4, bare-events@^2.7.0: + version "2.8.2" + resolved "https://registry.yarnpkg.com/bare-events/-/bare-events-2.8.2.tgz#7b3e10bd8e1fc80daf38bb516921678f566ab89f" + integrity sha512-riJjyv1/mHLIPX4RwiK+oW9/4c3TEUeORHKefKAKnZ5kyslbN+HXowtbaVEqt4IMUB7OXlfixcs6gsFeo/jhiQ== + +bare-fs@^4.0.1: + version "4.5.2" + resolved "https://registry.yarnpkg.com/bare-fs/-/bare-fs-4.5.2.tgz#d80ff8a9177e0db4818e7ba44b9302c0cf0788af" + integrity sha512-veTnRzkb6aPHOvSKIOy60KzURfBdUflr5VReI+NSaPL6xf+XLdONQgZgpYvUuZLVQ8dCqxpBAudaOM1+KpAUxw== + dependencies: + bare-events "^2.5.4" + bare-path "^3.0.0" + bare-stream "^2.6.4" + bare-url "^2.2.2" + fast-fifo "^1.3.2" + +bare-os@^3.0.1: + version "3.6.2" + resolved "https://registry.yarnpkg.com/bare-os/-/bare-os-3.6.2.tgz#b3c4f5ad5e322c0fd0f3c29fc97d19009e2796e5" + integrity sha512-T+V1+1srU2qYNBmJCXZkUY5vQ0B4FSlL3QDROnKQYOqeiQR8UbjNHlPa+TIbM4cuidiN9GaTaOZgSEgsvPbh5A== + +bare-path@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/bare-path/-/bare-path-3.0.0.tgz#b59d18130ba52a6af9276db3e96a2e3d3ea52178" + integrity sha512-tyfW2cQcB5NN8Saijrhqn0Zh7AnFNsnczRcuWODH0eYAXBsJ5gVxAUuNr7tsHSC6IZ77cA0SitzT+s47kot8Mw== + dependencies: + bare-os "^3.0.1" + +bare-stream@^2.6.4: + version "2.7.0" + resolved "https://registry.yarnpkg.com/bare-stream/-/bare-stream-2.7.0.tgz#5b9e7dd0a354d06e82d6460c426728536c35d789" + integrity sha512-oyXQNicV1y8nc2aKffH+BUHFRXmx6VrPzlnaEvMhram0nPBrKcEdcyBg5r08D0i8VxngHFAiVyn1QKXpSG0B8A== + dependencies: + streamx "^2.21.0" + +bare-url@^2.2.2: + version "2.3.2" + resolved "https://registry.yarnpkg.com/bare-url/-/bare-url-2.3.2.tgz#4aef382efa662b2180a6fe4ca07a71b39bdf7ca3" + integrity sha512-ZMq4gd9ngV5aTMa5p9+UfY0b3skwhHELaDkhEHetMdX0LRkW9kzaym4oo/Eh+Ghm0CCDuMTsRIGM/ytUc1ZYmw== + dependencies: + bare-path "^3.0.0" + +base64-js@^1.3.1: + version "1.5.1" + resolved "https://registry.yarnpkg.com/base64-js/-/base64-js-1.5.1.tgz#1b1b440160a5bf7ad40b650f095963481903930a" + integrity sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA== + +baseline-browser-mapping@^2.9.0: + version "2.9.5" + resolved "https://registry.yarnpkg.com/baseline-browser-mapping/-/baseline-browser-mapping-2.9.5.tgz#47f9549e0be1a84cd16651ac4c3b7d87a71408e6" + integrity sha512-D5vIoztZOq1XM54LUdttJVc96ggEsIfju2JBvht06pSzpckp3C7HReun67Bghzrtdsq9XdMGbSSB3v3GhMNmAA== + +bcrypt-pbkdf@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/bcrypt-pbkdf/-/bcrypt-pbkdf-1.0.2.tgz#a4301d389b6a43f9b67ff3ca11a3f6637e360e9e" + integrity sha512-qeFIXtP4MSoi6NLqO12WfqARWWuCKi2Rn/9hJLEmtB5yTNr9DqFWkJRCf2qShWzPeAMRnOgCrq0sg/KLv5ES9w== + dependencies: + tweetnacl "^0.14.3" + +bl@^4.0.3: + version "4.1.0" + resolved "https://registry.yarnpkg.com/bl/-/bl-4.1.0.tgz#451535264182bec2fbbc83a62ab98cf11d9f7b3a" + integrity sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w== + dependencies: + buffer "^5.5.0" + inherits "^2.0.4" + readable-stream "^3.4.0" + +bn.js@^4.0.0, bn.js@^4.1.0, bn.js@^4.11.9: + version "4.12.2" + resolved "https://registry.yarnpkg.com/bn.js/-/bn.js-4.12.2.tgz#3d8fed6796c24e177737f7cc5172ee04ef39ec99" + integrity sha512-n4DSx829VRTRByMRGdjQ9iqsN0Bh4OolPsFnaZBLcbi8iXcB+kJ9s7EnRt4wILZNV3kPLHkRVfOc/HvhC3ovDw== + +bn.js@^5.2.1, bn.js@^5.2.2: + version "5.2.2" + resolved "https://registry.yarnpkg.com/bn.js/-/bn.js-5.2.2.tgz#82c09f9ebbb17107cd72cb7fd39bd1f9d0aaa566" + integrity sha512-v2YAxEmKaBLahNwE1mjp4WON6huMNeuDvagFZW+ASCuA/ku0bXR9hSMw0XpiqMoA3+rmnyck/tPRSFQkoC9Cuw== + +brace-expansion@^1.1.7: + version "1.1.12" + resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-1.1.12.tgz#ab9b454466e5a8cc3a187beaad580412a9c5b843" + integrity sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg== + dependencies: + balanced-match "^1.0.0" + concat-map "0.0.1" + +brace-expansion@^2.0.1: + version "2.0.2" + resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-2.0.2.tgz#54fc53237a613d854c7bd37463aad17df87214e7" + integrity sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ== + dependencies: + balanced-match "^1.0.0" + +braces@^3.0.3: + version "3.0.3" + resolved "https://registry.yarnpkg.com/braces/-/braces-3.0.3.tgz#490332f40919452272d55a8480adc0c441358789" + integrity sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA== + dependencies: + fill-range "^7.1.1" + +brorand@^1.0.1, brorand@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/brorand/-/brorand-1.1.0.tgz#12c25efe40a45e3c323eb8675a0a0ce57b22371f" + integrity sha512-cKV8tMCEpQs4hK/ik71d6LrPOnpkpGBR0wzxqr68g2m/LB2GxVYQroAjMJZRVM1Y4BCjCKc3vAamxSzOY2RP+w== + +browser-resolve@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/browser-resolve/-/browser-resolve-2.0.0.tgz#99b7304cb392f8d73dba741bb2d7da28c6d7842b" + integrity sha512-7sWsQlYL2rGLy2IWm8WL8DCTJvYLc/qlOnsakDac87SOoCd16WLsaAMdCiAqsTNHIe+SXfaqyxyo6THoWqs8WQ== + dependencies: + resolve "^1.17.0" + +browserify-aes@^1.0.4, browserify-aes@^1.2.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/browserify-aes/-/browserify-aes-1.2.0.tgz#326734642f403dabc3003209853bb70ad428ef48" + integrity sha512-+7CHXqGuspUn/Sl5aO7Ea0xWGAtETPXNSAjHo48JfLdPWcMng33Xe4znFvQweqc/uzk5zSOI3H52CYnjCfb5hA== + dependencies: + buffer-xor "^1.0.3" + cipher-base "^1.0.0" + create-hash "^1.1.0" + evp_bytestokey "^1.0.3" + inherits "^2.0.1" + safe-buffer "^5.0.1" + +browserify-cipher@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/browserify-cipher/-/browserify-cipher-1.0.1.tgz#8d6474c1b870bfdabcd3bcfcc1934a10e94f15f0" + integrity sha512-sPhkz0ARKbf4rRQt2hTpAHqn47X3llLkUGn+xEJzLjwY8LRs2p0v7ljvI5EyoRO/mexrNunNECisZs+gw2zz1w== + dependencies: + browserify-aes "^1.0.4" + browserify-des "^1.0.0" + evp_bytestokey "^1.0.0" + +browserify-des@^1.0.0: + version "1.0.2" + resolved "https://registry.yarnpkg.com/browserify-des/-/browserify-des-1.0.2.tgz#3af4f1f59839403572f1c66204375f7a7f703e9c" + integrity sha512-BioO1xf3hFwz4kc6iBhI3ieDFompMhrMlnDFC4/0/vd5MokpuAc3R+LYbwTA9A5Yc9pq9UYPqffKpW2ObuwX5A== + dependencies: + cipher-base "^1.0.1" + des.js "^1.0.0" + inherits "^2.0.1" + safe-buffer "^5.1.2" + +browserify-rsa@^4.0.0, browserify-rsa@^4.1.1: + version "4.1.1" + resolved "https://registry.yarnpkg.com/browserify-rsa/-/browserify-rsa-4.1.1.tgz#06e530907fe2949dc21fc3c2e2302e10b1437238" + integrity sha512-YBjSAiTqM04ZVei6sXighu679a3SqWORA3qZTEqZImnlkDIFtKc6pNutpjyZ8RJTjQtuYfeetkxM11GwoYXMIQ== + dependencies: + bn.js "^5.2.1" + randombytes "^2.1.0" + safe-buffer "^5.2.1" + +browserify-sign@^4.2.3: + version "4.2.5" + resolved "https://registry.yarnpkg.com/browserify-sign/-/browserify-sign-4.2.5.tgz#3979269fa8af55ba18aac35deef11b45515cd27d" + integrity sha512-C2AUdAJg6rlM2W5QMp2Q4KGQMVBwR1lIimTsUnutJ8bMpW5B52pGpR2gEnNBNwijumDo5FojQ0L9JrXA8m4YEw== + dependencies: + bn.js "^5.2.2" + browserify-rsa "^4.1.1" + create-hash "^1.2.0" + create-hmac "^1.1.7" + elliptic "^6.6.1" + inherits "^2.0.4" + parse-asn1 "^5.1.9" + readable-stream "^2.3.8" + safe-buffer "^5.2.1" + +browserify-zlib@^0.2.0: + version "0.2.0" + resolved "https://registry.yarnpkg.com/browserify-zlib/-/browserify-zlib-0.2.0.tgz#2869459d9aa3be245fe8fe2ca1f46e2e7f54d73f" + integrity sha512-Z942RysHXmJrhqk88FmKBVq/v5tqmSkDz7p54G/MGyjMnCFFnC79XWNbg+Vta8W6Wb2qtSZTSxIGkJrRpCFEiA== + dependencies: + pako "~1.0.5" + +browserslist@^4.24.0: + version "4.28.1" + resolved "https://registry.yarnpkg.com/browserslist/-/browserslist-4.28.1.tgz#7f534594628c53c63101079e27e40de490456a95" + integrity sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA== + dependencies: + baseline-browser-mapping "^2.9.0" + caniuse-lite "^1.0.30001759" + electron-to-chromium "^1.5.263" + node-releases "^2.0.27" + update-browserslist-db "^1.2.0" + +bser@2.1.1: + version "2.1.1" + resolved "https://registry.yarnpkg.com/bser/-/bser-2.1.1.tgz#e6787da20ece9d07998533cfd9de6f5c38f4bc05" + integrity sha512-gQxTNE/GAfIIrmHLUE3oJyp5FO6HRBfhjnw4/wMmA63ZGDJnWBmgY/lyQBpnDUkGmAhbSe39tx2d/iTOAfglwQ== + dependencies: + node-int64 "^0.4.0" + +buffer-crc32@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/buffer-crc32/-/buffer-crc32-1.0.0.tgz#a10993b9055081d55304bd9feb4a072de179f405" + integrity sha512-Db1SbgBS/fg/392AblrMJk97KggmvYhr4pB5ZIMTWtaivCPMWLkmb7m21cJvpvgK+J3nsU2CmmixNBZx4vFj/w== + +buffer-from@^1.0.0: + version "1.1.2" + resolved "https://registry.yarnpkg.com/buffer-from/-/buffer-from-1.1.2.tgz#2b146a6fd72e80b4f55d255f35ed59a3a9a41bd5" + integrity sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ== + +buffer-xor@^1.0.3: + version "1.0.3" + resolved "https://registry.yarnpkg.com/buffer-xor/-/buffer-xor-1.0.3.tgz#26e61ed1422fb70dd42e6e36729ed51d855fe8d9" + integrity sha512-571s0T7nZWK6vB67HI5dyUF7wXiNcfaPPPTl6zYCNApANjIvYJTg7hlud/+cJpdAhS7dVzqMLmfhfHR3rAcOjQ== + +buffer@^5.5.0, buffer@^5.7.1: + version "5.7.1" + resolved "https://registry.yarnpkg.com/buffer/-/buffer-5.7.1.tgz#ba62e7c13133053582197160851a8f648e99eed0" + integrity sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ== + dependencies: + base64-js "^1.3.1" + ieee754 "^1.1.13" + +buffer@^6.0.3: + version "6.0.3" + resolved "https://registry.yarnpkg.com/buffer/-/buffer-6.0.3.tgz#2ace578459cc8fbe2a70aaa8f52ee63b6a74c6c6" + integrity sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA== + dependencies: + base64-js "^1.3.1" + ieee754 "^1.2.1" + +buildcheck@~0.0.6: + version "0.0.7" + resolved "https://registry.yarnpkg.com/buildcheck/-/buildcheck-0.0.7.tgz#07a5e76c10ead8fa67d9e4c587b68f49e8f29d61" + integrity sha512-lHblz4ahamxpTmnsk+MNTRWsjYKv965MwOrSJyeD588rR3Jcu7swE+0wN5F+PbL5cjgu/9ObkhfzEPuofEMwLA== + +builtin-status-codes@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/builtin-status-codes/-/builtin-status-codes-3.0.0.tgz#85982878e21b98e1c66425e03d0174788f569ee8" + integrity sha512-HpGFw18DgFWlncDfjTa2rcQ4W88O1mC8e8yZ2AvQY5KDaktSTwo+KRf6nHK6FRI5FyRyb/5T6+TSxfP7QyGsmQ== + +bundle-name@^4.1.0: + version "4.1.0" + resolved "https://registry.yarnpkg.com/bundle-name/-/bundle-name-4.1.0.tgz#f3b96b34160d6431a19d7688135af7cfb8797889" + integrity sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q== + dependencies: + run-applescript "^7.0.0" + +byline@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/byline/-/byline-5.0.0.tgz#741c5216468eadc457b03410118ad77de8c1ddb1" + integrity sha512-s6webAy+R4SR8XVuJWt2V2rGvhnrhxN+9S15GNuTK3wKPOXFF6RNc+8ug2XhH+2s4f+uudG4kUVYmYOQWL2g0Q== + +caching-transform@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/caching-transform/-/caching-transform-4.0.0.tgz#00d297a4206d71e2163c39eaffa8157ac0651f0f" + integrity sha512-kpqOvwXnjjN44D89K5ccQC+RUrsy7jB/XLlRrx0D7/2HNcTPqzsb6XgYoErwko6QsV184CA2YgS1fxDiiDZMWA== + dependencies: + hasha "^5.0.0" + make-dir "^3.0.0" + package-hash "^4.0.0" + write-file-atomic "^3.0.0" + +call-bind-apply-helpers@^1.0.0, call-bind-apply-helpers@^1.0.1, call-bind-apply-helpers@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz#4b5428c222be985d79c3d82657479dbe0b59b2d6" + integrity sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ== + dependencies: + es-errors "^1.3.0" + function-bind "^1.1.2" + +call-bind@^1.0.0, call-bind@^1.0.2, call-bind@^1.0.7, call-bind@^1.0.8: + version "1.0.8" + resolved "https://registry.yarnpkg.com/call-bind/-/call-bind-1.0.8.tgz#0736a9660f537e3388826f440d5ec45f744eaa4c" + integrity sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww== + dependencies: + call-bind-apply-helpers "^1.0.0" + es-define-property "^1.0.0" + get-intrinsic "^1.2.4" + set-function-length "^1.2.2" + +call-bound@^1.0.2, call-bound@^1.0.3, call-bound@^1.0.4: + version "1.0.4" + resolved "https://registry.yarnpkg.com/call-bound/-/call-bound-1.0.4.tgz#238de935d2a2a692928c538c7ccfa91067fd062a" + integrity sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg== + dependencies: + call-bind-apply-helpers "^1.0.2" + get-intrinsic "^1.3.0" + +callsites@^3.0.0, callsites@^3.1.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/callsites/-/callsites-3.1.0.tgz#b3630abd8943432f54b3f0519238e33cd7df2f73" + integrity sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ== + +camelcase@^5.0.0, camelcase@^5.3.1: + version "5.3.1" + resolved "https://registry.yarnpkg.com/camelcase/-/camelcase-5.3.1.tgz#e3c9b31569e106811df242f715725a1f4c494320" + integrity sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg== + +camelcase@^6.3.0: + version "6.3.0" + resolved "https://registry.yarnpkg.com/camelcase/-/camelcase-6.3.0.tgz#5685b95eb209ac9c0c177467778c9c84df58ba9a" + integrity sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA== + +caniuse-lite@^1.0.30001759: + version "1.0.30001760" + resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001760.tgz#bdd1960fafedf8d5f04ff16e81460506ff9b798f" + integrity sha512-7AAMPcueWELt1p3mi13HR/LHH0TJLT11cnwDJEs3xA4+CK/PLKeO9Kl1oru24htkyUKtkGCvAx4ohB0Ttry8Dw== + +chai@^5.2.0: + version "5.3.3" + resolved "https://registry.yarnpkg.com/chai/-/chai-5.3.3.tgz#dd3da955e270916a4bd3f625f4b919996ada7e06" + integrity sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw== + dependencies: + assertion-error "^2.0.1" + check-error "^2.1.1" + deep-eql "^5.0.1" + loupe "^3.1.0" + pathval "^2.0.0" + +chalk@4.1.2, chalk@^4.0.0, chalk@^4.1.0, chalk@^4.1.2, chalk@~4.1.2: + version "4.1.2" + resolved "https://registry.yarnpkg.com/chalk/-/chalk-4.1.2.tgz#aac4e2b7734a740867aeb16bf02aad556a1e7a01" + integrity sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA== + dependencies: + ansi-styles "^4.1.0" + supports-color "^7.1.0" + +chalk@^2.4.2: + version "2.4.2" + resolved "https://registry.yarnpkg.com/chalk/-/chalk-2.4.2.tgz#cd42541677a54333cf541a49108c1432b44c9424" + integrity sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ== + dependencies: + ansi-styles "^3.2.1" + escape-string-regexp "^1.0.5" + supports-color "^5.3.0" + +chalk@^5.2.0: + version "5.6.2" + resolved "https://registry.yarnpkg.com/chalk/-/chalk-5.6.2.tgz#b1238b6e23ea337af71c7f8a295db5af0c158aea" + integrity sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA== + +char-regex@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/char-regex/-/char-regex-1.0.2.tgz#d744358226217f981ed58f479b1d6bcc29545dcf" + integrity sha512-kWWXztvZ5SBQV+eRgKFeh8q5sLuZY2+8WUIzlxWVTg+oGwY14qylx1KbKzHd8P6ZYkAg0xyIDU9JMHhyJMZ1jw== + +check-error@^2.1.1: + version "2.1.1" + resolved "https://registry.yarnpkg.com/check-error/-/check-error-2.1.1.tgz#87eb876ae71ee388fa0471fe423f494be1d96ccc" + integrity sha512-OAlb+T7V4Op9OwdkjmguYRqncdlx5JiofwOAUkmTF+jNdHwzTaTs4sRAGpzLF3oOz5xAyDGrPgeIDFQmDOTiJw== + +chownr@^1.1.1: + version "1.1.4" + resolved "https://registry.yarnpkg.com/chownr/-/chownr-1.1.4.tgz#6fc9d7b42d32a583596337666e7d08084da2cc6b" + integrity sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg== + +ci-info@^3.7.0: + version "3.9.0" + resolved "https://registry.yarnpkg.com/ci-info/-/ci-info-3.9.0.tgz#4279a62028a7b1f262f3473fc9605f5e218c59b4" + integrity sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ== + +ci-info@^4.2.0: + version "4.3.1" + resolved "https://registry.yarnpkg.com/ci-info/-/ci-info-4.3.1.tgz#355ad571920810b5623e11d40232f443f16f1daa" + integrity sha512-Wdy2Igu8OcBpI2pZePZ5oWjPC38tmDVx5WKUXKwlLYkA0ozo85sLsLvkBbBn/sZaSCMFOGZJ14fvW9t5/d7kdA== + +cipher-base@^1.0.0, cipher-base@^1.0.1, cipher-base@^1.0.3: + version "1.0.7" + resolved "https://registry.yarnpkg.com/cipher-base/-/cipher-base-1.0.7.tgz#bd094bfef42634ccfd9e13b9fc73274997111e39" + integrity sha512-Mz9QMT5fJe7bKI7MH31UilT5cEK5EHHRCccw/YRFsRY47AuNgaV6HY3rscp0/I4Q+tTW/5zoqpSeRRI54TkDWA== + dependencies: + inherits "^2.0.4" + safe-buffer "^5.2.1" + to-buffer "^1.2.2" + +cjs-module-lexer@^2.1.0: + version "2.1.1" + resolved "https://registry.yarnpkg.com/cjs-module-lexer/-/cjs-module-lexer-2.1.1.tgz#bff23b0609cc9afa428bd35f1918f7d03b448562" + integrity sha512-+CmxIZ/L2vNcEfvNtLdU0ZQ6mbq3FZnwAP2PPTiKP+1QOoKwlKlPgb8UKV0Dds7QVaMnHm+FwSft2VB0s/SLjQ== + +classnames@^2.5.1: + version "2.5.1" + resolved "https://registry.yarnpkg.com/classnames/-/classnames-2.5.1.tgz#ba774c614be0f016da105c858e7159eae8e7687b" + integrity sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow== + +clean-stack@^2.0.0: + version "2.2.0" + resolved "https://registry.yarnpkg.com/clean-stack/-/clean-stack-2.2.0.tgz#ee8472dbb129e727b31e8a10a427dee9dfe4008b" + integrity sha512-4diC9HaTE+KRAMWhDhrGOECgWZxoevMc5TlkObMqNSsVU62PYzXZ/SMTjzyGAFF1YusgxGcSWTEXBhp0CPwQ1A== + +cliui@^6.0.0: + version "6.0.0" + resolved "https://registry.yarnpkg.com/cliui/-/cliui-6.0.0.tgz#511d702c0c4e41ca156d7d0e96021f23e13225b1" + integrity sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ== + dependencies: + string-width "^4.2.0" + strip-ansi "^6.0.0" + wrap-ansi "^6.2.0" + +cliui@^8.0.1: + version "8.0.1" + resolved "https://registry.yarnpkg.com/cliui/-/cliui-8.0.1.tgz#0c04b075db02cbfe60dc8e6cf2f5486b1a3608aa" + integrity sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ== + dependencies: + string-width "^4.2.0" + strip-ansi "^6.0.1" + wrap-ansi "^7.0.0" + +co@^4.6.0: + version "4.6.0" + resolved "https://registry.yarnpkg.com/co/-/co-4.6.0.tgz#6ea6bdf3d853ae54ccb8e47bfa0bf3f9031fb184" + integrity sha512-QVb0dM5HvG+uaxitm8wONl7jltx8dqhfU33DcqtOZcLSVIKSDDLDi7+0LbAKiyI8hD9u42m2YxXSkMGWThaecQ== + +collect-v8-coverage@^1.0.2: + version "1.0.3" + resolved "https://registry.yarnpkg.com/collect-v8-coverage/-/collect-v8-coverage-1.0.3.tgz#cc1f01eb8d02298cbc9a437c74c70ab4e5210b80" + integrity sha512-1L5aqIkwPfiodaMgQunkF1zRhNqifHBmtbbbxcr6yVxxBnliw4TDOW6NxpO8DJLgJ16OT+Y4ztZqP6p/FtXnAw== + +color-convert@^1.9.0: + version "1.9.3" + resolved "https://registry.yarnpkg.com/color-convert/-/color-convert-1.9.3.tgz#bb71850690e1f136567de629d2d5471deda4c1e8" + integrity sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg== + dependencies: + color-name "1.1.3" + +color-convert@^2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/color-convert/-/color-convert-2.0.1.tgz#72d3a68d598c9bdb3af2ad1e84f21d896abd4de3" + integrity sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ== + dependencies: + color-name "~1.1.4" + +color-name@1.1.3: + version "1.1.3" + resolved "https://registry.yarnpkg.com/color-name/-/color-name-1.1.3.tgz#a7d0558bd89c42f795dd42328f740831ca53bc25" + integrity sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw== + +color-name@~1.1.4: + version "1.1.4" + resolved "https://registry.yarnpkg.com/color-name/-/color-name-1.1.4.tgz#c2a09a87acbde69543de6f63fa3995c826c536a2" + integrity sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA== + +combined-stream@^1.0.8: + version "1.0.8" + resolved "https://registry.yarnpkg.com/combined-stream/-/combined-stream-1.0.8.tgz#c3d45a8b34fd730631a110a8a2520682b31d5a7f" + integrity sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg== + dependencies: + delayed-stream "~1.0.0" + +commander@^12.1.0: + version "12.1.0" + resolved "https://registry.yarnpkg.com/commander/-/commander-12.1.0.tgz#01423b36f501259fdaac4d0e4d60c96c991585d3" + integrity sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA== + +commander@^3.0.2: + version "3.0.2" + resolved "https://registry.yarnpkg.com/commander/-/commander-3.0.2.tgz#6837c3fb677ad9933d1cfba42dd14d5117d6b39e" + integrity sha512-Gar0ASD4BDyKC4hl4DwHqDrmvjoxWKZigVnAbn5H1owvm4CxCPdb0HQDehwNYMJpla5+M2tPmPARzhtYuwpHow== + +commondir@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/commondir/-/commondir-1.0.1.tgz#ddd800da0c66127393cca5950ea968a3aaf1253b" + integrity sha512-W9pAhw0ja1Edb5GVdIF1mjZw/ASI0AlShXM83UUGe2DVr5TdAPEA1OA8m/g8zWp9x6On7gqufY+FatDbC3MDQg== + +compare-versions@^6.1.1: + version "6.1.1" + resolved "https://registry.yarnpkg.com/compare-versions/-/compare-versions-6.1.1.tgz#7af3cc1099ba37d244b3145a9af5201b629148a9" + integrity sha512-4hm4VPpIecmlg59CHXnRDnqGplJFrbLG4aFEl5vl6cK1u76ws3LLvX7ikFnTDl5vo39sjWD6AaDPYodJp/NNHg== + +compress-commons@^6.0.2: + version "6.0.2" + resolved "https://registry.yarnpkg.com/compress-commons/-/compress-commons-6.0.2.tgz#26d31251a66b9d6ba23a84064ecd3a6a71d2609e" + integrity sha512-6FqVXeETqWPoGcfzrXb37E50NP0LXT8kAMu5ooZayhWWdgEY4lBEEcbQNXtkuKQsGduxiIcI4gOTsxTmuq/bSg== + dependencies: + crc-32 "^1.2.0" + crc32-stream "^6.0.0" + is-stream "^2.0.1" + normalize-path "^3.0.0" + readable-stream "^4.0.0" + +concat-map@0.0.1: + version "0.0.1" + resolved "https://registry.yarnpkg.com/concat-map/-/concat-map-0.0.1.tgz#d8a96bd77fd68df7793a73036a3ba0d5405d477b" + integrity sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg== + +concurrently@^9.2.1: + version "9.2.1" + resolved "https://registry.yarnpkg.com/concurrently/-/concurrently-9.2.1.tgz#248ea21b95754947be2dad9c3e4b60f18ca4e44f" + integrity sha512-fsfrO0MxV64Znoy8/l1vVIjjHa29SZyyqPgQBwhiDcaW8wJc2W3XWVOGx4M3oJBnv/zdUZIIp1gDeS98GzP8Ng== + dependencies: + chalk "4.1.2" + rxjs "7.8.2" + shell-quote "1.8.3" + supports-color "8.1.1" + tree-kill "1.2.2" + yargs "17.7.2" + +confbox@^0.1.8: + version "0.1.8" + resolved "https://registry.yarnpkg.com/confbox/-/confbox-0.1.8.tgz#820d73d3b3c82d9bd910652c5d4d599ef8ff8b06" + integrity sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w== + +confbox@^0.2.2: + version "0.2.2" + resolved "https://registry.yarnpkg.com/confbox/-/confbox-0.2.2.tgz#8652f53961c74d9e081784beed78555974a9c110" + integrity sha512-1NB+BKqhtNipMsov4xI/NnhCKp9XG9NamYp5PVm9klAT0fsrNPjaFICsCFhNhwZJKNh7zB/3q8qXz0E9oaMNtQ== + +console-browserify@^1.1.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/console-browserify/-/console-browserify-1.2.0.tgz#67063cef57ceb6cf4993a2ab3a55840ae8c49336" + integrity sha512-ZMkYO/LkF17QvCPqM0gxw8yUzigAOZOSWSHg91FH6orS7vcEj5dVZTidN2fQ14yBSdg97RqhSNwLUXInd52OTA== + +constants-browserify@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/constants-browserify/-/constants-browserify-1.0.0.tgz#c20b96d8c617748aaf1c16021760cd27fcb8cb75" + integrity sha512-xFxOwqIzR/e1k1gLiWEophSCMqXcwVHIH7akf7b/vxcUeGunlj3hvZaaqxwHsTgn+IndtkQJgSztIDWeumWJDQ== + +convert-source-map@^1.7.0: + version "1.9.0" + resolved "https://registry.yarnpkg.com/convert-source-map/-/convert-source-map-1.9.0.tgz#7faae62353fb4213366d0ca98358d22e8368b05f" + integrity sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A== + +convert-source-map@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/convert-source-map/-/convert-source-map-2.0.0.tgz#4b560f649fc4e918dd0ab75cf4961e8bc882d82a" + integrity sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg== + +core-util-is@~1.0.0: + version "1.0.3" + resolved "https://registry.yarnpkg.com/core-util-is/-/core-util-is-1.0.3.tgz#a6042d3634c2b27e9328f837b965fac83808db85" + integrity sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ== + +counterpart@^0.18.6: + version "0.18.6" + resolved "https://registry.yarnpkg.com/counterpart/-/counterpart-0.18.6.tgz#cf6b60d8ef99a4b44b8bf6445fa99b4bd1b2f9dd" + integrity sha512-cAIDAYbC3x8S2DDbvFEJ4TzPtPYXma25/kfAkfmprNLlkPWeX4SdUp1c2xklfphqCU3HnDaivR4R3BrAYf5OMA== + dependencies: + date-names "^0.1.11" + except "^0.1.3" + extend "^3.0.0" + pluralizers "^0.1.7" + sprintf-js "^1.0.3" + +cpu-features@~0.0.10: + version "0.0.10" + resolved "https://registry.yarnpkg.com/cpu-features/-/cpu-features-0.0.10.tgz#9aae536db2710c7254d7ed67cb3cbc7d29ad79c5" + integrity sha512-9IkYqtX3YHPCzoVg1Py+o9057a3i0fp7S530UWokCSaFVTc7CwXPRiOjRjBQQ18ZCNafx78YfnG+HALxtVmOGA== + dependencies: + buildcheck "~0.0.6" + nan "^2.19.0" + +crc-32@^1.2.0: + version "1.2.2" + resolved "https://registry.yarnpkg.com/crc-32/-/crc-32-1.2.2.tgz#3cad35a934b8bf71f25ca524b6da51fb7eace2ff" + integrity sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ== + +crc32-stream@^6.0.0: + version "6.0.0" + resolved "https://registry.yarnpkg.com/crc32-stream/-/crc32-stream-6.0.0.tgz#8529a3868f8b27abb915f6c3617c0fadedbf9430" + integrity sha512-piICUB6ei4IlTv1+653yq5+KoqfBYmj9bw6LqXoOneTMDXk5nM1qt12mFW1caG3LlJXEKW1Bp0WggEmIfQB34g== + dependencies: + crc-32 "^1.2.0" + readable-stream "^4.0.0" + +create-ecdh@^4.0.4: + version "4.0.4" + resolved "https://registry.yarnpkg.com/create-ecdh/-/create-ecdh-4.0.4.tgz#d6e7f4bffa66736085a0762fd3a632684dabcc4e" + integrity sha512-mf+TCx8wWc9VpuxfP2ht0iSISLZnt0JgWlrOKZiNqyUZWnjIaCIVNQArMHnCZKfEYRg6IM7A+NeJoN8gf/Ws0A== + dependencies: + bn.js "^4.1.0" + elliptic "^6.5.3" + +create-hash@^1.1.0, create-hash@^1.2.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/create-hash/-/create-hash-1.2.0.tgz#889078af11a63756bcfb59bd221996be3a9ef196" + integrity sha512-z00bCGNHDG8mHAkP7CtT1qVu+bFQUPjYq/4Iv3C3kWjTFV10zIjfSoeqXo9Asws8gwSHDGj/hl2u4OGIjapeCg== + dependencies: + cipher-base "^1.0.1" + inherits "^2.0.1" + md5.js "^1.3.4" + ripemd160 "^2.0.1" + sha.js "^2.4.0" + +create-hmac@^1.1.7: + version "1.1.7" + resolved "https://registry.yarnpkg.com/create-hmac/-/create-hmac-1.1.7.tgz#69170c78b3ab957147b2b8b04572e47ead2243ff" + integrity sha512-MJG9liiZ+ogc4TzUwuvbER1JRdgvUFSB5+VR/g5h82fGaIRWMWddtKBHi7/sVhfjQZ6SehlyhvQYrcYkaUIpLg== + dependencies: + cipher-base "^1.0.3" + create-hash "^1.1.0" + inherits "^2.0.1" + ripemd160 "^2.0.0" + safe-buffer "^5.0.1" + sha.js "^2.4.8" + +create-require@^1.1.1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/create-require/-/create-require-1.1.1.tgz#c1d7e8f1e5f6cfc9ff65f9cd352d37348756c333" + integrity sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ== + +cross-spawn@^7.0.0, cross-spawn@^7.0.2, cross-spawn@^7.0.3, cross-spawn@^7.0.6: + version "7.0.6" + resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-7.0.6.tgz#8a58fe78f00dcd70c370451759dfbfaf03e8ee9f" + integrity sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA== + dependencies: + path-key "^3.1.0" + shebang-command "^2.0.0" + which "^2.0.1" + +crypto-browserify@^3.12.1: + version "3.12.1" + resolved "https://registry.yarnpkg.com/crypto-browserify/-/crypto-browserify-3.12.1.tgz#bb8921bec9acc81633379aa8f52d69b0b69e0dac" + integrity sha512-r4ESw/IlusD17lgQi1O20Fa3qNnsckR126TdUuBgAu7GBYSIPvdNyONd3Zrxh0xCwA4+6w/TDArBPsMvhur+KQ== + dependencies: + browserify-cipher "^1.0.1" + browserify-sign "^4.2.3" + create-ecdh "^4.0.4" + create-hash "^1.2.0" + create-hmac "^1.1.7" + diffie-hellman "^5.0.3" + hash-base "~3.0.4" + inherits "^2.0.4" + pbkdf2 "^3.1.2" + public-encrypt "^4.0.3" + randombytes "^2.1.0" + randomfill "^1.0.4" + +css.escape@^1.5.1: + version "1.5.1" + resolved "https://registry.yarnpkg.com/css.escape/-/css.escape-1.5.1.tgz#42e27d4fa04ae32f931a4b4d4191fa9cddee97cb" + integrity sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg== + +csstype@^3.2.2: + version "3.2.3" + resolved "https://registry.yarnpkg.com/csstype/-/csstype-3.2.3.tgz#ec48c0f3e993e50648c86da559e2610995cf989a" + integrity sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ== + +cwd@^0.10.0: + version "0.10.0" + resolved "https://registry.yarnpkg.com/cwd/-/cwd-0.10.0.tgz#172400694057c22a13b0cf16162c7e4b7a7fe567" + integrity sha512-YGZxdTTL9lmLkCUTpg4j0zQ7IhRB5ZmqNBbGCl3Tg6MP/d5/6sY7L5mmTjzbc6JKgVZYiqTQTNhPFsbXNGlRaA== + dependencies: + find-pkg "^0.1.2" + fs-exists-sync "^0.1.0" + +date-names@^0.1.11: + version "0.1.13" + resolved "https://registry.yarnpkg.com/date-names/-/date-names-0.1.13.tgz#c4358f6f77c8056e2f5ea68fdbb05f0bf1e53bd0" + integrity sha512-IxxoeD9tdx8pXVcmqaRlPvrXIsSrSrIZzfzlOkm9u+hyzKp5Wk/odt9O/gd7Ockzy8n/WHeEpTVJ2bF3mMV4LA== + +de-indent@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/de-indent/-/de-indent-1.0.2.tgz#b2038e846dc33baa5796128d0804b455b8c1e21d" + integrity sha512-e/1zu3xH5MQryN2zdVaF0OrdNLUbvWxzMbi+iNA6Bky7l1RoP8a2fIbRocyHclXt/arDrrR6lL3TqFD9pMQTsg== + +debug@^4.1.0, debug@^4.1.1, debug@^4.3.1, debug@^4.3.2, debug@^4.3.4, debug@^4.4.0, debug@^4.4.3: + version "4.4.3" + resolved "https://registry.yarnpkg.com/debug/-/debug-4.4.3.tgz#c6ae432d9bd9662582fce08709b038c58e9e3d6a" + integrity sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA== + dependencies: + ms "^2.1.3" + +decamelize@^1.2.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/decamelize/-/decamelize-1.2.0.tgz#f6534d15148269b20352e7bee26f501f9a191290" + integrity sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA== + +dedent@^1.6.0: + version "1.7.0" + resolved "https://registry.yarnpkg.com/dedent/-/dedent-1.7.0.tgz#c1f9445335f0175a96587be245a282ff451446ca" + integrity sha512-HGFtf8yhuhGhqO07SV79tRp+br4MnbdjeVxotpn1QBl30pcLLCQjX5b2295ll0fv8RKDKsmWYrl05usHM9CewQ== + +deep-eql@^5.0.1: + version "5.0.2" + resolved "https://registry.yarnpkg.com/deep-eql/-/deep-eql-5.0.2.tgz#4b756d8d770a9257300825d52a2c2cff99c3a341" + integrity sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q== + +deep-is@^0.1.3: + version "0.1.4" + resolved "https://registry.yarnpkg.com/deep-is/-/deep-is-0.1.4.tgz#a6f2dce612fadd2ef1f519b73551f17e85199831" + integrity sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ== + +deepmerge@^4.3.1: + version "4.3.1" + resolved "https://registry.yarnpkg.com/deepmerge/-/deepmerge-4.3.1.tgz#44b5f2147cd3b00d4b56137685966f26fd25dd4a" + integrity sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A== + +default-browser-id@^5.0.0: + version "5.0.1" + resolved "https://registry.yarnpkg.com/default-browser-id/-/default-browser-id-5.0.1.tgz#f7a7ccb8f5104bf8e0f71ba3b1ccfa5eafdb21e8" + integrity sha512-x1VCxdX4t+8wVfd1so/9w+vQ4vx7lKd2Qp5tDRutErwmR85OgmfX7RlLRMWafRMY7hbEiXIbudNrjOAPa/hL8Q== + +default-browser@^5.2.1: + version "5.4.0" + resolved "https://registry.yarnpkg.com/default-browser/-/default-browser-5.4.0.tgz#b55cf335bb0b465dd7c961a02cd24246aa434287" + integrity sha512-XDuvSq38Hr1MdN47EDvYtx3U0MTqpCEn+F6ft8z2vYDzMrvQhVp0ui9oQdqW3MvK3vqUETglt1tVGgjLuJ5izg== + dependencies: + bundle-name "^4.1.0" + default-browser-id "^5.0.0" + +default-require-extensions@^3.0.0: + version "3.0.1" + resolved "https://registry.yarnpkg.com/default-require-extensions/-/default-require-extensions-3.0.1.tgz#bfae00feeaeada68c2ae256c62540f60b80625bd" + integrity sha512-eXTJmRbm2TIt9MgWTsOH1wEuhew6XGZcMeGKCtLedIg/NCsg1iBePXkceTdK4Fii7pzmN9tGsZhKzZ4h7O/fxw== + dependencies: + strip-bom "^4.0.0" + +define-data-property@^1.0.1, define-data-property@^1.1.4: + version "1.1.4" + resolved "https://registry.yarnpkg.com/define-data-property/-/define-data-property-1.1.4.tgz#894dc141bb7d3060ae4366f6a0107e68fbe48c5e" + integrity sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A== + dependencies: + es-define-property "^1.0.0" + es-errors "^1.3.0" + gopd "^1.0.1" + +define-lazy-prop@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/define-lazy-prop/-/define-lazy-prop-3.0.0.tgz#dbb19adfb746d7fc6d734a06b72f4a00d021255f" + integrity sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg== + +define-properties@^1.1.3, define-properties@^1.2.1: + version "1.2.1" + resolved "https://registry.yarnpkg.com/define-properties/-/define-properties-1.2.1.tgz#10781cc616eb951a80a034bafcaa7377f6af2b6c" + integrity sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg== + dependencies: + define-data-property "^1.0.1" + has-property-descriptors "^1.0.0" + object-keys "^1.1.1" + +delayed-stream@~1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/delayed-stream/-/delayed-stream-1.0.0.tgz#df3ae199acadfb7d440aaae0b29e2272b24ec619" + integrity sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ== + +dequal@^2.0.3: + version "2.0.3" + resolved "https://registry.yarnpkg.com/dequal/-/dequal-2.0.3.tgz#2644214f1997d39ed0ee0ece72335490a7ac67be" + integrity sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA== + +des.js@^1.0.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/des.js/-/des.js-1.1.0.tgz#1d37f5766f3bbff4ee9638e871a8768c173b81da" + integrity sha512-r17GxjhUCjSRy8aiJpr8/UadFIzMzJGexI3Nmz4ADi9LYSFx4gTBp80+NaX/YsXWWLhpZ7v/v/ubEc/bCNfKwg== + dependencies: + inherits "^2.0.1" + minimalistic-assert "^1.0.0" + +detect-newline@^3.1.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/detect-newline/-/detect-newline-3.1.0.tgz#576f5dfc63ae1a192ff192d8ad3af6308991b651" + integrity sha512-TLz+x/vEXm/Y7P7wn1EJFNLxYpUD4TgMosxY6fAVJUnJMbupHBOncxyWUG9OpTaH9EBD7uFI5LfEgmMOc54DsA== + +diffable-html@^4.1.0: + version "4.1.0" + resolved "https://registry.yarnpkg.com/diffable-html/-/diffable-html-4.1.0.tgz#e7a2d1de187c4e23a59751b4e4c17483a058c696" + integrity sha512-++kyNek+YBLH8cLXS+iTj/Hiy2s5qkRJEJ8kgu/WHbFrVY2vz9xPFUT+fii2zGF0m1CaojDlQJjkfrCt7YWM1g== + dependencies: + htmlparser2 "^3.9.2" + +diffie-hellman@^5.0.3: + version "5.0.3" + resolved "https://registry.yarnpkg.com/diffie-hellman/-/diffie-hellman-5.0.3.tgz#40e8ee98f55a2149607146921c63e1ae5f3d2875" + integrity sha512-kqag/Nl+f3GwyK25fhUMYj81BUOrZ9IuJsjIcDE5icNM9FJHAVm3VcUDxdLPoQtTuUylWm6ZIknYJwwaPxsUzg== + dependencies: + bn.js "^4.1.0" + miller-rabin "^4.0.0" + randombytes "^2.0.0" + +docker-compose@^1.3.0: + version "1.3.0" + resolved "https://registry.yarnpkg.com/docker-compose/-/docker-compose-1.3.0.tgz#6da4bb9d542b4cce6474aa3146a909eda5d23623" + integrity sha512-7Gevk/5eGD50+eMD+XDnFnOrruFkL0kSd7jEG4cjmqweDSUhB7i0g8is/nBdVpl+Bx338SqIB2GLKm32M+Vs6g== + dependencies: + yaml "^2.2.2" + +docker-modem@^5.0.6: + version "5.0.6" + resolved "https://registry.yarnpkg.com/docker-modem/-/docker-modem-5.0.6.tgz#cbe9d86a1fe66d7a072ac7fb99a9fc390a3e8b9a" + integrity sha512-ens7BiayssQz/uAxGzH8zGXCtiV24rRWXdjNha5V4zSOcxmAZsfGVm/PPFbwQdqEkDnhG+SyR9E3zSHUbOKXBQ== + dependencies: + debug "^4.1.1" + readable-stream "^3.5.0" + split-ca "^1.0.1" + ssh2 "^1.15.0" + +dockerode@^4.0.9: + version "4.0.9" + resolved "https://registry.yarnpkg.com/dockerode/-/dockerode-4.0.9.tgz#15b32000edad25520be6fafa9ad6bc4529b06be7" + integrity sha512-iND4mcOWhPaCNh54WmK/KoSb35AFqPAUWFMffTQcp52uQt36b5uNwEJTSXntJZBbeGad72Crbi/hvDIv6us/6Q== + dependencies: + "@balena/dockerignore" "^1.0.2" + "@grpc/grpc-js" "^1.11.1" + "@grpc/proto-loader" "^0.7.13" + docker-modem "^5.0.6" + protobufjs "^7.3.2" + tar-fs "^2.1.4" + uuid "^10.0.0" + +doctrine@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/doctrine/-/doctrine-3.0.0.tgz#addebead72a6574db783639dc87a121773973961" + integrity sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w== + dependencies: + esutils "^2.0.2" + +dom-accessibility-api@^0.5.9: + version "0.5.16" + resolved "https://registry.yarnpkg.com/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz#5a7429e6066eb3664d911e33fb0e45de8eb08453" + integrity sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg== + +dom-accessibility-api@^0.6.3: + version "0.6.3" + resolved "https://registry.yarnpkg.com/dom-accessibility-api/-/dom-accessibility-api-0.6.3.tgz#993e925cc1d73f2c662e7d75dd5a5445259a8fd8" + integrity sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w== + +dom-serializer@0: + version "0.2.2" + resolved "https://registry.yarnpkg.com/dom-serializer/-/dom-serializer-0.2.2.tgz#1afb81f533717175d478655debc5e332d9f9bb51" + integrity sha512-2/xPb3ORsQ42nHYiSunXkDjPLBaEj/xTwUO4B7XCZQTRk7EBtTOPaygh10YAAh2OI1Qrp6NWfpAhzswj0ydt9g== + dependencies: + domelementtype "^2.0.1" + entities "^2.0.0" + +domain-browser@4.22.0: + version "4.22.0" + resolved "https://registry.yarnpkg.com/domain-browser/-/domain-browser-4.22.0.tgz#6ddd34220ec281f9a65d3386d267ddd35c491f9f" + integrity sha512-IGBwjF7tNk3cwypFNH/7bfzBcgSCbaMOD3GsaY1AU/JRrnHnYgEM0+9kQt52iZxjNsjBtJYtao146V+f8jFZNw== + +domelementtype@1, domelementtype@^1.3.1: + version "1.3.1" + resolved "https://registry.yarnpkg.com/domelementtype/-/domelementtype-1.3.1.tgz#d048c44b37b0d10a7f2a3d5fee3f4333d790481f" + integrity sha512-BSKB+TSpMpFI/HOxCNr1O8aMOTZ8hT3pM3GQ0w/mWRmkhEDSFJkkyzz4XQsBV44BChwGkrDfMyjVD0eA2aFV3w== + +domelementtype@^2.0.1: + version "2.3.0" + resolved "https://registry.yarnpkg.com/domelementtype/-/domelementtype-2.3.0.tgz#5c45e8e869952626331d7aab326d01daf65d589d" + integrity sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw== + +domhandler@^2.3.0: + version "2.4.2" + resolved "https://registry.yarnpkg.com/domhandler/-/domhandler-2.4.2.tgz#8805097e933d65e85546f726d60f5eb88b44f803" + integrity sha512-JiK04h0Ht5u/80fdLMCEmV4zkNh2BcoMFBmZ/91WtYZ8qVXSKjiw7fXMgFPnHcSZgOo3XdinHvmnDUeMf5R4wA== + dependencies: + domelementtype "1" + +domutils@^1.5.1: + version "1.7.0" + resolved "https://registry.yarnpkg.com/domutils/-/domutils-1.7.0.tgz#56ea341e834e06e6748af7a1cb25da67ea9f8c2a" + integrity sha512-Lgd2XcJ/NjEw+7tFvfKxOzCYKZsdct5lczQ2ZaQY8Djz7pfAD3Gbp8ySJWtreII/vDlMVmxwa6pHmdxIYgttDg== + dependencies: + dom-serializer "0" + domelementtype "1" + +dunder-proto@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/dunder-proto/-/dunder-proto-1.0.1.tgz#d7ae667e1dc83482f8b70fd0f6eefc50da30f58a" + integrity sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A== + dependencies: + call-bind-apply-helpers "^1.0.1" + es-errors "^1.3.0" + gopd "^1.2.0" + +eastasianwidth@^0.2.0: + version "0.2.0" + resolved "https://registry.yarnpkg.com/eastasianwidth/-/eastasianwidth-0.2.0.tgz#696ce2ec0aa0e6ea93a397ffcf24aa7840c827cb" + integrity sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA== + +electron-to-chromium@^1.5.263: + version "1.5.267" + resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.5.267.tgz#5d84f2df8cdb6bfe7e873706bb21bd4bfb574dc7" + integrity sha512-0Drusm6MVRXSOJpGbaSVgcQsuB4hEkMpHXaVstcPmhu5LIedxs1xNK/nIxmQIU/RPC0+1/o0AVZfBTkTNJOdUw== + +elliptic@^6.5.3, elliptic@^6.6.1: + version "6.6.1" + resolved "https://registry.yarnpkg.com/elliptic/-/elliptic-6.6.1.tgz#3b8ffb02670bf69e382c7f65bf524c97c5405c06" + integrity sha512-RaddvvMatK2LJHqFJ+YA4WysVN5Ita9E35botqIYspQ4TkRAlCicdzKOjlyv/1Za5RyTNn7di//eEV0uTAfe3g== + dependencies: + bn.js "^4.11.9" + brorand "^1.1.0" + hash.js "^1.0.0" + hmac-drbg "^1.0.1" + inherits "^2.0.4" + minimalistic-assert "^1.0.1" + minimalistic-crypto-utils "^1.0.1" + +emittery@^0.13.1: + version "0.13.1" + resolved "https://registry.yarnpkg.com/emittery/-/emittery-0.13.1.tgz#c04b8c3457490e0847ae51fced3af52d338e3dad" + integrity sha512-DeWwawk6r5yR9jFgnDKYt4sLS0LmHJJi3ZOnb5/JdbYwj3nW+FxQnHIjhBKz8YLC7oRNPVM9NQ47I3CVx34eqQ== + +emoji-regex@^8.0.0: + version "8.0.0" + resolved "https://registry.yarnpkg.com/emoji-regex/-/emoji-regex-8.0.0.tgz#e818fd69ce5ccfcb404594f842963bf53164cc37" + integrity sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A== + +emoji-regex@^9.2.2: + version "9.2.2" + resolved "https://registry.yarnpkg.com/emoji-regex/-/emoji-regex-9.2.2.tgz#840c8803b0d8047f4ff0cf963176b32d4ef3ed72" + integrity sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg== + +empathic@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/empathic/-/empathic-2.0.0.tgz#71d3c2b94fad49532ef98a6c34be0386659f6131" + integrity sha512-i6UzDscO/XfAcNYD75CfICkmfLedpyPDdozrLMmQc5ORaQcdMoc21OnlEylMIqI7U8eniKrPMxxtj8k0vhmJhA== + +end-of-stream@^1.1.0, end-of-stream@^1.4.1: + version "1.4.5" + resolved "https://registry.yarnpkg.com/end-of-stream/-/end-of-stream-1.4.5.tgz#7344d711dea40e0b74abc2ed49778743ccedb08c" + integrity sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg== + dependencies: + once "^1.4.0" + +entities@^1.1.1: + version "1.1.2" + resolved "https://registry.yarnpkg.com/entities/-/entities-1.1.2.tgz#bdfa735299664dfafd34529ed4f8522a275fea56" + integrity sha512-f2LZMYl1Fzu7YSBKg+RoROelpOaNrcGmE9AZubeDfrCEia483oW4MI4VyFd5VNHIgQ/7qm1I0wUHK1eJnn2y2w== + +entities@^2.0.0: + version "2.2.0" + resolved "https://registry.yarnpkg.com/entities/-/entities-2.2.0.tgz#098dc90ebb83d8dffa089d55256b351d34c4da55" + integrity sha512-p92if5Nz619I0w+akJrLZH0MX0Pb5DX39XOwQTtXSdQQOaYH03S1uIQp4mhOZtAXrxq4ViO67YTiLBo2638o9A== + +entities@^4.5.0: + version "4.5.0" + resolved "https://registry.yarnpkg.com/entities/-/entities-4.5.0.tgz#5d268ea5e7113ec74c4d033b79ea5a35a488fb48" + integrity sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw== + +environment@^1.0.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/environment/-/environment-1.1.0.tgz#8e86c66b180f363c7ab311787e0259665f45a9f1" + integrity sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q== + +error-ex@^1.3.1: + version "1.3.4" + resolved "https://registry.yarnpkg.com/error-ex/-/error-ex-1.3.4.tgz#b3a8d8bb6f92eecc1629e3e27d3c8607a8a32414" + integrity sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ== + dependencies: + is-arrayish "^0.2.1" + +es-define-property@^1.0.0, es-define-property@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/es-define-property/-/es-define-property-1.0.1.tgz#983eb2f9a6724e9303f61addf011c72e09e0b0fa" + integrity sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g== + +es-errors@^1.3.0: + version "1.3.0" + resolved "https://registry.yarnpkg.com/es-errors/-/es-errors-1.3.0.tgz#05f75a25dab98e4fb1dcd5e1472c0546d5057c8f" + integrity sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw== + +es-object-atoms@^1.0.0, es-object-atoms@^1.1.1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/es-object-atoms/-/es-object-atoms-1.1.1.tgz#1c4f2c4837327597ce69d2ca190a7fdd172338c1" + integrity sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA== + dependencies: + es-errors "^1.3.0" + +es-set-tostringtag@^2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz#f31dbbe0c183b00a6d26eb6325c810c0fd18bd4d" + integrity sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA== + dependencies: + es-errors "^1.3.0" + get-intrinsic "^1.2.6" + has-tostringtag "^1.0.2" + hasown "^2.0.2" + +es6-error@^4.0.1: + version "4.1.1" + resolved "https://registry.yarnpkg.com/es6-error/-/es6-error-4.1.1.tgz#9e3af407459deed47e9a91f9b885a84eb05c561d" + integrity sha512-Um/+FxMr9CISWh0bi5Zv0iOD+4cFh5qLeks1qhAopKVAJw3drgKbKySikp7wGhDL0HPeaja0P5ULZrxLkniUVg== + +"esbuild@^0.18.0 || ^0.19.0 || ^0.20.0 || ^0.21.0 || ^0.22.0 || ^0.23.0 || ^0.24.0 || ^0.25.0 || ^0.26.0 || ^0.27.0": + version "0.27.2" + resolved "https://registry.yarnpkg.com/esbuild/-/esbuild-0.27.2.tgz#d83ed2154d5813a5367376bb2292a9296fc83717" + integrity sha512-HyNQImnsOC7X9PMNaCIeAm4ISCQXs5a5YasTXVliKv4uuBo1dKrG0A+uQS8M5eXjVMnLg3WgXaKvprHlFJQffw== + optionalDependencies: + "@esbuild/aix-ppc64" "0.27.2" + "@esbuild/android-arm" "0.27.2" + "@esbuild/android-arm64" "0.27.2" + "@esbuild/android-x64" "0.27.2" + "@esbuild/darwin-arm64" "0.27.2" + "@esbuild/darwin-x64" "0.27.2" + "@esbuild/freebsd-arm64" "0.27.2" + "@esbuild/freebsd-x64" "0.27.2" + "@esbuild/linux-arm" "0.27.2" + "@esbuild/linux-arm64" "0.27.2" + "@esbuild/linux-ia32" "0.27.2" + "@esbuild/linux-loong64" "0.27.2" + "@esbuild/linux-mips64el" "0.27.2" + "@esbuild/linux-ppc64" "0.27.2" + "@esbuild/linux-riscv64" "0.27.2" + "@esbuild/linux-s390x" "0.27.2" + "@esbuild/linux-x64" "0.27.2" + "@esbuild/netbsd-arm64" "0.27.2" + "@esbuild/netbsd-x64" "0.27.2" + "@esbuild/openbsd-arm64" "0.27.2" + "@esbuild/openbsd-x64" "0.27.2" + "@esbuild/openharmony-arm64" "0.27.2" + "@esbuild/sunos-x64" "0.27.2" + "@esbuild/win32-arm64" "0.27.2" + "@esbuild/win32-ia32" "0.27.2" + "@esbuild/win32-x64" "0.27.2" + +esbuild@^0.25.0: + version "0.25.12" + resolved "https://registry.yarnpkg.com/esbuild/-/esbuild-0.25.12.tgz#97a1d041f4ab00c2fce2f838d2b9969a2d2a97a5" + integrity sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg== + optionalDependencies: + "@esbuild/aix-ppc64" "0.25.12" + "@esbuild/android-arm" "0.25.12" + "@esbuild/android-arm64" "0.25.12" + "@esbuild/android-x64" "0.25.12" + "@esbuild/darwin-arm64" "0.25.12" + "@esbuild/darwin-x64" "0.25.12" + "@esbuild/freebsd-arm64" "0.25.12" + "@esbuild/freebsd-x64" "0.25.12" + "@esbuild/linux-arm" "0.25.12" + "@esbuild/linux-arm64" "0.25.12" + "@esbuild/linux-ia32" "0.25.12" + "@esbuild/linux-loong64" "0.25.12" + "@esbuild/linux-mips64el" "0.25.12" + "@esbuild/linux-ppc64" "0.25.12" + "@esbuild/linux-riscv64" "0.25.12" + "@esbuild/linux-s390x" "0.25.12" + "@esbuild/linux-x64" "0.25.12" + "@esbuild/netbsd-arm64" "0.25.12" + "@esbuild/netbsd-x64" "0.25.12" + "@esbuild/openbsd-arm64" "0.25.12" + "@esbuild/openbsd-x64" "0.25.12" + "@esbuild/openharmony-arm64" "0.25.12" + "@esbuild/sunos-x64" "0.25.12" + "@esbuild/win32-arm64" "0.25.12" + "@esbuild/win32-ia32" "0.25.12" + "@esbuild/win32-x64" "0.25.12" + +escalade@^3.1.1, escalade@^3.2.0: + version "3.2.0" + resolved "https://registry.yarnpkg.com/escalade/-/escalade-3.2.0.tgz#011a3f69856ba189dffa7dc8fcce99d2a87903e5" + integrity sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA== + +escape-string-regexp@^1.0.5: + version "1.0.5" + resolved "https://registry.yarnpkg.com/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz#1b61c0562190a8dff6ae3bb2cf0200ca130b86d4" + integrity sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg== + +escape-string-regexp@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/escape-string-regexp/-/escape-string-regexp-2.0.0.tgz#a30304e99daa32e23b2fd20f51babd07cffca344" + integrity sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w== + +escape-string-regexp@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz#14ba83a5d373e3d311e5afca29cf5bfad965bf34" + integrity sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA== + +eslint-plugin-matrix-org@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/eslint-plugin-matrix-org/-/eslint-plugin-matrix-org-3.0.0.tgz#0e5eaa556b6780dcd616b1381baec252df78b56d" + integrity sha512-xOPMeyLxOgoB2SsPhJmIc+drorZmXozSBo3X9whk/62DvbVpJcnththOCGx8ljYScADLb+baNOeN+wKZqwkldw== + +eslint-plugin-storybook@^10.0.7: + version "10.1.10" + resolved "https://registry.yarnpkg.com/eslint-plugin-storybook/-/eslint-plugin-storybook-10.1.10.tgz#030a55e58a5e96a513cbf873434a840abdaf6a7d" + integrity sha512-ITr6Aq3buR/DuDATkq1BafUVJLybyo676fY+tj9Zjd1Ak+UXBAMQcQ++tiBVVHm1RqADwM3b1o6bnWHK2fPPKw== + dependencies: + "@typescript-eslint/utils" "^8.8.1" + +eslint-scope@^7.2.2: + version "7.2.2" + resolved "https://registry.yarnpkg.com/eslint-scope/-/eslint-scope-7.2.2.tgz#deb4f92563390f32006894af62a22dba1c46423f" + integrity sha512-dOt21O7lTMhDM+X9mB4GX+DZrZtCUJPL/wlcTqxyrx5IvO0IYtILdtrQGQp+8n5S0gwSVmOf9NQrjMOgfQZlIg== + dependencies: + esrecurse "^4.3.0" + estraverse "^5.2.0" + +eslint-visitor-keys@^3.4.1, eslint-visitor-keys@^3.4.3: + version "3.4.3" + resolved "https://registry.yarnpkg.com/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz#0cd72fe8550e3c2eae156a96a4dddcd1c8ac5800" + integrity sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag== + +eslint-visitor-keys@^4.2.1: + version "4.2.1" + resolved "https://registry.yarnpkg.com/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz#4cfea60fe7dd0ad8e816e1ed026c1d5251b512c1" + integrity sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ== + +eslint@8: + version "8.57.1" + resolved "https://registry.yarnpkg.com/eslint/-/eslint-8.57.1.tgz#7df109654aba7e3bbe5c8eae533c5e461d3c6ca9" + integrity sha512-ypowyDxpVSYpkXr9WPv2PAZCtNip1Mv5KTW0SCurXv/9iOpcrH9PaqUElksqEB6pChqHGDRCFTyrZlGhnLNGiA== + dependencies: + "@eslint-community/eslint-utils" "^4.2.0" + "@eslint-community/regexpp" "^4.6.1" + "@eslint/eslintrc" "^2.1.4" + "@eslint/js" "8.57.1" + "@humanwhocodes/config-array" "^0.13.0" + "@humanwhocodes/module-importer" "^1.0.1" + "@nodelib/fs.walk" "^1.2.8" + "@ungap/structured-clone" "^1.2.0" + ajv "^6.12.4" + chalk "^4.0.0" + cross-spawn "^7.0.2" + debug "^4.3.2" + doctrine "^3.0.0" + escape-string-regexp "^4.0.0" + eslint-scope "^7.2.2" + eslint-visitor-keys "^3.4.3" + espree "^9.6.1" + esquery "^1.4.2" + esutils "^2.0.2" + fast-deep-equal "^3.1.3" + file-entry-cache "^6.0.1" + find-up "^5.0.0" + glob-parent "^6.0.2" + globals "^13.19.0" + graphemer "^1.4.0" + ignore "^5.2.0" + imurmurhash "^0.1.4" + is-glob "^4.0.0" + is-path-inside "^3.0.3" + js-yaml "^4.1.0" + json-stable-stringify-without-jsonify "^1.0.1" + levn "^0.4.1" + lodash.merge "^4.6.2" + minimatch "^3.1.2" + natural-compare "^1.4.0" + optionator "^0.9.3" + strip-ansi "^6.0.1" + text-table "^0.2.0" + +espree@^9.6.0, espree@^9.6.1: + version "9.6.1" + resolved "https://registry.yarnpkg.com/espree/-/espree-9.6.1.tgz#a2a17b8e434690a5432f2f8018ce71d331a48c6f" + integrity sha512-oruZaFkjorTpF32kDSI5/75ViwGeZginGGy2NoOSg3Q9bnwlnmDm4HLnkl0RE3n+njDXR037aY1+x58Z/zFdwQ== + dependencies: + acorn "^8.9.0" + acorn-jsx "^5.3.2" + eslint-visitor-keys "^3.4.1" + +esprima@^4.0.0, esprima@~4.0.0: + version "4.0.1" + resolved "https://registry.yarnpkg.com/esprima/-/esprima-4.0.1.tgz#13b04cdb3e6c5d19df91ab6987a8695619b0aa71" + integrity sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A== + +esquery@^1.4.2: + version "1.6.0" + resolved "https://registry.yarnpkg.com/esquery/-/esquery-1.6.0.tgz#91419234f804d852a82dceec3e16cdc22cf9dae7" + integrity sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg== + dependencies: + estraverse "^5.1.0" + +esrecurse@^4.3.0: + version "4.3.0" + resolved "https://registry.yarnpkg.com/esrecurse/-/esrecurse-4.3.0.tgz#7ad7964d679abb28bee72cec63758b1c5d2c9921" + integrity sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag== + dependencies: + estraverse "^5.2.0" + +estraverse@^5.1.0, estraverse@^5.2.0: + version "5.3.0" + resolved "https://registry.yarnpkg.com/estraverse/-/estraverse-5.3.0.tgz#2eea5290702f26ab8fe5370370ff86c965d21123" + integrity sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA== + +estree-walker@^2.0.2: + version "2.0.2" + resolved "https://registry.yarnpkg.com/estree-walker/-/estree-walker-2.0.2.tgz#52f010178c2a4c117a7757cfe942adb7d2da4cac" + integrity sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w== + +estree-walker@^3.0.3: + version "3.0.3" + resolved "https://registry.yarnpkg.com/estree-walker/-/estree-walker-3.0.3.tgz#67c3e549ec402a487b4fc193d1953a524752340d" + integrity sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g== + dependencies: + "@types/estree" "^1.0.0" + +esutils@^2.0.2: + version "2.0.3" + resolved "https://registry.yarnpkg.com/esutils/-/esutils-2.0.3.tgz#74d2eb4de0b8da1293711910d50775b9b710ef64" + integrity sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g== + +event-target-shim@^5.0.0: + version "5.0.1" + resolved "https://registry.yarnpkg.com/event-target-shim/-/event-target-shim-5.0.1.tgz#5d4d3ebdf9583d63a5333ce2deb7480ab2b05789" + integrity sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ== + +events-universal@^1.0.0: + version "1.0.1" + resolved "https://registry.yarnpkg.com/events-universal/-/events-universal-1.0.1.tgz#b56a84fd611b6610e0a2d0f09f80fdf931e2dfe6" + integrity sha512-LUd5euvbMLpwOF8m6ivPCbhQeSiYVNb8Vs0fQ8QjXo0JTkEHpz8pxdQf0gStltaPpw0Cca8b39KxvK9cfKRiAw== + dependencies: + bare-events "^2.7.0" + +events@^3.0.0, events@^3.3.0: + version "3.3.0" + resolved "https://registry.yarnpkg.com/events/-/events-3.3.0.tgz#31a95ad0a924e2d2c419a813aeb2c4e878ea7400" + integrity sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q== + +evp_bytestokey@^1.0.0, evp_bytestokey@^1.0.3: + version "1.0.3" + resolved "https://registry.yarnpkg.com/evp_bytestokey/-/evp_bytestokey-1.0.3.tgz#7fcbdb198dc71959432efe13842684e0525acb02" + integrity sha512-/f2Go4TognH/KvCISP7OUsHn85hT9nUkxxA9BEWxFn+Oj9o8ZNLm/40hdlgSLyuOimsrTKLUMEorQexp/aPQeA== + dependencies: + md5.js "^1.3.4" + safe-buffer "^5.1.1" + +except@^0.1.3: + version "0.1.3" + resolved "https://registry.yarnpkg.com/except/-/except-0.1.3.tgz#98261c91958551536b44482238e9783fb73d292a" + integrity sha512-ouwgJavvMOTOfy0RE8NGQFAIoWh8ehJhkuxDyXxngMVTxTq7HGE7gZopZhqKFnu5lZLI+qQdtvJ8n03ehp7RJg== + dependencies: + indexof "0.0.1" + +execa@^5.1.1: + version "5.1.1" + resolved "https://registry.yarnpkg.com/execa/-/execa-5.1.1.tgz#f80ad9cbf4298f7bd1d4c9555c21e93741c411dd" + integrity sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg== + dependencies: + cross-spawn "^7.0.3" + get-stream "^6.0.0" + human-signals "^2.1.0" + is-stream "^2.0.0" + merge-stream "^2.0.0" + npm-run-path "^4.0.1" + onetime "^5.1.2" + signal-exit "^3.0.3" + strip-final-newline "^2.0.0" + +exit-x@^0.2.2: + version "0.2.2" + resolved "https://registry.yarnpkg.com/exit-x/-/exit-x-0.2.2.tgz#1f9052de3b8d99a696b10dad5bced9bdd5c3aa64" + integrity sha512-+I6B/IkJc1o/2tiURyz/ivu/O0nKNEArIUB5O7zBrlDVJr22SCLH3xTeEry428LvFhRzIA1g8izguxJ/gbNcVQ== + +exit@^0.1.2: + version "0.1.2" + resolved "https://registry.yarnpkg.com/exit/-/exit-0.1.2.tgz#0632638f8d877cc82107d30a0fff1a17cba1cd0c" + integrity sha512-Zk/eNKV2zbjpKzrsQ+n1G6poVbErQxJ0LBOJXaKZ1EViLzH+hrLu9cdXI4zw9dBQJslwBEpbQ2P1oS7nDxs6jQ== + +expand-tilde@^1.2.2: + version "1.2.2" + resolved "https://registry.yarnpkg.com/expand-tilde/-/expand-tilde-1.2.2.tgz#0b81eba897e5a3d31d1c3d102f8f01441e559449" + integrity sha512-rtmc+cjLZqnu9dSYosX9EWmSJhTwpACgJQTfj4hgg2JjOD/6SIQalZrt4a3aQeh++oNxkazcaxrhPUj6+g5G/Q== + dependencies: + os-homedir "^1.0.1" + +expect-playwright@^0.8.0: + version "0.8.0" + resolved "https://registry.yarnpkg.com/expect-playwright/-/expect-playwright-0.8.0.tgz#6d4ebe0bdbdd3c1693d880d97153b96a129ae4e8" + integrity sha512-+kn8561vHAY+dt+0gMqqj1oY+g5xWrsuGMk4QGxotT2WS545nVqqjs37z6hrYfIuucwqthzwJfCJUEYqixyljg== + +expect@30.2.0, expect@^30.0.0: + version "30.2.0" + resolved "https://registry.yarnpkg.com/expect/-/expect-30.2.0.tgz#d4013bed267013c14bc1199cec8aa57cee9b5869" + integrity sha512-u/feCi0GPsI+988gU2FLcsHyAHTU0MX1Wg68NhAnN7z/+C5wqG+CY8J53N9ioe8RXgaoz0nBR/TYMf3AycUuPw== + dependencies: + "@jest/expect-utils" "30.2.0" + "@jest/get-type" "30.1.0" + jest-matcher-utils "30.2.0" + jest-message-util "30.2.0" + jest-mock "30.2.0" + jest-util "30.2.0" + +exsolve@^1.0.7: + version "1.0.7" + resolved "https://registry.yarnpkg.com/exsolve/-/exsolve-1.0.7.tgz#3b74e4c7ca5c5f9a19c3626ca857309fa99f9e9e" + integrity sha512-VO5fQUzZtI6C+vx4w/4BWJpg3s/5l+6pRQEHzFRM8WFi4XffSP1Z+4qi7GbjWbvRQEbdIco5mIMq+zX4rPuLrw== + +extend@^3.0.0: + version "3.0.2" + resolved "https://registry.yarnpkg.com/extend/-/extend-3.0.2.tgz#f8b1136b4071fbd8eb140aff858b1019ec2915fa" + integrity sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g== + +fast-deep-equal@^3.1.1, fast-deep-equal@^3.1.3: + version "3.1.3" + resolved "https://registry.yarnpkg.com/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz#3a7d56b559d6cbc3eb512325244e619a65c6c525" + integrity sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q== + +fast-fifo@^1.2.0, fast-fifo@^1.3.2: + version "1.3.2" + resolved "https://registry.yarnpkg.com/fast-fifo/-/fast-fifo-1.3.2.tgz#286e31de96eb96d38a97899815740ba2a4f3640c" + integrity sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ== + +fast-json-stable-stringify@^2.0.0, fast-json-stable-stringify@^2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz#874bf69c6f404c2b5d99c481341399fd55892633" + integrity sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw== + +fast-levenshtein@^2.0.6: + version "2.0.6" + resolved "https://registry.yarnpkg.com/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz#3d8a5c66883a16a30ca8643e851f19baa7797917" + integrity sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw== + +fast-uri@^3.0.1: + version "3.1.0" + resolved "https://registry.yarnpkg.com/fast-uri/-/fast-uri-3.1.0.tgz#66eecff6c764c0df9b762e62ca7edcfb53b4edfa" + integrity sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA== + +fastq@^1.6.0: + version "1.19.1" + resolved "https://registry.yarnpkg.com/fastq/-/fastq-1.19.1.tgz#d50eaba803c8846a883c16492821ebcd2cda55f5" + integrity sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ== + dependencies: + reusify "^1.0.4" + +fb-watchman@^2.0.2: + version "2.0.2" + resolved "https://registry.yarnpkg.com/fb-watchman/-/fb-watchman-2.0.2.tgz#e9524ee6b5c77e9e5001af0f85f3adbb8623255c" + integrity sha512-p5161BqbuCaSnB8jIbzQHOlpgsPmK5rJVDfDKO91Axs5NC1uu3HRQm6wt9cd9/+GtQQIO53JdGXXoyDpTAsgYA== + dependencies: + bser "2.1.1" + +fdir@^6.5.0: + version "6.5.0" + resolved "https://registry.yarnpkg.com/fdir/-/fdir-6.5.0.tgz#ed2ab967a331ade62f18d077dae192684d50d350" + integrity sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg== + +file-entry-cache@^6.0.1: + version "6.0.1" + resolved "https://registry.yarnpkg.com/file-entry-cache/-/file-entry-cache-6.0.1.tgz#211b2dd9659cb0394b073e7323ac3c933d522027" + integrity sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg== + dependencies: + flat-cache "^3.0.4" + +fill-range@^7.1.1: + version "7.1.1" + resolved "https://registry.yarnpkg.com/fill-range/-/fill-range-7.1.1.tgz#44265d3cac07e3ea7dc247516380643754a05292" + integrity sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg== + dependencies: + to-regex-range "^5.0.1" + +find-cache-dir@^3.2.0: + version "3.3.2" + resolved "https://registry.yarnpkg.com/find-cache-dir/-/find-cache-dir-3.3.2.tgz#b30c5b6eff0730731aea9bbd9dbecbd80256d64b" + integrity sha512-wXZV5emFEjrridIgED11OoUKLxiYjAcqot/NJdAkOhlJ+vGzwhOAfcG5OX1jP+S0PcjEn8bdMJv+g2jwQ3Onig== + dependencies: + commondir "^1.0.1" + make-dir "^3.0.2" + pkg-dir "^4.1.0" + +find-file-up@^0.1.2: + version "0.1.3" + resolved "https://registry.yarnpkg.com/find-file-up/-/find-file-up-0.1.3.tgz#cf68091bcf9f300a40da411b37da5cce5a2fbea0" + integrity sha512-mBxmNbVyjg1LQIIpgO8hN+ybWBgDQK8qjht+EbrTCGmmPV/sc7RF1i9stPTD6bpvXZywBdrwRYxhSdJv867L6A== + dependencies: + fs-exists-sync "^0.1.0" + resolve-dir "^0.1.0" + +find-pkg@^0.1.2: + version "0.1.2" + resolved "https://registry.yarnpkg.com/find-pkg/-/find-pkg-0.1.2.tgz#1bdc22c06e36365532e2a248046854b9788da557" + integrity sha512-0rnQWcFwZr7eO0513HahrWafsc3CTFioEB7DRiEYCUM/70QXSY8f3mCST17HXLcPvEhzH/Ty/Bxd72ZZsr/yvw== + dependencies: + find-file-up "^0.1.2" + +find-process@^1.4.4: + version "1.4.11" + resolved "https://registry.yarnpkg.com/find-process/-/find-process-1.4.11.tgz#f7246251d396b35b9ae41fff7b87137673567fcc" + integrity sha512-mAOh9gGk9WZ4ip5UjV0o6Vb4SrfnAmtsFNzkMRH9HQiFXVQnDyQFrSHTK5UoG6E+KV+s+cIznbtwpfN41l2nFA== + dependencies: + chalk "~4.1.2" + commander "^12.1.0" + loglevel "^1.9.2" + +find-up@^4.0.0, find-up@^4.1.0: + version "4.1.0" + resolved "https://registry.yarnpkg.com/find-up/-/find-up-4.1.0.tgz#97afe7d6cdc0bc5928584b7c8d7b16e8a9aa5d19" + integrity sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw== + dependencies: + locate-path "^5.0.0" + path-exists "^4.0.0" + +find-up@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/find-up/-/find-up-5.0.0.tgz#4c92819ecb7083561e4f4a240a86be5198f536fc" + integrity sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng== + dependencies: + locate-path "^6.0.0" + path-exists "^4.0.0" + +find-yarn-workspace-root@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/find-yarn-workspace-root/-/find-yarn-workspace-root-2.0.0.tgz#f47fb8d239c900eb78179aa81b66673eac88f7bd" + integrity sha512-1IMnbjt4KzsQfnhnzNd8wUEgXZ44IzZaZmnLYx7D5FZlaHt2gW20Cri8Q+E/t5tIj4+epTBub+2Zxu/vNILzqQ== + dependencies: + micromatch "^4.0.2" + +flat-cache@^3.0.4: + version "3.2.0" + resolved "https://registry.yarnpkg.com/flat-cache/-/flat-cache-3.2.0.tgz#2c0c2d5040c99b1632771a9d105725c0115363ee" + integrity sha512-CYcENa+FtcUKLmhhqyctpclsq7QF38pKjZHsGNiSQF5r4FtoKDWabFDl3hzaEQMvT1LHEysw5twgLvpYYb4vbw== + dependencies: + flatted "^3.2.9" + keyv "^4.5.3" + rimraf "^3.0.2" + +flatted@^3.2.9: + version "3.3.3" + resolved "https://registry.yarnpkg.com/flatted/-/flatted-3.3.3.tgz#67c8fad95454a7c7abebf74bb78ee74a44023358" + integrity sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg== + +follow-redirects@^1.15.6: + version "1.15.11" + resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.15.11.tgz#777d73d72a92f8ec4d2e410eb47352a56b8e8340" + integrity sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ== + +for-each@^0.3.5: + version "0.3.5" + resolved "https://registry.yarnpkg.com/for-each/-/for-each-0.3.5.tgz#d650688027826920feeb0af747ee7b9421a41d47" + integrity sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg== + dependencies: + is-callable "^1.2.7" + +foreachasync@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/foreachasync/-/foreachasync-3.0.0.tgz#5502987dc8714be3392097f32e0071c9dee07cf6" + integrity sha512-J+ler7Ta54FwwNcx6wQRDhTIbNeyDcARMkOcguEqnEdtm0jKvN3Li3PDAb2Du3ubJYEWfYL83XMROXdsXAXycw== + +foreground-child@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/foreground-child/-/foreground-child-2.0.0.tgz#71b32800c9f15aa8f2f83f4a6bd9bff35d861a53" + integrity sha512-dCIq9FpEcyQyXKCkyzmlPTFNgrCzPudOe+mhvJU5zAtlBnGVy2yKxtfsxK2tQBThwq225jcvBjpw1Gr40uzZCA== + dependencies: + cross-spawn "^7.0.0" + signal-exit "^3.0.2" + +foreground-child@^3.1.0, foreground-child@^3.3.1: + version "3.3.1" + resolved "https://registry.yarnpkg.com/foreground-child/-/foreground-child-3.3.1.tgz#32e8e9ed1b68a3497befb9ac2b6adf92a638576f" + integrity sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw== + dependencies: + cross-spawn "^7.0.6" + signal-exit "^4.0.1" + +form-data@^4.0.4: + version "4.0.5" + resolved "https://registry.yarnpkg.com/form-data/-/form-data-4.0.5.tgz#b49e48858045ff4cbf6b03e1805cebcad3679053" + integrity sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w== + dependencies: + asynckit "^0.4.0" + combined-stream "^1.0.8" + es-set-tostringtag "^2.1.0" + hasown "^2.0.2" + mime-types "^2.1.12" + +fromentries@^1.2.0: + version "1.3.2" + resolved "https://registry.yarnpkg.com/fromentries/-/fromentries-1.3.2.tgz#e4bca6808816bf8f93b52750f1127f5a6fd86e3a" + integrity sha512-cHEpEQHUg0f8XdtZCc2ZAhrHzKzT0MrFUTcvx+hfxYu7rGMDc5SKoXFh+n4YigxsHXRzc6OrCshdR1bWH6HHyg== + +fs-constants@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/fs-constants/-/fs-constants-1.0.0.tgz#6be0de9be998ce16af8afc24497b9ee9b7ccd9ad" + integrity sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow== + +fs-exists-sync@^0.1.0: + version "0.1.0" + resolved "https://registry.yarnpkg.com/fs-exists-sync/-/fs-exists-sync-0.1.0.tgz#982d6893af918e72d08dec9e8673ff2b5a8d6add" + integrity sha512-cR/vflFyPZtrN6b38ZyWxpWdhlXrzZEBawlpBQMq7033xVY7/kg0GDMBK5jg8lDYQckdJ5x/YC88lM3C7VMsLg== + +fs-extra@^10.0.0: + version "10.1.0" + resolved "https://registry.yarnpkg.com/fs-extra/-/fs-extra-10.1.0.tgz#02873cfbc4084dde127eaa5f9905eef2325d1abf" + integrity sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ== + dependencies: + graceful-fs "^4.2.0" + jsonfile "^6.0.1" + universalify "^2.0.0" + +fs-extra@~11.3.0: + version "11.3.2" + resolved "https://registry.yarnpkg.com/fs-extra/-/fs-extra-11.3.2.tgz#c838aeddc6f4a8c74dd15f85e11fe5511bfe02a4" + integrity sha512-Xr9F6z6up6Ws+NjzMCZc6WXg2YFRlrLP9NQDO3VQrWrfiojdhS56TzueT88ze0uBdCTwEIhQ3ptnmKeWGFAe0A== + dependencies: + graceful-fs "^4.2.0" + jsonfile "^6.0.1" + universalify "^2.0.0" + +fs.realpath@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/fs.realpath/-/fs.realpath-1.0.0.tgz#1504ad2523158caa40db4a2787cb01411994ea4f" + integrity sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw== + +fsevents@2.3.2: + version "2.3.2" + resolved "https://registry.yarnpkg.com/fsevents/-/fsevents-2.3.2.tgz#8a526f78b8fdf4623b709e0b975c52c24c02fd1a" + integrity sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA== + +fsevents@^2.3.3, fsevents@~2.3.2, fsevents@~2.3.3: + version "2.3.3" + resolved "https://registry.yarnpkg.com/fsevents/-/fsevents-2.3.3.tgz#cac6407785d03675a2a5e1a5305c697b347d90d6" + integrity sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw== + +function-bind@^1.1.2: + version "1.1.2" + resolved "https://registry.yarnpkg.com/function-bind/-/function-bind-1.1.2.tgz#2c02d864d97f3ea6c8830c464cbd11ab6eab7a1c" + integrity sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA== + +generator-function@^2.0.0: + version "2.0.1" + resolved "https://registry.yarnpkg.com/generator-function/-/generator-function-2.0.1.tgz#0e75dd410d1243687a0ba2e951b94eedb8f737a2" + integrity sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g== + +gensync@^1.0.0-beta.2: + version "1.0.0-beta.2" + resolved "https://registry.yarnpkg.com/gensync/-/gensync-1.0.0-beta.2.tgz#32a6ee76c3d7f52d46b2b1ae5d93fea8580a25e0" + integrity sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg== + +get-caller-file@^2.0.1, get-caller-file@^2.0.5: + version "2.0.5" + resolved "https://registry.yarnpkg.com/get-caller-file/-/get-caller-file-2.0.5.tgz#4f94412a82db32f36e3b0b9741f8a97feb031f7e" + integrity sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg== + +get-intrinsic@^1.2.4, get-intrinsic@^1.2.5, get-intrinsic@^1.2.6, get-intrinsic@^1.3.0: + version "1.3.0" + resolved "https://registry.yarnpkg.com/get-intrinsic/-/get-intrinsic-1.3.0.tgz#743f0e3b6964a93a5491ed1bffaae054d7f98d01" + integrity sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ== + dependencies: + call-bind-apply-helpers "^1.0.2" + es-define-property "^1.0.1" + es-errors "^1.3.0" + es-object-atoms "^1.1.1" + function-bind "^1.1.2" + get-proto "^1.0.1" + gopd "^1.2.0" + has-symbols "^1.1.0" + hasown "^2.0.2" + math-intrinsics "^1.1.0" + +get-package-type@^0.1.0: + version "0.1.0" + resolved "https://registry.yarnpkg.com/get-package-type/-/get-package-type-0.1.0.tgz#8de2d803cff44df3bc6c456e6668b36c3926e11a" + integrity sha512-pjzuKtY64GYfWizNAJ0fr9VqttZkNiK2iS430LtIHzjBEr6bX8Am2zm4sW4Ro5wjWW5cAlRL1qAMTcXbjNAO2Q== + +get-port@^7.1.0: + version "7.1.0" + resolved "https://registry.yarnpkg.com/get-port/-/get-port-7.1.0.tgz#d5a500ebfc7aa705294ec2b83cc38c5d0e364fec" + integrity sha512-QB9NKEeDg3xxVwCCwJQ9+xycaz6pBB6iQ76wiWMl1927n0Kir6alPiP+yuiICLLU4jpMe08dXfpebuQppFA2zw== + +get-proto@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/get-proto/-/get-proto-1.0.1.tgz#150b3f2743869ef3e851ec0c49d15b1d14d00ee1" + integrity sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g== + dependencies: + dunder-proto "^1.0.1" + es-object-atoms "^1.0.0" + +get-stdin@^5.0.1: + version "5.0.1" + resolved "https://registry.yarnpkg.com/get-stdin/-/get-stdin-5.0.1.tgz#122e161591e21ff4c52530305693f20e6393a398" + integrity sha512-jZV7n6jGE3Gt7fgSTJoz91Ak5MuTLwMwkoYdjxuJ/AmjIsE1UC03y/IWkZCQGEvVNS9qoRNwy5BCqxImv0FVeA== + +get-stream@^6.0.0: + version "6.0.1" + resolved "https://registry.yarnpkg.com/get-stream/-/get-stream-6.0.1.tgz#a262d8eef67aced57c2852ad6167526a43cbf7b7" + integrity sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg== + +glob-parent@^6.0.2: + version "6.0.2" + resolved "https://registry.yarnpkg.com/glob-parent/-/glob-parent-6.0.2.tgz#6d237d99083950c79290f24c7642a3de9a28f9e3" + integrity sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A== + dependencies: + is-glob "^4.0.3" + +glob@^10.0.0, glob@^10.3.10: + version "10.5.0" + resolved "https://registry.yarnpkg.com/glob/-/glob-10.5.0.tgz#8ec0355919cd3338c28428a23d4f24ecc5fe738c" + integrity sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg== + dependencies: + foreground-child "^3.1.0" + jackspeak "^3.1.2" + minimatch "^9.0.4" + minipass "^7.1.2" + package-json-from-dist "^1.0.0" + path-scurry "^1.11.1" + +glob@^11.1.0: + version "11.1.0" + resolved "https://registry.yarnpkg.com/glob/-/glob-11.1.0.tgz#4f826576e4eb99c7dad383793d2f9f08f67e50a6" + integrity sha512-vuNwKSaKiqm7g0THUBu2x7ckSs3XJLXE+2ssL7/MfTGPLLcrJQ/4Uq1CjPTtO5cCIiRxqvN6Twy1qOwhL0Xjcw== + dependencies: + foreground-child "^3.3.1" + jackspeak "^4.1.1" + minimatch "^10.1.1" + minipass "^7.1.2" + package-json-from-dist "^1.0.0" + path-scurry "^2.0.0" + +glob@^7.1.3, glob@^7.1.4, glob@^7.1.6: + version "7.2.3" + resolved "https://registry.yarnpkg.com/glob/-/glob-7.2.3.tgz#b8df0fb802bbfa8e89bd1d938b4e16578ed44f2b" + integrity sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q== + dependencies: + fs.realpath "^1.0.0" + inflight "^1.0.4" + inherits "2" + minimatch "^3.1.1" + once "^1.3.0" + path-is-absolute "^1.0.0" + +global-modules@^0.2.3: + version "0.2.3" + resolved "https://registry.yarnpkg.com/global-modules/-/global-modules-0.2.3.tgz#ea5a3bed42c6d6ce995a4f8a1269b5dae223828d" + integrity sha512-JeXuCbvYzYXcwE6acL9V2bAOeSIGl4dD+iwLY9iUx2VBJJ80R18HCn+JCwHM9Oegdfya3lEkGCdaRkSyc10hDA== + dependencies: + global-prefix "^0.1.4" + is-windows "^0.2.0" + +global-prefix@^0.1.4: + version "0.1.5" + resolved "https://registry.yarnpkg.com/global-prefix/-/global-prefix-0.1.5.tgz#8d3bc6b8da3ca8112a160d8d496ff0462bfef78f" + integrity sha512-gOPiyxcD9dJGCEArAhF4Hd0BAqvAe/JzERP7tYumE4yIkmIedPUVXcJFWbV3/p/ovIIvKjkrTk+f1UVkq7vvbw== + dependencies: + homedir-polyfill "^1.0.0" + ini "^1.3.4" + is-windows "^0.2.0" + which "^1.2.12" + +globals@^13.19.0: + version "13.24.0" + resolved "https://registry.yarnpkg.com/globals/-/globals-13.24.0.tgz#8432a19d78ce0c1e833949c36adb345400bb1171" + integrity sha512-AhO5QUcj8llrbG09iWhPU2B204J1xnPeL8kQmVorSsy+Sjj1sk8gIyh6cUocGmH4L0UuhAJy+hJMRA4mgA4mFQ== + dependencies: + type-fest "^0.20.2" + +glur@^1.1.2: + version "1.1.2" + resolved "https://registry.yarnpkg.com/glur/-/glur-1.1.2.tgz#f20ea36db103bfc292343921f1f91e83c3467689" + integrity sha512-l+8esYHTKOx2G/Aao4lEQ0bnHWg4fWtJbVoZZT9Knxi01pB8C80BR85nONLFwkkQoFRCmXY+BUcGZN3yZ2QsRA== + +gopd@^1.0.1, gopd@^1.2.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/gopd/-/gopd-1.2.0.tgz#89f56b8217bdbc8802bd299df6d7f1081d7e51a1" + integrity sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg== + +graceful-fs@^4.1.11, graceful-fs@^4.1.15, graceful-fs@^4.1.6, graceful-fs@^4.2.0, graceful-fs@^4.2.11, graceful-fs@^4.2.4: + version "4.2.11" + resolved "https://registry.yarnpkg.com/graceful-fs/-/graceful-fs-4.2.11.tgz#4183e4e8bf08bb6e05bbb2f7d2e0c8f712ca40e3" + integrity sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ== + +graphemer@^1.4.0: + version "1.4.0" + resolved "https://registry.yarnpkg.com/graphemer/-/graphemer-1.4.0.tgz#fb2f1d55e0e3a1849aeffc90c4fa0dd53a0e66c6" + integrity sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag== + +has-flag@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/has-flag/-/has-flag-3.0.0.tgz#b5d454dc2199ae225699f3467e5a07f3b955bafd" + integrity sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw== + +has-flag@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/has-flag/-/has-flag-4.0.0.tgz#944771fd9c81c81265c4d6941860da06bb59479b" + integrity sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ== + +has-property-descriptors@^1.0.0, has-property-descriptors@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz#963ed7d071dc7bf5f084c5bfbe0d1b6222586854" + integrity sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg== + dependencies: + es-define-property "^1.0.0" + +has-symbols@^1.0.3, has-symbols@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/has-symbols/-/has-symbols-1.1.0.tgz#fc9c6a783a084951d0b971fe1018de813707a338" + integrity sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ== + +has-tostringtag@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/has-tostringtag/-/has-tostringtag-1.0.2.tgz#2cdc42d40bef2e5b4eeab7c01a73c54ce7ab5abc" + integrity sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw== + dependencies: + has-symbols "^1.0.3" + +hash-base@^3.0.0, hash-base@^3.1.2: + version "3.1.2" + resolved "https://registry.yarnpkg.com/hash-base/-/hash-base-3.1.2.tgz#79d72def7611c3f6e3c3b5730652638001b10a74" + integrity sha512-Bb33KbowVTIj5s7Ked1OsqHUeCpz//tPwR+E2zJgJKo9Z5XolZ9b6bdUgjmYlwnWhoOQKoTd1TYToZGn5mAYOg== + dependencies: + inherits "^2.0.4" + readable-stream "^2.3.8" + safe-buffer "^5.2.1" + to-buffer "^1.2.1" + +hash-base@~3.0.4: + version "3.0.5" + resolved "https://registry.yarnpkg.com/hash-base/-/hash-base-3.0.5.tgz#52480e285395cf7fba17dc4c9e47acdc7f248a8a" + integrity sha512-vXm0l45VbcHEVlTCzs8M+s0VeYsB2lnlAaThoLKGXr3bE/VWDOelNUnycUPEhKEaXARL2TEFjBOyUiM6+55KBg== + dependencies: + inherits "^2.0.4" + safe-buffer "^5.2.1" + +hash.js@^1.0.0, hash.js@^1.0.3: + version "1.1.7" + resolved "https://registry.yarnpkg.com/hash.js/-/hash.js-1.1.7.tgz#0babca538e8d4ee4a0f8988d68866537a003cf42" + integrity sha512-taOaskGt4z4SOANNseOviYDvjEJinIkRgmp7LbKP2YTTmVxWBl87s/uzK9r+44BclBSp2X7K1hqeNfz9JbBeXA== + dependencies: + inherits "^2.0.3" + minimalistic-assert "^1.0.1" + +hasha@^5.0.0: + version "5.2.2" + resolved "https://registry.yarnpkg.com/hasha/-/hasha-5.2.2.tgz#a48477989b3b327aea3c04f53096d816d97522a1" + integrity sha512-Hrp5vIK/xr5SkeN2onO32H0MgNZ0f17HRNH39WfL0SYUNOTZ5Lz1TJ8Pajo/87dYGEFlLMm7mIc/k/s6Bvz9HQ== + dependencies: + is-stream "^2.0.0" + type-fest "^0.8.0" + +hasown@^2.0.2: + version "2.0.2" + resolved "https://registry.yarnpkg.com/hasown/-/hasown-2.0.2.tgz#003eaf91be7adc372e84ec59dc37252cedb80003" + integrity sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ== + dependencies: + function-bind "^1.1.2" + +he@^1.2.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/he/-/he-1.2.0.tgz#84ae65fa7eafb165fddb61566ae14baf05664f0f" + integrity sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw== + +hmac-drbg@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/hmac-drbg/-/hmac-drbg-1.0.1.tgz#d2745701025a6c775a6c545793ed502fc0c649a1" + integrity sha512-Tti3gMqLdZfhOQY1Mzf/AanLiqh1WTiJgEj26ZuYQ9fbkLomzGchCws4FyrSd4VkpBfiNhaE1On+lOz894jvXg== + dependencies: + hash.js "^1.0.3" + minimalistic-assert "^1.0.0" + minimalistic-crypto-utils "^1.0.1" + +homedir-polyfill@^1.0.0: + version "1.0.3" + resolved "https://registry.yarnpkg.com/homedir-polyfill/-/homedir-polyfill-1.0.3.tgz#743298cef4e5af3e194161fbadcc2151d3a058e8" + integrity sha512-eSmmWE5bZTK2Nou4g0AI3zZ9rswp7GRKoKXS1BLUkvPviOqs4YTN1djQIqrXy9k5gEtdLPy86JjRwsNM9tnDcA== + dependencies: + parse-passwd "^1.0.0" + +html-escaper@^2.0.0: + version "2.0.2" + resolved "https://registry.yarnpkg.com/html-escaper/-/html-escaper-2.0.2.tgz#dfd60027da36a36dfcbe236262c00a5822681453" + integrity sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg== + +htmlparser2@^3.9.2: + version "3.10.1" + resolved "https://registry.yarnpkg.com/htmlparser2/-/htmlparser2-3.10.1.tgz#bd679dc3f59897b6a34bb10749c855bb53a9392f" + integrity sha512-IgieNijUMbkDovyoKObU1DUhm1iwNYE/fuifEoEHfd1oZKZDaONBSkal7Y01shxsM49R4XaMdGez3WnF9UfiCQ== + dependencies: + domelementtype "^1.3.1" + domhandler "^2.3.0" + domutils "^1.5.1" + entities "^1.1.1" + inherits "^2.0.1" + readable-stream "^3.1.1" + +https-browserify@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/https-browserify/-/https-browserify-1.0.0.tgz#ec06c10e0a34c0f2faf199f7fd7fc78fffd03c73" + integrity sha512-J+FkSdyD+0mA0N+81tMotaRMfSL9SGi+xpD3T6YApKsc3bGSXJlfXri3VyFOeYkfLRQisDk1W+jIFFKBeUBbBg== + +human-signals@^2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/human-signals/-/human-signals-2.1.0.tgz#dc91fcba42e4d06e4abaed33b3e7a3c02f514ea0" + integrity sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw== + +ieee754@^1.1.13, ieee754@^1.2.1: + version "1.2.1" + resolved "https://registry.yarnpkg.com/ieee754/-/ieee754-1.2.1.tgz#8eb7a10a63fff25d15a57b001586d177d1b0d352" + integrity sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA== + +ignore@^5.2.0: + version "5.3.2" + resolved "https://registry.yarnpkg.com/ignore/-/ignore-5.3.2.tgz#3cd40e729f3643fd87cb04e50bf0eb722bc596f5" + integrity sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g== + +import-fresh@^3.2.1: + version "3.3.1" + resolved "https://registry.yarnpkg.com/import-fresh/-/import-fresh-3.3.1.tgz#9cecb56503c0ada1f2741dbbd6546e4b13b57ccf" + integrity sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ== + dependencies: + parent-module "^1.0.0" + resolve-from "^4.0.0" + +import-lazy@~4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/import-lazy/-/import-lazy-4.0.0.tgz#e8eb627483a0a43da3c03f3e35548be5cb0cc153" + integrity sha512-rKtvo6a868b5Hu3heneU+L4yEQ4jYKLtjpnPeUdK7h0yzXGmyBTypknlkCvHFBqfX9YlorEiMM6Dnq/5atfHkw== + +import-local@^3.2.0: + version "3.2.0" + resolved "https://registry.yarnpkg.com/import-local/-/import-local-3.2.0.tgz#c3d5c745798c02a6f8b897726aba5100186ee260" + integrity sha512-2SPlun1JUPWoM6t3F0dw0FkCF/jWY8kttcY4f599GLTSjh2OCuuhdTkJQsEcZzBqbXZGKMK2OqW1oZsjtf/gQA== + dependencies: + pkg-dir "^4.2.0" + resolve-cwd "^3.0.0" + +imurmurhash@^0.1.4: + version "0.1.4" + resolved "https://registry.yarnpkg.com/imurmurhash/-/imurmurhash-0.1.4.tgz#9218b9b2b928a238b13dc4fb6b6d576f231453ea" + integrity sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA== + +indent-string@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/indent-string/-/indent-string-4.0.0.tgz#624f8f4497d619b2d9768531d58f4122854d7251" + integrity sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg== + +indexof@0.0.1: + version "0.0.1" + resolved "https://registry.yarnpkg.com/indexof/-/indexof-0.0.1.tgz#82dc336d232b9062179d05ab3293a66059fd435d" + integrity sha512-i0G7hLJ1z0DE8dsqJa2rycj9dBmNKgXBvotXtZYXakU9oivfB9Uj2ZBC27qqef2U58/ZLwalxa1X/RDCdkHtVg== + +inflight@^1.0.4: + version "1.0.6" + resolved "https://registry.yarnpkg.com/inflight/-/inflight-1.0.6.tgz#49bd6331d7d02d0c09bc910a1075ba8165b56df9" + integrity sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA== + dependencies: + once "^1.3.0" + wrappy "1" + +inherits@2, inherits@^2.0.1, inherits@^2.0.3, inherits@^2.0.4, inherits@~2.0.3, inherits@~2.0.4: + version "2.0.4" + resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.4.tgz#0fa2c64f932917c3433a0ded55363aae37416b7c" + integrity sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ== + +ini@^1.3.4: + version "1.3.8" + resolved "https://registry.yarnpkg.com/ini/-/ini-1.3.8.tgz#a29da425b48806f34767a4efce397269af28432c" + integrity sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew== + +is-arguments@^1.0.4: + version "1.2.0" + resolved "https://registry.yarnpkg.com/is-arguments/-/is-arguments-1.2.0.tgz#ad58c6aecf563b78ef2bf04df540da8f5d7d8e1b" + integrity sha512-7bVbi0huj/wrIAOzb8U1aszg9kdi3KN/CyU19CTI7tAoZYEZoL9yCDXpbXN+uPsuWnP02cyug1gleqq+TU+YCA== + dependencies: + call-bound "^1.0.2" + has-tostringtag "^1.0.2" + +is-arrayish@^0.2.1: + version "0.2.1" + resolved "https://registry.yarnpkg.com/is-arrayish/-/is-arrayish-0.2.1.tgz#77c99840527aa8ecb1a8ba697b80645a7a926a9d" + integrity sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg== + +is-callable@^1.2.7: + version "1.2.7" + resolved "https://registry.yarnpkg.com/is-callable/-/is-callable-1.2.7.tgz#3bc2a85ea742d9e36205dcacdd72ca1fdc51b055" + integrity sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA== + +is-core-module@^2.16.0, is-core-module@^2.16.1: + version "2.16.1" + resolved "https://registry.yarnpkg.com/is-core-module/-/is-core-module-2.16.1.tgz#2a98801a849f43e2add644fbb6bc6229b19a4ef4" + integrity sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w== + dependencies: + hasown "^2.0.2" + +is-docker@^2.0.0: + version "2.2.1" + resolved "https://registry.yarnpkg.com/is-docker/-/is-docker-2.2.1.tgz#33eeabe23cfe86f14bde4408a02c0cfb853acdaa" + integrity sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ== + +is-docker@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/is-docker/-/is-docker-3.0.0.tgz#90093aa3106277d8a77a5910dbae71747e15a200" + integrity sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ== + +is-extglob@^2.1.1: + version "2.1.1" + resolved "https://registry.yarnpkg.com/is-extglob/-/is-extglob-2.1.1.tgz#a88c02535791f02ed37c76a1b9ea9773c833f8c2" + integrity sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ== + +is-fullwidth-code-point@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz#f116f8064fe90b3f7844a38997c0b75051269f1d" + integrity sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg== + +is-generator-fn@^2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/is-generator-fn/-/is-generator-fn-2.1.0.tgz#7d140adc389aaf3011a8f2a2a4cfa6faadffb118" + integrity sha512-cTIB4yPYL/Grw0EaSzASzg6bBy9gqCofvWN8okThAYIxKJZC+udlRAmGbM0XLeniEJSs8uEgHPGuHSe1XsOLSQ== + +is-generator-function@^1.0.7: + version "1.1.2" + resolved "https://registry.yarnpkg.com/is-generator-function/-/is-generator-function-1.1.2.tgz#ae3b61e3d5ea4e4839b90bad22b02335051a17d5" + integrity sha512-upqt1SkGkODW9tsGNG5mtXTXtECizwtS2kA161M+gJPc1xdb/Ax629af6YrTwcOeQHbewrPNlE5Dx7kzvXTizA== + dependencies: + call-bound "^1.0.4" + generator-function "^2.0.0" + get-proto "^1.0.1" + has-tostringtag "^1.0.2" + safe-regex-test "^1.1.0" + +is-glob@^4.0.0, is-glob@^4.0.3: + version "4.0.3" + resolved "https://registry.yarnpkg.com/is-glob/-/is-glob-4.0.3.tgz#64f61e42cbbb2eec2071a9dac0b28ba1e65d5084" + integrity sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg== + dependencies: + is-extglob "^2.1.1" + +is-inside-container@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/is-inside-container/-/is-inside-container-1.0.0.tgz#e81fba699662eb31dbdaf26766a61d4814717ea4" + integrity sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA== + dependencies: + is-docker "^3.0.0" + +is-nan@^1.3.2: + version "1.3.2" + resolved "https://registry.yarnpkg.com/is-nan/-/is-nan-1.3.2.tgz#043a54adea31748b55b6cd4e09aadafa69bd9e1d" + integrity sha512-E+zBKpQ2t6MEo1VsonYmluk9NxGrbzpeeLC2xIViuO2EjU2xsXsBPwTr3Ykv9l08UYEVEdWeRZNouaZqF6RN0w== + dependencies: + call-bind "^1.0.0" + define-properties "^1.1.3" + +is-number@^7.0.0: + version "7.0.0" + resolved "https://registry.yarnpkg.com/is-number/-/is-number-7.0.0.tgz#7535345b896734d5f80c4d06c50955527a14f12b" + integrity sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng== + +is-path-inside@^3.0.3: + version "3.0.3" + resolved "https://registry.yarnpkg.com/is-path-inside/-/is-path-inside-3.0.3.tgz#d231362e53a07ff2b0e0ea7fed049161ffd16283" + integrity sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ== + +is-regex@^1.2.1: + version "1.2.1" + resolved "https://registry.yarnpkg.com/is-regex/-/is-regex-1.2.1.tgz#76d70a3ed10ef9be48eb577887d74205bf0cad22" + integrity sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g== + dependencies: + call-bound "^1.0.2" + gopd "^1.2.0" + has-tostringtag "^1.0.2" + hasown "^2.0.2" + +is-stream@^2.0.0, is-stream@^2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/is-stream/-/is-stream-2.0.1.tgz#fac1e3d53b97ad5a9d0ae9cef2389f5810a5c077" + integrity sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg== + +is-typed-array@^1.1.14, is-typed-array@^1.1.3: + version "1.1.15" + resolved "https://registry.yarnpkg.com/is-typed-array/-/is-typed-array-1.1.15.tgz#4bfb4a45b61cee83a5a46fba778e4e8d59c0ce0b" + integrity sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ== + dependencies: + which-typed-array "^1.1.16" + +is-typedarray@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/is-typedarray/-/is-typedarray-1.0.0.tgz#e479c80858df0c1b11ddda6940f96011fcda4a9a" + integrity sha512-cyA56iCMHAh5CdzjJIa4aohJyeO1YbwLi3Jc35MmRU6poroFjIGZzUzupGiRPOjgHg9TLu43xbpwXk523fMxKA== + +is-windows@^0.2.0: + version "0.2.0" + resolved "https://registry.yarnpkg.com/is-windows/-/is-windows-0.2.0.tgz#de1aa6d63ea29dd248737b69f1ff8b8002d2108c" + integrity sha512-n67eJYmXbniZB7RF4I/FTjK1s6RPOCTxhYrVYLRaCt3lF0mpWZPKr3T2LSZAqyjQsxR2qMmGYXXzK0YWwcPM1Q== + +is-windows@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/is-windows/-/is-windows-1.0.2.tgz#d1850eb9791ecd18e6182ce12a30f396634bb19d" + integrity sha512-eXK1UInq2bPmjyX6e3VHIzMLobc4J94i4AWn+Hpq3OU5KkrRC96OAcR3PRJ/pGu6m8TRnBHP9dkXQVsT/COVIA== + +is-wsl@^2.1.1: + version "2.2.0" + resolved "https://registry.yarnpkg.com/is-wsl/-/is-wsl-2.2.0.tgz#74a4c76e77ca9fd3f932f290c17ea326cd157271" + integrity sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww== + dependencies: + is-docker "^2.0.0" + +is-wsl@^3.1.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/is-wsl/-/is-wsl-3.1.0.tgz#e1c657e39c10090afcbedec61720f6b924c3cbd2" + integrity sha512-UcVfVfaK4Sc4m7X3dUSoHoozQGBEFeDC+zVo06t98xe8CzHSZZBekNXH+tu0NalHolcJ/QAGqS46Hef7QXBIMw== + dependencies: + is-inside-container "^1.0.0" + +isarray@^2.0.5: + version "2.0.5" + resolved "https://registry.yarnpkg.com/isarray/-/isarray-2.0.5.tgz#8af1e4c1221244cc62459faf38940d4e644a5723" + integrity sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw== + +isarray@~1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/isarray/-/isarray-1.0.0.tgz#bb935d48582cba168c06834957a54a3e07124f11" + integrity sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ== + +isexe@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/isexe/-/isexe-2.0.0.tgz#e8fbf374dc556ff8947a10dcb0572d633f2cfa10" + integrity sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw== + +isomorphic-timers-promises@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/isomorphic-timers-promises/-/isomorphic-timers-promises-1.0.1.tgz#e4137c24dbc54892de8abae3a4b5c1ffff381598" + integrity sha512-u4sej9B1LPSxTGKB/HiuzvEQnXH0ECYkSVQU39koSwmFAxhlEAFl9RdTvLv4TOTQUgBS5O3O5fwUxk6byBZ+IQ== + +istanbul-lib-coverage@^3.0.0, istanbul-lib-coverage@^3.2.0: + version "3.2.2" + resolved "https://registry.yarnpkg.com/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz#2d166c4b0644d43a39f04bf6c2edd1e585f31756" + integrity sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg== + +istanbul-lib-hook@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/istanbul-lib-hook/-/istanbul-lib-hook-3.0.0.tgz#8f84c9434888cc6b1d0a9d7092a76d239ebf0cc6" + integrity sha512-Pt/uge1Q9s+5VAZ+pCo16TYMWPBIl+oaNIjgLQxcX0itS6ueeaA+pEfThZpH8WxhFgCiEb8sAJY6MdUKgiIWaQ== + dependencies: + append-transform "^2.0.0" + +istanbul-lib-instrument@^4.0.0: + version "4.0.3" + resolved "https://registry.yarnpkg.com/istanbul-lib-instrument/-/istanbul-lib-instrument-4.0.3.tgz#873c6fff897450118222774696a3f28902d77c1d" + integrity sha512-BXgQl9kf4WTCPCCpmFGoJkz/+uhvm7h7PFKUYxh7qarQd3ER33vHG//qaE8eN25l07YqZPpHXU9I09l/RD5aGQ== + dependencies: + "@babel/core" "^7.7.5" + "@istanbuljs/schema" "^0.1.2" + istanbul-lib-coverage "^3.0.0" + semver "^6.3.0" + +istanbul-lib-instrument@^6.0.0, istanbul-lib-instrument@^6.0.2: + version "6.0.3" + resolved "https://registry.yarnpkg.com/istanbul-lib-instrument/-/istanbul-lib-instrument-6.0.3.tgz#fa15401df6c15874bcb2105f773325d78c666765" + integrity sha512-Vtgk7L/R2JHyyGW07spoFlB8/lpjiOLTjMdms6AFMraYt3BaJauod/NGrfnVG/y4Ix1JEuMRPDPEj2ua+zz1/Q== + dependencies: + "@babel/core" "^7.23.9" + "@babel/parser" "^7.23.9" + "@istanbuljs/schema" "^0.1.3" + istanbul-lib-coverage "^3.2.0" + semver "^7.5.4" + +istanbul-lib-processinfo@^2.0.2: + version "2.0.3" + resolved "https://registry.yarnpkg.com/istanbul-lib-processinfo/-/istanbul-lib-processinfo-2.0.3.tgz#366d454cd0dcb7eb6e0e419378e60072c8626169" + integrity sha512-NkwHbo3E00oybX6NGJi6ar0B29vxyvNwoC7eJ4G4Yq28UfY758Hgn/heV8VRFhevPED4LXfFz0DQ8z/0kw9zMg== + dependencies: + archy "^1.0.0" + cross-spawn "^7.0.3" + istanbul-lib-coverage "^3.2.0" + p-map "^3.0.0" + rimraf "^3.0.0" + uuid "^8.3.2" + +istanbul-lib-report@^3.0.0: + version "3.0.1" + resolved "https://registry.yarnpkg.com/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz#908305bac9a5bd175ac6a74489eafd0fc2445a7d" + integrity sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw== + dependencies: + istanbul-lib-coverage "^3.0.0" + make-dir "^4.0.0" + supports-color "^7.1.0" + +istanbul-lib-source-maps@^4.0.0: + version "4.0.1" + resolved "https://registry.yarnpkg.com/istanbul-lib-source-maps/-/istanbul-lib-source-maps-4.0.1.tgz#895f3a709fcfba34c6de5a42939022f3e4358551" + integrity sha512-n3s8EwkdFIJCG3BPKBYvskgXGoy88ARzvegkitk60NxRdwltLOTaH7CUiMRXvwYorl0Q712iEjcWB+fK/MrWVw== + dependencies: + debug "^4.1.1" + istanbul-lib-coverage "^3.0.0" + source-map "^0.6.1" + +istanbul-lib-source-maps@^5.0.0: + version "5.0.6" + resolved "https://registry.yarnpkg.com/istanbul-lib-source-maps/-/istanbul-lib-source-maps-5.0.6.tgz#acaef948df7747c8eb5fbf1265cb980f6353a441" + integrity sha512-yg2d+Em4KizZC5niWhQaIomgf5WlL4vOOjZ5xGCmF8SnPE/mDWWXgvRExdcpCgh9lLRRa1/fSYp2ymmbJ1pI+A== + dependencies: + "@jridgewell/trace-mapping" "^0.3.23" + debug "^4.1.1" + istanbul-lib-coverage "^3.0.0" + +istanbul-reports@^3.0.2, istanbul-reports@^3.1.3: + version "3.2.0" + resolved "https://registry.yarnpkg.com/istanbul-reports/-/istanbul-reports-3.2.0.tgz#cb4535162b5784aa623cee21a7252cf2c807ac93" + integrity sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA== + dependencies: + html-escaper "^2.0.0" + istanbul-lib-report "^3.0.0" + +jackspeak@^3.1.2: + version "3.4.3" + resolved "https://registry.yarnpkg.com/jackspeak/-/jackspeak-3.4.3.tgz#8833a9d89ab4acde6188942bd1c53b6390ed5a8a" + integrity sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw== + dependencies: + "@isaacs/cliui" "^8.0.2" + optionalDependencies: + "@pkgjs/parseargs" "^0.11.0" + +jackspeak@^4.1.1: + version "4.1.1" + resolved "https://registry.yarnpkg.com/jackspeak/-/jackspeak-4.1.1.tgz#96876030f450502047fc7e8c7fcf8ce8124e43ae" + integrity sha512-zptv57P3GpL+O0I7VdMJNBZCu+BPHVQUk55Ft8/QCJjTVxrnJHuVuX/0Bl2A6/+2oyR/ZMEuFKwmzqqZ/U5nPQ== + dependencies: + "@isaacs/cliui" "^8.0.2" + +jest-changed-files@30.2.0: + version "30.2.0" + resolved "https://registry.yarnpkg.com/jest-changed-files/-/jest-changed-files-30.2.0.tgz#602266e478ed554e1e1469944faa7efd37cee61c" + integrity sha512-L8lR1ChrRnSdfeOvTrwZMlnWV8G/LLjQ0nG9MBclwWZidA2N5FviRki0Bvh20WRMOX31/JYvzdqTJrk5oBdydQ== + dependencies: + execa "^5.1.1" + jest-util "30.2.0" + p-limit "^3.1.0" + +jest-circus@30.2.0, jest-circus@^30.0.4: + version "30.2.0" + resolved "https://registry.yarnpkg.com/jest-circus/-/jest-circus-30.2.0.tgz#98b8198b958748a2f322354311023d1d02e7603f" + integrity sha512-Fh0096NC3ZkFx05EP2OXCxJAREVxj1BcW/i6EWqqymcgYKWjyyDpral3fMxVcHXg6oZM7iULer9wGRFvfpl+Tg== + dependencies: + "@jest/environment" "30.2.0" + "@jest/expect" "30.2.0" + "@jest/test-result" "30.2.0" + "@jest/types" "30.2.0" + "@types/node" "*" + chalk "^4.1.2" + co "^4.6.0" + dedent "^1.6.0" + is-generator-fn "^2.1.0" + jest-each "30.2.0" + jest-matcher-utils "30.2.0" + jest-message-util "30.2.0" + jest-runtime "30.2.0" + jest-snapshot "30.2.0" + jest-util "30.2.0" + p-limit "^3.1.0" + pretty-format "30.2.0" + pure-rand "^7.0.0" + slash "^3.0.0" + stack-utils "^2.0.6" + +jest-cli@30.2.0: + version "30.2.0" + resolved "https://registry.yarnpkg.com/jest-cli/-/jest-cli-30.2.0.tgz#1780f8e9d66bf84a10b369aea60aeda7697dcc67" + integrity sha512-Os9ukIvADX/A9sLt6Zse3+nmHtHaE6hqOsjQtNiugFTbKRHYIYtZXNGNK9NChseXy7djFPjndX1tL0sCTlfpAA== + dependencies: + "@jest/core" "30.2.0" + "@jest/test-result" "30.2.0" + "@jest/types" "30.2.0" + chalk "^4.1.2" + exit-x "^0.2.2" + import-local "^3.2.0" + jest-config "30.2.0" + jest-util "30.2.0" + jest-validate "30.2.0" + yargs "^17.7.2" + +jest-config@30.2.0: + version "30.2.0" + resolved "https://registry.yarnpkg.com/jest-config/-/jest-config-30.2.0.tgz#29df8c50e2ad801cc59c406b50176c18c362a90b" + integrity sha512-g4WkyzFQVWHtu6uqGmQR4CQxz/CH3yDSlhzXMWzNjDx843gYjReZnMRanjRCq5XZFuQrGDxgUaiYWE8BRfVckA== + dependencies: + "@babel/core" "^7.27.4" + "@jest/get-type" "30.1.0" + "@jest/pattern" "30.0.1" + "@jest/test-sequencer" "30.2.0" + "@jest/types" "30.2.0" + babel-jest "30.2.0" + chalk "^4.1.2" + ci-info "^4.2.0" + deepmerge "^4.3.1" + glob "^10.3.10" + graceful-fs "^4.2.11" + jest-circus "30.2.0" + jest-docblock "30.2.0" + jest-environment-node "30.2.0" + jest-regex-util "30.0.1" + jest-resolve "30.2.0" + jest-runner "30.2.0" + jest-util "30.2.0" + jest-validate "30.2.0" + micromatch "^4.0.8" + parse-json "^5.2.0" + pretty-format "30.2.0" + slash "^3.0.0" + strip-json-comments "^3.1.1" + +jest-diff@30.2.0: + version "30.2.0" + resolved "https://registry.yarnpkg.com/jest-diff/-/jest-diff-30.2.0.tgz#e3ec3a6ea5c5747f605c9e874f83d756cba36825" + integrity sha512-dQHFo3Pt4/NLlG5z4PxZ/3yZTZ1C7s9hveiOj+GCN+uT109NC2QgsoVZsVOAvbJ3RgKkvyLGXZV9+piDpWbm6A== + dependencies: + "@jest/diff-sequences" "30.0.1" + "@jest/get-type" "30.1.0" + chalk "^4.1.2" + pretty-format "30.2.0" + +jest-docblock@30.2.0: + version "30.2.0" + resolved "https://registry.yarnpkg.com/jest-docblock/-/jest-docblock-30.2.0.tgz#42cd98d69f887e531c7352309542b1ce4ee10256" + integrity sha512-tR/FFgZKS1CXluOQzZvNH3+0z9jXr3ldGSD8bhyuxvlVUwbeLOGynkunvlTMxchC5urrKndYiwCFC0DLVjpOCA== + dependencies: + detect-newline "^3.1.0" + +jest-each@30.2.0: + version "30.2.0" + resolved "https://registry.yarnpkg.com/jest-each/-/jest-each-30.2.0.tgz#39e623ae71641c2ac3ee69b3ba3d258fce8e768d" + integrity sha512-lpWlJlM7bCUf1mfmuqTA8+j2lNURW9eNafOy99knBM01i5CQeY5UH1vZjgT9071nDJac1M4XsbyI44oNOdhlDQ== + dependencies: + "@jest/get-type" "30.1.0" + "@jest/types" "30.2.0" + chalk "^4.1.2" + jest-util "30.2.0" + pretty-format "30.2.0" + +jest-environment-node@30.2.0, jest-environment-node@^30.0.4: + version "30.2.0" + resolved "https://registry.yarnpkg.com/jest-environment-node/-/jest-environment-node-30.2.0.tgz#3def7980ebd2fd86e74efd4d2e681f55ab38da0f" + integrity sha512-ElU8v92QJ9UrYsKrxDIKCxu6PfNj4Hdcktcn0JX12zqNdqWHB0N+hwOnnBBXvjLd2vApZtuLUGs1QSY+MsXoNA== + dependencies: + "@jest/environment" "30.2.0" + "@jest/fake-timers" "30.2.0" + "@jest/types" "30.2.0" + "@types/node" "*" + jest-mock "30.2.0" + jest-util "30.2.0" + jest-validate "30.2.0" + +jest-haste-map@30.2.0: + version "30.2.0" + resolved "https://registry.yarnpkg.com/jest-haste-map/-/jest-haste-map-30.2.0.tgz#808e3889f288603ac70ff0ac047598345a66022e" + integrity sha512-sQA/jCb9kNt+neM0anSj6eZhLZUIhQgwDt7cPGjumgLM4rXsfb9kpnlacmvZz3Q5tb80nS+oG/if+NBKrHC+Xw== + dependencies: + "@jest/types" "30.2.0" + "@types/node" "*" + anymatch "^3.1.3" + fb-watchman "^2.0.2" + graceful-fs "^4.2.11" + jest-regex-util "30.0.1" + jest-util "30.2.0" + jest-worker "30.2.0" + micromatch "^4.0.8" + walker "^1.0.8" + optionalDependencies: + fsevents "^2.3.3" + +jest-image-snapshot@^6.5.1: + version "6.5.1" + resolved "https://registry.yarnpkg.com/jest-image-snapshot/-/jest-image-snapshot-6.5.1.tgz#cc714aff86187ecf38f90c506b443769aff87499" + integrity sha512-xlJFufgfY2Z4DsRsjcnTwxuynvo1bKdhf4OfcEftNuUAK+BwSCUtPmwlBGJhQ0XJXfm9JMAi/4BhQiHbaV8HrA== + dependencies: + chalk "^4.0.0" + get-stdin "^5.0.1" + glur "^1.1.2" + lodash "^4.17.4" + pixelmatch "^5.1.0" + pngjs "^3.4.0" + ssim.js "^3.1.1" + +jest-junit@^16.0.0: + version "16.0.0" + resolved "https://registry.yarnpkg.com/jest-junit/-/jest-junit-16.0.0.tgz#d838e8c561cf9fdd7eb54f63020777eee4136785" + integrity sha512-A94mmw6NfJab4Fg/BlvVOUXzXgF0XIH6EmTgJ5NDPp4xoKq0Kr7sErb+4Xs9nZvu58pJojz5RFGpqnZYJTrRfQ== + dependencies: + mkdirp "^1.0.4" + strip-ansi "^6.0.1" + uuid "^8.3.2" + xml "^1.0.1" + +jest-leak-detector@30.2.0: + version "30.2.0" + resolved "https://registry.yarnpkg.com/jest-leak-detector/-/jest-leak-detector-30.2.0.tgz#292fdca7b7c9cf594e1e570ace140b01d8beb736" + integrity sha512-M6jKAjyzjHG0SrQgwhgZGy9hFazcudwCNovY/9HPIicmNSBuockPSedAP9vlPK6ONFJ1zfyH/M2/YYJxOz5cdQ== + dependencies: + "@jest/get-type" "30.1.0" + pretty-format "30.2.0" + +jest-matcher-utils@30.2.0: + version "30.2.0" + resolved "https://registry.yarnpkg.com/jest-matcher-utils/-/jest-matcher-utils-30.2.0.tgz#69a0d4c271066559ec8b0d8174829adc3f23a783" + integrity sha512-dQ94Nq4dbzmUWkQ0ANAWS9tBRfqCrn0bV9AMYdOi/MHW726xn7eQmMeRTpX2ViC00bpNaWXq+7o4lIQ3AX13Hg== + dependencies: + "@jest/get-type" "30.1.0" + chalk "^4.1.2" + jest-diff "30.2.0" + pretty-format "30.2.0" + +jest-message-util@30.2.0: + version "30.2.0" + resolved "https://registry.yarnpkg.com/jest-message-util/-/jest-message-util-30.2.0.tgz#fc97bf90d11f118b31e6131e2b67fc4f39f92152" + integrity sha512-y4DKFLZ2y6DxTWD4cDe07RglV88ZiNEdlRfGtqahfbIjfsw1nMCPx49Uev4IA/hWn3sDKyAnSPwoYSsAEdcimw== + dependencies: + "@babel/code-frame" "^7.27.1" + "@jest/types" "30.2.0" + "@types/stack-utils" "^2.0.3" + chalk "^4.1.2" + graceful-fs "^4.2.11" + micromatch "^4.0.8" + pretty-format "30.2.0" + slash "^3.0.0" + stack-utils "^2.0.6" + +jest-mock@30.2.0: + version "30.2.0" + resolved "https://registry.yarnpkg.com/jest-mock/-/jest-mock-30.2.0.tgz#69f991614eeb4060189459d3584f710845bff45e" + integrity sha512-JNNNl2rj4b5ICpmAcq+WbLH83XswjPbjH4T7yvGzfAGCPh1rw+xVNbtk+FnRslvt9lkCcdn9i1oAoKUuFsOxRw== + dependencies: + "@jest/types" "30.2.0" + "@types/node" "*" + jest-util "30.2.0" + +jest-pnp-resolver@^1.2.3: + version "1.2.3" + resolved "https://registry.yarnpkg.com/jest-pnp-resolver/-/jest-pnp-resolver-1.2.3.tgz#930b1546164d4ad5937d5540e711d4d38d4cad2e" + integrity sha512-+3NpwQEnRoIBtx4fyhblQDPgJI0H1IEIkX7ShLUjPGA7TtUTvI1oiKi3SR4oBR0hQhQR80l4WAe5RrXBwWMA8w== + +jest-process-manager@^0.4.0: + version "0.4.0" + resolved "https://registry.yarnpkg.com/jest-process-manager/-/jest-process-manager-0.4.0.tgz#fb05c8e09ad400fd038436004815653bb98f4e8b" + integrity sha512-80Y6snDyb0p8GG83pDxGI/kQzwVTkCxc7ep5FPe/F6JYdvRDhwr6RzRmPSP7SEwuLhxo80lBS/NqOdUIbHIfhw== + dependencies: + "@types/wait-on" "^5.2.0" + chalk "^4.1.0" + cwd "^0.10.0" + exit "^0.1.2" + find-process "^1.4.4" + prompts "^2.4.1" + signal-exit "^3.0.3" + spawnd "^5.0.0" + tree-kill "^1.2.2" + wait-on "^7.0.0" + +jest-regex-util@30.0.1, jest-regex-util@^30.0.0: + version "30.0.1" + resolved "https://registry.yarnpkg.com/jest-regex-util/-/jest-regex-util-30.0.1.tgz#f17c1de3958b67dfe485354f5a10093298f2a49b" + integrity sha512-jHEQgBXAgc+Gh4g0p3bCevgRCVRkB4VB70zhoAE48gxeSr1hfUOsM/C2WoJgVL7Eyg//hudYENbm3Ne+/dRVVA== + +jest-resolve-dependencies@30.2.0: + version "30.2.0" + resolved "https://registry.yarnpkg.com/jest-resolve-dependencies/-/jest-resolve-dependencies-30.2.0.tgz#3370e2c0b49cc560f6a7e8ec3a59dd99525e1a55" + integrity sha512-xTOIGug/0RmIe3mmCqCT95yO0vj6JURrn1TKWlNbhiAefJRWINNPgwVkrVgt/YaerPzY3iItufd80v3lOrFJ2w== + dependencies: + jest-regex-util "30.0.1" + jest-snapshot "30.2.0" + +jest-resolve@30.2.0: + version "30.2.0" + resolved "https://registry.yarnpkg.com/jest-resolve/-/jest-resolve-30.2.0.tgz#2e2009cbd61e8f1f003355d5ec87225412cebcd7" + integrity sha512-TCrHSxPlx3tBY3hWNtRQKbtgLhsXa1WmbJEqBlTBrGafd5fiQFByy2GNCEoGR+Tns8d15GaL9cxEzKOO3GEb2A== + dependencies: + chalk "^4.1.2" + graceful-fs "^4.2.11" + jest-haste-map "30.2.0" + jest-pnp-resolver "^1.2.3" + jest-util "30.2.0" + jest-validate "30.2.0" + slash "^3.0.0" + unrs-resolver "^1.7.11" + +jest-runner@30.2.0, jest-runner@^30.0.4: + version "30.2.0" + resolved "https://registry.yarnpkg.com/jest-runner/-/jest-runner-30.2.0.tgz#c62b4c3130afa661789705e13a07bdbcec26a114" + integrity sha512-PqvZ2B2XEyPEbclp+gV6KO/F1FIFSbIwewRgmROCMBo/aZ6J1w8Qypoj2pEOcg3G2HzLlaP6VUtvwCI8dM3oqQ== + dependencies: + "@jest/console" "30.2.0" + "@jest/environment" "30.2.0" + "@jest/test-result" "30.2.0" + "@jest/transform" "30.2.0" + "@jest/types" "30.2.0" + "@types/node" "*" + chalk "^4.1.2" + emittery "^0.13.1" + exit-x "^0.2.2" + graceful-fs "^4.2.11" + jest-docblock "30.2.0" + jest-environment-node "30.2.0" + jest-haste-map "30.2.0" + jest-leak-detector "30.2.0" + jest-message-util "30.2.0" + jest-resolve "30.2.0" + jest-runtime "30.2.0" + jest-util "30.2.0" + jest-watcher "30.2.0" + jest-worker "30.2.0" + p-limit "^3.1.0" + source-map-support "0.5.13" + +jest-runtime@30.2.0: + version "30.2.0" + resolved "https://registry.yarnpkg.com/jest-runtime/-/jest-runtime-30.2.0.tgz#395ea792cde048db1b0cd1a92dc9cb9f1921bf8a" + integrity sha512-p1+GVX/PJqTucvsmERPMgCPvQJpFt4hFbM+VN3n8TMo47decMUcJbt+rgzwrEme0MQUA/R+1de2axftTHkKckg== + dependencies: + "@jest/environment" "30.2.0" + "@jest/fake-timers" "30.2.0" + "@jest/globals" "30.2.0" + "@jest/source-map" "30.0.1" + "@jest/test-result" "30.2.0" + "@jest/transform" "30.2.0" + "@jest/types" "30.2.0" + "@types/node" "*" + chalk "^4.1.2" + cjs-module-lexer "^2.1.0" + collect-v8-coverage "^1.0.2" + glob "^10.3.10" + graceful-fs "^4.2.11" + jest-haste-map "30.2.0" + jest-message-util "30.2.0" + jest-mock "30.2.0" + jest-regex-util "30.0.1" + jest-resolve "30.2.0" + jest-snapshot "30.2.0" + jest-util "30.2.0" + slash "^3.0.0" + strip-bom "^4.0.0" + +jest-serializer-html@^7.1.0: + version "7.1.0" + resolved "https://registry.yarnpkg.com/jest-serializer-html/-/jest-serializer-html-7.1.0.tgz#0cfea8a03b9b82bc420fd2cb969bd76713a87c08" + integrity sha512-xYL2qC7kmoYHJo8MYqJkzrl/Fdlx+fat4U1AqYg+kafqwcKPiMkOcjWHPKhueuNEgr+uemhGc+jqXYiwCyRyLA== + dependencies: + diffable-html "^4.1.0" + +jest-snapshot@30.2.0: + version "30.2.0" + resolved "https://registry.yarnpkg.com/jest-snapshot/-/jest-snapshot-30.2.0.tgz#266fbbb4b95fc4665ce6f32f1f38eeb39f4e26d0" + integrity sha512-5WEtTy2jXPFypadKNpbNkZ72puZCa6UjSr/7djeecHWOu7iYhSXSnHScT8wBz3Rn8Ena5d5RYRcsyKIeqG1IyA== + dependencies: + "@babel/core" "^7.27.4" + "@babel/generator" "^7.27.5" + "@babel/plugin-syntax-jsx" "^7.27.1" + "@babel/plugin-syntax-typescript" "^7.27.1" + "@babel/types" "^7.27.3" + "@jest/expect-utils" "30.2.0" + "@jest/get-type" "30.1.0" + "@jest/snapshot-utils" "30.2.0" + "@jest/transform" "30.2.0" + "@jest/types" "30.2.0" + babel-preset-current-node-syntax "^1.2.0" + chalk "^4.1.2" + expect "30.2.0" + graceful-fs "^4.2.11" + jest-diff "30.2.0" + jest-matcher-utils "30.2.0" + jest-message-util "30.2.0" + jest-util "30.2.0" + pretty-format "30.2.0" + semver "^7.7.2" + synckit "^0.11.8" + +jest-util@30.2.0: + version "30.2.0" + resolved "https://registry.yarnpkg.com/jest-util/-/jest-util-30.2.0.tgz#5142adbcad6f4e53c2776c067a4db3c14f913705" + integrity sha512-QKNsM0o3Xe6ISQU869e+DhG+4CK/48aHYdJZGlFQVTjnbvgpcKyxpzk29fGiO7i/J8VENZ+d2iGnSsvmuHywlA== + dependencies: + "@jest/types" "30.2.0" + "@types/node" "*" + chalk "^4.1.2" + ci-info "^4.2.0" + graceful-fs "^4.2.11" + picomatch "^4.0.2" + +jest-validate@30.2.0: + version "30.2.0" + resolved "https://registry.yarnpkg.com/jest-validate/-/jest-validate-30.2.0.tgz#273eaaed4c0963b934b5b31e96289edda6e0a2ef" + integrity sha512-FBGWi7dP2hpdi8nBoWxSsLvBFewKAg0+uSQwBaof4Y4DPgBabXgpSYC5/lR7VmnIlSpASmCi/ntRWPbv7089Pw== + dependencies: + "@jest/get-type" "30.1.0" + "@jest/types" "30.2.0" + camelcase "^6.3.0" + chalk "^4.1.2" + leven "^3.1.0" + pretty-format "30.2.0" + +jest-watch-typeahead@^3.0.1: + version "3.0.1" + resolved "https://registry.yarnpkg.com/jest-watch-typeahead/-/jest-watch-typeahead-3.0.1.tgz#50cb9653190228b8ebd489b37645067ee0bb7584" + integrity sha512-SFmHcvdueTswZlVhPCWfLXMazvwZlA2UZTrcE7MC3NwEVeWvEcOx6HUe+igMbnmA6qowuBSW4in8iC6J2EYsgQ== + dependencies: + ansi-escapes "^7.0.0" + chalk "^5.2.0" + jest-regex-util "^30.0.0" + jest-watcher "^30.0.0" + slash "^5.0.0" + string-length "^6.0.0" + strip-ansi "^7.0.1" + +jest-watcher@30.2.0, jest-watcher@^30.0.0: + version "30.2.0" + resolved "https://registry.yarnpkg.com/jest-watcher/-/jest-watcher-30.2.0.tgz#f9c055de48e18c979e7756a3917e596e2d69b07b" + integrity sha512-PYxa28dxJ9g777pGm/7PrbnMeA0Jr7osHP9bS7eJy9DuAjMgdGtxgf0uKMyoIsTWAkIbUW5hSDdJ3urmgXBqxg== + dependencies: + "@jest/test-result" "30.2.0" + "@jest/types" "30.2.0" + "@types/node" "*" + ansi-escapes "^4.3.2" + chalk "^4.1.2" + emittery "^0.13.1" + jest-util "30.2.0" + string-length "^4.0.2" + +jest-worker@30.2.0: + version "30.2.0" + resolved "https://registry.yarnpkg.com/jest-worker/-/jest-worker-30.2.0.tgz#fd5c2a36ff6058ec8f74366ec89538cc99539d26" + integrity sha512-0Q4Uk8WF7BUwqXHuAjc23vmopWJw5WH7w2tqBoUOZpOjW/ZnR44GXXd1r82RvnmI2GZge3ivrYXk/BE2+VtW2g== + dependencies: + "@types/node" "*" + "@ungap/structured-clone" "^1.3.0" + jest-util "30.2.0" + merge-stream "^2.0.0" + supports-color "^8.1.1" + +jest@^30.0.4, jest@^30.2.0: + version "30.2.0" + resolved "https://registry.yarnpkg.com/jest/-/jest-30.2.0.tgz#9f0a71e734af968f26952b5ae4b724af82681630" + integrity sha512-F26gjC0yWN8uAA5m5Ss8ZQf5nDHWGlN/xWZIh8S5SRbsEKBovwZhxGd6LJlbZYxBgCYOtreSUyb8hpXyGC5O4A== + dependencies: + "@jest/core" "30.2.0" + "@jest/types" "30.2.0" + import-local "^3.2.0" + jest-cli "30.2.0" + +jju@~1.4.0: + version "1.4.0" + resolved "https://registry.yarnpkg.com/jju/-/jju-1.4.0.tgz#a3abe2718af241a2b2904f84a625970f389ae32a" + integrity sha512-8wb9Yw966OSxApiCt0K3yNJL8pnNeIv+OEq2YMidz4FKP6nonSRoOXc80iXY4JaN2FC11B9qsNmDsm+ZOfMROA== + +joi@^17.11.0: + version "17.13.3" + resolved "https://registry.yarnpkg.com/joi/-/joi-17.13.3.tgz#0f5cc1169c999b30d344366d384b12d92558bcec" + integrity sha512-otDA4ldcIx+ZXsKHWmp0YizCweVRZG96J10b0FevjfuncLO1oX59THoAmHkNubYJ+9gWsYsp5k8v4ib6oDv1fA== + dependencies: + "@hapi/hoek" "^9.3.0" + "@hapi/topo" "^5.1.0" + "@sideway/address" "^4.1.5" + "@sideway/formula" "^3.0.1" + "@sideway/pinpoint" "^2.0.0" + +js-tokens@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/js-tokens/-/js-tokens-4.0.0.tgz#19203fb59991df98e3a287050d4647cdeaf32499" + integrity sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ== + +js-yaml@^3.13.1: + version "3.14.2" + resolved "https://registry.yarnpkg.com/js-yaml/-/js-yaml-3.14.2.tgz#77485ce1dd7f33c061fd1b16ecea23b55fcb04b0" + integrity sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg== + dependencies: + argparse "^1.0.7" + esprima "^4.0.0" + +js-yaml@^4.1.0: + version "4.1.0" + resolved "https://registry.yarnpkg.com/js-yaml/-/js-yaml-4.1.0.tgz#c1fb65f8f5017901cdd2c951864ba18458a10602" + integrity sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA== + dependencies: + argparse "^2.0.1" + +jsesc@^3.0.2: + version "3.1.0" + resolved "https://registry.yarnpkg.com/jsesc/-/jsesc-3.1.0.tgz#74d335a234f67ed19907fdadfac7ccf9d409825d" + integrity sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA== + +json-buffer@3.0.1: + version "3.0.1" + resolved "https://registry.yarnpkg.com/json-buffer/-/json-buffer-3.0.1.tgz#9338802a30d3b6605fbe0613e094008ca8c05a13" + integrity sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ== + +json-parse-even-better-errors@^2.3.0: + version "2.3.1" + resolved "https://registry.yarnpkg.com/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz#7c47805a94319928e05777405dc12e1f7a4ee02d" + integrity sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w== + +json-schema-traverse@^0.4.1: + version "0.4.1" + resolved "https://registry.yarnpkg.com/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz#69f6a87d9513ab8bb8fe63bdb0979c448e684660" + integrity sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg== + +json-schema-traverse@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz#ae7bcb3656ab77a73ba5c49bf654f38e6b6860e2" + integrity sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug== + +json-stable-stringify-without-jsonify@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz#9db7b59496ad3f3cfef30a75142d2d930ad72651" + integrity sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw== + +json-stable-stringify@^1.0.2: + version "1.3.0" + resolved "https://registry.yarnpkg.com/json-stable-stringify/-/json-stable-stringify-1.3.0.tgz#8903cfac42ea1a0f97f35d63a4ce0518f0cc6a70" + integrity sha512-qtYiSSFlwot9XHtF9bD9c7rwKjr+RecWT//ZnPvSmEjpV5mmPOCN4j8UjY5hbjNkOwZ/jQv3J6R1/pL7RwgMsg== + dependencies: + call-bind "^1.0.8" + call-bound "^1.0.4" + isarray "^2.0.5" + jsonify "^0.0.1" + object-keys "^1.1.1" + +json5@^2.2.2, json5@^2.2.3: + version "2.2.3" + resolved "https://registry.yarnpkg.com/json5/-/json5-2.2.3.tgz#78cd6f1a19bdc12b73db5ad0c61efd66c1e29283" + integrity sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg== + +jsonc-parser@^3.2.0: + version "3.3.1" + resolved "https://registry.yarnpkg.com/jsonc-parser/-/jsonc-parser-3.3.1.tgz#f2a524b4f7fd11e3d791e559977ad60b98b798b4" + integrity sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ== + +jsonfile@^6.0.1: + version "6.2.0" + resolved "https://registry.yarnpkg.com/jsonfile/-/jsonfile-6.2.0.tgz#7c265bd1b65de6977478300087c99f1c84383f62" + integrity sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg== + dependencies: + universalify "^2.0.0" + optionalDependencies: + graceful-fs "^4.1.6" + +jsonify@^0.0.1: + version "0.0.1" + resolved "https://registry.yarnpkg.com/jsonify/-/jsonify-0.0.1.tgz#2aa3111dae3d34a0f151c63f3a45d995d9420978" + integrity sha512-2/Ki0GcmuqSrgFyelQq9M05y7PS0mEwuIzrf3f1fPqkVDVRvZrPZtVSMHxdgo8Aq0sxAOb/cr2aqqA3LeWHVPg== + +keyv@^4.5.3: + version "4.5.4" + resolved "https://registry.yarnpkg.com/keyv/-/keyv-4.5.4.tgz#a879a99e29452f942439f2a405e3af8b31d4de93" + integrity sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw== + dependencies: + json-buffer "3.0.1" + +klaw-sync@^6.0.0: + version "6.0.0" + resolved "https://registry.yarnpkg.com/klaw-sync/-/klaw-sync-6.0.0.tgz#1fd2cfd56ebb6250181114f0a581167099c2b28c" + integrity sha512-nIeuVSzdCCs6TDPTqI8w1Yre34sSq7AkZ4B3sfOBbI2CgVSB4Du4aLQijFU2+lhAFCwt9+42Hel6lQNIv6AntQ== + dependencies: + graceful-fs "^4.1.11" + +kleur@^3.0.3: + version "3.0.3" + resolved "https://registry.yarnpkg.com/kleur/-/kleur-3.0.3.tgz#a79c9ecc86ee1ce3fa6206d1216c501f147fc07e" + integrity sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w== + +kolorist@^1.8.0: + version "1.8.0" + resolved "https://registry.yarnpkg.com/kolorist/-/kolorist-1.8.0.tgz#edddbbbc7894bc13302cdf740af6374d4a04743c" + integrity sha512-Y+60/zizpJ3HRH8DCss+q95yr6145JXZo46OTpFvDZWLfRCE4qChOyk1b26nMaNpfHHgxagk9dXT5OP0Tfe+dQ== + +lazystream@^1.0.0: + version "1.0.1" + resolved "https://registry.yarnpkg.com/lazystream/-/lazystream-1.0.1.tgz#494c831062f1f9408251ec44db1cba29242a2638" + integrity sha512-b94GiNHQNy6JNTrt5w6zNyffMrNkXZb3KTkCZJb2V1xaEGCk093vkZ2jk3tpaeP33/OiXC+WvK9AxUebnf5nbw== + dependencies: + readable-stream "^2.0.5" + +leven@^3.1.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/leven/-/leven-3.1.0.tgz#77891de834064cccba82ae7842bb6b14a13ed7f2" + integrity sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A== + +levn@^0.4.1: + version "0.4.1" + resolved "https://registry.yarnpkg.com/levn/-/levn-0.4.1.tgz#ae4562c007473b932a6200d403268dd2fffc6ade" + integrity sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ== + dependencies: + prelude-ls "^1.2.1" + type-check "~0.4.0" + +lines-and-columns@^1.1.6: + version "1.2.4" + resolved "https://registry.yarnpkg.com/lines-and-columns/-/lines-and-columns-1.2.4.tgz#eca284f75d2965079309dc0ad9255abb2ebc1632" + integrity sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg== + +local-pkg@^1.0.0: + version "1.1.2" + resolved "https://registry.yarnpkg.com/local-pkg/-/local-pkg-1.1.2.tgz#c03d208787126445303f8161619dc701afa4abb5" + integrity sha512-arhlxbFRmoQHl33a0Zkle/YWlmNwoyt6QNZEIJcqNbdrsix5Lvc4HyyI3EnwxTYlZYc32EbYrQ8SzEZ7dqgg9A== + dependencies: + mlly "^1.7.4" + pkg-types "^2.3.0" + quansync "^0.2.11" + +locate-path@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/locate-path/-/locate-path-5.0.0.tgz#1afba396afd676a6d42504d0a67a3a7eb9f62aa0" + integrity sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g== + dependencies: + p-locate "^4.1.0" + +locate-path@^6.0.0: + version "6.0.0" + resolved "https://registry.yarnpkg.com/locate-path/-/locate-path-6.0.0.tgz#55321eb309febbc59c4801d931a72452a681d286" + integrity sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw== + dependencies: + p-locate "^5.0.0" + +lodash-es@^4.17.21: + version "4.17.21" + resolved "https://registry.yarnpkg.com/lodash-es/-/lodash-es-4.17.21.tgz#43e626c46e6591b7750beb2b50117390c609e3ee" + integrity sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw== + +lodash.camelcase@^4.3.0: + version "4.3.0" + resolved "https://registry.yarnpkg.com/lodash.camelcase/-/lodash.camelcase-4.3.0.tgz#b28aa6288a2b9fc651035c7711f65ab6190331a6" + integrity sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA== + +lodash.flattendeep@^4.4.0: + version "4.4.0" + resolved "https://registry.yarnpkg.com/lodash.flattendeep/-/lodash.flattendeep-4.4.0.tgz#fb030917f86a3134e5bc9bec0d69e0013ddfedb2" + integrity sha512-uHaJFihxmJcEX3kT4I23ABqKKalJ/zDrDg0lsFtc1h+3uw49SIJ5beyhx5ExVRti3AvKoOJngIj7xz3oylPdWQ== + +lodash.merge@^4.6.2: + version "4.6.2" + resolved "https://registry.yarnpkg.com/lodash.merge/-/lodash.merge-4.6.2.tgz#558aa53b43b661e1925a0afdfa36a9a1085fe57a" + integrity sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ== + +lodash@^4.17.15, lodash@^4.17.21, lodash@^4.17.4, lodash@~4.17.15: + version "4.17.21" + resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.21.tgz#679591c564c3bffaae8454cf0b3df370c3d6911c" + integrity sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg== + +loglevel@^1.9.2: + version "1.9.2" + resolved "https://registry.yarnpkg.com/loglevel/-/loglevel-1.9.2.tgz#c2e028d6c757720107df4e64508530db6621ba08" + integrity sha512-HgMmCqIJSAKqo68l0rS2AanEWfkxaZ5wNiEFb5ggm08lDs9Xl2KxBlX3PTcaD2chBM1gXAYf491/M2Rv8Jwayg== + +long@^5.0.0: + version "5.3.2" + resolved "https://registry.yarnpkg.com/long/-/long-5.3.2.tgz#1d84463095999262d7d7b7f8bfd4a8cc55167f83" + integrity sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA== + +loupe@^3.1.0, loupe@^3.1.4: + version "3.2.1" + resolved "https://registry.yarnpkg.com/loupe/-/loupe-3.2.1.tgz#0095cf56dc5b7a9a7c08ff5b1a8796ec8ad17e76" + integrity sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ== + +lru-cache@^10.2.0: + version "10.4.3" + resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-10.4.3.tgz#410fc8a17b70e598013df257c2446b7f3383f119" + integrity sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ== + +lru-cache@^11.0.0: + version "11.2.4" + resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-11.2.4.tgz#ecb523ebb0e6f4d837c807ad1abaea8e0619770d" + integrity sha512-B5Y16Jr9LB9dHVkh6ZevG+vAbOsNOYCX+sXvFWFu7B3Iz5mijW3zdbMyhsh8ANd2mSWBYdJgnqi+mL7/LrOPYg== + +lru-cache@^5.1.1: + version "5.1.1" + resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-5.1.1.tgz#1da27e6710271947695daf6848e847f01d84b920" + integrity sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w== + dependencies: + yallist "^3.0.2" + +lru-cache@^6.0.0: + version "6.0.0" + resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-6.0.0.tgz#6d6fe6570ebd96aaf90fcad1dafa3b2566db3a94" + integrity sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA== + dependencies: + yallist "^4.0.0" + +lz-string@^1.5.0: + version "1.5.0" + resolved "https://registry.yarnpkg.com/lz-string/-/lz-string-1.5.0.tgz#c1ab50f77887b712621201ba9fd4e3a6ed099941" + integrity sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ== + +magic-string@^0.30.0, magic-string@^0.30.17: + version "0.30.21" + resolved "https://registry.yarnpkg.com/magic-string/-/magic-string-0.30.21.tgz#56763ec09a0fa8091df27879fd94d19078c00d91" + integrity sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ== + dependencies: + "@jridgewell/sourcemap-codec" "^1.5.5" + +magic-string@^0.30.3: + version "0.30.19" + resolved "https://registry.yarnpkg.com/magic-string/-/magic-string-0.30.19.tgz#cebe9f104e565602e5d2098c5f2e79a77cc86da9" + integrity sha512-2N21sPY9Ws53PZvsEpVtNuSW+ScYbQdp4b9qUaL+9QkHUrGFKo56Lg9Emg5s9V/qrtNBmiR01sYhUOwu3H+VOw== + dependencies: + "@jridgewell/sourcemap-codec" "^1.5.5" + +mailpit-api@^1.2.0: + version "1.5.4" + resolved "https://registry.yarnpkg.com/mailpit-api/-/mailpit-api-1.5.4.tgz#b53e0d437c3a2dcd22a20db9dcadc99ffb3b789d" + integrity sha512-Zz7qrNHF7p67sDn7VzBmP3Djk7eJNcBRiwCKeiWl5ADRou14++wvP1Mq9XD6sLyU5sQ/tgo3rXbmwTbNx1uY0A== + dependencies: + axios "^1.12.1" + +make-dir@^3.0.0, make-dir@^3.0.2: + version "3.1.0" + resolved "https://registry.yarnpkg.com/make-dir/-/make-dir-3.1.0.tgz#415e967046b3a7f1d185277d84aa58203726a13f" + integrity sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw== + dependencies: + semver "^6.0.0" + +make-dir@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/make-dir/-/make-dir-4.0.0.tgz#c3c2307a771277cd9638305f915c29ae741b614e" + integrity sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw== + dependencies: + semver "^7.5.3" + +makeerror@1.0.12: + version "1.0.12" + resolved "https://registry.yarnpkg.com/makeerror/-/makeerror-1.0.12.tgz#3e5dd2079a82e812e983cc6610c4a2cb0eaa801a" + integrity sha512-JmqCvUhmt43madlpFzG4BQzG2Z3m6tvQDNKdClZnO3VbIudJYmxsT0FNJMeiB2+JTSlTQTSbU8QdesVmwJcmLg== + dependencies: + tmpl "1.0.5" + +math-intrinsics@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/math-intrinsics/-/math-intrinsics-1.1.0.tgz#a0dd74be81e2aa5c2f27e65ce283605ee4e2b7f9" + integrity sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g== + +matrix-web-i18n@^3.4.0: + version "3.4.0" + resolved "https://registry.yarnpkg.com/matrix-web-i18n/-/matrix-web-i18n-3.4.0.tgz#2f088a2ec2cb4598092b7e9dd2f24f65d820df0e" + integrity sha512-8Bftf3LkACR6oy0iXYuIsDuvHjoJoOCWA+gDuy/lvyPwEpwViMs3XP5mJsXla+51SZEULZRUUtTreufH6xVA8g== + dependencies: + "@babel/parser" "^7.18.5" + "@babel/traverse" "^7.18.5" + lodash "^4.17.21" + minimist "^1.2.8" + walk "^2.3.15" + +md5.js@^1.3.4: + version "1.3.5" + resolved "https://registry.yarnpkg.com/md5.js/-/md5.js-1.3.5.tgz#b5d07b8e3216e3e27cd728d72f70d1e6a342005f" + integrity sha512-xitP+WxNPcTTOgnTJcrhM0xvdPepipPSf3I8EIpGKeFLjt3PlJLIDG3u8EX53ZIubkb+5U2+3rELYpEhHhzdkg== + dependencies: + hash-base "^3.0.0" + inherits "^2.0.1" + safe-buffer "^5.1.2" + +merge-stream@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/merge-stream/-/merge-stream-2.0.0.tgz#52823629a14dd00c9770fb6ad47dc6310f2c1f60" + integrity sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w== + +micromatch@^4.0.2, micromatch@^4.0.8: + version "4.0.8" + resolved "https://registry.yarnpkg.com/micromatch/-/micromatch-4.0.8.tgz#d66fa18f3a47076789320b9b1af32bd86d9fa202" + integrity sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA== + dependencies: + braces "^3.0.3" + picomatch "^2.3.1" + +miller-rabin@^4.0.0: + version "4.0.1" + resolved "https://registry.yarnpkg.com/miller-rabin/-/miller-rabin-4.0.1.tgz#f080351c865b0dc562a8462966daa53543c78a4d" + integrity sha512-115fLhvZVqWwHPbClyntxEVfVDfl9DLLTuJvq3g2O/Oxi8AiNouAHvDSzHS0viUJc+V5vm3eq91Xwqn9dp4jRA== + dependencies: + bn.js "^4.0.0" + brorand "^1.0.1" + +mime-db@1.52.0: + version "1.52.0" + resolved "https://registry.yarnpkg.com/mime-db/-/mime-db-1.52.0.tgz#bbabcdc02859f4987301c856e3387ce5ec43bf70" + integrity sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg== + +mime-types@^2.1.12: + version "2.1.35" + resolved "https://registry.yarnpkg.com/mime-types/-/mime-types-2.1.35.tgz#381a871b62a734450660ae3deee44813f70d959a" + integrity sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw== + dependencies: + mime-db "1.52.0" + +mimic-fn@^2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/mimic-fn/-/mimic-fn-2.1.0.tgz#7ed2c2ccccaf84d3ffcb7a69b57711fc2083401b" + integrity sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg== + +min-indent@^1.0.0: + version "1.0.1" + resolved "https://registry.yarnpkg.com/min-indent/-/min-indent-1.0.1.tgz#a63f681673b30571fbe8bc25686ae746eefa9869" + integrity sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg== + +minimalistic-assert@^1.0.0, minimalistic-assert@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz#2e194de044626d4a10e7f7fbc00ce73e83e4d5c7" + integrity sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A== + +minimalistic-crypto-utils@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/minimalistic-crypto-utils/-/minimalistic-crypto-utils-1.0.1.tgz#f6c00c1c0b082246e5c4d99dfb8c7c083b2b582a" + integrity sha512-JIYlbt6g8i5jKfJ3xz7rF0LXmv2TkDxBLUkiBeZ7bAx4GnnNMr8xFpGnOxn6GhTEHx3SjRrZEoU+j04prX1ktg== + +minimatch@10.0.3: + version "10.0.3" + resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-10.0.3.tgz#cf7a0314a16c4d9ab73a7730a0e8e3c3502d47aa" + integrity sha512-IPZ167aShDZZUMdRk66cyQAW3qr0WzbHkPdMYa8bzZhlHhO3jALbKdxcaak7W9FfT2rZNpQuUu4Od7ILEpXSaw== + dependencies: + "@isaacs/brace-expansion" "^5.0.0" + +minimatch@^10.1.1: + version "10.1.1" + resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-10.1.1.tgz#e6e61b9b0c1dcab116b5a7d1458e8b6ae9e73a55" + integrity sha512-enIvLvRAFZYXJzkCYG5RKmPfrFArdLv+R+lbQ53BmIMLIry74bjKzX6iHAm8WYamJkhSSEabrWN5D97XnKObjQ== + dependencies: + "@isaacs/brace-expansion" "^5.0.0" + +minimatch@^3.0.4, minimatch@^3.0.5, minimatch@^3.1.1, minimatch@^3.1.2: + version "3.1.2" + resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-3.1.2.tgz#19cd194bfd3e428f049a70817c038d89ab4be35b" + integrity sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw== + dependencies: + brace-expansion "^1.1.7" + +minimatch@^5.1.0: + version "5.1.6" + resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-5.1.6.tgz#1cfcb8cf5522ea69952cd2af95ae09477f122a96" + integrity sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g== + dependencies: + brace-expansion "^2.0.1" + +minimatch@^9.0.3, minimatch@^9.0.4: + version "9.0.5" + resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-9.0.5.tgz#d74f9dd6b57d83d8e98cfb82133b03978bc929e5" + integrity sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow== + dependencies: + brace-expansion "^2.0.1" + +minimist@^1.2.6, minimist@^1.2.8: + version "1.2.8" + resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.8.tgz#c1a464e7693302e082a075cee0c057741ac4772c" + integrity sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA== + +"minipass@^5.0.0 || ^6.0.2 || ^7.0.0", minipass@^7.1.2: + version "7.1.2" + resolved "https://registry.yarnpkg.com/minipass/-/minipass-7.1.2.tgz#93a9626ce5e5e66bd4db86849e7515e92340a707" + integrity sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw== + +mkdirp-classic@^0.5.2: + version "0.5.3" + resolved "https://registry.yarnpkg.com/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz#fa10c9115cc6d8865be221ba47ee9bed78601113" + integrity sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A== + +mkdirp@^1.0.4: + version "1.0.4" + resolved "https://registry.yarnpkg.com/mkdirp/-/mkdirp-1.0.4.tgz#3eb5ed62622756d79a5f0e2a221dfebad75c2f7e" + integrity sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw== + +mlly@^1.7.4: + version "1.8.0" + resolved "https://registry.yarnpkg.com/mlly/-/mlly-1.8.0.tgz#e074612b938af8eba1eaf43299cbc89cb72d824e" + integrity sha512-l8D9ODSRWLe2KHJSifWGwBqpTZXIXTeo8mlKjY+E2HAakaTeNpqAyBZ8GSqLzHgw4XmHmC8whvpjJNMbFZN7/g== + dependencies: + acorn "^8.15.0" + pathe "^2.0.3" + pkg-types "^1.3.1" + ufo "^1.6.1" + +ms@^2.1.3: + version "2.1.3" + resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.3.tgz#574c8138ce1d2b5861f0b44579dbadd60c6615b2" + integrity sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA== + +muggle-string@^0.4.1: + version "0.4.1" + resolved "https://registry.yarnpkg.com/muggle-string/-/muggle-string-0.4.1.tgz#3b366bd43b32f809dc20659534dd30e7c8a0d328" + integrity sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ== + +nan@^2.19.0, nan@^2.23.0: + version "2.24.0" + resolved "https://registry.yarnpkg.com/nan/-/nan-2.24.0.tgz#a8919b36e692aa5b260831910e4f81419fc0a283" + integrity sha512-Vpf9qnVW1RaDkoNKFUvfxqAbtI8ncb8OJlqZ9wwpXzWPEsvsB1nvdUi6oYrHIkQ1Y/tMDnr1h4nczS0VB9Xykg== + +nanoid@^3.3.11: + version "3.3.11" + resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.3.11.tgz#4f4f112cefbe303202f2199838128936266d185b" + integrity sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w== + +napi-postinstall@^0.3.0: + version "0.3.4" + resolved "https://registry.yarnpkg.com/napi-postinstall/-/napi-postinstall-0.3.4.tgz#7af256d6588b5f8e952b9190965d6b019653bbb9" + integrity sha512-PHI5f1O0EP5xJ9gQmFGMS6IZcrVvTjpXjz7Na41gTE7eE2hK11lg04CECCYEEjdc17EV4DO+fkGEtt7TpTaTiQ== + +natural-compare@^1.4.0: + version "1.4.0" + resolved "https://registry.yarnpkg.com/natural-compare/-/natural-compare-1.4.0.tgz#4abebfeed7541f2c27acfb29bdbbd15c8d5ba4f7" + integrity sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw== + +node-int64@^0.4.0: + version "0.4.0" + resolved "https://registry.yarnpkg.com/node-int64/-/node-int64-0.4.0.tgz#87a9065cdb355d3182d8f94ce11188b825c68a3b" + integrity sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw== + +node-preload@^0.2.1: + version "0.2.1" + resolved "https://registry.yarnpkg.com/node-preload/-/node-preload-0.2.1.tgz#c03043bb327f417a18fee7ab7ee57b408a144301" + integrity sha512-RM5oyBy45cLEoHqCeh+MNuFAxO0vTFBLskvQbOKnEE7YTTSN4tbN8QWDIPQ6L+WvKsB/qLEGpYe2ZZ9d4W9OIQ== + dependencies: + process-on-spawn "^1.0.0" + +node-releases@^2.0.27: + version "2.0.27" + resolved "https://registry.yarnpkg.com/node-releases/-/node-releases-2.0.27.tgz#eedca519205cf20f650f61d56b070db111231e4e" + integrity sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA== + +node-stdlib-browser@^1.2.0: + version "1.3.1" + resolved "https://registry.yarnpkg.com/node-stdlib-browser/-/node-stdlib-browser-1.3.1.tgz#f41fa554f720a3df951e40339f4d92ac512222ac" + integrity sha512-X75ZN8DCLftGM5iKwoYLA3rjnrAEs97MkzvSd4q2746Tgpg8b8XWiBGiBG4ZpgcAqBgtgPHTiAc8ZMCvZuikDw== + dependencies: + assert "^2.0.0" + browser-resolve "^2.0.0" + browserify-zlib "^0.2.0" + buffer "^5.7.1" + console-browserify "^1.1.0" + constants-browserify "^1.0.0" + create-require "^1.1.1" + crypto-browserify "^3.12.1" + domain-browser "4.22.0" + events "^3.0.0" + https-browserify "^1.0.0" + isomorphic-timers-promises "^1.0.1" + os-browserify "^0.3.0" + path-browserify "^1.0.1" + pkg-dir "^5.0.0" + process "^0.11.10" + punycode "^1.4.1" + querystring-es3 "^0.2.1" + readable-stream "^3.6.0" + stream-browserify "^3.0.0" + stream-http "^3.2.0" + string_decoder "^1.0.0" + timers-browserify "^2.0.4" + tty-browserify "0.0.1" + url "^0.11.4" + util "^0.12.4" + vm-browserify "^1.0.1" + +normalize-path@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/normalize-path/-/normalize-path-3.0.0.tgz#0dcd69ff23a1c9b11fd0978316644a0388216a65" + integrity sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA== + +npm-run-path@^4.0.1: + version "4.0.1" + resolved "https://registry.yarnpkg.com/npm-run-path/-/npm-run-path-4.0.1.tgz#b7ecd1e5ed53da8e37a55e1c2269e0b97ed748ea" + integrity sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw== + dependencies: + path-key "^3.0.0" + +nyc@^15.1.0: + version "15.1.0" + resolved "https://registry.yarnpkg.com/nyc/-/nyc-15.1.0.tgz#1335dae12ddc87b6e249d5a1994ca4bdaea75f02" + integrity sha512-jMW04n9SxKdKi1ZMGhvUTHBN0EICCRkHemEoE5jm6mTYcqcdas0ATzgUgejlQUHMvpnOZqGB5Xxsv9KxJW1j8A== + dependencies: + "@istanbuljs/load-nyc-config" "^1.0.0" + "@istanbuljs/schema" "^0.1.2" + caching-transform "^4.0.0" + convert-source-map "^1.7.0" + decamelize "^1.2.0" + find-cache-dir "^3.2.0" + find-up "^4.1.0" + foreground-child "^2.0.0" + get-package-type "^0.1.0" + glob "^7.1.6" + istanbul-lib-coverage "^3.0.0" + istanbul-lib-hook "^3.0.0" + istanbul-lib-instrument "^4.0.0" + istanbul-lib-processinfo "^2.0.2" + istanbul-lib-report "^3.0.0" + istanbul-lib-source-maps "^4.0.0" + istanbul-reports "^3.0.2" + make-dir "^3.0.0" + node-preload "^0.2.1" + p-map "^3.0.0" + process-on-spawn "^1.0.0" + resolve-from "^5.0.0" + rimraf "^3.0.0" + signal-exit "^3.0.2" + spawn-wrap "^2.0.0" + test-exclude "^6.0.0" + yargs "^15.0.2" + +object-inspect@^1.13.3: + version "1.13.4" + resolved "https://registry.yarnpkg.com/object-inspect/-/object-inspect-1.13.4.tgz#8375265e21bc20d0fa582c22e1b13485d6e00213" + integrity sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew== + +object-is@^1.1.5: + version "1.1.6" + resolved "https://registry.yarnpkg.com/object-is/-/object-is-1.1.6.tgz#1a6a53aed2dd8f7e6775ff870bea58545956ab07" + integrity sha512-F8cZ+KfGlSGi09lJT7/Nd6KJZ9ygtvYC0/UYYLI9nmQKLMnydpB9yvbv9K1uSkEu7FU9vYPmVwLg328tX+ot3Q== + dependencies: + call-bind "^1.0.7" + define-properties "^1.2.1" + +object-keys@^1.1.1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/object-keys/-/object-keys-1.1.1.tgz#1c47f272df277f3b1daf061677d9c82e2322c60e" + integrity sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA== + +object.assign@^4.1.4: + version "4.1.7" + resolved "https://registry.yarnpkg.com/object.assign/-/object.assign-4.1.7.tgz#8c14ca1a424c6a561b0bb2a22f66f5049a945d3d" + integrity sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw== + dependencies: + call-bind "^1.0.8" + call-bound "^1.0.3" + define-properties "^1.2.1" + es-object-atoms "^1.0.0" + has-symbols "^1.1.0" + object-keys "^1.1.1" + +once@^1.3.0, once@^1.3.1, once@^1.4.0: + version "1.4.0" + resolved "https://registry.yarnpkg.com/once/-/once-1.4.0.tgz#583b1aa775961d4b113ac17d9c50baef9dd76bd1" + integrity sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w== + dependencies: + wrappy "1" + +onetime@^5.1.2: + version "5.1.2" + resolved "https://registry.yarnpkg.com/onetime/-/onetime-5.1.2.tgz#d0e96ebb56b07476df1dd9c4806e5237985ca45e" + integrity sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg== + dependencies: + mimic-fn "^2.1.0" + +open@^10.2.0: + version "10.2.0" + resolved "https://registry.yarnpkg.com/open/-/open-10.2.0.tgz#b9d855be007620e80b6fb05fac98141fe62db73c" + integrity sha512-YgBpdJHPyQ2UE5x+hlSXcnejzAvD0b22U2OuAP+8OnlJT+PjWPxtgmGqKKc+RgTM63U9gN0YzrYc71R2WT/hTA== + dependencies: + default-browser "^5.2.1" + define-lazy-prop "^3.0.0" + is-inside-container "^1.0.0" + wsl-utils "^0.1.0" + +open@^7.4.2: + version "7.4.2" + resolved "https://registry.yarnpkg.com/open/-/open-7.4.2.tgz#b8147e26dcf3e426316c730089fd71edd29c2321" + integrity sha512-MVHddDVweXZF3awtlAS+6pgKLlm/JgxZ90+/NBurBoQctVOOB/zDdVjcyPzQ+0laDGbsWgrRkflI65sQeOgT9Q== + dependencies: + is-docker "^2.0.0" + is-wsl "^2.1.1" + +optionator@^0.9.3: + version "0.9.4" + resolved "https://registry.yarnpkg.com/optionator/-/optionator-0.9.4.tgz#7ea1c1a5d91d764fb282139c88fe11e182a3a734" + integrity sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g== + dependencies: + deep-is "^0.1.3" + fast-levenshtein "^2.0.6" + levn "^0.4.1" + prelude-ls "^1.2.1" + type-check "^0.4.0" + word-wrap "^1.2.5" + +os-browserify@^0.3.0: + version "0.3.0" + resolved "https://registry.yarnpkg.com/os-browserify/-/os-browserify-0.3.0.tgz#854373c7f5c2315914fc9bfc6bd8238fdda1ec27" + integrity sha512-gjcpUc3clBf9+210TRaDWbf+rZZZEshZ+DlXMRCeAjp0xhTrnQsKHypIy1J3d5hKdUzj69t708EHtU8P6bUn0A== + +os-homedir@^1.0.1: + version "1.0.2" + resolved "https://registry.yarnpkg.com/os-homedir/-/os-homedir-1.0.2.tgz#ffbc4988336e0e833de0c168c7ef152121aa7fb3" + integrity sha512-B5JU3cabzk8c67mRRd3ECmROafjYMXbuzlwtqdM8IbS8ktlTix8aFGb2bAGKrSRIlnfKwovGUUr72JUPyOb6kQ== + +p-limit@^2.2.0: + version "2.3.0" + resolved "https://registry.yarnpkg.com/p-limit/-/p-limit-2.3.0.tgz#3dd33c647a214fdfffd835933eb086da0dc21db1" + integrity sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w== + dependencies: + p-try "^2.0.0" + +p-limit@^3.0.2, p-limit@^3.1.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/p-limit/-/p-limit-3.1.0.tgz#e1daccbe78d0d1388ca18c64fea38e3e57e3706b" + integrity sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ== + dependencies: + yocto-queue "^0.1.0" + +p-locate@^4.1.0: + version "4.1.0" + resolved "https://registry.yarnpkg.com/p-locate/-/p-locate-4.1.0.tgz#a3428bb7088b3a60292f66919278b7c297ad4f07" + integrity sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A== + dependencies: + p-limit "^2.2.0" + +p-locate@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/p-locate/-/p-locate-5.0.0.tgz#83c8315c6785005e3bd021839411c9e110e6d834" + integrity sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw== + dependencies: + p-limit "^3.0.2" + +p-map@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/p-map/-/p-map-3.0.0.tgz#d704d9af8a2ba684e2600d9a215983d4141a979d" + integrity sha512-d3qXVTF/s+W+CdJ5A29wywV2n8CQQYahlgz2bFiA+4eVNJbHJodPZ+/gXwPGh0bOqA+j8S+6+ckmvLGPk1QpxQ== + dependencies: + aggregate-error "^3.0.0" + +p-try@^2.0.0: + version "2.2.0" + resolved "https://registry.yarnpkg.com/p-try/-/p-try-2.2.0.tgz#cb2868540e313d61de58fafbe35ce9004d5540e6" + integrity sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ== + +package-hash@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/package-hash/-/package-hash-4.0.0.tgz#3537f654665ec3cc38827387fc904c163c54f506" + integrity sha512-whdkPIooSu/bASggZ96BWVvZTRMOFxnyUG5PnTSGKoJE2gd5mbVNmR2Nj20QFzxYYgAXpoqC+AiXzl+UMRh7zQ== + dependencies: + graceful-fs "^4.1.15" + hasha "^5.0.0" + lodash.flattendeep "^4.4.0" + release-zalgo "^1.0.0" + +package-json-from-dist@^1.0.0: + version "1.0.1" + resolved "https://registry.yarnpkg.com/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz#4f1471a010827a86f94cfd9b0727e36d267de505" + integrity sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw== + +pako@~1.0.5: + version "1.0.11" + resolved "https://registry.yarnpkg.com/pako/-/pako-1.0.11.tgz#6c9599d340d54dfd3946380252a35705a6b992bf" + integrity sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw== + +parent-module@^1.0.0: + version "1.0.1" + resolved "https://registry.yarnpkg.com/parent-module/-/parent-module-1.0.1.tgz#691d2709e78c79fae3a156622452d00762caaaa2" + integrity sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g== + dependencies: + callsites "^3.0.0" + +parse-asn1@^5.0.0, parse-asn1@^5.1.9: + version "5.1.9" + resolved "https://registry.yarnpkg.com/parse-asn1/-/parse-asn1-5.1.9.tgz#8dd24c3ea8da77dffbc708d94eaf232fd6156e95" + integrity sha512-fIYNuZ/HastSb80baGOuPRo1O9cf4baWw5WsAp7dBuUzeTD/BoaG8sVTdlPFksBE2lF21dN+A1AnrpIjSWqHHg== + dependencies: + asn1.js "^4.10.1" + browserify-aes "^1.2.0" + evp_bytestokey "^1.0.3" + pbkdf2 "^3.1.5" + safe-buffer "^5.2.1" + +parse-json@^5.2.0: + version "5.2.0" + resolved "https://registry.yarnpkg.com/parse-json/-/parse-json-5.2.0.tgz#c76fc66dee54231c962b22bcc8a72cf2f99753cd" + integrity sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg== + dependencies: + "@babel/code-frame" "^7.0.0" + error-ex "^1.3.1" + json-parse-even-better-errors "^2.3.0" + lines-and-columns "^1.1.6" + +parse-passwd@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/parse-passwd/-/parse-passwd-1.0.0.tgz#6d5b934a456993b23d37f40a382d6f1666a8e5c6" + integrity sha512-1Y1A//QUXEZK7YKz+rD9WydcE1+EuPr6ZBgKecAB8tmoW6UFv0NREVJe1p+jRxtThkcbbKkfwIbWJe/IeE6m2Q== + +patch-package@^8.0.1: + version "8.0.1" + resolved "https://registry.yarnpkg.com/patch-package/-/patch-package-8.0.1.tgz#79d02f953f711e06d1f8949c8a13e5d3d7ba1a60" + integrity sha512-VsKRIA8f5uqHQ7NGhwIna6Bx6D9s/1iXlA1hthBVBEbkq+t4kXD0HHt+rJhf/Z+Ci0F/HCB2hvn0qLdLG+Qxlw== + dependencies: + "@yarnpkg/lockfile" "^1.1.0" + chalk "^4.1.2" + ci-info "^3.7.0" + cross-spawn "^7.0.3" + find-yarn-workspace-root "^2.0.0" + fs-extra "^10.0.0" + json-stable-stringify "^1.0.2" + klaw-sync "^6.0.0" + minimist "^1.2.6" + open "^7.4.2" + semver "^7.5.3" + slash "^2.0.0" + tmp "^0.2.4" + yaml "^2.2.2" + +path-browserify@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/path-browserify/-/path-browserify-1.0.1.tgz#d98454a9c3753d5790860f16f68867b9e46be1fd" + integrity sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g== + +path-exists@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/path-exists/-/path-exists-4.0.0.tgz#513bdbe2d3b95d7762e8c1137efa195c6c61b5b3" + integrity sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w== + +path-is-absolute@^1.0.0: + version "1.0.1" + resolved "https://registry.yarnpkg.com/path-is-absolute/-/path-is-absolute-1.0.1.tgz#174b9268735534ffbc7ace6bf53a5a9e1b5c5f5f" + integrity sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg== + +path-key@^3.0.0, path-key@^3.1.0: + version "3.1.1" + resolved "https://registry.yarnpkg.com/path-key/-/path-key-3.1.1.tgz#581f6ade658cbba65a0d3380de7753295054f375" + integrity sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q== + +path-parse@^1.0.7: + version "1.0.7" + resolved "https://registry.yarnpkg.com/path-parse/-/path-parse-1.0.7.tgz#fbc114b60ca42b30d9daf5858e4bd68bbedb6735" + integrity sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw== + +path-scurry@^1.11.1: + version "1.11.1" + resolved "https://registry.yarnpkg.com/path-scurry/-/path-scurry-1.11.1.tgz#7960a668888594a0720b12a911d1a742ab9f11d2" + integrity sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA== + dependencies: + lru-cache "^10.2.0" + minipass "^5.0.0 || ^6.0.2 || ^7.0.0" + +path-scurry@^2.0.0: + version "2.0.1" + resolved "https://registry.yarnpkg.com/path-scurry/-/path-scurry-2.0.1.tgz#4b6572376cfd8b811fca9cd1f5c24b3cbac0fe10" + integrity sha512-oWyT4gICAu+kaA7QWk/jvCHWarMKNs6pXOGWKDTr7cw4IGcUbW+PeTfbaQiLGheFRpjo6O9J0PmyMfQPjH71oA== + dependencies: + lru-cache "^11.0.0" + minipass "^7.1.2" + +pathe@^2.0.1, pathe@^2.0.3: + version "2.0.3" + resolved "https://registry.yarnpkg.com/pathe/-/pathe-2.0.3.tgz#3ecbec55421685b70a9da872b2cff3e1cbed1716" + integrity sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w== + +pathval@^2.0.0: + version "2.0.1" + resolved "https://registry.yarnpkg.com/pathval/-/pathval-2.0.1.tgz#8855c5a2899af072d6ac05d11e46045ad0dc605d" + integrity sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ== + +pbkdf2@^3.1.2, pbkdf2@^3.1.5: + version "3.1.5" + resolved "https://registry.yarnpkg.com/pbkdf2/-/pbkdf2-3.1.5.tgz#444a59d7a259a95536c56e80c89de31cc01ed366" + integrity sha512-Q3CG/cYvCO1ye4QKkuH7EXxs3VC/rI1/trd+qX2+PolbaKG0H+bgcZzrTt96mMyRtejk+JMCiLUn3y29W8qmFQ== + dependencies: + create-hash "^1.2.0" + create-hmac "^1.1.7" + ripemd160 "^2.0.3" + safe-buffer "^5.2.1" + sha.js "^2.4.12" + to-buffer "^1.2.1" + +picocolors@1.1.1, picocolors@^1.1.1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/picocolors/-/picocolors-1.1.1.tgz#3d321af3eab939b083c8f929a1d12cda81c26b6b" + integrity sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA== + +picomatch@^2.0.4, picomatch@^2.3.1: + version "2.3.1" + resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-2.3.1.tgz#3ba3833733646d9d3e4995946c1365a67fb07a42" + integrity sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA== + +picomatch@^4.0.2, picomatch@^4.0.3: + version "4.0.3" + resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-4.0.3.tgz#796c76136d1eead715db1e7bad785dedd695a042" + integrity sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q== + +pirates@^4.0.7: + version "4.0.7" + resolved "https://registry.yarnpkg.com/pirates/-/pirates-4.0.7.tgz#643b4a18c4257c8a65104b73f3049ce9a0a15e22" + integrity sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA== + +pixelmatch@^5.1.0: + version "5.3.0" + resolved "https://registry.yarnpkg.com/pixelmatch/-/pixelmatch-5.3.0.tgz#5e5321a7abedfb7962d60dbf345deda87cb9560a" + integrity sha512-o8mkY4E/+LNUf6LzX96ht6k6CEDi65k9G2rjMtBe9Oo+VPKSvl+0GKHuH/AlG+GA5LPG/i5hrekkxUc3s2HU+Q== + dependencies: + pngjs "^6.0.0" + +pkg-dir@^4.1.0, pkg-dir@^4.2.0: + version "4.2.0" + resolved "https://registry.yarnpkg.com/pkg-dir/-/pkg-dir-4.2.0.tgz#f099133df7ede422e81d1d8448270eeb3e4261f3" + integrity sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ== + dependencies: + find-up "^4.0.0" + +pkg-dir@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/pkg-dir/-/pkg-dir-5.0.0.tgz#a02d6aebe6ba133a928f74aec20bafdfe6b8e760" + integrity sha512-NPE8TDbzl/3YQYY7CSS228s3g2ollTFnc+Qi3tqmqJp9Vg2ovUpixcJEo2HJScN2Ez+kEaal6y70c0ehqJBJeA== + dependencies: + find-up "^5.0.0" + +pkg-types@^1.3.1: + version "1.3.1" + resolved "https://registry.yarnpkg.com/pkg-types/-/pkg-types-1.3.1.tgz#bd7cc70881192777eef5326c19deb46e890917df" + integrity sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ== + dependencies: + confbox "^0.1.8" + mlly "^1.7.4" + pathe "^2.0.1" + +pkg-types@^2.3.0: + version "2.3.0" + resolved "https://registry.yarnpkg.com/pkg-types/-/pkg-types-2.3.0.tgz#037f2c19bd5402966ff6810e32706558cb5b5726" + integrity sha512-SIqCzDRg0s9npO5XQ3tNZioRY1uK06lA41ynBC1YmFTmnY6FjUjVt6s4LoADmwoig1qqD0oK8h1p/8mlMx8Oig== + dependencies: + confbox "^0.2.2" + exsolve "^1.0.7" + pathe "^2.0.3" + +playwright-core@1.57.0, playwright-core@>=1.2.0: + version "1.57.0" + resolved "https://registry.yarnpkg.com/playwright-core/-/playwright-core-1.57.0.tgz#3dcc9a865af256fa9f0af0d67fc8dd54eecaebf5" + integrity sha512-agTcKlMw/mjBWOnD6kFZttAAGHgi/Nw0CZ2o6JqWSbMlI219lAFLZZCyqByTsvVAJq5XA5H8cA6PrvBRpBWEuQ== + +playwright@1.57.0, playwright@^1.14.0: + version "1.57.0" + resolved "https://registry.yarnpkg.com/playwright/-/playwright-1.57.0.tgz#74d1dacff5048dc40bf4676940b1901e18ad0f46" + integrity sha512-ilYQj1s8sr2ppEJ2YVadYBN0Mb3mdo9J0wQ+UuDhzYqURwSoW4n1Xs5vs7ORwgDGmyEh33tRMeS8KhdkMoLXQw== + dependencies: + playwright-core "1.57.0" + optionalDependencies: + fsevents "2.3.2" + +pluralizers@^0.1.7: + version "0.1.7" + resolved "https://registry.yarnpkg.com/pluralizers/-/pluralizers-0.1.7.tgz#8d38dd0a1b660e739b10ab2eab10b684c9d50142" + integrity sha512-mw6AejUiCaMQ6uPN9ObjJDTnR5AnBSmnHHy3uVTbxrSFSxO5scfwpTs8Dxyb6T2v7GSulhvOq+pm9y+hXUvtOA== + +pngjs@^3.4.0: + version "3.4.0" + resolved "https://registry.yarnpkg.com/pngjs/-/pngjs-3.4.0.tgz#99ca7d725965fb655814eaf65f38f12bbdbf555f" + integrity sha512-NCrCHhWmnQklfH4MtJMRjZ2a8c80qXeMlQMv2uVp9ISJMTt562SbGd6n2oq0PaPgKm7Z6pL9E2UlLIhC+SHL3w== + +pngjs@^6.0.0: + version "6.0.0" + resolved "https://registry.yarnpkg.com/pngjs/-/pngjs-6.0.0.tgz#ca9e5d2aa48db0228a52c419c3308e87720da821" + integrity sha512-TRzzuFRRmEoSW/p1KVAmiOgPco2Irlah+bGFCeNfJXxxYGwSw7YwAOAcd7X28K/m5bjBWKsC29KyoMfHbypayg== + +possible-typed-array-names@^1.0.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz#93e3582bc0e5426586d9d07b79ee40fc841de4ae" + integrity sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg== + +postcss@^8.5.6: + version "8.5.6" + resolved "https://registry.yarnpkg.com/postcss/-/postcss-8.5.6.tgz#2825006615a619b4f62a9e7426cc120b349a8f3c" + integrity sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg== + dependencies: + nanoid "^3.3.11" + picocolors "^1.1.1" + source-map-js "^1.2.1" + +prelude-ls@^1.2.1: + version "1.2.1" + resolved "https://registry.yarnpkg.com/prelude-ls/-/prelude-ls-1.2.1.tgz#debc6489d7a6e6b0e7611888cec880337d316396" + integrity sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g== + +prettier@^3.6.2: + version "3.7.4" + resolved "https://registry.yarnpkg.com/prettier/-/prettier-3.7.4.tgz#d2f8335d4b1cec47e1c8098645411b0c9dff9c0f" + integrity sha512-v6UNi1+3hSlVvv8fSaoUbggEM5VErKmmpGA7Pl3HF8V6uKY7rvClBOJlH6yNwQtfTueNkGVpOv/mtWL9L4bgRA== + +pretty-format@30.2.0, pretty-format@^30.0.0: + version "30.2.0" + resolved "https://registry.yarnpkg.com/pretty-format/-/pretty-format-30.2.0.tgz#2d44fe6134529aed18506f6d11509d8a62775ebe" + integrity sha512-9uBdv/B4EefsuAL+pWqueZyZS2Ba+LxfFeQ9DN14HU4bN8bhaxKdkpjpB6fs9+pSjIBu+FXQHImEg8j/Lw0+vA== + dependencies: + "@jest/schemas" "30.0.5" + ansi-styles "^5.2.0" + react-is "^18.3.1" + +pretty-format@^27.0.2: + version "27.5.1" + resolved "https://registry.yarnpkg.com/pretty-format/-/pretty-format-27.5.1.tgz#2181879fdea51a7a5851fb39d920faa63f01d88e" + integrity sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ== + dependencies: + ansi-regex "^5.0.1" + ansi-styles "^5.0.0" + react-is "^17.0.1" + +process-nextick-args@~2.0.0: + version "2.0.1" + resolved "https://registry.yarnpkg.com/process-nextick-args/-/process-nextick-args-2.0.1.tgz#7820d9b16120cc55ca9ae7792680ae7dba6d7fe2" + integrity sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag== + +process-on-spawn@^1.0.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/process-on-spawn/-/process-on-spawn-1.1.0.tgz#9d5999ba87b3bf0a8acb05322d69f2f5aa4fb763" + integrity sha512-JOnOPQ/8TZgjs1JIH/m9ni7FfimjNa/PRx7y/Wb5qdItsnhO0jE4AT7fC0HjC28DUQWDr50dwSYZLdRMlqDq3Q== + dependencies: + fromentries "^1.2.0" + +process@^0.11.10: + version "0.11.10" + resolved "https://registry.yarnpkg.com/process/-/process-0.11.10.tgz#7332300e840161bda3e69a1d1d91a7d4bc16f182" + integrity sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A== + +prompts@^2.4.1: + version "2.4.2" + resolved "https://registry.yarnpkg.com/prompts/-/prompts-2.4.2.tgz#7b57e73b3a48029ad10ebd44f74b01722a4cb069" + integrity sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q== + dependencies: + kleur "^3.0.3" + sisteransi "^1.0.5" + +proper-lockfile@^4.1.2: + version "4.1.2" + resolved "https://registry.yarnpkg.com/proper-lockfile/-/proper-lockfile-4.1.2.tgz#c8b9de2af6b2f1601067f98e01ac66baa223141f" + integrity sha512-TjNPblN4BwAWMXU8s9AEz4JmQxnD1NNL7bNOY/AKUzyamc379FWASUhc/K1pL2noVb+XmZKLL68cjzLsiOAMaA== + dependencies: + graceful-fs "^4.2.4" + retry "^0.12.0" + signal-exit "^3.0.2" + +properties-reader@^2.3.0: + version "2.3.0" + resolved "https://registry.yarnpkg.com/properties-reader/-/properties-reader-2.3.0.tgz#f3ab84224c9535a7a36e011ae489a79a13b472b2" + integrity sha512-z597WicA7nDZxK12kZqHr2TcvwNU1GCfA5UwfDY/HDp3hXPoPlb5rlEx9bwGTiJnc0OqbBTkU975jDToth8Gxw== + dependencies: + mkdirp "^1.0.4" + +protobufjs@^7.2.5, protobufjs@^7.3.2, protobufjs@^7.5.3: + version "7.5.4" + resolved "https://registry.yarnpkg.com/protobufjs/-/protobufjs-7.5.4.tgz#885d31fe9c4b37f25d1bb600da30b1c5b37d286a" + integrity sha512-CvexbZtbov6jW2eXAvLukXjXUW1TzFaivC46BpWc/3BpcCysb5Vffu+B3XHMm8lVEuy2Mm4XGex8hBSg1yapPg== + dependencies: + "@protobufjs/aspromise" "^1.1.2" + "@protobufjs/base64" "^1.1.2" + "@protobufjs/codegen" "^2.0.4" + "@protobufjs/eventemitter" "^1.1.0" + "@protobufjs/fetch" "^1.1.0" + "@protobufjs/float" "^1.0.2" + "@protobufjs/inquire" "^1.1.0" + "@protobufjs/path" "^1.1.2" + "@protobufjs/pool" "^1.1.0" + "@protobufjs/utf8" "^1.1.0" + "@types/node" ">=13.7.0" + long "^5.0.0" + +proxy-from-env@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/proxy-from-env/-/proxy-from-env-1.1.0.tgz#e102f16ca355424865755d2c9e8ea4f24d58c3e2" + integrity sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg== + +public-encrypt@^4.0.3: + version "4.0.3" + resolved "https://registry.yarnpkg.com/public-encrypt/-/public-encrypt-4.0.3.tgz#4fcc9d77a07e48ba7527e7cbe0de33d0701331e0" + integrity sha512-zVpa8oKZSz5bTMTFClc1fQOnyyEzpl5ozpi1B5YcvBrdohMjH2rfsBtyXcuNuwjsDIXmBYlF2N5FlJYhR29t8Q== + dependencies: + bn.js "^4.1.0" + browserify-rsa "^4.0.0" + create-hash "^1.1.0" + parse-asn1 "^5.0.0" + randombytes "^2.0.1" + safe-buffer "^5.1.2" + +pump@^3.0.0: + version "3.0.3" + resolved "https://registry.yarnpkg.com/pump/-/pump-3.0.3.tgz#151d979f1a29668dc0025ec589a455b53282268d" + integrity sha512-todwxLMY7/heScKmntwQG8CXVkWUOdYxIvY2s0VWAAMh/nd8SoYiRaKjlr7+iCs984f2P8zvrfWcDDYVb73NfA== + dependencies: + end-of-stream "^1.1.0" + once "^1.3.1" + +punycode@^1.4.1: + version "1.4.1" + resolved "https://registry.yarnpkg.com/punycode/-/punycode-1.4.1.tgz#c0d5a63b2718800ad8e1eb0fa5269c84dd41845e" + integrity sha512-jmYNElW7yvO7TV33CjSmvSiE2yco3bV2czu/OzDKdMNVZQWfxCblURLhf+47syQRBntjfLdd/H0egrzIG+oaFQ== + +punycode@^2.1.0: + version "2.3.1" + resolved "https://registry.yarnpkg.com/punycode/-/punycode-2.3.1.tgz#027422e2faec0b25e1549c3e1bd8309b9133b6e5" + integrity sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg== + +pure-rand@^7.0.0: + version "7.0.1" + resolved "https://registry.yarnpkg.com/pure-rand/-/pure-rand-7.0.1.tgz#6f53a5a9e3e4a47445822af96821ca509ed37566" + integrity sha512-oTUZM/NAZS8p7ANR3SHh30kXB+zK2r2BPcEn/awJIbOvq82WoMN4p62AWWp3Hhw50G0xMsw1mhIBLqHw64EcNQ== + +qs@^6.12.3: + version "6.14.0" + resolved "https://registry.yarnpkg.com/qs/-/qs-6.14.0.tgz#c63fa40680d2c5c941412a0e899c89af60c0a930" + integrity sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w== + dependencies: + side-channel "^1.1.0" + +quansync@^0.2.11: + version "0.2.11" + resolved "https://registry.yarnpkg.com/quansync/-/quansync-0.2.11.tgz#f9c3adda2e1272e4f8cf3f1457b04cbdb4ee692a" + integrity sha512-AifT7QEbW9Nri4tAwR5M/uzpBuqfZf+zwaEM/QkzEjj7NBuFD2rBuy0K3dE+8wltbezDV7JMA0WfnCPYRSYbXA== + +querystring-es3@^0.2.1: + version "0.2.1" + resolved "https://registry.yarnpkg.com/querystring-es3/-/querystring-es3-0.2.1.tgz#9ec61f79049875707d69414596fd907a4d711e73" + integrity sha512-773xhDQnZBMFobEiztv8LIl70ch5MSF/jUQVlhwFyBILqq96anmoctVIYz+ZRp0qbCKATTn6ev02M3r7Ga5vqA== + +queue-microtask@^1.2.2: + version "1.2.3" + resolved "https://registry.yarnpkg.com/queue-microtask/-/queue-microtask-1.2.3.tgz#4929228bbc724dfac43e0efb058caf7b6cfb6243" + integrity sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A== + +randombytes@^2.0.0, randombytes@^2.0.1, randombytes@^2.0.5, randombytes@^2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/randombytes/-/randombytes-2.1.0.tgz#df6f84372f0270dc65cdf6291349ab7a473d4f2a" + integrity sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ== + dependencies: + safe-buffer "^5.1.0" + +randomfill@^1.0.4: + version "1.0.4" + resolved "https://registry.yarnpkg.com/randomfill/-/randomfill-1.0.4.tgz#c92196fc86ab42be983f1bf31778224931d61458" + integrity sha512-87lcbR8+MhcWcUiQ+9e+Rwx8MyR2P7qnt15ynUlbm3TU/fjbgz4GsvfSUDTemtCCtVCqb4ZcEFlyPNTh9bBTLw== + dependencies: + randombytes "^2.0.5" + safe-buffer "^5.1.0" + +react-docgen-typescript@^2.2.2: + version "2.4.0" + resolved "https://registry.yarnpkg.com/react-docgen-typescript/-/react-docgen-typescript-2.4.0.tgz#033428b4a6a639d050ac8baf2a5195c596521713" + integrity sha512-ZtAp5XTO5HRzQctjPU0ybY0RRCQO19X/8fxn3w7y2VVTUbGHDKULPTL4ky3vB05euSgG5NpALhEhDPvQ56wvXg== + +react-docgen@^8.0.0, react-docgen@^8.0.2: + version "8.0.2" + resolved "https://registry.yarnpkg.com/react-docgen/-/react-docgen-8.0.2.tgz#450efcac75813e3d614d7bd15eb4066e2e7bcbf5" + integrity sha512-+NRMYs2DyTP4/tqWz371Oo50JqmWltR1h2gcdgUMAWZJIAvrd0/SqlCfx7tpzpl/s36rzw6qH2MjoNrxtRNYhA== + dependencies: + "@babel/core" "^7.28.0" + "@babel/traverse" "^7.28.0" + "@babel/types" "^7.28.2" + "@types/babel__core" "^7.20.5" + "@types/babel__traverse" "^7.20.7" + "@types/doctrine" "^0.0.9" + "@types/resolve" "^1.20.2" + doctrine "^3.0.0" + resolve "^1.22.1" + strip-indent "^4.0.0" + +"react-dom@^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0": + version "19.2.1" + resolved "https://registry.yarnpkg.com/react-dom/-/react-dom-19.2.1.tgz#ce3527560bda4f997e47d10dab754825b3061f59" + integrity sha512-ibrK8llX2a4eOskq1mXKu/TGZj9qzomO+sNfO98M6d9zIPOEhlBkMkBUBLd1vgS0gQsLDBzA+8jJBVXDnfHmJg== + dependencies: + scheduler "^0.27.0" + +react-is@^17.0.1: + version "17.0.2" + resolved "https://registry.yarnpkg.com/react-is/-/react-is-17.0.2.tgz#e691d4a8e9c789365655539ab372762b0efb54f0" + integrity sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w== + +react-is@^18.3.1: + version "18.3.1" + resolved "https://registry.yarnpkg.com/react-is/-/react-is-18.3.1.tgz#e83557dc12eae63a99e003a46388b1dcbb44db7e" + integrity sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg== + +react-merge-refs@^3.0.2: + version "3.0.2" + resolved "https://registry.yarnpkg.com/react-merge-refs/-/react-merge-refs-3.0.2.tgz#483b4e8029f89d805c4e55c8d22e9b8f77e3b58e" + integrity sha512-MSZAfwFfdbEvwkKWP5EI5chuLYnNUxNS7vyS0i1Jp+wtd8J4Ga2ddzhaE68aMol2Z4vCnRM/oGOo1a3V75UPlw== + +"react@^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0": + version "19.2.1" + resolved "https://registry.yarnpkg.com/react/-/react-19.2.1.tgz#8600fa205e58e2e807f6ef431c9f6492591a2700" + integrity sha512-DGrYcCWK7tvYMnWh79yrPHt+vdx9tY+1gPZa7nJQtO/p8bLTDaHp4dzwEhQB7pZ4Xe3ok4XKuEPrVuc+wlpkmw== + +readable-stream@^2.0.5, readable-stream@^2.3.8: + version "2.3.8" + resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-2.3.8.tgz#91125e8042bba1b9887f49345f6277027ce8be9b" + integrity sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA== + dependencies: + core-util-is "~1.0.0" + inherits "~2.0.3" + isarray "~1.0.0" + process-nextick-args "~2.0.0" + safe-buffer "~5.1.1" + string_decoder "~1.1.1" + util-deprecate "~1.0.1" + +readable-stream@^3.1.1, readable-stream@^3.4.0, readable-stream@^3.5.0, readable-stream@^3.6.0: + version "3.6.2" + resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-3.6.2.tgz#56a9b36ea965c00c5a93ef31eb111a0f11056967" + integrity sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA== + dependencies: + inherits "^2.0.3" + string_decoder "^1.1.1" + util-deprecate "^1.0.1" + +readable-stream@^4.0.0: + version "4.7.0" + resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-4.7.0.tgz#cedbd8a1146c13dfff8dab14068028d58c15ac91" + integrity sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg== + dependencies: + abort-controller "^3.0.0" + buffer "^6.0.3" + events "^3.3.0" + process "^0.11.10" + string_decoder "^1.3.0" + +readdir-glob@^1.1.2: + version "1.1.3" + resolved "https://registry.yarnpkg.com/readdir-glob/-/readdir-glob-1.1.3.tgz#c3d831f51f5e7bfa62fa2ffbe4b508c640f09584" + integrity sha512-v05I2k7xN8zXvPD9N+z/uhXPaj0sUFCe2rcWZIpBsqxfP7xXFQ0tipAd/wjj1YxWyWtUS5IDJpOG82JKt2EAVA== + dependencies: + minimatch "^5.1.0" + +recast@^0.23.5: + version "0.23.11" + resolved "https://registry.yarnpkg.com/recast/-/recast-0.23.11.tgz#8885570bb28cf773ba1dc600da7f502f7883f73f" + integrity sha512-YTUo+Flmw4ZXiWfQKGcwwc11KnoRAYgzAE2E7mXKCjSviTKShtxBsN6YUUBB2gtaBzKzeKunxhUwNHQuRryhWA== + dependencies: + ast-types "^0.16.1" + esprima "~4.0.0" + source-map "~0.6.1" + tiny-invariant "^1.3.3" + tslib "^2.0.1" + +redent@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/redent/-/redent-3.0.0.tgz#e557b7998316bb53c9f1f56fa626352c6963059f" + integrity sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg== + dependencies: + indent-string "^4.0.0" + strip-indent "^3.0.0" + +release-zalgo@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/release-zalgo/-/release-zalgo-1.0.0.tgz#09700b7e5074329739330e535c5a90fb67851730" + integrity sha512-gUAyHVHPPC5wdqX/LG4LWtRYtgjxyX78oanFNTMMyFEfOqdC54s3eE82imuWKbOeqYht2CrNf64Qb8vgmmtZGA== + dependencies: + es6-error "^4.0.1" + +require-directory@^2.1.1: + version "2.1.1" + resolved "https://registry.yarnpkg.com/require-directory/-/require-directory-2.1.1.tgz#8c64ad5fd30dab1c976e2344ffe7f792a6a6df42" + integrity sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q== + +require-from-string@^2.0.2: + version "2.0.2" + resolved "https://registry.yarnpkg.com/require-from-string/-/require-from-string-2.0.2.tgz#89a7fdd938261267318eafe14f9c32e598c36909" + integrity sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw== + +require-main-filename@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/require-main-filename/-/require-main-filename-2.0.0.tgz#d0b329ecc7cc0f61649f62215be69af54aa8989b" + integrity sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg== + +resolve-cwd@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/resolve-cwd/-/resolve-cwd-3.0.0.tgz#0f0075f1bb2544766cf73ba6a6e2adfebcb13f2d" + integrity sha512-OrZaX2Mb+rJCpH/6CpSqt9xFVpN++x01XnN2ie9g6P5/3xelLAkXWVADpdz1IHD/KFfEXyE6V0U01OQ3UO2rEg== + dependencies: + resolve-from "^5.0.0" + +resolve-dir@^0.1.0: + version "0.1.1" + resolved "https://registry.yarnpkg.com/resolve-dir/-/resolve-dir-0.1.1.tgz#b219259a5602fac5c5c496ad894a6e8cc430261e" + integrity sha512-QxMPqI6le2u0dCLyiGzgy92kjkkL6zO0XyvHzjdTNH3zM6e5Hz3BwG6+aEyNgiQ5Xz6PwTwgQEj3U50dByPKIA== + dependencies: + expand-tilde "^1.2.2" + global-modules "^0.2.3" + +resolve-from@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/resolve-from/-/resolve-from-4.0.0.tgz#4abcd852ad32dd7baabfe9b40e00a36db5f392e6" + integrity sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g== + +resolve-from@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/resolve-from/-/resolve-from-5.0.0.tgz#c35225843df8f776df21c57557bc087e9dfdfc69" + integrity sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw== + +resolve@^1.17.0, resolve@~1.22.1, resolve@~1.22.2: + version "1.22.10" + resolved "https://registry.yarnpkg.com/resolve/-/resolve-1.22.10.tgz#b663e83ffb09bbf2386944736baae803029b8b39" + integrity sha512-NPRy+/ncIMeDlTAsuqwKIiferiawhefFJtkNSW0qZJEqMEb+qBt/77B/jGeeek+F0uOeN05CDa6HXbbIgtVX4w== + dependencies: + is-core-module "^2.16.0" + path-parse "^1.0.7" + supports-preserve-symlinks-flag "^1.0.0" + +resolve@^1.22.1, resolve@^1.22.8: + version "1.22.11" + resolved "https://registry.yarnpkg.com/resolve/-/resolve-1.22.11.tgz#aad857ce1ffb8bfa9b0b1ac29f1156383f68c262" + integrity sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ== + dependencies: + is-core-module "^2.16.1" + path-parse "^1.0.7" + supports-preserve-symlinks-flag "^1.0.0" + +retry@^0.12.0: + version "0.12.0" + resolved "https://registry.yarnpkg.com/retry/-/retry-0.12.0.tgz#1b42a6266a21f07421d1b0b54b7dc167b01c013b" + integrity sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow== + +reusify@^1.0.4: + version "1.1.0" + resolved "https://registry.yarnpkg.com/reusify/-/reusify-1.1.0.tgz#0fe13b9522e1473f51b558ee796e08f11f9b489f" + integrity sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw== + +rimraf@^3.0.0, rimraf@^3.0.2: + version "3.0.2" + resolved "https://registry.yarnpkg.com/rimraf/-/rimraf-3.0.2.tgz#f1a5402ba6220ad52cc1282bac1ae3aa49fd061a" + integrity sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA== + dependencies: + glob "^7.1.3" + +ripemd160@^2.0.0, ripemd160@^2.0.1, ripemd160@^2.0.3: + version "2.0.3" + resolved "https://registry.yarnpkg.com/ripemd160/-/ripemd160-2.0.3.tgz#9be54e4ba5e3559c8eee06a25cd7648bbccdf5a8" + integrity sha512-5Di9UC0+8h1L6ZD2d7awM7E/T4uA1fJRlx6zk/NvdCCVEoAnFqvHmCuNeIKoCeIixBX/q8uM+6ycDvF8woqosA== + dependencies: + hash-base "^3.1.2" + inherits "^2.0.4" + +rollup@^4.43.0: + version "4.53.3" + resolved "https://registry.yarnpkg.com/rollup/-/rollup-4.53.3.tgz#dbc8cd8743b38710019fb8297e8d7a76e3faa406" + integrity sha512-w8GmOxZfBmKknvdXU1sdM9NHcoQejwF/4mNgj2JuEEdRaHwwF12K7e9eXn1nLZ07ad+du76mkVsyeb2rKGllsA== + dependencies: + "@types/estree" "1.0.8" + optionalDependencies: + "@rollup/rollup-android-arm-eabi" "4.53.3" + "@rollup/rollup-android-arm64" "4.53.3" + "@rollup/rollup-darwin-arm64" "4.53.3" + "@rollup/rollup-darwin-x64" "4.53.3" + "@rollup/rollup-freebsd-arm64" "4.53.3" + "@rollup/rollup-freebsd-x64" "4.53.3" + "@rollup/rollup-linux-arm-gnueabihf" "4.53.3" + "@rollup/rollup-linux-arm-musleabihf" "4.53.3" + "@rollup/rollup-linux-arm64-gnu" "4.53.3" + "@rollup/rollup-linux-arm64-musl" "4.53.3" + "@rollup/rollup-linux-loong64-gnu" "4.53.3" + "@rollup/rollup-linux-ppc64-gnu" "4.53.3" + "@rollup/rollup-linux-riscv64-gnu" "4.53.3" + "@rollup/rollup-linux-riscv64-musl" "4.53.3" + "@rollup/rollup-linux-s390x-gnu" "4.53.3" + "@rollup/rollup-linux-x64-gnu" "4.53.3" + "@rollup/rollup-linux-x64-musl" "4.53.3" + "@rollup/rollup-openharmony-arm64" "4.53.3" + "@rollup/rollup-win32-arm64-msvc" "4.53.3" + "@rollup/rollup-win32-ia32-msvc" "4.53.3" + "@rollup/rollup-win32-x64-gnu" "4.53.3" + "@rollup/rollup-win32-x64-msvc" "4.53.3" + fsevents "~2.3.2" + +run-applescript@^7.0.0: + version "7.1.0" + resolved "https://registry.yarnpkg.com/run-applescript/-/run-applescript-7.1.0.tgz#2e9e54c4664ec3106c5b5630e249d3d6595c4911" + integrity sha512-DPe5pVFaAsinSaV6QjQ6gdiedWDcRCbUuiQfQa2wmWV7+xC9bGulGI8+TdRmoFkAPaBXk8CrAbnlY2ISniJ47Q== + +run-parallel@^1.1.9: + version "1.2.0" + resolved "https://registry.yarnpkg.com/run-parallel/-/run-parallel-1.2.0.tgz#66d1368da7bdf921eb9d95bd1a9229e7f21a43ee" + integrity sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA== + dependencies: + queue-microtask "^1.2.2" + +rxjs@7.8.2, rxjs@^7.8.1: + version "7.8.2" + resolved "https://registry.yarnpkg.com/rxjs/-/rxjs-7.8.2.tgz#955bc473ed8af11a002a2be52071bf475638607b" + integrity sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA== + dependencies: + tslib "^2.1.0" + +safe-buffer@^5.0.1, safe-buffer@^5.1.0, safe-buffer@^5.1.1, safe-buffer@^5.1.2, safe-buffer@^5.2.1, safe-buffer@~5.2.0: + version "5.2.1" + resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.2.1.tgz#1eaf9fa9bdb1fdd4ec75f58f9cdb4e6b7827eec6" + integrity sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ== + +safe-buffer@~5.1.0, safe-buffer@~5.1.1: + version "5.1.2" + resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.1.2.tgz#991ec69d296e0313747d59bdfd2b745c35f8828d" + integrity sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g== + +safe-regex-test@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/safe-regex-test/-/safe-regex-test-1.1.0.tgz#7f87dfb67a3150782eaaf18583ff5d1711ac10c1" + integrity sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw== + dependencies: + call-bound "^1.0.2" + es-errors "^1.3.0" + is-regex "^1.2.1" + +safer-buffer@~2.1.0: + version "2.1.2" + resolved "https://registry.yarnpkg.com/safer-buffer/-/safer-buffer-2.1.2.tgz#44fa161b0187b9549dd84bb91802f9bd8385cd6a" + integrity sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg== + +scheduler@^0.27.0: + version "0.27.0" + resolved "https://registry.yarnpkg.com/scheduler/-/scheduler-0.27.0.tgz#0c4ef82d67d1e5c1e359e8fc76d3a87f045fe5bd" + integrity sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q== + +semver@^6.0.0, semver@^6.3.0, semver@^6.3.1: + version "6.3.1" + resolved "https://registry.yarnpkg.com/semver/-/semver-6.3.1.tgz#556d2ef8689146e46dcea4bfdd095f3434dffcb4" + integrity sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA== + +semver@^7.5.3, semver@^7.5.4, semver@^7.6.0, semver@^7.6.2, semver@^7.7.2: + version "7.7.3" + resolved "https://registry.yarnpkg.com/semver/-/semver-7.7.3.tgz#4b5f4143d007633a8dc671cd0a6ef9147b8bb946" + integrity sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q== + +semver@~7.5.4: + version "7.5.4" + resolved "https://registry.yarnpkg.com/semver/-/semver-7.5.4.tgz#483986ec4ed38e1c6c48c34894a9182dbff68a6e" + integrity sha512-1bCSESV6Pv+i21Hvpxp3Dx+pSD8lIPt8uVjRrxAUt/nbswYc+tK6Y2btiULjd4+fnq15PX+nqQDC7Oft7WkwcA== + dependencies: + lru-cache "^6.0.0" + +set-blocking@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/set-blocking/-/set-blocking-2.0.0.tgz#045f9782d011ae9a6803ddd382b24392b3d890f7" + integrity sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw== + +set-function-length@^1.2.2: + version "1.2.2" + resolved "https://registry.yarnpkg.com/set-function-length/-/set-function-length-1.2.2.tgz#aac72314198eaed975cf77b2c3b6b880695e5449" + integrity sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg== + dependencies: + define-data-property "^1.1.4" + es-errors "^1.3.0" + function-bind "^1.1.2" + get-intrinsic "^1.2.4" + gopd "^1.0.1" + has-property-descriptors "^1.0.2" + +setimmediate@^1.0.4: + version "1.0.5" + resolved "https://registry.yarnpkg.com/setimmediate/-/setimmediate-1.0.5.tgz#290cbb232e306942d7d7ea9b83732ab7856f8285" + integrity sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA== + +sha.js@^2.4.0, sha.js@^2.4.12, sha.js@^2.4.8: + version "2.4.12" + resolved "https://registry.yarnpkg.com/sha.js/-/sha.js-2.4.12.tgz#eb8b568bf383dfd1867a32c3f2b74eb52bdbf23f" + integrity sha512-8LzC5+bvI45BjpfXU8V5fdU2mfeKiQe1D1gIMn7XUlF3OTUrpdJpPPH4EMAnF0DsHHdSZqCdSss5qCmJKuiO3w== + dependencies: + inherits "^2.0.4" + safe-buffer "^5.2.1" + to-buffer "^1.2.0" + +shebang-command@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/shebang-command/-/shebang-command-2.0.0.tgz#ccd0af4f8835fbdc265b82461aaf0c36663f34ea" + integrity sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA== + dependencies: + shebang-regex "^3.0.0" + +shebang-regex@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/shebang-regex/-/shebang-regex-3.0.0.tgz#ae16f1644d873ecad843b0307b143362d4c42172" + integrity sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A== + +shell-quote@1.8.3: + version "1.8.3" + resolved "https://registry.yarnpkg.com/shell-quote/-/shell-quote-1.8.3.tgz#55e40ef33cf5c689902353a3d8cd1a6725f08b4b" + integrity sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw== + +side-channel-list@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/side-channel-list/-/side-channel-list-1.0.0.tgz#10cb5984263115d3b7a0e336591e290a830af8ad" + integrity sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA== + dependencies: + es-errors "^1.3.0" + object-inspect "^1.13.3" + +side-channel-map@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/side-channel-map/-/side-channel-map-1.0.1.tgz#d6bb6b37902c6fef5174e5f533fab4c732a26f42" + integrity sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA== + dependencies: + call-bound "^1.0.2" + es-errors "^1.3.0" + get-intrinsic "^1.2.5" + object-inspect "^1.13.3" + +side-channel-weakmap@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz#11dda19d5368e40ce9ec2bdc1fb0ecbc0790ecea" + integrity sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A== + dependencies: + call-bound "^1.0.2" + es-errors "^1.3.0" + get-intrinsic "^1.2.5" + object-inspect "^1.13.3" + side-channel-map "^1.0.1" + +side-channel@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/side-channel/-/side-channel-1.1.0.tgz#c3fcff9c4da932784873335ec9765fa94ff66bc9" + integrity sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw== + dependencies: + es-errors "^1.3.0" + object-inspect "^1.13.3" + side-channel-list "^1.0.0" + side-channel-map "^1.0.1" + side-channel-weakmap "^1.0.2" + +signal-exit@^3.0.2, signal-exit@^3.0.3: + version "3.0.7" + resolved "https://registry.yarnpkg.com/signal-exit/-/signal-exit-3.0.7.tgz#a9a1767f8af84155114eaabd73f99273c8f59ad9" + integrity sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ== + +signal-exit@^4.0.1: + version "4.1.0" + resolved "https://registry.yarnpkg.com/signal-exit/-/signal-exit-4.1.0.tgz#952188c1cbd546070e2dd20d0f41c0ae0530cb04" + integrity sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw== + +sisteransi@^1.0.5: + version "1.0.5" + resolved "https://registry.yarnpkg.com/sisteransi/-/sisteransi-1.0.5.tgz#134d681297756437cc05ca01370d3a7a571075ed" + integrity sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg== + +slash@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/slash/-/slash-2.0.0.tgz#de552851a1759df3a8f206535442f5ec4ddeab44" + integrity sha512-ZYKh3Wh2z1PpEXWr0MpSBZ0V6mZHAQfYevttO11c51CaWjGTaadiKZ+wVt1PbMlDV5qhMFslpZCemhwOK7C89A== + +slash@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/slash/-/slash-3.0.0.tgz#6539be870c165adbd5240220dbe361f1bc4d4634" + integrity sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q== + +slash@^5.0.0: + version "5.1.0" + resolved "https://registry.yarnpkg.com/slash/-/slash-5.1.0.tgz#be3adddcdf09ac38eebe8dcdc7b1a57a75b095ce" + integrity sha512-ZA6oR3T/pEyuqwMgAKT0/hAv8oAXckzbkmR0UkUosQ+Mc4RxGoJkRmwHgHufaenlyAgE1Mxgpdcrf75y6XcnDg== + +source-map-js@^1.2.1: + version "1.2.1" + resolved "https://registry.yarnpkg.com/source-map-js/-/source-map-js-1.2.1.tgz#1ce5650fddd87abc099eda37dcff024c2667ae46" + integrity sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA== + +source-map-support@0.5.13: + version "0.5.13" + resolved "https://registry.yarnpkg.com/source-map-support/-/source-map-support-0.5.13.tgz#31b24a9c2e73c2de85066c0feb7d44767ed52932" + integrity sha512-SHSKFHadjVA5oR4PPqhtAVdcBWwRYVd6g6cAXnIbRiIwc2EhPrTuKUBdSLvlEKyIP3GCf89fltvcZiP9MMFA1w== + dependencies: + buffer-from "^1.0.0" + source-map "^0.6.0" + +source-map@^0.6.0, source-map@^0.6.1, source-map@~0.6.1: + version "0.6.1" + resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.6.1.tgz#74722af32e9614e9c287a8d0bbde48b5e2f1a263" + integrity sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g== + +spawn-wrap@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/spawn-wrap/-/spawn-wrap-2.0.0.tgz#103685b8b8f9b79771318827aa78650a610d457e" + integrity sha512-EeajNjfN9zMnULLwhZZQU3GWBoFNkbngTUPfaawT4RkMiviTxcX0qfhVbGey39mfctfDHkWtuecgQ8NJcyQWHg== + dependencies: + foreground-child "^2.0.0" + is-windows "^1.0.2" + make-dir "^3.0.0" + rimraf "^3.0.0" + signal-exit "^3.0.2" + which "^2.0.1" + +spawnd@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/spawnd/-/spawnd-5.0.0.tgz#ea72200bdc468998e84e1c3e7b914ce85fc1c32c" + integrity sha512-28+AJr82moMVWolQvlAIv3JcYDkjkFTEmfDc503wxrF5l2rQ3dFz6DpbXp3kD4zmgGGldfM4xM4v1sFj/ZaIOA== + dependencies: + exit "^0.1.2" + signal-exit "^3.0.3" + tree-kill "^1.2.2" + wait-port "^0.2.9" + +split-ca@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/split-ca/-/split-ca-1.0.1.tgz#6c83aff3692fa61256e0cd197e05e9de157691a6" + integrity sha512-Q5thBSxp5t8WPTTJQS59LrGqOZqOsrhDGDVm8azCqIBjSBd7nd9o2PM+mDulQQkh8h//4U6hFZnc/mul8t5pWQ== + +sprintf-js@^1.0.3: + version "1.1.3" + resolved "https://registry.yarnpkg.com/sprintf-js/-/sprintf-js-1.1.3.tgz#4914b903a2f8b685d17fdf78a70e917e872e444a" + integrity sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA== + +sprintf-js@~1.0.2: + version "1.0.3" + resolved "https://registry.yarnpkg.com/sprintf-js/-/sprintf-js-1.0.3.tgz#04e6926f662895354f3dd015203633b857297e2c" + integrity sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g== + +ssh-remote-port-forward@^1.0.4: + version "1.0.4" + resolved "https://registry.yarnpkg.com/ssh-remote-port-forward/-/ssh-remote-port-forward-1.0.4.tgz#72b0c5df8ec27ca300c75805cc6b266dee07e298" + integrity sha512-x0LV1eVDwjf1gmG7TTnfqIzf+3VPRz7vrNIjX6oYLbeCrf/PeVY6hkT68Mg+q02qXxQhrLjB0jfgvhevoCRmLQ== + dependencies: + "@types/ssh2" "^0.5.48" + ssh2 "^1.4.0" + +ssh2@^1.15.0, ssh2@^1.4.0: + version "1.17.0" + resolved "https://registry.yarnpkg.com/ssh2/-/ssh2-1.17.0.tgz#dc686e8e3abdbd4ad95d46fa139615903c12258c" + integrity sha512-wPldCk3asibAjQ/kziWQQt1Wh3PgDFpC0XpwclzKcdT1vql6KeYxf5LIt4nlFkUeR8WuphYMKqUA56X4rjbfgQ== + dependencies: + asn1 "^0.2.6" + bcrypt-pbkdf "^1.0.2" + optionalDependencies: + cpu-features "~0.0.10" + nan "^2.23.0" + +ssim.js@^3.1.1: + version "3.5.0" + resolved "https://registry.yarnpkg.com/ssim.js/-/ssim.js-3.5.0.tgz#d7276b9ee99b57a5ff0db34035f02f35197e62df" + integrity sha512-Aj6Jl2z6oDmgYFFbQqK7fght19bXdOxY7Tj03nF+03M9gCBAjeIiO8/PlEGMfKDwYpw4q6iBqVq2YuREorGg/g== + +stack-utils@^2.0.6: + version "2.0.6" + resolved "https://registry.yarnpkg.com/stack-utils/-/stack-utils-2.0.6.tgz#aaf0748169c02fc33c8232abccf933f54a1cc34f" + integrity sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ== + dependencies: + escape-string-regexp "^2.0.0" + +storybook@^10.0.7: + version "10.1.10" + resolved "https://registry.yarnpkg.com/storybook/-/storybook-10.1.10.tgz#9023e063f97cb2c38b4520e03d49336bcbf6661f" + integrity sha512-oK0t0jEogiKKfv5Z1ao4Of99+xWw1TMUGuGRYDQS4kp2yyBsJQEgu7NI7OLYsCDI6gzt5p3RPtl1lqdeVLUi8A== + dependencies: + "@storybook/global" "^5.0.0" + "@storybook/icons" "^2.0.0" + "@testing-library/jest-dom" "^6.6.3" + "@testing-library/user-event" "^14.6.1" + "@vitest/expect" "3.2.4" + "@vitest/spy" "3.2.4" + esbuild "^0.18.0 || ^0.19.0 || ^0.20.0 || ^0.21.0 || ^0.22.0 || ^0.23.0 || ^0.24.0 || ^0.25.0 || ^0.26.0 || ^0.27.0" + open "^10.2.0" + recast "^0.23.5" + semver "^7.6.2" + use-sync-external-store "^1.5.0" + ws "^8.18.0" + +stream-browserify@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/stream-browserify/-/stream-browserify-3.0.0.tgz#22b0a2850cdf6503e73085da1fc7b7d0c2122f2f" + integrity sha512-H73RAHsVBapbim0tU2JwwOiXUj+fikfiaoYAKHF3VJfA0pe2BCzkhAHBlLG6REzE+2WNZcxOXjK7lkso+9euLA== + dependencies: + inherits "~2.0.4" + readable-stream "^3.5.0" + +stream-http@^3.2.0: + version "3.2.0" + resolved "https://registry.yarnpkg.com/stream-http/-/stream-http-3.2.0.tgz#1872dfcf24cb15752677e40e5c3f9cc1926028b5" + integrity sha512-Oq1bLqisTyK3TSCXpPbT4sdeYNdmyZJv1LxpEm2vu1ZhK89kSE5YXwZc3cWk0MagGaKriBh9mCFbVGtO+vY29A== + dependencies: + builtin-status-codes "^3.0.0" + inherits "^2.0.4" + readable-stream "^3.6.0" + xtend "^4.0.2" + +streamx@^2.15.0, streamx@^2.21.0: + version "2.23.0" + resolved "https://registry.yarnpkg.com/streamx/-/streamx-2.23.0.tgz#7d0f3d00d4a6c5de5728aecd6422b4008d66fd0b" + integrity sha512-kn+e44esVfn2Fa/O0CPFcex27fjIL6MkVae0Mm6q+E6f0hWv578YCERbv+4m02cjxvDsPKLnmxral/rR6lBMAg== + dependencies: + events-universal "^1.0.0" + fast-fifo "^1.3.2" + text-decoder "^1.1.0" + +string-argv@~0.3.1: + version "0.3.2" + resolved "https://registry.yarnpkg.com/string-argv/-/string-argv-0.3.2.tgz#2b6d0ef24b656274d957d54e0a4bbf6153dc02b6" + integrity sha512-aqD2Q0144Z+/RqG52NeHEkZauTAUWJO8c6yTftGJKO3Tja5tUgIfmIl6kExvhtxSDP7fXB6DvzkfMpCd/F3G+Q== + +string-length@^4.0.2: + version "4.0.2" + resolved "https://registry.yarnpkg.com/string-length/-/string-length-4.0.2.tgz#a8a8dc7bd5c1a82b9b3c8b87e125f66871b6e57a" + integrity sha512-+l6rNN5fYHNhZZy41RXsYptCjA2Igmq4EG7kZAYFQI1E1VTXarr6ZPXBg6eq7Y6eK4FEhY6AJlyuFIb/v/S0VQ== + dependencies: + char-regex "^1.0.2" + strip-ansi "^6.0.0" + +string-length@^6.0.0: + version "6.0.0" + resolved "https://registry.yarnpkg.com/string-length/-/string-length-6.0.0.tgz#1c7342bbf032129b2f80003e69f889c70231d791" + integrity sha512-1U361pxZHEQ+FeSjzqRpV+cu2vTzYeWeafXFLykiFlv4Vc0n3njgU8HrMbyik5uwm77naWMuVG8fhEF+Ovb1Kg== + dependencies: + strip-ansi "^7.1.0" + +"string-width-cjs@npm:string-width@^4.2.0": + version "4.2.3" + resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" + integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== + dependencies: + emoji-regex "^8.0.0" + is-fullwidth-code-point "^3.0.0" + strip-ansi "^6.0.1" + +string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3: + version "4.2.3" + resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" + integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== + dependencies: + emoji-regex "^8.0.0" + is-fullwidth-code-point "^3.0.0" + strip-ansi "^6.0.1" + +string-width@^5.0.1, string-width@^5.1.2: + version "5.1.2" + resolved "https://registry.yarnpkg.com/string-width/-/string-width-5.1.2.tgz#14f8daec6d81e7221d2a357e668cab73bdbca794" + integrity sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA== + dependencies: + eastasianwidth "^0.2.0" + emoji-regex "^9.2.2" + strip-ansi "^7.0.1" + +string_decoder@^1.0.0, string_decoder@^1.1.1, string_decoder@^1.3.0: + version "1.3.0" + resolved "https://registry.yarnpkg.com/string_decoder/-/string_decoder-1.3.0.tgz#42f114594a46cf1a8e30b0a84f56c78c3edac21e" + integrity sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA== + dependencies: + safe-buffer "~5.2.0" + +string_decoder@~1.1.1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/string_decoder/-/string_decoder-1.1.1.tgz#9cf1611ba62685d7030ae9e4ba34149c3af03fc8" + integrity sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg== + dependencies: + safe-buffer "~5.1.0" + +"strip-ansi-cjs@npm:strip-ansi@^6.0.1": + version "6.0.1" + resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" + integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== + dependencies: + ansi-regex "^5.0.1" + +strip-ansi@^6.0.0, strip-ansi@^6.0.1: + version "6.0.1" + resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" + integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== + dependencies: + ansi-regex "^5.0.1" + +strip-ansi@^7.0.1, strip-ansi@^7.1.0: + version "7.1.2" + resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-7.1.2.tgz#132875abde678c7ea8d691533f2e7e22bb744dba" + integrity sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA== + dependencies: + ansi-regex "^6.0.1" + +strip-bom@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/strip-bom/-/strip-bom-3.0.0.tgz#2334c18e9c759f7bdd56fdef7e9ae3d588e68ed3" + integrity sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA== + +strip-bom@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/strip-bom/-/strip-bom-4.0.0.tgz#9c3505c1db45bcedca3d9cf7a16f5c5aa3901878" + integrity sha512-3xurFv5tEgii33Zi8Jtp55wEIILR9eh34FAW00PZf+JnSsTmV/ioewSgQl97JHvgjoRGwPShsWm+IdrxB35d0w== + +strip-final-newline@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/strip-final-newline/-/strip-final-newline-2.0.0.tgz#89b852fb2fcbe936f6f4b3187afb0a12c1ab58ad" + integrity sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA== + +strip-indent@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/strip-indent/-/strip-indent-3.0.0.tgz#c32e1cee940b6b3432c771bc2c54bcce73cd3001" + integrity sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ== + dependencies: + min-indent "^1.0.0" + +strip-indent@^4.0.0: + version "4.1.1" + resolved "https://registry.yarnpkg.com/strip-indent/-/strip-indent-4.1.1.tgz#aba13de189d4ad9a17f6050e76554ac27585c7af" + integrity sha512-SlyRoSkdh1dYP0PzclLE7r0M9sgbFKKMFXpFRUMNuKhQSbC6VQIGzq3E0qsfvGJaUFJPGv6Ws1NZ/haTAjfbMA== + +strip-json-comments@^3.1.1, strip-json-comments@~3.1.1: + version "3.1.1" + resolved "https://registry.yarnpkg.com/strip-json-comments/-/strip-json-comments-3.1.1.tgz#31f1281b3832630434831c310c01cccda8cbe006" + integrity sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig== + +supports-color@8.1.1, supports-color@^8.1.1, supports-color@~8.1.1: + version "8.1.1" + resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-8.1.1.tgz#cd6fc17e28500cff56c1b86c0a7fd4a54a73005c" + integrity sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q== + dependencies: + has-flag "^4.0.0" + +supports-color@^5.3.0: + version "5.5.0" + resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-5.5.0.tgz#e2e69a44ac8772f78a1ec0b35b689df6530efc8f" + integrity sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow== + dependencies: + has-flag "^3.0.0" + +supports-color@^7.1.0: + version "7.2.0" + resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-7.2.0.tgz#1b7dcdcb32b8138801b3e478ba6a51caa89648da" + integrity sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw== + dependencies: + has-flag "^4.0.0" + +supports-preserve-symlinks-flag@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz#6eda4bd344a3c94aea376d4cc31bc77311039e09" + integrity sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w== + +synckit@^0.11.8: + version "0.11.11" + resolved "https://registry.yarnpkg.com/synckit/-/synckit-0.11.11.tgz#c0b619cf258a97faa209155d9cd1699b5c998cb0" + integrity sha512-MeQTA1r0litLUf0Rp/iisCaL8761lKAZHaimlbGK4j0HysC4PLfqygQj9srcs0m2RdtDYnF8UuYyKpbjHYp7Jw== + dependencies: + "@pkgr/core" "^0.2.9" + +tar-fs@^2.1.4: + version "2.1.4" + resolved "https://registry.yarnpkg.com/tar-fs/-/tar-fs-2.1.4.tgz#800824dbf4ef06ded9afea4acafe71c67c76b930" + integrity sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ== + dependencies: + chownr "^1.1.1" + mkdirp-classic "^0.5.2" + pump "^3.0.0" + tar-stream "^2.1.4" + +tar-fs@^3.1.1: + version "3.1.1" + resolved "https://registry.yarnpkg.com/tar-fs/-/tar-fs-3.1.1.tgz#4f164e59fb60f103d472360731e8c6bb4a7fe9ef" + integrity sha512-LZA0oaPOc2fVo82Txf3gw+AkEd38szODlptMYejQUhndHMLQ9M059uXR+AfS7DNo0NpINvSqDsvyaCrBVkptWg== + dependencies: + pump "^3.0.0" + tar-stream "^3.1.5" + optionalDependencies: + bare-fs "^4.0.1" + bare-path "^3.0.0" + +tar-stream@^2.1.4: + version "2.2.0" + resolved "https://registry.yarnpkg.com/tar-stream/-/tar-stream-2.2.0.tgz#acad84c284136b060dc3faa64474aa9aebd77287" + integrity sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ== + dependencies: + bl "^4.0.3" + end-of-stream "^1.4.1" + fs-constants "^1.0.0" + inherits "^2.0.3" + readable-stream "^3.1.1" + +tar-stream@^3.0.0, tar-stream@^3.1.5: + version "3.1.7" + resolved "https://registry.yarnpkg.com/tar-stream/-/tar-stream-3.1.7.tgz#24b3fb5eabada19fe7338ed6d26e5f7c482e792b" + integrity sha512-qJj60CXt7IU1Ffyc3NJMjh6EkuCFej46zUqJ4J7pqYlThyd9bO0XBTmcOIhSzZJVWfsLks0+nle/j538YAW9RQ== + dependencies: + b4a "^1.6.4" + fast-fifo "^1.2.0" + streamx "^2.15.0" + +temporal-polyfill@^0.3.0: + version "0.3.0" + resolved "https://registry.yarnpkg.com/temporal-polyfill/-/temporal-polyfill-0.3.0.tgz#7fe90e913ac5ec8e0d508fb50d04dd7a74cec23e" + integrity sha512-qNsTkX9K8hi+FHDfHmf22e/OGuXmfBm9RqNismxBrnSmZVJKegQ+HYYXT+R7Ha8F/YSm2Y34vmzD4cxMu2u95g== + dependencies: + temporal-spec "0.3.0" + +temporal-spec@0.3.0: + version "0.3.0" + resolved "https://registry.yarnpkg.com/temporal-spec/-/temporal-spec-0.3.0.tgz#8c4210c575fb28ba0a1c2e02ad68d1be5956a11f" + integrity sha512-n+noVpIqz4hYgFSMOSiINNOUOMFtV5cZQNCmmszA6GiVFVRt3G7AqVyhXjhCSmowvQn+NsGn+jMDMKJYHd3bSQ== + +test-exclude@^6.0.0: + version "6.0.0" + resolved "https://registry.yarnpkg.com/test-exclude/-/test-exclude-6.0.0.tgz#04a8698661d805ea6fa293b6cb9e63ac044ef15e" + integrity sha512-cAGWPIyOHU6zlmg88jwm7VRyXnMN7iV68OGAbYDk/Mh/xC/pzVPlQtY6ngoIH/5/tciuhGfvESU8GrHrcxD56w== + dependencies: + "@istanbuljs/schema" "^0.1.2" + glob "^7.1.4" + minimatch "^3.0.4" + +testcontainers@^11.0.0, testcontainers@^11.9.0: + version "11.9.0" + resolved "https://registry.yarnpkg.com/testcontainers/-/testcontainers-11.9.0.tgz#464c2feb31b56ef594563349048efeaf0af2bd1e" + integrity sha512-SQ6OqQUig7HcGVF72i+ZVIMvxPSpEz8cgC/B63ekqMzgf98DnveoBbOmqux/Wa5wQAQCt4mEPNMa/Jz7vMg9fQ== + dependencies: + "@balena/dockerignore" "^1.0.2" + "@types/dockerode" "^3.3.47" + archiver "^7.0.1" + async-lock "^1.4.1" + byline "^5.0.0" + debug "^4.4.3" + docker-compose "^1.3.0" + dockerode "^4.0.9" + get-port "^7.1.0" + proper-lockfile "^4.1.2" + properties-reader "^2.3.0" + ssh-remote-port-forward "^1.0.4" + tar-fs "^3.1.1" + tmp "^0.2.5" + undici "^7.16.0" + +text-decoder@^1.1.0: + version "1.2.3" + resolved "https://registry.yarnpkg.com/text-decoder/-/text-decoder-1.2.3.tgz#b19da364d981b2326d5f43099c310cc80d770c65" + integrity sha512-3/o9z3X0X0fTupwsYvR03pJ/DjWuqqrfwBgTQzdWDiQSm9KitAyz/9WqsT2JQW7KV2m+bC2ol/zqpW37NHxLaA== + dependencies: + b4a "^1.6.4" + +text-table@^0.2.0: + version "0.2.0" + resolved "https://registry.yarnpkg.com/text-table/-/text-table-0.2.0.tgz#7f5ee823ae805207c00af2df4a84ec3fcfa570b4" + integrity sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw== + +timers-browserify@^2.0.4: + version "2.0.12" + resolved "https://registry.yarnpkg.com/timers-browserify/-/timers-browserify-2.0.12.tgz#44a45c11fbf407f34f97bccd1577c652361b00ee" + integrity sha512-9phl76Cqm6FhSX9Xe1ZUAMLtm1BLkKj2Qd5ApyWkXzsMRaA7dgr81kf4wJmQf/hAvg8EEyJxDo3du/0KlhPiKQ== + dependencies: + setimmediate "^1.0.4" + +tiny-invariant@^1.3.3: + version "1.3.3" + resolved "https://registry.yarnpkg.com/tiny-invariant/-/tiny-invariant-1.3.3.tgz#46680b7a873a0d5d10005995eb90a70d74d60127" + integrity sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg== + +tinyglobby@^0.2.15: + version "0.2.15" + resolved "https://registry.yarnpkg.com/tinyglobby/-/tinyglobby-0.2.15.tgz#e228dd1e638cea993d2fdb4fcd2d4602a79951c2" + integrity sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ== + dependencies: + fdir "^6.5.0" + picomatch "^4.0.3" + +tinyrainbow@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/tinyrainbow/-/tinyrainbow-2.0.0.tgz#9509b2162436315e80e3eee0fcce4474d2444294" + integrity sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw== + +tinyspy@^4.0.3: + version "4.0.4" + resolved "https://registry.yarnpkg.com/tinyspy/-/tinyspy-4.0.4.tgz#d77a002fb53a88aa1429b419c1c92492e0c81f78" + integrity sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q== + +tmp@^0.2.4, tmp@^0.2.5: + version "0.2.5" + resolved "https://registry.yarnpkg.com/tmp/-/tmp-0.2.5.tgz#b06bcd23f0f3c8357b426891726d16015abfd8f8" + integrity sha512-voyz6MApa1rQGUxT3E+BK7/ROe8itEx7vD8/HEvt4xwXucvQ5G5oeEiHkmHZJuBO21RpOf+YYm9MOivj709jow== + +tmpl@1.0.5: + version "1.0.5" + resolved "https://registry.yarnpkg.com/tmpl/-/tmpl-1.0.5.tgz#8683e0b902bb9c20c4f726e3c0b69f36518c07cc" + integrity sha512-3f0uOEAQwIqGuWW2MVzYg8fV/QNnc/IpuJNG837rLuczAaLVHslWHZQj4IGiEl5Hs3kkbhwL9Ab7Hrsmuj+Smw== + +to-buffer@^1.2.0, to-buffer@^1.2.1, to-buffer@^1.2.2: + version "1.2.2" + resolved "https://registry.yarnpkg.com/to-buffer/-/to-buffer-1.2.2.tgz#ffe59ef7522ada0a2d1cb5dfe03bb8abc3cdc133" + integrity sha512-db0E3UJjcFhpDhAF4tLo03oli3pwl3dbnzXOUIlRKrp+ldk/VUxzpWYZENsw2SZiuBjHAk7DfB0VU7NKdpb6sw== + dependencies: + isarray "^2.0.5" + safe-buffer "^5.2.1" + typed-array-buffer "^1.0.3" + +to-regex-range@^5.0.1: + version "5.0.1" + resolved "https://registry.yarnpkg.com/to-regex-range/-/to-regex-range-5.0.1.tgz#1648c44aae7c8d988a326018ed72f5b4dd0392e4" + integrity sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ== + dependencies: + is-number "^7.0.0" + +tree-kill@1.2.2, tree-kill@^1.2.2: + version "1.2.2" + resolved "https://registry.yarnpkg.com/tree-kill/-/tree-kill-1.2.2.tgz#4ca09a9092c88b73a7cdc5e8a01b507b0790a0cc" + integrity sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A== + +ts-api-utils@^2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/ts-api-utils/-/ts-api-utils-2.1.0.tgz#595f7094e46eed364c13fd23e75f9513d29baf91" + integrity sha512-CUgTZL1irw8u29bzrOD/nH85jqyc74D6SshFgujOIA7osm2Rz7dYH77agkx7H4FBNxDq7Cjf+IjaX/8zwFW+ZQ== + +ts-dedent@^2.0.0: + version "2.2.0" + resolved "https://registry.yarnpkg.com/ts-dedent/-/ts-dedent-2.2.0.tgz#39e4bd297cd036292ae2394eb3412be63f563bb5" + integrity sha512-q5W7tVM71e2xjHZTlgfTDoPF/SmqKG5hddq9SzR49CH2hayqRKJtQ4mtRlSxKaJlR/+9rEM+mnBHf7I2/BQcpQ== + +tsconfig-paths@^4.2.0: + version "4.2.0" + resolved "https://registry.yarnpkg.com/tsconfig-paths/-/tsconfig-paths-4.2.0.tgz#ef78e19039133446d244beac0fd6a1632e2d107c" + integrity sha512-NoZ4roiN7LnbKn9QqE1amc9DJfzvZXxF4xDavcOWt1BPkdx+m+0gJuPM+S0vCe7zTJMYUP0R8pO2XMr+Y8oLIg== + dependencies: + json5 "^2.2.2" + minimist "^1.2.6" + strip-bom "^3.0.0" + +tslib@^2.0.1, tslib@^2.1.0, tslib@^2.4.0: + version "2.8.1" + resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.8.1.tgz#612efe4ed235d567e8aba5f2a5fab70280ade83f" + integrity sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w== + +tty-browserify@0.0.1: + version "0.0.1" + resolved "https://registry.yarnpkg.com/tty-browserify/-/tty-browserify-0.0.1.tgz#3f05251ee17904dfd0677546670db9651682b811" + integrity sha512-C3TaO7K81YvjCgQH9Q1S3R3P3BtN3RIM8n+OvX4il1K1zgE8ZhI0op7kClgkxtutIE8hQrcrHBXvIheqKUUCxw== + +tweetnacl@^0.14.3: + version "0.14.5" + resolved "https://registry.yarnpkg.com/tweetnacl/-/tweetnacl-0.14.5.tgz#5ae68177f192d4456269d108afa93ff8743f4f64" + integrity sha512-KXXFFdAbFXY4geFIwoyNK+f5Z1b7swfXABfL7HXCmoIWMKU3dmS26672A4EeQtDzLKy7SXmfBu51JolvEKwtGA== + +type-check@^0.4.0, type-check@~0.4.0: + version "0.4.0" + resolved "https://registry.yarnpkg.com/type-check/-/type-check-0.4.0.tgz#07b8203bfa7056c0657050e3ccd2c37730bab8f1" + integrity sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew== + dependencies: + prelude-ls "^1.2.1" + +type-detect@4.0.8: + version "4.0.8" + resolved "https://registry.yarnpkg.com/type-detect/-/type-detect-4.0.8.tgz#7646fb5f18871cfbb7749e69bd39a6388eb7450c" + integrity sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g== + +type-fest@^0.20.2: + version "0.20.2" + resolved "https://registry.yarnpkg.com/type-fest/-/type-fest-0.20.2.tgz#1bf207f4b28f91583666cb5fbd327887301cd5f4" + integrity sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ== + +type-fest@^0.21.3: + version "0.21.3" + resolved "https://registry.yarnpkg.com/type-fest/-/type-fest-0.21.3.tgz#d260a24b0198436e133fa26a524a6d65fa3b2e37" + integrity sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w== + +type-fest@^0.8.0: + version "0.8.1" + resolved "https://registry.yarnpkg.com/type-fest/-/type-fest-0.8.1.tgz#09e249ebde851d3b1e48d27c105444667f17b83d" + integrity sha512-4dbzIzqvjtgiM5rw1k5rEHtBANKmdudhGyBEajN01fEyhaAIhsoKNy6y7+IN93IfpFtwY9iqi7kD+xwKhQsNJA== + +typed-array-buffer@^1.0.3: + version "1.0.3" + resolved "https://registry.yarnpkg.com/typed-array-buffer/-/typed-array-buffer-1.0.3.tgz#a72395450a4869ec033fd549371b47af3a2ee536" + integrity sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw== + dependencies: + call-bound "^1.0.3" + es-errors "^1.3.0" + is-typed-array "^1.1.14" + +typedarray-to-buffer@^3.1.5: + version "3.1.5" + resolved "https://registry.yarnpkg.com/typedarray-to-buffer/-/typedarray-to-buffer-3.1.5.tgz#a97ee7a9ff42691b9f783ff1bc5112fe3fca9080" + integrity sha512-zdu8XMNEDepKKR+XYOXAVPtWui0ly0NtohUscw+UmaHiAWT8hrV1rr//H6V+0DvJ3OQ19S979M0laLfX8rm82Q== + dependencies: + is-typedarray "^1.0.0" + +typescript@5.8.2: + version "5.8.2" + resolved "https://registry.yarnpkg.com/typescript/-/typescript-5.8.2.tgz#8170b3702f74b79db2e5a96207c15e65807999e4" + integrity sha512-aJn6wq13/afZp/jT9QZmwEjDqqvSGp1VT5GVg+f/t6/oVyrgXM6BY1h9BRh/O5p3PlUPAe+WuiEZOmb/49RqoQ== + +typescript@^5.9.3: + version "5.9.3" + resolved "https://registry.yarnpkg.com/typescript/-/typescript-5.9.3.tgz#5b4f59e15310ab17a216f5d6cf53ee476ede670f" + integrity sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw== + +ufo@^1.6.1: + version "1.6.1" + resolved "https://registry.yarnpkg.com/ufo/-/ufo-1.6.1.tgz#ac2db1d54614d1b22c1d603e3aef44a85d8f146b" + integrity sha512-9a4/uxlTWJ4+a5i0ooc1rU7C7YOw3wT+UGqdeNNHWnOF9qcMBgLRS+4IYUqbczewFx4mLEig6gawh7X6mFlEkA== + +undici-types@~5.26.4: + version "5.26.5" + resolved "https://registry.yarnpkg.com/undici-types/-/undici-types-5.26.5.tgz#bcd539893d00b56e964fd2657a4866b221a65617" + integrity sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA== + +undici-types@~7.16.0: + version "7.16.0" + resolved "https://registry.yarnpkg.com/undici-types/-/undici-types-7.16.0.tgz#ffccdff36aea4884cbfce9a750a0580224f58a46" + integrity sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw== + +undici@^7.16.0: + version "7.16.0" + resolved "https://registry.yarnpkg.com/undici/-/undici-7.16.0.tgz#cb2a1e957726d458b536e3f076bf51f066901c1a" + integrity sha512-QEg3HPMll0o3t2ourKwOeUAZ159Kn9mx5pnzHRQO8+Wixmh88YdZRiIwat0iNzNNXn0yoEtXJqFpyW7eM8BV7g== + +universalify@^2.0.0: + version "2.0.1" + resolved "https://registry.yarnpkg.com/universalify/-/universalify-2.0.1.tgz#168efc2180964e6386d061e094df61afe239b18d" + integrity sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw== + +unplugin@^2.3.5: + version "2.3.11" + resolved "https://registry.yarnpkg.com/unplugin/-/unplugin-2.3.11.tgz#411e020dd2ba90e2fbe1e7bd63a5a399e6ee3b54" + integrity sha512-5uKD0nqiYVzlmCRs01Fhs2BdkEgBS3SAVP6ndrBsuK42iC2+JHyxM05Rm9G8+5mkmRtzMZGY8Ct5+mliZxU/Ww== + dependencies: + "@jridgewell/remapping" "^2.3.5" + acorn "^8.15.0" + picomatch "^4.0.3" + webpack-virtual-modules "^0.6.2" + +unrs-resolver@^1.7.11: + version "1.11.1" + resolved "https://registry.yarnpkg.com/unrs-resolver/-/unrs-resolver-1.11.1.tgz#be9cd8686c99ef53ecb96df2a473c64d304048a9" + integrity sha512-bSjt9pjaEBnNiGgc9rUiHGKv5l4/TGzDmYw3RhnkJGtLhbnnA/5qJj7x3dNDCRx/PJxu774LlH8lCOlB4hEfKg== + dependencies: + napi-postinstall "^0.3.0" + optionalDependencies: + "@unrs/resolver-binding-android-arm-eabi" "1.11.1" + "@unrs/resolver-binding-android-arm64" "1.11.1" + "@unrs/resolver-binding-darwin-arm64" "1.11.1" + "@unrs/resolver-binding-darwin-x64" "1.11.1" + "@unrs/resolver-binding-freebsd-x64" "1.11.1" + "@unrs/resolver-binding-linux-arm-gnueabihf" "1.11.1" + "@unrs/resolver-binding-linux-arm-musleabihf" "1.11.1" + "@unrs/resolver-binding-linux-arm64-gnu" "1.11.1" + "@unrs/resolver-binding-linux-arm64-musl" "1.11.1" + "@unrs/resolver-binding-linux-ppc64-gnu" "1.11.1" + "@unrs/resolver-binding-linux-riscv64-gnu" "1.11.1" + "@unrs/resolver-binding-linux-riscv64-musl" "1.11.1" + "@unrs/resolver-binding-linux-s390x-gnu" "1.11.1" + "@unrs/resolver-binding-linux-x64-gnu" "1.11.1" + "@unrs/resolver-binding-linux-x64-musl" "1.11.1" + "@unrs/resolver-binding-wasm32-wasi" "1.11.1" + "@unrs/resolver-binding-win32-arm64-msvc" "1.11.1" + "@unrs/resolver-binding-win32-ia32-msvc" "1.11.1" + "@unrs/resolver-binding-win32-x64-msvc" "1.11.1" + +update-browserslist-db@^1.2.0: + version "1.2.2" + resolved "https://registry.yarnpkg.com/update-browserslist-db/-/update-browserslist-db-1.2.2.tgz#cfb4358afa08b3d5731a2ecd95eebf4ddef8033e" + integrity sha512-E85pfNzMQ9jpKkA7+TJAi4TJN+tBCuWh5rUcS/sv6cFi+1q9LYDwDI5dpUL0u/73EElyQ8d3TEaeW4sPedBqYA== + dependencies: + escalade "^3.2.0" + picocolors "^1.1.1" + +uri-js@^4.2.2, uri-js@^4.4.1: + version "4.4.1" + resolved "https://registry.yarnpkg.com/uri-js/-/uri-js-4.4.1.tgz#9b1a52595225859e55f669d928f88c6c57f2a77e" + integrity sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg== + dependencies: + punycode "^2.1.0" + +url@^0.11.4: + version "0.11.4" + resolved "https://registry.yarnpkg.com/url/-/url-0.11.4.tgz#adca77b3562d56b72746e76b330b7f27b6721f3c" + integrity sha512-oCwdVC7mTuWiPyjLUz/COz5TLk6wgp0RCsN+wHZ2Ekneac9w8uuV0njcbbie2ME+Vs+d6duwmYuR3HgQXs1fOg== + dependencies: + punycode "^1.4.1" + qs "^6.12.3" + +use-sync-external-store@^1.5.0: + version "1.6.0" + resolved "https://registry.yarnpkg.com/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz#b174bfa65cb2b526732d9f2ac0a408027876f32d" + integrity sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w== + +util-deprecate@^1.0.1, util-deprecate@~1.0.1: + version "1.0.2" + resolved "https://registry.yarnpkg.com/util-deprecate/-/util-deprecate-1.0.2.tgz#450d4dc9fa70de732762fbd2d4a28981419a0ccf" + integrity sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw== + +util@^0.12.4, util@^0.12.5: + version "0.12.5" + resolved "https://registry.yarnpkg.com/util/-/util-0.12.5.tgz#5f17a6059b73db61a875668781a1c2b136bd6fbc" + integrity sha512-kZf/K6hEIrWHI6XqOFUiiMa+79wE/D8Q+NCNAWclkyg3b4d2k7s0QGepNjiABc+aR3N1PAyHL7p6UcLY6LmrnA== + dependencies: + inherits "^2.0.3" + is-arguments "^1.0.4" + is-generator-function "^1.0.7" + is-typed-array "^1.1.3" + which-typed-array "^1.1.2" + +uuid@^10.0.0: + version "10.0.0" + resolved "https://registry.yarnpkg.com/uuid/-/uuid-10.0.0.tgz#5a95aa454e6e002725c79055fd42aaba30ca6294" + integrity sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ== + +uuid@^8.3.2: + version "8.3.2" + resolved "https://registry.yarnpkg.com/uuid/-/uuid-8.3.2.tgz#80d5b5ced271bb9af6c445f21a1a04c606cefbe2" + integrity sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg== + +v8-to-istanbul@^9.0.1: + version "9.3.0" + resolved "https://registry.yarnpkg.com/v8-to-istanbul/-/v8-to-istanbul-9.3.0.tgz#b9572abfa62bd556c16d75fdebc1a411d5ff3175" + integrity sha512-kiGUalWN+rgBJ/1OHZsBtU4rXZOfj/7rKQxULKlIzwzQSvMJUUNgPwJEEh7gU6xEVxC0ahoOBvN2YI8GH6FNgA== + dependencies: + "@jridgewell/trace-mapping" "^0.3.12" + "@types/istanbul-lib-coverage" "^2.0.1" + convert-source-map "^2.0.0" + +vite-plugin-dts@^4.5.4: + version "4.5.4" + resolved "https://registry.yarnpkg.com/vite-plugin-dts/-/vite-plugin-dts-4.5.4.tgz#51b60aaaa760d9cf5c2bb3676c69d81910d6b08c" + integrity sha512-d4sOM8M/8z7vRXHHq/ebbblfaxENjogAAekcfcDCCwAyvGqnPrc7f4NZbvItS+g4WTgerW0xDwSz5qz11JT3vg== + dependencies: + "@microsoft/api-extractor" "^7.50.1" + "@rollup/pluginutils" "^5.1.4" + "@volar/typescript" "^2.4.11" + "@vue/language-core" "2.2.0" + compare-versions "^6.1.1" + debug "^4.4.0" + kolorist "^1.8.0" + local-pkg "^1.0.0" + magic-string "^0.30.17" + +vite-plugin-node-polyfills@^0.24.0: + version "0.24.0" + resolved "https://registry.yarnpkg.com/vite-plugin-node-polyfills/-/vite-plugin-node-polyfills-0.24.0.tgz#4a2e984bba134017fc88cace0149cf8afdb50b54" + integrity sha512-GA9QKLH+vIM8NPaGA+o2t8PDfFUl32J8rUp1zQfMKVJQiNkOX4unE51tR6ppl6iKw5yOrDAdSH7r/UIFLCVhLw== + dependencies: + "@rollup/plugin-inject" "^5.0.5" + node-stdlib-browser "^1.2.0" + +vite@^7.1.9: + version "7.2.7" + resolved "https://registry.yarnpkg.com/vite/-/vite-7.2.7.tgz#0789a4c3206081699f34a9ecca2dda594a07478e" + integrity sha512-ITcnkFeR3+fI8P1wMgItjGrR10170d8auB4EpMLPqmx6uxElH3a/hHGQabSHKdqd4FXWO1nFIp9rRn7JQ34ACQ== + dependencies: + esbuild "^0.25.0" + fdir "^6.5.0" + picomatch "^4.0.3" + postcss "^8.5.6" + rollup "^4.43.0" + tinyglobby "^0.2.15" + optionalDependencies: + fsevents "~2.3.3" + +vm-browserify@^1.0.1: + version "1.1.2" + resolved "https://registry.yarnpkg.com/vm-browserify/-/vm-browserify-1.1.2.tgz#78641c488b8e6ca91a75f511e7a3b32a86e5dda0" + integrity sha512-2ham8XPWTONajOR0ohOKOHXkm3+gaBmGut3SRuu75xLd/RRaY6vqgh8NBYYk7+RW3u5AtzPQZG8F10LHkl0lAQ== + +vscode-uri@^3.0.8: + version "3.1.0" + resolved "https://registry.yarnpkg.com/vscode-uri/-/vscode-uri-3.1.0.tgz#dd09ec5a66a38b5c3fffc774015713496d14e09c" + integrity sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ== + +wait-on@^7.0.0: + version "7.2.0" + resolved "https://registry.yarnpkg.com/wait-on/-/wait-on-7.2.0.tgz#d76b20ed3fc1e2bebc051fae5c1ff93be7892928" + integrity sha512-wCQcHkRazgjG5XoAq9jbTMLpNIjoSlZslrJ2+N9MxDsGEv1HnFoVjOCexL0ESva7Y9cu350j+DWADdk54s4AFQ== + dependencies: + axios "^1.6.1" + joi "^17.11.0" + lodash "^4.17.21" + minimist "^1.2.8" + rxjs "^7.8.1" + +wait-port@^0.2.9: + version "0.2.14" + resolved "https://registry.yarnpkg.com/wait-port/-/wait-port-0.2.14.tgz#6df40629be2c95aa4073ceb895abef7d872b28c6" + integrity sha512-kIzjWcr6ykl7WFbZd0TMae8xovwqcqbx6FM9l+7agOgUByhzdjfzZBPK2CPufldTOMxbUivss//Sh9MFawmPRQ== + dependencies: + chalk "^2.4.2" + commander "^3.0.2" + debug "^4.1.1" + +walk@^2.3.15: + version "2.3.15" + resolved "https://registry.yarnpkg.com/walk/-/walk-2.3.15.tgz#1b4611e959d656426bc521e2da5db3acecae2424" + integrity sha512-4eRTBZljBfIISK1Vnt69Gvr2w/wc3U6Vtrw7qiN5iqYJPH7LElcYh/iU4XWhdCy2dZqv1ToMyYlybDylfG/5Vg== + dependencies: + foreachasync "^3.0.0" + +walker@^1.0.8: + version "1.0.8" + resolved "https://registry.yarnpkg.com/walker/-/walker-1.0.8.tgz#bd498db477afe573dc04185f011d3ab8a8d7653f" + integrity sha512-ts/8E8l5b7kY0vlWLewOkDXMmPdLcVV4GmOQLyxuSswIJsweeFZtAsMF7k1Nszz+TYBQrlYRmzOnr398y1JemQ== + dependencies: + makeerror "1.0.12" + +webpack-virtual-modules@^0.6.2: + version "0.6.2" + resolved "https://registry.yarnpkg.com/webpack-virtual-modules/-/webpack-virtual-modules-0.6.2.tgz#057faa9065c8acf48f24cb57ac0e77739ab9a7e8" + integrity sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ== + +which-module@^2.0.0: + version "2.0.1" + resolved "https://registry.yarnpkg.com/which-module/-/which-module-2.0.1.tgz#776b1fe35d90aebe99e8ac15eb24093389a4a409" + integrity sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ== + +which-typed-array@^1.1.16, which-typed-array@^1.1.2: + version "1.1.19" + resolved "https://registry.yarnpkg.com/which-typed-array/-/which-typed-array-1.1.19.tgz#df03842e870b6b88e117524a4b364b6fc689f956" + integrity sha512-rEvr90Bck4WZt9HHFC4DJMsjvu7x+r6bImz0/BrbWb7A2djJ8hnZMrWnHo9F8ssv0OMErasDhftrfROTyqSDrw== + dependencies: + available-typed-arrays "^1.0.7" + call-bind "^1.0.8" + call-bound "^1.0.4" + for-each "^0.3.5" + get-proto "^1.0.1" + gopd "^1.2.0" + has-tostringtag "^1.0.2" + +which@^1.2.12: + version "1.3.1" + resolved "https://registry.yarnpkg.com/which/-/which-1.3.1.tgz#a45043d54f5805316da8d62f9f50918d3da70b0a" + integrity sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ== + dependencies: + isexe "^2.0.0" + +which@^2.0.1: + version "2.0.2" + resolved "https://registry.yarnpkg.com/which/-/which-2.0.2.tgz#7c6a8dd0a636a0327e10b59c9286eee93f3f51b1" + integrity sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA== + dependencies: + isexe "^2.0.0" + +word-wrap@^1.2.5: + version "1.2.5" + resolved "https://registry.yarnpkg.com/word-wrap/-/word-wrap-1.2.5.tgz#d2c45c6dd4fbce621a66f136cbe328afd0410b34" + integrity sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA== + +"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0": + version "7.0.0" + resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" + integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== + dependencies: + ansi-styles "^4.0.0" + string-width "^4.1.0" + strip-ansi "^6.0.0" + +wrap-ansi@^6.2.0: + version "6.2.0" + resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-6.2.0.tgz#e9393ba07102e6c91a3b221478f0257cd2856e53" + integrity sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA== + dependencies: + ansi-styles "^4.0.0" + string-width "^4.1.0" + strip-ansi "^6.0.0" + +wrap-ansi@^7.0.0: + version "7.0.0" + resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" + integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== + dependencies: + ansi-styles "^4.0.0" + string-width "^4.1.0" + strip-ansi "^6.0.0" + +wrap-ansi@^8.1.0: + version "8.1.0" + resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-8.1.0.tgz#56dc22368ee570face1b49819975d9b9a5ead214" + integrity sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ== + dependencies: + ansi-styles "^6.1.0" + string-width "^5.0.1" + strip-ansi "^7.0.1" + +wrappy@1: + version "1.0.2" + resolved "https://registry.yarnpkg.com/wrappy/-/wrappy-1.0.2.tgz#b5243d8f3ec1aa35f1364605bc0d1036e30ab69f" + integrity sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ== + +write-file-atomic@^3.0.0: + version "3.0.3" + resolved "https://registry.yarnpkg.com/write-file-atomic/-/write-file-atomic-3.0.3.tgz#56bd5c5a5c70481cd19c571bd39ab965a5de56e8" + integrity sha512-AvHcyZ5JnSfq3ioSyjrBkH9yW4m7Ayk8/9My/DD9onKeu/94fwrMocemO2QAJFAlnnDN+ZDS+ZjAR5ua1/PV/Q== + dependencies: + imurmurhash "^0.1.4" + is-typedarray "^1.0.0" + signal-exit "^3.0.2" + typedarray-to-buffer "^3.1.5" + +write-file-atomic@^5.0.1: + version "5.0.1" + resolved "https://registry.yarnpkg.com/write-file-atomic/-/write-file-atomic-5.0.1.tgz#68df4717c55c6fa4281a7860b4c2ba0a6d2b11e7" + integrity sha512-+QU2zd6OTD8XWIJCbffaiQeH9U73qIqafo1x6V1snCWYGJf6cVE0cDR4D8xRzcEnfI21IFrUPzPGtcPf8AC+Rw== + dependencies: + imurmurhash "^0.1.4" + signal-exit "^4.0.1" + +ws@^8.18.0: + version "8.18.3" + resolved "https://registry.yarnpkg.com/ws/-/ws-8.18.3.tgz#b56b88abffde62791c639170400c93dcb0c95472" + integrity sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg== + +wsl-utils@^0.1.0: + version "0.1.0" + resolved "https://registry.yarnpkg.com/wsl-utils/-/wsl-utils-0.1.0.tgz#8783d4df671d4d50365be2ee4c71917a0557baab" + integrity sha512-h3Fbisa2nKGPxCpm89Hk33lBLsnaGBvctQopaBSOW/uIs6FTe1ATyAnKFJrzVs9vpGdsTe73WF3V4lIsk4Gacw== + dependencies: + is-wsl "^3.1.0" + +xml@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/xml/-/xml-1.0.1.tgz#78ba72020029c5bc87b8a81a3cfcd74b4a2fc1e5" + integrity sha512-huCv9IH9Tcf95zuYCsQraZtWnJvBtLVE0QHMOs8bWyZAFZNDcYjsPq1nEx8jKA9y+Beo9v+7OBPRisQTjinQMw== + +xtend@^4.0.2: + version "4.0.2" + resolved "https://registry.yarnpkg.com/xtend/-/xtend-4.0.2.tgz#bb72779f5fa465186b1f438f674fa347fdb5db54" + integrity sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ== + +y18n@^4.0.0: + version "4.0.3" + resolved "https://registry.yarnpkg.com/y18n/-/y18n-4.0.3.tgz#b5f259c82cd6e336921efd7bfd8bf560de9eeedf" + integrity sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ== + +y18n@^5.0.5: + version "5.0.8" + resolved "https://registry.yarnpkg.com/y18n/-/y18n-5.0.8.tgz#7f4934d0f7ca8c56f95314939ddcd2dd91ce1d55" + integrity sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA== + +yallist@^3.0.2: + version "3.1.1" + resolved "https://registry.yarnpkg.com/yallist/-/yallist-3.1.1.tgz#dbb7daf9bfd8bac9ab45ebf602b8cbad0d5d08fd" + integrity sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g== + +yallist@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/yallist/-/yallist-4.0.0.tgz#9bb92790d9c0effec63be73519e11a35019a3a72" + integrity sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A== + +yaml@^2.2.2, yaml@^2.7.0: + version "2.8.2" + resolved "https://registry.yarnpkg.com/yaml/-/yaml-2.8.2.tgz#5694f25eca0ce9c3e7a9d9e00ce0ddabbd9e35c5" + integrity sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A== + +yargs-parser@^18.1.2: + version "18.1.3" + resolved "https://registry.yarnpkg.com/yargs-parser/-/yargs-parser-18.1.3.tgz#be68c4975c6b2abf469236b0c870362fab09a7b0" + integrity sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ== + dependencies: + camelcase "^5.0.0" + decamelize "^1.2.0" + +yargs-parser@^21.1.1: + version "21.1.1" + resolved "https://registry.yarnpkg.com/yargs-parser/-/yargs-parser-21.1.1.tgz#9096bceebf990d21bb31fa9516e0ede294a77d35" + integrity sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw== + +yargs@17.7.2, yargs@^17.7.2: + version "17.7.2" + resolved "https://registry.yarnpkg.com/yargs/-/yargs-17.7.2.tgz#991df39aca675a192b816e1e0363f9d75d2aa269" + integrity sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w== + dependencies: + cliui "^8.0.1" + escalade "^3.1.1" + get-caller-file "^2.0.5" + require-directory "^2.1.1" + string-width "^4.2.3" + y18n "^5.0.5" + yargs-parser "^21.1.1" + +yargs@^15.0.2: + version "15.4.1" + resolved "https://registry.yarnpkg.com/yargs/-/yargs-15.4.1.tgz#0d87a16de01aee9d8bec2bfbf74f67851730f4f8" + integrity sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A== + dependencies: + cliui "^6.0.0" + decamelize "^1.2.0" + find-up "^4.1.0" + get-caller-file "^2.0.1" + require-directory "^2.1.1" + require-main-filename "^2.0.0" + set-blocking "^2.0.0" + string-width "^4.2.0" + which-module "^2.0.0" + y18n "^4.0.0" + yargs-parser "^18.1.2" + +yocto-queue@^0.1.0: + version "0.1.0" + resolved "https://registry.yarnpkg.com/yocto-queue/-/yocto-queue-0.1.0.tgz#0294eb3dee05028d31ee1a5fa2c556a6aaf10a1b" + integrity sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q== + +zip-stream@^6.0.1: + version "6.0.1" + resolved "https://registry.yarnpkg.com/zip-stream/-/zip-stream-6.0.1.tgz#e141b930ed60ccaf5d7fa9c8260e0d1748a2bbfb" + integrity sha512-zK7YHHz4ZXpW89AHXUPbQVGKI7uvkd3hzusTdotCg1UxyaVtg0zFJSTfW/Dq5f7OBBVnq6cZIaC8Ti4hb6dtCA== + dependencies: + archiver-utils "^5.0.0" + compress-commons "^6.0.2" + readable-stream "^4.0.0" diff --git a/patches/@matrix-org+react-sdk-module-api+2.5.0.patch b/patches/@matrix-org+react-sdk-module-api+2.5.0.patch index 5b17244da0..89cbca457f 100644 --- a/patches/@matrix-org+react-sdk-module-api+2.5.0.patch +++ b/patches/@matrix-org+react-sdk-module-api+2.5.0.patch @@ -11,3 +11,42 @@ index 917a7fc..a2710c6 100644 didOkOrSubmit: boolean; model: M; }>; +diff --git a/node_modules/@matrix-org/react-sdk-module-api/lib/lifecycles/CryptoSetupExtensions.js b/node_modules/@matrix-org/react-sdk-module-api/lib/lifecycles/CryptoSetupExtensions.js +index 5d422ed..b823add 100644 +--- a/node_modules/@matrix-org/react-sdk-module-api/lib/lifecycles/CryptoSetupExtensions.js ++++ b/node_modules/@matrix-org/react-sdk-module-api/lib/lifecycles/CryptoSetupExtensions.js +@@ -124,34 +124,28 @@ var DefaultCryptoSetupExtensions = /*#__PURE__*/function (_CryptoSetupExtension) + (0, _createClass2["default"])(DefaultCryptoSetupExtensions, [{ + key: "examineLoginResponse", + value: function examineLoginResponse(response, credentials) { +- console.log("Default empty examineLoginResponse() => void"); + } + }, { + key: "persistCredentials", + value: function persistCredentials(credentials) { +- console.log("Default empty persistCredentials() => void"); + } + }, { + key: "getSecretStorageKey", + value: function getSecretStorageKey() { +- console.log("Default empty getSecretStorageKey() => null"); + return null; + } + }, { + key: "createSecretStorageKey", + value: function createSecretStorageKey() { +- console.log("Default empty createSecretStorageKey() => null"); + return null; + } + }, { + key: "catchAccessSecretStorageError", + value: function catchAccessSecretStorageError(e) { +- console.log("Default catchAccessSecretStorageError() => void"); + } + }, { + key: "setupEncryptionNeeded", + value: function setupEncryptionNeeded(args) { +- console.log("Default setupEncryptionNeeded() => false"); + return false; + } + }, { diff --git a/patches/@types+react+19.1.4.patch b/patches/@types+react+19.1.4.patch deleted file mode 100644 index ceba85b000..0000000000 --- a/patches/@types+react+19.1.4.patch +++ /dev/null @@ -1,31 +0,0 @@ -diff --git a/node_modules/@types/react/index.d.ts b/node_modules/@types/react/index.d.ts -index d3318dc..c2b2c77 100644 ---- a/node_modules/@types/react/index.d.ts -+++ b/node_modules/@types/react/index.d.ts -@@ -134,7 +134,7 @@ declare namespace React { - props: P, - ) => ReactNode | Promise) - // constructor signature must match React.Component -- | (new(props: P) => Component); -+ | (new(props: P, context?: any) => Component); - - /** - * Created by {@link createRef}, or {@link useRef} when passed `null`. -@@ -945,7 +945,7 @@ declare namespace React { - context: unknown; - - // Keep in sync with constructor signature of JSXElementConstructor and ComponentClass. -- constructor(props: P); -+ constructor(props: P, context?: unknown); - - // We MUST keep setState() as a unified signature because it allows proper checking of the method return type. - // See: https://github.com/DefinitelyTyped/DefinitelyTyped/issues/18365#issuecomment-351013257 -@@ -1117,7 +1117,7 @@ declare namespace React { - */ - interface ComponentClass

    extends StaticLifecycle { - // constructor signature must match React.Component -- new(props: P): Component; -+ new(props: P, context?: any): Component; - /** - * Ignored by React. - * @deprecated Only kept in types for backwards compatibility. Will be removed in a future major release. diff --git a/patches/jsdom+26.1.0.patch b/patches/jsdom+26.1.0.patch new file mode 100644 index 0000000000..95922328cc --- /dev/null +++ b/patches/jsdom+26.1.0.patch @@ -0,0 +1,56 @@ +diff --git a/node_modules/jsdom/lib/jsdom/browser/Window.js b/node_modules/jsdom/lib/jsdom/browser/Window.js +index 52d011c..f62f6d6 100644 +--- a/node_modules/jsdom/lib/jsdom/browser/Window.js ++++ b/node_modules/jsdom/lib/jsdom/browser/Window.js +@@ -505,10 +505,10 @@ function installOwnProperties(window, options) { + event: makeReplaceablePropertyDescriptor("event", window), + + // [LegacyUnforgeable]: +- window: { configurable: false }, +- document: { configurable: false }, +- location: { configurable: false }, +- top: { configurable: false } ++ window: { configurable: true }, ++ document: { configurable: true }, ++ location: { configurable: true }, ++ top: { configurable: true } + }); + + +diff --git a/node_modules/jsdom/lib/jsdom/living/generated/Location.js b/node_modules/jsdom/lib/jsdom/living/generated/Location.js +index fc4d1dd..c855bd5 100644 +--- a/node_modules/jsdom/lib/jsdom/living/generated/Location.js ++++ b/node_modules/jsdom/lib/jsdom/living/generated/Location.js +@@ -322,19 +322,19 @@ function getUnforgeables(globalObject) { + } + }); + Object.defineProperties(unforgeables, { +- assign: { configurable: false, writable: false }, +- replace: { configurable: false, writable: false }, +- reload: { configurable: false, writable: false }, +- href: { configurable: false }, +- toString: { configurable: false, writable: false }, +- origin: { configurable: false }, +- protocol: { configurable: false }, +- host: { configurable: false }, +- hostname: { configurable: false }, +- port: { configurable: false }, +- pathname: { configurable: false }, +- search: { configurable: false }, +- hash: { configurable: false } ++ assign: { configurable: true, writable: false }, ++ replace: { configurable: true, writable: false }, ++ reload: { configurable: true, writable: false }, ++ href: { configurable: true }, ++ toString: { configurable: true, writable: false }, ++ origin: { configurable: true }, ++ protocol: { configurable: true }, ++ host: { configurable: true }, ++ hostname: { configurable: true }, ++ port: { configurable: true }, ++ pathname: { configurable: true }, ++ search: { configurable: true }, ++ hash: { configurable: true } + }); + unforgeablesMap.set(globalObject, unforgeables); + } diff --git a/playwright/e2e/accessibility/keyboard-navigation.spec.ts b/playwright/e2e/accessibility/keyboard-navigation.spec.ts index e22664c898..8ba6cc3a92 100644 --- a/playwright/e2e/accessibility/keyboard-navigation.spec.ts +++ b/playwright/e2e/accessibility/keyboard-navigation.spec.ts @@ -29,7 +29,7 @@ test.describe("Landmark navigation tests", () => { // Pressing Control+F6 again will focus room search await page.keyboard.press("ControlOrMeta+F6"); - await expect(page.locator(".mx_RoomSearch")).toBeFocused(); + await expect(page.locator("#room-list-search-button")).toBeFocused(); // Pressing Control+F6 again will focus the message composer await page.keyboard.press("ControlOrMeta+F6"); @@ -44,7 +44,7 @@ test.describe("Landmark navigation tests", () => { await expect(page.locator(".mx_HomePage")).toBeFocused(); await page.keyboard.press("ControlOrMeta+Shift+F6"); - await expect(page.locator(".mx_RoomSearch")).toBeFocused(); + await expect(page.locator("#room-list-search-button")).toBeFocused(); await page.keyboard.press("ControlOrMeta+Shift+F6"); await expect(page.locator(".mx_SpaceButton_active")).toBeFocused(); @@ -75,11 +75,11 @@ test.describe("Landmark navigation tests", () => { // Pressing Control+F6 again will focus room search await page.keyboard.press("ControlOrMeta+F6"); - await expect(page.locator(".mx_RoomSearch")).toBeFocused(); + await expect(page.locator("#room-list-search-button")).toBeFocused(); // Pressing Control+F6 again will focus the room tile in the room list await page.keyboard.press("ControlOrMeta+F6"); - await expect(page.locator(".mx_RoomTile_selected")).toBeFocused(); + await expect(page.locator(".mx_RoomListItemView_selected")).toBeFocused(); // Pressing Control+F6 again will focus the message composer await page.keyboard.press("ControlOrMeta+F6"); @@ -94,10 +94,10 @@ test.describe("Landmark navigation tests", () => { await expect(page.locator(".mx_BasicMessageComposer_input")).toBeFocused(); await page.keyboard.press("ControlOrMeta+Shift+F6"); - await expect(page.locator(".mx_RoomTile_selected")).toBeFocused(); + await expect(page.locator(".mx_RoomListItemView_selected")).toBeFocused(); await page.keyboard.press("ControlOrMeta+Shift+F6"); - await expect(page.locator(".mx_RoomSearch")).toBeFocused(); + await expect(page.locator("#room-list-search-button")).toBeFocused(); await page.keyboard.press("ControlOrMeta+Shift+F6"); await expect(page.locator(".mx_SpaceButton_active")).toBeFocused(); @@ -131,11 +131,11 @@ test.describe("Landmark navigation tests", () => { // Pressing Control+F6 again will focus room search await page.keyboard.press("ControlOrMeta+F6"); - await expect(page.locator(".mx_RoomSearch")).toBeFocused(); + await expect(page.locator("#room-list-search-button")).toBeFocused(); // Pressing Control+F6 again will focus the room tile in the room list await page.keyboard.press("ControlOrMeta+F6"); - await expect(page.locator(".mx_RoomTile")).toBeFocused(); + await expect(page.locator(".mx_RoomListItemView")).toBeFocused(); // Pressing Control+F6 again will focus the home section await page.keyboard.press("ControlOrMeta+F6"); @@ -150,10 +150,10 @@ test.describe("Landmark navigation tests", () => { await expect(page.locator(".mx_HomePage")).toBeFocused(); await page.keyboard.press("ControlOrMeta+Shift+F6"); - await expect(page.locator(".mx_RoomTile")).toBeFocused(); + await expect(page.locator(".mx_RoomListItemView")).toBeFocused(); await page.keyboard.press("ControlOrMeta+Shift+F6"); - await expect(page.locator(".mx_RoomSearch")).toBeFocused(); + await expect(page.locator("#room-list-search-button")).toBeFocused(); await page.keyboard.press("ControlOrMeta+Shift+F6"); await expect(page.locator(".mx_SpaceButton_active")).toBeFocused(); diff --git a/playwright/e2e/audio-player/audio-player.spec.ts b/playwright/e2e/audio-player/audio-player.spec.ts index a8cb15a5da..282440f74e 100644 --- a/playwright/e2e/audio-player/audio-player.spec.ts +++ b/playwright/e2e/audio-player/audio-player.spec.ts @@ -19,6 +19,7 @@ const clickButtonReply = async (tile: Locator) => { await tile.hover(); await tile.getByRole("button", { name: "Reply", exact: true }).click(); }).toPass(); + await expect(tile.page().getByText("Replying", { exact: true })).toBeVisible(); }; test.describe("Audio player", { tag: ["@no-firefox", "@no-webkit"] }, () => { @@ -39,7 +40,7 @@ test.describe("Audio player", { tag: ["@no-firefox", "@no-webkit"] }, () => { // wait for the tile to finish loading await expect( page - .locator(".mx_AudioPlayer_mediaName") + .getByTestId("audio-player-name") .last() .filter({ hasText: file.split("/").at(-1) }), ).toBeVisible(); @@ -48,25 +49,23 @@ test.describe("Audio player", { tag: ["@no-firefox", "@no-webkit"] }, () => { /** * Take snapshots of mx_EventTile_last on each layout, outputting log for reference/debugging. * @param detail The snapshot name. Used for outputting logs too. - * @param monospace This changes the font used to render the UI from a default one to Inconsolata. Set to false by default. + * @param monospace This changes the font used to render the UI from a default one to Fira Code. Set to false by default. */ const takeSnapshots = async (page: Page, app: ElementAppPage, detail: string, monospace = false) => { // Check that the audio player is rendered and its button becomes visible const checkPlayerVisibility = async (locator: Locator) => { // Assert that the audio player and media information are visible - const mediaInfo = locator.locator( - ".mx_EventTile_mediaLine .mx_MAudioBody .mx_AudioPlayer_container .mx_AudioPlayer_mediaInfo", - ); - await expect(mediaInfo.locator(".mx_AudioPlayer_mediaName", { hasText: ".ogg" })).toBeVisible(); // extension - await expect(mediaInfo.locator(".mx_AudioPlayer_byline", { hasText: "00:01" })).toBeVisible(); - await expect(mediaInfo.locator(".mx_AudioPlayer_byline", { hasText: "(3.56 KB)" })).toBeVisible(); // actual size + const mediaInfo = locator.getByRole("region", { name: "Audio player" }); + await expect(mediaInfo.getByText(".ogg")).toBeVisible(); // extension + await expect(mediaInfo.getByRole("time")).toHaveText("00:01"); // duration + await expect(mediaInfo.getByText("(3.56 KB)")).toBeVisible(); // actual size; // Assert that the play button can be found and is visible await expect(locator.getByRole("button", { name: "Play" })).toBeVisible(); if (monospace) { // Assert that the monospace timer is visible - await expect(locator.locator("[role='timer']")).toHaveCSS("font-family", "Inconsolata"); + await expect(locator.locator("[role='timer']")).toHaveCSS("font-family", '"Fira Code"'); } }; @@ -74,11 +73,11 @@ test.describe("Audio player", { tag: ["@no-firefox", "@no-webkit"] }, () => { // Enable system font and monospace setting await app.settings.setValue("useBundledEmojiFont", null, SettingLevel.DEVICE, false); await app.settings.setValue("useSystemFont", null, SettingLevel.DEVICE, true); - await app.settings.setValue("systemFont", null, SettingLevel.DEVICE, "Inconsolata"); + await app.settings.setValue("systemFont", null, SettingLevel.DEVICE, "Fira Code"); } // Check the status of the seek bar - expect(await page.locator(".mx_AudioPlayer_seek input[type='range']").count()).toBeGreaterThan(0); + expect(await page.getByRole("region", { name: "Audio player" }).getByRole("slider").count()).toBeGreaterThan(0); // Enable IRC layout await app.settings.setValue("layout", null, SettingLevel.DEVICE, Layout.IRC); @@ -93,14 +92,14 @@ test.describe("Audio player", { tag: ["@no-firefox", "@no-webkit"] }, () => { css: ` /* The timestamp is of inconsistent width depending on the time the test runs at */ .mx_MessageTimestamp { - display: none !important; + visibility: hidden; } /* The MAB showing up on hover is not needed for the test */ .mx_MessageActionBar { display: none !important; } `, - mask: [page.locator(".mx_AudioPlayer_seek")], + mask: [page.getByTestId("audio-player-seek")], }; // Take a snapshot of mx_EventTile_last on IRC layout @@ -186,9 +185,9 @@ test.describe("Audio player", { tag: ["@no-firefox", "@no-webkit"] }, () => { await uploadFile(page, "playwright/sample-files/1sec.ogg"); // Assert that the audio player is rendered - const container = page.locator(".mx_EventTile_last .mx_AudioPlayer_container"); + const container = page.locator(".mx_EventTile_last").getByRole("region", { name: "Audio player" }); // Assert that the counter is zero before clicking the play button - await expect(container.locator(".mx_AudioPlayer_seek [role='timer']", { hasText: "00:00" })).toBeVisible(); + await expect(container.getByRole("timer")).toHaveText("00:00"); // Find and click "Play" button, the wait is to make the test less flaky await expect(container.getByRole("button", { name: "Play" })).toBeVisible(); @@ -198,7 +197,7 @@ test.describe("Audio player", { tag: ["@no-firefox", "@no-webkit"] }, () => { await expect(container.getByRole("button", { name: "Pause" })).toBeVisible(); // Assert that the timer is reset when the audio file finished playing - await expect(container.locator(".mx_AudioPlayer_seek [role='timer']", { hasText: "00:00" })).toBeVisible(); + await expect(container.getByRole("timer")).toHaveText("00:00"); // Assert that "Play" button can be found await expect(container.getByRole("button", { name: "Play" })).toBeVisible(); @@ -226,7 +225,7 @@ test.describe("Audio player", { tag: ["@no-firefox", "@no-webkit"] }, () => { await uploadFile(page, "playwright/sample-files/1sec.ogg"); // Assert the audio player is rendered - await expect(page.locator(".mx_EventTile_last .mx_AudioPlayer_container")).toBeVisible(); + await expect(page.getByRole("region", { name: "Audio player" })).toBeVisible(); // Find and click "Reply" button on MessageActionBar const tile = page.locator(".mx_EventTile_last"); @@ -236,7 +235,7 @@ test.describe("Audio player", { tag: ["@no-firefox", "@no-webkit"] }, () => { await uploadFile(page, "playwright/sample-files/1sec.ogg"); // Assert that the audio player is rendered - await expect(tile.locator(".mx_AudioPlayer_container")).toBeVisible(); + await expect(tile.getByRole("region", { name: "Audio player" })).toBeVisible(); // Assert that replied audio file is rendered as file button inside ReplyChain const button = tile.locator(".mx_ReplyChain_wrapper .mx_MFileBody_info[role='button']"); @@ -261,7 +260,9 @@ test.describe("Audio player", { tag: ["@no-firefox", "@no-webkit"] }, () => { await uploadFile(page, "playwright/sample-files/upload-first.ogg"); // Assert that the audio player is rendered - await expect(page.locator(".mx_EventTile_last .mx_AudioPlayer_container")).toBeVisible(); + await expect( + page.locator(".mx_EventTile_last").getByRole("region", { name: "Audio player" }), + ).toBeVisible(); await clickButtonReply(tile); @@ -269,7 +270,9 @@ test.describe("Audio player", { tag: ["@no-firefox", "@no-webkit"] }, () => { await uploadFile(page, "playwright/sample-files/upload-second.ogg"); // Assert that the audio player is rendered - await expect(page.locator(".mx_EventTile_last .mx_AudioPlayer_container")).toBeVisible(); + await expect( + page.locator(".mx_EventTile_last").getByRole("region", { name: "Audio player" }), + ).toBeVisible(); await clickButtonReply(tile); @@ -277,7 +280,7 @@ test.describe("Audio player", { tag: ["@no-firefox", "@no-webkit"] }, () => { await uploadFile(page, "playwright/sample-files/upload-third.ogg"); // Assert that the audio player is rendered - await expect(tile.locator(".mx_AudioPlayer_container")).toBeVisible(); + await expect(tile.getByRole("region", { name: "Audio player" })).toBeVisible(); // Assert that there are two "mx_ReplyChain" elements await expect(tile.locator(".mx_ReplyChain")).toHaveCount(2); @@ -313,7 +316,9 @@ test.describe("Audio player", { tag: ["@no-firefox", "@no-webkit"] }, () => { // On the main timeline const messageList = page.locator(".mx_RoomView_MessageList"); // Assert the audio player is rendered - await expect(messageList.locator(".mx_EventTile_last .mx_AudioPlayer_container")).toBeVisible(); + await expect( + messageList.locator(".mx_EventTile_last").getByRole("region", { name: "Audio player" }), + ).toBeVisible(); // Find and click "Reply in thread" button await messageList.locator(".mx_EventTile_last").hover(); await messageList.locator(".mx_EventTile_last").getByRole("button", { name: "Reply in thread" }).click(); @@ -321,10 +326,10 @@ test.describe("Audio player", { tag: ["@no-firefox", "@no-webkit"] }, () => { // On a thread const thread = page.locator(".mx_ThreadView"); const threadTile = thread.locator(".mx_EventTile_last"); - const audioPlayer = threadTile.locator(".mx_AudioPlayer_container"); + const audioPlayer = threadTile.getByRole("region", { name: "Audio player" }); // Assert that the counter is zero before clicking the play button - await expect(audioPlayer.locator(".mx_AudioPlayer_seek [role='timer']", { hasText: "00:00" })).toBeVisible(); + await expect(audioPlayer.getByRole("timer")).toHaveText("00:00"); // Find and click "Play" button, the wait is to make the test less flaky await expect(audioPlayer.getByRole("button", { name: "Play" })).toBeVisible(); @@ -334,7 +339,7 @@ test.describe("Audio player", { tag: ["@no-firefox", "@no-webkit"] }, () => { await expect(audioPlayer.getByRole("button", { name: "Pause" })).toBeVisible(); // Assert that the timer is reset when the audio file finished playing - await expect(audioPlayer.locator(".mx_AudioPlayer_seek [role='timer']", { hasText: "00:00" })).toBeVisible(); + await expect(audioPlayer.getByRole("timer")).toHaveText("00:00"); // Assert that "Play" button can be found await expect(audioPlayer.getByRole("button", { name: "Play" })).not.toBeDisabled(); diff --git a/playwright/e2e/chat-export/html-export.spec.ts b/playwright/e2e/chat-export/html-export.spec.ts index 760d3cc5f1..371111151e 100644 --- a/playwright/e2e/chat-export/html-export.spec.ts +++ b/playwright/e2e/chat-export/html-export.spec.ts @@ -124,11 +124,12 @@ test.describe("HTML Export", () => { const zip = await extractZipFileToPath(zipPath, dirPath); await page.goto(`file://${dirPath}/${Object.keys(zip.files)[0]}/messages.html`); await expect(page).toMatchScreenshot("html-export.png", { - mask: [ - // We need to mask the whole thing because the width of the time part changes - page.locator(".mx_TimelineSeparator"), - page.locator(".mx_MessageTimestamp"), - ], + mask: [page.locator(".mx_TimelineSeparator")], + css: ` + .mx_MessageTimestamp { + visibility: hidden; + } + `, }); }, ); diff --git a/playwright/e2e/composer/CIDER.spec.ts b/playwright/e2e/composer/CIDER.spec.ts index 03fc59cd0f..55cb84f37a 100644 --- a/playwright/e2e/composer/CIDER.spec.ts +++ b/playwright/e2e/composer/CIDER.spec.ts @@ -14,6 +14,9 @@ const CtrlOrMeta = process.platform === "darwin" ? "Meta" : "Control"; test.describe("Composer", () => { test.use({ displayName: "Janet", + botCreateOpts: { + displayName: "Bob", + }, }); test.use({ @@ -28,7 +31,7 @@ test.describe("Composer", () => { test.describe("CIDER", () => { test("sends a message when you click send or press Enter", async ({ page }) => { - const composer = page.getByRole("textbox", { name: "Send a message…" }); + const composer = page.getByRole("textbox", { name: "Send an unencrypted message…" }); // Type a message await composer.pressSequentially("my message 0"); @@ -52,7 +55,7 @@ test.describe("Composer", () => { }); test("can write formatted text", async ({ page }) => { - const composer = page.getByRole("textbox", { name: "Send a message…" }); + const composer = page.getByRole("textbox", { name: "Send an unencrypted message…" }); await composer.pressSequentially("my bold"); await composer.press(`${CtrlOrMeta}+KeyB`); @@ -68,18 +71,69 @@ test.describe("Composer", () => { await page.getByTestId("mx_EmojiPicker").locator(".mx_EmojiPicker_item", { hasText: "😇" }).click(); await page.locator(".mx_ContextualMenu_background").click(); // Close emoji picker - await page.getByRole("textbox", { name: "Send a message…" }).press("Enter"); // Send message + await page.getByRole("textbox", { name: "Send an unencrypted message…" }).press("Enter"); // Send message await expect(page.locator(".mx_EventTile_body", { hasText: "😇" })).toBeVisible(); }); + test.describe("render emoji picker with larger viewport height", async () => { + test.use({ viewport: { width: 1280, height: 720 } }); + test("render emoji picker", { tag: "@screenshot" }, async ({ page, app }) => { + await app.getComposer(false).getByRole("button", { name: "Emoji" }).click(); + await expect(page.getByTestId("mx_EmojiPicker")).toMatchScreenshot("emoji-picker.png"); + }); + }); + + test.describe("render emoji picker with small viewport height", async () => { + test.use({ viewport: { width: 1280, height: 360 } }); + test("render emoji picker", { tag: "@screenshot" }, async ({ page, app }) => { + await app.getComposer(false).getByRole("button", { name: "Emoji" }).click(); + await expect(page.getByTestId("mx_EmojiPicker")).toMatchScreenshot("emoji-picker-small.png"); + }); + }); + + test("should have focus lock in emoji picker", async ({ page, app }) => { + const emojiButton = app.getComposer(false).getByRole("button", { name: "Emoji" }); + + // Open emoji picker by clicking the button + await emojiButton.click(); + + // Wait for emoji picker to be visible + const emojiPicker = page.getByTestId("mx_EmojiPicker"); + await expect(emojiPicker).toBeVisible(); + + // Get initial focused element (should be search input) + const searchInput = emojiPicker.getByRole("textbox", { name: "Search" }); + await expect(searchInput).toBeFocused(); + + // Try to tab multiple times - focus should stay within emoji picker + await page.keyboard.press("Tab"); + await page.keyboard.press("Tab"); + await page.keyboard.press("Tab"); + await page.keyboard.press("Tab"); + await page.keyboard.press("Tab"); + + // Verify we're still within the emoji picker (not back to composer) + const focusedElement = await page.evaluate(() => document.activeElement?.closest(".mx_EmojiPicker")); + expect(focusedElement).not.toBeNull(); + + // Close with Escape key + await page.keyboard.press("Escape"); + + // Verify emoji picker is closed + await expect(emojiPicker).not.toBeVisible(); + + // Verify focus returns to emoji button + await expect(emojiButton).toBeFocused(); + }); + test.describe("when Control+Enter is required to send", () => { test.beforeEach(async ({ app }) => { await app.settings.setValue("MessageComposerInput.ctrlEnterToSend", null, SettingLevel.ACCOUNT, true); }); test("only sends when you press Control+Enter", async ({ page }) => { - const composer = page.getByRole("textbox", { name: "Send a message…" }); + const composer = page.getByRole("textbox", { name: "Send an unencrypted message…" }); // Type a message and press Enter await composer.pressSequentially("my message 3"); await composer.press("Enter"); @@ -94,5 +148,39 @@ test.describe("Composer", () => { ).toBeVisible(); }); }); + + test("can send mention", { tag: "@screenshot" }, async ({ page, bot, app }) => { + // Set up a private room so we have another user to mention + await app.client.createRoom({ + is_direct: true, + invite: [bot.credentials.userId], + }); + await app.viewRoomByName("Bob"); + + const composer = page.getByRole("textbox", { name: "Send an unencrypted message…" }); + await composer.pressSequentially("@bob"); + + // Note that we include the user ID here as the room tile is also an 'option' role + // with text 'Bob' + await page.getByRole("option", { name: `Bob ${bot.credentials.userId}` }).click(); + await expect(composer.getByText("Bob")).toBeVisible(); + await expect(composer).toMatchScreenshot("mention.png"); + await composer.press("Enter"); + await expect(page.locator(".mx_EventTile_body", { hasText: "Bob" })).toBeVisible(); + }); + + test("renders emoji autocomplete", { tag: "@screenshot" }, async ({ page }) => { + const composer = page.getByRole("textbox", { name: "Send an unencrypted message…" }); + + // Type ":+1" to trigger emoji autocomplete + await composer.pressSequentially(":+1"); + + // Wait for autocomplete to appear + const autocomplete = page.locator("#mx_Autocomplete"); + await expect(autocomplete).toBeVisible(); + + // Take a screenshot of the autocomplete + await expect(autocomplete).toMatchScreenshot("emoji-autocomplete.png"); + }); }); }); diff --git a/playwright/e2e/create-room/create-room.spec.ts b/playwright/e2e/create-room/create-room.spec.ts deleted file mode 100644 index 087a89e68d..0000000000 --- a/playwright/e2e/create-room/create-room.spec.ts +++ /dev/null @@ -1,34 +0,0 @@ -/* -Copyright 2024 New Vector Ltd. -Copyright 2022, 2023 The Matrix.org Foundation C.I.C. - -SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial -Please see LICENSE files in the repository root for full details. -*/ - -import { test, expect } from "../../element-web-test"; - -test.describe("Create Room", () => { - test.use({ displayName: "Jim" }); - - test("should allow us to create a public room with name, topic & address set", async ({ page, user, app }) => { - const name = "Test room 1"; - const topic = "This room is dedicated to this test and this test only!"; - - const dialog = await app.openCreateRoomDialog(); - // Fill name & topic - await dialog.getByRole("textbox", { name: "Name" }).fill(name); - await dialog.getByRole("textbox", { name: "Topic" }).fill(topic); - // Change room to public - await dialog.getByRole("button", { name: "Room visibility" }).click(); - await dialog.getByRole("option", { name: "Public room" }).click(); - // Fill room address - await dialog.getByRole("textbox", { name: "Room address" }).fill("test-room-1"); - // Submit - await dialog.getByRole("button", { name: "Create room" }).click(); - - await expect(page).toHaveURL(new RegExp(`/#/room/#test-room-1:${user.homeServer}`)); - const header = page.locator(".mx_RoomHeader"); - await expect(header).toContainText(name); - }); -}); diff --git a/playwright/e2e/crypto/backups-mas.spec.ts b/playwright/e2e/crypto/backups-mas.spec.ts index 6519a484e9..f048bc6a2b 100644 --- a/playwright/e2e/crypto/backups-mas.spec.ts +++ b/playwright/e2e/crypto/backups-mas.spec.ts @@ -23,7 +23,13 @@ test.describe("Encryption state after registration", () => { test("Key backup is enabled by default", async ({ page, mailpitClient, app }, testInfo) => { await page.goto("/#/login"); await page.getByRole("button", { name: "Continue" }).click(); - await registerAccountMas(page, mailpitClient, `alice_${testInfo.testId}`, "alice@email.com", "Pa$sW0rD!"); + await registerAccountMas( + page, + mailpitClient, + `alice_${testInfo.testId}`, + `alice_${testInfo.testId}@email.com`, + "Pa$sW0rD!", + ); // Wait for the ui to load await expect(page.locator(".mx_MatrixChat")).toBeVisible(); @@ -35,9 +41,18 @@ test.describe("Encryption state after registration", () => { test("user is prompted to set up recovery", async ({ page, mailpitClient, app }, testInfo) => { await page.goto("/#/login"); await page.getByRole("button", { name: "Continue" }).click(); - await registerAccountMas(page, mailpitClient, `alice_${testInfo.testId}`, "alice@email.com", "Pa$sW0rD!"); + await registerAccountMas( + page, + mailpitClient, + `alice_${testInfo.testId}`, + `alice_${testInfo.testId}@email.com`, + "Pa$sW0rD!", + ); - await page.getByRole("button", { name: "Add room" }).click(); + await page + .getByRole("navigation", { name: "Room list" }) + .getByRole("button", { name: "New conversation" }) + .click(); await page.getByRole("menuitem", { name: "New room" }).click(); await page.getByRole("textbox", { name: "Name" }).fill("test room"); await page.getByRole("button", { name: "Create room" }).click(); @@ -64,9 +79,12 @@ test.describe("Key backup reset from elsewhere", () => { await page.goto("/#/login"); await page.getByRole("button", { name: "Continue" }).click(); - await registerAccountMas(page, mailpitClient, testUsername, "alice@email.com", testPassword); + await registerAccountMas(page, mailpitClient, testUsername, `${testUsername}@email.com`, testPassword); - await page.getByRole("button", { name: "Add room" }).click(); + await page + .getByRole("navigation", { name: "Room list" }) + .getByRole("button", { name: "New conversation" }) + .click(); await page.getByRole("menuitem", { name: "New room" }).click(); await page.getByRole("textbox", { name: "Name" }).fill("test room"); await page.getByRole("button", { name: "Create room" }).click(); @@ -79,10 +97,10 @@ test.describe("Key backup reset from elsewhere", () => { await csAPI.deleteBackupVersion(backupInfo.version); - await page.getByRole("textbox", { name: "Send an encrypted message…" }).fill("/discardsession"); + await page.getByRole("textbox", { name: "Send a message…" }).fill("/discardsession"); await page.getByRole("button", { name: "Send message" }).click(); - await page.getByRole("textbox", { name: "Send an encrypted message…" }).fill("Message with broken key backup"); + await page.getByRole("textbox", { name: "Send a message…" }).fill("Message with broken key backup"); await page.getByRole("button", { name: "Send message" }).click(); // Should be the message we sent plus the room creation event diff --git a/playwright/e2e/crypto/crypto.spec.ts b/playwright/e2e/crypto/crypto.spec.ts index bad9072f0c..ae4db1b0c3 100644 --- a/playwright/e2e/crypto/crypto.spec.ts +++ b/playwright/e2e/crypto/crypto.spec.ts @@ -21,12 +21,11 @@ const checkDMRoom = async (page: Page) => { }; const startDMWithBob = async (page: Page, bob: Bot) => { - await page.locator(".mx_LegacyRoomList").getByRole("button", { name: "Start chat" }).click(); + await page.getByRole("navigation", { name: "Room list" }).getByRole("button", { name: "New conversation" }).click(); + await page.getByRole("menuitem", { name: "Start chat" }).click(); await page.getByTestId("invite-dialog-input").fill(bob.credentials.userId); - await page.locator(".mx_InviteDialog_tile_nameStack_name").getByText("Bob").click(); - await expect( - page.locator(".mx_InviteDialog_userTile_pill .mx_InviteDialog_userTile_name").getByText("Bob"), - ).toBeVisible(); + await page.getByRole("option", { name: bob.credentials.displayName }).click(); + await expect(page.getByTestId("invite-dialog-input-wrapper").getByText("Bob")).toBeVisible(); await page.getByRole("button", { name: "Go" }).click(); }; @@ -147,6 +146,29 @@ test.describe("Cryptography", function () { }).toPass(); }); + // When the user resets their identity, key storage also gets enabled. + // Check that the toggle updates to show the correct state. + test("Key backup status updates after resetting identity", async ({ page, app, user: aliceCredentials }) => { + await app.client.bootstrapCrossSigning(aliceCredentials); + + const encryptionTab = await app.settings.openUserSettings("Encryption"); + const keyStorageToggle = encryptionTab.getByRole("switch", { name: "Allow key storage" }); + // Check that key storage starts off as disabled + expect(await keyStorageToggle.isChecked()).toBe(false); + // Find "the Reset cryptographic identity" button + await encryptionTab.getByRole("button", { name: "Reset cryptographic identity" }).click(); + + // Confirm + await encryptionTab.getByRole("button", { name: "Continue" }).click(); + + // Enter the password + await page.getByPlaceholder("Password").fill(aliceCredentials.password); + await page.getByRole("button", { name: "Continue" }).click(); + + // Key storage should now be enabled + expect(await keyStorageToggle.isChecked()).toBe(true); + }); + test( "creating a DM should work, being e2e-encrypted / user verification", { tag: "@screenshot" }, @@ -158,6 +180,9 @@ test.describe("Cryptography", function () { await page.getByRole("textbox", { name: "Send a message…" }).press("Enter"); await checkDMRoom(page); const bobRoomId = await bobJoin(page, bob); + // We no longer show the grey badge in the composer, check that it is not there. + await expect(page.locator(".mx_MessageComposer_e2eIcon")).toHaveCount(0); + await testMessages(page, bob, bobRoomId); await verify(app, bob); @@ -168,6 +193,7 @@ test.describe("Cryptography", function () { // Take a snapshot of RoomSummaryCard with a verified E2EE icon await expect(page.locator(".mx_RightPanel")).toMatchScreenshot("RoomSummaryCard-with-verified-e2ee.png"); + await expect(page.locator(".mx_MessageComposer_e2eIcon")).toMatchScreenshot("composer-e2e-icon.png"); }, ); diff --git a/playwright/e2e/crypto/decryption-failure-messages.spec.ts b/playwright/e2e/crypto/decryption-failure-messages.spec.ts index 529251b223..306e073c00 100644 --- a/playwright/e2e/crypto/decryption-failure-messages.spec.ts +++ b/playwright/e2e/crypto/decryption-failure-messages.spec.ts @@ -143,10 +143,7 @@ test.describe("Cryptography", function () { ); // Alice accepts the invite - await expect( - page.getByRole("group", { name: "Invites" }).locator(".mx_RoomSublist_tiles").getByRole("treeitem"), - ).toHaveCount(1); - await page.getByRole("treeitem", { name: "Test room" }).click(); + await page.getByRole("option", { name: "Test room" }).click(); await page.locator(".mx_RoomView").getByRole("button", { name: "Accept" }).click(); // Bob sends an encrypted event and an undecryptable event @@ -280,10 +277,7 @@ test.describe("Cryptography", function () { ); // Alice accepts the invite - await expect( - page.getByRole("group", { name: "Invites" }).locator(".mx_RoomSublist_tiles").getByRole("treeitem"), - ).toHaveCount(1); - await page.getByRole("treeitem", { name: "Test room" }).click(); + await page.getByRole("option", { name: "Test room" }).click(); await page.locator(".mx_RoomView").getByRole("button", { name: "Accept" }).click(); // wait until we're joined and see the timeline diff --git a/playwright/e2e/crypto/dehydration.spec.ts b/playwright/e2e/crypto/dehydration.spec.ts index 379fc36cf9..4483593f1c 100644 --- a/playwright/e2e/crypto/dehydration.spec.ts +++ b/playwright/e2e/crypto/dehydration.spec.ts @@ -38,7 +38,7 @@ test.describe("Dehydration", () => { // Reset the identity key const settings = await app.settings.openUserSettings("Encryption"); await settings.getByRole("button", { name: "Verify this device" }).click(); - await page.getByRole("button", { name: "Proceed with reset" }).click(); + await page.getByRole("button", { name: "Can't confirm?" }).click(); await page.getByRole("button", { name: "Continue" }).click(); // Set up recovery @@ -106,7 +106,7 @@ test.describe("Dehydration", () => { await logIntoElement(page, credentials); // Oh no, we forgot our recovery key - reset our identity - await page.locator(".mx_AuthPage").getByRole("button", { name: "Reset all" }).click(); + await page.locator(".mx_AuthPage").getByRole("button", { name: "Can't confirm" }).click(); await expect( page.getByRole("heading", { name: "Are you sure you want to reset your identity?" }), ).toBeVisible(); diff --git a/playwright/e2e/crypto/device-verification.spec.ts b/playwright/e2e/crypto/device-verification.spec.ts index 64846ac86d..eb43d4dc78 100644 --- a/playwright/e2e/crypto/device-verification.spec.ts +++ b/playwright/e2e/crypto/device-verification.spec.ts @@ -22,6 +22,7 @@ import { } from "./utils"; import { type Bot } from "../../pages/bot"; import { Toasts } from "../../pages/toasts.ts"; +import type { ElementAppPage } from "../../pages/ElementAppPage.ts"; test.describe("Device verification", { tag: "@no-webkit" }, () => { let aliceBotClient: Bot; @@ -35,43 +36,50 @@ test.describe("Device verification", { tag: "@no-webkit" }, () => { expectedBackupVersion = res.expectedBackupVersion; }); - // Click the "Verify with another device" button, and have the bot client auto-accept it. + // Click the "Use another device" button, and have the bot client auto-accept it. async function initiateAliceVerificationRequest(page: Page): Promise> { // alice bot waits for verification request const promiseVerificationRequest = waitForVerificationRequest(aliceBotClient); - // Click on "Verify with another device" - await page.locator(".mx_AuthPage").getByRole("button", { name: "Verify with another device" }).click(); + // Click on "Use another device" + await page.locator(".mx_AuthPage").getByRole("button", { name: "Use another device" }).click(); // alice bot responds yes to verification request from alice return promiseVerificationRequest; } - test("Verify device with SAS during login", async ({ page, app, credentials, homeserver }) => { - await logIntoElement(page, credentials); + test( + "Verify device with SAS during login", + { tag: "@screenshot" }, + async ({ page, app, credentials, homeserver }) => { + await logIntoElement(page, credentials); - // Launch the verification request between alice and the bot - const verificationRequest = await initiateAliceVerificationRequest(page); + // Launch the verification request between alice and the bot + const verificationRequest = await initiateAliceVerificationRequest(page); - // Handle emoji SAS verification - const infoDialog = page.locator(".mx_InfoDialog"); - // the bot chooses to do an emoji verification - const verifier = await verificationRequest.evaluateHandle((request) => request.startVerification("m.sas.v1")); + // Handle emoji SAS verification + const infoDialog = page.locator(".mx_InfoDialog"); + // the bot chooses to do an emoji verification + const verifier = await verificationRequest.evaluateHandle((request) => + request.startVerification("m.sas.v1"), + ); - // Handle emoji request and check that emojis are matching - await doTwoWaySasVerification(page, verifier); + // Handle emoji request and check that emojis are matching + await doTwoWaySasVerification(page, verifier); - await infoDialog.getByRole("button", { name: "They match" }).click(); - await infoDialog.getByRole("button", { name: "Got it" }).click(); + await infoDialog.getByRole("button", { name: "They match" }).click(); + await expect(page.locator(".mx_E2EIcon_verified")).toMatchScreenshot("device-verified-e2eIcon.png"); + await infoDialog.getByRole("button", { name: "Got it" }).click(); - // Check that our device is now cross-signed - await checkDeviceIsCrossSigned(app); + // Check that our device is now cross-signed + await checkDeviceIsCrossSigned(app); - // Check that the current device is connected to key backup - // For now we don't check that the backup key is in cache because it's a bit flaky, - // as we need to wait for the secret gossiping to happen. - await checkDeviceIsConnectedKeyBackup(app, expectedBackupVersion, false); - }); + // Check that the current device is connected to key backup + // For now we don't check that the backup key is in cache because it's a bit flaky, + // as we need to wait for the secret gossiping to happen. + await checkDeviceIsConnectedKeyBackup(app, expectedBackupVersion, false); + }, + ); // Regression test for https://github.com/element-hq/element-web/issues/29110 test("No toast after verification, even if the secrets take a while to arrive", async ({ page, credentials }) => { @@ -116,85 +124,143 @@ test.describe("Device verification", { tag: "@no-webkit" }, () => { const toasts = new Toasts(page); await toasts.rejectToast("Notifications"); await toasts.assertNoToasts(); + + // There may still be a `/sendToDevice/m.secret.request` in flight, which will later throw an error and cause + // a *subsequent* test to fail. Tell playwright to ignore any errors resulting from in-flight routes. + await page.unrouteAll({ behavior: "ignoreErrors" }); }); - test("Verify device with QR code during login", async ({ page, app, credentials, homeserver }) => { - // A mode 0x02 verification: "self-verifying in which the current device does not yet trust the master key" - await logIntoElement(page, credentials); + test( + "Verify device with QR code during login", + { tag: "@screenshot" }, + async ({ page, app, credentials, homeserver }) => { + // A mode 0x02 verification: "self-verifying in which the current device does not yet trust the master key" + await logIntoElement(page, credentials); - // Launch the verification request between alice and the bot - const verificationRequest = await initiateAliceVerificationRequest(page); + // Launch the verification request between alice and the bot + const verificationRequest = await initiateAliceVerificationRequest(page); - const infoDialog = page.locator(".mx_InfoDialog"); - // feed the QR code into the verification request. - const qrData = await readQrCode(infoDialog); - const verifier = await verificationRequest.evaluateHandle( - (request, qrData) => request.scanQRCode(new Uint8ClampedArray(qrData)), - [...qrData], - ); + const infoDialog = page.locator(".mx_InfoDialog"); + // feed the QR code into the verification request. + const qrData = await readQrCode(infoDialog); + await expect(page.locator(".mx_Dialog")).toMatchScreenshot("qr-code.png", { + mask: [infoDialog.locator("img")], + }); + const verifier = await verificationRequest.evaluateHandle( + (request, qrData) => request.scanQRCode(new Uint8ClampedArray(qrData)), + [...qrData], + ); - // Confirm that the bot user scanned successfully - await expect(infoDialog.getByText("Almost there! Is your other device showing the same shield?")).toBeVisible(); - await infoDialog.getByRole("button", { name: "Yes" }).click(); - await infoDialog.getByRole("button", { name: "Got it" }).click(); + // Confirm that the bot user scanned successfully + await expect( + infoDialog.getByText("Confirm that you see a green shield on your other device"), + ).toBeVisible(); + await expect(page.locator(".mx_Dialog")).toMatchScreenshot("confirm-green-shield.png"); + await infoDialog.getByRole("button", { name: "Yes, I see a green shield" }).click(); + await expect(page.locator(".mx_Dialog")).toMatchScreenshot("got-it.png"); + await infoDialog.getByRole("button", { name: "Got it" }).click(); - // wait for the bot to see we have finished - await verifier.evaluate((verifier) => verifier.verify()); + // wait for the bot to see we have finished + await verifier.evaluate((verifier) => verifier.verify()); - // the bot uploads the signatures asynchronously, so wait for that to happen - await page.waitForTimeout(1000); + // the bot uploads the signatures asynchronously, so wait for that to happen + await page.waitForTimeout(1000); - // our device should trust the bot device - await app.client.evaluate(async (cli, aliceBotCredentials) => { - const deviceStatus = await cli - .getCrypto()! - .getDeviceVerificationStatus(aliceBotCredentials.userId, aliceBotCredentials.deviceId); - if (!deviceStatus.isVerified()) { - throw new Error("Bot device was not verified after QR code verification"); - } - }, aliceBotClient.credentials); + // our device should trust the bot device + await app.client.evaluate(async (cli, aliceBotCredentials) => { + const deviceStatus = await cli + .getCrypto()! + .getDeviceVerificationStatus(aliceBotCredentials.userId, aliceBotCredentials.deviceId); + if (!deviceStatus.isVerified()) { + throw new Error("Bot device was not verified after QR code verification"); + } + }, aliceBotClient.credentials); - // Check that our device is now cross-signed - await checkDeviceIsCrossSigned(app); + // Check that our device is now cross-signed + await checkDeviceIsCrossSigned(app); - // Check that the current device is connected to key backup - await checkDeviceIsConnectedKeyBackup(app, expectedBackupVersion, true); - }); + // Check that the current device is connected to key backup + await checkDeviceIsConnectedKeyBackup(app, expectedBackupVersion, true); + }, + ); - test("Verify device with Security Phrase during login", async ({ page, app, credentials, homeserver }) => { - await logIntoElement(page, credentials); - - // Select the security phrase - await page.locator(".mx_AuthPage").getByRole("button", { name: "Verify with Recovery Key or Phrase" }).click(); - - // Fill the passphrase - const dialog = page.locator(".mx_Dialog"); - await dialog.locator("textarea").fill("new passphrase"); - await dialog.getByRole("button", { name: "Continue", disabled: false }).click(); - - await page.locator(".mx_AuthPage").getByRole("button", { name: "Done" }).click(); - - // Check that our device is now cross-signed - await checkDeviceIsCrossSigned(app); - - // Check that the current device is connected to key backup - // The backup decryption key should be in cache also, as we got it directly from the 4S - await checkDeviceIsConnectedKeyBackup(app, expectedBackupVersion, true); - }); + test( + "Verify device with Security Phrase during login", + { tag: "@screenshot" }, + async ({ page, app, credentials, homeserver }) => { + await logIntoElement(page, credentials); + await enterRecoveryKeyAndCheckVerified(page, app, "new passphrase", true); + }, + ); test("Verify device with Recovery Key during login", async ({ page, app, credentials, homeserver }) => { + const recoveryKey = (await aliceBotClient.getRecoveryKey()).encodedPrivateKey; + + await logIntoElement(page, credentials); + await enterRecoveryKeyAndCheckVerified(page, app, recoveryKey); + }); + + test("Verify device with Recovery Key from settings", async ({ page, app, credentials }) => { + const recoveryKey = (await aliceBotClient.getRecoveryKey()).encodedPrivateKey; + await logIntoElement(page, credentials); - // Select the security phrase - await page.locator(".mx_AuthPage").getByRole("button", { name: "Verify with Recovery Key or Phrase" }).click(); + /* Dismiss "Verify this device" */ + const authPage = page.locator(".mx_AuthPage"); + await authPage.getByRole("button", { name: "Skip verification for now" }).click(); + await authPage.getByRole("button", { name: "I'll verify later" }).click(); + await page.waitForSelector(".mx_MatrixChat"); - // Fill the recovery key + const settings = await app.settings.openUserSettings("Encryption"); + await settings.getByRole("button", { name: "Verify this device" }).click(); + await enterRecoveryKeyAndCheckVerified(page, app, recoveryKey); + }); + + test("After cancelling verify with another device, I can try again #29882", async ({ page, app, credentials }) => { + // Regression test for https://github.com/element-hq/element-web/issues/29882 + + // Log in without verifying + await logIntoElement(page, credentials); + const authPage = page.locator(".mx_AuthPage"); + await authPage.getByRole("button", { name: "Skip verification for now" }).click(); + await authPage.getByRole("button", { name: "I'll verify later" }).click(); + await page.waitForSelector(".mx_MatrixChat"); + + // Start to verify with "Use another device" but cancel + const settings = await app.settings.openUserSettings("Encryption"); + await settings.getByRole("button", { name: "Verify this device" }).click(); + await page.getByRole("button", { name: "Use another device" }).click(); + await page.locator("#mx_Dialog_Container").getByRole("button", { name: "Close dialog" }).click(); + + // Start again + await settings.getByRole("button", { name: "Verify this device" }).click(); + + // We should be offered to use another device again. + // (In the bug, we were immediately told that verification has been cancelled.) + await expect(page.getByRole("button", { name: "Use another device" })).toBeVisible(); + }); + + /** Helper for the three tests above which verify by recovery key */ + async function enterRecoveryKeyAndCheckVerified( + page: Page, + app: ElementAppPage, + recoveryKey: string, + screenshot = false, + ) { + await page.getByRole("button", { name: "Use recovery key" }).click(); + + // Enter the recovery key const dialog = page.locator(".mx_Dialog"); - const aliceRecoveryKey = await aliceBotClient.getRecoveryKey(); - await dialog.locator("textarea").fill(aliceRecoveryKey.encodedPrivateKey); + // We use `pressSequentially` here to make sure that the FocusLock isn't causing us any problems + // (cf https://github.com/element-hq/element-web/issues/30089) + await dialog.getByTitle("Recovery key").pressSequentially(recoveryKey); + if (screenshot) { + await expect(page.locator(".mx_Dialog").filter({ hasText: "Enter your recovery key" })).toMatchScreenshot( + "recovery-key.png", + ); + } await dialog.getByRole("button", { name: "Continue", disabled: false }).click(); - - await page.locator(".mx_AuthPage").getByRole("button", { name: "Done" }).click(); + await page.getByRole("button", { name: "Done" }).click(); // Check that our device is now cross-signed await checkDeviceIsCrossSigned(app); @@ -202,7 +268,7 @@ test.describe("Device verification", { tag: "@no-webkit" }, () => { // Check that the current device is connected to key backup // The backup decryption key should be in cache also, as we got it directly from the 4S await checkDeviceIsConnectedKeyBackup(app, expectedBackupVersion, true); - }); + } test("Handle incoming verification request with SAS", async ({ page, credentials, homeserver, toasts }) => { await logIntoElement(page, credentials); @@ -228,7 +294,7 @@ test.describe("Device verification", { tag: "@no-webkit" }, () => { // it should contain the device ID of the requesting device await expect(toast.getByText(`${aliceBotClient.credentials.deviceId} from `)).toBeVisible(); // Accept - await toast.getByRole("button", { name: "Verify Session" }).click(); + await toast.getByRole("button", { name: "Start verification" }).click(); /* Click 'Start' to start SAS verification */ await page.getByRole("button", { name: "Start" }).click(); @@ -243,10 +309,7 @@ test.describe("Device verification", { tag: "@no-webkit" }, () => { /* And we're all done! */ const infoDialog = page.locator(".mx_InfoDialog"); await infoDialog.getByRole("button", { name: "They match" }).click(); - // We don't assert the full string as the device name is unset on Synapse but set to the user ID on Dendrite - await expect(infoDialog.getByText(`You've successfully verified`)).toContainText( - `(${aliceBotClient.credentials.deviceId})`, - ); + await expect(infoDialog.getByText("Device verified")).toBeVisible(); await infoDialog.getByRole("button", { name: "Got it" }).click(); }); }); diff --git a/playwright/e2e/crypto/event-shields.spec.ts b/playwright/e2e/crypto/event-shields.spec.ts index e577a66467..722a30a0d2 100644 --- a/playwright/e2e/crypto/event-shields.spec.ts +++ b/playwright/e2e/crypto/event-shields.spec.ts @@ -14,7 +14,7 @@ import { createSecondBotDevice, createSharedRoomWithUser, enableKeyBackup, - logIntoElement, + logIntoElementAndVerify, logOutOfElement, verify, waitForDevices, @@ -58,108 +58,108 @@ test.describe("Cryptography", function () { await app.client.network.setupRoute(); }); - test("should show the correct shield on e2e events", async ({ - page, - app, - bot: bob, - homeserver, - }, workerInfo) => { - // Bob has a second, not cross-signed, device - const bobSecondDevice = await createSecondBotDevice(page, homeserver, bob); + test( + "should show the correct shield on e2e events", + { tag: "@screenshot" }, + async ({ page, app, bot: bob, homeserver }, workerInfo) => { + // Bob has a second, not cross-signed, device + const bobSecondDevice = await createSecondBotDevice(page, homeserver, bob); - // Dismiss the toasts nagging us, otherwise they get in the way of clicking the room list - await page.getByRole("button", { name: "Dismiss" }).click(); - await page.getByRole("button", { name: "Yes, dismiss" }).click(); + // Dismiss the toasts nagging us, otherwise they get in the way of clicking the room list + await page.getByRole("button", { name: "Dismiss" }).click(); + await page.getByRole("button", { name: "Yes, dismiss" }).click(); - await bob.sendEvent(testRoomId, null, "m.room.encrypted", { - algorithm: "m.megolm.v1.aes-sha2", - ciphertext: "the bird is in the hand", - }); + await bob.sendEvent(testRoomId, null, "m.room.encrypted", { + algorithm: "m.megolm.v1.aes-sha2", + ciphertext: "the bird is in the hand", + }); - const last = page.locator(".mx_EventTile_last"); - await expect(last).toContainText("Unable to decrypt message"); - const lastE2eIcon = last.locator(".mx_EventTile_e2eIcon"); - await expect(lastE2eIcon).toHaveClass(/mx_EventTile_e2eIcon_decryption_failure/); - await lastE2eIcon.focus(); - await expect(await app.getTooltipForElement(lastE2eIcon)).toContainText( - "This message could not be decrypted", - ); + const last = page.locator(".mx_EventTile_last"); + await expect(last).toContainText("Unable to decrypt message"); + const lastE2eIcon = last.locator(".mx_EventTile_e2eIcon"); + await expect(lastE2eIcon).toHaveClass(/mx_EventTile_e2eIcon_decryption_failure/); + await lastE2eIcon.focus(); + await expect(await app.getTooltipForElement(lastE2eIcon)).toContainText( + "This message could not be decrypted", + ); - /* Should show a red padlock for an unencrypted message in an e2e room */ - await bob.evaluate( - (cli, testRoomId) => - cli.http.authedRequest( - window.matrixcs.Method.Put, - `/rooms/${encodeURIComponent(testRoomId)}/send/m.room.message/test_txn_1`, - undefined, - { - msgtype: "m.text", - body: "test unencrypted", - }, - ), - testRoomId, - ); + /* Should show a red padlock for an unencrypted message in an e2e room */ + await bob.evaluate( + (cli, testRoomId) => + cli.http.authedRequest( + window.matrixcs.Method.Put, + `/rooms/${encodeURIComponent(testRoomId)}/send/m.room.message/test_txn_1`, + undefined, + { + msgtype: "m.text", + body: "test unencrypted", + }, + ), + testRoomId, + ); - await expect(last).toContainText("test unencrypted"); - await expect(lastE2eIcon).toHaveClass(/mx_EventTile_e2eIcon_warning/); - await lastE2eIcon.focus(); - await expect(await app.getTooltipForElement(lastE2eIcon)).toContainText("Not encrypted"); + await expect(last).toContainText("test unencrypted"); + await expect(lastE2eIcon).toHaveClass(/mx_EventTile_e2eIcon_warning/); + await expect(lastE2eIcon).toMatchScreenshot("event-shield-warning.png"); + await lastE2eIcon.focus(); + await expect(await app.getTooltipForElement(lastE2eIcon)).toContainText("Not encrypted"); - /* Should show no padlock for an unverified user */ - // bob sends a valid event - await bob.sendMessage(testRoomId, "test encrypted 1"); + /* Should show no padlock for an unverified user */ + // bob sends a valid event + await bob.sendMessage(testRoomId, "test encrypted 1"); - // the message should appear, decrypted, with no warning, but also no "verified" - const lastTile = page.locator(".mx_EventTile_last"); - const lastTileE2eIcon = lastTile.locator(".mx_EventTile_e2eIcon"); - await expect(lastTile).toContainText("test encrypted 1"); - // no e2e icon - await expect(lastTileE2eIcon).not.toBeVisible(); + // the message should appear, decrypted, with no warning, but also no "verified" + const lastTile = page.locator(".mx_EventTile_last"); + const lastTileE2eIcon = lastTile.locator(".mx_EventTile_e2eIcon"); + await expect(lastTile).toContainText("test encrypted 1"); + // no e2e icon + await expect(lastTileE2eIcon).not.toBeVisible(); - /* Now verify Bob */ - await verify(app, bob); + /* Now verify Bob */ + await verify(app, bob); - /* Existing message should be updated when user is verified. */ - await expect(last).toContainText("test encrypted 1"); - // still no e2e icon - await expect(last.locator(".mx_EventTile_e2eIcon")).not.toBeVisible(); + /* Existing message should be updated when user is verified. */ + await expect(last).toContainText("test encrypted 1"); + // still no e2e icon + await expect(last.locator(".mx_EventTile_e2eIcon")).not.toBeVisible(); - /* should show no padlock, and be verified, for a message from a verified device */ - await bob.sendMessage(testRoomId, "test encrypted 2"); + /* should show no padlock, and be verified, for a message from a verified device */ + await bob.sendMessage(testRoomId, "test encrypted 2"); - await expect(lastTile).toContainText("test encrypted 2"); - // no e2e icon - await expect(lastTileE2eIcon).not.toBeVisible(); + await expect(lastTile).toContainText("test encrypted 2"); + // no e2e icon + await expect(lastTileE2eIcon).not.toBeVisible(); - /* should show red padlock for a message from an unverified device */ - await bobSecondDevice.sendMessage(testRoomId, "test encrypted from unverified"); - await expect(lastTile).toContainText("test encrypted from unverified"); - await expect(lastTileE2eIcon).toHaveClass(/mx_EventTile_e2eIcon_warning/); - await lastTileE2eIcon.focus(); - await expect(await app.getTooltipForElement(lastTileE2eIcon)).toContainText( - "Encrypted by a device not verified by its owner.", - ); + /* should show red padlock for a message from an unverified device */ + await bobSecondDevice.sendMessage(testRoomId, "test encrypted from unverified"); + await expect(lastTile).toContainText("test encrypted from unverified"); + await expect(lastTileE2eIcon).toHaveClass(/mx_EventTile_e2eIcon_warning/); + await lastTileE2eIcon.focus(); + await expect(await app.getTooltipForElement(lastTileE2eIcon)).toContainText( + "Encrypted by a device not verified by its owner.", + ); - /* Should show a red padlock for a message from an unverified device. - * Rust crypto remembers the verification state of the sending device, so it will know that the device was - * unverified, even if it gets deleted. */ - // bob deletes his second device - await bobSecondDevice.evaluate((cli) => cli.logout(true)); + /* Should show a red padlock for a message from an unverified device. + * Rust crypto remembers the verification state of the sending device, so it will know that the device was + * unverified, even if it gets deleted. */ + // bob deletes his second device + await bobSecondDevice.evaluate((cli) => cli.logout(true)); - // wait for the logout to propagate. - await waitForDevices(app, bob.credentials.userId, 1); + // wait for the logout to propagate. + await waitForDevices(app, bob.credentials.userId, 1); - // close and reopen the room, to get the shield to update. - await app.viewRoomByName("Bob"); - await app.viewRoomByName("TestRoom"); + // close and reopen the room, to get the shield to update. + await app.viewRoomByName("Bob"); + await app.viewRoomByName("TestRoom"); - await expect(last).toContainText("test encrypted from unverified"); - await expect(lastE2eIcon).toHaveClass(/mx_EventTile_e2eIcon_warning/); - await lastE2eIcon.focus(); - await expect(await app.getTooltipForElement(lastE2eIcon)).toContainText( - "Encrypted by a device not verified by its owner.", - ); - }); + await expect(last).toContainText("test encrypted from unverified"); + await expect(lastE2eIcon).toHaveClass(/mx_EventTile_e2eIcon_warning/); + await lastE2eIcon.focus(); + await expect(await app.getTooltipForElement(lastE2eIcon)).toContainText( + "Encrypted by a device not verified by its owner.", + ); + }, + ); test("Should show a grey padlock for a key restored from backup", async ({ page, @@ -195,7 +195,7 @@ test.describe("Cryptography", function () { window.localStorage.clear(); }); await page.reload(); - await logIntoElement(page, aliceCredentials, securityKey); + await logIntoElementAndVerify(page, aliceCredentials, securityKey); /* go back to the test room and find Bob's message again */ await app.viewRoomById(testRoomId); diff --git a/playwright/e2e/crypto/history-sharing.spec.ts b/playwright/e2e/crypto/history-sharing.spec.ts new file mode 100644 index 0000000000..ee275da84a --- /dev/null +++ b/playwright/e2e/crypto/history-sharing.spec.ts @@ -0,0 +1,140 @@ +/* +Copyright 2025 New Vector Ltd. + +SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial +Please see LICENSE files in the repository root for full details. +*/ + +import { createNewInstance } from "@element-hq/element-web-playwright-common"; + +import { expect, test } from "../../element-web-test"; +import { ElementAppPage } from "../../pages/ElementAppPage"; +import { createRoom, sendMessageInCurrentRoom } from "./utils"; + +test.use({ + displayName: "Alice", + labsFlags: ["feature_share_history_on_invite"], +}); + +/** Tests for MSC4268: encrypted history sharing */ +test.describe("History sharing", function () { + test( + "We should share history when sending invites", + { tag: "@screenshot" }, + async ( + { labsFlags, browser, page: alicePage, user: aliceCredentials, app: aliceElementApp, homeserver }, + testInfo, + ) => { + // In this test, Alice creates an encrypted room and sends an event; + // we then invite Bob, and ensure Bob can see the content. + + await aliceElementApp.client.bootstrapCrossSigning(aliceCredentials); + + // Register a second user, and open it in a second instance of the app + const bobCredentials = await homeserver.registerUser(`user_${testInfo.testId}_bob`, "password", "Bob"); + const bobPage = await createNewInstance(browser, bobCredentials, {}, labsFlags); + const bobElementApp = new ElementAppPage(bobPage); + await bobElementApp.client.bootstrapCrossSigning(bobCredentials); + + // Create the room and send a message + await createRoom(alicePage, "TestRoom", true); + await sendMessageInCurrentRoom(alicePage, "A message from Alice"); + + // Send the invite to Bob + await aliceElementApp.inviteUserToCurrentRoom(bobCredentials.userId); + + // Bob accepts the invite + await bobPage.getByRole("option", { name: "TestRoom" }).click(); + await bobPage.getByRole("button", { name: "Accept" }).click(); + + // Bob should now be able to decrypt the event + await expect(bobPage.getByText("A message from Alice")).toBeVisible(); + + const mask = [bobPage.locator(".mx_MessageTimestamp")]; + await expect(bobPage.locator(".mx_RoomView_body")).toMatchScreenshot("shared-history-invite-accepted.png", { + mask, + }); + }, + ); + + test("Messages sent when we believed the room history was unshared should not be visible", async ({ + labsFlags, + browser, + page: alicePage, + user: aliceCredentials, + app: aliceElementApp, + homeserver, + }, testInfo) => { + test.setTimeout(60000); + + // In this test: + // 1. Alice creates an encrypted room with Bob. + // 2. She sets the history visibility to "shared", but Bob doesn't receive the memo + // 3. Bob sends a message + // 4. Alice invites Charlie + // 5. Charlie can't see the message. + + await aliceElementApp.client.bootstrapCrossSigning(aliceCredentials); + await createRoom(alicePage, "TestRoom", true); + + // Register a second user, and open it in a second instance of the app + const bobCredentials = await homeserver.registerUser(`user_${testInfo.testId}_bob`, "password", "Bob"); + const bobPage = await createNewInstance(browser, bobCredentials, {}, labsFlags); + const bobElementApp = new ElementAppPage(bobPage); + await bobElementApp.client.bootstrapCrossSigning(bobCredentials); + + // ... and a third + const charlieCredentials = await homeserver.registerUser( + `user_${testInfo.testId}_charlie`, + "password", + "Charlie", + ); + const charliePage = await createNewInstance(browser, charlieCredentials, {}, labsFlags); + const charlieElementApp = new ElementAppPage(charliePage); + await charlieElementApp.client.bootstrapCrossSigning(charlieCredentials); + + // Alice invites Bob, and Bob accepts + const roomId = await aliceElementApp.getCurrentRoomIdFromUrl(); + await aliceElementApp.inviteUserToCurrentRoom(bobCredentials.userId); + await bobPage.getByRole("option", { name: "TestRoom" }).click(); + await bobPage.getByRole("button", { name: "Accept" }).click(); + + // Bob sends a message with "shared" visibility + await sendMessageInCurrentRoom(bobPage, "Message1: 'shared' visibility"); + await expect(alicePage.getByText("Message1")).toBeVisible(); + + // Alice sets the history visibility to "joined" + await aliceElementApp.client.sendStateEvent(roomId, "m.room.history_visibility", { + history_visibility: "joined", + }); + await expect( + bobPage.getByText( + "Alice made future room history visible to all room members, from the point they joined.", + ), + ).toBeVisible(); + + // Bob stops syncing, and sends a message with "joined" visibility. + // (Stopping syncing *before* sending the message means that the active sync will be flushed by sending the + // message, so that Alice's change to the history viz below won't be seen by Bob.) + await bobPage.route(`**/sync*`, (route) => route.fulfill({})); + await sendMessageInCurrentRoom(bobPage, "Message2: 'joined' visibility"); + await expect(alicePage.getByText("Message2")).toBeVisible(); + + // Alice changes the history viz, but Bob doesn't receive the memo + await aliceElementApp.client.sendStateEvent(roomId, "m.room.history_visibility", { + history_visibility: "shared", + }); + await sendMessageInCurrentRoom(bobPage, "Message3: 'shared' visibility, but Bob thinks it is still 'joined'"); + + // Alice now invites Charlie + await aliceElementApp.inviteUserToCurrentRoom(charlieCredentials.userId); + await charliePage.getByRole("option", { name: "TestRoom" }).click(); + await charliePage.getByRole("button", { name: "Accept" }).click(); + + // Message1 should be visible + // Message2 should be invisible + // Message3 should be undecryptable + await expect(charliePage.getByText("Message1")).toBeVisible(); + await expect(charliePage.getByText("You don't have access to this message")).toBeVisible(); + }); +}); diff --git a/playwright/e2e/crypto/toasts.spec.ts b/playwright/e2e/crypto/toasts.spec.ts index 7b838edaac..5e1dedfdc7 100644 --- a/playwright/e2e/crypto/toasts.spec.ts +++ b/playwright/e2e/crypto/toasts.spec.ts @@ -8,7 +8,7 @@ import { type GeneratedSecretStorageKey } from "matrix-js-sdk/src/crypto-api"; import { test, expect } from "../../element-web-test"; -import { createBot, deleteCachedSecrets, disableKeyBackup, logIntoElement } from "./utils"; +import { createBot, deleteCachedSecrets, disableKeyBackup, logIntoElementAndVerify } from "./utils"; import { type Bot } from "../../pages/bot"; test.describe("Key storage out of sync toast", () => { @@ -18,12 +18,15 @@ test.describe("Key storage out of sync toast", () => { const res = await createBot(page, homeserver, credentials); recoveryKey = res.recoveryKey; - await logIntoElement(page, credentials, recoveryKey.encodedPrivateKey); + await logIntoElementAndVerify(page, credentials, recoveryKey.encodedPrivateKey); await deleteCachedSecrets(page); // We won't be prompted for crypto setup unless we have an e2e room, so make one - await page.getByRole("button", { name: "Add room" }).click(); + await page + .getByRole("navigation", { name: "Room list" }) + .getByRole("button", { name: "New conversation" }) + .click(); await page.getByRole("menuitem", { name: "New room" }).click(); await page.getByRole("textbox", { name: "Name" }).fill("Test room"); await page.getByRole("button", { name: "Create room" }).click(); @@ -65,10 +68,13 @@ test.describe("'Turn on key storage' toast", () => { const recoveryKey = res.recoveryKey; botClient = res.botClient; - await logIntoElement(page, credentials, recoveryKey.encodedPrivateKey); + await logIntoElementAndVerify(page, credentials, recoveryKey.encodedPrivateKey); // We won't be prompted for crypto setup unless we have an e2e room, so make one - await page.getByRole("button", { name: "Add room" }).click(); + await page + .getByRole("navigation", { name: "Room list" }) + .getByRole("button", { name: "New conversation" }) + .click(); await page.getByRole("menuitem", { name: "New room" }).click(); await page.getByRole("textbox", { name: "Name" }).fill("Test room"); await page.getByRole("button", { name: "Create room" }).click(); @@ -126,7 +132,7 @@ test.describe("'Turn on key storage' toast", () => { await toast.getByRole("button", { name: "Continue" }).click(); // Then we see the Encryption settings dialog with an option to turn on key storage - await expect(page.getByRole("checkbox", { name: "Allow key storage" })).toBeVisible(); + await expect(page.getByRole("switch", { name: "Allow key storage" })).toBeVisible(); // And when we close that await page.getByRole("button", { name: "Close dialog" }).click(); @@ -153,7 +159,7 @@ test.describe("'Turn on key storage' toast", () => { await page.getByRole("button", { name: "Go to Settings" }).click(); // Then we see Encryption settings again - await expect(page.getByRole("checkbox", { name: "Allow key storage" })).toBeVisible(); + await expect(page.getByRole("switch", { name: "Allow key storage" })).toBeVisible(); // And when we close that, see the toast, click Dismiss, and Yes, Dismiss await page.getByRole("button", { name: "Close dialog" }).click(); diff --git a/playwright/e2e/crypto/utils.ts b/playwright/e2e/crypto/utils.ts index 289b123e86..8b677ed4cb 100644 --- a/playwright/e2e/crypto/utils.ts +++ b/playwright/e2e/crypto/utils.ts @@ -206,32 +206,42 @@ export async function checkDeviceIsConnectedKeyBackup( /** * Fill in the login form in element with the given creds. - * - * If a `securityKey` is given, verifies the new device using the key. */ -export async function logIntoElement(page: Page, credentials: Credentials, securityKey?: string) { +export async function logIntoElement(page: Page, credentials: Credentials) { await page.goto("/#/login"); await page.getByRole("textbox", { name: "Username" }).fill(credentials.userId); await page.getByPlaceholder("Password").fill(credentials.password); await page.getByRole("button", { name: "Sign in" }).click(); +} - // if a securityKey was given, verify the new device - if (securityKey !== undefined) { - await page.locator(".mx_AuthPage").getByRole("button", { name: "Verify with Recovery Key" }).click(); +/** + * Fill in the login form in Element with the given creds, and then complete the `CompleteSecurity` step, using the + * given recovery key. (Normally this will verify the new device using the secrets from 4S.) + * + * Afterwards, waits for the application to redirect to the home page. + */ +export async function logIntoElementAndVerify(page: Page, credentials: Credentials, recoveryKey: string) { + await logIntoElement(page, credentials); - const useSecurityKey = page.locator(".mx_Dialog").getByRole("button", { name: "use your Recovery Key" }); - // If the user has set a recovery *passphrase*, they'll be prompted for that first and have to click - // through to enter the recovery key which is what we have here. If they haven't, they'll be prompted - // for a recovery key straight away. We click the button if it's there so this works in both cases. - if (await useSecurityKey.isVisible()) { - await useSecurityKey.click(); - } - // Fill in the recovery key - await page.locator(".mx_Dialog").locator("textarea").fill(securityKey); - await page.getByRole("button", { name: "Continue", disabled: false }).click(); - await page.getByRole("button", { name: "Done" }).click(); + await page.locator(".mx_AuthPage").getByRole("button", { name: "Use recovery key" }).click(); + + const useSecurityKey = page.locator(".mx_Dialog").getByRole("button", { name: "Use recovery key" }); + // If the user has set a recovery *passphrase*, they'll be prompted for that first and have to click + // through to enter the recovery key which is what we have here. If they haven't, they'll be prompted + // for a recovery key straight away. We click the button if it's there so this works in both cases. + if (await useSecurityKey.isVisible()) { + await useSecurityKey.click(); } + + // Fill in the recovery key + await page.locator(".mx_Dialog").getByTitle("Recovery key").fill(recoveryKey); + await page.getByRole("button", { name: "Continue", disabled: false }).click(); + await page.getByRole("button", { name: "Done" }).click(); + + // The application should now redirect to `/#/home`. Wait for that to happen, otherwise if a test immediately does + // a `viewRoomById` or similar, it could race. + await page.waitForURL("/#/home"); } /** @@ -262,8 +272,8 @@ export async function logOutOfElement(page: Page, discardKeys: boolean = false) export async function verifySession(app: ElementAppPage, securityKey: string) { const settings = await app.settings.openUserSettings("Encryption"); await settings.getByRole("button", { name: "Verify this device" }).click(); - await app.page.getByRole("button", { name: "Verify with Recovery Key" }).click(); - await app.page.locator(".mx_Dialog").locator("textarea").fill(securityKey); + await app.page.getByRole("button", { name: "Use recovery key" }).click(); + await app.page.locator(".mx_Dialog").getByTitle("Recovery key").fill(securityKey); await app.page.getByRole("button", { name: "Continue", disabled: false }).click(); await app.page.getByRole("button", { name: "Done" }).click(); await app.settings.closeDialog(); @@ -300,9 +310,9 @@ export async function doTwoWaySasVerification(page: Page, verifier: JSHandle { const encryptionTab = await app.settings.openUserSettings("Encryption"); - const keyStorageToggle = encryptionTab.getByRole("checkbox", { name: "Allow key storage" }); + const keyStorageToggle = encryptionTab.getByRole("switch", { name: "Allow key storage" }); if (!(await keyStorageToggle.isChecked())) { - await encryptionTab.getByRole("checkbox", { name: "Allow key storage" }).click(); + await encryptionTab.getByRole("switch", { name: "Allow key storage" }).click(); } await encryptionTab.getByRole("button", { name: "Set up recovery" }).click(); @@ -323,11 +333,11 @@ export async function enableKeyBackup(app: ElementAppPage): Promise { export async function disableKeyBackup(app: ElementAppPage): Promise { const encryptionTab = await app.settings.openUserSettings("Encryption"); - const keyStorageToggle = encryptionTab.getByRole("checkbox", { name: "Allow key storage" }); + const keyStorageToggle = encryptionTab.getByRole("switch", { name: "Allow key storage" }); if (await keyStorageToggle.isChecked()) { - await encryptionTab.getByRole("checkbox", { name: "Allow key storage" }).click(); + await encryptionTab.getByRole("switch", { name: "Allow key storage" }).click(); await encryptionTab.getByRole("button", { name: "Delete key storage" }).click(); - await encryptionTab.getByRole("checkbox", { name: "Allow key storage" }).isVisible(); + await encryptionTab.getByRole("switch", { name: "Allow key storage" }).isVisible(); // Wait for the update to account data to stick await new Promise((resolve) => setTimeout(resolve, 2000)); @@ -428,8 +438,8 @@ export async function sendMessageInCurrentRoom(page: Page, message: string): Pro * @param isEncrypted - Whether the room should be encrypted */ export async function createRoom(page: Page, roomName: string, isEncrypted: boolean): Promise { - await page.getByRole("button", { name: "Add room" }).click(); - await page.locator(".mx_IconizedContextMenu").getByRole("menuitem", { name: "New room" }).click(); + await page.getByRole("navigation", { name: "Room list" }).getByRole("button", { name: "New conversation" }).click(); + await page.getByRole("menuitem", { name: "New room" }).click(); const dialog = page.locator(".mx_Dialog"); diff --git a/playwright/e2e/devtools/devtools.spec.ts b/playwright/e2e/devtools/devtools.spec.ts new file mode 100644 index 0000000000..1f9217d7eb --- /dev/null +++ b/playwright/e2e/devtools/devtools.spec.ts @@ -0,0 +1,40 @@ +/* +Copyright 2025 New Vector Ltd. + +SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial +Please see LICENSE files in the repository root for full details. +*/ + +import { test, expect } from "../../element-web-test"; + +test.describe("Devtools", () => { + test.use({ + displayName: "Alice", + }); + + test("should render the devtools", { tag: "@screenshot" }, async ({ page, homeserver, user, app, axe }) => { + await app.client.createRoom({ name: "Test Room" }); + await app.viewRoomByName("Test Room"); + + const composer = app.getComposer().locator("[contenteditable]"); + await composer.fill("/devtools"); + await composer.press("Enter"); + const dialog = page.locator(".mx_Dialog"); + await dialog.getByLabel("Developer mode").check(); + + axe.disableRules("color-contrast"); // XXX: Inheriting colour contrast issues from room view. + await expect(axe).toHaveNoViolations(); + await expect(dialog).toMatchScreenshot("devtools-dialog.png", { + css: `.mx_CopyableText { + display: none; + }`, + }); + + // Try entering a value for the Developer.elementCallUrl setting + const input = page.getByRole("textbox", { name: "Element Call URL" }); + await input.fill("https://example.com"); + await input.press("Enter"); + // expect EW NOT to reload + await page.getByText("Saved").isVisible(); + }); +}); diff --git a/playwright/e2e/devtools/upgraderoom.spec.ts b/playwright/e2e/devtools/upgraderoom.spec.ts new file mode 100644 index 0000000000..69de03b6ee --- /dev/null +++ b/playwright/e2e/devtools/upgraderoom.spec.ts @@ -0,0 +1,38 @@ +/* +Copyright 2025 New Vector Ltd. + +SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial +Please see LICENSE files in the repository root for full details. +*/ + +import { SettingLevel } from "../../../src/settings/SettingLevel"; +import { test, expect } from "../../element-web-test"; + +test.describe("Room upgrade dialog", () => { + test.use({ + displayName: "Alice", + }); + + test( + "should render the room upgrade dialog", + { tag: "@screenshot" }, + async ({ page, homeserver, user, app, axe }) => { + // Enable developer mode + await app.settings.setValue("developerMode", null, SettingLevel.ACCOUNT, true); + + await app.client.createRoom({ name: "Test Room" }); + await app.viewRoomByName("Test Room"); + + const composer = app.getComposer().locator("[contenteditable]"); + // Pick a room version that is likely to be supported by all our target homeservers. + await composer.fill("/upgraderoom 5"); + await composer.press("Enter"); + const dialog = page.locator(".mx_Dialog"); + await dialog.getByLabel("Automatically invite members from this room to the new one").check(); + + axe.disableRules("color-contrast"); // XXX: Inheriting colour contrast issues from room view. + await expect(axe).toHaveNoViolations(); + await expect(dialog).toMatchScreenshot("upgrade-room.png"); + }, + ); +}); diff --git a/playwright/e2e/editing/editing.spec.ts b/playwright/e2e/editing/editing.spec.ts index 6f8e68bbc3..415bcd1845 100644 --- a/playwright/e2e/editing/editing.spec.ts +++ b/playwright/e2e/editing/editing.spec.ts @@ -138,7 +138,11 @@ test.describe("Editing", () => { // Take a snapshot of the dialog await expect(dialog).toMatchScreenshot("message-edit-history-dialog.png", { - mask: [page.locator(".mx_MessageTimestamp")], + css: ` + .mx_MessageTimestamp { + visibility: hidden; + } + `, }); { diff --git a/playwright/e2e/invite/decline-and-block-invite-dialog.spec.ts b/playwright/e2e/invite/decline-and-block-invite-dialog.spec.ts new file mode 100644 index 0000000000..1dbacaac24 --- /dev/null +++ b/playwright/e2e/invite/decline-and-block-invite-dialog.spec.ts @@ -0,0 +1,28 @@ +/* +Copyright 2025 New Vector Ltd. + +SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial +Please see LICENSE files in the repository root for full details. +*/ + +import { test, expect } from "../../element-web-test"; + +test.describe("Decline and block invite dialog", function () { + test.use({ + displayName: "Hanako", + }); + + test( + "should show decline and block dialog for a room", + { tag: "@screenshot" }, + async ({ page, app, user, bot, axe }) => { + await bot.createRoom({ name: "Test Room", invite: [user.userId] }); + await app.viewRoomByName("Test Room"); + await page.getByRole("button", { name: "Decline and block" }).click(); + + axe.disableRules("color-contrast"); // XXX: Inheriting colour contrast issues from room view. + await expect(axe).toHaveNoViolations(); + await expect(page.locator(".mx_Dialog")).toMatchScreenshot("decline-and-block-invite-empty.png"); + }, + ); +}); diff --git a/playwright/e2e/invite/invite-dialog.spec.ts b/playwright/e2e/invite/invite-dialog.spec.ts index 8d64e6e047..42588f37c7 100644 --- a/playwright/e2e/invite/invite-dialog.spec.ts +++ b/playwright/e2e/invite/invite-dialog.spec.ts @@ -50,15 +50,11 @@ test.describe("Invite dialog", function () { await expect(other.locator(".mx_InviteDialog_identityServer")).toBeVisible(); // Assert that the bot id is rendered properly - await expect( - other.locator(".mx_InviteDialog_tile_nameStack_userId").getByText(bot.credentials.userId), - ).toBeVisible(); + await expect(other.getByRole("option", { name: botName }).getByText(bot.credentials.userId)).toBeVisible(); - await other.locator(".mx_InviteDialog_tile_nameStack_name").getByText(botName).click(); + await other.getByRole("option", { name: botName }).click(); - await expect( - other.locator(".mx_InviteDialog_userTile_pill .mx_InviteDialog_userTile_name").getByText(botName), - ).toBeVisible(); + await expect(other.getByTestId("invite-dialog-input-wrapper").getByText(botName)).toBeVisible(); // Take a snapshot of the invite dialog with a user pill await expect(page.locator(".mx_Dialog")).toMatchScreenshot("invite-dialog-room-with-user-pill.png"); @@ -77,7 +73,11 @@ test.describe("Invite dialog", function () { "should support inviting a user to Direct Messages", { tag: "@screenshot" }, async ({ page, app, user, bot }) => { - await page.locator(".mx_LegacyRoomList").getByRole("button", { name: "Start chat" }).click(); + await page + .getByRole("navigation", { name: "Room list" }) + .getByRole("button", { name: "New conversation" }) + .click(); + await page.getByRole("menuitem", { name: "Start chat" }).click(); const other = page.locator(".mx_InviteDialog_other"); // Assert that the header is rendered @@ -93,14 +93,10 @@ test.describe("Invite dialog", function () { await other.getByTestId("invite-dialog-input").fill(bot.credentials.userId); - await expect( - other.locator(".mx_InviteDialog_tile_nameStack").getByText(bot.credentials.userId), - ).toBeVisible(); - await other.locator(".mx_InviteDialog_tile_nameStack").getByText(botName).click(); + await expect(other.getByRole("option", { name: botName }).getByText(bot.credentials.userId)).toBeVisible(); + await other.getByRole("option", { name: botName }).click(); - await expect( - other.locator(".mx_InviteDialog_userTile_pill .mx_InviteDialog_userTile_name").getByText(botName), - ).toBeVisible(); + await expect(other.getByTestId("invite-dialog-input-wrapper").getByText(botName)).toBeVisible(); // Take a snapshot of the invite dialog with a user pill await expect(page.locator(".mx_Dialog")).toMatchScreenshot("invite-dialog-dm-with-user-pill.png"); @@ -120,6 +116,8 @@ test.describe("Invite dialog", function () { "rgba(0, 0, 0, 0)", ); + await expect(page.locator(".mx_RoomView")).toMatchScreenshot("send_your_first_message_view.png"); + // Send a message to invite the bots const composer = app.getComposer().locator("[contenteditable]"); await composer.fill("Hello}"); diff --git a/playwright/e2e/knock/create-knock-room.spec.ts b/playwright/e2e/knock/create-knock-room.spec.ts index e21b30a3c2..d53271ef3c 100644 --- a/playwright/e2e/knock/create-knock-room.spec.ts +++ b/playwright/e2e/knock/create-knock-room.spec.ts @@ -27,8 +27,7 @@ test.describe("Create Knock Room", () => { await expect(page.locator(".mx_RoomHeader").getByText("Cybersecurity")).toBeVisible(); - const urlHash = await page.evaluate(() => window.location.hash); - const roomId = urlHash.replace("#/room/", ""); + const roomId = await app.getCurrentRoomIdFromUrl(); // Room should have a knock join rule await waitForRoom(page, app.client, roomId, (room) => { @@ -44,8 +43,7 @@ test.describe("Create Knock Room", () => { await expect(page.locator(".mx_RoomHeader").getByText("Cybersecurity")).toBeVisible(); - const urlHash = await page.evaluate(() => window.location.hash); - const roomId = urlHash.replace("#/room/", ""); + const roomId = await app.getCurrentRoomIdFromUrl(); await app.settings.openRoomSettings("Security & Privacy"); @@ -70,8 +68,7 @@ test.describe("Create Knock Room", () => { await expect(page.locator(".mx_RoomHeader").getByText("Cybersecurity")).toBeVisible(); - const urlHash = await page.evaluate(() => window.location.hash); - const roomId = urlHash.replace("#/room/", ""); + const roomId = await app.getCurrentRoomIdFromUrl(); // Room should have a knock join rule await waitForRoom(page, app.client, roomId, (room) => { diff --git a/playwright/e2e/knock/knock-into-room.spec.ts b/playwright/e2e/knock/knock-into-room.spec.ts index be6619697d..26440a6e1e 100644 --- a/playwright/e2e/knock/knock-into-room.spec.ts +++ b/playwright/e2e/knock/knock-into-room.spec.ts @@ -59,7 +59,7 @@ test.describe("Knock Into Room", () => { // Knocked room should appear in Rooms await expect( - page.getByRole("group", { name: "Rooms" }).getByRole("treeitem", { name: "Cybersecurity" }), + page.getByTestId("room-list").getByRole("option", { name: "Open room Cybersecurity" }), ).toBeVisible(); // bot waits for knock request from Alice @@ -77,7 +77,7 @@ test.describe("Knock Into Room", () => { await bot.inviteUser(room.roomId, user.userId); await expect( - page.getByRole("group", { name: "Invites" }).getByRole("treeitem", { name: "Cybersecurity" }), + page.getByTestId("room-list").getByRole("option", { name: "Open room Cybersecurity" }), ).toBeVisible(); // Alice have to accept invitation in order to join the room. @@ -85,7 +85,7 @@ test.describe("Knock Into Room", () => { await page.locator(".mx_RoomView").getByRole("button", { name: "Accept" }).click(); await expect( - page.getByRole("group", { name: "Rooms" }).getByRole("treeitem", { name: "Cybersecurity" }), + page.getByTestId("room-list").getByRole("option", { name: "Open room Cybersecurity" }), ).toBeVisible(); await expect(page.getByText("Alice joined the room")).toBeVisible(); @@ -136,7 +136,7 @@ test.describe("Knock Into Room", () => { // Knocked room should appear in Rooms await expect( - page.getByRole("group", { name: "Rooms" }).getByRole("treeitem", { name: "Cybersecurity" }), + page.getByTestId("room-list").getByRole("option", { name: "Open room Cybersecurity" }), ).toBeVisible(); // bot waits for knock request from Alice @@ -154,7 +154,7 @@ test.describe("Knock Into Room", () => { await bot.inviteUser(room.roomId, user.userId); await expect( - page.getByRole("group", { name: "Invites" }).getByRole("treeitem", { name: "Cybersecurity" }), + page.getByTestId("room-list").getByRole("option", { name: "Open room Cybersecurity" }), ).toBeVisible(); // Alice have to accept invitation in order to join the room. @@ -162,7 +162,7 @@ test.describe("Knock Into Room", () => { await page.locator(".mx_RoomView").getByRole("button", { name: "Accept" }).click(); await expect( - page.getByRole("group", { name: "Rooms" }).getByRole("treeitem", { name: "Cybersecurity" }), + page.getByTestId("room-list").getByRole("option", { name: "Open room Cybersecurity" }), ).toBeVisible(); await expect(page.getByText("Alice joined the room")).toBeVisible(); @@ -215,14 +215,14 @@ test.describe("Knock Into Room", () => { await expect(roomPreviewBar.getByRole("heading", { name: "Request to join sent" })).toBeVisible(); // Knocked room should appear in Rooms - page.getByRole("group", { name: "Rooms" }).getByRole("treeitem", { name: "Cybersecurity" }); + page.getByTestId("room-list").getByRole("option", { name: "Open room Cybersecurity" }); await roomPreviewBar.getByRole("button", { name: "Cancel request" }).click(); await expect(roomPreviewBar.getByRole("heading", { name: "Ask to join Cybersecurity?" })).toBeVisible(); await expect(roomPreviewBar.getByRole("button", { name: "Request access" })).toBeVisible(); await expect( - page.getByRole("group", { name: "Rooms" }).getByRole("treeitem", { name: "Cybersecurity" }), + page.getByTestId("room-list").getByRole("option", { name: "Open room Cybersecurity" }), ).not.toBeVisible(); }); @@ -244,7 +244,7 @@ test.describe("Knock Into Room", () => { // Knocked room should appear in Rooms await expect( - page.getByRole("group", { name: "Rooms" }).getByRole("treeitem", { name: "Cybersecurity" }), + page.getByTestId("room-list").getByRole("option", { name: "Open room Cybersecurity" }), ).toBeVisible(); // bot waits for knock request from Alice @@ -262,13 +262,10 @@ test.describe("Knock Into Room", () => { await bot.kick(room.roomId, user.userId); // Room should stay in Rooms and have red badge when knock is denied - await expect( - page.getByRole("group", { name: "Rooms" }).getByRole("treeitem", { name: "Cybersecurity", exact: true }), - ).not.toBeVisible(); await expect( page - .getByRole("group", { name: "Rooms" }) - .getByRole("treeitem", { name: "Cybersecurity 1 unread mention." }), + .getByTestId("room-list") + .getByRole("option", { name: "Open room Cybersecurity with 1 unread mention." }), ).toBeVisible(); await expect(roomPreviewBar.getByRole("heading", { name: "You have been denied access" })).toBeVisible(); diff --git a/playwright/e2e/lazy-loading/lazy-loading.spec.ts b/playwright/e2e/lazy-loading/lazy-loading.spec.ts index 7c31c288fa..f6f098a079 100644 --- a/playwright/e2e/lazy-loading/lazy-loading.spec.ts +++ b/playwright/e2e/lazy-loading/lazy-loading.spec.ts @@ -30,6 +30,10 @@ test.describe("Lazy Loading", () => { }); test.beforeEach(async ({ page, homeserver, user, bot, app }) => { + // The charlies were running off the bottom of the screen. + // We no longer overscan the member list so the result is they are not in the dom. + // Increase the viewport size to ensure they are. + await page.setViewportSize({ width: 1000, height: 1000 }); for (let i = 1; i <= 10; i++) { const displayName = `Charly #${i}`; const bot = new Bot(page, homeserver, { displayName, startClient: false, autoAcceptInvites: false }); diff --git a/playwright/e2e/left-panel/left-panel.spec.ts b/playwright/e2e/left-panel/left-panel.spec.ts index 3850df5cd0..cae6fd8934 100644 --- a/playwright/e2e/left-panel/left-panel.spec.ts +++ b/playwright/e2e/left-panel/left-panel.spec.ts @@ -17,7 +17,7 @@ test.describe("LeftPanel", () => { // create rooms and check room names are correct for (const name of ["Apple", "Pineapple", "Orange"]) { await app.client.createRoom({ name }); - await expect(page.getByRole("treeitem", { name })).toBeVisible(); + await expect(page.getByRole("option", { name: `Open room ${name}` })).toBeVisible(); } }); }); diff --git a/playwright/e2e/left-panel/room-list-panel/room-list-filter-sort.spec.ts b/playwright/e2e/left-panel/room-list-panel/room-list-filter-sort.spec.ts index d3aa060a27..d1c0332cef 100644 --- a/playwright/e2e/left-panel/room-list-panel/room-list-filter-sort.spec.ts +++ b/playwright/e2e/left-panel/room-list-panel/room-list-filter-sort.spec.ts @@ -22,13 +22,21 @@ test.describe("Room list filters and sort", () => { }); function getPrimaryFilters(page: Page): Locator { - return page.getByRole("listbox", { name: "Room list filters" }); + return page.getByTestId("primary-filters"); } function getRoomOptionsMenu(page: Page): Locator { return page.getByRole("button", { name: "Room Options" }); } + function getFilterExpandButton(page: Page): Locator { + return getPrimaryFilters(page).getByRole("button", { name: "Expand filter list" }); + } + + function getFilterCollapseButton(page: Page): Locator { + return getPrimaryFilters(page).getByRole("button", { name: "Collapse filter list" }); + } + /** * Get the room list * @param page @@ -60,7 +68,7 @@ test.describe("Room list filters and sort", () => { So we expect 'Old Room' to show up in the room list. */ const roomListView = getRoomList(page); - const oldRoomTile = roomListView.getByRole("gridcell", { name: "Open room Old Room" }); + const oldRoomTile = roomListView.getByRole("option", { name: "Open room Old Room" }); await expect(oldRoomTile).toBeVisible(); /* @@ -131,18 +139,20 @@ test.describe("Room list filters and sort", () => { // Open the non-favourite room const roomListView = getRoomList(page); - const tile = roomListView.getByRole("gridcell", { name: "Open room room-non-fav" }); - await tile.scrollIntoViewIfNeeded(); + const tile = roomListView.getByRole("option", { name: "Open room room-non-fav" }); + // item may not be in the DOM using scrollListToBottom rather than scrollIntoViewIfNeeded + await app.scrollListToBottom(roomListView); await tile.click(); // Enable Favourite filter + await getFilterExpandButton(page).click(); const primaryFilters = getPrimaryFilters(page); await primaryFilters.getByRole("option", { name: "Favourite" }).click(); await expect(tile).not.toBeVisible(); // Ensure the room list is not scrolled const isScrolledDown = await page - .getByRole("grid", { name: "Room list" }) + .getByRole("listbox", { name: "Room list", exact: true }) .evaluate((e) => e.scrollTop !== 0); expect(isScrolledDown).toStrictEqual(false); }); @@ -218,35 +228,40 @@ test.describe("Room list filters and sort", () => { await primaryFilters.getByRole("option", { name: "Unread" }).click(); // only one room should be visible - await expect(roomList.getByRole("gridcell", { name: "unread dm" })).toBeVisible(); - await expect(roomList.getByRole("gridcell", { name: "unread room" })).toBeVisible(); - expect(await roomList.locator("role=gridcell").count()).toBe(4); + await expect(roomList.getByRole("option", { name: "unread dm" })).toBeVisible(); + await expect(roomList.getByRole("option", { name: "unread room" })).toBeVisible(); + await expect.poll(() => roomList.locator("role=option").count()).toBe(4); await expect(primaryFilters).toMatchScreenshot("unread-primary-filters.png"); - await primaryFilters.getByRole("option", { name: "Favourite" }).click(); - await expect(roomList.getByRole("gridcell", { name: "favourite room" })).toBeVisible(); - expect(await roomList.locator("role=gridcell").count()).toBe(1); - await primaryFilters.getByRole("option", { name: "People" }).click(); - await expect(roomList.getByRole("gridcell", { name: "unread dm" })).toBeVisible(); - await expect(roomList.getByRole("gridcell", { name: "invited room" })).toBeVisible(); - expect(await roomList.locator("role=gridcell").count()).toBe(2); + await expect(roomList.getByRole("option", { name: "unread dm" })).toBeVisible(); + await expect(roomList.getByRole("option", { name: "invited room" })).toBeVisible(); + await expect.poll(() => roomList.locator("role=option").count()).toBe(2); await primaryFilters.getByRole("option", { name: "Rooms" }).click(); - await expect(roomList.getByRole("gridcell", { name: "unread room" })).toBeVisible(); - await expect(roomList.getByRole("gridcell", { name: "favourite room" })).toBeVisible(); - await expect(roomList.getByRole("gridcell", { name: "empty room" })).toBeVisible(); - await expect(roomList.getByRole("gridcell", { name: "room with mention" })).toBeVisible(); - await expect(roomList.getByRole("gridcell", { name: "Low prio room" })).toBeVisible(); - expect(await roomList.locator("role=gridcell").count()).toBe(5); + await expect(roomList.getByRole("option", { name: "unread room" })).toBeVisible(); + await expect(roomList.getByRole("option", { name: "favourite room" })).toBeVisible(); + await expect(roomList.getByRole("option", { name: "empty room" })).toBeVisible(); + await expect(roomList.getByRole("option", { name: "room with mention" })).toBeVisible(); + await expect(roomList.getByRole("option", { name: "Low prio room" })).toBeVisible(); + await expect.poll(() => roomList.locator("role=option").count()).toBe(5); + + await getFilterExpandButton(page).click(); + + await primaryFilters.getByRole("option", { name: "Favourite" }).click(); + await expect(roomList.getByRole("option", { name: "favourite room" })).toBeVisible(); + await expect.poll(() => roomList.locator("role=option").count()).toBe(1); await primaryFilters.getByRole("option", { name: "Mentions" }).click(); - await expect(roomList.getByRole("gridcell", { name: "room with mention" })).toBeVisible(); - expect(await roomList.locator("role=gridcell").count()).toBe(1); + await expect(roomList.getByRole("option", { name: "room with mention" })).toBeVisible(); + await expect.poll(() => roomList.locator("role=option").count()).toBe(1); await primaryFilters.getByRole("option", { name: "Invites" }).click(); - await expect(roomList.getByRole("gridcell", { name: "invited room" })).toBeVisible(); - expect(await roomList.locator("role=gridcell").count()).toBe(1); + await expect(roomList.getByRole("option", { name: "invited room" })).toBeVisible(); + await expect.poll(() => roomList.locator("role=option").count()).toBe(1); + + await getFilterCollapseButton(page).click(); + await expect(primaryFilters.locator("role=option").first()).toHaveText("Invites"); }); test( @@ -254,6 +269,7 @@ test.describe("Room list filters and sort", () => { { tag: "@screenshot" }, async ({ page, app, bot }) => { const roomListView = getRoomList(page); + const primaryFilters = getPrimaryFilters(page); // Let's configure unread dm room so that we only get notification for mentions and keywords await app.viewRoomById(unReadDmId); @@ -262,20 +278,20 @@ test.describe("Room list filters and sort", () => { await app.settings.closeDialog(); // Let's open a room other than unread room or unread dm - await roomListView.getByRole("gridcell", { name: "Open room favourite room" }).click(); + await roomListView.getByRole("option", { name: "Open room favourite room" }).click(); // Let's make the bot send a new message in both rooms await bot.sendMessage(unReadDmId, "Hello!"); await bot.sendMessage(unReadRoomId, "Hello!"); // Let's activate the unread filter now - await page.getByRole("option", { name: "Unread" }).click(); + await primaryFilters.getByRole("option", { name: "Unread" }).click(); // Unread filter should only show unread room and not unread dm! - const unreadDm = roomListView.getByRole("gridcell", { name: "Open room unread room" }); + const unreadDm = roomListView.getByRole("option", { name: "Open room unread room" }); await expect(unreadDm).toBeVisible(); await expect(unreadDm).toMatchScreenshot("unread-dm.png"); - await expect(roomListView.getByRole("gridcell", { name: "Open room unread dm" })).not.toBeVisible(); + await expect(roomListView.getByRole("option", { name: "Open room unread dm" })).not.toBeVisible(); }, ); @@ -285,7 +301,7 @@ test.describe("Room list filters and sort", () => { await getRoomOptionsMenu(page).click(); await page.getByRole("menuitemradio", { name: "A-Z" }).click(); - await expect(roomListView.getByRole("gridcell").first()).toHaveText(/empty room/); + await expect(roomListView.getByRole("option").first()).toHaveText(/empty room/); }); test("should move room to the top on message when sorting by activity", async ({ page, bot }) => { @@ -293,7 +309,7 @@ test.describe("Room list filters and sort", () => { await bot.sendMessage(unReadDmId, "Hello!"); - await expect(roomListView.getByRole("gridcell").first()).toHaveText(/unread dm/); + await expect(roomListView.getByRole("option").first()).toHaveText(/unread dm/); }); }); @@ -306,13 +322,22 @@ test.describe("Room list filters and sort", () => { return page.getByTestId("empty-room-list"); } + test("should render the primary filters", { tag: "@screenshot" }, async ({ page, app, user }) => { + const primaryFilters = getPrimaryFilters(page); + await expect(primaryFilters).toMatchScreenshot("collapsed-primary-filters.png"); + await getFilterExpandButton(page).click(); + await expect(primaryFilters).toMatchScreenshot("expanded-primary-filters.png"); + }); + test( "should render the default placeholder when there is no filter", { tag: "@screenshot" }, async ({ page, app, user }) => { const emptyRoomList = getEmptyRoomList(page); await expect(emptyRoomList).toMatchScreenshot("default-empty-room-list.png"); - await expect(page.getByTestId("room-list-panel")).toMatchScreenshot("room-panel-empty-room-list.png"); + await expect(page.getByRole("navigation", { name: "Room list" })).toMatchScreenshot( + "room-panel-empty-room-list.png", + ); }, ); @@ -326,6 +351,8 @@ test.describe("Room list filters and sort", () => { { tag: "@screenshot" }, async ({ page, app, user }) => { const primaryFilters = getPrimaryFilters(page); + await getFilterExpandButton(page).click(); + await primaryFilters.getByRole("option", { name: filter }).click(); const emptyRoomList = getEmptyRoomList(page); @@ -343,6 +370,8 @@ test.describe("Room list filters and sort", () => { { tag: "@screenshot" }, async ({ page, app, user }) => { const primaryFilters = getPrimaryFilters(page); + await getFilterExpandButton(page).click(); + await primaryFilters.getByRole("option", { name: filter }).click(); const emptyRoomList = getEmptyRoomList(page); diff --git a/playwright/e2e/left-panel/room-list-panel/room-list-header.spec.ts b/playwright/e2e/left-panel/room-list-panel/room-list-header.spec.ts index daa8d3869f..96e0ca8597 100644 --- a/playwright/e2e/left-panel/room-list-panel/room-list-header.spec.ts +++ b/playwright/e2e/left-panel/room-list-panel/room-list-header.spec.ts @@ -30,13 +30,13 @@ test.describe("Header section of the room list", () => { const roomListHeader = getHeaderSection(page); await expect(roomListHeader).toMatchScreenshot("room-list-header.png"); - const composeMenu = roomListHeader.getByRole("button", { name: "Add" }); + const composeMenu = roomListHeader.getByRole("button", { name: "New conversation" }); await composeMenu.click(); await expect(page.getByRole("menu")).toMatchScreenshot("room-list-header-compose-menu.png"); - // New message should open the direct messages dialog - await page.getByRole("menuitem", { name: "New message" }).click(); + // Start chat should open the direct messages dialog + await page.getByRole("menuitem", { name: "Start chat" }).click(); await expect(page.getByRole("heading", { name: "Direct Messages" })).toBeVisible(); await app.closeDialog(); @@ -55,7 +55,7 @@ test.describe("Header section of the room list", () => { await expect(roomListHeader).toMatchScreenshot("room-list-space-header.png"); await expect(roomListHeader.getByRole("heading", { name: "MySpace" })).toBeVisible(); - await expect(roomListHeader.getByRole("button", { name: "Add" })).toBeVisible(); + await expect(roomListHeader.getByRole("button", { name: "New conversation" })).toBeVisible(); const spaceMenu = roomListHeader.getByRole("button", { name: "Open space menu" }); await spaceMenu.click(); diff --git a/playwright/e2e/left-panel/room-list-panel/room-list-panel.spec.ts b/playwright/e2e/left-panel/room-list-panel/room-list-panel.spec.ts index 8ca138a707..bc1387cbce 100644 --- a/playwright/e2e/left-panel/room-list-panel/room-list-panel.spec.ts +++ b/playwright/e2e/left-panel/room-list-panel/room-list-panel.spec.ts @@ -19,7 +19,7 @@ test.describe("Room list panel", () => { * @param page */ function getRoomListView(page: Page) { - return page.getByTestId("room-list-panel"); + return page.getByRole("navigation", { name: "Room list" }); } test.beforeEach(async ({ page, app, user }) => { @@ -38,13 +38,13 @@ test.describe("Room list panel", () => { test("should render the room list panel", { tag: "@screenshot" }, async ({ page, app, user }) => { const roomListView = getRoomListView(page); // Wait for the last room to be visible - await expect(roomListView.getByRole("gridcell", { name: "Open room room19" })).toBeVisible(); + await expect(roomListView.getByRole("option", { name: "Open room room19" })).toBeVisible(); await expect(roomListView).toMatchScreenshot("room-list-panel.png"); }); test("should respond to small screen sizes", { tag: "@screenshot" }, async ({ page }) => { await page.setViewportSize({ width: 575, height: 600 }); - const roomListPanel = page.getByTestId("room-list-panel"); + const roomListPanel = getRoomListView(page); await expect(roomListPanel).toMatchScreenshot("room-list-panel-smallscreen.png"); }); }); diff --git a/playwright/e2e/left-panel/room-list-panel/room-list.spec.ts b/playwright/e2e/left-panel/room-list-panel/room-list.spec.ts index d9e1922934..5cc78eca69 100644 --- a/playwright/e2e/left-panel/room-list-panel/room-list.spec.ts +++ b/playwright/e2e/left-panel/room-list-panel/room-list.spec.ts @@ -41,28 +41,38 @@ test.describe("Room list", () => { } }); - test("should render the room list", { tag: "@screenshot" }, async ({ page, app, user }) => { + test("should render the room list", { tag: "@screenshot" }, async ({ page, app, user, axe }) => { const roomListView = getRoomList(page); - await expect(roomListView.getByRole("gridcell", { name: "Open room room29" })).toBeVisible(); + await expect(roomListView.getByRole("option", { name: "Open room room29" })).toBeVisible(); await expect(roomListView).toMatchScreenshot("room-list.png"); // Put focus on the room list - await roomListView.getByRole("gridcell", { name: "Open room room29" }).click(); + await roomListView.getByRole("option", { name: "Open room room29" }).click(); // Scroll to the end of the room list - await page.mouse.wheel(0, 1000); - await expect(roomListView.getByRole("gridcell", { name: "Open room room0" })).toBeVisible(); + await app.scrollListToBottom(roomListView); + + // scrollListToBottom seems to leave the mouse hovered over the list, move it away. + await page.getByRole("button", { name: "User menu" }).hover(); + + await expect(axe).toHaveNoViolations(); await expect(roomListView).toMatchScreenshot("room-list-scrolled.png"); }); test("should open the room when it is clicked", async ({ page, app, user }) => { const roomListView = getRoomList(page); - await roomListView.getByRole("gridcell", { name: "Open room room29" }).click(); + await roomListView.getByRole("option", { name: "Open room room29" }).click(); await expect(page.getByRole("heading", { name: "room29", level: 1 })).toBeVisible(); }); + test("should open the context menu", { tag: "@screenshot" }, async ({ page, app, user }) => { + const roomListView = getRoomList(page); + await roomListView.getByRole("option", { name: "Open room room29" }).click({ button: "right" }); + await expect(page.getByRole("menu", { name: "More Options" })).toBeVisible(); + }); + test("should open the more options menu", { tag: "@screenshot" }, async ({ page, app, user }) => { const roomListView = getRoomList(page); - const roomItem = roomListView.getByRole("gridcell", { name: "Open room room29" }); + const roomItem = roomListView.getByRole("option", { name: "Open room room29" }); await roomItem.hover(); await expect(roomItem).toMatchScreenshot("room-list-item-hover.png"); @@ -92,7 +102,7 @@ test.describe("Room list", () => { test("should open the notification options menu", { tag: "@screenshot" }, async ({ page, app, user }) => { const roomListView = getRoomList(page); - const roomItem = roomListView.getByRole("gridcell", { name: "Open room room29" }); + const roomItem = roomListView.getByRole("option", { name: "Open room room29" }); await roomItem.hover(); await expect(roomItem).toMatchScreenshot("room-list-item-hover.png"); @@ -109,10 +119,13 @@ test.describe("Room list", () => { // It should make the room muted await page.getByRole("menuitem", { name: "Mute room" }).click(); + await expect(roomItem.getByTestId("notification-decoration")).not.toBeVisible(); + // Put focus on the room list - await roomListView.getByRole("gridcell", { name: "Open room room28" }).click(); + await roomListView.getByRole("option", { name: "Open room room28" }).click(); + // Scroll to the end of the room list - await page.mouse.wheel(0, 1000); + await app.scrollListToBottom(roomListView); // The room decoration should have the muted icon await expect(roomItem.getByTestId("notification-decoration")).toBeVisible(); @@ -131,24 +144,25 @@ test.describe("Room list", () => { test("should scroll to the current room", async ({ page, app, user }) => { const roomListView = getRoomList(page); // Put focus on the room list - await roomListView.getByRole("gridcell", { name: "Open room room29" }).click(); + await roomListView.getByRole("option", { name: "Open room room29" }).click(); // Scroll to the end of the room list - await page.mouse.wheel(0, 1000); + await app.scrollListToBottom(roomListView); - await roomListView.getByRole("gridcell", { name: "Open room room0" }).click(); + await expect(roomListView.getByRole("option", { name: "Open room room0" })).toBeVisible(); + await roomListView.getByRole("option", { name: "Open room room0" }).click(); const filters = page.getByRole("listbox", { name: "Room list filters" }); await filters.getByRole("option", { name: "People" }).click(); - await expect(roomListView.getByRole("gridcell", { name: "Open room room0" })).not.toBeVisible(); + await expect(roomListView.getByRole("option", { name: "Open room room0" })).not.toBeVisible(); await filters.getByRole("option", { name: "People" }).click(); - await expect(roomListView.getByRole("gridcell", { name: "Open room room0" })).toBeVisible(); + await expect(roomListView.getByRole("option", { name: "Open room room0" })).toBeVisible(); }); test.describe("Shortcuts", () => { test("should select the next room", async ({ page, app, user }) => { const roomListView = getRoomList(page); - await roomListView.getByRole("gridcell", { name: "Open room room29" }).click(); + await roomListView.getByRole("option", { name: "Open room room29" }).click(); await page.keyboard.press("Alt+ArrowDown"); await expect(page.getByRole("heading", { name: "room28", level: 1 })).toBeVisible(); @@ -156,7 +170,7 @@ test.describe("Room list", () => { test("should select the previous room", async ({ page, app, user }) => { const roomListView = getRoomList(page); - await roomListView.getByRole("gridcell", { name: "Open room room28" }).click(); + await roomListView.getByRole("option", { name: "Open room room28" }).click(); await page.keyboard.press("Alt+ArrowUp"); await expect(page.getByRole("heading", { name: "room29", level: 1 })).toBeVisible(); @@ -164,7 +178,7 @@ test.describe("Room list", () => { test("should select the last room", async ({ page, app, user }) => { const roomListView = getRoomList(page); - await roomListView.getByRole("gridcell", { name: "Open room room29" }).click(); + await roomListView.getByRole("option", { name: "Open room room29" }).click(); await page.keyboard.press("Alt+ArrowUp"); await expect(page.getByRole("heading", { name: "room0", level: 1 })).toBeVisible(); @@ -178,7 +192,10 @@ test.describe("Room list", () => { await bot.joinRoom(roomId); await bot.sendMessage(roomId, "I am a robot. Beep."); - await roomListView.getByRole("gridcell", { name: "Open room room20" }).click(); + await roomListView.getByRole("option", { name: "Open room room20" }).click(); + + // Make sure the room with the unread is visible before we press the keyboard action to select it + await expect(roomListView.getByRole("option", { name: "1 notification" })).toBeVisible(); await page.keyboard.press("Alt+Shift+ArrowDown"); @@ -190,8 +207,8 @@ test.describe("Room list", () => { test("should navigate to the room list", async ({ page, app, user }) => { const roomListView = getRoomList(page); - const room29 = roomListView.getByRole("gridcell", { name: "Open room room29" }); - const room28 = roomListView.getByRole("gridcell", { name: "Open room room28" }); + const room29 = roomListView.getByRole("option", { name: "Open room room29" }); + const room28 = roomListView.getByRole("option", { name: "Open room room28" }); // open the room await room29.click(); @@ -210,7 +227,7 @@ test.describe("Room list", () => { test("should navigate to the notification menu", async ({ page, app, user }) => { const roomListView = getRoomList(page); - const room29 = roomListView.getByRole("gridcell", { name: "Open room room29" }); + const room29 = roomListView.getByRole("option", { name: "Open room room29" }); const moreButton = room29.getByRole("button", { name: "More options" }); const notificationButton = room29.getByRole("button", { name: "Notification options" }); @@ -223,17 +240,37 @@ test.describe("Room list", () => { await expect(notificationButton).toBeFocused(); // Open the menu - await notificationButton.click(); + await page.keyboard.press("Enter"); // Wait for the menu to be open await expect(page.getByRole("menuitem", { name: "Match default settings" })).toHaveAttribute( "aria-selected", "true", ); - // Close the menu + await page.keyboard.press("ArrowDown"); await page.keyboard.press("Escape"); - // Focus should be back on the room list item - await expect(room29).toBeFocused(); + // Focus should be back on the notification button + await expect(notificationButton).toBeFocused(); + }); + + test("should navigate to the top and then bottom of the room list", async ({ page, app, user }) => { + const roomListView = getRoomList(page); + + const topRoom = roomListView.getByRole("option", { name: "Open room room29" }); + + // open the room + await topRoom.click(); + // put focus back on the room list item + await topRoom.click(); + await expect(topRoom).toBeFocused(); + + await page.keyboard.press("End"); + const bottomRoom = roomListView.getByRole("option", { name: "Open room room0" }); + await expect(bottomRoom).toBeFocused(); + + await page.keyboard.press("Home"); + const topRoomAgain = roomListView.getByRole("option", { name: "Open room room29" }); + await expect(topRoomAgain).toBeFocused(); }); }); }); @@ -249,20 +286,43 @@ test.describe("Room list", () => { await page.getByRole("button", { name: "User menu" }).focus(); const roomListView = getRoomList(page); - const publicRoom = roomListView.getByRole("gridcell", { name: "public room" }); + const publicRoom = roomListView.getByRole("option", { name: "public room" }); await expect(publicRoom).toBeVisible(); await expect(publicRoom).toMatchScreenshot("room-list-item-public.png"); }); + test("should be a low priority room", { tag: "@screenshot" }, async ({ page, app, user }) => { + // @ts-ignore Visibility enum is not accessible + await app.client.createRoom({ name: "low priority room", visibility: "public" }); + const roomListView = getRoomList(page); + const publicRoom = roomListView.getByRole("option", { name: "low priority room" }); + + // Make room low priority + await publicRoom.click({ button: "right" }); + await page.getByRole("menuitemcheckbox", { name: "Low priority" }).click(); + + // Should have low priority decoration + await expect(publicRoom.locator(".mx_RoomAvatarView_icon")).toHaveAccessibleName( + "This is a low priority room", + ); + + // focus the header to avoid to have hover decoration + await page.getByTestId("room-list-header").click(); + await expect(publicRoom).toMatchScreenshot("room-list-item-low-priority.png"); + }); + test("should be a video room", { tag: "@screenshot" }, async ({ page, app, user }) => { - await page.getByTestId("room-list-panel").getByRole("button", { name: "Add" }).click(); + await page + .getByRole("navigation", { name: "Room list" }) + .getByRole("button", { name: "New conversation" }) + .click(); await page.getByRole("menuitem", { name: "New video room" }).click(); await page.getByRole("textbox", { name: "Name" }).fill("video room"); await page.getByRole("button", { name: "Create video room" }).click(); const roomListView = getRoomList(page); - const videoRoom = roomListView.getByRole("gridcell", { name: "video room" }); + const videoRoom = roomListView.getByRole("option", { name: "video room" }); // focus the user menu to avoid to have hover decoration await page.getByRole("button", { name: "User menu" }).focus(); @@ -281,7 +341,7 @@ test.describe("Room list", () => { invite: [user.userId], is_direct: true, }); - const invitedRoom = roomListView.getByRole("gridcell", { name: "invited room" }); + const invitedRoom = roomListView.getByRole("option", { name: "invited room" }); await expect(invitedRoom).toBeVisible(); await expect(invitedRoom).toMatchScreenshot("room-list-item-invited.png"); }); @@ -296,7 +356,7 @@ test.describe("Room list", () => { await bot.sendMessage(roomId, "I am a robot. Beep."); await bot.sendMessage(roomId, "I am a robot. Beep."); - const room = roomListView.getByRole("gridcell", { name: "2 notifications" }); + const room = roomListView.getByRole("option", { name: "2 notifications" }); await expect(room).toBeVisible(); await expect(room.getByTestId("notification-decoration")).toHaveText("2"); await expect(room).toMatchScreenshot("room-list-item-notification.png"); @@ -327,16 +387,17 @@ test.describe("Room list", () => { ); await bot.sendMessage(roomId, "I am a robot. Beep."); - const room = roomListView.getByRole("gridcell", { name: "mention" }); + const room = roomListView.getByRole("option", { name: "mention" }); await expect(room).toBeVisible(); await expect(room).toMatchScreenshot("room-list-item-mention.png"); }); test("should render a message preview", { tag: "@screenshot" }, async ({ page, app, user, bot }) => { - const roomListView = getRoomList(page); + await app.settings.openUserSettings("Preferences"); + await page.getByRole("switch", { name: "Show message previews" }).click(); + await app.closeDialog(); - await page.getByRole("button", { name: "Room Options" }).click(); - await page.getByRole("menuitemcheckbox", { name: "Show message previews" }).click(); + const roomListView = getRoomList(page); const roomId = await app.client.createRoom({ name: "activity" }); @@ -347,7 +408,7 @@ test.describe("Room list", () => { await bot.joinRoom(roomId); await bot.sendMessage(roomId, "I am a robot. Beep."); - const room = roomListView.getByRole("gridcell", { name: "activity" }); + const room = roomListView.getByRole("option", { name: "activity" }); await expect(room.getByText("I am a robot. Beep.")).toBeVisible(); await expect(room).toMatchScreenshot("room-list-item-message-preview.png"); }); @@ -367,14 +428,16 @@ test.describe("Room list", () => { await app.settings.closeDialog(); await app.settings.openUserSettings("Notifications"); - await page.getByText("Show all activity in the room list (dots or number of unread messages)").click(); + await page + .getByRole("switch", { name: "Show all activity in the room list (dots or number of unread messages)" }) + .check(); await app.settings.closeDialog(); // Switch to the other room to avoid the notification to be cleared await app.viewRoomById(otherRoomId); await bot.sendMessage(roomId, "I am a robot. Beep."); - const room = roomListView.getByRole("gridcell", { name: "activity" }); + const room = roomListView.getByRole("option", { name: "activity" }); await expect(room.getByTestId("notification-decoration")).toBeVisible(); await expect(room).toMatchScreenshot("room-list-item-activity.png"); }); @@ -386,13 +449,12 @@ test.describe("Room list", () => { await app.client.inviteUser(roomId, bot.credentials.userId); await bot.joinRoom(roomId); - const room = roomListView.getByRole("gridcell", { name: "mark as unread" }); - await room.hover(); - await room.getByRole("button", { name: "More Options" }).click(); + const room = roomListView.getByRole("option", { name: "mark as unread" }); + await room.click({ button: "right" }); await page.getByRole("menuitem", { name: "mark as unread" }).click(); - // focus the user menu to avoid to have hover decoration - await page.getByRole("button", { name: "User menu" }).focus(); + // focus the header to avoid to have hover decoration + await page.getByTestId("room-list-header").click(); await expect(room).toMatchScreenshot("room-list-item-mark-as-unread.png"); }); @@ -409,7 +471,7 @@ test.describe("Room list", () => { await page.getByText("Off").click(); await app.settings.closeDialog(); - const room = roomListView.getByRole("gridcell", { name: "silent" }); + const room = roomListView.getByRole("option", { name: "silent" }); await expect(room.getByTestId("notification-decoration")).toBeVisible(); await expect(room).toMatchScreenshot("room-list-item-silent.png"); }); diff --git a/playwright/e2e/location/location.spec.ts b/playwright/e2e/location/location.spec.ts index 52afd5e173..5ea371b70d 100644 --- a/playwright/e2e/location/location.spec.ts +++ b/playwright/e2e/location/location.spec.ts @@ -1,5 +1,5 @@ /* -Copyright 2024 New Vector Ltd. +Copyright 2024, 2025 New Vector Ltd. Copyright 2022 The Matrix.org Foundation C.I.C. SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial @@ -30,31 +30,59 @@ test.describe("Location sharing", { tag: "@no-firefox" }, () => { }); }); - test("sends and displays pin drop location message successfully", async ({ page, user, app }) => { - const roomId = await app.client.createRoom({}); - await page.goto(`/#/room/${roomId}`); + test( + "sends and displays pin drop location message successfully", + { tag: "@screenshot" }, + async ({ page, user, app }) => { + const roomId = await app.client.createRoom({}); + await page.goto(`/#/room/${roomId}`); - const composerOptions = await app.openMessageComposerOptions(); - await composerOptions.getByRole("menuitem", { name: "Location", exact: true }).click(); + const composerOptions = await app.openMessageComposerOptions(); + await composerOptions.getByRole("menuitem", { name: "Location", exact: true }).click(); - await selectLocationShareTypeOption(page, "Pin").click(); + await selectLocationShareTypeOption(page, "Pin").click(); - await page.locator("#mx_LocationPicker_map").click(); + await page.locator("#mx_LocationPicker_map").click(); - await submitShareLocation(page); + await submitShareLocation(page); - await page.locator(".mx_RoomView_body .mx_EventTile .mx_MLocationBody").click({ - position: { - x: 225, - y: 150, - }, - }); + await page.getByRole("button", { name: "Map marker" }).click(); - // clicking location tile opens maximised map - await expect(page.getByRole("dialog")).toBeVisible(); + const dialog = page.getByRole("dialog"); - await app.closeDialog(); + // wait for the dialog to be visible + await expect(dialog).toBeVisible(); - await expect(page.locator(".mx_Marker")).toBeVisible(); - }); + // screenshot the map within the dialog + await expect(dialog.getByRole("region", { name: "Map" })).toMatchScreenshot( + "location-pin-drop-message-map.png", + ); + + await app.closeDialog(); + + await expect(page.getByRole("button", { name: "Map marker" })).toBeVisible(); + }, + ); + + test( + "is prompted for and can consent to live location sharing", + { tag: "@screenshot" }, + async ({ page, user, app, axe }) => { + await app.viewRoomById(await app.client.createRoom({})); + + const composerOptions = await app.openMessageComposerOptions(); + await composerOptions.getByRole("menuitem", { name: "Location", exact: true }).click(); + const menu = page.locator(".mx_LocationShareMenu"); + + await menu.getByRole("button", { name: "My live location" }).click(); + await menu.getByLabel("Enable live location sharing").check(); + + axe.disableRules([ + "color-contrast", // XXX: Inheriting colour contrast issues from room view. + "region", // XXX: ContextMenu managed=false does not provide a role. + ]); + await expect(axe).toHaveNoViolations(); + await expect(menu).toMatchScreenshot("location-live-share-dialog.png"); + }, + ); }); diff --git a/playwright/e2e/login/login-consent.spec.ts b/playwright/e2e/login/login-consent.spec.ts index 23baf023fa..33dd88df6c 100644 --- a/playwright/e2e/login/login-consent.spec.ts +++ b/playwright/e2e/login/login-consent.spec.ts @@ -129,6 +129,7 @@ test.describe("Login", () => { await expect(page.getByRole("heading", { name: "Welcome to Element!" })).toBeVisible(); // Start the login process + await expect(axe).toHaveNoViolations(); await page.getByRole("link", { name: "Sign in" }).click(); // first pick the homeserver, as otherwise the user picker won't be visible @@ -148,8 +149,6 @@ test.describe("Login", () => { await selectHomeserver(page, homeserver.baseUrl); await expect(page.getByRole("textbox", { name: "Username" })).toBeVisible(); - // Disabled because flaky - see https://github.com/vector-im/element-web/issues/24688 - // cy.percySnapshot("Login"); await expect(axe).toHaveNoViolations(); await page.getByRole("textbox", { name: "Username" }).fill(credentials.username); @@ -186,7 +185,7 @@ test.describe("Login", () => { await page.goto("/"); await login(page, homeserver, credentials); - await expect(page.getByRole("heading", { name: "Verify this device", level: 1 })).toBeVisible(); + await expect(page.getByRole("heading", { name: "Confirm your identity", level: 2 })).toBeVisible(); await expect(page.getByRole("button", { name: "Skip verification for now" })).toBeVisible(); }); @@ -219,7 +218,7 @@ test.describe("Login", () => { await page.goto("/"); await login(page, homeserver, credentials); - await expect(page.getByRole("heading", { name: "Verify this device", level: 1 })).toBeVisible(); + await expect(page.getByRole("heading", { name: "Confirm your identity", level: 2 })).toBeVisible(); await expect(page.getByRole("button", { name: "Skip verification for now" })).toBeVisible(); }); @@ -254,10 +253,10 @@ test.describe("Login", () => { await page.goto("/"); await login(page, homeserver, credentials); - const h1 = page.getByRole("heading", { name: "Verify this device", level: 1 }); - await expect(h1).toBeVisible(); + const h2 = page.getByRole("heading", { name: "Confirm your identity", level: 2 }); + await expect(h2).toBeVisible(); - await expect(h1.locator(".mx_CompleteSecurity_skip")).toHaveCount(0); + await expect(h2.locator(".mx_CompleteSecurity_skip")).toHaveCount(0); }); test("Continues to show verification prompt after cancelling device verification", async ({ @@ -274,18 +273,18 @@ test.describe("Login", () => { // Load the page and see that we are asked to verify await page.goto("/#/welcome"); await login(page, homeserver, credentials); - let h1 = page.getByRole("heading", { name: "Verify this device", level: 1 }); - await expect(h1).toBeVisible(); + let h2 = page.getByRole("heading", { name: "Confirm your identity", level: 2 }); + await expect(h2).toBeVisible(); - // Click "Verify with another device" - await page.getByRole("button", { name: "Verify with another device" }).click(); + // Click "Use another device" + await page.getByRole("button", { name: "Use another device" }).click(); // Cancel the new dialog await page.getByRole("button", { name: "Close dialog" }).click(); // Check that we are still being asked to verify - h1 = page.getByRole("heading", { name: "Verify this device", level: 1 }); - await expect(h1).toBeVisible(); + h2 = page.getByRole("heading", { name: "Confirm your identity", level: 2 }); + await expect(h2).toBeVisible(); }); }); @@ -303,18 +302,18 @@ test.describe("Login", () => { await page.goto("/"); await login(page, homeserver, credentials); - await expect(page.getByRole("heading", { name: "Verify this device", level: 1 })).toBeVisible(); + await expect(page.getByRole("heading", { name: "Confirm your identity", level: 2 })).toBeVisible(); // Start the reset process - await page.getByRole("button", { name: "Proceed with reset" }).click(); + await page.getByRole("button", { name: "Can't confirm?" }).click(); // First try cancelling and restarting await page.getByRole("button", { name: "Cancel" }).click(); - await page.getByRole("button", { name: "Proceed with reset" }).click(); + await page.getByRole("button", { name: "Can't confirm?" }).click(); // Then click outside the dialog and restart await page.getByRole("link", { name: "Powered by Matrix" }).click({ force: true }); - await page.getByRole("button", { name: "Proceed with reset" }).click(); + await page.getByRole("button", { name: "Can't confirm?" }).click(); // Finally we actually continue await page.getByRole("button", { name: "Continue" }).click(); diff --git a/playwright/e2e/login/login.spec.ts b/playwright/e2e/login/login.spec.ts new file mode 100644 index 0000000000..56025d9405 --- /dev/null +++ b/playwright/e2e/login/login.spec.ts @@ -0,0 +1,30 @@ +/* +Copyright 2025 Element Creations Ltd. + +SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial +Please see LICENSE files in the repository root for full details. +*/ + +import { expect, test } from "../../element-web-test"; +import { logIntoElement } from "../crypto/utils.ts"; + +test.describe(`With force_verification: true`, () => { + test.use({ + config: { + force_verification: true, + }, + }); + + test("Can reload after login", async ({ page, credentials }) => { + // The page should reload fine when going to the base client URL + // Regression test for https://github.com/element-hq/element-web/issues/31203 + await logIntoElement(page, credentials); + + // We should auto-upload the E2EE keys, and show a welcome page + await expect(page.getByRole("heading", { name: `Welcome ${credentials.displayName}` })).toBeVisible(); + + await page.goto("/"); + + await expect(page.getByRole("heading", { name: `Welcome ${credentials.displayName}` })).toBeVisible(); + }); +}); diff --git a/playwright/e2e/login/utils.ts b/playwright/e2e/login/utils.ts index d74300908a..e9ff05bb69 100644 --- a/playwright/e2e/login/utils.ts +++ b/playwright/e2e/login/utils.ts @@ -51,6 +51,7 @@ export async function doTokenRegistration( await expect(page.getByRole("heading", { name: "Welcome Alice", exact: true })).toBeVisible(); return page.evaluate(() => ({ + homeserverBaseUrl: window.mxMatrixClientPeg.get().getHomeserverUrl(), accessToken: window.mxMatrixClientPeg.get().getAccessToken(), userId: window.mxMatrixClientPeg.get().getUserId(), deviceId: window.mxMatrixClientPeg.get().getDeviceId(), diff --git a/playwright/e2e/messages/messages.spec.ts b/playwright/e2e/messages/messages.spec.ts index f430d6b18b..a5ea38482c 100644 --- a/playwright/e2e/messages/messages.spec.ts +++ b/playwright/e2e/messages/messages.spec.ts @@ -13,7 +13,7 @@ import { type Locator, type Page } from "@playwright/test"; import { test, expect } from "../../element-web-test"; async function sendMessage(page: Page, message: string): Promise { - await page.getByRole("textbox", { name: "Send a message…" }).fill(message); + await page.getByRole("textbox", { name: "Send an unencrypted message…" }).fill(message); await page.getByRole("button", { name: "Send message" }).click(); const msgTile = page.locator(".mx_EventTile_last"); @@ -22,7 +22,7 @@ async function sendMessage(page: Page, message: string): Promise { } async function sendMultilineMessages(page: Page, messages: string[]) { - await page.getByRole("textbox", { name: "Send a message…" }).focus(); + await page.getByRole("textbox", { name: "Send an unencrypted message…" }).focus(); for (let i = 0; i < messages.length; i++) { await page.keyboard.type(messages[i]); if (i < messages.length - 1) await page.keyboard.press("Shift+Enter"); @@ -40,7 +40,7 @@ async function replyMessage(page: Page, message: Locator, replyMessage: string): await line.hover(); await line.getByRole("button", { name: "Reply", exact: true }).click(); - await page.getByRole("textbox", { name: "Send a reply…" }).fill(replyMessage); + await page.getByRole("textbox", { name: "Send an unencrypted reply…" }).fill(replyMessage); await page.getByRole("button", { name: "Send message" }).click(); const msgTile = page.locator(".mx_EventTile_last"); @@ -59,9 +59,11 @@ async function editMessage(page: Page, message: Locator, newMsg: string): Promis } const screenshotOptions = (page?: Page) => ({ - mask: page ? [page.locator(".mx_MessageTimestamp")] : undefined, // Hide the jump to bottom button in the timeline to avoid flakiness css: ` + .mx_MessageTimestamp { + visibility: hidden; + } .mx_JumpToBottomButton { display: none !important; } diff --git a/playwright/e2e/mobile-guide/mobile-guide.spec.ts b/playwright/e2e/mobile-guide/mobile-guide.spec.ts new file mode 100644 index 0000000000..ff2ad69bf8 --- /dev/null +++ b/playwright/e2e/mobile-guide/mobile-guide.spec.ts @@ -0,0 +1,36 @@ +/* +Copyright 2025 New Vector Ltd. + +SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial +Please see LICENSE files in the repository root for full details. +*/ + +import { test, expect } from "../../element-web-test"; +import { MobileAppVariant } from "../../../src/vector/mobile_guide/mobile-apps"; + +const variants = [MobileAppVariant.Classic, MobileAppVariant.X, MobileAppVariant.Pro]; + +test.describe("Mobile Guide Screenshots", { tag: "@screenshot" }, () => { + for (const variant of variants) { + test.describe(`for variant ${variant}`, () => { + test.use({ + config: { + default_server_config: { + "m.homeserver": { + base_url: "https://matrix.server.invalid", + server_name: "server.invalid", + }, + }, + mobile_guide_app_variant: variant, + }, + viewport: { width: 390, height: 844 }, // iPhone 16e + }); + + test("should match the mobile_guide screenshot", async ({ page, axe }) => { + await page.goto("/mobile_guide/"); + await expect(page).toMatchScreenshot(`mobile-guide-${variant}.png`); + await expect(axe).toHaveNoViolations(); + }); + }); + } +}); diff --git a/playwright/e2e/modules/custom-component.spec.ts b/playwright/e2e/modules/custom-component.spec.ts new file mode 100644 index 0000000000..d8f4d4646d --- /dev/null +++ b/playwright/e2e/modules/custom-component.spec.ts @@ -0,0 +1,162 @@ +/* +Copyright 2025 New Vector Ltd. + +SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial +Please see LICENSE files in the repository root for full details. +*/ + +import { type Page } from "@playwright/test"; +import fs from "node:fs"; + +import { test, expect } from "../../element-web-test"; + +const screenshotOptions = (page: Page) => ({ + // Hide the jump to bottom button in the timeline to avoid flakiness + // Exclude timestamp and read marker from snapshot + css: ` + .mx_MessageTimestamp { + visibility: hidden; + } + .mx_JumpToBottomButton { + display: none !important; + } + .mx_TopUnreadMessagesBar, .mx_MessagePanel_myReadMarker { + display: none !important; + } + `, +}); + +const IMAGE_FILE = fs.readFileSync("playwright/sample-files/element.png"); + +test.describe("Custom Component API", () => { + test.use({ + displayName: "Manny", + config: { + modules: ["/modules/custom-component-module.js"], + }, + page: async ({ page }, use) => { + await page.route("/modules/custom-component-module.js", async (route) => { + await route.fulfill({ path: "playwright/sample-files/custom-component-module.js" }); + }); + await use(page); + }, + room: async ({ page, app, user, bot }, use) => { + const roomId = await app.client.createRoom({ name: "TestRoom" }); + await use({ roomId }); + }, + }); + test.describe("basic functionality", () => { + test( + "should replace the render method of a textual event", + { tag: "@screenshot" }, + async ({ page, room, app }) => { + await app.viewRoomById(room.roomId); + await app.client.sendMessage(room.roomId, "Simple message"); + await expect(await page.locator(".mx_EventTile_last")).toMatchScreenshot( + "custom-component-tile.png", + screenshotOptions(page), + ); + }, + ); + test( + "should fall through if one module does not render a component", + { tag: "@screenshot" }, + async ({ page, room, app }) => { + await app.viewRoomById(room.roomId); + await app.client.sendMessage(room.roomId, "Fall through here"); + await expect(await page.locator(".mx_EventTile_last")).toMatchScreenshot( + "custom-component-tile-fall-through.png", + screenshotOptions(page), + ); + }, + ); + test( + "should render the original content of a textual event conditionally", + { tag: "@screenshot" }, + async ({ page, room, app }) => { + await app.viewRoomById(room.roomId); + await app.client.sendMessage(room.roomId, "Do not replace me"); + await expect(await page.locator(".mx_EventTile_last")).toMatchScreenshot( + "custom-component-tile-original.png", + screenshotOptions(page), + ); + }, + ); + test("should disallow editing when the allowEditingEvent hint is set to false", async ({ page, room, app }) => { + await app.viewRoomById(room.roomId); + await app.client.sendMessage(room.roomId, "Do not show edits"); + await page.getByText("Do not show edits").hover(); + await expect( + await page.getByRole("toolbar", { name: "Message Actions" }).getByRole("button", { name: "Edit" }), + ).not.toBeVisible(); + }); + test("should disallow downloading media when the allowDownloading hint is set to false", async ({ + page, + room, + app, + }) => { + await app.viewRoomById(room.roomId); + await app.viewRoomById(room.roomId); + const upload = await app.client.uploadContent(IMAGE_FILE, { name: "bad.png", type: "image/png" }); + await app.client.sendEvent(room.roomId, null, "m.room.message", { + msgtype: "m.image", + body: "bad.png", + url: upload.content_uri, + }); + + await app.timeline.scrollToBottom(); + const imgTile = page.locator(".mx_MImageBody").first(); + await expect(imgTile).toBeVisible(); + await imgTile.hover(); + await expect(page.getByRole("button", { name: "Download" })).not.toBeVisible(); + await imgTile.click(); + await expect(page.getByLabel("Image view").getByLabel("Download")).not.toBeVisible(); + }); + test("should allow downloading media when the allowDownloading hint is set to true", async ({ + page, + room, + app, + }) => { + await app.viewRoomById(room.roomId); + await app.viewRoomById(room.roomId); + const upload = await app.client.uploadContent(IMAGE_FILE, { name: "good.png", type: "image/png" }); + await app.client.sendEvent(room.roomId, null, "m.room.message", { + msgtype: "m.image", + body: "good.png", + url: upload.content_uri, + }); + + await app.timeline.scrollToBottom(); + const imgTile = page.locator(".mx_MImageBody").first(); + await expect(imgTile).toBeVisible(); + await imgTile.hover(); + await expect(page.getByRole("button", { name: "Download" })).toBeVisible(); + await imgTile.click(); + await expect(page.getByLabel("Image view").getByLabel("Download")).toBeVisible(); + }); + test( + "should render the next registered component if the filter function throws", + { tag: "@screenshot" }, + async ({ page, room, app }) => { + await app.viewRoomById(room.roomId); + await app.client.sendMessage(room.roomId, "Crash the filter!"); + await expect(await page.locator(".mx_EventTile_last")).toMatchScreenshot( + "custom-component-crash-handle-filter.png", + screenshotOptions(page), + ); + }, + ); + test( + "should render original component if the render function throws", + { tag: "@screenshot" }, + async ({ page, room, app }) => { + await app.viewRoomById(room.roomId); + await app.client.sendMessage(room.roomId, "Crash the renderer!"); + await expect(await page.locator(".mx_EventTile_last")).toMatchScreenshot( + "custom-component-crash-handle-renderer.png", + screenshotOptions(page), + ); + }, + ); + }); +}); diff --git a/playwright/e2e/oidc/index.ts b/playwright/e2e/oidc/index.ts index 02de6e2f03..50aa2de8d5 100644 --- a/playwright/e2e/oidc/index.ts +++ b/playwright/e2e/oidc/index.ts @@ -11,6 +11,9 @@ import { type Page } from "@playwright/test"; import { expect } from "../../element-web-test"; +/** + * Click through registering a new user in the MAS UI. + */ export async function registerAccountMas( page: Page, mailpit: MailpitClient, @@ -39,6 +42,20 @@ export async function registerAccountMas( await page.getByRole("button", { name: "Continue" }).click(); await page.getByRole("textbox", { name: "Display Name" }).fill(username); await page.getByRole("button", { name: "Continue" }).click(); - await expect(page.getByText("Allow access to your account?")).toBeVisible(); + await expect(page.getByText("Continue to Element?")).toBeVisible(); + await page.getByRole("button", { name: "Continue" }).click(); +} + +/** + * Click through entering username and password into the MAS login prompt. + */ +export async function logInAccountMas(page: Page, username: string, password: string): Promise { + await expect(page.getByText("Please sign in to continue:")).toBeVisible(); + + await page.getByRole("textbox", { name: "Username" }).fill(username); + await page.getByRole("textbox", { name: "Password", exact: true }).fill(password); + await page.getByRole("button", { name: "Continue" }).click(); + + await expect(page.getByText("Continue to Element?")).toBeVisible(); await page.getByRole("button", { name: "Continue" }).click(); } diff --git a/playwright/e2e/oidc/oidc-native.spec.ts b/playwright/e2e/oidc/oidc-native.spec.ts index e985e58d09..3d80c7d56f 100644 --- a/playwright/e2e/oidc/oidc-native.spec.ts +++ b/playwright/e2e/oidc/oidc-native.spec.ts @@ -6,8 +6,13 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com Please see LICENSE files in the repository root for full details. */ +import { type Config } from "@element-hq/element-web-playwright-common"; +import { type Browser, type Page } from "@playwright/test"; +import { type StartedHomeserverContainer } from "@element-hq/element-web-playwright-common/lib/testcontainers/HomeserverContainer"; +import { routeConfigJson } from "@element-hq/element-web-playwright-common"; + import { test, expect } from "../../element-web-test.ts"; -import { registerAccountMas } from "."; +import { logInAccountMas, registerAccountMas } from "."; import { ElementAppPage } from "../../pages/ElementAppPage.ts"; import { masHomeserver } from "../../plugins/homeserver/synapse/masHomeserver.ts"; @@ -33,7 +38,7 @@ test.describe("OIDC Native", { tag: ["@no-firefox", "@no-webkit"] }, () => { await page.getByRole("button", { name: "Continue" }).click(); const userId = `alice_${testInfo.testId}`; - await registerAccountMas(page, mailpitClient, userId, "alice@email.com", "Pa$sW0rD!"); + await registerAccountMas(page, mailpitClient, userId, `${userId}@email.com`, "Pa$sW0rD!"); // Eventually, we should end up at the home screen. await expect(page).toHaveURL(/\/#\/home$/, { timeout: 10000 }); @@ -55,7 +60,7 @@ test.describe("OIDC Native", { tag: ["@no-firefox", "@no-webkit"] }, () => { const newPage = await newPagePromise; await newPage.getByText("Devices").click(); await newPage.getByText(deviceId).click(); - await expect(newPage.getByText("Element")).toBeVisible(); + await expect(newPage.getByText("Element", { exact: true })).toBeVisible(); await expect(newPage.getByText("http://localhost:8080/")).toBeVisible(); await expect(newPage).toHaveURL(/\/oauth2_session/); await newPage.close(); @@ -77,13 +82,13 @@ test.describe("OIDC Native", { tag: ["@no-firefox", "@no-webkit"] }, () => { test( "it should log out the user & wipe data when logging out via MAS", { tag: "@screenshot" }, - async ({ mas, page, mailpitClient }, testInfo) => { + async ({ mas, page, mailpitClient, homeserver }, testInfo) => { // We use this over the `user` fixture to ensure we get an OIDC session rather than a compatibility one await page.goto("/#/login"); await page.getByRole("button", { name: "Continue" }).click(); const userId = `alice_${testInfo.testId}`; - await registerAccountMas(page, mailpitClient, userId, "alice@email.com", "Pa$sW0rD!"); + await registerAccountMas(page, mailpitClient, userId, `${userId}@email.com`, "Pa$sW0rD!"); await expect(page.getByText("Welcome")).toBeVisible(); await page.goto("about:blank"); @@ -95,10 +100,149 @@ test.describe("OIDC Native", { tag: ["@no-firefox", "@no-webkit"] }, () => { await expect( page.getByText("For security, this session has been signed out. Please sign in again."), ).toBeVisible(); - await expect(page).toMatchScreenshot("token-expired.png", { includeDialogBackground: true }); + //await expect(page).toMatchScreenshot("token-expired.png", { includeDialogBackground: true }); const localStorageKeys = await page.evaluate(() => Object.keys(localStorage)); expect(localStorageKeys).toHaveLength(0); }, ); + + test("can log in to an existing MAS account", { tag: "@screenshot" }, async ({ page, mailpitClient }, testInfo) => { + // Register an account with MAS + await page.goto("/#/login"); + await page.getByRole("button", { name: "Continue" }).click(); + + const userId = `alice_${testInfo.testId}`; + await registerAccountMas(page, mailpitClient, userId, `${userId}@email.com`, "Pa$sW0rD!"); + await expect(page.getByText("Welcome")).toBeVisible(); + + // Log out + await page.getByRole("button", { name: "User menu" }).click(); + await expect(page.getByText(userId, { exact: true })).toBeVisible(); + + // Allow the outstanding requests queue to settle before logging out + await page.waitForTimeout(2000); + await page.locator(".mx_UserMenu_contextMenu").getByRole("menuitem", { name: "Sign out" }).click(); + await expect(page).toHaveURL(/\/#\/login$/); + + // Log in again + await page.goto("/#/login"); + await page.getByRole("button", { name: "Continue" }).click(); + await page.getByRole("button", { name: "Continue" }).click(); + + // We should be in + await expect(page.getByText("Confirm your identity")).toBeVisible(); + }); + + test.describe("with force_verification on", () => { + test.use({ + config: { + force_verification: true, + }, + }); + + test("verify dialog cannot be dismissed", { tag: "@screenshot" }, async ({ page, mailpitClient }, testInfo) => { + // Register an account with MAS + await page.goto("/#/login"); + await page.getByRole("button", { name: "Continue" }).click(); + + const userId = `alice_${testInfo.testId}`; + await registerAccountMas(page, mailpitClient, userId, `${userId}@email.com`, "Pa$sW0rD!"); + await expect(page.getByText("Welcome")).toBeVisible(); + + // Log out + await page.getByRole("button", { name: "User menu" }).click(); + await expect(page.getByText(userId, { exact: true })).toBeVisible(); + await page.waitForTimeout(2000); + await page.locator(".mx_UserMenu_contextMenu").getByRole("menuitem", { name: "Sign out" }).click(); + await expect(page).toHaveURL(/\/#\/login$/); + + // Log in again + await page.goto("/#/login"); + await page.getByRole("button", { name: "Continue" }).click(); + await page.getByRole("button", { name: "Continue" }).click(); + + // We should be being warned that we need to verify (but we can't) + await expect(page.getByText("Confirm your identity")).toBeVisible(); + + // And there should be no way to close this prompt + await expect(page.getByRole("button", { name: "Skip verification for now" })).not.toBeVisible(); + }); + + test( + "continues to show verification prompt after cancelling device verification", + { tag: "@screenshot" }, + async ({ browser, config, homeserver, page, mailpitClient }, testInfo) => { + // Register an account with MAS + await page.goto("/#/login"); + await page.getByRole("button", { name: "Continue" }).click(); + + const userId = `alice_${testInfo.testId}`; + const password = "Pa$sW0rD!"; + await registerAccountMas(page, mailpitClient, userId, `${userId}@email.com`, password); + await expect(page.getByText("Welcome")).toBeVisible(); + + // Log in an additional account, and verify it. + // + // This means that when we log out and in again, we are offered + // to verify using another device. + const otherContext = await newContext(browser, config, homeserver); + const otherDevicePage = await otherContext.newPage(); + await otherDevicePage.goto("/#/login"); + await otherDevicePage.getByRole("button", { name: "Continue" }).click(); + await logInAccountMas(otherDevicePage, userId, password); + await verifyUsingOtherDevice(otherDevicePage, page); + await otherDevicePage.close(); + + // Log out + await page.getByRole("button", { name: "User menu" }).click(); + await expect(page.getByText(userId, { exact: true })).toBeVisible(); + await page.waitForTimeout(2000); + await page.locator(".mx_UserMenu_contextMenu").getByRole("menuitem", { name: "Sign out" }).click(); + await expect(page).toHaveURL(/\/#\/login$/); + + // Log in again + await page.goto("/#/login"); + await page.getByRole("button", { name: "Continue" }).click(); + await page.getByRole("button", { name: "Continue" }).click(); + + // We should be in, and not able to dismiss the verify dialog + await expect(page.getByText("Verify this device")).toBeVisible(); + await expect(page.getByRole("button", { name: "Skip verification for now" })).not.toBeVisible(); + + // When we start verifying with another device + await page.getByRole("button", { name: "Use another device" }).click(); + + // And then cancel it + await page.getByRole("button", { name: "Close dialog" }).click(); + + // Then we should still be at the unskippable verify prompt + await expect(page.getByText("Verify this device")).toBeVisible(); + await expect(page.getByRole("button", { name: "Skip verification for now" })).not.toBeVisible(); + }, + ); + }); }); + +/** + * Perform interactive emoji verification for a new device. + */ +async function verifyUsingOtherDevice(deviceToVerifyPage: Page, alreadyVerifiedDevicePage: Page) { + await deviceToVerifyPage.getByRole("button", { name: "Use another device" }).click(); + await alreadyVerifiedDevicePage.getByRole("button", { name: "Start verification" }).click(); + await alreadyVerifiedDevicePage.getByRole("button", { name: "Start" }).click(); + await alreadyVerifiedDevicePage.getByRole("button", { name: "They match" }).click(); + await deviceToVerifyPage.getByRole("button", { name: "They match" }).click(); + await alreadyVerifiedDevicePage.getByRole("button", { name: "Got it" }).click(); + await deviceToVerifyPage.getByRole("button", { name: "Got it" }).click(); +} + +/** + * Create a new browser context which serves up the default config plus what you supplied, and sets m.homeserver to the + * supplied homeserver's URL. + */ +async function newContext(browser: Browser, config: Partial>, homeserver: StartedHomeserverContainer) { + const otherContext = await browser.newContext(); + await routeConfigJson(otherContext, homeserver.baseUrl, config); + return otherContext; +} diff --git a/playwright/e2e/permalinks/permalinks.spec.ts b/playwright/e2e/permalinks/permalinks.spec.ts index e7657b1394..cd1aa5c42e 100644 --- a/playwright/e2e/permalinks/permalinks.spec.ts +++ b/playwright/e2e/permalinks/permalinks.spec.ts @@ -93,10 +93,60 @@ test.describe("permalinks", () => { getPill(timeline, danielleId); await expect(timeline).toMatchScreenshot("permalink-rendering.png", { - mask: [ - // Exclude timestamps from the snapshot, for consistency. - page.locator(".mx_MessageTimestamp"), - ], + // Exclude timestamps from the snapshot, for consistency. + css: ` + .mx_MessageTimestamp { + visibility: hidden; + } + `, }); }); }); + +test.describe("triple-click message selection", () => { + test.use({ + displayName: "Alice", + }); + + test("should select entire message line when triple-clicking on message with pills", async ({ + page, + app, + user, + bot, + }) => { + await bot.prepareClient(); + + const roomId = await app.client.createRoom({ name: "Test Room" }); + await app.client.inviteUser(roomId, bot.credentials.userId); + await app.viewRoomByName("Test Room"); + + // Send a message with user and room pills + await app.client.sendMessage( + roomId, + `Testing triple-click message selection. ` + + `User: ${permalinkPrefix}${bot.credentials.userId}, ` + + `Room: ${permalinkPrefix}${roomId}, ` + + `Message: ${permalinkPrefix}${roomId}/$dummy-event, ` + + `and @room mention.`, + ); + + const timeline = page.locator(".mx_RoomView_timeline"); + const messageTile = timeline.locator(".mx_EventTile").last(); + + // Triple-click on the message body to select its entire content + const messageBody = messageTile.locator(".mx_EventTile_body"); + await messageBody.click({ clickCount: 3 }); + + // Get the expected text content of the message, including pills + const expectedText = await messageBody.innerText(); + + // Get the currently selected text from the page + const selectedText = await page.evaluate(() => { + const selection = window.getSelection(); + return selection ? selection.toString().trim() : ""; + }); + + // Verify that the selected text exactly matches the message content + expect(selectedText).toBe(expectedText); + }); +}); diff --git a/playwright/e2e/pinned-messages/index.ts b/playwright/e2e/pinned-messages/index.ts index 198ab24118..793dc1b45e 100644 --- a/playwright/e2e/pinned-messages/index.ts +++ b/playwright/e2e/pinned-messages/index.ts @@ -29,6 +29,7 @@ export const test = base.extend<{ room1Name: "Room 1", room1: async ({ room1Name: name, app, user, bot }, use) => { const roomId = await app.client.createRoom({ name, invite: [bot.credentials.userId] }); + await bot.awaitRoomMembership(roomId); await use({ name, roomId }); }, diff --git a/playwright/e2e/polls/polls.spec.ts b/playwright/e2e/polls/polls.spec.ts index fc49906b47..f10b46f209 100644 --- a/playwright/e2e/polls/polls.spec.ts +++ b/playwright/e2e/polls/polls.spec.ts @@ -121,7 +121,11 @@ test.describe("Polls", () => { .filter({ hasText: pollParams.title }) .getAttribute("data-scroll-tokens"); await expect(getPollTile(page, pollId)).toMatchScreenshot("Polls_Timeline_tile_no_votes.png", { - mask: [page.locator(".mx_MessageTimestamp")], + css: ` + .mx_MessageTimestamp { + visibility: hidden; + } + `, }); // Bot votes 'Maybe' in the poll @@ -293,7 +297,11 @@ test.describe("Polls", () => { await expect(page.locator(".mx_ThreadView")).toMatchScreenshot( "ThreadView_with_a_poll_on_bubble_layout.png", { - mask: [page.locator(".mx_MessageTimestamp")], + css: ` + .mx_MessageTimestamp { + visibility: hidden; + } + `, }, ); @@ -303,7 +311,11 @@ test.describe("Polls", () => { await expect(page.locator(".mx_ThreadView")).toMatchScreenshot( "ThreadView_with_a_poll_on_group_layout.png", { - mask: [page.locator(".mx_MessageTimestamp")], + css: ` + .mx_MessageTimestamp { + visibility: hidden; + } + `, }, ); diff --git a/playwright/e2e/read-receipts/index.ts b/playwright/e2e/read-receipts/index.ts index 067d3d16d2..bd04be36a8 100644 --- a/playwright/e2e/read-receipts/index.ts +++ b/playwright/e2e/read-receipts/index.ts @@ -6,7 +6,7 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com Please see LICENSE files in the repository root for full details. */ -import type { JSHandle, Page } from "@playwright/test"; +import type { JSHandle, Locator, Page } from "@playwright/test"; import type { MatrixEvent, Room, IndexedDBStore, ReceiptType } from "matrix-js-sdk/src/matrix"; import { test as base, expect } from "../../element-web-test"; import { type Bot } from "../../pages/bot"; @@ -36,11 +36,13 @@ export const test = base.extend<{ roomAlphaName: "Room Alpha", roomAlpha: async ({ roomAlphaName: name, app, user, bot }, use) => { const roomId = await app.client.createRoom({ name, invite: [bot.credentials.userId] }); + await bot.awaitRoomMembership(roomId); await use({ name, roomId }); }, roomBetaName: "Room Beta", roomBeta: async ({ roomBetaName: name, app, user, bot }, use) => { const roomId = await app.client.createRoom({ name, invite: [bot.credentials.userId] }); + await bot.awaitRoomMembership(roomId); await use({ name, roomId }); }, msg: async ({ page, app, util }, use) => { @@ -428,7 +430,7 @@ class Helpers { } getRoomListTile(label: string) { - return this.page.getByRole("treeitem", { name: new RegExp("^" + label) }); + return this.page.getByRole("option", { name: new RegExp("^Open room " + label) }); } /** @@ -446,8 +448,8 @@ class Helpers { */ async assertRead(room: RoomRef) { const tile = this.getRoomListTile(room.name); - await expect(tile.locator(".mx_NotificationBadge_dot")).not.toBeVisible(); - await expect(tile.locator(".mx_NotificationBadge_count")).not.toBeVisible(); + await expect(tile.getByTestId("notification-decoration")).not.toBeVisible(); + await expect(tile).not.toHaveAccessibleName(/with \d* unread message/); } /** @@ -463,15 +465,18 @@ class Helpers { /** * Assert a given room is marked as unread (via the room list tile) * @param room - the name of the room to check - * @param count - the numeric count to assert, or if "." specified then a bold/dot (no count) state is asserted + * @param count - the numeric count to assert */ - async assertUnread(room: RoomRef, count: number | ".") { + async assertUnread(room: RoomRef, count: number) { const tile = this.getRoomListTile(room.name); - if (count === ".") { - await expect(tile.locator(".mx_NotificationBadge_dot")).toBeVisible(); - } else { - await expect(tile.locator(".mx_NotificationBadge_count")).toHaveText(count.toString()); - } + await expect(tile).toBeVisible(); + await expect(tile).toHaveAccessibleName(/with \d* unread message/); + } + + async unreadCountForRoomTile(tile: Locator): Promise { + const accessibleName = await tile.getAttribute("aria-label"); + const match = accessibleName?.match(/(\d+)\s+unread message/); + return match ? parseInt(match[1], 10) : 0; } /** @@ -487,7 +492,7 @@ class Helpers { // .toBeLessThan doesn't have a retry mechanism, so we use .poll await expect .poll(async () => { - return parseInt(await tile.locator(".mx_NotificationBadge_count").textContent(), 10); + return this.unreadCountForRoomTile(tile); }) .toBeLessThan(lessThan); } @@ -505,7 +510,7 @@ class Helpers { // .toBeGreaterThan doesn't have a retry mechanism, so we use .poll await expect .poll(async () => { - return parseInt(await tile.locator(".mx_NotificationBadge_count").textContent(), 10); + return this.unreadCountForRoomTile(tile); }) .toBeGreaterThan(greaterThan); } @@ -596,24 +601,15 @@ class Helpers { await button.click(); } - /** - * Toggle the `Show rooms with unread messages first` option for the room list - */ - async toggleRoomUnreadOrder() { - await this.toggleRoomListMenu(); - await this.page.getByText("Show rooms with unread messages first").click(); - // Close contextual menu - await this.page.locator(".mx_ContextualMenu_background").click(); - } - /** * Assert that the room list is ordered as expected * @param rooms */ async assertRoomListOrder(rooms: Array<{ name: string }>) { - const roomList = this.page.locator(".mx_RoomTile_title"); + const roomListContainer = this.page.getByTestId("room-list"); + const roomTiles = roomListContainer.getByRole("option"); for (const [i, room] of rooms.entries()) { - await expect(roomList.nth(i)).toHaveText(room.name); + await expect(roomTiles.nth(i)).toHaveAccessibleName(new RegExp(`${room.name}`)); } } } diff --git a/playwright/e2e/read-receipts/message-ordering.spec.ts b/playwright/e2e/read-receipts/message-ordering.spec.ts index 59a56e6c50..5f4c994fda 100644 --- a/playwright/e2e/read-receipts/message-ordering.spec.ts +++ b/playwright/e2e/read-receipts/message-ordering.spec.ts @@ -13,72 +13,30 @@ import { test } from "."; test.describe("Read receipts", { tag: "@mergequeue" }, () => { test.describe("Message ordering", () => { test.describe("in the main timeline", () => { - test.fixme( - "A receipt for the last event in sync order (even with wrong ts) marks a room as read", - () => {}, - ); - test.fixme( - "A receipt for a non-last event in sync order (even when ts makes it last) leaves room unread", - () => {}, - ); + test.fixme("A receipt for the last event in sync order (even with wrong ts) marks a room as read", () => {}); + test.fixme("A receipt for a non-last event in sync order (even when ts makes it last) leaves room unread", () => {}); }); test.describe("in threads", () => { // These don't pass yet - we need MSC4033 - we don't even know the Sync order yet - test.fixme( - "A receipt for the last event in sync order (even with wrong ts) marks a thread as read", - () => {}, - ); - test.fixme( - "A receipt for a non-last event in sync order (even when ts makes it last) leaves thread unread", - () => {}, - ); + test.fixme("A receipt for the last event in sync order (even with wrong ts) marks a thread as read", () => {}); + test.fixme("A receipt for a non-last event in sync order (even when ts makes it last) leaves thread unread", () => {}); // These pass now and should not later - we should use order from MSC4033 instead of ts // These are broken out - test.fixme( - "A receipt for last threaded event in ts order (even when it was received non-last) marks a thread as read", - () => {}, - ); - test.fixme( - "A receipt for non-last threaded event in ts order (even when it was received last) leaves thread unread", - () => {}, - ); - test.fixme( - "A receipt for last threaded edit in ts order (even when it was received non-last) marks a thread as read", - () => {}, - ); - test.fixme( - "A receipt for non-last threaded edit in ts order (even when it was received last) leaves thread unread", - () => {}, - ); - test.fixme( - "A receipt for last threaded reaction in ts order (even when it was received non-last) marks a thread as read", - () => {}, - ); - test.fixme( - "A receipt for non-last threaded reaction in ts order (even when it was received last) leaves thread unread", - () => {}, - ); + test.fixme("A receipt for last threaded event in ts order (even when it was received non-last) marks a thread as read", () => {}); + test.fixme("A receipt for non-last threaded event in ts order (even when it was received last) leaves thread unread", () => {}); + test.fixme("A receipt for last threaded edit in ts order (even when it was received non-last) marks a thread as read", () => {}); + test.fixme("A receipt for non-last threaded edit in ts order (even when it was received last) leaves thread unread", () => {}); + test.fixme("A receipt for last threaded reaction in ts order (even when it was received non-last) marks a thread as read", () => {}); + test.fixme("A receipt for non-last threaded reaction in ts order (even when it was received last) leaves thread unread", () => {}); }); test.describe("thread roots", () => { - test.fixme( - "A receipt for last reaction to thread root in sync order (even when ts makes it last) marks room as read", - () => {}, - ); - test.fixme( - "A receipt for non-last reaction to thread root in sync order (even when ts makes it last) leaves room unread", - () => {}, - ); - test.fixme( - "A receipt for last edit to thread root in sync order (even when ts makes it last) marks room as read", - () => {}, - ); - test.fixme( - "A receipt for non-last edit to thread root in sync order (even when ts makes it last) leaves room unread", - () => {}, - ); + test.fixme("A receipt for last reaction to thread root in sync order (even when ts makes it last) marks room as read", () => {}); + test.fixme("A receipt for non-last reaction to thread root in sync order (even when ts makes it last) leaves room unread", () => {}); + test.fixme("A receipt for last edit to thread root in sync order (even when ts makes it last) marks room as read", () => {}); + test.fixme("A receipt for non-last edit to thread root in sync order (even when ts makes it last) leaves room unread", () => {}); }); }); }); diff --git a/playwright/e2e/read-receipts/missing-referents.spec.ts b/playwright/e2e/read-receipts/missing-referents.spec.ts index a953aae448..baa5e907f5 100644 --- a/playwright/e2e/read-receipts/missing-referents.spec.ts +++ b/playwright/e2e/read-receipts/missing-referents.spec.ts @@ -12,18 +12,20 @@ import { test } from "."; test.describe("Read receipts", { tag: "@mergequeue" }, () => { test.describe("messages with missing referents", () => { - test.fixme( - "A message in an unknown thread is not visible and the room is read", - async ({ roomAlpha: room1, roomBeta: room2, util, msg }) => { - // Given a thread existed and the room is read - await util.goTo(room1); - await util.receiveMessages(room2, ["Root1", msg.threadedOff("Root1", "T1a")]); + test.fixme("A message in an unknown thread is not visible and the room is read", async ({ + roomAlpha: room1, + roomBeta: room2, + util, + msg, + }) => { + // Given a thread existed and the room is read + await util.goTo(room1); + await util.receiveMessages(room2, ["Root1", msg.threadedOff("Root1", "T1a")]); - // When I restart, forgetting the thread root - // And I receive a message on that thread - // Then the message is invisible and the room remains read - }, - ); + // When I restart, forgetting the thread root + // And I receive a message on that thread + // Then the message is invisible and the room remains read + }); test.fixme("When a message's thread root appears later the thread appears and the room is unread", () => {}); test.fixme("An edit of an unknown message is not visible and the room is read", () => {}); test.fixme("When an edit's message appears later the edited version appears and the room is unread", () => {}); diff --git a/playwright/e2e/read-receipts/notifications.spec.ts b/playwright/e2e/read-receipts/notifications.spec.ts index 93f5515eeb..7013d49141 100644 --- a/playwright/e2e/read-receipts/notifications.spec.ts +++ b/playwright/e2e/read-receipts/notifications.spec.ts @@ -14,14 +14,8 @@ test.describe("Read receipts", { tag: "@mergequeue" }, () => { test.describe("Notifications", () => { test.describe("in the main timeline", () => { test.fixme("A new message that mentions me shows a notification", () => {}); - test.fixme( - "Reading a notifying message reduces the notification count in the room list, space and tab", - () => {}, - ); - test.fixme( - "Reading the last notifying message removes the notification marker from room list, space and tab", - () => {}, - ); + test.fixme("Reading a notifying message reduces the notification count in the room list, space and tab", () => {}); + test.fixme("Reading the last notifying message removes the notification marker from room list, space and tab", () => {}); test.fixme("Editing a message to mentions me shows a notification", () => {}); test.fixme("Reading the last notifying edited message removes the notification marker", () => {}); test.fixme("Redacting a notifying message removes the notification marker", () => {}); @@ -30,18 +24,9 @@ test.describe("Read receipts", { tag: "@mergequeue" }, () => { test.describe("in threads", () => { test.fixme("A new threaded message that mentions me shows a notification", () => {}); test.fixme("Reading a notifying threaded message removes the notification count", () => {}); - test.fixme( - "Notification count remains steady when reading threads that contain seen notifications", - () => {}, - ); - test.fixme( - "Notification count remains steady when paging up thread view even when threads contain seen notifications", - () => {}, - ); - test.fixme( - "Notification count remains steady when paging up thread view after mark as unread even if older threads contain notifications", - () => {}, - ); + test.fixme("Notification count remains steady when reading threads that contain seen notifications", () => {}); + test.fixme("Notification count remains steady when paging up thread view even when threads contain seen notifications", () => {}); + test.fixme("Notification count remains steady when paging up thread view after mark as unread even if older threads contain notifications", () => {}); test.fixme("Redacting a notifying threaded message removes the notification marker", () => {}); }); }); diff --git a/playwright/e2e/read-receipts/read-receipts.spec.ts b/playwright/e2e/read-receipts/read-receipts.spec.ts index 8ebce22b52..b1142bae59 100644 --- a/playwright/e2e/read-receipts/read-receipts.spec.ts +++ b/playwright/e2e/read-receipts/read-receipts.spec.ts @@ -112,7 +112,7 @@ test.describe("Read receipts", { tag: "@mergequeue" }, () => { const main3 = await sendMessage(bot); // (So the room starts off unread) - await expect(page.getByLabel(`${otherRoomName} 3 unread messages.`)).toBeVisible(); + await expect(page.getByLabel(`${otherRoomName} with 3 unread messages.`)).toBeVisible(); // When we send a threaded receipt for the last event in main // And an unthreaded receipt for an earlier event @@ -147,13 +147,13 @@ test.describe("Read receipts", { tag: "@mergequeue" }, () => { await sendMessage(bot); // (The room starts off unread) - await expect(page.getByLabel(`${otherRoomName} 3 unread messages.`)).toBeVisible(); + await expect(page.getByLabel(`${otherRoomName} with 3 unread messages.`)).toBeVisible(); // When we send a threaded receipt for the second-last event in main await sendThreadedReadReceipt(app, main2); // Then the room has only one unread - await expect(page.getByLabel(`${otherRoomName} 1 unread message.`)).toBeVisible(); + await expect(page.getByLabel(`${otherRoomName} with 1 unread message.`)).toBeVisible(); }); test("Considers room read if there is only a main thread and we have a main receipt", async ({ @@ -166,7 +166,7 @@ test.describe("Read receipts", { tag: "@mergequeue" }, () => { await sendMessage(bot); const main3 = await sendMessage(bot); // (The room starts off unread) - await expect(page.getByLabel(`${otherRoomName} 3 unread messages.`)).toBeVisible(); + await expect(page.getByLabel(`${otherRoomName} with 3 unread messages.`)).toBeVisible(); // When we send a threaded receipt for the last event in main await sendThreadedReadReceipt(app, main3); @@ -186,7 +186,7 @@ test.describe("Read receipts", { tag: "@mergequeue" }, () => { const thread1a = await botSendThreadMessage(bot, main1.event_id); await botSendThreadMessage(bot, main1.event_id); // 1 unread on the main thread, 2 in the new thread that aren't shown - await expect(page.getByLabel(`${otherRoomName} 1 unread message.`)).toBeVisible(); + await expect(page.getByLabel(`${otherRoomName} with 1 unread message.`)).toBeVisible(); // When we send receipts for main, and the second-last in the thread await sendThreadedReadReceipt(app, main1); @@ -203,7 +203,7 @@ test.describe("Read receipts", { tag: "@mergequeue" }, () => { await botSendThreadMessage(bot, main1.event_id); const thread1b = await botSendThreadMessage(bot, main1.event_id); // 1 unread on the main thread, 2 in the new thread which don't show - await expect(page.getByLabel(`${otherRoomName} 1 unread message.`)).toBeVisible(); + await expect(page.getByLabel(`${otherRoomName} with 1 unread message.`)).toBeVisible(); // When we send receipts for main, and the last in the thread await sendThreadedReadReceipt(app, main1); @@ -226,7 +226,7 @@ test.describe("Read receipts", { tag: "@mergequeue" }, () => { const thread1a = await botSendThreadMessage(bot, main1.event_id); await botSendThreadMessage(bot, main1.event_id); // 1 unread on the main thread, 2 in the new thread which don't count - await expect(page.getByLabel(`${otherRoomName} 1 unread message.`)).toBeVisible(); + await expect(page.getByLabel(`${otherRoomName} with 1 unread message.`)).toBeVisible(); // When we send an unthreaded receipt for the second-last in the thread await sendUnthreadedReadReceipt(app, thread1a); @@ -251,7 +251,7 @@ test.describe("Read receipts", { tag: "@mergequeue" }, () => { const thread1b = await botSendThreadMessage(bot, main1.event_id); await sendMessage(bot); // 2 unreads on the main thread, 2 in the new thread which don't count - await expect(page.getByLabel(`${otherRoomName} 2 unread messages.`)).toBeVisible(); + await expect(page.getByLabel(`${otherRoomName} with 2 unread messages.`)).toBeVisible(); // When we send an unthreaded receipt for the last in the thread await sendUnthreadedReadReceipt(app, thread1b); @@ -259,7 +259,7 @@ test.describe("Read receipts", { tag: "@mergequeue" }, () => { // Then the room has only one unread - the one in the // main thread, because it is later than the unthreaded // receipt. - await expect(page.getByLabel(`${otherRoomName} 1 unread message.`)).toBeVisible(); + await expect(page.getByLabel(`${otherRoomName} with 1 unread message.`)).toBeVisible(); }); /** @@ -291,7 +291,9 @@ test.describe("Read receipts", { tag: "@mergequeue" }, () => { const uriEncodedLastMessageId = encodeURIComponent(lastMessageId); // wait until all messages have been received - await expect(page.getByLabel(`${otherRoomName} ${sendMessageResponses.length} unread messages.`)).toBeVisible(); + await expect( + page.getByLabel(`${otherRoomName} with ${sendMessageResponses.length} unread messages.`), + ).toBeVisible(); // switch to the room with the messages await page.goto(`/#/room/${otherRoomId}`); diff --git a/playwright/e2e/read-receipts/room-list-order.spec.ts b/playwright/e2e/read-receipts/room-list-order.spec.ts index e940c45b87..405cdf22ed 100644 --- a/playwright/e2e/read-receipts/room-list-order.spec.ts +++ b/playwright/e2e/read-receipts/room-list-order.spec.ts @@ -12,7 +12,7 @@ import { test } from "."; test.describe("Read receipts", { tag: "@mergequeue" }, () => { test.describe("Room list order", () => { - test("Rooms with unread messages appear at the top of room list if 'unread first' is selected", async ({ + test("Rooms with unread messages appear at the top of room list with default 'activity' ordering", async ({ roomAlpha: room1, roomBeta: room2, util, @@ -22,15 +22,18 @@ test.describe("Read receipts", { tag: "@mergequeue" }, () => { await util.goTo(room2); // Display the unread first room - await util.toggleRoomUnreadOrder(); await util.receiveMessages(room1, ["Msg1"]); await page.reload(); + // switch rooms so they can re-order in the list + await util.goTo(room1); + // Room 1 has an unread message and should be displayed first + // (as the default is to sort by activity) await util.assertRoomListOrder([room1, room2]); }); - test("Rooms with unread threads appear at the top of room list if 'unread first' is selected", async ({ + test("Rooms with unread threads appear at the top of room list with default 'activity' order", async ({ roomAlpha: room1, roomBeta: room2, util, @@ -42,7 +45,6 @@ test.describe("Read receipts", { tag: "@mergequeue" }, () => { await util.assertRead(room1); // Display the unread first room - await util.toggleRoomUnreadOrder(); await util.receiveMessages(room1, [msg.threadedOff("Msg1", "Resp1")]); await util.saveAndReload(); diff --git a/playwright/e2e/regression-tests/pills-click-in-app.spec.ts b/playwright/e2e/regression-tests/pills-click-in-app.spec.ts index 3670e64308..6856e70609 100644 --- a/playwright/e2e/regression-tests/pills-click-in-app.spec.ts +++ b/playwright/e2e/regression-tests/pills-click-in-app.spec.ts @@ -29,7 +29,7 @@ test.describe("Pills", () => { // send a message using the built-in room mention functionality (autocomplete) await page - .getByRole("textbox", { name: "Send a message…" }) + .getByRole("textbox", { name: "Send an unencrypted message…" }) .pressSequentially(`Hello world! Join here: #${targetLocalpart.substring(0, 3)}`); await page.locator(".mx_Autocomplete_Completion_title").click(); await page.getByRole("button", { name: "Send message" }).click(); diff --git a/playwright/e2e/release-announcement/index.ts b/playwright/e2e/release-announcement/index.ts index 3b6c2dd38a..e28fa6f989 100644 --- a/playwright/e2e/release-announcement/index.ts +++ b/playwright/e2e/release-announcement/index.ts @@ -30,9 +30,8 @@ export class Helpers { /** * Get the release announcement with the given name. * @param name - * @private */ - private getReleaseAnnouncement(name: string) { + public getReleaseAnnouncement(name: string) { return this.page.getByRole("dialog", { name }); } @@ -42,7 +41,10 @@ export class Helpers { */ async assertReleaseAnnouncementIsVisible(name: string) { await expect(this.getReleaseAnnouncement(name)).toBeVisible(); - await expect(this.page).toMatchScreenshot(`release-announcement-${name}.png`, { showTooltips: true }); + await expect(this.page).toMatchScreenshot(`release-announcement-${name}.png`, { + showTooltips: true, + hideJumpToBottomButton: true, + }); } /** @@ -52,16 +54,6 @@ export class Helpers { assertReleaseAnnouncementIsNotVisible(name: string) { return expect(this.getReleaseAnnouncement(name)).not.toBeVisible(); } - - /** - * Mark the release announcement with the given name as read. - * If the release announcement is not visible, this will throw an error. - * @param name - */ - async markReleaseAnnouncementAsRead(name: string) { - const dialog = this.getReleaseAnnouncement(name); - await dialog.getByRole("button", { name: "Ok" }).click(); - } } export { expect }; diff --git a/playwright/e2e/release-announcement/releaseAnnouncement.spec.ts b/playwright/e2e/release-announcement/releaseAnnouncement.spec.ts index 812b66b796..8f0a73ebff 100644 --- a/playwright/e2e/release-announcement/releaseAnnouncement.spec.ts +++ b/playwright/e2e/release-announcement/releaseAnnouncement.spec.ts @@ -22,27 +22,37 @@ test.describe("Release announcement", () => { await app.viewRoomById(roomId); await use({ roomId }); }, + labsFlags: ["feature_new_room_list"], }); - test( - "should display the pinned messages release announcement", + // There is no release announcement currently live + test.skip( + "should display the new room list release announcement", { tag: "@screenshot" }, async ({ page, app, room, util }) => { - await app.toggleRoomInfoPanel(); + // dismiss the toast so the announcement appears + await page.getByRole("button", { name: "Dismiss" }).click(); - const name = "All new pinned messages"; + const newSoundsName = "We’ve refreshed your sounds"; + // The new sounds release announcement should be displayed + await util.assertReleaseAnnouncementIsVisible(newSoundsName); + // Hide the new sounds release announcement + const newSoundsDialog = util.getReleaseAnnouncement(newSoundsName); + await newSoundsDialog.getByRole("button", { name: "OK" }).click(); - // The release announcement should be displayed - await util.assertReleaseAnnouncementIsVisible(name); - // Hide the release announcement - await util.markReleaseAnnouncementAsRead(name); - await util.assertReleaseAnnouncementIsNotVisible(name); + const newRoomListName = "Chats has a new look!"; + // The new room list release announcement should be displayed + await util.assertReleaseAnnouncementIsVisible(newRoomListName); + // Hide the new room list release announcement + const dialog = util.getReleaseAnnouncement(newRoomListName); + await dialog.getByRole("button", { name: "Next" }).click(); + + await util.assertReleaseAnnouncementIsNotVisible(newRoomListName); await page.reload(); - await app.toggleRoomInfoPanel(); - await expect(page.getByRole("menuitem", { name: "Pinned messages" })).toBeVisible(); - // Check that once the release announcement has been marked as viewed, it does not appear again - await util.assertReleaseAnnouncementIsNotVisible(name); + await expect(page.getByRole("button", { name: "Room options" })).toBeVisible(); + // Check that once the release announcements has been marked as viewed, it does not appear again + await util.assertReleaseAnnouncementIsNotVisible(newRoomListName); }, ); }); diff --git a/playwright/e2e/right-panel/file-panel.spec.ts b/playwright/e2e/right-panel/file-panel.spec.ts index d69b7d4731..5549b2d315 100644 --- a/playwright/e2e/right-panel/file-panel.spec.ts +++ b/playwright/e2e/right-panel/file-panel.spec.ts @@ -6,7 +6,7 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com Please see LICENSE files in the repository root for full details. */ -import { type Download, type Page } from "@playwright/test"; +import { type Page } from "@playwright/test"; import { test, expect } from "../../element-web-test"; import { viewRoomSummaryByName } from "./utils"; @@ -63,9 +63,7 @@ test.describe("FilePanel", () => { await expect(roomViewBody.locator(".mx_EventTile[data-layout='group'] img[alt='riot.png']")).toBeVisible(); // Assert that the audio player is rendered - await expect( - roomViewBody.locator(".mx_EventTile[data-layout='group'] .mx_AudioPlayer_container"), - ).toBeVisible(); + await expect(roomViewBody.getByRole("region", { name: "Audio player" })).toBeVisible(); // Assert that the file button exists await expect( @@ -97,9 +95,7 @@ test.describe("FilePanel", () => { await expect(image.locator("img[alt='riot.png']")).toBeVisible(); // Detect the audio file - const audio = filePanelMessageList.locator( - ".mx_EventTile_mediaLine .mx_MAudioBody .mx_AudioPlayer_container", - ); + const audio = filePanelMessageList.getByRole("region", { name: "Audio player" }); // Assert that the play button is rendered await expect(audio.getByRole("button", { name: "Play" })).toBeVisible(); @@ -130,7 +126,12 @@ test.describe("FilePanel", () => { // Take a snapshot of file tiles list on FilePanel await expect(filePanelMessageList).toMatchScreenshot("file-tiles-list.png", { // Exclude timestamps & flaky seek bar from snapshot - mask: [page.locator(".mx_MessageTimestamp, .mx_AudioPlayer_seek")], + mask: [page.getByTestId("audio-player-seek")], + css: ` + .mx_MessageTimestamp { + visibility: hidden; + } + `, }); }); @@ -138,21 +139,19 @@ test.describe("FilePanel", () => { // Upload an image file await uploadFile(page, "playwright/sample-files/1sec.ogg"); - const audioBody = page.locator( - ".mx_FilePanel .mx_RoomView_MessageList .mx_EventTile_mediaLine .mx_MAudioBody .mx_AudioPlayer_container", - ); + const audioBody = page.getByTestId("right-panel").getByRole("region", { name: "Audio player" }); + // Assert that the audio player is rendered - // Assert that the audio file information is rendered - const mediaInfo = audioBody.locator(".mx_AudioPlayer_mediaInfo"); - await expect(mediaInfo.locator(".mx_AudioPlayer_mediaName").getByText("1sec.ogg")).toBeVisible(); - await expect(mediaInfo.locator(".mx_AudioPlayer_byline", { hasText: "00:01" })).toBeVisible(); - await expect(mediaInfo.locator(".mx_AudioPlayer_byline", { hasText: "(3.56 KB)" })).toBeVisible(); // actual size + // Assert that the audio file information is rendered; + await expect(audioBody.getByText("1sec.ogg")).toBeVisible(); // extension + await expect(audioBody.getByRole("time")).toHaveText("00:01"); // duration + await expect(audioBody.getByText("(3.56 KB)")).toBeVisible(); // actual size; // Assert that the duration counter is 00:01 before clicking the play button - await expect(audioBody.locator(".mx_AudioPlayer_mediaInfo time", { hasText: "00:01" })).toBeVisible(); + await expect(audioBody.getByRole("time")).toHaveText("00:01"); // Assert that the counter is zero before clicking the play button - await expect(audioBody.locator(".mx_AudioPlayer_seek [role='timer']", { hasText: "00:00" })).toBeVisible(); + await expect(audioBody.getByRole("timer")).toHaveText("00:00"); // Click the play button await audioBody.getByRole("button", { name: "Play" }).click(); @@ -161,7 +160,7 @@ test.describe("FilePanel", () => { await expect(audioBody.getByRole("button", { name: "Pause" })).toBeVisible(); // Assert that the timer is reset when the audio file finished playing - await expect(audioBody.locator(".mx_AudioPlayer_seek [role='timer']", { hasText: "00:00" })).toBeVisible(); + await expect(audioBody.getByRole("timer")).toHaveText("00:00"); // Assert that the play button is rendered await expect(audioBody.getByRole("button", { name: "Play" })).toBeVisible(); @@ -195,23 +194,13 @@ test.describe("FilePanel", () => { const link = imageBody.locator(".mx_MFileBody_download a"); - const newPagePromise = context.waitForEvent("page"); - - const downloadPromise = new Promise((resolve) => { - page.once("download", resolve); - }); + const downloadPromise = page.waitForEvent("download"); // Click the anchor link (not the image itself) await link.click(); - const newPage = await newPagePromise; - // XXX: Clicking the link opens the image in a new tab on some browsers rather than downloading - await expect(newPage) - .toHaveURL(/.+\/_matrix\/media\/\w+\/download\/localhost\/\w+/) - .catch(async () => { - const download = await downloadPromise; - expect(download.suggestedFilename()).toBe("riot.png"); - }); + const download = await downloadPromise; + expect(download.suggestedFilename()).toBe("riot.png"); }); }); }); diff --git a/playwright/e2e/right-panel/memberlist.spec.ts b/playwright/e2e/right-panel/memberlist.spec.ts index cd22626575..bb3dc8cb33 100644 --- a/playwright/e2e/right-panel/memberlist.spec.ts +++ b/playwright/e2e/right-panel/memberlist.spec.ts @@ -11,6 +11,32 @@ import { Bot } from "../../pages/bot"; const ROOM_NAME = "Test room"; const NAME = "Alice"; +async function setupRoomWithMembers( + app: any, + page: any, + homeserver: any, + roomName: string, + memberNames: string[], +): Promise { + const visibility = await page.evaluate(() => (window as any).matrixcs.Visibility.Public); + const id = await app.client.createRoom({ name: roomName, visibility }); + const bots: Bot[] = []; + + for (let i = 0; i < memberNames.length; i++) { + const displayName = memberNames[i]; + const bot = new Bot(page, homeserver, { displayName, startClient: false, autoAcceptInvites: false }); + if (displayName === "Susan") { + await bot.prepareClient(); + await app.client.inviteUser(id, bot.credentials?.userId); + } else { + await bot.joinRoom(id); + } + bots.push(bot); + } + + return id; +} + test.use({ synapseConfig: { presence: { @@ -25,17 +51,8 @@ test.use({ test.describe("Memberlist", () => { test.beforeEach(async ({ app, user, page, homeserver }, testInfo) => { testInfo.setTimeout(testInfo.timeout + 30_000); - const id = await app.client.createRoom({ name: ROOM_NAME }); - const newBots: Bot[] = []; const names = ["Bob", "Bob", "Susan"]; - for (let i = 0; i < 3; i++) { - const displayName = names[i]; - const autoAcceptInvites = displayName !== "Susan"; - const bot = new Bot(page, homeserver, { displayName, startClient: true, autoAcceptInvites }); - await bot.prepareClient(); - await app.client.inviteUser(id, bot.credentials?.userId); - newBots.push(bot); - } + await setupRoomWithMembers(app, page, homeserver, ROOM_NAME, names); }); test("Renders correctly", { tag: "@screenshot" }, async ({ page, app }) => { @@ -45,4 +62,36 @@ test.describe("Memberlist", () => { await expect(memberlist.getByText("Invited")).toHaveCount(1); await expect(page.locator(".mx_MemberListView")).toMatchScreenshot("with-four-members.png"); }); + + test("should handle scroll and click to view member profile", async ({ page, app, homeserver }) => { + // Create a room with many members to enable scrolling + const memberNames = Array.from({ length: 15 }, (_, i) => `Member${i.toString()}`); + await setupRoomWithMembers(app, page, homeserver, "Large Room", memberNames); + + // Navigate to the room and open member list + await app.viewRoomByName("Large Room"); + + const memberlist = await app.toggleMemberlistPanel(); + + // Get the scrollable container + const memberListContainer = memberlist.locator(".mx_AutoHideScrollbar"); + + // Scroll down to the bottom of the member list + await app.scrollListToBottom(memberListContainer); + + // Wait for the target member to be visible after scrolling + // Member9 is the last in the list as they are lexicographically sorted + const targetName = "Member9"; + const targetMember = memberlist.locator(".mx_MemberTileView_name").filter({ hasText: targetName }); + await targetMember.waitFor({ state: "visible" }); + // Alice is not visible and will require scrolling to, + // but is likely in the dom as we have an overscan on the top and bottom of the list. + // Click on a member near the bottom of the list + await expect(targetMember).toBeVisible(); + await targetMember.click(); + + // Verify that the user info screen is shown and hasn't scrolled back to top + await expect(page.locator(".mx_UserInfo")).toBeVisible(); + await expect(page.locator(".mx_UserInfo_profile").getByText(targetName)).toBeVisible(); + }); }); diff --git a/playwright/e2e/right-panel/right-panel.spec.ts b/playwright/e2e/right-panel/right-panel.spec.ts index 0fbd306d86..6a6aba8fd5 100644 --- a/playwright/e2e/right-panel/right-panel.spec.ts +++ b/playwright/e2e/right-panel/right-panel.spec.ts @@ -164,7 +164,7 @@ test.describe("RightPanel", () => { css: ` /* Use monospace font for consistent mask width */ .mx_UserInfo_profile_mxid { - font-family: Inconsolata !important; + font-family: "Fira Code" !important; } `, }); @@ -207,7 +207,7 @@ test.describe("RightPanel", () => { // \d represents the number of the space members await page - .locator(".mx_RoomInfoLine_private") + .locator(".mx_RoomInfoLine") .getByRole("button", { name: /\d member/ }) .click(); await expect(page.locator(".mx_MemberListView")).toBeVisible(); diff --git a/playwright/e2e/room-directory/room-directory.spec.ts b/playwright/e2e/room-directory/room-directory.spec.ts index 8f90ef4b7e..741fde3505 100644 --- a/playwright/e2e/room-directory/room-directory.spec.ts +++ b/playwright/e2e/room-directory/room-directory.spec.ts @@ -38,12 +38,11 @@ test.describe("Room Directory", () => { // Publish into the public rooms directory const publishedAddresses = page.locator(".mx_SettingsFieldset", { hasText: "Published Addresses" }); await expect(publishedAddresses.locator("#canonicalAlias")).toHaveValue(`#gaming:${user.homeServer}`); - const checkbox = publishedAddresses - .locator(".mx_SettingsFlag", { - hasText: `Publish this room to the public in ${user.homeServer}'s room directory?`, - }) - .getByRole("switch"); - await checkbox.check(); + const checkbox = publishedAddresses.getByRole("switch", { + name: `Publish this room to the public in ${user.homeServer}'s room directory?`, + }); + // .click() rather than .check() as checking happens after publish + await checkbox.click(); await expect(checkbox).toBeChecked(); await app.closeDialog(); diff --git a/playwright/e2e/room/create-room.spec.ts b/playwright/e2e/room/create-room.spec.ts new file mode 100644 index 0000000000..dd0ea63776 --- /dev/null +++ b/playwright/e2e/room/create-room.spec.ts @@ -0,0 +1,227 @@ +/* +Copyright 2024 New Vector Ltd. +Copyright 2022, 2023 The Matrix.org Foundation C.I.C. + +SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial +Please see LICENSE files in the repository root for full details. +*/ + +import { type Page } from "playwright-core"; + +import { SettingLevel } from "../../../src/settings/SettingLevel"; +import { UIFeature } from "../../../src/settings/UIFeature"; +import { test, expect } from "../../element-web-test"; + +const name = "Test room"; +const topic = "A decently explanatory topic for a test room."; + +test.describe("Create Room", () => { + test.use({ displayName: "Jim" }); + + test( + "should create a public room with name, topic & address set", + { tag: "@screenshot" }, + async ({ page, user, app, axe }) => { + const dialog = await app.openCreateRoomDialog(); + // Fill name & topic + await dialog.getByRole("textbox", { name: "Name" }).fill(name); + await dialog.getByRole("textbox", { name: "Topic" }).fill(topic); + // Change room to public + await dialog.getByRole("button", { name: "Room visibility" }).click(); + await dialog.getByRole("option", { name: "Public room" }).click(); + // Fill room address + await dialog.getByRole("textbox", { name: "Room address" }).fill("test-create-room-standard"); + + axe.disableRules("color-contrast"); // XXX: Inheriting colour contrast issues from room view. + await expect(axe).toHaveNoViolations(); + // Snapshot it + // Mask topic to avoid flakiness with top border + await expect(dialog).toMatchScreenshot("create-room.png", { + mask: [dialog.locator(".mx_CreateRoomDialog_topic")], + }); + + // Submit + await dialog.getByRole("button", { name: "Create room" }).click(); + + await expect(page).toHaveURL(new RegExp(`/#/room/#test-create-room-standard:${user.homeServer}`)); + const header = page.locator(".mx_RoomHeader"); + await expect(header).toContainText(name); + }, + ); + + test("should allow us to start a chat and show encryption state", async ({ page, user, app }) => { + await page.getByRole("button", { name: "New conversation", exact: true }).click(); + await page.getByRole("menuitem", { name: "Start chat" }).click(); + + await page.getByTestId("invite-dialog-input").fill(user.userId); + + await page.getByRole("button", { name: "Go" }).click(); + + await expect(page.getByText("Encryption enabled")).toBeVisible(); + await expect(page.getByText("Send your first message to")).toBeVisible(); + + const composer = page.getByRole("region", { name: "Message composer" }); + await expect(composer.getByRole("textbox", { name: "Send a message…" })).toBeVisible(); + }); + + test("should create a video room", { tag: "@screenshot" }, async ({ page, user, app }) => { + await app.settings.setValue("feature_video_rooms", null, SettingLevel.DEVICE, true); + + const dialog = await app.openCreateRoomDialog("New video room"); + // Fill name & topic + await dialog.getByRole("textbox", { name: "Name" }).fill(name); + await dialog.getByRole("textbox", { name: "Topic" }).fill(topic); + // Change room to public + await dialog.getByRole("button", { name: "Room visibility" }).click(); + await dialog.getByRole("option", { name: "Public room" }).click(); + // Fill room address + await dialog.getByRole("textbox", { name: "Room address" }).fill("test-create-room-video"); + // Snapshot it + // Mask topic to avoid flakiness with top border + await expect(dialog).toMatchScreenshot("create-video-room.png", { + mask: [dialog.locator(".mx_CreateRoomDialog_topic")], + }); + + // Submit + await dialog.getByRole("button", { name: "Create video room" }).click(); + + await expect(page).toHaveURL(new RegExp(`/#/room/#test-create-room-video:${user.homeServer}`)); + const header = page.locator(".mx_RoomHeader"); + await expect(header).toContainText(name); + }); + + test.describe("Should hide public room option if not allowed", () => { + test.use({ + config: { + setting_defaults: { + [UIFeature.AllowCreatingPublicRooms]: false, + }, + }, + }); + + test("should disallow creating public rooms", { tag: "@screenshot" }, async ({ page, user, app, axe }) => { + const dialog = await app.openCreateRoomDialog(); + // Fill name & topic + await dialog.getByRole("textbox", { name: "Name" }).fill(name); + await dialog.getByRole("textbox", { name: "Topic" }).fill(topic); + + axe.disableRules("color-contrast"); // XXX: Inheriting colour contrast issues from room view. + await expect(axe).toHaveNoViolations(); + // Snapshot it + // Mask topic to avoid flakiness with top border + await expect(dialog).toMatchScreenshot("create-room-no-public.png", { + mask: [dialog.locator(".mx_CreateRoomDialog_topic")], + }); + + // Submit + await dialog.getByRole("button", { name: "Create room" }).click(); + + await expect(page).toHaveURL(new RegExp(`/#/room/!.+`)); + const header = page.locator(".mx_RoomHeader"); + await expect(header).toContainText(name); + }); + }); + + test.describe("when the encrypted state labs flag is turned off", () => { + test.use({ labsFlags: [] }); + + test("creates a room without encrypted state", { tag: "@screenshot" }, async ({ page, user: _user }) => { + // When we start to create a room + await page.getByRole("button", { name: "New conversation", exact: true }).click(); + await page.getByRole("menuitem", { name: "New room" }).click(); + await page.getByRole("textbox", { name: "Name" }).fill(name); + + // Then there is no Encrypt state events button + await expect(page.getByRole("checkbox", { name: "Encrypt state events" })).not.toBeVisible(); + + // And when we create the room + await page.getByRole("button", { name: "Create room" }).click(); + + // Then we created a normal encrypted room, without encrypted state + await expect(page.getByText("Encryption enabled")).toBeVisible(); + await expect(page.getByText("State encryption enabled")).not.toBeVisible(); + + // And the room name state event is not encrypted + await viewSourceOnRoomNameEvent(page); + await expect(page.getByText("Original event source")).toBeVisible(); + await expect(page.getByText("Decrypted event source")).not.toBeVisible(); + }); + }); + + test.describe("when the encrypted state labs flag is turned on", () => { + test.use({ labsFlags: ["feature_msc4362_encrypted_state_events"] }); + + test( + "creates a room with encrypted state if we check the box", + { tag: "@screenshot" }, + async ({ page, user: _user }) => { + // Given we check the Encrypted State checkbox + await page.getByRole("button", { name: "New conversation", exact: true }).click(); + await page.getByRole("menuitem", { name: "New room" }).click(); + await expect(page.getByRole("switch", { name: "Enable end-to-end encryption" })).toBeChecked(); + await page.getByRole("switch", { name: "Encrypt state events" }).click(); + await expect(page.getByRole("switch", { name: "Encrypt state events" })).toBeChecked(); + + // When we create a room + await page.getByRole("textbox", { name: "Name" }).fill(name); + await page.getByRole("button", { name: "Create room" }).click(); + + // Then we created an encrypted state room + await expect(page.getByText("State encryption enabled")).toBeVisible(); + + // And it has the correct name + await expect(page.getByTestId("timeline").getByRole("heading", { name })).toBeVisible(); + + // And the room name state event is encrypted + await viewSourceOnRoomNameEvent(page); + await expect(page.getByText("Decrypted event source")).toBeVisible(); + }, + ); + + test( + "creates a room without encrypted state if we don't check the box", + { tag: "@screenshot" }, + async ({ page, user: _user }) => { + // Given we did not check the Encrypted State checkbox + await page.getByRole("button", { name: "New conversation", exact: true }).click(); + await page.getByRole("menuitem", { name: "New room" }).click(); + await expect(page.getByRole("switch", { name: "Enable end-to-end encryption" })).toBeChecked(); + + // And it is off by default + await expect(page.getByRole("switch", { name: "Encrypt state events" })).not.toBeChecked(); + + // When we create a room + await page.getByRole("textbox", { name: "Name" }).fill(name); + await page.getByRole("button", { name: "Create room" }).click(); + + // Then we created a normal encrypted room, without encrypted state + await expect(page.getByText("Encryption enabled")).toBeVisible(); + await expect(page.getByText("State encryption enabled")).not.toBeVisible(); + + // And it has the correct name + await expect(page.getByTestId("timeline").getByRole("heading", { name })).toBeVisible(); + + // And the room name state event is not encrypted + await viewSourceOnRoomNameEvent(page); + await expect(page.getByText("Original event source")).toBeVisible(); + await expect(page.getByText("Decrypted event source")).not.toBeVisible(); + }, + ); + }); +}); + +async function viewSourceOnRoomNameEvent(page: Page) { + await page + .getByRole("listitem") + .filter({ hasText: "created and configured the room" }) + .getByRole("button", { name: "expand" }) + .click(); + + await page + .getByRole("listitem") + .filter({ hasText: "changed the room name to" }) + .getByRole("button", { name: "Options" }) + .click(); + + await page.getByRole("menuitem", { name: "View source" }).click(); +} diff --git a/playwright/e2e/room/room-header.spec.ts b/playwright/e2e/room/room-header.spec.ts index 78e37cd4d2..2f681fe3d3 100644 --- a/playwright/e2e/room/room-header.spec.ts +++ b/playwright/e2e/room/room-header.spec.ts @@ -15,6 +15,11 @@ import { type ElementAppPage } from "../../pages/ElementAppPage"; test.describe("Room Header", () => { test.use({ displayName: "Sakura", + config: { + features: { + feature_new_room_list: false, + }, + }, }); test.describe("with feature_notifications enabled", () => { @@ -23,7 +28,7 @@ test.describe("Room Header", () => { }); test("should render default buttons properly", { tag: "@screenshot" }, async ({ page, app, user }) => { await app.client.createRoom({ name: "Test Room" }); - await app.viewRoomByName("Test Room"); + await app.viewRoomByNameOnOldRoomList("Test Room"); const header = page.locator(".mx_RoomHeader"); @@ -37,11 +42,8 @@ test.describe("Room Header", () => { await expect(header.locator(".mx_FacePile")).toBeVisible(); // There should be both a voice and a video call button - // but they'll be disabled - const callButtons = header.getByRole("button", { name: "There's no one here to call" }); - await expect(callButtons).toHaveCount(2); - await expect(callButtons.first()).toBeVisible(); - await expect(callButtons.last()).toBeVisible(); + await expect(header.getByRole("button", { name: "Video call" })).toBeVisible(); + await expect(header.getByRole("button", { name: "Voice call" })).toBeVisible(); await expect(header.getByRole("button", { name: "Threads" })).toBeVisible(); await expect(header.getByRole("button", { name: "Notifications" })).toBeVisible(); @@ -64,7 +66,7 @@ test.describe("Room Header", () => { "officia deserunt mollit anim id est laborum."; await app.client.createRoom({ name: LONG_ROOM_NAME }); - await app.viewRoomByName(LONG_ROOM_NAME); + await app.viewRoomByNameOnOldRoomList(LONG_ROOM_NAME); const header = page.locator(".mx_RoomHeader"); // Wait until the room name is set @@ -89,7 +91,7 @@ test.describe("Room Header", () => { test("should render room header icon correctly", { tag: "@screenshot" }, async ({ page, app, user }) => { await app.client.createRoom({ name: "Test Room", visibility: "public" as Visibility }); - await app.viewRoomByName("Test Room"); + await app.viewRoomByNameOnOldRoomList("Test Room"); const header = page.locator(".mx_RoomHeader"); @@ -109,7 +111,7 @@ test.describe("Room Header", () => { await page.getByRole("button", { name: "Create video room" }).click(); - await app.viewRoomByName("Test video room"); + await app.viewRoomByNameOnOldRoomList("Test video room"); }; test.describe("and with feature_notifications enabled", () => { diff --git a/playwright/e2e/room/room-status-bar.spec.ts b/playwright/e2e/room/room-status-bar.spec.ts new file mode 100644 index 0000000000..249aa6e9d5 --- /dev/null +++ b/playwright/e2e/room/room-status-bar.spec.ts @@ -0,0 +1,174 @@ +/* +Copyright 2025 Element Creations Ltd. +SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial +Please see LICENSE files in the repository root for full details. +*/ + +import { test, expect } from "../../element-web-test"; + +test.describe("Room Status Bar", () => { + test.use({ + displayName: "Jim", + page: async ({ page }, use) => { + // Increase width as these components look horrible at lower + // widths. + await page.setViewportSize({ width: 1400, height: 768 }); + await use(page); + }, + room: async ({ app, user }, use) => { + const roomId = await app.client.createRoom({ + name: "A room", + }); + await app.closeNotificationToast(); + await app.viewRoomById(roomId); + await use({ roomId }); + }, + }); + + test("should show an error when sync stops", { tag: "@screenshot" }, async ({ page, user, app, room, axe }) => { + await page.route("**/_matrix/client/*/sync*", async (route, req) => { + await route.fulfill({ + status: 500, + contentType: "application/json", + body: '{"error": "Test fail", "errcode": "M_UNKNOWN"}', + }); + }); + await app.client.sendMessage(room.roomId, "forcing sync to run"); + const banner = page.getByRole("region", { name: "Room status bar" }); + await expect(banner).toBeVisible({ timeout: 15000 }); + await expect(banner).toMatchScreenshot("connectivity_lost.png"); + }); + test("should NOT an error when a resource limit is hit", async ({ page, user, app, room, axe, toasts }) => { + await app.viewRoomById(room.roomId); + await page.route("**/_matrix/client/*/sync*", async (route, req) => { + await route.fulfill({ + status: 400, + contentType: "application/json", + body: JSON.stringify({ + error: "Test fail", + errcode: "M_RESOURCE_LIMIT_EXCEEDED", + limit_type: "monthly_active_user", + admin_contact: "https://example.org", + }), + }); + }); + await app.client.sendMessage(room.roomId, "forcing sync to run"); + // Wait for the MAU warning toast to appear so we know this status bar would have appeared. + await toasts.getToast("Warning", 15000); + await expect(page.getByRole("region", { name: "Room status bar" })).not.toBeVisible(); + }); + test( + "should show an error when the user needs to consent", + { tag: "@screenshot" }, + async ({ page, user, app, room, axe }) => { + await app.viewRoomById(room.roomId); + await page.route("**/_matrix/client/**/send**", async (route) => { + await route.fulfill({ + status: 400, + contentType: "application/json", + body: JSON.stringify({ + error: "Test fail", + errcode: "M_CONSENT_NOT_GIVEN", + consent_uri: "https://example.org", + }), + }); + }); + const composer = app.getComposerField(); + await composer.fill("Hello!"); + await composer.press("Enter"); + await page + .getByRole("dialog", { name: "Terms and Conditions" }) + .getByRole("button", { name: "Dismiss" }) + .click(); + const banner = page.getByRole("region", { name: "Room status bar" }); + await expect(banner).toBeVisible({ timeout: 15000 }); + await expect(banner).toMatchScreenshot("consent.png"); + }, + ); + test.describe("Message fails to send", () => { + test.beforeEach(async ({ page, user, app, room, axe }) => { + await app.viewRoomById(room.roomId); + await page.route("**/_matrix/client/**/send**", async (route) => { + await route.fulfill({ + status: 400, + contentType: "application/json", + body: JSON.stringify({ error: "Test fail", errcode: "M_UNKNOWN" }), + }); + }); + const composer = app.getComposerField(); + await composer.fill("Hello!"); + await composer.press("Enter"); + const banner = page.getByRole("region", { name: "Room status bar" }); + await expect(banner).toBeVisible(); + }); + test( + "should show an error when a message fails to send", + { tag: "@screenshot" }, + async ({ page, user, app, room, axe }) => { + const banner = page.getByRole("region", { name: "Room status bar" }); + await expect(banner).toMatchScreenshot("message_failed.png"); + }, + ); + test("should be able to 'Delete all' messages", async ({ page, user, app, room, axe }) => { + const banner = page.getByRole("region", { name: "Room status bar" }); + await banner.getByRole("button", { name: "Delete all" }).click(); + await expect(banner).not.toBeVisible(); + }); + test("should be able to 'Retry all' messages", async ({ page, user, app, room, axe }) => { + const banner = page.getByRole("region", { name: "Room status bar" }); + await page.unroute("**/_matrix/client/**/send**"); + await banner.getByRole("button", { name: "Retry all" }).click(); + await expect(banner).not.toBeVisible(); + }); + }); + + test.describe("Local rooms", () => { + test.use({ + botCreateOpts: { + displayName: "Alice", + }, + }); + test( + "should show an error when creating a local room fails", + { tag: "@screenshot" }, + async ({ page, app, user, bot }) => { + await page + .getByRole("navigation", { name: "Room list" }) + .getByRole("button", { name: "New conversation" }) + .click(); + await page.getByRole("menuitem", { name: "Start chat" }).click(); + + await page.route("**/_matrix/client/*/createRoom*", async (route, req) => { + await route.fulfill({ + status: 400, + contentType: "application/json", + body: JSON.stringify({ + error: "Test fail", + errcode: "M_UNKNOWN", + }), + }); + }); + + const other = page.locator(".mx_InviteDialog_other"); + await other.getByTestId("invite-dialog-input").fill(bot.credentials.userId); + await expect( + other.getByRole("option", { name: "Alice" }).getByText(bot.credentials.userId), + ).toBeVisible(); + await other.getByRole("option", { name: "Alice" }).click(); + await other.getByRole("button", { name: "Go" }).click(); + // Send a message to invite the bots + const composer = app.getComposerField(); + await composer.fill("Hello"); + await composer.press("Enter"); + + const banner = page.getByText("!Some of your messages have"); + await expect(banner).toBeVisible(); + await expect(banner).toMatchScreenshot("local_room_create_failed.png"); + + await page.unroute("**/_matrix/client/*/createRoom*"); + await banner.getByRole("button", { name: "Retry" }).click(); + await expect(banner).not.toBeVisible(); + }, + ); + }); +}); diff --git a/playwright/e2e/room_options/marked_unread.spec.ts b/playwright/e2e/room_options/marked_unread.spec.ts index 2817bbc921..ce3e170072 100644 --- a/playwright/e2e/room_options/marked_unread.spec.ts +++ b/playwright/e2e/room_options/marked_unread.spec.ts @@ -34,7 +34,7 @@ test.describe("Mark as Unread", () => { await bot.sendMessage(roomId, "I am a robot. Beep."); // Regular notification on new message - await expect(page.getByLabel(TEST_ROOM_NAME + " 1 unread message.")).toBeVisible(); + await expect(page.getByLabel(`Open room ${TEST_ROOM_NAME} with 1 unread message.`)).toBeVisible(); await expect(page).toHaveTitle("Element [1]"); await page.goto("/#/room/" + roomId); @@ -47,10 +47,12 @@ test.describe("Mark as Unread", () => { await page.goto("/#/room/" + dummyRoomId); const roomTile = page.getByLabel(TEST_ROOM_NAME); - await roomTile.focus(); - await roomTile.getByRole("button", { name: "Room options" }).click(); + await roomTile.click({ button: "right" }); await page.getByRole("menuitem", { name: "Mark as unread" }).click(); - await expect(page.getByLabel(TEST_ROOM_NAME + " Unread messages.")).toBeVisible(); + // focus another room to make the notification decoration appear (room options are display on hover) + await page.getByRole("option", { name: "Open room Room of no consequence" }).click(); + + await expect(roomTile.getByTestId("notification-decoration")).toBeVisible(); }); }); diff --git a/playwright/e2e/settings/appearance-user-settings-tab/appearance-user-settings-tab.spec.ts b/playwright/e2e/settings/appearance-user-settings-tab/appearance-user-settings-tab.spec.ts index 53f9c37dd0..df41ef4b70 100644 --- a/playwright/e2e/settings/appearance-user-settings-tab/appearance-user-settings-tab.spec.ts +++ b/playwright/e2e/settings/appearance-user-settings-tab/appearance-user-settings-tab.spec.ts @@ -50,8 +50,8 @@ test.describe("Appearance user settings tab", () => { // Click "Show advanced" link button await tab.getByRole("button", { name: "Show advanced" }).click(); - await tab.getByLabel("Use bundled emoji font").click(); - await tab.getByLabel("Use a system font").click(); + await tab.getByRole("switch", { name: "Use bundled emoji font" }).click(); + await tab.getByRole("switch", { name: "Use a system font" }).click(); // Assert that the font-family value was removed await expect(page.locator("body")).toHaveCSS("font-family", '""'); diff --git a/playwright/e2e/settings/appearance-user-settings-tab/index.ts b/playwright/e2e/settings/appearance-user-settings-tab/index.ts index 29e51fb0dd..15f2f47888 100644 --- a/playwright/e2e/settings/appearance-user-settings-tab/index.ts +++ b/playwright/e2e/settings/appearance-user-settings-tab/index.ts @@ -84,8 +84,8 @@ class Helpers { /** * Return the system theme toggle */ - getMatchSystemThemeCheckbox() { - return this.getThemePanel().getByRole("checkbox", { name: "Match system theme" }); + getMatchSystemThemeSwitch() { + return this.getThemePanel().getByRole("switch", { name: "Match system theme" }); } /** @@ -216,10 +216,10 @@ class Helpers { } /** - * Return the compact layout checkbox + * Return the compact layout switch */ - getCompactLayoutCheckbox() { - return this.getMessageLayoutPanel().getByRole("checkbox", { name: "Show compact text and messages" }); + getCompactLayoutSwitch() { + return this.getMessageLayoutPanel().getByRole("switch", { name: "Show compact text and messages" }); } /** diff --git a/playwright/e2e/settings/appearance-user-settings-tab/message-layout-panel.spec.ts b/playwright/e2e/settings/appearance-user-settings-tab/message-layout-panel.spec.ts index ce0d614d90..3c05487723 100644 --- a/playwright/e2e/settings/appearance-user-settings-tab/message-layout-panel.spec.ts +++ b/playwright/e2e/settings/appearance-user-settings-tab/message-layout-panel.spec.ts @@ -40,9 +40,9 @@ test.describe("Appearance user settings tab", () => { ); test("should enable compact layout when the modern layout is selected", async ({ page, app, user, util }) => { - await expect(util.getCompactLayoutCheckbox()).not.toBeChecked(); + await expect(util.getCompactLayoutSwitch()).not.toBeChecked(); - await util.getCompactLayoutCheckbox().click(); + await util.getCompactLayoutSwitch().click(); await util.assertCompactLayout(); }); @@ -52,11 +52,11 @@ test.describe("Appearance user settings tab", () => { user, util, }) => { - await expect(util.getCompactLayoutCheckbox()).not.toBeDisabled(); + await expect(util.getCompactLayoutSwitch()).not.toBeDisabled(); // Select the bubble layout, which should disable the compact layout checkbox await util.getBubbleLayout().click(); - await expect(util.getCompactLayoutCheckbox()).toBeDisabled(); + await expect(util.getCompactLayoutSwitch()).toBeDisabled(); }); }); }); diff --git a/playwright/e2e/settings/appearance-user-settings-tab/theme-choice-panel.spec.ts b/playwright/e2e/settings/appearance-user-settings-tab/theme-choice-panel.spec.ts index 1fad16948d..7198e1fd82 100644 --- a/playwright/e2e/settings/appearance-user-settings-tab/theme-choice-panel.spec.ts +++ b/playwright/e2e/settings/appearance-user-settings-tab/theme-choice-panel.spec.ts @@ -25,7 +25,7 @@ test.describe("Appearance user settings tab", () => { { tag: "@screenshot" }, async ({ page, app, util }) => { // Assert that 'Match system theme' is not checked - await expect(util.getMatchSystemThemeCheckbox()).not.toBeChecked(); + await expect(util.getMatchSystemThemeSwitch()).not.toBeChecked(); // Assert that the light theme is selected await expect(util.getLightTheme()).toBeChecked(); @@ -41,7 +41,7 @@ test.describe("Appearance user settings tab", () => { "should disable the themes when the system theme is clicked", { tag: "@screenshot" }, async ({ page, app, util }) => { - await util.getMatchSystemThemeCheckbox().click(); + await util.getMatchSystemThemeSwitch().click(); // Assert that the themes are disabled await expect(util.getLightTheme()).toBeDisabled(); diff --git a/playwright/e2e/settings/encryption-user-tab/encryption-tab.spec.ts b/playwright/e2e/settings/encryption-user-tab/encryption-tab.spec.ts index ed1daecf35..c944e92307 100644 --- a/playwright/e2e/settings/encryption-user-tab/encryption-tab.spec.ts +++ b/playwright/e2e/settings/encryption-user-tab/encryption-tab.spec.ts @@ -25,7 +25,9 @@ test.describe("Encryption tab", () => { test.beforeEach(async ({ page, homeserver, credentials }) => { // The bot bootstraps cross-signing, creates a key backup and sets up a recovery key - const res = await createBot(page, homeserver, credentials); + const botCredentials = { ...credentials }; + delete botCredentials.accessToken; // use a new login for the bot + const res = await createBot(page, homeserver, botCredentials); recoveryKey = res.recoveryKey; expectedBackupVersion = res.expectedBackupVersion; }); @@ -117,7 +119,7 @@ test.describe("Encryption tab", () => { await verifySession(app, recoveryKey.encodedPrivateKey); await util.openEncryptionTab(); - await page.getByRole("checkbox", { name: "Allow key storage" }).click(); + await page.getByRole("switch", { name: "Allow key storage" }).click(); await expect( page.getByRole("heading", { name: "Are you sure you want to turn off key storage and delete it?" }), @@ -136,7 +138,7 @@ test.describe("Encryption tab", () => { await page.getByRole("button", { name: "Delete key storage" }).click(); - await expect(page.getByRole("checkbox", { name: "Allow key storage" })).not.toBeChecked(); + await expect(page.getByRole("switch", { name: "Allow key storage" })).not.toBeChecked(); for (const prom of deleteRequestPromises) { const request = await prom; @@ -160,15 +162,15 @@ test.describe("Encryption tab", () => { // We will reset our identity await settings.getByRole("button", { name: "Verify this device" }).click(); - await page.getByRole("button", { name: "Proceed with reset" }).click(); + await page.getByRole("button", { name: "Can't confirm?" }).click(); // First try cancelling and restarting await page.getByRole("button", { name: "Cancel" }).click(); - await page.getByRole("button", { name: "Proceed with reset" }).click(); + await page.getByRole("button", { name: "Can't confirm?" }).click(); // Then click outside the dialog and restart await page.locator("li").filter({ hasText: "Encryption" }).click({ force: true }); - await page.getByRole("button", { name: "Proceed with reset" }).click(); + await page.getByRole("button", { name: "Can't confirm?" }).click(); // Finally we actually continue await page.getByRole("button", { name: "Continue" }).click(); diff --git a/playwright/e2e/settings/encryption-user-tab/index.ts b/playwright/e2e/settings/encryption-user-tab/index.ts index a7351fd2b4..3d180047a1 100644 --- a/playwright/e2e/settings/encryption-user-tab/index.ts +++ b/playwright/e2e/settings/encryption-user-tab/index.ts @@ -43,7 +43,7 @@ class Helpers { */ async verifyDevice(recoveryKey: GeneratedSecretStorageKey) { // Select the security phrase - await this.page.getByRole("button", { name: "Verify with Recovery Key" }).click(); + await this.page.getByRole("button", { name: "Use recovery key" }).click(); await this.enterRecoveryKey(recoveryKey); await this.page.getByRole("button", { name: "Done" }).click(); } @@ -104,7 +104,10 @@ class Helpers { const clipboardContent = await this.app.getClipboard(); await dialog.getByRole("textbox").fill(clipboardContent); - await dialog.getByRole("button", { name: confirmButtonLabel }).click(); + const button = dialog.getByRole("button", { name: confirmButtonLabel }); + await button.click(); + // Button should disable immediately after clicking. + await expect(button).toBeDisabled(); await expect(this.getEncryptionRecoverySection()).toMatchScreenshot("default-recovery.png"); } } diff --git a/playwright/e2e/settings/encryption-user-tab/recovery.spec.ts b/playwright/e2e/settings/encryption-user-tab/recovery.spec.ts index 8895e4a7ee..db558a43da 100644 --- a/playwright/e2e/settings/encryption-user-tab/recovery.spec.ts +++ b/playwright/e2e/settings/encryption-user-tab/recovery.spec.ts @@ -17,7 +17,9 @@ test.describe("Recovery section in Encryption tab", () => { let recoveryKey: GeneratedSecretStorageKey; test.beforeEach(async ({ page, homeserver, credentials }) => { // The bot bootstraps cross-signing, creates a key backup and sets up a recovery key - const res = await createBot(page, homeserver, credentials); + const botCredentials = { ...credentials }; + delete botCredentials.accessToken; // use a new login for the bot + const res = await createBot(page, homeserver, botCredentials); recoveryKey = res.recoveryKey; }); diff --git a/playwright/e2e/settings/notifications/notifications-settings-2-tab.spec.ts b/playwright/e2e/settings/notifications/notifications-settings-2-tab.spec.ts new file mode 100644 index 0000000000..48f03ecf0d --- /dev/null +++ b/playwright/e2e/settings/notifications/notifications-settings-2-tab.spec.ts @@ -0,0 +1,28 @@ +/* +Copyright 2025 New Vector Ltd. + +SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial +Please see LICENSE files in the repository root for full details. +*/ + +import { test, expect } from "../../../element-web-test"; +import { SettingLevel } from "../../../../src/settings/SettingLevel"; + +test.describe("Notifications 2 tab", () => { + test.use({ + displayName: "Alice", + }); + + test("should display notification settings", { tag: "@screenshot" }, async ({ page, app, user, axe }) => { + await app.settings.setValue("feature_notification_settings2", null, SettingLevel.DEVICE, true); + await page.setViewportSize({ width: 1024, height: 2000 }); + const settings = await app.settings.openUserSettings("Notifications"); + + axe.disableRules("color-contrast"); // XXX: Inheriting colour contrast issues from room view. + await expect(axe).toHaveNoViolations(); + await expect(settings).toMatchScreenshot("standard-notifications-2-settings.png", { + // Mask the mxid. + mask: [settings.locator("#mx_NotificationSettings2_MentionCheckbox span")], + }); + }); +}); diff --git a/playwright/e2e/settings/notifications/notifications-settings-tab.spec.ts b/playwright/e2e/settings/notifications/notifications-settings-tab.spec.ts new file mode 100644 index 0000000000..a63e730788 --- /dev/null +++ b/playwright/e2e/settings/notifications/notifications-settings-tab.spec.ts @@ -0,0 +1,25 @@ +/* +Copyright 2025 New Vector Ltd. + +SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial +Please see LICENSE files in the repository root for full details. +*/ + +import { test, expect } from "../../../element-web-test"; + +test.describe("Notifications tab", () => { + test.use({ + displayName: "Alice", + }); + + test("should display notification settings", { tag: "@screenshot" }, async ({ page, app, user, axe }) => { + await page.setViewportSize({ width: 1024, height: 1400 }); + const settings = await app.settings.openUserSettings("Notifications"); + await settings.getByLabel("Enable notifications for this account").check(); + await settings.getByLabel("Enable notifications for this device").check(); + + axe.disableRules("color-contrast"); // XXX: Inheriting colour contrast issues from room view. + await expect(axe).toHaveNoViolations(); + await expect(settings).toMatchScreenshot("standard-notification-settings.png"); + }); +}); diff --git a/playwright/e2e/settings/preferences-user-settings-tab.spec.ts b/playwright/e2e/settings/preferences-user-settings-tab.spec.ts index 33392d8848..8adbc74cc5 100644 --- a/playwright/e2e/settings/preferences-user-settings-tab.spec.ts +++ b/playwright/e2e/settings/preferences-user-settings-tab.spec.ts @@ -21,16 +21,23 @@ test.describe("Preferences user settings tab", () => { const locator = await app.settings.openUserSettings("Preferences"); await use(locator); }, + // display message preview settings + labsFlags: ["feature_new_room_list"], }); test("should be rendered properly", { tag: "@screenshot" }, async ({ app, page, user }) => { - await page.setViewportSize({ width: 1024, height: 3300 }); + await page.setViewportSize({ width: 1024, height: 4000 }); const tab = await app.settings.openUserSettings("Preferences"); // Assert that the top heading is rendered await expect(tab.getByRole("heading", { name: "Preferences" })).toBeVisible(); await expect(tab).toMatchScreenshot("Preferences-user-settings-tab-should-be-rendered-properly-1.png", { - // masked due to daylight saving time + // masked with fixed-width due to daylight saving time making the text content vary mask: [tab.locator("#mx_dropdownUserTimezone_value")], + css: ` + #mx_dropdownUserTimezone_value { + width: 200px; + } + `, }); }); diff --git a/playwright/e2e/settings/roles-permissions-room-settings-tab.spec.ts b/playwright/e2e/settings/room-settings/roles-permissions-room-settings-tab.spec.ts similarity index 97% rename from playwright/e2e/settings/roles-permissions-room-settings-tab.spec.ts rename to playwright/e2e/settings/room-settings/roles-permissions-room-settings-tab.spec.ts index 06ee25e9cb..d287234af5 100644 --- a/playwright/e2e/settings/roles-permissions-room-settings-tab.spec.ts +++ b/playwright/e2e/settings/room-settings/roles-permissions-room-settings-tab.spec.ts @@ -8,7 +8,7 @@ import { type Locator } from "@playwright/test"; -import { test, expect } from "../../element-web-test"; +import { test, expect } from "../../../element-web-test"; test.describe("Roles & Permissions room settings tab", () => { const roomName = "Test room"; diff --git a/playwright/e2e/settings/room-settings/room-security-tab.spec.ts b/playwright/e2e/settings/room-settings/room-security-tab.spec.ts new file mode 100644 index 0000000000..605d38abc2 --- /dev/null +++ b/playwright/e2e/settings/room-settings/room-security-tab.spec.ts @@ -0,0 +1,121 @@ +/* + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial + * Please see LICENSE files in the repository root for full details. + */ + +import { type Locator } from "@playwright/test"; + +import { test, expect } from "../../../element-web-test"; + +test.describe("Roles & Permissions room settings tab", () => { + const roomName = "Test room"; + + test.use({ + displayName: "Alice", + }); + + let settings: Locator; + + test.beforeEach(async ({ user, app }) => { + await app.client.createRoom({ + name: roomName, + power_level_content_override: { + events: { + // Set the join rules as lower than the history vis to test an edge case. + ["m.room.join_rules"]: 80, + ["m.room.history_visibility"]: 100, + }, + }, + }); + await app.viewRoomByName(roomName); + settings = await app.settings.openRoomSettings("Security & Privacy"); + }); + + test( + "should be able to toggle on encryption in a room", + { tag: "@screenshot" }, + async ({ page, app, user, axe }) => { + await page.setViewportSize({ width: 1024, height: 1400 }); + const encryptedToggle = settings.getByLabel("Encrypted"); + await encryptedToggle.click(); + + // Accept the dialog. + await page.getByRole("button", { name: "Ok " }).click(); + + await expect(encryptedToggle).toBeChecked(); + await expect(encryptedToggle).toBeDisabled(); + + await settings.getByLabel("Only send messages to verified users.").check(); + + axe.disableRules("color-contrast"); // XXX: Inheriting colour contrast issues from room view. + await expect(axe).toHaveNoViolations(); + await expect(settings).toMatchScreenshot("room-security-settings.png"); + }, + ); + + test( + "should automatically adjust history visibility when a room is changed from public to private", + { tag: "@screenshot" }, + async ({ page, app, user, axe }) => { + await page.setViewportSize({ width: 1024, height: 1400 }); + + const settingsGroupAccess = page.getByRole("group", { name: "Access" }); + const settingsGroupHistory = page.getByRole("group", { name: "Who can read history?" }); + + await settingsGroupAccess.getByText("Public").click(); + await settingsGroupHistory.getByText("Anyone").click(); + + // Test that we have the warning appear. + axe.disableRules("color-contrast"); // XXX: Inheriting colour contrast issues from room view. + await expect(axe).toHaveNoViolations(); + await expect(settings).toMatchScreenshot("room-security-settings-world-readable.png"); + + await settingsGroupAccess.getByText("Private (invite only)").click(); + // Element should have automatically set the room to "sharing" history visibility + await expect( + settingsGroupHistory.getByText("Members only (since the point in time of selecting this option)"), + ).toBeChecked(); + }, + ); + + test( + "should disallow changing from public to private if the user cannot alter history", + { tag: "@screenshot" }, + async ({ page, app, user, bot }) => { + await page.setViewportSize({ width: 1024, height: 1400 }); + + const settingsGroupAccess = page.getByRole("group", { name: "Access" }); + const settingsGroupHistory = page.getByRole("group", { name: "Who can read history?" }); + + await settingsGroupAccess.getByText("Public").click(); + await settingsGroupHistory.getByText("Anyone").click(); + + // De-op ourselves + await app.settings.switchTab("Roles & Permissions"); + + // Wait for the permissions list to be visible + await expect(settings.getByRole("heading", { name: "Permissions" })).toBeVisible(); + + const ourComboBox = settings.getByRole("combobox", { name: user.userId }); + await ourComboBox.selectOption("Custom level"); + const ourPl = settings.getByRole("spinbutton", { name: user.userId }); + await ourPl.fill("80"); + await ourPl.blur(); // Shows a warning on + + // Accept the de-op + await page.getByRole("button", { name: "Continue" }).click(); + await settings.getByRole("button", { name: "Apply", disabled: false }).click(); + + await app.settings.switchTab("Security & Privacy"); + + await settingsGroupAccess.getByText("Private (invite only)").click(); + // Element should have automatically set the room to "sharing" history visibility + const errorDialog = page.getByRole("heading", { name: "Cannot make room private" }); + await expect(errorDialog).toBeVisible(); + await errorDialog.getByLabel("OK"); + await expect(settingsGroupHistory.getByText("Anyone")).toBeChecked(); + }, + ); +}); diff --git a/playwright/e2e/settings/room-settings/room-video-tab.spec.ts b/playwright/e2e/settings/room-settings/room-video-tab.spec.ts new file mode 100644 index 0000000000..feff22a04e --- /dev/null +++ b/playwright/e2e/settings/room-settings/room-video-tab.spec.ts @@ -0,0 +1,42 @@ +/* + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial + * Please see LICENSE files in the repository root for full details. + */ + +import { type Locator } from "@playwright/test"; + +import { test, expect } from "../../../element-web-test"; +import { SettingLevel } from "../../../../src/settings/SettingLevel"; + +test.describe("Voice & Video room settings tab", () => { + const roomName = "Test room"; + + test.use({ + displayName: "Alice", + }); + + let settings: Locator; + + test.beforeEach(async ({ user, app, page }) => { + // Execute client actions before setting, as the setting will force a reload. + await app.client.createRoom({ name: roomName }); + await app.settings.setValue("feature_group_calls", null, SettingLevel.DEVICE, true); + await app.viewRoomByName(roomName); + settings = await app.settings.openRoomSettings("Voice & Video"); + }); + + test( + "should be able to toggle on Element Call in the room", + { tag: "@screenshot" }, + async ({ page, app, user, axe }) => { + await page.setViewportSize({ width: 1024, height: 1400 }); + const callToggle = settings.getByLabel("Enable Element Call as an additional calling option in this room"); + await callToggle.check(); + axe.disableRules("color-contrast"); // XXX: Inheriting colour contrast issues from room view. + await expect(axe).toHaveNoViolations(); + await expect(settings).toMatchScreenshot("room-video-settings.png"); + }, + ); +}); diff --git a/playwright/e2e/settings/security-user-settings-tab.spec.ts b/playwright/e2e/settings/security-user-settings-tab.spec.ts index 25bf1a9dbe..377cae7495 100644 --- a/playwright/e2e/settings/security-user-settings-tab.spec.ts +++ b/playwright/e2e/settings/security-user-settings-tab.spec.ts @@ -41,6 +41,18 @@ test.describe("Security user settings tab", () => { }); }); + test("should render the security tab", { tag: "@screenshot" }, async ({ app, page, user }) => { + await page.setViewportSize({ width: 1024, height: 1400 }); + const tab = await app.settings.openUserSettings("Security"); + await expect(tab).toMatchScreenshot("security-settings-tab.png", { + mask: [ + // Contains IM name. + tab.locator("#mx_SetIntegrationManager_BodyText"), + tab.locator("#mx_SetIntegrationManager_ManagerName"), + ], + }); + }); + test("should be able to set an ID server", async ({ app, context, user, page }) => { const tab = await app.settings.openUserSettings("Security"); diff --git a/playwright/e2e/share-dialog/share-by-url.spec.ts b/playwright/e2e/share-dialog/share-by-url.spec.ts index c5b9174782..49024d79d9 100644 --- a/playwright/e2e/share-dialog/share-by-url.spec.ts +++ b/playwright/e2e/share-dialog/share-by-url.spec.ts @@ -18,13 +18,14 @@ test.describe("share from URL", () => { test("should share message when users navigates to share URL", async ({ page, user, room, app }) => { await page.goto("/#/share?msg=Hello+world"); + const dialog = page.getByRole("dialog", { name: "Forward message" }); // The forward message dialog doesn't update as new infomation arrives via sync, which means sometimes // this is just says, "Empty room". For the same reason, we can't reliably write a test for loading the // app straight away with a /#/share url as the room doesn't appear until the client syncs.] // Ideally we should fix the forward dialog to update and eliminate races, until then, there is only one // room so we click the first button. - await page.getByRole("listitem" /*, { name: "A test room" }*/).getByRole("button", { name: "Send" }).click(); - await page.keyboard.press("Escape"); + await dialog.getByRole("listitem" /*, { name: "A test room" }*/).getByRole("button", { name: "Send" }).click(); + await dialog.getByRole("button", { name: "Close" }).click(); await app.viewRoomByName("A test room"); const lastMessage = page.locator(".mx_RoomView_MessageList .mx_EventTile_last"); await expect(lastMessage).toBeVisible(); diff --git a/playwright/e2e/share-dialog/share-dialog.spec.ts b/playwright/e2e/share-dialog/share-dialog.spec.ts index 58574a46ff..a77e89fcdc 100644 --- a/playwright/e2e/share-dialog/share-dialog.spec.ts +++ b/playwright/e2e/share-dialog/share-dialog.spec.ts @@ -35,7 +35,7 @@ test.describe("Share dialog", () => { const rightPanel = await app.toggleRoomInfoPanel(); await rightPanel.getByRole("menuitem", { name: "People" }).click(); - await rightPanel.getByRole("button", { name: `${user.userId} (power 100)` }).click(); + await rightPanel.getByRole("option", { name: user.displayName }).click(); await rightPanel.getByRole("button", { name: "Share profile" }).click(); const dialog = page.getByRole("dialog", { name: "Share User" }); diff --git a/playwright/e2e/sliding-sync/sliding-sync.spec.ts b/playwright/e2e/sliding-sync/sliding-sync.spec.ts index b540cd11d5..b31deadace 100644 --- a/playwright/e2e/sliding-sync/sliding-sync.spec.ts +++ b/playwright/e2e/sliding-sync/sliding-sync.spec.ts @@ -6,7 +6,7 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com Please see LICENSE files in the repository root for full details. */ -import { type Page, type Request } from "@playwright/test"; +import { type Locator, type Page, type Request } from "@playwright/test"; import { test as base, expect } from "../../element-web-test"; import type { ElementAppPage } from "../../pages/ElementAppPage"; @@ -38,7 +38,7 @@ const test = base.extend<{ test.describe("Sliding Sync", () => { const checkOrder = async (wantOrder: string[], page: Page) => { - await expect(page.getByRole("group", { name: "Rooms" }).locator(".mx_RoomTile_title")).toHaveText(wantOrder); + await expect(page.getByTestId("room-list").locator(".mx_RoomListItemView_text")).toHaveText(wantOrder); }; const bumpRoom = async (roomId: string, app: ElementAppPage) => { @@ -50,6 +50,18 @@ test.describe("Sliding Sync", () => { }); }; + function getPrimaryFilters(page: Page): Locator { + return page.getByTestId("primary-filters"); + } + + function getRoomOptionsMenu(page: Page): Locator { + return page.getByRole("button", { name: "Room Options" }); + } + + function getFilterExpandButton(page: Page): Locator { + return getPrimaryFilters(page).getByRole("button", { name: "Expand filter list" }); + } + test.use({ config: { features: { @@ -69,20 +81,15 @@ test.describe("Sliding Sync", () => { // create rooms and check room names are correct for (const fruit of ["Apple", "Pineapple", "Orange"]) { await app.client.createRoom({ name: fruit }); - await expect(page.getByRole("treeitem", { name: fruit })).toBeVisible(); + await expect(page.getByRole("option", { name: `Open room ${fruit}` })).toBeVisible(); } - + const roomList = page.getByTestId("room-list"); // Check count, 3 fruits + 1 testRoom = 4 - await expect(page.locator(".mx_RoomSublist_tiles").getByRole("treeitem")).toHaveCount(4); + await expect(roomList.getByRole("option")).toHaveCount(4); await checkOrder(["Orange", "Pineapple", "Apple", "Test Room"], page); - const locator = page.getByRole("group", { name: "Rooms" }).locator(".mx_RoomSublist_headerContainer"); - await locator.hover(); - await locator.getByRole("button", { name: "List options" }).click(); - - // force click as the radio button's size is zero - await page.getByRole("menuitemradio", { name: "A-Z" }).dispatchEvent("click"); - await expect(page.locator(".mx_StyledRadioButton_checked").getByText("A-Z")).toBeVisible(); + await getRoomOptionsMenu(page).click(); + await page.getByRole("menuitemradio", { name: "A-Z" }).click(); await checkOrder(["Apple", "Orange", "Pineapple", "Test Room"], page); }); @@ -93,11 +100,11 @@ test.describe("Sliding Sync", () => { for (const fruit of ["Apple", "Pineapple", "Orange"]) { const id = await app.client.createRoom({ name: fruit }); roomIds.push(id); - await expect(page.getByRole("treeitem", { name: fruit })).toBeVisible(); + await expect(page.getByRole("option", { name: `Open room ${fruit}` })).toBeVisible(); } // Select the Test Room - await page.getByRole("treeitem", { name: "Test Room" }).click(); + await page.getByRole("option", { name: "Open room Test Room" }).click(); const [apple, pineapple, orange] = roomIds; await checkOrder(["Orange", "Pineapple", "Apple", "Test Room"], page); await bumpRoom(apple, app); @@ -116,7 +123,7 @@ test.describe("Sliding Sync", () => { for (const fruit of ["Apple", "Pineapple", "Orange"]) { const id = await app.client.createRoom({ name: fruit }); roomIds.push(id); - await expect(page.getByRole("treeitem", { name: fruit })).toBeVisible(); + await expect(page.getByRole("option", { name: `Open room ${fruit}` })).toBeVisible(); } // Given a list of Orange, Pineapple, Apple - if Pineapple is active and a message is sent in Apple, the list should @@ -124,7 +131,7 @@ test.describe("Sliding Sync", () => { // be Apple, Orange Pineapple - only when you click on a different room do things reshuffle. // Select the Pineapple room - await page.getByRole("treeitem", { name: "Pineapple" }).click(); + await page.getByRole("option", { name: "Open room Pineapple" }).click(); await checkOrder(["Orange", "Pineapple", "Apple", "Test Room"], page); // Move Apple @@ -132,7 +139,7 @@ test.describe("Sliding Sync", () => { await checkOrder(["Apple", "Pineapple", "Orange", "Test Room"], page); // Select the Test Room - await page.getByRole("treeitem", { name: "Test Room" }).click(); + await page.getByRole("option", { name: "Open room Test Room" }).click(); // the rooms reshuffle to match reality await checkOrder(["Apple", "Orange", "Pineapple", "Test Room"], page); @@ -142,25 +149,20 @@ test.describe("Sliding Sync", () => { // send a message in the test room: unread notification count should increment await bob.sendMessage(testRoom.roomId, "Hello World"); - const treeItemLocator1 = page.getByRole("treeitem", { name: "Test Room 1 unread message." }); - await expect(treeItemLocator1.locator(".mx_NotificationBadge_count")).toHaveText("1"); - // await expect(page.locator(".mx_NotificationBadge")).not.toHaveClass("mx_NotificationBadge_highlighted"); - await expect(treeItemLocator1.locator(".mx_NotificationBadge")).not.toHaveClass( - /mx_NotificationBadge_highlighted/, - ); + const itemLocator1 = page.getByRole("option", { name: "Open room David Langley with 1 unread message." }); + await expect(itemLocator1.getByTestId("notification-decoration")).toHaveText("1"); // send an @mention: highlight count (red) should be 2. await bob.sendMessage(testRoom.roomId, `Hello ${user.displayName}`); - const treeItemLocator2 = page.getByRole("treeitem", { - name: "Test Room 2 unread messages including mentions.", + const itemLocator2 = page.getByRole("treeitem", { + name: "Open room Test Room 2 unread messages including mentions.", }); - await expect(treeItemLocator2.locator(".mx_NotificationBadge_count")).toHaveText("2"); - await expect(treeItemLocator2.locator(".mx_NotificationBadge")).toHaveClass(/mx_NotificationBadge_highlighted/); + await expect(itemLocator2.getByTestId("notification-decoration")).toHaveText("2"); // click on the room, the notif counts should disappear - await page.getByRole("treeitem", { name: "Test Room 2 unread messages including mentions." }).click(); + await page.getByRole("option", { name: "Open room Test Room 2 unread messages including mentions." }).click(); await expect( - page.getByRole("treeitem", { name: "Test Room" }).locator("mx_NotificationBadge_count"), + page.getByRole("option", { name: "Open room Test Room" }).getByTestId("notification-decoration"), ).not.toBeAttached(); }); @@ -175,16 +177,16 @@ test.describe("Sliding Sync", () => { // wait for this message to arrive, tell by the room list resorting await checkOrder(["Test Room", "Dummy"], page); - await expect(page.getByRole("treeitem", { name: "Test Room" }).locator(".mx_NotificationBadge")).toBeAttached(); + await expect( + page.getByRole("option", { name: "Open room Test Room" }).getByTestId("notification-decoration"), + ).toBeAttached(); }); test("should update user settings promptly", async ({ page, app }) => { await app.settings.openUserSettings("Preferences"); - const locator = page.locator(".mx_SettingsFlag").filter({ hasText: "Show timestamps in 12 hour format" }); + const locator = page.getByRole("switch", { name: "Show timestamps in 12 hour format" }); await expect(locator).toBeVisible(); - await expect(locator.locator(".mx_ToggleSwitch_on")).not.toBeAttached(); - await locator.locator(".mx_ToggleSwitch_ball").click(); - await expect(locator.locator(".mx_ToggleSwitch_on")).toBeAttached(); + await locator.check(); }); test("should send subscribe_rooms on room switch if room not already subscribed", async ({ page, app }) => { @@ -193,7 +195,7 @@ test.describe("Sliding Sync", () => { for (const fruit of ["Apple", "Pineapple", "Orange"]) { const id = await app.client.createRoom({ name: fruit }); roomIds.push(id); - await expect(page.getByRole("treeitem", { name: fruit })).toBeVisible(); + await expect(page.getByRole("option", { name: `Open room ${fruit}` })).toBeVisible(); } const [roomAId, roomPId] = roomIds; @@ -206,7 +208,7 @@ test.describe("Sliding Sync", () => { // Select the Test Room and wait for playwright to get the request const [request] = await Promise.all([ page.waitForRequest(matchRoomSubRequest(roomAId)), - page.getByRole("treeitem", { name: "Apple", exact: true }).click(), + page.getByRole("option", { name: "Open room Apple", exact: true }).click(), ]); const roomSubscriptions = request.postDataJSON().room_subscriptions; expect(roomSubscriptions, "room_subscriptions is object").toBeDefined(); @@ -214,7 +216,7 @@ test.describe("Sliding Sync", () => { // Switch to another room and wait for playwright to get the request await Promise.all([ page.waitForRequest(matchRoomSubRequest(roomPId)), - page.getByRole("treeitem", { name: "Pineapple", exact: true }).click(), + page.getByRole("option", { name: "Open room Pineapple", exact: true }).click(), ]); }); @@ -240,34 +242,29 @@ test.describe("Sliding Sync", () => { { roomNames, clientUserId }, ); - await expect( - page.getByRole("group", { name: "Invites" }).locator(".mx_RoomSublist_tiles").getByRole("treeitem"), - ).toHaveCount(3); + await getFilterExpandButton(page).click(); + const primaryFilters = getPrimaryFilters(page); + await primaryFilters.getByRole("option", { name: "Invites" }).click(); + + await expect(page.getByTestId("room-list").getByRole("option")).toHaveCount(3); // Select the room to join - await page.getByRole("treeitem", { name: "Room to Join" }).click(); + await page.getByRole("option", { name: "Open room Room to Join" }).click(); // Accept the invite await page.locator(".mx_RoomView").getByRole("button", { name: "Accept" }).click(); - await checkOrder(["Room to Join", "Test Room"], page); + await checkOrder(["Room to Rescind", "Room to Reject"], page); // Select the room to reject - await page.getByRole("treeitem", { name: "Room to Reject" }).click(); + await page.getByRole("option", { name: "Open room Room to Reject" }).click(); // Decline the invite await page.locator(".mx_RoomView").getByRole("button", { name: "Decline", exact: true }).click(); - await expect( - page.getByRole("group", { name: "Invites" }).locator(".mx_RoomSublist_tiles").getByRole("treeitem"), - ).toHaveCount(2); + await expect(page.getByTestId("room-list").getByRole("option")).toHaveCount(1); - // check the lists are correct - await checkOrder(["Room to Join", "Test Room"], page); - - const titleLocator = page.getByRole("group", { name: "Invites" }).locator(".mx_RoomTile_title"); - await expect(titleLocator).toHaveCount(1); - await expect(titleLocator).toHaveText("Room to Rescind"); + await expect(page.getByRole("option", { name: "Open room Room to Rescind" })).toBeVisible(); // now rescind the invite await bot.evaluate( @@ -277,10 +274,14 @@ test.describe("Sliding Sync", () => { { roomRescind, clientUserId }, ); + await page.getByRole("option", { name: "Open room Room to Rescind" }).click(); + + await page.locator(".mx_RoomView").getByRole("button", { name: "Forget this room", exact: true }).click(); + + await primaryFilters.getByRole("option", { name: "Invites" }).click(); + // Wait for the rescind to take effect and check the joined list once more - await expect( - page.getByRole("group", { name: "Rooms" }).locator(".mx_RoomSublist_tiles").getByRole("treeitem"), - ).toHaveCount(2); + await expect(page.getByTestId("room-list").getByRole("option")).toHaveCount(2); await checkOrder(["Room to Join", "Test Room"], page); }); @@ -293,19 +294,27 @@ test.describe("Sliding Sync", () => { await app.client.evaluate(async (client, roomId) => { await client.setRoomTag(roomId, "m.favourite", { order: 0.5 }); }, roomId); - await expect(page.getByRole("group", { name: "Favourites" }).getByText("Favourite DM")).toBeVisible(); - await expect(page.getByRole("group", { name: "People" }).getByText("Favourite DM")).not.toBeAttached(); + + await getFilterExpandButton(page).click(); + const primaryFilters = getPrimaryFilters(page); + await primaryFilters.getByRole("option", { name: "Favourites" }).click(); + + await expect(page.getByRole("option", { name: "Favourite DM" })).toBeVisible(); + + await primaryFilters.getByRole("option", { name: "People" }).click(); + + await expect(page.getByRole("option", { name: "Favourite DM" })).not.toBeAttached(); }); // Regression test for a bug in SS mode, but would be useful to have in non-SS mode too. // This ensures we are setting RoomViewStore state correctly. test("should clear the reply to field when swapping rooms", async ({ page, app, testRoom }) => { await app.client.createRoom({ name: "Other Room" }); - await expect(page.getByRole("treeitem", { name: "Other Room" })).toBeVisible(); + await expect(page.getByRole("option", { name: "Open room Other Room" })).toBeVisible(); await app.client.sendMessage(testRoom.roomId, "Hello world"); // select the room - await page.getByRole("treeitem", { name: "Test Room" }).click(); + await page.getByRole("option", { name: "Open room Test Room" }).click(); await expect(page.locator(".mx_ReplyPreview")).not.toBeAttached(); @@ -318,13 +327,13 @@ test.describe("Sliding Sync", () => { await expect(page.locator(".mx_ReplyPreview")).toBeVisible(); // now click Other Room - await page.getByRole("treeitem", { name: "Other Room" }).click(); + await page.getByRole("option", { name: "Open room Other Room" }).click(); // ensure the reply-to disappears await expect(page.locator(".mx_ReplyPreview")).not.toBeAttached(); // click back - await page.getByRole("treeitem", { name: "Test Room" }).click(); + await page.getByRole("option", { name: "Open room Test Room" }).click(); // ensure the reply-to reappears await expect(page.locator(".mx_ReplyPreview")).toBeVisible(); @@ -338,7 +347,7 @@ test.describe("Sliding Sync", () => { await app.client.sendMessage(testRoom.roomId, "Reply to me"); // select the room - await page.getByRole("treeitem", { name: "Test Room" }).click(); + await page.getByRole("option", { name: "Open room Test Room" }).click(); await expect(page.locator(".mx_ReplyPreview")).not.toBeAttached(); // click reply-to on the Reply to me message @@ -350,7 +359,9 @@ test.describe("Sliding Sync", () => { await expect(page.locator(".mx_ReplyPreview")).toBeVisible(); // now click on the permalink for Permalink me - await page.locator(".mx_EventTile").filter({ hasText: "Permalink me" }).locator("a").dispatchEvent("click"); + const tile = page.locator(".mx_EventTile").filter({ hasText: "Permalink me" }); + await tile.hover(); + await tile.locator("a").dispatchEvent("click"); // make sure it is now selected with the little green | await expect(page.locator(".mx_EventTile_selected").filter({ hasText: "Permalink me" })).toBeVisible(); diff --git a/playwright/e2e/spaces/spaces.spec.ts b/playwright/e2e/spaces/spaces.spec.ts index 56c8ae22b8..19e619e9c6 100644 --- a/playwright/e2e/spaces/spaces.spec.ts +++ b/playwright/e2e/spaces/spaces.spec.ts @@ -11,6 +11,7 @@ import { test, expect } from "../../element-web-test"; import type { Preset, ICreateRoomOpts } from "matrix-js-sdk/src/matrix"; import { type ElementAppPage } from "../../pages/ElementAppPage"; import { isDendrite } from "../../plugins/homeserver/dendrite"; +import { UIFeature } from "../../../src/settings/UIFeature"; async function openSpaceCreateMenu(page: Page): Promise { await page.getByRole("button", { name: "Create a space" }).click(); @@ -23,7 +24,7 @@ async function openSpaceContextMenu(page: Page, app: ElementAppPage, spaceName: return page.locator(".mx_SpacePanel_contextMenu"); } -function spaceCreateOptions(spaceName: string, roomIds: string[] = []): ICreateRoomOpts { +function spaceCreateOptions(serverName: string, spaceName: string, roomIds: string[] = []): ICreateRoomOpts { return { creation_content: { type: "m.space", @@ -35,17 +36,21 @@ function spaceCreateOptions(spaceName: string, roomIds: string[] = []): ICreateR name: spaceName, }, }, - ...roomIds.map((r) => spaceChildInitialState(r)), + ...roomIds.map((r) => spaceChildInitialState(serverName, r)), ], }; } -function spaceChildInitialState(roomId: string, order?: string): ICreateRoomOpts["initial_state"]["0"] { +function spaceChildInitialState( + serverName: string, + roomId: string, + order?: string, +): ICreateRoomOpts["initial_state"]["0"] { return { type: "m.space.child", state_key: roomId, content: { - via: [roomId.split(":")[1]], + via: [serverName], order, }, }; @@ -91,9 +96,9 @@ test.describe("Spaces", () => { await page.getByRole("button", { name: "Go to my first room" }).click(); // Assert rooms exist in the room list - await expect(page.getByRole("treeitem", { name: "General" })).toBeVisible(); - await expect(page.getByRole("treeitem", { name: "Random" })).toBeVisible(); - await expect(page.getByRole("treeitem", { name: "Jokes" })).toBeVisible(); + await expect(page.getByRole("option", { name: "General" })).toBeVisible(); + await expect(page.getByRole("option", { name: "Random" })).toBeVisible(); + await expect(page.getByRole("option", { name: "Jokes" })).toBeVisible(); }, ); @@ -122,10 +127,10 @@ test.describe("Spaces", () => { await page.getByRole("button", { name: "Skip for now" }).click(); // Assert rooms exist in the room list - const roomList = page.getByRole("tree", { name: "Rooms" }); - await expect(roomList.getByRole("treeitem", { name: "General", exact: true })).toBeVisible(); - await expect(roomList.getByRole("treeitem", { name: "Random", exact: true })).toBeVisible(); - await expect(roomList.getByRole("treeitem", { name: "Projects", exact: true })).toBeVisible(); + const roomList = page.getByRole("listbox", { name: "Room list", exact: true }); + await expect(roomList.getByRole("option", { name: "General" })).toBeVisible(); + await expect(roomList.getByRole("option", { name: "Random" })).toBeVisible(); + await expect(roomList.getByRole("option", { name: "Projects" })).toBeVisible(); // Assert rooms exist in the space explorer await expect( @@ -195,7 +200,7 @@ test.describe("Spaces", () => { await page.getByRole("button", { name: "Skip for now" }).click(); - await page.getByRole("button", { name: "Add room" }).click(); + await page.getByRole("main").getByRole("button", { name: "Add" }).click(); await page.getByRole("menuitem", { name: "Add existing room" }).click(); await page.getByRole("checkbox", { name: "Sample Room" }).click(); @@ -240,7 +245,7 @@ test.describe("Spaces", () => { }); await expect(await app.getSpacePanelButton("My Space")).toBeVisible(); - const roomId = await bot.createRoom(spaceCreateOptions("Space Space")); + const roomId = await bot.createRoom(spaceCreateOptions(user.homeServer, "Space Space")); await bot.inviteUser(roomId, user.userId); // Assert that `Space Space` is above `My Space` due to it being an invite @@ -260,7 +265,10 @@ test.describe("Spaces", () => { const spaceName = "Spacey Mc. Space Space"; await app.client.createSpace({ name: spaceName, - initial_state: [spaceChildInitialState(roomId1), spaceChildInitialState(roomId2)], + initial_state: [ + spaceChildInitialState(user.homeServer, roomId1), + spaceChildInitialState(user.homeServer, roomId2), + ], }); await app.viewSpaceHomeByName(spaceName); @@ -287,7 +295,7 @@ test.describe("Spaces", () => { }); await app.client.createSpace({ name: "Root Space", - initial_state: [spaceChildInitialState(childSpaceId)], + initial_state: [spaceChildInitialState(user.homeServer, childSpaceId)], }); // Find collapsed Space panel @@ -323,7 +331,7 @@ test.describe("Spaces", () => { name: "Test Room", topic: "This is a topic https://github.com/matrix-org/matrix-react-sdk/pull/10060 with a link", }); - const spaceId = await bot.createRoom(spaceCreateOptions("Test Space", [roomId])); + const spaceId = await bot.createRoom(spaceCreateOptions(user.homeServer, "Test Space", [roomId])); await bot.inviteUser(spaceId, user.userId); await expect(await app.getSpacePanelButton("Test Space")).toBeVisible(); @@ -361,12 +369,76 @@ test.describe("Spaces", () => { await app.client.createSpace({ name: "Root Space", initial_state: [ - spaceChildInitialState(childSpaceId1, "a"), - spaceChildInitialState(childSpaceId2, "b"), - spaceChildInitialState(childSpaceId3, "c"), + spaceChildInitialState(user.homeServer, childSpaceId1, "a"), + spaceChildInitialState(user.homeServer, childSpaceId2, "b"), + spaceChildInitialState(user.homeServer, childSpaceId3, "c"), ], }); await app.viewSpaceByName("Root Space"); await expect(page.locator(".mx_SpaceRoomView")).toMatchScreenshot("space-room-view.png"); }); + + test("should render spaces visibility settings", { tag: "@screenshot" }, async ({ page, app, user, axe }) => { + await app.client.createSpace({ + name: "My Space", + }); + await app.viewSpaceByName("My space"); + await page.getByLabel("Settings", { exact: true }).click(); + await app.settings.switchTab("Visibility"); + + axe.disableRules("color-contrast"); // XXX: Inheriting colour contrast issues from room view. + await expect(axe).toHaveNoViolations(); + await expect(page.locator("#mx_tabpanel_SPACE_VISIBILITY_TAB")).toMatchScreenshot( + "space-visibility-settings.png", + ); + }); + + test.describe("Should hide public spaces option if not allowed", () => { + test.use({ + config: { + setting_defaults: { + [UIFeature.AllowCreatingPublicSpaces]: false, + }, + }, + }); + + test("should disallow creating public rooms", { tag: "@screenshot" }, async ({ page, user, app }) => { + const menu = await openSpaceCreateMenu(page); + await menu + .locator('.mx_SpaceBasicSettings_avatarContainer input[type="file"]') + .setInputFiles("playwright/sample-files/riot.png"); + await menu.getByRole("textbox", { name: "Name" }).fill("This is a private space"); + await expect(menu.getByRole("textbox", { name: "Address" })).not.toBeVisible(); + await menu + .getByRole("textbox", { name: "Description" }) + .fill("This is a private space because we can't make public ones"); + await menu.getByRole("button", { name: "Create" }).click(); + + await page.getByRole("button", { name: "Me and my teammates" }).click(); + + // Create the default General & Random rooms, as well as a custom "Projects" room + await expect(page.getByPlaceholder("General")).toBeVisible(); + await expect(page.getByPlaceholder("Random")).toBeVisible(); + await page.getByPlaceholder("Support").fill("Projects"); + await page.getByRole("button", { name: "Continue" }).click(); + await page.getByRole("button", { name: "Skip for now" }).click(); + + // Assert rooms exist in the room list + const roomList = page.getByRole("listbox", { name: "Room list", exact: true }); + await expect(roomList.getByRole("option", { name: "General" })).toBeVisible(); + await expect(roomList.getByRole("option", { name: "Random" })).toBeVisible(); + await expect(roomList.getByRole("option", { name: "Projects" })).toBeVisible(); + + // Assert rooms exist in the space explorer + await expect( + page.locator(".mx_SpaceHierarchy_list .mx_SpaceHierarchy_roomTile", { hasText: "General" }), + ).toBeVisible(); + await expect( + page.locator(".mx_SpaceHierarchy_list .mx_SpaceHierarchy_roomTile", { hasText: "Random" }), + ).toBeVisible(); + await expect( + page.locator(".mx_SpaceHierarchy_list .mx_SpaceHierarchy_roomTile", { hasText: "Projects" }), + ).toBeVisible(); + }); + }); }); diff --git a/playwright/e2e/spaces/threads-activity-centre/index.ts b/playwright/e2e/spaces/threads-activity-centre/index.ts index a050175c83..cf889f64cc 100644 --- a/playwright/e2e/spaces/threads-activity-centre/index.ts +++ b/playwright/e2e/spaces/threads-activity-centre/index.ts @@ -377,7 +377,7 @@ export class Helpers { * Expand the space panel */ expandSpacePanel() { - return this.page.getByRole("button", { name: "Expand" }).click(); + return this.page.getByRole("navigation", { name: "Spaces" }).getByRole("button", { name: "Expand" }).click(); } /** diff --git a/playwright/e2e/spotlight/spotlight.spec.ts b/playwright/e2e/spotlight/spotlight.spec.ts index 7a5f7d4ea8..e07643c193 100644 --- a/playwright/e2e/spotlight/spotlight.spec.ts +++ b/playwright/e2e/spotlight/spotlight.spec.ts @@ -36,7 +36,7 @@ async function startDM(app: ElementAppPage, page: Page, name: string): Promise { // Assert DM exists by checking for the first message and the room being in the room list await expect(page.locator(".mx_EventTile_body").filter({ hasText: "Hey!" })).toBeAttached({ timeout: 3000 }); - await expect(page.getByRole("group", { name: "People" })).toContainText(bot2.credentials.displayName); + await expect( + page.getByTestId("room-list").getByRole("option", { name: `Open room ${bot2.credentials.displayName}` }), + ).toBeVisible(); // Invite BotBob into existing DM with ByteBot const dmRooms = await app.client.evaluate((client, userId) => { @@ -279,7 +281,9 @@ test.describe("Spotlight", () => { const groupDmName = await app.client.evaluate((client, id) => client.getRoom(id).name, dmRooms[0]); await app.client.inviteUser(dmRooms[0], bot1.credentials.userId); await expect(roomHeaderName(page).first()).toContainText(groupDmName); - await expect(page.getByRole("group", { name: "People" }).first()).toContainText(groupDmName); + await expect( + page.getByTestId("room-list").getByRole("option", { name: `Open room ${groupDmName}` }), + ).toBeVisible(); // Search for BotBob by id, should return group DM and user spotlight = await app.openSpotlight(); diff --git a/playwright/e2e/threads/threads.spec.ts b/playwright/e2e/threads/threads.spec.ts index 89cfe418ba..5ffc2003b8 100644 --- a/playwright/e2e/threads/threads.spec.ts +++ b/playwright/e2e/threads/threads.spec.ts @@ -39,11 +39,16 @@ test.describe("Threads", () => { const ThreadViewGroupSpacingStart = "56px"; // --ThreadView_group_spacing-start // Exclude timestamp and read marker from snapshots - const mask = [page.locator(".mx_MessageTimestamp"), page.locator(".mx_MessagePanel_myReadMarker")]; + const mask = [page.locator(".mx_MessagePanel_myReadMarker")]; + const css = ` + .mx_MessageTimestamp { + visibility: hidden; + } + `; const roomViewLocator = page.locator(".mx_RoomView_body"); // User sends message - const textbox = roomViewLocator.getByRole("textbox", { name: "Send a message…" }); + const textbox = roomViewLocator.getByRole("textbox", { name: "Send an unencrypted message…" }); await textbox.fill("Hello Mr. Bot"); await textbox.press("Enter"); @@ -74,13 +79,15 @@ test.describe("Threads", () => { // Take snapshots in group layout and bubble layout (IRC layout is not available on ThreadView) await expect(page.locator(".mx_ThreadView")).toMatchScreenshot("Initial_ThreadView_on_group_layout.png", { - mask: mask, + mask, + css, }); await app.settings.setValue("layout", null, SettingLevel.DEVICE, Layout.Bubble); await expect(page.locator(".mx_ThreadView .mx_EventTile[data-layout='bubble']")).toHaveCount(2); await expect(page.locator(".mx_ThreadView")).toMatchScreenshot("Initial_ThreadView_on_bubble_layout.png", { - mask: mask, + mask, + css, }); // Set the group layout @@ -108,7 +115,7 @@ test.describe("Threads", () => { await app.settings.setValue("layout", null, SettingLevel.DEVICE, Layout.Group); // User responds in thread - locator = page.locator(".mx_ThreadView").getByRole("textbox", { name: "Send a message…" }); + locator = page.locator(".mx_ThreadView").getByRole("textbox", { name: "Send an unencrypted message…" }); await locator.fill("Test"); await locator.press("Enter"); @@ -154,7 +161,8 @@ test.describe("Threads", () => { await expect(page.locator(".mx_ThreadView")).toMatchScreenshot( "ThreadView_with_reaction_and_a_hidden_event_on_group_layout.png", { - mask: mask, + mask, + css, }, ); @@ -178,7 +186,8 @@ test.describe("Threads", () => { await expect(page.locator(".mx_ThreadView")).toMatchScreenshot( "ThreadView_with_reaction_and_a_hidden_event_on_bubble_layout.png", { - mask: mask, + mask, + css, }, ); @@ -214,7 +223,8 @@ test.describe("Threads", () => { await expect(page.locator(".mx_ThreadView")).toMatchScreenshot( "ThreadView_with_redacted_messages_on_group_layout.png", { - mask: mask, + mask, + css, }, ); await app.settings.setValue("layout", null, SettingLevel.DEVICE, Layout.Bubble); @@ -222,7 +232,8 @@ test.describe("Threads", () => { await expect(page.locator(".mx_ThreadView")).toMatchScreenshot( "ThreadView_with_redacted_messages_on_bubble_layout.png", { - mask: mask, + mask, + css, }, ); @@ -262,7 +273,7 @@ test.describe("Threads", () => { await locator.locator(".mx_EventTile_line").click(); // User responds & asserts - locator = page.locator(".mx_ThreadView").getByRole("textbox", { name: "Send a message…" }); + locator = page.locator(".mx_ThreadView").getByRole("textbox", { name: "Send an unencrypted message…" }); await locator.fill("Great!"); await locator.press("Enter"); @@ -335,8 +346,8 @@ test.describe("Threads", () => { // Send message const locator = page.locator(".mx_RoomView_body"); - await locator.getByRole("textbox", { name: "Send a message…" }).fill("Hello Mr. Bot"); - await locator.getByRole("textbox", { name: "Send a message…" }).press("Enter"); + await locator.getByRole("textbox", { name: "Send an unencrypted message…" }).fill("Hello Mr. Bot"); + await locator.getByRole("textbox", { name: "Send an unencrypted message…" }).press("Enter"); // Create thread const locator2 = locator.locator(".mx_EventTile[data-scroll-tokens]").filter({ hasText: "Hello Mr. Bot" }); await locator2.hover(); @@ -362,11 +373,11 @@ test.describe("Threads", () => { // Exclude timestamp, read marker, and maplibregl-map from snapshots const css = - ".mx_MessageTimestamp, .mx_MessagePanel_myReadMarker, .maplibregl-map { visibility: hidden !important; }"; + ".mx_MessageTimestamp, .mx_MessagePanel_myReadMarker, .maplibregl-map, .maplibregl-ctrl-attrib { visibility: hidden !important; }"; let locator = page.locator(".mx_RoomView_body"); // User sends message - let textbox = locator.getByRole("textbox", { name: "Send a message…" }); + let textbox = locator.getByRole("textbox", { name: "Send an unencrypted message…" }); await textbox.fill("Hello Mr. Bot"); await textbox.press("Enter"); // Wait for message to send, get its ID and save as @threadId @@ -395,7 +406,7 @@ test.describe("Threads", () => { locator = page.locator(".mx_ThreadView"); await locator.locator(".mx_EventTile_last").hover(); await locator.locator(".mx_EventTile_last").getByRole("button", { name: "Reply" }).click(); - textbox = locator.getByRole("textbox", { name: "Reply to thread…" }); + textbox = locator.getByRole("textbox", { name: "Reply to unencrypted thread…" }); await textbox.fill("Please come here"); await textbox.press("Enter"); // Wait until the reply is sent @@ -414,7 +425,7 @@ test.describe("Threads", () => { // Send message let locator = page.locator(".mx_RoomView_body"); - let textbox = locator.getByRole("textbox", { name: "Send a message…" }); + let textbox = locator.getByRole("textbox", { name: "Send an unencrypted message…" }); await textbox.fill("Hello Mr. Bot"); await textbox.press("Enter"); // Create thread @@ -425,7 +436,7 @@ test.describe("Threads", () => { // Send message to thread locator = page.locator(".mx_ThreadPanel"); - textbox = locator.getByRole("textbox", { name: "Send a message…" }); + textbox = locator.getByRole("textbox", { name: "Send an unencrypted message…" }); await textbox.fill("Hello Mr. User"); await textbox.press("Enter"); await expect(locator.locator(".mx_EventTile_last").getByText("Hello Mr. User")).toBeAttached(); @@ -445,7 +456,7 @@ test.describe("Threads", () => { await expect(locator.locator(".mx_EventTile").last().getByText("Hello Mr. User")).toBeAttached(); }); - test("navigate through right panel", async ({ page, app, user }) => { + test("navigate through right panel", { tag: "@screenshot" }, async ({ page, app, user }) => { // Create room const roomId = await app.client.createRoom({}); await page.goto("/#/room/" + roomId); @@ -456,7 +467,7 @@ test.describe("Threads", () => { */ const sendMessage = async (message: string) => { const messageComposer = page.getByRole("region", { name: "Message composer" }); - const textbox = messageComposer.getByRole("textbox", { name: "Send a message…" }); + const textbox = messageComposer.getByRole("textbox", { name: "Send an unencrypted message…" }); await textbox.fill(message); await textbox.press("Enter"); }; @@ -478,7 +489,7 @@ test.describe("Threads", () => { // Send a message in the thread const threadPanel = page.locator(".mx_ThreadPanel"); - const textbox = threadPanel.getByRole("textbox", { name: "Send a message…" }); + const textbox = threadPanel.getByRole("textbox", { name: "Send an unencrypted message…" }); await textbox.fill(threadMessage); await textbox.press("Enter"); await expect(threadPanel.locator(".mx_EventTile_last").getByText(threadMessage)).toBeVisible(); @@ -497,6 +508,9 @@ test.describe("Threads", () => { await expect( threadPanel.locator(".mx_EventTile_last").getByText("Hello again Mr. User in a thread"), ).toBeVisible(); + await expect(threadPanel).toMatchScreenshot("thread-panel.png", { + css: ".mx_MessageTimestamp { visibility: hidden !important; }", + }); const rightPanel = page.locator(".mx_RightPanel"); // Check that the threads are listed diff --git a/playwright/e2e/timeline/media-preview-settings.spec.ts b/playwright/e2e/timeline/media-preview-settings.spec.ts index e32a7dbc82..617aa08c09 100644 --- a/playwright/e2e/timeline/media-preview-settings.spec.ts +++ b/playwright/e2e/timeline/media-preview-settings.spec.ts @@ -50,9 +50,12 @@ test.describe("Media preview settings", () => { } `, }); - await expect( - page.getByRole("tree", { name: "Rooms" }).getByRole("treeitem", { name: "Test room" }), - ).toMatchScreenshot("invite-room-tree-no-avatar.png"); + + const testRoomTile = page + .getByRole("listbox", { name: "Room list" }) + .getByRole("option", { name: "Test room" }); + await expect(testRoomTile).toBeVisible(); + await expect(testRoomTile).toMatchScreenshot("invite-room-tree-no-avatar.png"); // And then go back to being visible settings = await app.settings.openUserSettings("Preferences"); @@ -70,9 +73,7 @@ test.describe("Media preview settings", () => { } `, }); - await expect( - page.getByRole("tree", { name: "Rooms" }).getByRole("treeitem", { name: "Test room" }), - ).toMatchScreenshot("invite-room-tree-with-avatar.png"); + await expect(testRoomTile).toMatchScreenshot("invite-room-tree-with-avatar.png"); }); test("should be able to hide media in rooms globally", async ({ page, app, room, user }) => { diff --git a/playwright/e2e/timeline/timeline.spec.ts b/playwright/e2e/timeline/timeline.spec.ts index e5bfa0c71b..806e7e9038 100644 --- a/playwright/e2e/timeline/timeline.spec.ts +++ b/playwright/e2e/timeline/timeline.spec.ts @@ -181,8 +181,10 @@ test.describe("Timeline", () => { await expect(gels.getByRole("button", { name: "Collapse" })).toBeVisible(); await expect(page.locator(".mx_MainSplit")).toMatchScreenshot("expanded-gels-irc-layout.png", { - mask: [page.locator(".mx_MessageTimestamp")], css: ` + .mx_MessageTimestamp { + visibility: hidden; + } .mx_TopUnreadMessagesBar, .mx_MessagePanel_myReadMarker { display: none !important; } @@ -215,8 +217,10 @@ test.describe("Timeline", () => { await expect(gels.getByRole("button", { name: "Collapse" })).toBeVisible(); await expect(page.locator(".mx_MainSplit")).toMatchScreenshot("expanded-gels-modern-layout.png", { - mask: [page.locator(".mx_MessageTimestamp")], css: ` + .mx_MessageTimestamp { + visibility: hidden; + } .mx_TopUnreadMessagesBar, .mx_MessagePanel_myReadMarker { display: none !important; } @@ -255,7 +259,11 @@ test.describe("Timeline", () => { // Save snapshot of expanded generic event list summary on bubble layout await expect(page.locator(".mx_MainSplit")).toMatchScreenshot("expanded-gels-bubble-layout.png", { // Exclude timestamp from snapshot - mask: [page.locator(".mx_MessageTimestamp")], + css: ` + .mx_MessageTimestamp { + visibility: hidden; + } + `, }); // Click "collapse" link button on the first hovered info event line @@ -271,7 +279,11 @@ test.describe("Timeline", () => { // Save snapshot of collapsed generic event list summary on bubble layout await expect(page.locator(".mx_MainSplit")).toMatchScreenshot("collapsed-gels-bubble-layout.png", { - mask: [page.locator(".mx_MessageTimestamp")], + css: ` + .mx_MessageTimestamp { + visibility: hidden; + } + `, }); }, ); @@ -312,12 +324,14 @@ test.describe("Timeline", () => { "event-line-inline-start-margin-irc-layout.png", { // Exclude timestamp and read marker from snapshot - mask: [page.locator(".mx_MessageTimestamp")], css: ` - .mx_TopUnreadMessagesBar, .mx_MessagePanel_myReadMarker { - display: none !important; - } - `, + .mx_MessageTimestamp { + visibility: hidden; + } + .mx_TopUnreadMessagesBar, .mx_MessagePanel_myReadMarker { + display: none !important; + } + `, }, ); await expect(axe).toHaveNoViolations(); @@ -409,7 +423,11 @@ test.describe("Timeline", () => { "collapsed-gels-and-messages-irc-layout.png", { // Exclude timestamp from snapshot of mx_MainSplit - mask: [page.locator(".mx_MessageTimestamp")], + css: ` + .mx_MessageTimestamp { + visibility: hidden; + } + `, }, ); @@ -428,7 +446,11 @@ test.describe("Timeline", () => { "expanded-gels-and-messages-irc-layout.png", { // Exclude timestamp from snapshot of mx_MainSplit - mask: [page.locator(".mx_MessageTimestamp")], + css: ` + .mx_MessageTimestamp { + visibility: hidden; + } + `, }, ); @@ -453,7 +475,11 @@ test.describe("Timeline", () => { "expanded-gels-redaction-placeholder.png", { // Exclude timestamp from snapshot of mx_MainSplit - mask: [page.locator(".mx_MessageTimestamp")], + css: ` + .mx_MessageTimestamp { + visibility: hidden; + } + `, }, ); @@ -461,11 +487,11 @@ test.describe("Timeline", () => { // Send a emote await page .locator(".mx_RoomView_body") - .getByRole("textbox", { name: "Send a message…" }) + .getByRole("textbox", { name: "Send an unencrypted message…" }) .fill("/me says hello to Mr. Bot"); await page .locator(".mx_RoomView_body") - .getByRole("textbox", { name: "Send a message…" }) + .getByRole("textbox", { name: "Send an unencrypted message…" }) .press("Enter"); // Check inline start margin of its avatar // Here --right-padding is for the avatar on the message line @@ -481,7 +507,11 @@ test.describe("Timeline", () => { // Record alignment of expanded GELS, placeholder of deleted message, and emote await expect(page.locator(".mx_MainSplit")).toMatchScreenshot("expanded-gels-emote-irc-layout.png", { // Exclude timestamp from snapshot of mx_MainSplit - mask: [page.locator(".mx_MessageTimestamp")], + css: ` + .mx_MessageTimestamp { + visibility: hidden; + } + `, }); }, ); @@ -492,12 +522,14 @@ test.describe("Timeline", () => { async ({ page, app, room }) => { const screenshotOptions = { // Hide because flaky - See https://github.com/vector-im/element-web/issues/24957 - mask: [page.locator(".mx_MessageTimestamp")], css: ` - .mx_TopUnreadMessagesBar, .mx_MessagePanel_myReadMarker { - display: none !important; - } - `, + .mx_MessageTimestamp { + visibility: hidden; + } + .mx_TopUnreadMessagesBar, .mx_MessagePanel_myReadMarker { + display: none !important; + } + `, }; await sendEvent(app.client, room.roomId); @@ -605,12 +637,10 @@ test.describe("Timeline", () => { await messageEdit(page); // Click timestamp to highlight hidden event line - const timestamp = page.locator(".mx_RoomView_body .mx_EventTile_info a", { - has: page.locator(".mx_MessageTimestamp"), - }); + const timestamp = page.locator(".mx_RoomView_body .mx_EventTile_info a.mx_MessageTimestamp"); // wait for the remote echo otherwise we get an error modal due to a 404 on the /event/ API await expect(timestamp).not.toHaveAttribute("href", /~!/); - await timestamp.locator(".mx_MessageTimestamp").click(); + await timestamp.click(); // should not add inline start padding to a hidden event line on IRC layout await app.settings.setValue("layout", null, SettingLevel.DEVICE, Layout.IRC); @@ -620,12 +650,14 @@ test.describe("Timeline", () => { // Exclude timestamp and read marker from snapshot const screenshotOptions = { - mask: [page.locator(".mx_MessageTimestamp")], css: ` - .mx_TopUnreadMessagesBar, .mx_MessagePanel_myReadMarker { - display: none !important; - } - `, + .mx_MessageTimestamp { + visibility: hidden; + } + .mx_TopUnreadMessagesBar, .mx_MessagePanel_myReadMarker { + display: none !important; + } + `, }; await expect(page.locator(".mx_MainSplit")).toMatchScreenshot( @@ -654,7 +686,11 @@ test.describe("Timeline", () => { // Exclude timestamp from snapshot const screenshotOptions = { - mask: [page.locator(".mx_MessageTimestamp")], + css: ` + .mx_MessageTimestamp { + visibility: hidden; + } + `, }; await sendEvent(app.client, room.roomId); @@ -795,8 +831,10 @@ test.describe("Timeline", () => { await app.timeline.scrollToBottom(); await expect(page.locator(".mx_EventTile_last")).toMatchScreenshot("url-preview.png", { // Exclude timestamp and read marker from snapshot - mask: [page.locator(".mx_MessageTimestamp")], css: ` + .mx_MessageTimestamp { + visibility: hidden; + } .mx_TopUnreadMessagesBar, .mx_MessagePanel_myReadMarker { display: none !important; } @@ -892,7 +930,13 @@ test.describe("Timeline", () => { const tile = page.locator(".mx_EventTile"); await expect(tile).toBeVisible(); - await expect(tile).toMatchScreenshot("code-block.png", { mask: [page.locator(".mx_MessageTimestamp")] }); + await expect(tile).toMatchScreenshot("code-block.png", { + css: ` + .mx_MessageTimestamp { + visibility: hidden; + } + `, + }); // Edit a code block and assert the edited code block has been correctly rendered await tile.hover(); @@ -904,27 +948,45 @@ test.describe("Timeline", () => { const newTile = page.locator(".mx_EventTile"); await expect(newTile).toMatchScreenshot("edited-code-block.png", { - mask: [page.locator(".mx_MessageTimestamp")], + css: ` + .mx_MessageTimestamp { + visibility: hidden; + } + `, }); }); - test("should be able to hide an image", { tag: "@screenshot" }, async ({ page, app, room, context }) => { - await app.viewRoomById(room.roomId); - await sendImage(app.client, room.roomId, NEW_AVATAR); - await app.timeline.scrollToBottom(); - const imgTile = page.locator(".mx_MImageBody").first(); - await expect(imgTile).toBeVisible(); - await imgTile.hover(); - await page.getByRole("button", { name: "Hide" }).click(); + test( + "should be able to hide an image", + { tag: "@screenshot" }, + async ({ page, app, homeserver, room, context }) => { + await app.viewRoomById(room.roomId); - // Check that the image is now hidden. - await expect(page.getByRole("button", { name: "Show image" })).toBeVisible(); - }); + const bot = new Bot(page, homeserver, {}); + await bot.prepareClient(); + await app.client.inviteUser(room.roomId, bot.credentials.userId); - test("should be able to hide a video", async ({ page, app, room, context }) => { + await sendImage(bot, room.roomId, NEW_AVATAR); + await app.timeline.scrollToBottom(); + const imgTile = page.locator(".mx_MImageBody").first(); + await expect(imgTile).toBeVisible(); + await imgTile.hover(); + await page.getByRole("button", { name: "Hide" }).click(); + + // Check that the image is now hidden. + await expect(page.getByRole("button", { name: "Show image" })).toBeVisible(); + }, + ); + + test("should be able to hide a video", async ({ page, app, homeserver, room, context }) => { await app.viewRoomById(room.roomId); - const upload = await app.client.uploadContent(VIDEO_FILE, { name: "bbb.webm", type: "video/webm" }); - await app.client.sendEvent(room.roomId, null, "m.room.message" as EventType, { + + const bot = new Bot(page, homeserver, {}); + await bot.prepareClient(); + await app.client.inviteUser(room.roomId, bot.credentials.userId); + + const upload = await bot.uploadContent(VIDEO_FILE, { name: "bbb.webm", type: "video/webm" }); + await bot.sendEvent(room.roomId, null, "m.room.message" as EventType, { msgtype: "m.video" as MsgType, body: "bbb.webm", url: upload.content_uri, @@ -947,7 +1009,7 @@ test.describe("Timeline", () => { const reply = "Reply"; const viewRoomSendMessageAndSetupReply = async (page: Page, app: ElementAppPage, roomId: string) => { // View room - await page.goto(`/#/room/${roomId}`); + await app.viewRoomById(roomId); // Send a message const composer = app.getComposerField(); @@ -979,6 +1041,24 @@ test.describe("Timeline", () => { await expect(eventTileLine.getByText(reply)).toHaveCount(1); }); + test("can send a voice message", { tag: "@screenshot" }, async ({ page, app, room, context }) => { + await app.viewRoomById(room.roomId); + + const composerOptions = await app.openMessageComposerOptions(); + await composerOptions.getByRole("menuitem", { name: "Voice Message" }).click(); + + // Record an empty message + await page.waitForTimeout(3000); + + const roomViewBody = page.locator(".mx_RoomView_body"); + await roomViewBody + .locator(".mx_MessageComposer") + .getByRole("button", { name: "Send voice message" }) + .click(); + + await expect(roomViewBody.locator(".mx_MVoiceMessageBody")).toMatchScreenshot("voice-message.png"); + }); + test("can reply with a voice message", async ({ page, app, room, context }) => { await viewRoomSendMessageAndSetupReply(page, app, room.roomId); @@ -1075,8 +1155,10 @@ test.describe("Timeline", () => { // Exclude timestamp and read marker from snapshot const screenshotOptions = { - mask: [page.locator(".mx_MessageTimestamp")], css: ` + .mx_MessageTimestamp { + visibility: hidden; + } .mx_TopUnreadMessagesBar, .mx_MessagePanel_myReadMarker { display: none !important; } @@ -1201,12 +1283,14 @@ test.describe("Timeline", () => { // Exclude timestamp and read marker from snapshot const screenshotOptions = { - mask: [page.locator(".mx_MessageTimestamp")], css: ` - .mx_TopUnreadMessagesBar, .mx_MessagePanel_myReadMarker { - display: none !important; - } - `, + .mx_MessageTimestamp { + visibility: hidden; + } + .mx_TopUnreadMessagesBar, .mx_MessagePanel_myReadMarker { + display: none !important; + } + `, }; // Make sure the strings do not overflow on IRC layout @@ -1265,8 +1349,10 @@ test.describe("Timeline", () => { // Exclude timestamp and read marker from snapshot const screenshotOptions = { - mask: [page.locator(".mx_MessageTimestamp")], css: ` + .mx_MessageTimestamp { + visibility: hidden; + } .mx_TopUnreadMessagesBar, .mx_MessagePanel_myReadMarker { display: none !important; } diff --git a/playwright/e2e/user-view/user-view.spec.ts b/playwright/e2e/user-view/user-view.spec.ts index de97133e6a..e09d8a1593 100644 --- a/playwright/e2e/user-view/user-view.spec.ts +++ b/playwright/e2e/user-view/user-view.spec.ts @@ -24,7 +24,7 @@ test.describe("UserView", () => { css: ` /* Use monospace font for consistent mask width */ .mx_UserInfo_profile_mxid { - font-family: Inconsolata !important; + font-family: "Fira Code" !important; } `, }); diff --git a/playwright/e2e/voip/element-call.spec.ts b/playwright/e2e/voip/element-call.spec.ts new file mode 100644 index 0000000000..7c2494d2ae --- /dev/null +++ b/playwright/e2e/voip/element-call.spec.ts @@ -0,0 +1,662 @@ +/* +Copyright 2025 New Vector Ltd. +Copyright (C) 2025 Element Creations Ltd +SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial +Please see LICENSE files in the repository root for full details. +*/ + +import { readFile } from "node:fs/promises"; +import { type Page } from "playwright-core"; + +import type { EventType, Preset } from "matrix-js-sdk/src/matrix"; +import { SettingLevel } from "../../../src/settings/SettingLevel"; +import { test, expect } from "../../element-web-test"; +import type { Credentials } from "../../plugins/homeserver"; +import { Bot } from "../../pages/bot"; +import { isDendrite } from "../../plugins/homeserver/dendrite"; + +// Load a copy of our fake Element Call app, and the latest widget API. +// The fake call app does *just* enough to convince Element Web that a call is ongoing +// and functions like PiP work. It does not actually do anything though, to limit the +// surface we test. +const widgetApi = readFile("node_modules/matrix-widget-api/dist/api.min.js", "utf-8"); +const fakeCallClient = readFile("playwright/sample-files/fake-element-call.html", "utf-8"); + +function assertCommonCallParameters( + url: URLSearchParams, + hash: URLSearchParams, + user: Credentials, + room: { roomId: string }, +): void { + expect(url.has("widgetId")).toEqual(true); + expect(url.has("parentUrl")).toEqual(true); + + expect(hash.get("perParticipantE2EE")).toEqual("false"); + expect(hash.get("userId")).toEqual(user.userId); + expect(hash.get("deviceId")).toEqual(user.deviceId); + expect(hash.get("roomId")).toEqual(room.roomId); + expect(hash.get("preload")).toEqual("false"); +} + +async function sendRTCState(bot: Bot, roomId: string, notification?: "ring" | "notification", intent?: string) { + const resp = await bot.sendStateEvent( + roomId, + "org.matrix.msc3401.call.member", + { + "application": "m.call", + "call_id": "", + "m.call.intent": intent, + "device_id": "OiDFxsZrjz", + "expires": 180000000, + "foci_preferred": [ + { + livekit_alias: roomId, + livekit_service_url: "https://example.org", + type: "livekit", + }, + ], + "focus_active": { + focus_selection: "oldest_membership", + type: "livekit", + }, + "scope": "m.room", + }, + `_@${bot.credentials.userId}_OiDFxsZrjz_m.call`, + ); + if (!notification) { + return; + } + await bot.sendEvent(roomId, null, "org.matrix.msc4075.rtc.notification", { + "lifetime": 30000, + "m.mentions": { + room: true, + user_ids: [], + }, + "m.relates_to": { + event_id: resp.event_id, + rel_type: "org.matrix.msc4075.rtc.notification.parent", + }, + "m.call.intent": intent, + "notification_type": notification, + "sender_ts": 1758611895996, + }); +} + +test.describe("Element Call", () => { + test.use({ + config: { + element_call: { + use_exclusively: false, + }, + features: { + feature_group_calls: true, + }, + }, + displayName: "Alice", + botCreateOpts: { + autoAcceptInvites: true, + displayName: "Bob", + }, + }); + + test.beforeEach(async ({ page, user, app }) => { + // Mock a widget page. We use a fake version of Element Call here. + // We should match on things after .html as these widgets get a ton of extra params. + await page.route(/\/widget.html.+/, async (route) => { + await route.fulfill({ + status: 200, + // Do enough to + body: (await fakeCallClient).replace("widgetCodeHere", await widgetApi), + }); + }); + await app.settings.setValue( + "Developer.elementCallUrl", + null, + SettingLevel.DEVICE, + new URL("/widget.html#", page.url()).toString(), + ); + }); + + test.describe("Group Chat", () => { + let charlie: Bot; + test.use({ + room: async ({ page, app, user, homeserver, bot }, use) => { + charlie = new Bot(page, homeserver, { displayName: "Charlie" }); + await charlie.prepareClient(); + const roomId = await app.client.createRoom({ + name: "TestRoom", + invite: [bot.credentials.userId, charlie.credentials.userId], + }); + await use({ roomId }); + }, + }); + test("should be able to start a video call", async ({ page, user, room, app }) => { + await app.viewRoomById(room.roomId); + await expect(page.getByText("Bob and one other were invited and joined")).toBeVisible(); + + await page.getByRole("button", { name: "Video call" }).click(); + await page.getByRole("menuitem", { name: "Element Call" }).click(); + + const frameUrlStr = await page.locator("iframe").getAttribute("src"); + await expect(frameUrlStr).toBeDefined(); + // Ensure we set the correct parameters for ECall. + const url = new URL(frameUrlStr); + const hash = new URLSearchParams(url.hash.slice(1)); + assertCommonCallParameters(url.searchParams, hash, user, room); + expect(hash.get("intent")).toEqual("start_call"); + expect(hash.get("skipLobby")).toEqual(null); + }); + + test("should NOT be able to start a voice call", async ({ page, user, room, app }) => { + // Voice calls do not exist in group rooms + await app.viewRoomById(room.roomId); + await expect(page.getByText("Bob and one other were invited and joined")).toBeVisible(); + await expect(page.getByRole("button", { name: "Voice call" })).not.toBeVisible(); + }); + + test("should be able to skip lobby by holding down shift", async ({ page, user, bot, room, app }) => { + await app.viewRoomById(room.roomId); + await expect(page.getByText("Bob and one other were invited and joined")).toBeVisible(); + + await page.getByRole("button", { name: "Video call" }).click(); + await page.keyboard.down("Shift"); + await page.getByRole("menuitem", { name: "Element Call" }).click(); + await page.keyboard.up("Shift"); + + const frameUrlStr = await page.locator("iframe").getAttribute("src"); + await expect(frameUrlStr).toBeDefined(); + const url = new URL(frameUrlStr); + const hash = new URLSearchParams(url.hash.slice(1)); + assertCommonCallParameters(url.searchParams, hash, user, room); + expect(hash.get("intent")).toEqual("start_call"); + expect(hash.get("skipLobby")).toEqual("true"); + }); + + test("should be able to join a call in progress", async ({ page, user, bot, room, app }) => { + await app.viewRoomById(room.roomId); + // Allow bob to create a call + await expect(page.getByText("Bob and one other were invited and joined")).toBeVisible(); + await app.client.setPowerLevel(room.roomId, bot.credentials.userId, 50); + // Fake a start of a call + await sendRTCState(bot, room.roomId); + const button = page.getByTestId("join-call-button"); + await expect(button).toBeInViewport({ timeout: 5000 }); + // And test joining + await button.click(); + const frameUrlStr = await page.locator("iframe").getAttribute("src"); + await expect(frameUrlStr).toBeDefined(); + const url = new URL(frameUrlStr); + const hash = new URLSearchParams(url.hash.slice(1)); + assertCommonCallParameters(url.searchParams, hash, user, room); + + expect(hash.get("intent")).toEqual("join_existing"); + expect(hash.get("skipLobby")).toEqual(null); + }); + + [true, false].forEach((skipLobbyToggle) => { + test( + `should be able to join a call via incoming video call toast (skipLobby=${skipLobbyToggle})`, + { tag: ["@screenshot"] }, + async ({ page, user, bot, room, app }) => { + await app.viewRoomById(room.roomId); + // Allow bob to create a call + await expect(page.getByText("Bob and one other were invited and joined")).toBeVisible(); + await app.client.setPowerLevel(room.roomId, bot.credentials.userId, 50); + // Fake a start of a call + await sendRTCState(bot, room.roomId, "notification", "video"); + const toast = page.locator(".mx_Toast_toast"); + const button = toast.getByRole("button", { name: "Join" }); + + if (skipLobbyToggle) { + await toast.getByRole("switch").check(); + await expect(toast).toMatchScreenshot(`incoming-call-group-video-toast-checked.png`); + } else { + await toast.getByRole("switch").uncheck(); + await expect(toast).toMatchScreenshot(`incoming-call-group-video-toast-unchecked.png`); + } + + // And test joining + await button.click(); + const frameUrlStr = await page.locator("iframe").getAttribute("src"); + await expect(frameUrlStr).toBeDefined(); + const url = new URL(frameUrlStr); + const hash = new URLSearchParams(url.hash.slice(1)); + assertCommonCallParameters(url.searchParams, hash, user, room); + + expect(hash.get("intent")).toEqual("join_existing"); + expect(hash.get("skipLobby")).toEqual(skipLobbyToggle.toString()); + }, + ); + }); + + test( + `should be able to join a call via incoming voice call toast`, + { tag: ["@screenshot"] }, + async ({ page, user, bot, room, app }) => { + await app.viewRoomById(room.roomId); + // Allow bob to create a call + await expect(page.getByText("Bob and one other were invited and joined")).toBeVisible(); + await app.client.setPowerLevel(room.roomId, bot.credentials.userId, 50); + // Fake a start of a call + await sendRTCState(bot, room.roomId, "notification", "audio"); + const toast = page.locator(".mx_Toast_toast"); + const button = toast.getByRole("button", { name: "Join" }); + + await expect(toast).toMatchScreenshot(`incoming-call-group-voice-toast.png`); + + // And test joining + await button.click(); + const frameUrlStr = await page.locator("iframe").getAttribute("src"); + await expect(frameUrlStr).toBeDefined(); + const url = new URL(frameUrlStr); + const hash = new URLSearchParams(url.hash.slice(1)); + assertCommonCallParameters(url.searchParams, hash, user, room); + + expect(hash.get("intent")).toEqual("join_existing"); + expect(hash.get("skipLobby")).toEqual("true"); + }, + ); + }); + + test.describe("DMs", () => { + test.use({ + room: async ({ page, app, user, bot }, use) => { + const roomId = await app.client.createRoom({ + preset: "trusted_private_chat" as Preset.TrustedPrivateChat, + invite: [bot.credentials.userId], + }); + await bot.awaitRoomMembership(roomId); + await app.client.setAccountData("m.direct" as EventType.Direct, { + [bot.credentials.userId]: [roomId], + }); + await use({ roomId }); + }, + }); + + test("should be able to start a video call", async ({ page, user, room, app }) => { + await app.viewRoomById(room.roomId); + await expect(page.getByText("Bob joined the room")).toBeVisible(); + + await page.getByRole("button", { name: "Video call" }).click(); + await page.getByRole("menuitem", { name: "Element Call" }).click(); + const frameUrlStr = await page.locator("iframe").getAttribute("src"); + + await expect(frameUrlStr).toBeDefined(); + const url = new URL(frameUrlStr); + const hash = new URLSearchParams(url.hash.slice(1)); + assertCommonCallParameters(url.searchParams, hash, user, room); + expect(hash.get("intent")).toEqual("start_call_dm"); + expect(hash.get("skipLobby")).toEqual(null); + }); + + test("should be able to skip lobby by holding down shift", async ({ page, user, room, app }) => { + await app.viewRoomById(room.roomId); + await expect(page.getByText("Bob joined the room")).toBeVisible(); + + await page.getByRole("button", { name: "Video call" }).click(); + await page.keyboard.down("Shift"); + await page.getByRole("menuitem", { name: "Element Call" }).click(); + await page.keyboard.up("Shift"); + const frameUrlStr = await page.locator("iframe").getAttribute("src"); + + await expect(frameUrlStr).toBeDefined(); + const url = new URL(frameUrlStr); + const hash = new URLSearchParams(url.hash.slice(1)); + assertCommonCallParameters(url.searchParams, hash, user, room); + expect(hash.get("intent")).toEqual("start_call_dm"); + expect(hash.get("skipLobby")).toEqual("true"); + }); + + test("should be able to join a call in progress", async ({ page, user, bot, room, app }) => { + await app.viewRoomById(room.roomId); + await expect(page.getByText("Bob joined the room")).toBeVisible(); + // Fake a start of a call + await sendRTCState(bot, room.roomId); + const button = page.getByTestId("join-call-button"); + await expect(button).toBeInViewport({ timeout: 5000 }); + // And test joining + await button.click(); + const frameUrlStr = await page.locator("iframe").getAttribute("src"); + await expect(frameUrlStr).toBeDefined(); + const url = new URL(frameUrlStr); + const hash = new URLSearchParams(url.hash.slice(1)); + assertCommonCallParameters(url.searchParams, hash, user, room); + + expect(hash.get("intent")).toEqual("join_existing_dm"); + expect(hash.get("skipLobby")).toEqual(null); + }); + + [true, false].forEach((skipLobbyToggle) => { + test( + `should be able to join a call via incoming call toast (skipLobby=${skipLobbyToggle})`, + { tag: ["@screenshot"] }, + async ({ page, user, bot, room, app }) => { + await app.viewRoomById(room.roomId); + await expect(page.getByText("Bob joined the room")).toBeVisible(); + // Fake a start of a call + await sendRTCState(bot, room.roomId, "ring", "video"); + const toast = page.locator(".mx_Toast_toast"); + const button = toast.getByRole("button", { name: "Accept" }); + if (skipLobbyToggle) { + await toast.getByRole("switch").check(); + } else { + await toast.getByRole("switch").uncheck(); + } + await expect(toast).toMatchScreenshot( + `incoming-call-dm-video-toast-${skipLobbyToggle ? "checked" : "unchecked"}.png`, + { + // Hide UserId + css: ` + .mx_IncomingCallToast_AvatarWithDetails span:nth-child(2) { + opacity: 0; + } + `, + }, + ); + + // And test joining + await button.click(); + const frameUrlStr = await page.locator("iframe").getAttribute("src"); + await expect(frameUrlStr).toBeDefined(); + const url = new URL(frameUrlStr); + const hash = new URLSearchParams(url.hash.slice(1)); + assertCommonCallParameters(url.searchParams, hash, user, room); + + expect(hash.get("intent")).toEqual("join_existing_dm"); + expect(hash.get("skipLobby")).toEqual(skipLobbyToggle.toString()); + }, + ); + }); + + test( + `should be able to join a call via incoming voice call toast`, + { tag: ["@screenshot"] }, + async ({ page, user, bot, room, app }) => { + await app.viewRoomById(room.roomId); + await expect(page.getByText("Bob joined the room")).toBeVisible(); + // Fake a start of a call + await sendRTCState(bot, room.roomId, "ring", "audio"); + const toast = page.locator(".mx_Toast_toast"); + const button = toast.getByRole("button", { name: "Accept" }); + + await expect(toast).toMatchScreenshot(`incoming-call-dm-voice-toast.png`, { + // Hide UserId + css: ` + .mx_IncomingCallToast_AvatarWithDetails span:nth-child(2) { + opacity: 0; + } + `, + }); + + // And test joining + await button.click(); + const frameUrlStr = await page.locator("iframe").getAttribute("src"); + await expect(frameUrlStr).toBeDefined(); + const url = new URL(frameUrlStr); + const hash = new URLSearchParams(url.hash.slice(1)); + assertCommonCallParameters(url.searchParams, hash, user, room); + + expect(hash.get("intent")).toEqual("join_existing_dm_voice"); + expect(hash.get("skipLobby")).toEqual("true"); + }, + ); + }); + + test.describe("Video Rooms", () => { + test.use({ + config: { + features: { + feature_video_rooms: true, + feature_element_call_video_rooms: true, + }, + }, + }); + test("should be able to create and join a video room", async ({ page, user }) => { + await page + .getByRole("navigation", { name: "Room list" }) + .getByRole("button", { name: "New conversation" }) + .click(); + await page.getByRole("menuitem", { name: "New video room" }).click(); + await page.getByRole("textbox", { name: "Name" }).fill("Test room"); + await page.getByRole("button", { name: "Create video room" }).click(); + await expect(page).toHaveURL(new RegExp(`/#/room/`)); + const roomId = new URL(page.url()).hash.slice("#/room/".length); + + const frameUrlStr = await page.locator("iframe").getAttribute("src"); + await expect(frameUrlStr).toBeDefined(); + // Ensure we set the correct parameters for ECall. + const url = new URL(frameUrlStr); + const hash = new URLSearchParams(url.hash.slice(1)); + assertCommonCallParameters(url.searchParams, hash, user, { roomId }); + expect(hash.get("intent")).toEqual("join_existing"); + expect(hash.get("skipLobby")).toEqual("false"); + expect(hash.get("returnToLobby")).toEqual("true"); + }); + }); + + test.describe("Switching rooms", () => { + let charlie: Bot; + test.use({ + room: async ({ page, app, user, homeserver, bot }, use) => { + charlie = new Bot(page, homeserver, { displayName: "Charlie" }); + await charlie.prepareClient(); + const roomId = await app.client.createRoom({ + name: "TestRoom", + invite: [bot.credentials.userId, charlie.credentials.userId], + }); + await app.client.createRoom({ + name: "OtherRoom", + }); + await use({ roomId }); + }, + }); + + async function openAndJoinCall(page: Page, existing = false) { + if (existing) { + await page.getByTestId("join-call-button").click(); + } else { + await page.getByRole("button", { name: "Video call" }).click(); + await page.getByRole("menuitem", { name: "Element Call" }).click(); + } + const iframe = page.locator("iframe"); + await expect(iframe).toBeVisible(); + const frameUrlStr = await page.locator("iframe").getAttribute("src"); + const callFrame = page.frame({ url: frameUrlStr }); + await callFrame.getByRole("button", { name: "Join Call" }).click(); + await expect(callFrame.getByText("In call", { exact: true })).toBeVisible(); + + // Wait for Element Web to pickup the RTC session and update the room list entry. + await expect(await page.getByTestId("notification-decoration")).toBeVisible(); + } + + test("should be able to switch rooms and have the call persist", async ({ page, user, room, app }) => { + await app.viewRoomById(room.roomId); + await expect(page.getByText("Bob and one other were invited and joined")).toBeVisible(); + + await openAndJoinCall(page); + await app.viewRoomByName("OtherRoom"); + + // We should have a PiP container here. + await expect(page.locator(".mx_AppTile_persistedWrapper")).toBeVisible(); + }); + + test("should be able to start a call, close it via PiP, and start again in the same room", async ({ + page, + user, + room, + app, + }) => { + await app.viewRoomById(room.roomId); + await expect(page.getByText("Bob and one other were invited and joined")).toBeVisible(); + + await openAndJoinCall(page); + await app.viewRoomByName("OtherRoom"); + const pipContainer = page.locator(".mx_WidgetPip"); + + // We should have a PiP container here. + await expect(pipContainer).toBeVisible(); + + // Leave the call. + const overlay = page.locator(".mx_WidgetPip_overlay"); + await overlay.hover({ timeout: 2000 }); // Show the call footer. + await overlay.getByRole("button", { name: "Leave", exact: true }).click(); + + // PiP container goes. + await expect(pipContainer).not.toBeVisible(); + + // Wait for call to stop. + await expect(await page.getByTestId("notification-decoration")).not.toBeVisible(); + await app.viewRoomById(room.roomId); + await expect(await page.getByTestId("join-call-button")).not.toBeVisible(); + + // Join the call again. + await openAndJoinCall(page); + }); + + test("should be able to start a call, close it via PiP, and start again in a different room", async ({ + page, + user, + room, + app, + }) => { + await app.viewRoomById(room.roomId); + await expect(page.getByText("Bob and one other were invited and joined")).toBeVisible(); + + await openAndJoinCall(page); + await app.viewRoomByName("OtherRoom"); + const pipContainer = page.locator(".mx_WidgetPip"); + + // We should have a PiP container here. + await expect(pipContainer).toBeVisible(); + + // Leave the call. + const overlay = page.locator(".mx_WidgetPip_overlay"); + await overlay.hover({ timeout: 2000 }); // Show the call footer. + await overlay.getByRole("button", { name: "Leave", exact: true }).click(); + + // PiP container goes. + await expect(pipContainer).not.toBeVisible(); + + // Wait for call to stop. + await expect(await page.getByTestId("notification-decoration")).not.toBeVisible(); + await expect(await page.getByTestId("join-call-button")).not.toBeVisible(); + + // Join the call again, but from the other room. + await openAndJoinCall(page); + }); + + // For https://github.com/element-hq/element-web/issues/30838 + test("should be able to join a call, leave via PiP, and rejoin the call", async ({ + page, + user, + room, + app, + bot, + }) => { + await app.viewRoomById(room.roomId); + await expect(page.getByText("Bob and one other were invited and joined")).toBeVisible(); + await app.client.setPowerLevel(room.roomId, bot.credentials.userId, 50); + + await sendRTCState(bot, room.roomId); + await openAndJoinCall(page, true); + + await app.viewRoomByName("OtherRoom"); + const pipContainer = page.locator(".mx_WidgetPip"); + + // We should have a PiP container here. + await expect(pipContainer).toBeVisible(); + + // Leave the call. + const overlay = page.locator(".mx_WidgetPip_overlay"); + await overlay.hover({ timeout: 2000 }); // Show the call footer. + await overlay.getByRole("button", { name: "Leave", exact: true }).click(); + + // PiP container goes. + await expect(pipContainer).not.toBeVisible(); + + // Rejoin the call + await app.viewRoomById(room.roomId); + await openAndJoinCall(page, true); + }); + }); + + test.describe("Widget leak bug reproduction", { tag: ["@no-firefox", "@no-webkit"] }, () => { + test.skip(isDendrite, "No need to test on other HS, this is a client bug reproduction"); + test.use({ + config: { + features: { + feature_video_rooms: true, + feature_element_call_video_rooms: true, + }, + }, + }); + + const fakeCallClientSend = readFile("playwright/sample-files/fake-element-call-with-send.html", "utf-8"); + + let charlie: Bot; + test.use({ + room: async ({ page, app, user, homeserver, bot }, use) => { + charlie = new Bot(page, homeserver, { displayName: "Charlie" }); + await charlie.prepareClient(); + const roomId = await app.client.createRoom({ + name: "VideoRoom", + invite: [bot.credentials.userId, charlie.credentials.userId], + creation_content: { + type: "org.matrix.msc3417.call", + }, + }); + await app.client.createRoom({ + name: "OtherRoom", + }); + await use({ roomId }); + }, + }); + + test.beforeEach(async ({ page, user, app }) => { + // use a specific widget to reproduce the bug. + // Mock a widget page. We use a fake version of Element Call here. + // We should match on things after .html as these widgets get a ton of extra params. + await page.route(/\/widget-with-send.html.+/, async (route) => { + await route.fulfill({ + status: 200, + // Do enough to + body: (await fakeCallClientSend).replace("widgetCodeHere", await widgetApi), + }); + }); + await app.settings.setValue( + "Developer.elementCallUrl", + null, + SettingLevel.DEVICE, + new URL("/widget-with-send.html#", page.url()).toString(), + ); + }); + + test("Switching rooms should not leak widgets", async ({ page, user, room, app }) => { + await app.viewRoomByName("VideoRoom"); + + await expect(page.getByRole("heading", { name: "Approve widget permissions" })).toBeVisible(); + // approve + await page.getByTestId("dialog-primary-button").click(); + + // Switch back and forth a few times to trigger the bug. + + await app.viewRoomByName("OtherRoom"); + await app.viewRoomByName("VideoRoom"); + await app.viewRoomByName("OtherRoom"); + await app.viewRoomByName("VideoRoom"); + + // For this test we want to display the chat area alongside the widget + await page.getByRole("button", { name: "Chat" }).click(); + + await page + .locator('iframe[title="Element Call"]') + .contentFrame() + .getByRole("button", { name: "Send Room Message" }) + .click(); + + const messageSent = await page.getByText("I sent this once!!").count(); + + expect(messageSent).toBe(1); + }); + }); +}); diff --git a/playwright/e2e/voip/pstn.spec.ts b/playwright/e2e/voip/pstn.spec.ts index 9a35d9b9c3..4241db6522 100644 --- a/playwright/e2e/voip/pstn.spec.ts +++ b/playwright/e2e/voip/pstn.spec.ts @@ -24,7 +24,7 @@ test.describe("PSTN", () => { await toasts.rejectToast("Notifications"); await toasts.assertNoToasts(); - await expect(page.locator(".mx_LeftPanel_filterContainer")).toMatchScreenshot("dialpad-trigger.png"); + await expect(page.getByTestId("room-list-search")).toMatchScreenshot("dialpad-trigger.png"); await page.getByLabel("Open dial pad").click(); await expect(page.locator(".mx_Dialog")).toMatchScreenshot("dialpad.png"); }); diff --git a/playwright/e2e/widgets/jitsi.spec.ts b/playwright/e2e/widgets/jitsi.spec.ts new file mode 100644 index 0000000000..fc269c506b --- /dev/null +++ b/playwright/e2e/widgets/jitsi.spec.ts @@ -0,0 +1,51 @@ +/* +Copyright 2025 New Vector Ltd. + +SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial +Please see LICENSE files in the repository root for full details. +*/ + +import { test as base, expect } from "../../element-web-test"; +import { Bot } from "../../pages/bot"; + +const ROOM_NAME = "Jitsi Room"; + +const test = base.extend<{ + bot1: Bot; + bot2: Bot; +}>({ + bot2: async ({ page, homeserver }, use, testInfo) => { + const bot = new Bot(page, homeserver, { displayName: `ByteBot_${testInfo.testId}` }); + await bot.prepareClient(); // eagerly register the bot + await use(bot); + }, +}); + +test.describe("Jitsi Calls", () => { + test.use({ + displayName: "Jimmy", + botCreateOpts: { displayName: "Bot", autoAcceptInvites: false }, + }); + + test("should be able to pop out a jitsi widget", async ({ page, app, bot, bot2, context }) => { + const roomId = await app.client.createRoom({ + name: ROOM_NAME, + invite: [bot.credentials.userId, bot2.credentials.userId], + }); + + await bot.joinRoom(roomId); + await bot2.joinRoom(roomId); + + // open the room + await app.viewRoomByName(ROOM_NAME); + + await page.getByRole("button", { name: "Video call" }).click(); + + const pagePromise = context.waitForEvent("page"); + + await page.getByRole("button", { name: "Popout widget" }).click(); + + const newPage = await pagePromise; + await expect(newPage.getByRole("button", { name: "Join Conference" })).toBeVisible(); + }); +}); diff --git a/playwright/e2e/widgets/permissions-dialog.spec.ts b/playwright/e2e/widgets/permissions-dialog.spec.ts new file mode 100644 index 0000000000..bdaec30cd5 --- /dev/null +++ b/playwright/e2e/widgets/permissions-dialog.spec.ts @@ -0,0 +1,98 @@ +/* +Copyright 2025 New Vector Ltd. + +SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial +Please see LICENSE files in the repository root for full details. +*/ + +import { test, expect } from "../../element-web-test"; + +const DEMO_WIDGET_ID = "demo-widget-id"; +const DEMO_WIDGET_NAME = "Demo Widget"; +const DEMO_WIDGET_TYPE = "demo"; +const ROOM_NAME = "Demo"; + +const DEMO_WIDGET_HTML = ` + + + Demo Widget + + + +`; + +test.describe("Widger permissions dialog", () => { + test.use({ + displayName: "Mike", + }); + + let demoWidgetUrl: string; + test.beforeEach(async ({ webserver }) => { + demoWidgetUrl = webserver.start(DEMO_WIDGET_HTML); + }); + + test( + "should be updated if user is re-invited into the room with updated state event", + { tag: "@screenshot" }, + async ({ page, app, user, axe }) => { + const roomId = await app.client.createRoom({ + name: ROOM_NAME, + }); + + // setup widget via state event + await app.client.sendStateEvent( + roomId, + "im.vector.modular.widgets", + { + id: DEMO_WIDGET_ID, + creatorUserId: "somebody", + type: DEMO_WIDGET_TYPE, + name: DEMO_WIDGET_NAME, + url: demoWidgetUrl, + }, + DEMO_WIDGET_ID, + ); + + // set initial layout + await app.client.sendStateEvent( + roomId, + "io.element.widgets.layout", + { + widgets: { + [DEMO_WIDGET_ID]: { + container: "top", + index: 1, + width: 100, + height: 0, + }, + }, + }, + "", + ); + + // open the room + await app.viewRoomByName(ROOM_NAME); + + axe.disableRules("color-contrast"); // XXX: Inheriting colour contrast issues from room view. + await expect(axe).toHaveNoViolations(); + await expect(page.locator(".mx_WidgetCapabilitiesPromptDialog")).toMatchScreenshot( + "widget-capabilites-prompt.png", + ); + }, + ); +}); diff --git a/playwright/element-web-test.ts b/playwright/element-web-test.ts index 8520533461..bf1353ea6c 100644 --- a/playwright/element-web-test.ts +++ b/playwright/element-web-test.ts @@ -107,6 +107,7 @@ interface ExtendedToMatchScreenshotOptions extends ToMatchScreenshotOptions { includeDialogBackground?: boolean; showTooltips?: boolean; timeout?: number; + hideJumpToBottomButton?: boolean; } type Expectations = { @@ -132,6 +133,7 @@ export const expect = baseExpect.extend({ } .mx_BaseAvatar { background-color: var(--cpd-color-fuchsia-1200) !important; + border-color: var(--cpd-color-fuchsia-1200) !important; color: white !important; } .mx_ReplyChain { @@ -143,7 +145,7 @@ export const expect = baseExpect.extend({ } /* Use monospace font for timestamp for consistent mask width */ .mx_MessageTimestamp { - font-family: Inconsolata !important; + font-family: "Fira Code" !important; } `; @@ -165,6 +167,14 @@ export const expect = baseExpect.extend({ `; } + if (options?.hideJumpToBottomButton) { + css += ` + .mx_JumpToBottomButton { + display: none !important; + } + `; + } + if (options?.css) { css += options.css; } diff --git a/playwright/pages/ElementAppPage.ts b/playwright/pages/ElementAppPage.ts index afc814b3e1..e51ed7b5d4 100644 --- a/playwright/pages/ElementAppPage.ts +++ b/playwright/pages/ElementAppPage.ts @@ -51,9 +51,13 @@ export class ElementAppPage { /** * Open room creation dialog. */ - public async openCreateRoomDialog(): Promise { - await this.page.getByRole("button", { name: "Add room", exact: true }).click(); - await this.page.getByRole("menuitem", { name: "New room", exact: true }).click(); + + public async openCreateRoomDialog(roomKindname: "New room" | "New video room" = "New room"): Promise { + await this.page + .getByRole("navigation", { name: "Room list" }) + .getByRole("button", { name: "New conversation" }) + .click(); + await this.page.getByRole("menuitem", { name: roomKindname }).click(); return this.page.locator(".mx_CreateRoomDialog"); } @@ -68,14 +72,39 @@ export class ElementAppPage { return await this.page.evaluate(() => navigator.clipboard.readText()); } + /** + * Get the room ID from the current URL. + * + * @returns The room ID. + * @throws if the current URL does not contain a room ID. + */ + public async getCurrentRoomIdFromUrl(): Promise { + const urlHash = await this.page.evaluate(() => window.location.hash); + if (!urlHash.startsWith("#/room/")) { + throw new Error("URL hash suggests we are not in a room"); + } + return urlHash.replace("#/room/", ""); + } + /** * Opens the given room by name. The room must be visible in the + * room list and the room may contain unread messages. + * + * @param name The exact room name to find and click on/open. + */ + public async viewRoomByName(name: string): Promise { + // We get the room list by test-id which is a listbox and matching title=name + return this.page.getByTestId("room-list").locator(`[title="${name}"]`).first().click(); + } + + /** + * Opens the given room on the old room list by name. The room must be visible in the * room list, but the room list may be folded horizontally, and the * room may contain unread messages. * * @param name The exact room name to find and click on/open. */ - public async viewRoomByName(name: string): Promise { + public async viewRoomByNameOnOldRoomList(name: string): Promise { // We look for the room inside the room list, which is a tree called Rooms. // // There are 3 cases: @@ -173,6 +202,21 @@ export class ElementAppPage { return this.page.locator(".mx_RightPanel"); } + /** + * Opens the room info panel if it is not already open. + * + * TODO: fix this so that it works correctly if, say, the member list was open instead of the room info panel. + * + * @returns locator to the right panel + */ + public async openRoomInfoPanel(): Promise { + const locator = this.page.getByTestId("right-panel"); + if (!(await locator.isVisible())) { + await this.page.getByRole("button", { name: "Room info" }).first().click(); + } + return locator; + } + /** * Opens/closes the memberlist panel * @returns locator to the memberlist panel @@ -185,6 +229,21 @@ export class ElementAppPage { return memberlist; } + /** + * Open the room info panel, and use it to send an invite to the given user. + * + * @param userId - The user to invite to the room. + */ + public async inviteUserToCurrentRoom(userId: string): Promise { + const rightPanel = await this.openRoomInfoPanel(); + await rightPanel.getByRole("menuitem", { name: "Invite" }).click(); + + const input = this.page.getByRole("dialog").getByTestId("invite-dialog-input"); + await input.fill(userId); + await input.press("Enter"); + await this.page.getByRole("dialog").getByRole("button", { name: "Invite" }).click(); + } + /** * Get a locator for the tooltip associated with an element * @param e The element with the tooltip @@ -213,4 +272,26 @@ export class ElementAppPage { .getByRole("button", { name: "Dismiss" }) .click(); } + + /** + * Scroll an infinite list to the bottom. + * @param list The element to scroll + */ + public async scrollListToBottom(list: Locator): Promise { + // First hover the mouse over the element that we want to scroll + await list.hover(); + + const needsScroll = async () => { + // From https://developer.mozilla.org/en-US/docs/Web/API/Element/scrollHeight#determine_if_an_element_has_been_totally_scrolled + const fullyScrolled = await list.evaluate( + (e) => Math.abs(e.scrollHeight - e.clientHeight - e.scrollTop) <= 1, + ); + return !fullyScrolled; + }; + + // Scroll the element until we detect that it is fully scrolled + do { + await this.page.mouse.wheel(0, 1000); + } while (await needsScroll()); + } } diff --git a/playwright/pages/bot.ts b/playwright/pages/bot.ts index 05a8948a65..c3168a89ac 100644 --- a/playwright/pages/bot.ts +++ b/playwright/pages/bot.ts @@ -16,6 +16,10 @@ import type { Credentials, HomeserverInstance } from "../plugins/homeserver"; import type { GeneratedSecretStorageKey } from "matrix-js-sdk/src/crypto-api"; import { bootstrapCrossSigningForClient, Client } from "./client"; +export interface CredentialsOptionalAccessToken extends Omit { + accessToken?: string; +} + export interface CreateBotOpts { /** * A prefix to use for the userid. If unspecified, "bot_" will be used. @@ -58,7 +62,7 @@ const defaultCreateBotOptions = { type ExtendedMatrixClient = MatrixClient & { __playwright_recovery_key: GeneratedSecretStorageKey }; export class Bot extends Client { - public credentials?: Credentials; + public credentials?: CredentialsOptionalAccessToken; private handlePromise: Promise>; constructor( @@ -70,7 +74,16 @@ export class Bot extends Client { this.opts = Object.assign({}, defaultCreateBotOptions, opts); } - public setCredentials(credentials: Credentials): void { + /** + * Set the credentials used by the bot. + * + * If `credentials.accessToken` is unset, then `buildClient` will log in a + * new session. Note that `getCredentials` will return the credentials + * passed to this function, rather than the updated credentials from the new + * login. In particular, the `accessToken` and `deviceId` will not be + * updated. + */ + public setCredentials(credentials: CredentialsOptionalAccessToken): void { if (this.credentials) throw new Error("Bot has already started"); this.credentials = credentials; } @@ -80,7 +93,7 @@ export class Bot extends Client { return client.evaluate((cli) => cli.__playwright_recovery_key); } - private async getCredentials(): Promise { + private async getCredentials(): Promise { if (this.credentials) return this.credentials; // We want to pad the uniqueId but not the prefix const username = @@ -161,6 +174,30 @@ export class Bot extends Client { getSecretStorageKey, }; + if (!("accessToken" in credentials)) { + const loginCli = new window.matrixcs.MatrixClient({ + baseUrl, + store: new window.matrixcs.MemoryStore(), + scheduler: new window.matrixcs.MatrixScheduler(), + cryptoStore: new window.matrixcs.MemoryCryptoStore(), + cryptoCallbacks, + logger, + }); + + const loginResponse = await loginCli.loginRequest({ + type: "m.login.password", + identifier: { + type: "m.id.user", + user: credentials.userId, + }, + password: credentials.password, + }); + + credentials.accessToken = loginResponse.access_token; + credentials.userId = loginResponse.user_id; + credentials.deviceId = loginResponse.device_id; + } + const cli = new window.matrixcs.MatrixClient({ baseUrl, userId: credentials.userId, diff --git a/playwright/pages/client.ts b/playwright/pages/client.ts index 8296e9111e..76f2733820 100644 --- a/playwright/pages/client.ts +++ b/playwright/pages/client.ts @@ -28,7 +28,7 @@ import type { EmptyObject, } from "matrix-js-sdk/src/matrix"; import type { RoomMessageEventContent } from "matrix-js-sdk/src/types"; -import { type Credentials } from "../plugins/homeserver"; +import { type CredentialsOptionalAccessToken } from "./bot"; export class Client { public network: Network; @@ -424,7 +424,7 @@ export class Client { /** * Bootstraps cross-signing. */ - public async bootstrapCrossSigning(credentials: Credentials): Promise { + public async bootstrapCrossSigning(credentials: CredentialsOptionalAccessToken): Promise { const client = await this.prepareClient(); return bootstrapCrossSigningForClient(client, credentials); } @@ -469,6 +469,27 @@ export class Client { ); } + /** + * Set a power level to one or multiple users. + * Will apply changes atop of current power level event. + * @param roomId - the room to update power levels in + * @param userId - the ID of the user or users to update power levels of + * @param powerLevel - the numeric power level to update given users to + */ + public async setPowerLevel( + roomId: string, + userId: string | string[], + powerLevel: number, + ): Promise { + const client = await this.prepareClient(); + return client.evaluate( + async (client, { roomId, userId, powerLevel }) => { + return client.setPowerLevel(roomId, userId, powerLevel); + }, + { roomId, userId, powerLevel }, + ); + } + /** * Leaves the given room. * @param roomId ID of the room to leave @@ -501,7 +522,7 @@ export class Client { */ export function bootstrapCrossSigningForClient( client: JSHandle, - credentials: Credentials, + credentials: CredentialsOptionalAccessToken, resetKeys: boolean = false, ) { return client.evaluate( diff --git a/playwright/pages/settings.ts b/playwright/pages/settings.ts index a08ca65f34..0ea8c50348 100644 --- a/playwright/pages/settings.ts +++ b/playwright/pages/settings.ts @@ -43,7 +43,7 @@ export class Settings { * @param {*} value The new value of the setting, may be null. * @return {Promise} Resolves when the setting has been changed. */ - public async setValue(settingName: string, roomId: string, level: SettingLevel, value: any): Promise { + public async setValue(settingName: string, roomId: string | null, level: SettingLevel, value: any): Promise { return this.page.evaluate< Promise, { diff --git a/playwright/pages/toasts.ts b/playwright/pages/toasts.ts index cfab354aaf..80ee3c9f26 100644 --- a/playwright/pages/toasts.ts +++ b/playwright/pages/toasts.ts @@ -15,11 +15,12 @@ export class Toasts { * Assert that a toast with the given title exists, and return it * * @param expectedTitle - Expected title of the toast + * @param timeout Time to retry the assertion for in milliseconds. Defaults to `timeout` in `TestConfig.expect`. * @returns the Locator for the matching toast */ - public async getToast(expectedTitle: string): Promise { + public async getToast(expectedTitle: string, timeout?: number): Promise { const toast = this.page.locator(".mx_Toast_toast", { hasText: expectedTitle }).first(); - await expect(toast).toBeVisible(); + await expect(toast).toBeVisible({ timeout }); return toast; } diff --git a/playwright/plugins/homeserver/index.ts b/playwright/plugins/homeserver/index.ts index 0571cd9615..993853a3a2 100644 --- a/playwright/plugins/homeserver/index.ts +++ b/playwright/plugins/homeserver/index.ts @@ -6,7 +6,8 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com Please see LICENSE files in the repository root for full details. */ -import { type ClientServerApi } from "@element-hq/element-web-playwright-common/lib/utils/api.js"; +import { type ClientServerApi, type Credentials } from "@element-hq/element-web-playwright-common/lib/utils/api.js"; +export { type Credentials } from "@element-hq/element-web-playwright-common/lib/utils/api.js"; export interface HomeserverInstance { readonly baseUrl: string; @@ -37,14 +38,4 @@ export interface HomeserverInstance { setThreepid(userId: string, medium: string, address: string): Promise; } -export interface Credentials { - accessToken: string; - userId: string; - deviceId: string; - homeServer: string; - password: string | null; // null for password-less users - displayName?: string; - username: string; // the localpart of the userId -} - export type HomeserverType = "synapse" | "dendrite" | "pinecone"; diff --git a/playwright/plugins/homeserver/synapse/masHomeserver.ts b/playwright/plugins/homeserver/synapse/masHomeserver.ts index 342737d80d..84d73018fc 100644 --- a/playwright/plugins/homeserver/synapse/masHomeserver.ts +++ b/playwright/plugins/homeserver/synapse/masHomeserver.ts @@ -1,38 +1,49 @@ /* -Copyright 2024 New Vector Ltd. +Copyright 2024-2025 New Vector Ltd. Copyright 2023 The Matrix.org Foundation C.I.C. SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial Please see LICENSE files in the repository root for full details. */ -import { MatrixAuthenticationServiceContainer } from "@element-hq/element-web-playwright-common/lib/testcontainers"; - +import { MatrixAuthenticationServiceContainer } from "../../../testcontainers/mas.ts"; import { type Fixtures } from "../../../element-web-test.ts"; export const masHomeserver: Fixtures = { mas: [ async ({ _homeserver: homeserver, logger, network, postgres, mailpit }, use) => { - const config = { - clients: [ - { - client_id: "0000000000000000000SYNAPSE", - client_auth_method: "client_secret_basic", - client_secret: "SomeRandomSecret", - }, - ], - matrix: { - homeserver: "localhost", - secret: "AnotherRandomSecret", - endpoint: "http://homeserver:8008", - }, - }; + const secret = "AnotherRandomSecret"; + const limits = { burst: 10, per_second: 10 }; const container = await new MatrixAuthenticationServiceContainer(postgres) .withNetwork(network) .withNetworkAliases("mas") .withLogConsumer(logger.getConsumer("mas")) - .withConfig(config) + .withConfig({ + matrix: { + kind: "synapse", + homeserver: "localhost", + secret, + endpoint: "http://homeserver:8008", + }, + rate_limiting: { + login: { + per_ip: limits, + per_account: limits, + }, + registration: limits, + email_authentication: { + per_ip: limits, + per_address: limits, + emails_per_session: limits, + attempt_per_session: limits, + }, + account_recovery: { + per_ip: limits, + per_address: limits, + }, + }, + }) .start(); homeserver.withConfig({ @@ -40,16 +51,10 @@ export const masHomeserver: Fixtures = { enable_registration_without_verification: undefined, disable_msisdn_registration: undefined, password_config: undefined, - experimental_features: { - msc3861: { - enabled: true, - issuer: `http://mas:8080/`, - introspection_endpoint: "http://mas:8080/oauth2/introspect", - client_id: config.clients[0].client_id, - client_auth_method: config.clients[0].client_auth_method, - client_secret: config.clients[0].client_secret, - admin_token: config.matrix.secret, - }, + matrix_authentication_service: { + enabled: true, + endpoint: "http://mas:8080/", + secret, }, }); @@ -59,28 +64,6 @@ export const masHomeserver: Fixtures = { { scope: "worker" }, ], - config: async ({ homeserver, context, mas }, use) => { - const issuer = `${mas.baseUrl}/`; - const wellKnown = { - "m.homeserver": { - base_url: homeserver.baseUrl, - }, - "org.matrix.msc2965.authentication": { - issuer, - account: `${issuer}account`, - }, - }; - - // Ensure org.matrix.msc2965.authentication is in well-known - await context.route("https://localhost/.well-known/matrix/client", async (route) => { - await route.fulfill({ json: wellKnown }); - }); - - await use({ - default_server_config: wellKnown, - }); - }, - context: async ({ homeserverType, context }, use, testInfo) => { testInfo.skip(homeserverType !== "synapse", "does not yet support MAS"); await use(context); diff --git a/playwright/sample-files/custom-component-module.js b/playwright/sample-files/custom-component-module.js new file mode 100644 index 0000000000..be2ab5928d --- /dev/null +++ b/playwright/sample-files/custom-component-module.js @@ -0,0 +1,74 @@ +/* +Copyright 2025 New Vector Ltd. + +SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial +Please see LICENSE files in the repository root for full details. +*/ + +// Note: eslint-plugin-jsdoc doesn't like import types as parameters, so we +// get around it with @typedef +/** + * @typedef {import("@element-hq/element-web-module-api").Api} Api + */ + +export default class CustomComponentModule { + static moduleApiVersion = "^1.2.0"; + /** + * Basic module for testing. + * @param {Api} api API object + */ + constructor(api) { + this.api = api; + this.api.customComponents.registerMessageRenderer( + (evt) => evt.content.body === "Do not show edits", + (_props, originalComponent) => { + return originalComponent(); + }, + { allowEditingEvent: false }, + ); + this.api.customComponents.registerMessageRenderer( + (evt) => evt.content.body === "Fall through here", + (props) => { + const body = props.mxEvent.content.body; + return `Fallthrough text for ${body}`; + }, + ); + this.api.customComponents.registerMessageRenderer( + (evt) => { + if (evt.content.body === "Crash the filter!") { + throw new Error("Fail test!"); + } + return false; + }, + () => { + return `Should not render!`; + }, + ); + this.api.customComponents.registerMessageRenderer( + (evt) => evt.content.body === "Crash the renderer!", + () => { + throw new Error("Fail test!"); + }, + ); + + this.api.customComponents.registerMessageRenderer( + (mxEvent) => mxEvent.type === "m.room.message" && mxEvent.content.msgtype === "m.image", + (_props, originalComponent) => { + return originalComponent(); + }, + { allowDownloadingMedia: async (mxEvent) => mxEvent.content.body !== "bad.png" }, + ); + + // Order is specific here to avoid this overriding the other renderers + this.api.customComponents.registerMessageRenderer("m.room.message", (props, originalComponent) => { + const body = props.mxEvent.content.body; + if (body === "Do not replace me") { + return originalComponent(); + } else if (body === "Fall through here") { + return null; + } + return `Custom text for ${body}`; + }); + } + async load() {} +} diff --git a/playwright/sample-files/fake-element-call-with-send.html b/playwright/sample-files/fake-element-call-with-send.html new file mode 100644 index 0000000000..c7fb2fdcbc --- /dev/null +++ b/playwright/sample-files/fake-element-call-with-send.html @@ -0,0 +1,53 @@ + + + + + + +

    +

    Fake Element Call

    +

    State: Loading

    + +
    + + + diff --git a/playwright/sample-files/fake-element-call.html b/playwright/sample-files/fake-element-call.html new file mode 100644 index 0000000000..a0fc1f8689 --- /dev/null +++ b/playwright/sample-files/fake-element-call.html @@ -0,0 +1,87 @@ + + + + + + +
    +

    Fake Element Call

    +

    State: Loading

    + + +
    + + + diff --git a/playwright/snapshots/audio-player/audio-player.spec.ts/Selected-EventTile-of-audio-player--dark-theme--bubble-layout-linux.png b/playwright/snapshots/audio-player/audio-player.spec.ts/Selected-EventTile-of-audio-player--dark-theme--bubble-layout-linux.png index 165033dbe9..ad46ca44d5 100644 Binary files a/playwright/snapshots/audio-player/audio-player.spec.ts/Selected-EventTile-of-audio-player--dark-theme--bubble-layout-linux.png and b/playwright/snapshots/audio-player/audio-player.spec.ts/Selected-EventTile-of-audio-player--dark-theme--bubble-layout-linux.png differ diff --git a/playwright/snapshots/audio-player/audio-player.spec.ts/Selected-EventTile-of-audio-player--dark-theme--group-layout-linux.png b/playwright/snapshots/audio-player/audio-player.spec.ts/Selected-EventTile-of-audio-player--dark-theme--group-layout-linux.png index f309d57bc0..d4c123d787 100644 Binary files a/playwright/snapshots/audio-player/audio-player.spec.ts/Selected-EventTile-of-audio-player--dark-theme--group-layout-linux.png and b/playwright/snapshots/audio-player/audio-player.spec.ts/Selected-EventTile-of-audio-player--dark-theme--group-layout-linux.png differ diff --git a/playwright/snapshots/audio-player/audio-player.spec.ts/Selected-EventTile-of-audio-player--dark-theme--irc-layout-linux.png b/playwright/snapshots/audio-player/audio-player.spec.ts/Selected-EventTile-of-audio-player--dark-theme--irc-layout-linux.png index bd02a2f21a..97097a3f28 100644 Binary files a/playwright/snapshots/audio-player/audio-player.spec.ts/Selected-EventTile-of-audio-player--dark-theme--irc-layout-linux.png and b/playwright/snapshots/audio-player/audio-player.spec.ts/Selected-EventTile-of-audio-player--dark-theme--irc-layout-linux.png differ diff --git a/playwright/snapshots/audio-player/audio-player.spec.ts/Selected-EventTile-of-audio-player--high-contrast--bubble-layout-linux.png b/playwright/snapshots/audio-player/audio-player.spec.ts/Selected-EventTile-of-audio-player--high-contrast--bubble-layout-linux.png index 16e0624b83..5d240ebd76 100644 Binary files a/playwright/snapshots/audio-player/audio-player.spec.ts/Selected-EventTile-of-audio-player--high-contrast--bubble-layout-linux.png and b/playwright/snapshots/audio-player/audio-player.spec.ts/Selected-EventTile-of-audio-player--high-contrast--bubble-layout-linux.png differ diff --git a/playwright/snapshots/audio-player/audio-player.spec.ts/Selected-EventTile-of-audio-player--high-contrast--group-layout-linux.png b/playwright/snapshots/audio-player/audio-player.spec.ts/Selected-EventTile-of-audio-player--high-contrast--group-layout-linux.png index 1e78930256..2620b9cd20 100644 Binary files a/playwright/snapshots/audio-player/audio-player.spec.ts/Selected-EventTile-of-audio-player--high-contrast--group-layout-linux.png and b/playwright/snapshots/audio-player/audio-player.spec.ts/Selected-EventTile-of-audio-player--high-contrast--group-layout-linux.png differ diff --git a/playwright/snapshots/audio-player/audio-player.spec.ts/Selected-EventTile-of-audio-player--high-contrast--irc-layout-linux.png b/playwright/snapshots/audio-player/audio-player.spec.ts/Selected-EventTile-of-audio-player--high-contrast--irc-layout-linux.png index 6a43aac7ef..6c09f076f9 100644 Binary files a/playwright/snapshots/audio-player/audio-player.spec.ts/Selected-EventTile-of-audio-player--high-contrast--irc-layout-linux.png and b/playwright/snapshots/audio-player/audio-player.spec.ts/Selected-EventTile-of-audio-player--high-contrast--irc-layout-linux.png differ diff --git a/playwright/snapshots/audio-player/audio-player.spec.ts/Selected-EventTile-of-audio-player--light-theme--bubble-layout-linux.png b/playwright/snapshots/audio-player/audio-player.spec.ts/Selected-EventTile-of-audio-player--light-theme--bubble-layout-linux.png index 014b8dbaec..06f850704d 100644 Binary files a/playwright/snapshots/audio-player/audio-player.spec.ts/Selected-EventTile-of-audio-player--light-theme--bubble-layout-linux.png and b/playwright/snapshots/audio-player/audio-player.spec.ts/Selected-EventTile-of-audio-player--light-theme--bubble-layout-linux.png differ diff --git a/playwright/snapshots/audio-player/audio-player.spec.ts/Selected-EventTile-of-audio-player--light-theme--group-layout-linux.png b/playwright/snapshots/audio-player/audio-player.spec.ts/Selected-EventTile-of-audio-player--light-theme--group-layout-linux.png index 156d89053c..5f4e409c3a 100644 Binary files a/playwright/snapshots/audio-player/audio-player.spec.ts/Selected-EventTile-of-audio-player--light-theme--group-layout-linux.png and b/playwright/snapshots/audio-player/audio-player.spec.ts/Selected-EventTile-of-audio-player--light-theme--group-layout-linux.png differ diff --git a/playwright/snapshots/audio-player/audio-player.spec.ts/Selected-EventTile-of-audio-player--light-theme--irc-layout-linux.png b/playwright/snapshots/audio-player/audio-player.spec.ts/Selected-EventTile-of-audio-player--light-theme--irc-layout-linux.png index caf6e1e698..74060e2f42 100644 Binary files a/playwright/snapshots/audio-player/audio-player.spec.ts/Selected-EventTile-of-audio-player--light-theme--irc-layout-linux.png and b/playwright/snapshots/audio-player/audio-player.spec.ts/Selected-EventTile-of-audio-player--light-theme--irc-layout-linux.png differ diff --git a/playwright/snapshots/audio-player/audio-player.spec.ts/Selected-EventTile-of-audio-player--light-theme--monospace-font--bubble-layout-linux.png b/playwright/snapshots/audio-player/audio-player.spec.ts/Selected-EventTile-of-audio-player--light-theme--monospace-font--bubble-layout-linux.png index c9591ebf49..706ed77713 100644 Binary files a/playwright/snapshots/audio-player/audio-player.spec.ts/Selected-EventTile-of-audio-player--light-theme--monospace-font--bubble-layout-linux.png and b/playwright/snapshots/audio-player/audio-player.spec.ts/Selected-EventTile-of-audio-player--light-theme--monospace-font--bubble-layout-linux.png differ diff --git a/playwright/snapshots/audio-player/audio-player.spec.ts/Selected-EventTile-of-audio-player--light-theme--monospace-font--group-layout-linux.png b/playwright/snapshots/audio-player/audio-player.spec.ts/Selected-EventTile-of-audio-player--light-theme--monospace-font--group-layout-linux.png index 794ac11b01..ac0c53994d 100644 Binary files a/playwright/snapshots/audio-player/audio-player.spec.ts/Selected-EventTile-of-audio-player--light-theme--monospace-font--group-layout-linux.png and b/playwright/snapshots/audio-player/audio-player.spec.ts/Selected-EventTile-of-audio-player--light-theme--monospace-font--group-layout-linux.png differ diff --git a/playwright/snapshots/audio-player/audio-player.spec.ts/Selected-EventTile-of-audio-player--light-theme--monospace-font--irc-layout-linux.png b/playwright/snapshots/audio-player/audio-player.spec.ts/Selected-EventTile-of-audio-player--light-theme--monospace-font--irc-layout-linux.png index 2b6475fbdf..07534229d8 100644 Binary files a/playwright/snapshots/audio-player/audio-player.spec.ts/Selected-EventTile-of-audio-player--light-theme--monospace-font--irc-layout-linux.png and b/playwright/snapshots/audio-player/audio-player.spec.ts/Selected-EventTile-of-audio-player--light-theme--monospace-font--irc-layout-linux.png differ diff --git a/playwright/snapshots/audio-player/audio-player.spec.ts/Selected-EventTile-of-audio-player-with-a-reply-bubble-layout-linux.png b/playwright/snapshots/audio-player/audio-player.spec.ts/Selected-EventTile-of-audio-player-with-a-reply-bubble-layout-linux.png index 0f643ee43a..a3a1894eac 100644 Binary files a/playwright/snapshots/audio-player/audio-player.spec.ts/Selected-EventTile-of-audio-player-with-a-reply-bubble-layout-linux.png and b/playwright/snapshots/audio-player/audio-player.spec.ts/Selected-EventTile-of-audio-player-with-a-reply-bubble-layout-linux.png differ diff --git a/playwright/snapshots/audio-player/audio-player.spec.ts/Selected-EventTile-of-audio-player-with-a-reply-chain-bubble-layout-linux.png b/playwright/snapshots/audio-player/audio-player.spec.ts/Selected-EventTile-of-audio-player-with-a-reply-chain-bubble-layout-linux.png index bc9d6c88c3..aa9956a50d 100644 Binary files a/playwright/snapshots/audio-player/audio-player.spec.ts/Selected-EventTile-of-audio-player-with-a-reply-chain-bubble-layout-linux.png and b/playwright/snapshots/audio-player/audio-player.spec.ts/Selected-EventTile-of-audio-player-with-a-reply-chain-bubble-layout-linux.png differ diff --git a/playwright/snapshots/audio-player/audio-player.spec.ts/Selected-EventTile-of-audio-player-with-a-reply-chain-group-layout-linux.png b/playwright/snapshots/audio-player/audio-player.spec.ts/Selected-EventTile-of-audio-player-with-a-reply-chain-group-layout-linux.png index 2b867170ae..45a80c8ed5 100644 Binary files a/playwright/snapshots/audio-player/audio-player.spec.ts/Selected-EventTile-of-audio-player-with-a-reply-chain-group-layout-linux.png and b/playwright/snapshots/audio-player/audio-player.spec.ts/Selected-EventTile-of-audio-player-with-a-reply-chain-group-layout-linux.png differ diff --git a/playwright/snapshots/audio-player/audio-player.spec.ts/Selected-EventTile-of-audio-player-with-a-reply-chain-irc-layout-linux.png b/playwright/snapshots/audio-player/audio-player.spec.ts/Selected-EventTile-of-audio-player-with-a-reply-chain-irc-layout-linux.png index 459ebd3584..b12365c9ac 100644 Binary files a/playwright/snapshots/audio-player/audio-player.spec.ts/Selected-EventTile-of-audio-player-with-a-reply-chain-irc-layout-linux.png and b/playwright/snapshots/audio-player/audio-player.spec.ts/Selected-EventTile-of-audio-player-with-a-reply-chain-irc-layout-linux.png differ diff --git a/playwright/snapshots/audio-player/audio-player.spec.ts/Selected-EventTile-of-audio-player-with-a-reply-group-layout-linux.png b/playwright/snapshots/audio-player/audio-player.spec.ts/Selected-EventTile-of-audio-player-with-a-reply-group-layout-linux.png index da97c28029..724bcb482f 100644 Binary files a/playwright/snapshots/audio-player/audio-player.spec.ts/Selected-EventTile-of-audio-player-with-a-reply-group-layout-linux.png and b/playwright/snapshots/audio-player/audio-player.spec.ts/Selected-EventTile-of-audio-player-with-a-reply-group-layout-linux.png differ diff --git a/playwright/snapshots/audio-player/audio-player.spec.ts/Selected-EventTile-of-audio-player-with-a-reply-irc-layout-linux.png b/playwright/snapshots/audio-player/audio-player.spec.ts/Selected-EventTile-of-audio-player-with-a-reply-irc-layout-linux.png index 009ea38f7b..340015a06b 100644 Binary files a/playwright/snapshots/audio-player/audio-player.spec.ts/Selected-EventTile-of-audio-player-with-a-reply-irc-layout-linux.png and b/playwright/snapshots/audio-player/audio-player.spec.ts/Selected-EventTile-of-audio-player-with-a-reply-irc-layout-linux.png differ diff --git a/playwright/snapshots/chat-export/html-export.spec.ts/html-export-linux.png b/playwright/snapshots/chat-export/html-export.spec.ts/html-export-linux.png index 26c506097a..ffe31b9b26 100644 Binary files a/playwright/snapshots/chat-export/html-export.spec.ts/html-export-linux.png and b/playwright/snapshots/chat-export/html-export.spec.ts/html-export-linux.png differ diff --git a/playwright/snapshots/composer/CIDER.spec.ts/emoji-autocomplete-linux.png b/playwright/snapshots/composer/CIDER.spec.ts/emoji-autocomplete-linux.png new file mode 100644 index 0000000000..94c7ec42c8 Binary files /dev/null and b/playwright/snapshots/composer/CIDER.spec.ts/emoji-autocomplete-linux.png differ diff --git a/playwright/snapshots/composer/CIDER.spec.ts/emoji-picker-linux.png b/playwright/snapshots/composer/CIDER.spec.ts/emoji-picker-linux.png new file mode 100644 index 0000000000..8c0daa555c Binary files /dev/null and b/playwright/snapshots/composer/CIDER.spec.ts/emoji-picker-linux.png differ diff --git a/playwright/snapshots/composer/CIDER.spec.ts/emoji-picker-small-linux.png b/playwright/snapshots/composer/CIDER.spec.ts/emoji-picker-small-linux.png new file mode 100644 index 0000000000..e3911831de Binary files /dev/null and b/playwright/snapshots/composer/CIDER.spec.ts/emoji-picker-small-linux.png differ diff --git a/playwright/snapshots/composer/CIDER.spec.ts/mention-linux.png b/playwright/snapshots/composer/CIDER.spec.ts/mention-linux.png new file mode 100644 index 0000000000..8098757aba Binary files /dev/null and b/playwright/snapshots/composer/CIDER.spec.ts/mention-linux.png differ diff --git a/playwright/snapshots/crypto/crypto.spec.ts/composer-e2e-icon-linux.png b/playwright/snapshots/crypto/crypto.spec.ts/composer-e2e-icon-linux.png new file mode 100644 index 0000000000..1adc91faf0 Binary files /dev/null and b/playwright/snapshots/crypto/crypto.spec.ts/composer-e2e-icon-linux.png differ diff --git a/playwright/snapshots/crypto/device-verification.spec.ts/confirm-green-shield-linux.png b/playwright/snapshots/crypto/device-verification.spec.ts/confirm-green-shield-linux.png new file mode 100644 index 0000000000..23c295c2f6 Binary files /dev/null and b/playwright/snapshots/crypto/device-verification.spec.ts/confirm-green-shield-linux.png differ diff --git a/playwright/snapshots/crypto/device-verification.spec.ts/device-verified-e2eIcon-linux.png b/playwright/snapshots/crypto/device-verification.spec.ts/device-verified-e2eIcon-linux.png new file mode 100644 index 0000000000..bd282ff485 Binary files /dev/null and b/playwright/snapshots/crypto/device-verification.spec.ts/device-verified-e2eIcon-linux.png differ diff --git a/playwright/snapshots/crypto/device-verification.spec.ts/got-it-linux.png b/playwright/snapshots/crypto/device-verification.spec.ts/got-it-linux.png new file mode 100644 index 0000000000..0d705053b3 Binary files /dev/null and b/playwright/snapshots/crypto/device-verification.spec.ts/got-it-linux.png differ diff --git a/playwright/snapshots/crypto/device-verification.spec.ts/qr-code-linux.png b/playwright/snapshots/crypto/device-verification.spec.ts/qr-code-linux.png new file mode 100644 index 0000000000..23094f7b8b Binary files /dev/null and b/playwright/snapshots/crypto/device-verification.spec.ts/qr-code-linux.png differ diff --git a/playwright/snapshots/crypto/device-verification.spec.ts/recovery-key-linux.png b/playwright/snapshots/crypto/device-verification.spec.ts/recovery-key-linux.png new file mode 100644 index 0000000000..7683872ec8 Binary files /dev/null and b/playwright/snapshots/crypto/device-verification.spec.ts/recovery-key-linux.png differ diff --git a/playwright/snapshots/crypto/event-shields.spec.ts/event-shield-warning-linux.png b/playwright/snapshots/crypto/event-shields.spec.ts/event-shield-warning-linux.png new file mode 100644 index 0000000000..eacd2a7803 Binary files /dev/null and b/playwright/snapshots/crypto/event-shields.spec.ts/event-shield-warning-linux.png differ diff --git a/playwright/snapshots/crypto/history-sharing.spec.ts/shared-history-invite-accepted-linux.png b/playwright/snapshots/crypto/history-sharing.spec.ts/shared-history-invite-accepted-linux.png new file mode 100644 index 0000000000..c33dbaadf9 Binary files /dev/null and b/playwright/snapshots/crypto/history-sharing.spec.ts/shared-history-invite-accepted-linux.png differ diff --git a/playwright/snapshots/crypto/toasts.spec.ts/key-storage-out-of-sync-toast-linux.png b/playwright/snapshots/crypto/toasts.spec.ts/key-storage-out-of-sync-toast-linux.png index 8cf3cb7d69..c5ca8fdc02 100644 Binary files a/playwright/snapshots/crypto/toasts.spec.ts/key-storage-out-of-sync-toast-linux.png and b/playwright/snapshots/crypto/toasts.spec.ts/key-storage-out-of-sync-toast-linux.png differ diff --git a/playwright/snapshots/devtools/devtools.spec.ts/devtools-dialog-linux.png b/playwright/snapshots/devtools/devtools.spec.ts/devtools-dialog-linux.png new file mode 100644 index 0000000000..7f802aca0e Binary files /dev/null and b/playwright/snapshots/devtools/devtools.spec.ts/devtools-dialog-linux.png differ diff --git a/playwright/snapshots/devtools/upgraderoom.spec.ts/upgrade-room-linux.png b/playwright/snapshots/devtools/upgraderoom.spec.ts/upgrade-room-linux.png new file mode 100644 index 0000000000..e4f9bf4b13 Binary files /dev/null and b/playwright/snapshots/devtools/upgraderoom.spec.ts/upgrade-room-linux.png differ diff --git a/playwright/snapshots/editing/editing.spec.ts/message-edit-history-dialog-linux.png b/playwright/snapshots/editing/editing.spec.ts/message-edit-history-dialog-linux.png index 81939514ff..f606955146 100644 Binary files a/playwright/snapshots/editing/editing.spec.ts/message-edit-history-dialog-linux.png and b/playwright/snapshots/editing/editing.spec.ts/message-edit-history-dialog-linux.png differ diff --git a/playwright/snapshots/forgot-password/forgot-password.spec.ts/forgot-password-linux.png b/playwright/snapshots/forgot-password/forgot-password.spec.ts/forgot-password-linux.png index 8bc25d6f2f..e9eb06375f 100644 Binary files a/playwright/snapshots/forgot-password/forgot-password.spec.ts/forgot-password-linux.png and b/playwright/snapshots/forgot-password/forgot-password.spec.ts/forgot-password-linux.png differ diff --git a/playwright/snapshots/invite/decline-and-block-invite-dialog.spec.ts/decline-and-block-invite-empty-linux.png b/playwright/snapshots/invite/decline-and-block-invite-dialog.spec.ts/decline-and-block-invite-empty-linux.png new file mode 100644 index 0000000000..cb3f9f8618 Binary files /dev/null and b/playwright/snapshots/invite/decline-and-block-invite-dialog.spec.ts/decline-and-block-invite-empty-linux.png differ diff --git a/playwright/snapshots/invite/invite-dialog.spec.ts/invite-dialog-dm-with-user-pill-linux.png b/playwright/snapshots/invite/invite-dialog.spec.ts/invite-dialog-dm-with-user-pill-linux.png index 1a66050e5f..86154ad35d 100644 Binary files a/playwright/snapshots/invite/invite-dialog.spec.ts/invite-dialog-dm-with-user-pill-linux.png and b/playwright/snapshots/invite/invite-dialog.spec.ts/invite-dialog-dm-with-user-pill-linux.png differ diff --git a/playwright/snapshots/invite/invite-dialog.spec.ts/invite-dialog-dm-without-user-linux.png b/playwright/snapshots/invite/invite-dialog.spec.ts/invite-dialog-dm-without-user-linux.png index 86cb11aad9..e63333c1e9 100644 Binary files a/playwright/snapshots/invite/invite-dialog.spec.ts/invite-dialog-dm-without-user-linux.png and b/playwright/snapshots/invite/invite-dialog.spec.ts/invite-dialog-dm-without-user-linux.png differ diff --git a/playwright/snapshots/invite/invite-dialog.spec.ts/invite-dialog-room-with-user-pill-linux.png b/playwright/snapshots/invite/invite-dialog.spec.ts/invite-dialog-room-with-user-pill-linux.png index 043e35b6be..0c0695ad64 100644 Binary files a/playwright/snapshots/invite/invite-dialog.spec.ts/invite-dialog-room-with-user-pill-linux.png and b/playwright/snapshots/invite/invite-dialog.spec.ts/invite-dialog-room-with-user-pill-linux.png differ diff --git a/playwright/snapshots/invite/invite-dialog.spec.ts/invite-dialog-room-without-user-linux.png b/playwright/snapshots/invite/invite-dialog.spec.ts/invite-dialog-room-without-user-linux.png index 910040e20f..cee338835f 100644 Binary files a/playwright/snapshots/invite/invite-dialog.spec.ts/invite-dialog-room-without-user-linux.png and b/playwright/snapshots/invite/invite-dialog.spec.ts/invite-dialog-room-without-user-linux.png differ diff --git a/playwright/snapshots/invite/invite-dialog.spec.ts/send-your-first-message-view-linux.png b/playwright/snapshots/invite/invite-dialog.spec.ts/send-your-first-message-view-linux.png new file mode 100644 index 0000000000..ab42c30cd2 Binary files /dev/null and b/playwright/snapshots/invite/invite-dialog.spec.ts/send-your-first-message-view-linux.png differ diff --git a/playwright/snapshots/left-panel/room-list-panel/room-list-filter-sort.spec.ts/Favourite-empty-room-list-linux.png b/playwright/snapshots/left-panel/room-list-panel/room-list-filter-sort.spec.ts/Favourite-empty-room-list-linux.png index 6c71c11b1d..d30b38b38f 100644 Binary files a/playwright/snapshots/left-panel/room-list-panel/room-list-filter-sort.spec.ts/Favourite-empty-room-list-linux.png and b/playwright/snapshots/left-panel/room-list-panel/room-list-filter-sort.spec.ts/Favourite-empty-room-list-linux.png differ diff --git a/playwright/snapshots/left-panel/room-list-panel/room-list-filter-sort.spec.ts/Invites-empty-room-list-linux.png b/playwright/snapshots/left-panel/room-list-panel/room-list-filter-sort.spec.ts/Invites-empty-room-list-linux.png index c7396da41d..c770da3377 100644 Binary files a/playwright/snapshots/left-panel/room-list-panel/room-list-filter-sort.spec.ts/Invites-empty-room-list-linux.png and b/playwright/snapshots/left-panel/room-list-panel/room-list-filter-sort.spec.ts/Invites-empty-room-list-linux.png differ diff --git a/playwright/snapshots/left-panel/room-list-panel/room-list-filter-sort.spec.ts/Mentions-empty-room-list-linux.png b/playwright/snapshots/left-panel/room-list-panel/room-list-filter-sort.spec.ts/Mentions-empty-room-list-linux.png index 15620d3612..44e27d9618 100644 Binary files a/playwright/snapshots/left-panel/room-list-panel/room-list-filter-sort.spec.ts/Mentions-empty-room-list-linux.png and b/playwright/snapshots/left-panel/room-list-panel/room-list-filter-sort.spec.ts/Mentions-empty-room-list-linux.png differ diff --git a/playwright/snapshots/left-panel/room-list-panel/room-list-filter-sort.spec.ts/People-empty-room-list-linux.png b/playwright/snapshots/left-panel/room-list-panel/room-list-filter-sort.spec.ts/People-empty-room-list-linux.png index 7b73e0b819..3f700037cb 100644 Binary files a/playwright/snapshots/left-panel/room-list-panel/room-list-filter-sort.spec.ts/People-empty-room-list-linux.png and b/playwright/snapshots/left-panel/room-list-panel/room-list-filter-sort.spec.ts/People-empty-room-list-linux.png differ diff --git a/playwright/snapshots/left-panel/room-list-panel/room-list-filter-sort.spec.ts/Rooms-empty-room-list-linux.png b/playwright/snapshots/left-panel/room-list-panel/room-list-filter-sort.spec.ts/Rooms-empty-room-list-linux.png index eb3add5733..e01564eeb4 100644 Binary files a/playwright/snapshots/left-panel/room-list-panel/room-list-filter-sort.spec.ts/Rooms-empty-room-list-linux.png and b/playwright/snapshots/left-panel/room-list-panel/room-list-filter-sort.spec.ts/Rooms-empty-room-list-linux.png differ diff --git a/playwright/snapshots/left-panel/room-list-panel/room-list-filter-sort.spec.ts/Unreads-empty-room-list-linux.png b/playwright/snapshots/left-panel/room-list-panel/room-list-filter-sort.spec.ts/Unreads-empty-room-list-linux.png index 70ed4bb782..94b09ac14f 100644 Binary files a/playwright/snapshots/left-panel/room-list-panel/room-list-filter-sort.spec.ts/Unreads-empty-room-list-linux.png and b/playwright/snapshots/left-panel/room-list-panel/room-list-filter-sort.spec.ts/Unreads-empty-room-list-linux.png differ diff --git a/playwright/snapshots/left-panel/room-list-panel/room-list-filter-sort.spec.ts/collapsed-primary-filters-linux.png b/playwright/snapshots/left-panel/room-list-panel/room-list-filter-sort.spec.ts/collapsed-primary-filters-linux.png new file mode 100644 index 0000000000..1d5a8ae87a Binary files /dev/null and b/playwright/snapshots/left-panel/room-list-panel/room-list-filter-sort.spec.ts/collapsed-primary-filters-linux.png differ diff --git a/playwright/snapshots/left-panel/room-list-panel/room-list-filter-sort.spec.ts/default-empty-room-list-linux.png b/playwright/snapshots/left-panel/room-list-panel/room-list-filter-sort.spec.ts/default-empty-room-list-linux.png index 6781c1d364..d47d04e9a6 100644 Binary files a/playwright/snapshots/left-panel/room-list-panel/room-list-filter-sort.spec.ts/default-empty-room-list-linux.png and b/playwright/snapshots/left-panel/room-list-panel/room-list-filter-sort.spec.ts/default-empty-room-list-linux.png differ diff --git a/playwright/snapshots/left-panel/room-list-panel/room-list-filter-sort.spec.ts/expanded-primary-filters-linux.png b/playwright/snapshots/left-panel/room-list-panel/room-list-filter-sort.spec.ts/expanded-primary-filters-linux.png new file mode 100644 index 0000000000..3bafa521b7 Binary files /dev/null and b/playwright/snapshots/left-panel/room-list-panel/room-list-filter-sort.spec.ts/expanded-primary-filters-linux.png differ diff --git a/playwright/snapshots/left-panel/room-list-panel/room-list-filter-sort.spec.ts/room-panel-empty-room-list-linux.png b/playwright/snapshots/left-panel/room-list-panel/room-list-filter-sort.spec.ts/room-panel-empty-room-list-linux.png index 34924cf69f..ac3f26e529 100644 Binary files a/playwright/snapshots/left-panel/room-list-panel/room-list-filter-sort.spec.ts/room-panel-empty-room-list-linux.png and b/playwright/snapshots/left-panel/room-list-panel/room-list-filter-sort.spec.ts/room-panel-empty-room-list-linux.png differ diff --git a/playwright/snapshots/left-panel/room-list-panel/room-list-filter-sort.spec.ts/unread-dm-linux.png b/playwright/snapshots/left-panel/room-list-panel/room-list-filter-sort.spec.ts/unread-dm-linux.png index 2f12ee4e41..6968a4f134 100644 Binary files a/playwright/snapshots/left-panel/room-list-panel/room-list-filter-sort.spec.ts/unread-dm-linux.png and b/playwright/snapshots/left-panel/room-list-panel/room-list-filter-sort.spec.ts/unread-dm-linux.png differ diff --git a/playwright/snapshots/left-panel/room-list-panel/room-list-filter-sort.spec.ts/unread-primary-filters-linux.png b/playwright/snapshots/left-panel/room-list-panel/room-list-filter-sort.spec.ts/unread-primary-filters-linux.png index f0cda0b577..ac8abd60ad 100644 Binary files a/playwright/snapshots/left-panel/room-list-panel/room-list-filter-sort.spec.ts/unread-primary-filters-linux.png and b/playwright/snapshots/left-panel/room-list-panel/room-list-filter-sort.spec.ts/unread-primary-filters-linux.png differ diff --git a/playwright/snapshots/left-panel/room-list-panel/room-list-filter-sort.spec.ts/unselected-primary-filters-linux.png b/playwright/snapshots/left-panel/room-list-panel/room-list-filter-sort.spec.ts/unselected-primary-filters-linux.png index 7d63f923b0..0b879c18fe 100644 Binary files a/playwright/snapshots/left-panel/room-list-panel/room-list-filter-sort.spec.ts/unselected-primary-filters-linux.png and b/playwright/snapshots/left-panel/room-list-panel/room-list-filter-sort.spec.ts/unselected-primary-filters-linux.png differ diff --git a/playwright/snapshots/left-panel/room-list-panel/room-list-header.spec.ts/room-list-header-compose-menu-linux.png b/playwright/snapshots/left-panel/room-list-panel/room-list-header.spec.ts/room-list-header-compose-menu-linux.png index 9f501a58d4..202a83c23a 100644 Binary files a/playwright/snapshots/left-panel/room-list-panel/room-list-header.spec.ts/room-list-header-compose-menu-linux.png and b/playwright/snapshots/left-panel/room-list-panel/room-list-header.spec.ts/room-list-header-compose-menu-linux.png differ diff --git a/playwright/snapshots/left-panel/room-list-panel/room-list-header.spec.ts/room-list-header-linux.png b/playwright/snapshots/left-panel/room-list-panel/room-list-header.spec.ts/room-list-header-linux.png index 46ff1a53be..5e6ddff442 100644 Binary files a/playwright/snapshots/left-panel/room-list-panel/room-list-header.spec.ts/room-list-header-linux.png and b/playwright/snapshots/left-panel/room-list-panel/room-list-header.spec.ts/room-list-header-linux.png differ diff --git a/playwright/snapshots/left-panel/room-list-panel/room-list-header.spec.ts/room-list-header-space-menu-linux.png b/playwright/snapshots/left-panel/room-list-panel/room-list-header.spec.ts/room-list-header-space-menu-linux.png index c706e71b0f..43d8781239 100644 Binary files a/playwright/snapshots/left-panel/room-list-panel/room-list-header.spec.ts/room-list-header-space-menu-linux.png and b/playwright/snapshots/left-panel/room-list-panel/room-list-header.spec.ts/room-list-header-space-menu-linux.png differ diff --git a/playwright/snapshots/left-panel/room-list-panel/room-list-header.spec.ts/room-list-space-header-linux.png b/playwright/snapshots/left-panel/room-list-panel/room-list-header.spec.ts/room-list-space-header-linux.png index 3a4aea566e..c42c449281 100644 Binary files a/playwright/snapshots/left-panel/room-list-panel/room-list-header.spec.ts/room-list-space-header-linux.png and b/playwright/snapshots/left-panel/room-list-panel/room-list-header.spec.ts/room-list-space-header-linux.png differ diff --git a/playwright/snapshots/left-panel/room-list-panel/room-list-panel.spec.ts/room-list-panel-linux.png b/playwright/snapshots/left-panel/room-list-panel/room-list-panel.spec.ts/room-list-panel-linux.png index 9b130b73c4..3795176be2 100644 Binary files a/playwright/snapshots/left-panel/room-list-panel/room-list-panel.spec.ts/room-list-panel-linux.png and b/playwright/snapshots/left-panel/room-list-panel/room-list-panel.spec.ts/room-list-panel-linux.png differ diff --git a/playwright/snapshots/left-panel/room-list-panel/room-list-panel.spec.ts/room-list-panel-smallscreen-linux.png b/playwright/snapshots/left-panel/room-list-panel/room-list-panel.spec.ts/room-list-panel-smallscreen-linux.png index 92b81245a2..f21e92a373 100644 Binary files a/playwright/snapshots/left-panel/room-list-panel/room-list-panel.spec.ts/room-list-panel-smallscreen-linux.png and b/playwright/snapshots/left-panel/room-list-panel/room-list-panel.spec.ts/room-list-panel-smallscreen-linux.png differ diff --git a/playwright/snapshots/left-panel/room-list-panel/room-list-search.spec.ts/search-section-linux.png b/playwright/snapshots/left-panel/room-list-panel/room-list-search.spec.ts/search-section-linux.png index 123cf37586..df3e924e4c 100644 Binary files a/playwright/snapshots/left-panel/room-list-panel/room-list-search.spec.ts/search-section-linux.png and b/playwright/snapshots/left-panel/room-list-panel/room-list-search.spec.ts/search-section-linux.png differ diff --git a/playwright/snapshots/left-panel/room-list-panel/room-list.spec.ts/room-list-item-activity-linux.png b/playwright/snapshots/left-panel/room-list-panel/room-list.spec.ts/room-list-item-activity-linux.png index aa73d79988..ac0ee1ad6c 100644 Binary files a/playwright/snapshots/left-panel/room-list-panel/room-list.spec.ts/room-list-item-activity-linux.png and b/playwright/snapshots/left-panel/room-list-panel/room-list.spec.ts/room-list-item-activity-linux.png differ diff --git a/playwright/snapshots/left-panel/room-list-panel/room-list.spec.ts/room-list-item-hover-linux.png b/playwright/snapshots/left-panel/room-list-panel/room-list.spec.ts/room-list-item-hover-linux.png index fba408c922..c3b5f34feb 100644 Binary files a/playwright/snapshots/left-panel/room-list-panel/room-list.spec.ts/room-list-item-hover-linux.png and b/playwright/snapshots/left-panel/room-list-panel/room-list.spec.ts/room-list-item-hover-linux.png differ diff --git a/playwright/snapshots/left-panel/room-list-panel/room-list.spec.ts/room-list-item-hover-silent-linux.png b/playwright/snapshots/left-panel/room-list-panel/room-list.spec.ts/room-list-item-hover-silent-linux.png index 36b7304a01..bc2085b205 100644 Binary files a/playwright/snapshots/left-panel/room-list-panel/room-list.spec.ts/room-list-item-hover-silent-linux.png and b/playwright/snapshots/left-panel/room-list-panel/room-list.spec.ts/room-list-item-hover-silent-linux.png differ diff --git a/playwright/snapshots/left-panel/room-list-panel/room-list.spec.ts/room-list-item-invited-linux.png b/playwright/snapshots/left-panel/room-list-panel/room-list.spec.ts/room-list-item-invited-linux.png index 1f2b691b4a..b024c0729c 100644 Binary files a/playwright/snapshots/left-panel/room-list-panel/room-list.spec.ts/room-list-item-invited-linux.png and b/playwright/snapshots/left-panel/room-list-panel/room-list.spec.ts/room-list-item-invited-linux.png differ diff --git a/playwright/snapshots/left-panel/room-list-panel/room-list.spec.ts/room-list-item-low-priority-linux.png b/playwright/snapshots/left-panel/room-list-panel/room-list.spec.ts/room-list-item-low-priority-linux.png new file mode 100644 index 0000000000..c9dd4b6bd8 Binary files /dev/null and b/playwright/snapshots/left-panel/room-list-panel/room-list.spec.ts/room-list-item-low-priority-linux.png differ diff --git a/playwright/snapshots/left-panel/room-list-panel/room-list.spec.ts/room-list-item-mark-as-unread-linux.png b/playwright/snapshots/left-panel/room-list-panel/room-list.spec.ts/room-list-item-mark-as-unread-linux.png index 310912e50d..ee778c6871 100644 Binary files a/playwright/snapshots/left-panel/room-list-panel/room-list.spec.ts/room-list-item-mark-as-unread-linux.png and b/playwright/snapshots/left-panel/room-list-panel/room-list.spec.ts/room-list-item-mark-as-unread-linux.png differ diff --git a/playwright/snapshots/left-panel/room-list-panel/room-list.spec.ts/room-list-item-mention-linux.png b/playwright/snapshots/left-panel/room-list-panel/room-list.spec.ts/room-list-item-mention-linux.png index 9fa531f5b1..4b3c7fc1d1 100644 Binary files a/playwright/snapshots/left-panel/room-list-panel/room-list.spec.ts/room-list-item-mention-linux.png and b/playwright/snapshots/left-panel/room-list-panel/room-list.spec.ts/room-list-item-mention-linux.png differ diff --git a/playwright/snapshots/left-panel/room-list-panel/room-list.spec.ts/room-list-item-message-preview-linux.png b/playwright/snapshots/left-panel/room-list-panel/room-list.spec.ts/room-list-item-message-preview-linux.png index dac349eb2d..7042a7078e 100644 Binary files a/playwright/snapshots/left-panel/room-list-panel/room-list.spec.ts/room-list-item-message-preview-linux.png and b/playwright/snapshots/left-panel/room-list-panel/room-list.spec.ts/room-list-item-message-preview-linux.png differ diff --git a/playwright/snapshots/left-panel/room-list-panel/room-list.spec.ts/room-list-item-notification-linux.png b/playwright/snapshots/left-panel/room-list-panel/room-list.spec.ts/room-list-item-notification-linux.png index 144604ffeb..4a92165c46 100644 Binary files a/playwright/snapshots/left-panel/room-list-panel/room-list.spec.ts/room-list-item-notification-linux.png and b/playwright/snapshots/left-panel/room-list-panel/room-list.spec.ts/room-list-item-notification-linux.png differ diff --git a/playwright/snapshots/left-panel/room-list-panel/room-list.spec.ts/room-list-item-open-more-options-linux.png b/playwright/snapshots/left-panel/room-list-panel/room-list.spec.ts/room-list-item-open-more-options-linux.png index 99bb312695..b44e61b3eb 100644 Binary files a/playwright/snapshots/left-panel/room-list-panel/room-list.spec.ts/room-list-item-open-more-options-linux.png and b/playwright/snapshots/left-panel/room-list-panel/room-list.spec.ts/room-list-item-open-more-options-linux.png differ diff --git a/playwright/snapshots/left-panel/room-list-panel/room-list.spec.ts/room-list-item-open-notification-options-linux.png b/playwright/snapshots/left-panel/room-list-panel/room-list.spec.ts/room-list-item-open-notification-options-linux.png index c91ebf3b3a..c182c853b9 100644 Binary files a/playwright/snapshots/left-panel/room-list-panel/room-list.spec.ts/room-list-item-open-notification-options-linux.png and b/playwright/snapshots/left-panel/room-list-panel/room-list.spec.ts/room-list-item-open-notification-options-linux.png differ diff --git a/playwright/snapshots/left-panel/room-list-panel/room-list.spec.ts/room-list-item-open-notification-options-selection-linux.png b/playwright/snapshots/left-panel/room-list-panel/room-list.spec.ts/room-list-item-open-notification-options-selection-linux.png index 4b3ac052ca..950bc3a0eb 100644 Binary files a/playwright/snapshots/left-panel/room-list-panel/room-list.spec.ts/room-list-item-open-notification-options-selection-linux.png and b/playwright/snapshots/left-panel/room-list-panel/room-list.spec.ts/room-list-item-open-notification-options-selection-linux.png differ diff --git a/playwright/snapshots/left-panel/room-list-panel/room-list.spec.ts/room-list-item-public-linux.png b/playwright/snapshots/left-panel/room-list-panel/room-list.spec.ts/room-list-item-public-linux.png index e951f77ef2..ad64e0c526 100644 Binary files a/playwright/snapshots/left-panel/room-list-panel/room-list.spec.ts/room-list-item-public-linux.png and b/playwright/snapshots/left-panel/room-list-panel/room-list.spec.ts/room-list-item-public-linux.png differ diff --git a/playwright/snapshots/left-panel/room-list-panel/room-list.spec.ts/room-list-item-silent-linux.png b/playwright/snapshots/left-panel/room-list-panel/room-list.spec.ts/room-list-item-silent-linux.png index 57c5cb1eb7..d9deb6cb1c 100644 Binary files a/playwright/snapshots/left-panel/room-list-panel/room-list.spec.ts/room-list-item-silent-linux.png and b/playwright/snapshots/left-panel/room-list-panel/room-list.spec.ts/room-list-item-silent-linux.png differ diff --git a/playwright/snapshots/left-panel/room-list-panel/room-list.spec.ts/room-list-item-video-linux.png b/playwright/snapshots/left-panel/room-list-panel/room-list.spec.ts/room-list-item-video-linux.png index 16ea458274..c3d6f5f952 100644 Binary files a/playwright/snapshots/left-panel/room-list-panel/room-list.spec.ts/room-list-item-video-linux.png and b/playwright/snapshots/left-panel/room-list-panel/room-list.spec.ts/room-list-item-video-linux.png differ diff --git a/playwright/snapshots/left-panel/room-list-panel/room-list.spec.ts/room-list-linux.png b/playwright/snapshots/left-panel/room-list-panel/room-list.spec.ts/room-list-linux.png index f2b625f498..896af9eff7 100644 Binary files a/playwright/snapshots/left-panel/room-list-panel/room-list.spec.ts/room-list-linux.png and b/playwright/snapshots/left-panel/room-list-panel/room-list.spec.ts/room-list-linux.png differ diff --git a/playwright/snapshots/left-panel/room-list-panel/room-list.spec.ts/room-list-scrolled-linux.png b/playwright/snapshots/left-panel/room-list-panel/room-list.spec.ts/room-list-scrolled-linux.png index a1752e30d3..81f3af0cb6 100644 Binary files a/playwright/snapshots/left-panel/room-list-panel/room-list.spec.ts/room-list-scrolled-linux.png and b/playwright/snapshots/left-panel/room-list-panel/room-list.spec.ts/room-list-scrolled-linux.png differ diff --git a/playwright/snapshots/location/location.spec.ts/location-live-share-dialog-linux.png b/playwright/snapshots/location/location.spec.ts/location-live-share-dialog-linux.png new file mode 100644 index 0000000000..13ba504919 Binary files /dev/null and b/playwright/snapshots/location/location.spec.ts/location-live-share-dialog-linux.png differ diff --git a/playwright/snapshots/location/location.spec.ts/location-pin-drop-message-map-linux.png b/playwright/snapshots/location/location.spec.ts/location-pin-drop-message-map-linux.png new file mode 100644 index 0000000000..36632e6a61 Binary files /dev/null and b/playwright/snapshots/location/location.spec.ts/location-pin-drop-message-map-linux.png differ diff --git a/playwright/snapshots/messages/messages.spec.ts/basic-message-ltr-ltrdisplayname-linux.png b/playwright/snapshots/messages/messages.spec.ts/basic-message-ltr-ltrdisplayname-linux.png index 9831051768..9e26f29f74 100644 Binary files a/playwright/snapshots/messages/messages.spec.ts/basic-message-ltr-ltrdisplayname-linux.png and b/playwright/snapshots/messages/messages.spec.ts/basic-message-ltr-ltrdisplayname-linux.png differ diff --git a/playwright/snapshots/messages/messages.spec.ts/basic-message-ltr-rtldisplayname-linux.png b/playwright/snapshots/messages/messages.spec.ts/basic-message-ltr-rtldisplayname-linux.png index 35523b7db8..5e6fc8dd6f 100644 Binary files a/playwright/snapshots/messages/messages.spec.ts/basic-message-ltr-rtldisplayname-linux.png and b/playwright/snapshots/messages/messages.spec.ts/basic-message-ltr-rtldisplayname-linux.png differ diff --git a/playwright/snapshots/messages/messages.spec.ts/basic-message-rtl-ltrdisplayname-linux.png b/playwright/snapshots/messages/messages.spec.ts/basic-message-rtl-ltrdisplayname-linux.png index 9f31f518fc..199daa5e5e 100644 Binary files a/playwright/snapshots/messages/messages.spec.ts/basic-message-rtl-ltrdisplayname-linux.png and b/playwright/snapshots/messages/messages.spec.ts/basic-message-rtl-ltrdisplayname-linux.png differ diff --git a/playwright/snapshots/messages/messages.spec.ts/basic-message-rtl-rtldisplayname-linux.png b/playwright/snapshots/messages/messages.spec.ts/basic-message-rtl-rtldisplayname-linux.png index 40a096409e..362930195d 100644 Binary files a/playwright/snapshots/messages/messages.spec.ts/basic-message-rtl-rtldisplayname-linux.png and b/playwright/snapshots/messages/messages.spec.ts/basic-message-rtl-rtldisplayname-linux.png differ diff --git a/playwright/snapshots/messages/messages.spec.ts/edited-message-ltr-ltrdisplayname-linux.png b/playwright/snapshots/messages/messages.spec.ts/edited-message-ltr-ltrdisplayname-linux.png index d8b02a028b..0e3826f022 100644 Binary files a/playwright/snapshots/messages/messages.spec.ts/edited-message-ltr-ltrdisplayname-linux.png and b/playwright/snapshots/messages/messages.spec.ts/edited-message-ltr-ltrdisplayname-linux.png differ diff --git a/playwright/snapshots/messages/messages.spec.ts/edited-message-ltr-rtldisplayname-linux.png b/playwright/snapshots/messages/messages.spec.ts/edited-message-ltr-rtldisplayname-linux.png index 557613e7e6..314d4e8938 100644 Binary files a/playwright/snapshots/messages/messages.spec.ts/edited-message-ltr-rtldisplayname-linux.png and b/playwright/snapshots/messages/messages.spec.ts/edited-message-ltr-rtldisplayname-linux.png differ diff --git a/playwright/snapshots/messages/messages.spec.ts/edited-message-rtl-ltrdisplayname-linux.png b/playwright/snapshots/messages/messages.spec.ts/edited-message-rtl-ltrdisplayname-linux.png index 85a3c69c0e..4f4266737f 100644 Binary files a/playwright/snapshots/messages/messages.spec.ts/edited-message-rtl-ltrdisplayname-linux.png and b/playwright/snapshots/messages/messages.spec.ts/edited-message-rtl-ltrdisplayname-linux.png differ diff --git a/playwright/snapshots/messages/messages.spec.ts/edited-message-rtl-rtldisplayname-linux.png b/playwright/snapshots/messages/messages.spec.ts/edited-message-rtl-rtldisplayname-linux.png index bd26e84628..fa17ddf3f5 100644 Binary files a/playwright/snapshots/messages/messages.spec.ts/edited-message-rtl-rtldisplayname-linux.png and b/playwright/snapshots/messages/messages.spec.ts/edited-message-rtl-rtldisplayname-linux.png differ diff --git a/playwright/snapshots/messages/messages.spec.ts/emote-ltr-ltrdisplayname-linux.png b/playwright/snapshots/messages/messages.spec.ts/emote-ltr-ltrdisplayname-linux.png index 5ab0a7e88f..d7325c874d 100644 Binary files a/playwright/snapshots/messages/messages.spec.ts/emote-ltr-ltrdisplayname-linux.png and b/playwright/snapshots/messages/messages.spec.ts/emote-ltr-ltrdisplayname-linux.png differ diff --git a/playwright/snapshots/messages/messages.spec.ts/emote-ltr-rtldisplayname-linux.png b/playwright/snapshots/messages/messages.spec.ts/emote-ltr-rtldisplayname-linux.png index 055ad23a81..4b45b09a90 100644 Binary files a/playwright/snapshots/messages/messages.spec.ts/emote-ltr-rtldisplayname-linux.png and b/playwright/snapshots/messages/messages.spec.ts/emote-ltr-rtldisplayname-linux.png differ diff --git a/playwright/snapshots/messages/messages.spec.ts/emote-rich-ltr-ltrdisplayname-linux.png b/playwright/snapshots/messages/messages.spec.ts/emote-rich-ltr-ltrdisplayname-linux.png index c3501583f8..eb95e95298 100644 Binary files a/playwright/snapshots/messages/messages.spec.ts/emote-rich-ltr-ltrdisplayname-linux.png and b/playwright/snapshots/messages/messages.spec.ts/emote-rich-ltr-ltrdisplayname-linux.png differ diff --git a/playwright/snapshots/messages/messages.spec.ts/emote-rich-ltr-rtldisplayname-linux.png b/playwright/snapshots/messages/messages.spec.ts/emote-rich-ltr-rtldisplayname-linux.png index cfb905b689..c1c8bc34fc 100644 Binary files a/playwright/snapshots/messages/messages.spec.ts/emote-rich-ltr-rtldisplayname-linux.png and b/playwright/snapshots/messages/messages.spec.ts/emote-rich-ltr-rtldisplayname-linux.png differ diff --git a/playwright/snapshots/messages/messages.spec.ts/emote-rich-rtl-ltrdisplayname-linux.png b/playwright/snapshots/messages/messages.spec.ts/emote-rich-rtl-ltrdisplayname-linux.png index 537f1dd2c4..e5af698b56 100644 Binary files a/playwright/snapshots/messages/messages.spec.ts/emote-rich-rtl-ltrdisplayname-linux.png and b/playwright/snapshots/messages/messages.spec.ts/emote-rich-rtl-ltrdisplayname-linux.png differ diff --git a/playwright/snapshots/messages/messages.spec.ts/emote-rich-rtl-rtldisplayname-linux.png b/playwright/snapshots/messages/messages.spec.ts/emote-rich-rtl-rtldisplayname-linux.png index ea29e98a75..df4d6c0642 100644 Binary files a/playwright/snapshots/messages/messages.spec.ts/emote-rich-rtl-rtldisplayname-linux.png and b/playwright/snapshots/messages/messages.spec.ts/emote-rich-rtl-rtldisplayname-linux.png differ diff --git a/playwright/snapshots/messages/messages.spec.ts/emote-rtl-ltrdisplayname-linux.png b/playwright/snapshots/messages/messages.spec.ts/emote-rtl-ltrdisplayname-linux.png index 27357dc503..fae90a16b3 100644 Binary files a/playwright/snapshots/messages/messages.spec.ts/emote-rtl-ltrdisplayname-linux.png and b/playwright/snapshots/messages/messages.spec.ts/emote-rtl-ltrdisplayname-linux.png differ diff --git a/playwright/snapshots/messages/messages.spec.ts/emote-rtl-rtldisplayname-linux.png b/playwright/snapshots/messages/messages.spec.ts/emote-rtl-rtldisplayname-linux.png index d84780f530..95531ecaf8 100644 Binary files a/playwright/snapshots/messages/messages.spec.ts/emote-rtl-rtldisplayname-linux.png and b/playwright/snapshots/messages/messages.spec.ts/emote-rtl-rtldisplayname-linux.png differ diff --git a/playwright/snapshots/messages/messages.spec.ts/reply-message-ltr-ltrdisplayname-linux.png b/playwright/snapshots/messages/messages.spec.ts/reply-message-ltr-ltrdisplayname-linux.png index a4de383fba..4e93ab419e 100644 Binary files a/playwright/snapshots/messages/messages.spec.ts/reply-message-ltr-ltrdisplayname-linux.png and b/playwright/snapshots/messages/messages.spec.ts/reply-message-ltr-ltrdisplayname-linux.png differ diff --git a/playwright/snapshots/messages/messages.spec.ts/reply-message-ltr-rtldisplayname-linux.png b/playwright/snapshots/messages/messages.spec.ts/reply-message-ltr-rtldisplayname-linux.png index b59d960f4f..0a65531909 100644 Binary files a/playwright/snapshots/messages/messages.spec.ts/reply-message-ltr-rtldisplayname-linux.png and b/playwright/snapshots/messages/messages.spec.ts/reply-message-ltr-rtldisplayname-linux.png differ diff --git a/playwright/snapshots/messages/messages.spec.ts/reply-message-trl-ltrdisplayname-linux.png b/playwright/snapshots/messages/messages.spec.ts/reply-message-trl-ltrdisplayname-linux.png index a3acc741f3..067fab6cf9 100644 Binary files a/playwright/snapshots/messages/messages.spec.ts/reply-message-trl-ltrdisplayname-linux.png and b/playwright/snapshots/messages/messages.spec.ts/reply-message-trl-ltrdisplayname-linux.png differ diff --git a/playwright/snapshots/messages/messages.spec.ts/reply-message-trl-rtldisplayname-linux.png b/playwright/snapshots/messages/messages.spec.ts/reply-message-trl-rtldisplayname-linux.png index f6eaea241a..a1c1f4aea7 100644 Binary files a/playwright/snapshots/messages/messages.spec.ts/reply-message-trl-rtldisplayname-linux.png and b/playwright/snapshots/messages/messages.spec.ts/reply-message-trl-rtldisplayname-linux.png differ diff --git a/playwright/snapshots/mobile-guide/mobile-guide.spec.ts/mobile-guide-element-classic-linux.png b/playwright/snapshots/mobile-guide/mobile-guide.spec.ts/mobile-guide-element-classic-linux.png new file mode 100644 index 0000000000..f091eeed74 Binary files /dev/null and b/playwright/snapshots/mobile-guide/mobile-guide.spec.ts/mobile-guide-element-classic-linux.png differ diff --git a/playwright/snapshots/mobile-guide/mobile-guide.spec.ts/mobile-guide-element-linux.png b/playwright/snapshots/mobile-guide/mobile-guide.spec.ts/mobile-guide-element-linux.png new file mode 100644 index 0000000000..9c32731ab7 Binary files /dev/null and b/playwright/snapshots/mobile-guide/mobile-guide.spec.ts/mobile-guide-element-linux.png differ diff --git a/playwright/snapshots/mobile-guide/mobile-guide.spec.ts/mobile-guide-element-pro-linux.png b/playwright/snapshots/mobile-guide/mobile-guide.spec.ts/mobile-guide-element-pro-linux.png new file mode 100644 index 0000000000..ff1bb69a4f Binary files /dev/null and b/playwright/snapshots/mobile-guide/mobile-guide.spec.ts/mobile-guide-element-pro-linux.png differ diff --git a/playwright/snapshots/modules/custom-component.spec.ts/custom-component-crash-handle-filter-linux.png b/playwright/snapshots/modules/custom-component.spec.ts/custom-component-crash-handle-filter-linux.png new file mode 100644 index 0000000000..ccf6439b65 Binary files /dev/null and b/playwright/snapshots/modules/custom-component.spec.ts/custom-component-crash-handle-filter-linux.png differ diff --git a/playwright/snapshots/modules/custom-component.spec.ts/custom-component-crash-handle-renderer-linux.png b/playwright/snapshots/modules/custom-component.spec.ts/custom-component-crash-handle-renderer-linux.png new file mode 100644 index 0000000000..479f2d34ad Binary files /dev/null and b/playwright/snapshots/modules/custom-component.spec.ts/custom-component-crash-handle-renderer-linux.png differ diff --git a/playwright/snapshots/modules/custom-component.spec.ts/custom-component-tile-fall-through-linux.png b/playwright/snapshots/modules/custom-component.spec.ts/custom-component-tile-fall-through-linux.png new file mode 100644 index 0000000000..4198fed325 Binary files /dev/null and b/playwright/snapshots/modules/custom-component.spec.ts/custom-component-tile-fall-through-linux.png differ diff --git a/playwright/snapshots/modules/custom-component.spec.ts/custom-component-tile-linux.png b/playwright/snapshots/modules/custom-component.spec.ts/custom-component-tile-linux.png new file mode 100644 index 0000000000..baf7bf4ef2 Binary files /dev/null and b/playwright/snapshots/modules/custom-component.spec.ts/custom-component-tile-linux.png differ diff --git a/playwright/snapshots/modules/custom-component.spec.ts/custom-component-tile-original-linux.png b/playwright/snapshots/modules/custom-component.spec.ts/custom-component-tile-original-linux.png new file mode 100644 index 0000000000..5ac5509641 Binary files /dev/null and b/playwright/snapshots/modules/custom-component.spec.ts/custom-component-tile-original-linux.png differ diff --git a/playwright/snapshots/permalinks/permalinks.spec.ts/permalink-rendering-linux.png b/playwright/snapshots/permalinks/permalinks.spec.ts/permalink-rendering-linux.png index b386eaa564..760e2b181a 100644 Binary files a/playwright/snapshots/permalinks/permalinks.spec.ts/permalink-rendering-linux.png and b/playwright/snapshots/permalinks/permalinks.spec.ts/permalink-rendering-linux.png differ diff --git a/playwright/snapshots/pinned-messages/pinned-messages.spec.ts/pinned-message-Msg1-linux.png b/playwright/snapshots/pinned-messages/pinned-messages.spec.ts/pinned-message-Msg1-linux.png index f39caf654a..fc82f24778 100644 Binary files a/playwright/snapshots/pinned-messages/pinned-messages.spec.ts/pinned-message-Msg1-linux.png and b/playwright/snapshots/pinned-messages/pinned-messages.spec.ts/pinned-message-Msg1-linux.png differ diff --git a/playwright/snapshots/pinned-messages/pinned-messages.spec.ts/pinned-message-banner-1-Msg1-linux.png b/playwright/snapshots/pinned-messages/pinned-messages.spec.ts/pinned-message-banner-1-Msg1-linux.png index ac13a2152d..e240565e11 100644 Binary files a/playwright/snapshots/pinned-messages/pinned-messages.spec.ts/pinned-message-banner-1-Msg1-linux.png and b/playwright/snapshots/pinned-messages/pinned-messages.spec.ts/pinned-message-banner-1-Msg1-linux.png differ diff --git a/playwright/snapshots/pinned-messages/pinned-messages.spec.ts/pinned-message-banner-2-Msg1-linux.png b/playwright/snapshots/pinned-messages/pinned-messages.spec.ts/pinned-message-banner-2-Msg1-linux.png index 20917ae16f..a22ae313ae 100644 Binary files a/playwright/snapshots/pinned-messages/pinned-messages.spec.ts/pinned-message-banner-2-Msg1-linux.png and b/playwright/snapshots/pinned-messages/pinned-messages.spec.ts/pinned-message-banner-2-Msg1-linux.png differ diff --git a/playwright/snapshots/pinned-messages/pinned-messages.spec.ts/pinned-message-banner-2-Msg2-linux.png b/playwright/snapshots/pinned-messages/pinned-messages.spec.ts/pinned-message-banner-2-Msg2-linux.png index aaf8c720eb..4f635ff5fd 100644 Binary files a/playwright/snapshots/pinned-messages/pinned-messages.spec.ts/pinned-message-banner-2-Msg2-linux.png and b/playwright/snapshots/pinned-messages/pinned-messages.spec.ts/pinned-message-banner-2-Msg2-linux.png differ diff --git a/playwright/snapshots/pinned-messages/pinned-messages.spec.ts/pinned-message-banner-4-Msg1-linux.png b/playwright/snapshots/pinned-messages/pinned-messages.spec.ts/pinned-message-banner-4-Msg1-linux.png index 7ef356c589..0c544e58cb 100644 Binary files a/playwright/snapshots/pinned-messages/pinned-messages.spec.ts/pinned-message-banner-4-Msg1-linux.png and b/playwright/snapshots/pinned-messages/pinned-messages.spec.ts/pinned-message-banner-4-Msg1-linux.png differ diff --git a/playwright/snapshots/pinned-messages/pinned-messages.spec.ts/pinned-message-banner-4-Msg2-linux.png b/playwright/snapshots/pinned-messages/pinned-messages.spec.ts/pinned-message-banner-4-Msg2-linux.png index a62d8182b1..f9df40b66d 100644 Binary files a/playwright/snapshots/pinned-messages/pinned-messages.spec.ts/pinned-message-banner-4-Msg2-linux.png and b/playwright/snapshots/pinned-messages/pinned-messages.spec.ts/pinned-message-banner-4-Msg2-linux.png differ diff --git a/playwright/snapshots/pinned-messages/pinned-messages.spec.ts/pinned-message-banner-4-Msg3-linux.png b/playwright/snapshots/pinned-messages/pinned-messages.spec.ts/pinned-message-banner-4-Msg3-linux.png index fa548aaadc..969df22f36 100644 Binary files a/playwright/snapshots/pinned-messages/pinned-messages.spec.ts/pinned-message-banner-4-Msg3-linux.png and b/playwright/snapshots/pinned-messages/pinned-messages.spec.ts/pinned-message-banner-4-Msg3-linux.png differ diff --git a/playwright/snapshots/pinned-messages/pinned-messages.spec.ts/pinned-message-banner-4-Msg4-linux.png b/playwright/snapshots/pinned-messages/pinned-messages.spec.ts/pinned-message-banner-4-Msg4-linux.png index bc2062f98d..8af50bb4e5 100644 Binary files a/playwright/snapshots/pinned-messages/pinned-messages.spec.ts/pinned-message-banner-4-Msg4-linux.png and b/playwright/snapshots/pinned-messages/pinned-messages.spec.ts/pinned-message-banner-4-Msg4-linux.png differ diff --git a/playwright/snapshots/polls/polls.spec.ts/Polls-Timeline-tile-no-votes-linux.png b/playwright/snapshots/polls/polls.spec.ts/Polls-Timeline-tile-no-votes-linux.png index 7b7f296be1..39e39de944 100644 Binary files a/playwright/snapshots/polls/polls.spec.ts/Polls-Timeline-tile-no-votes-linux.png and b/playwright/snapshots/polls/polls.spec.ts/Polls-Timeline-tile-no-votes-linux.png differ diff --git a/playwright/snapshots/polls/polls.spec.ts/ThreadView-with-a-poll-on-bubble-layout-linux.png b/playwright/snapshots/polls/polls.spec.ts/ThreadView-with-a-poll-on-bubble-layout-linux.png index 0a4abba833..b974688d7b 100644 Binary files a/playwright/snapshots/polls/polls.spec.ts/ThreadView-with-a-poll-on-bubble-layout-linux.png and b/playwright/snapshots/polls/polls.spec.ts/ThreadView-with-a-poll-on-bubble-layout-linux.png differ diff --git a/playwright/snapshots/polls/polls.spec.ts/ThreadView-with-a-poll-on-group-layout-linux.png b/playwright/snapshots/polls/polls.spec.ts/ThreadView-with-a-poll-on-group-layout-linux.png index f00047fe84..3af5214e43 100644 Binary files a/playwright/snapshots/polls/polls.spec.ts/ThreadView-with-a-poll-on-group-layout-linux.png and b/playwright/snapshots/polls/polls.spec.ts/ThreadView-with-a-poll-on-group-layout-linux.png differ diff --git a/playwright/snapshots/register/email.spec.ts/registration-check-your-email-linux.png b/playwright/snapshots/register/email.spec.ts/registration-check-your-email-linux.png index fc52c2cb65..c958717a58 100644 Binary files a/playwright/snapshots/register/email.spec.ts/registration-check-your-email-linux.png and b/playwright/snapshots/register/email.spec.ts/registration-check-your-email-linux.png differ diff --git a/playwright/snapshots/register/register.spec.ts/email-prompt-linux.png b/playwright/snapshots/register/register.spec.ts/email-prompt-linux.png index 9bc2ed101b..e49db5d408 100644 Binary files a/playwright/snapshots/register/register.spec.ts/email-prompt-linux.png and b/playwright/snapshots/register/register.spec.ts/email-prompt-linux.png differ diff --git a/playwright/snapshots/register/register.spec.ts/registration-linux.png b/playwright/snapshots/register/register.spec.ts/registration-linux.png index 481c74fcda..1cca0eb56b 100644 Binary files a/playwright/snapshots/register/register.spec.ts/registration-linux.png and b/playwright/snapshots/register/register.spec.ts/registration-linux.png differ diff --git a/playwright/snapshots/register/register.spec.ts/server-picker-linux.png b/playwright/snapshots/register/register.spec.ts/server-picker-linux.png index db3ec188d1..65bf54fc88 100644 Binary files a/playwright/snapshots/register/register.spec.ts/server-picker-linux.png and b/playwright/snapshots/register/register.spec.ts/server-picker-linux.png differ diff --git a/playwright/snapshots/register/register.spec.ts/terms-prompt-linux.png b/playwright/snapshots/register/register.spec.ts/terms-prompt-linux.png index f5326e9d2d..92839f2ad9 100644 Binary files a/playwright/snapshots/register/register.spec.ts/terms-prompt-linux.png and b/playwright/snapshots/register/register.spec.ts/terms-prompt-linux.png differ diff --git a/playwright/snapshots/release-announcement/releaseAnnouncement.spec.ts/release-announcement-All-new-pinned-messages-linux.png b/playwright/snapshots/release-announcement/releaseAnnouncement.spec.ts/release-announcement-All-new-pinned-messages-linux.png deleted file mode 100644 index f466c17d64..0000000000 Binary files a/playwright/snapshots/release-announcement/releaseAnnouncement.spec.ts/release-announcement-All-new-pinned-messages-linux.png and /dev/null differ diff --git a/playwright/snapshots/right-panel/file-panel.spec.ts/file-tiles-list-linux.png b/playwright/snapshots/right-panel/file-panel.spec.ts/file-tiles-list-linux.png index 1c75a92373..34a415dc21 100644 Binary files a/playwright/snapshots/right-panel/file-panel.spec.ts/file-tiles-list-linux.png and b/playwright/snapshots/right-panel/file-panel.spec.ts/file-tiles-list-linux.png differ diff --git a/playwright/snapshots/right-panel/memberlist.spec.ts/with-four-members-linux.png b/playwright/snapshots/right-panel/memberlist.spec.ts/with-four-members-linux.png index a5db88aae6..ab5b0ea76c 100644 Binary files a/playwright/snapshots/right-panel/memberlist.spec.ts/with-four-members-linux.png and b/playwright/snapshots/right-panel/memberlist.spec.ts/with-four-members-linux.png differ diff --git a/playwright/snapshots/right-panel/right-panel.spec.ts/room-report-dialog-linux.png b/playwright/snapshots/right-panel/right-panel.spec.ts/room-report-dialog-linux.png index 627071591c..573d63416d 100644 Binary files a/playwright/snapshots/right-panel/right-panel.spec.ts/room-report-dialog-linux.png and b/playwright/snapshots/right-panel/right-panel.spec.ts/room-report-dialog-linux.png differ diff --git a/playwright/snapshots/right-panel/right-panel.spec.ts/with-long-name-linux.png b/playwright/snapshots/right-panel/right-panel.spec.ts/with-long-name-linux.png index 9a3479a1e6..b1f73126c8 100644 Binary files a/playwright/snapshots/right-panel/right-panel.spec.ts/with-long-name-linux.png and b/playwright/snapshots/right-panel/right-panel.spec.ts/with-long-name-linux.png differ diff --git a/playwright/snapshots/right-panel/right-panel.spec.ts/with-name-and-address-linux.png b/playwright/snapshots/right-panel/right-panel.spec.ts/with-name-and-address-linux.png index 8ba6f98e11..0645b780b8 100644 Binary files a/playwright/snapshots/right-panel/right-panel.spec.ts/with-name-and-address-linux.png and b/playwright/snapshots/right-panel/right-panel.spec.ts/with-name-and-address-linux.png differ diff --git a/playwright/snapshots/room-directory/room-directory.spec.ts/filtered-no-results-linux.png b/playwright/snapshots/room-directory/room-directory.spec.ts/filtered-no-results-linux.png index 783e468c6e..e02a1a24cf 100644 Binary files a/playwright/snapshots/room-directory/room-directory.spec.ts/filtered-no-results-linux.png and b/playwright/snapshots/room-directory/room-directory.spec.ts/filtered-no-results-linux.png differ diff --git a/playwright/snapshots/room-directory/room-directory.spec.ts/filtered-one-result-linux.png b/playwright/snapshots/room-directory/room-directory.spec.ts/filtered-one-result-linux.png index aaa504f4e3..213cdb1d87 100644 Binary files a/playwright/snapshots/room-directory/room-directory.spec.ts/filtered-one-result-linux.png and b/playwright/snapshots/room-directory/room-directory.spec.ts/filtered-one-result-linux.png differ diff --git a/playwright/snapshots/room/create-room.spec.ts/create-room-linux.png b/playwright/snapshots/room/create-room.spec.ts/create-room-linux.png new file mode 100644 index 0000000000..10fe7a381f Binary files /dev/null and b/playwright/snapshots/room/create-room.spec.ts/create-room-linux.png differ diff --git a/playwright/snapshots/room/create-room.spec.ts/create-room-no-public-linux.png b/playwright/snapshots/room/create-room.spec.ts/create-room-no-public-linux.png new file mode 100644 index 0000000000..7cd35b0050 Binary files /dev/null and b/playwright/snapshots/room/create-room.spec.ts/create-room-no-public-linux.png differ diff --git a/playwright/snapshots/room/create-room.spec.ts/create-video-room-linux.png b/playwright/snapshots/room/create-room.spec.ts/create-video-room-linux.png new file mode 100644 index 0000000000..a8fe2e9e64 Binary files /dev/null and b/playwright/snapshots/room/create-room.spec.ts/create-video-room-linux.png differ diff --git a/playwright/snapshots/room/invites.spec.ts/Invites-reject-dialog-linux.png b/playwright/snapshots/room/invites.spec.ts/Invites-reject-dialog-linux.png index 22ef895d1a..ce50be25a2 100644 Binary files a/playwright/snapshots/room/invites.spec.ts/Invites-reject-dialog-linux.png and b/playwright/snapshots/room/invites.spec.ts/Invites-reject-dialog-linux.png differ diff --git a/playwright/snapshots/room/invites.spec.ts/Invites-room-view-linux.png b/playwright/snapshots/room/invites.spec.ts/Invites-room-view-linux.png index 86d0e37b93..60487a7768 100644 Binary files a/playwright/snapshots/room/invites.spec.ts/Invites-room-view-linux.png and b/playwright/snapshots/room/invites.spec.ts/Invites-room-view-linux.png differ diff --git a/playwright/snapshots/room/room-header.spec.ts/room-header-with-icon-linux.png b/playwright/snapshots/room/room-header.spec.ts/room-header-with-icon-linux.png index 471c26ccdb..c872720f66 100644 Binary files a/playwright/snapshots/room/room-header.spec.ts/room-header-with-icon-linux.png and b/playwright/snapshots/room/room-header.spec.ts/room-header-with-icon-linux.png differ diff --git a/playwright/snapshots/room/room-status-bar.spec.ts/connectivity-lost-linux.png b/playwright/snapshots/room/room-status-bar.spec.ts/connectivity-lost-linux.png new file mode 100644 index 0000000000..106f16403c Binary files /dev/null and b/playwright/snapshots/room/room-status-bar.spec.ts/connectivity-lost-linux.png differ diff --git a/playwright/snapshots/room/room-status-bar.spec.ts/consent-linux.png b/playwright/snapshots/room/room-status-bar.spec.ts/consent-linux.png new file mode 100644 index 0000000000..13aa6a4833 Binary files /dev/null and b/playwright/snapshots/room/room-status-bar.spec.ts/consent-linux.png differ diff --git a/playwright/snapshots/room/room-status-bar.spec.ts/local-room-create-failed-linux.png b/playwright/snapshots/room/room-status-bar.spec.ts/local-room-create-failed-linux.png new file mode 100644 index 0000000000..a8fe32646e Binary files /dev/null and b/playwright/snapshots/room/room-status-bar.spec.ts/local-room-create-failed-linux.png differ diff --git a/playwright/snapshots/room/room-status-bar.spec.ts/message-failed-linux.png b/playwright/snapshots/room/room-status-bar.spec.ts/message-failed-linux.png new file mode 100644 index 0000000000..cccc5fb675 Binary files /dev/null and b/playwright/snapshots/room/room-status-bar.spec.ts/message-failed-linux.png differ diff --git a/playwright/snapshots/settings/account-user-settings-tab.spec.ts/account-linux.png b/playwright/snapshots/settings/account-user-settings-tab.spec.ts/account-linux.png index 3b4031063c..677473dded 100644 Binary files a/playwright/snapshots/settings/account-user-settings-tab.spec.ts/account-linux.png and b/playwright/snapshots/settings/account-user-settings-tab.spec.ts/account-linux.png differ diff --git a/playwright/snapshots/settings/account-user-settings-tab.spec.ts/account-smallscreen-linux.png b/playwright/snapshots/settings/account-user-settings-tab.spec.ts/account-smallscreen-linux.png index e0e46682a3..da4d594e23 100644 Binary files a/playwright/snapshots/settings/account-user-settings-tab.spec.ts/account-smallscreen-linux.png and b/playwright/snapshots/settings/account-user-settings-tab.spec.ts/account-smallscreen-linux.png differ diff --git a/playwright/snapshots/settings/appearance-user-settings-tab/appearance-user-settings-tab.spec.ts/appearance-tab-linux.png b/playwright/snapshots/settings/appearance-user-settings-tab/appearance-user-settings-tab.spec.ts/appearance-tab-linux.png index 20518942b0..5a9190aa6b 100644 Binary files a/playwright/snapshots/settings/appearance-user-settings-tab/appearance-user-settings-tab.spec.ts/appearance-tab-linux.png and b/playwright/snapshots/settings/appearance-user-settings-tab/appearance-user-settings-tab.spec.ts/appearance-tab-linux.png differ diff --git a/playwright/snapshots/settings/appearance-user-settings-tab/appearance-user-settings-tab.spec.ts/window-12px-linux.png b/playwright/snapshots/settings/appearance-user-settings-tab/appearance-user-settings-tab.spec.ts/window-12px-linux.png index a847075a4d..c86f95ee29 100644 Binary files a/playwright/snapshots/settings/appearance-user-settings-tab/appearance-user-settings-tab.spec.ts/window-12px-linux.png and b/playwright/snapshots/settings/appearance-user-settings-tab/appearance-user-settings-tab.spec.ts/window-12px-linux.png differ diff --git a/playwright/snapshots/settings/encryption-user-tab/advanced.spec.ts/encryption-details-linux.png b/playwright/snapshots/settings/encryption-user-tab/advanced.spec.ts/encryption-details-linux.png index e18821b774..438691fd44 100644 Binary files a/playwright/snapshots/settings/encryption-user-tab/advanced.spec.ts/encryption-details-linux.png and b/playwright/snapshots/settings/encryption-user-tab/advanced.spec.ts/encryption-details-linux.png differ diff --git a/playwright/snapshots/settings/encryption-user-tab/recovery.spec.ts/change-key-2-encryption-tab-linux.png b/playwright/snapshots/settings/encryption-user-tab/recovery.spec.ts/change-key-2-encryption-tab-linux.png index ec0207a227..a9f601e822 100644 Binary files a/playwright/snapshots/settings/encryption-user-tab/recovery.spec.ts/change-key-2-encryption-tab-linux.png and b/playwright/snapshots/settings/encryption-user-tab/recovery.spec.ts/change-key-2-encryption-tab-linux.png differ diff --git a/playwright/snapshots/settings/encryption-user-tab/recovery.spec.ts/set-up-key-3-encryption-tab-linux.png b/playwright/snapshots/settings/encryption-user-tab/recovery.spec.ts/set-up-key-3-encryption-tab-linux.png index cb0bc78e00..526797f712 100644 Binary files a/playwright/snapshots/settings/encryption-user-tab/recovery.spec.ts/set-up-key-3-encryption-tab-linux.png and b/playwright/snapshots/settings/encryption-user-tab/recovery.spec.ts/set-up-key-3-encryption-tab-linux.png differ diff --git a/playwright/snapshots/settings/encryption-user-tab/recovery.spec.ts/set-up-recovery-linux.png b/playwright/snapshots/settings/encryption-user-tab/recovery.spec.ts/set-up-recovery-linux.png index e26d001a90..39e74833b6 100644 Binary files a/playwright/snapshots/settings/encryption-user-tab/recovery.spec.ts/set-up-recovery-linux.png and b/playwright/snapshots/settings/encryption-user-tab/recovery.spec.ts/set-up-recovery-linux.png differ diff --git a/playwright/snapshots/settings/general-room-settings-tab.spec.ts/General-room-settings-tab-should-be-rendered-properly-1-linux.png b/playwright/snapshots/settings/general-room-settings-tab.spec.ts/General-room-settings-tab-should-be-rendered-properly-1-linux.png index c326c3f8e4..c2d1cc52a0 100644 Binary files a/playwright/snapshots/settings/general-room-settings-tab.spec.ts/General-room-settings-tab-should-be-rendered-properly-1-linux.png and b/playwright/snapshots/settings/general-room-settings-tab.spec.ts/General-room-settings-tab-should-be-rendered-properly-1-linux.png differ diff --git a/playwright/snapshots/settings/notifications/notifications-settings-2-tab.spec.ts/standard-notifications-2-settings-linux.png b/playwright/snapshots/settings/notifications/notifications-settings-2-tab.spec.ts/standard-notifications-2-settings-linux.png new file mode 100644 index 0000000000..5bd96ef009 Binary files /dev/null and b/playwright/snapshots/settings/notifications/notifications-settings-2-tab.spec.ts/standard-notifications-2-settings-linux.png differ diff --git a/playwright/snapshots/settings/notifications/notifications-settings-tab.spec.ts/standard-notification-settings-linux.png b/playwright/snapshots/settings/notifications/notifications-settings-tab.spec.ts/standard-notification-settings-linux.png new file mode 100644 index 0000000000..9452120bfb Binary files /dev/null and b/playwright/snapshots/settings/notifications/notifications-settings-tab.spec.ts/standard-notification-settings-linux.png differ diff --git a/playwright/snapshots/settings/preferences-user-settings-tab.spec.ts/Preferences-user-settings-tab-should-be-rendered-properly-1-linux.png b/playwright/snapshots/settings/preferences-user-settings-tab.spec.ts/Preferences-user-settings-tab-should-be-rendered-properly-1-linux.png index 5f92c00fc3..ac54127655 100644 Binary files a/playwright/snapshots/settings/preferences-user-settings-tab.spec.ts/Preferences-user-settings-tab-should-be-rendered-properly-1-linux.png and b/playwright/snapshots/settings/preferences-user-settings-tab.spec.ts/Preferences-user-settings-tab-should-be-rendered-properly-1-linux.png differ diff --git a/playwright/snapshots/settings/quick-settings-menu.spec.ts/quick-settings-linux.png b/playwright/snapshots/settings/quick-settings-menu.spec.ts/quick-settings-linux.png index c6605b5b64..bd95b29bfa 100644 Binary files a/playwright/snapshots/settings/quick-settings-menu.spec.ts/quick-settings-linux.png and b/playwright/snapshots/settings/quick-settings-menu.spec.ts/quick-settings-linux.png differ diff --git a/playwright/snapshots/settings/room-settings/room-security-tab.spec.ts/room-security-settings-linux.png b/playwright/snapshots/settings/room-settings/room-security-tab.spec.ts/room-security-settings-linux.png new file mode 100644 index 0000000000..6149e4fe55 Binary files /dev/null and b/playwright/snapshots/settings/room-settings/room-security-tab.spec.ts/room-security-settings-linux.png differ diff --git a/playwright/snapshots/settings/room-settings/room-security-tab.spec.ts/room-security-settings-world-readable-linux.png b/playwright/snapshots/settings/room-settings/room-security-tab.spec.ts/room-security-settings-world-readable-linux.png new file mode 100644 index 0000000000..fb48a55879 Binary files /dev/null and b/playwright/snapshots/settings/room-settings/room-security-tab.spec.ts/room-security-settings-world-readable-linux.png differ diff --git a/playwright/snapshots/settings/room-settings/room-video-tab.spec.ts/room-video-settings-linux.png b/playwright/snapshots/settings/room-settings/room-video-tab.spec.ts/room-video-settings-linux.png new file mode 100644 index 0000000000..37303257b7 Binary files /dev/null and b/playwright/snapshots/settings/room-settings/room-video-tab.spec.ts/room-video-settings-linux.png differ diff --git a/playwright/snapshots/settings/security-user-settings-tab.spec.ts/Security-user-settings-tab-with-posthog-enable-b5d89-csLearnMoreDialog-should-be-rendered-properly-1-linux.png b/playwright/snapshots/settings/security-user-settings-tab.spec.ts/Security-user-settings-tab-with-posthog-enable-b5d89-csLearnMoreDialog-should-be-rendered-properly-1-linux.png index 9cd18bee3e..5a6493738f 100644 Binary files a/playwright/snapshots/settings/security-user-settings-tab.spec.ts/Security-user-settings-tab-with-posthog-enable-b5d89-csLearnMoreDialog-should-be-rendered-properly-1-linux.png and b/playwright/snapshots/settings/security-user-settings-tab.spec.ts/Security-user-settings-tab-with-posthog-enable-b5d89-csLearnMoreDialog-should-be-rendered-properly-1-linux.png differ diff --git a/playwright/snapshots/settings/security-user-settings-tab.spec.ts/security-settings-tab-linux.png b/playwright/snapshots/settings/security-user-settings-tab.spec.ts/security-settings-tab-linux.png new file mode 100644 index 0000000000..5ae88a2b1c Binary files /dev/null and b/playwright/snapshots/settings/security-user-settings-tab.spec.ts/security-settings-tab-linux.png differ diff --git a/playwright/snapshots/spaces/spaces.spec.ts/add-existing-rooms-dialog-linux.png b/playwright/snapshots/spaces/spaces.spec.ts/add-existing-rooms-dialog-linux.png index 53ccabf4f2..52fe99b90a 100644 Binary files a/playwright/snapshots/spaces/spaces.spec.ts/add-existing-rooms-dialog-linux.png and b/playwright/snapshots/spaces/spaces.spec.ts/add-existing-rooms-dialog-linux.png differ diff --git a/playwright/snapshots/spaces/spaces.spec.ts/invite-teammates-dialog-linux.png b/playwright/snapshots/spaces/spaces.spec.ts/invite-teammates-dialog-linux.png index b69496e5fe..d7126166d7 100644 Binary files a/playwright/snapshots/spaces/spaces.spec.ts/invite-teammates-dialog-linux.png and b/playwright/snapshots/spaces/spaces.spec.ts/invite-teammates-dialog-linux.png differ diff --git a/playwright/snapshots/spaces/spaces.spec.ts/space-panel-collapsed-linux.png b/playwright/snapshots/spaces/spaces.spec.ts/space-panel-collapsed-linux.png index 312ca2580c..4ca008798f 100644 Binary files a/playwright/snapshots/spaces/spaces.spec.ts/space-panel-collapsed-linux.png and b/playwright/snapshots/spaces/spaces.spec.ts/space-panel-collapsed-linux.png differ diff --git a/playwright/snapshots/spaces/spaces.spec.ts/space-panel-expanded-linux.png b/playwright/snapshots/spaces/spaces.spec.ts/space-panel-expanded-linux.png index f0606ce47a..6ebcb8635f 100644 Binary files a/playwright/snapshots/spaces/spaces.spec.ts/space-panel-expanded-linux.png and b/playwright/snapshots/spaces/spaces.spec.ts/space-panel-expanded-linux.png differ diff --git a/playwright/snapshots/spaces/spaces.spec.ts/space-room-view-linux.png b/playwright/snapshots/spaces/spaces.spec.ts/space-room-view-linux.png index 8e627ec9f2..ac19c94bdb 100644 Binary files a/playwright/snapshots/spaces/spaces.spec.ts/space-room-view-linux.png and b/playwright/snapshots/spaces/spaces.spec.ts/space-room-view-linux.png differ diff --git a/playwright/snapshots/spaces/spaces.spec.ts/space-visibility-settings-linux.png b/playwright/snapshots/spaces/spaces.spec.ts/space-visibility-settings-linux.png new file mode 100644 index 0000000000..2408c046bd Binary files /dev/null and b/playwright/snapshots/spaces/spaces.spec.ts/space-visibility-settings-linux.png differ diff --git a/playwright/snapshots/spaces/threads-activity-centre/threadsActivityCentre.spec.ts/tac-button-expanded-linux.png b/playwright/snapshots/spaces/threads-activity-centre/threadsActivityCentre.spec.ts/tac-button-expanded-linux.png index 2ad6315d9e..5d47b6b9b5 100644 Binary files a/playwright/snapshots/spaces/threads-activity-centre/threadsActivityCentre.spec.ts/tac-button-expanded-linux.png and b/playwright/snapshots/spaces/threads-activity-centre/threadsActivityCentre.spec.ts/tac-button-expanded-linux.png differ diff --git a/playwright/snapshots/spaces/threads-activity-centre/threadsActivityCentre.spec.ts/tac-hovered-expanded-linux.png b/playwright/snapshots/spaces/threads-activity-centre/threadsActivityCentre.spec.ts/tac-hovered-expanded-linux.png index 2ad6315d9e..5d47b6b9b5 100644 Binary files a/playwright/snapshots/spaces/threads-activity-centre/threadsActivityCentre.spec.ts/tac-hovered-expanded-linux.png and b/playwright/snapshots/spaces/threads-activity-centre/threadsActivityCentre.spec.ts/tac-hovered-expanded-linux.png differ diff --git a/playwright/snapshots/spaces/threads-activity-centre/threadsActivityCentre.spec.ts/tac-hovered-linux.png b/playwright/snapshots/spaces/threads-activity-centre/threadsActivityCentre.spec.ts/tac-hovered-linux.png index 591d22c3c4..40f1243619 100644 Binary files a/playwright/snapshots/spaces/threads-activity-centre/threadsActivityCentre.spec.ts/tac-hovered-linux.png and b/playwright/snapshots/spaces/threads-activity-centre/threadsActivityCentre.spec.ts/tac-hovered-linux.png differ diff --git a/playwright/snapshots/threads/threads.spec.ts/Initial-ThreadView-on-bubble-layout-linux.png b/playwright/snapshots/threads/threads.spec.ts/Initial-ThreadView-on-bubble-layout-linux.png index 60d6dc9e18..3c030baa04 100644 Binary files a/playwright/snapshots/threads/threads.spec.ts/Initial-ThreadView-on-bubble-layout-linux.png and b/playwright/snapshots/threads/threads.spec.ts/Initial-ThreadView-on-bubble-layout-linux.png differ diff --git a/playwright/snapshots/threads/threads.spec.ts/Initial-ThreadView-on-group-layout-linux.png b/playwright/snapshots/threads/threads.spec.ts/Initial-ThreadView-on-group-layout-linux.png index f217fe7094..77c96f7312 100644 Binary files a/playwright/snapshots/threads/threads.spec.ts/Initial-ThreadView-on-group-layout-linux.png and b/playwright/snapshots/threads/threads.spec.ts/Initial-ThreadView-on-group-layout-linux.png differ diff --git a/playwright/snapshots/threads/threads.spec.ts/Reply-to-the-location-on-ThreadView-linux.png b/playwright/snapshots/threads/threads.spec.ts/Reply-to-the-location-on-ThreadView-linux.png index e43e41dd79..f0d6c452a8 100644 Binary files a/playwright/snapshots/threads/threads.spec.ts/Reply-to-the-location-on-ThreadView-linux.png and b/playwright/snapshots/threads/threads.spec.ts/Reply-to-the-location-on-ThreadView-linux.png differ diff --git a/playwright/snapshots/threads/threads.spec.ts/ThreadView-with-reaction-and-a-hidden-event-on-bubble-layout-linux.png b/playwright/snapshots/threads/threads.spec.ts/ThreadView-with-reaction-and-a-hidden-event-on-bubble-layout-linux.png index cff1b27bd3..85ef504581 100644 Binary files a/playwright/snapshots/threads/threads.spec.ts/ThreadView-with-reaction-and-a-hidden-event-on-bubble-layout-linux.png and b/playwright/snapshots/threads/threads.spec.ts/ThreadView-with-reaction-and-a-hidden-event-on-bubble-layout-linux.png differ diff --git a/playwright/snapshots/threads/threads.spec.ts/ThreadView-with-reaction-and-a-hidden-event-on-group-layout-linux.png b/playwright/snapshots/threads/threads.spec.ts/ThreadView-with-reaction-and-a-hidden-event-on-group-layout-linux.png index 30fa37ab9e..669ef94f3c 100644 Binary files a/playwright/snapshots/threads/threads.spec.ts/ThreadView-with-reaction-and-a-hidden-event-on-group-layout-linux.png and b/playwright/snapshots/threads/threads.spec.ts/ThreadView-with-reaction-and-a-hidden-event-on-group-layout-linux.png differ diff --git a/playwright/snapshots/threads/threads.spec.ts/ThreadView-with-redacted-messages-on-bubble-layout-linux.png b/playwright/snapshots/threads/threads.spec.ts/ThreadView-with-redacted-messages-on-bubble-layout-linux.png index c92780196d..d17cf99326 100644 Binary files a/playwright/snapshots/threads/threads.spec.ts/ThreadView-with-redacted-messages-on-bubble-layout-linux.png and b/playwright/snapshots/threads/threads.spec.ts/ThreadView-with-redacted-messages-on-bubble-layout-linux.png differ diff --git a/playwright/snapshots/threads/threads.spec.ts/ThreadView-with-redacted-messages-on-group-layout-linux.png b/playwright/snapshots/threads/threads.spec.ts/ThreadView-with-redacted-messages-on-group-layout-linux.png index 4bad759050..5e8d75aeb5 100644 Binary files a/playwright/snapshots/threads/threads.spec.ts/ThreadView-with-redacted-messages-on-group-layout-linux.png and b/playwright/snapshots/threads/threads.spec.ts/ThreadView-with-redacted-messages-on-group-layout-linux.png differ diff --git a/playwright/snapshots/threads/threads.spec.ts/thread-panel-linux.png b/playwright/snapshots/threads/threads.spec.ts/thread-panel-linux.png new file mode 100644 index 0000000000..f0871c7625 Binary files /dev/null and b/playwright/snapshots/threads/threads.spec.ts/thread-panel-linux.png differ diff --git a/playwright/snapshots/timeline/media-preview-settings.spec.ts/invite-no-avatar-linux.png b/playwright/snapshots/timeline/media-preview-settings.spec.ts/invite-no-avatar-linux.png index 377282a2c8..6840739135 100644 Binary files a/playwright/snapshots/timeline/media-preview-settings.spec.ts/invite-no-avatar-linux.png and b/playwright/snapshots/timeline/media-preview-settings.spec.ts/invite-no-avatar-linux.png differ diff --git a/playwright/snapshots/timeline/media-preview-settings.spec.ts/invite-room-tree-no-avatar-linux.png b/playwright/snapshots/timeline/media-preview-settings.spec.ts/invite-room-tree-no-avatar-linux.png index 29d129f73f..f3478d05aa 100644 Binary files a/playwright/snapshots/timeline/media-preview-settings.spec.ts/invite-room-tree-no-avatar-linux.png and b/playwright/snapshots/timeline/media-preview-settings.spec.ts/invite-room-tree-no-avatar-linux.png differ diff --git a/playwright/snapshots/timeline/media-preview-settings.spec.ts/invite-room-tree-with-avatar-linux.png b/playwright/snapshots/timeline/media-preview-settings.spec.ts/invite-room-tree-with-avatar-linux.png index 452f08d3e2..7555a90440 100644 Binary files a/playwright/snapshots/timeline/media-preview-settings.spec.ts/invite-room-tree-with-avatar-linux.png and b/playwright/snapshots/timeline/media-preview-settings.spec.ts/invite-room-tree-with-avatar-linux.png differ diff --git a/playwright/snapshots/timeline/media-preview-settings.spec.ts/invite-with-avatar-linux.png b/playwright/snapshots/timeline/media-preview-settings.spec.ts/invite-with-avatar-linux.png index 93459507d0..7a7d2025c2 100644 Binary files a/playwright/snapshots/timeline/media-preview-settings.spec.ts/invite-with-avatar-linux.png and b/playwright/snapshots/timeline/media-preview-settings.spec.ts/invite-with-avatar-linux.png differ diff --git a/playwright/snapshots/timeline/timeline.spec.ts/code-block-linux.png b/playwright/snapshots/timeline/timeline.spec.ts/code-block-linux.png index 2f62d0dec6..3f4cae7c29 100644 Binary files a/playwright/snapshots/timeline/timeline.spec.ts/code-block-linux.png and b/playwright/snapshots/timeline/timeline.spec.ts/code-block-linux.png differ diff --git a/playwright/snapshots/timeline/timeline.spec.ts/collapsed-gels-and-messages-irc-layout-linux.png b/playwright/snapshots/timeline/timeline.spec.ts/collapsed-gels-and-messages-irc-layout-linux.png index f8135426c2..1a0a852cd1 100644 Binary files a/playwright/snapshots/timeline/timeline.spec.ts/collapsed-gels-and-messages-irc-layout-linux.png and b/playwright/snapshots/timeline/timeline.spec.ts/collapsed-gels-and-messages-irc-layout-linux.png differ diff --git a/playwright/snapshots/timeline/timeline.spec.ts/collapsed-gels-bubble-layout-linux.png b/playwright/snapshots/timeline/timeline.spec.ts/collapsed-gels-bubble-layout-linux.png index 4d75b3b966..cf5e66851c 100644 Binary files a/playwright/snapshots/timeline/timeline.spec.ts/collapsed-gels-bubble-layout-linux.png and b/playwright/snapshots/timeline/timeline.spec.ts/collapsed-gels-bubble-layout-linux.png differ diff --git a/playwright/snapshots/timeline/timeline.spec.ts/configured-room-irc-layout-linux.png b/playwright/snapshots/timeline/timeline.spec.ts/configured-room-irc-layout-linux.png index a6e31b89cd..d9359dab70 100644 Binary files a/playwright/snapshots/timeline/timeline.spec.ts/configured-room-irc-layout-linux.png and b/playwright/snapshots/timeline/timeline.spec.ts/configured-room-irc-layout-linux.png differ diff --git a/playwright/snapshots/timeline/timeline.spec.ts/edited-code-block-linux.png b/playwright/snapshots/timeline/timeline.spec.ts/edited-code-block-linux.png index 46f5cf1a7a..8b906b346e 100644 Binary files a/playwright/snapshots/timeline/timeline.spec.ts/edited-code-block-linux.png and b/playwright/snapshots/timeline/timeline.spec.ts/edited-code-block-linux.png differ diff --git a/playwright/snapshots/timeline/timeline.spec.ts/event-line-inline-start-margin-irc-layout-linux.png b/playwright/snapshots/timeline/timeline.spec.ts/event-line-inline-start-margin-irc-layout-linux.png index 8d3d7b09ed..7c660888e6 100644 Binary files a/playwright/snapshots/timeline/timeline.spec.ts/event-line-inline-start-margin-irc-layout-linux.png and b/playwright/snapshots/timeline/timeline.spec.ts/event-line-inline-start-margin-irc-layout-linux.png differ diff --git a/playwright/snapshots/timeline/timeline.spec.ts/event-tile-reply-chains-bubble-layout-linux.png b/playwright/snapshots/timeline/timeline.spec.ts/event-tile-reply-chains-bubble-layout-linux.png index 167f9b6855..99577dbece 100644 Binary files a/playwright/snapshots/timeline/timeline.spec.ts/event-tile-reply-chains-bubble-layout-linux.png and b/playwright/snapshots/timeline/timeline.spec.ts/event-tile-reply-chains-bubble-layout-linux.png differ diff --git a/playwright/snapshots/timeline/timeline.spec.ts/event-tile-reply-chains-compact-modern-layout-linux.png b/playwright/snapshots/timeline/timeline.spec.ts/event-tile-reply-chains-compact-modern-layout-linux.png index 0682bf760f..f724a3198f 100644 Binary files a/playwright/snapshots/timeline/timeline.spec.ts/event-tile-reply-chains-compact-modern-layout-linux.png and b/playwright/snapshots/timeline/timeline.spec.ts/event-tile-reply-chains-compact-modern-layout-linux.png differ diff --git a/playwright/snapshots/timeline/timeline.spec.ts/event-tile-reply-chains-irc-layout-linux.png b/playwright/snapshots/timeline/timeline.spec.ts/event-tile-reply-chains-irc-layout-linux.png index b9899fb177..3af1ce146f 100644 Binary files a/playwright/snapshots/timeline/timeline.spec.ts/event-tile-reply-chains-irc-layout-linux.png and b/playwright/snapshots/timeline/timeline.spec.ts/event-tile-reply-chains-irc-layout-linux.png differ diff --git a/playwright/snapshots/timeline/timeline.spec.ts/event-tile-reply-chains-irc-modern-linux.png b/playwright/snapshots/timeline/timeline.spec.ts/event-tile-reply-chains-irc-modern-linux.png index ca4ce5933f..031f5aa419 100644 Binary files a/playwright/snapshots/timeline/timeline.spec.ts/event-tile-reply-chains-irc-modern-linux.png and b/playwright/snapshots/timeline/timeline.spec.ts/event-tile-reply-chains-irc-modern-linux.png differ diff --git a/playwright/snapshots/timeline/timeline.spec.ts/event-tiles-bubble-layout-linux.png b/playwright/snapshots/timeline/timeline.spec.ts/event-tiles-bubble-layout-linux.png index 785681b28c..040b1ef72c 100644 Binary files a/playwright/snapshots/timeline/timeline.spec.ts/event-tiles-bubble-layout-linux.png and b/playwright/snapshots/timeline/timeline.spec.ts/event-tiles-bubble-layout-linux.png differ diff --git a/playwright/snapshots/timeline/timeline.spec.ts/event-tiles-compact-modern-layout-linux.png b/playwright/snapshots/timeline/timeline.spec.ts/event-tiles-compact-modern-layout-linux.png index e5e312ac73..63eb2da98f 100644 Binary files a/playwright/snapshots/timeline/timeline.spec.ts/event-tiles-compact-modern-layout-linux.png and b/playwright/snapshots/timeline/timeline.spec.ts/event-tiles-compact-modern-layout-linux.png differ diff --git a/playwright/snapshots/timeline/timeline.spec.ts/event-tiles-irc-layout-linux.png b/playwright/snapshots/timeline/timeline.spec.ts/event-tiles-irc-layout-linux.png index f79934e621..f943108d73 100644 Binary files a/playwright/snapshots/timeline/timeline.spec.ts/event-tiles-irc-layout-linux.png and b/playwright/snapshots/timeline/timeline.spec.ts/event-tiles-irc-layout-linux.png differ diff --git a/playwright/snapshots/timeline/timeline.spec.ts/event-tiles-modern-layout-linux.png b/playwright/snapshots/timeline/timeline.spec.ts/event-tiles-modern-layout-linux.png index ff4fa7c1b9..914e10dff2 100644 Binary files a/playwright/snapshots/timeline/timeline.spec.ts/event-tiles-modern-layout-linux.png and b/playwright/snapshots/timeline/timeline.spec.ts/event-tiles-modern-layout-linux.png differ diff --git a/playwright/snapshots/timeline/timeline.spec.ts/expanded-gels-and-messages-irc-layout-linux.png b/playwright/snapshots/timeline/timeline.spec.ts/expanded-gels-and-messages-irc-layout-linux.png index f6840e8daf..912fa679b8 100644 Binary files a/playwright/snapshots/timeline/timeline.spec.ts/expanded-gels-and-messages-irc-layout-linux.png and b/playwright/snapshots/timeline/timeline.spec.ts/expanded-gels-and-messages-irc-layout-linux.png differ diff --git a/playwright/snapshots/timeline/timeline.spec.ts/expanded-gels-bubble-layout-linux.png b/playwright/snapshots/timeline/timeline.spec.ts/expanded-gels-bubble-layout-linux.png index 6154a0a268..9fbe9c79e1 100644 Binary files a/playwright/snapshots/timeline/timeline.spec.ts/expanded-gels-bubble-layout-linux.png and b/playwright/snapshots/timeline/timeline.spec.ts/expanded-gels-bubble-layout-linux.png differ diff --git a/playwright/snapshots/timeline/timeline.spec.ts/expanded-gels-emote-irc-layout-linux.png b/playwright/snapshots/timeline/timeline.spec.ts/expanded-gels-emote-irc-layout-linux.png index 06853769d7..39fffefc70 100644 Binary files a/playwright/snapshots/timeline/timeline.spec.ts/expanded-gels-emote-irc-layout-linux.png and b/playwright/snapshots/timeline/timeline.spec.ts/expanded-gels-emote-irc-layout-linux.png differ diff --git a/playwright/snapshots/timeline/timeline.spec.ts/expanded-gels-irc-layout-linux.png b/playwright/snapshots/timeline/timeline.spec.ts/expanded-gels-irc-layout-linux.png index 8d3d7b09ed..7c660888e6 100644 Binary files a/playwright/snapshots/timeline/timeline.spec.ts/expanded-gels-irc-layout-linux.png and b/playwright/snapshots/timeline/timeline.spec.ts/expanded-gels-irc-layout-linux.png differ diff --git a/playwright/snapshots/timeline/timeline.spec.ts/expanded-gels-modern-layout-linux.png b/playwright/snapshots/timeline/timeline.spec.ts/expanded-gels-modern-layout-linux.png index f1a95a8275..3684f57b11 100644 Binary files a/playwright/snapshots/timeline/timeline.spec.ts/expanded-gels-modern-layout-linux.png and b/playwright/snapshots/timeline/timeline.spec.ts/expanded-gels-modern-layout-linux.png differ diff --git a/playwright/snapshots/timeline/timeline.spec.ts/expanded-gels-redaction-placeholder-linux.png b/playwright/snapshots/timeline/timeline.spec.ts/expanded-gels-redaction-placeholder-linux.png index 9d9dacd1bf..39c40e0b13 100644 Binary files a/playwright/snapshots/timeline/timeline.spec.ts/expanded-gels-redaction-placeholder-linux.png and b/playwright/snapshots/timeline/timeline.spec.ts/expanded-gels-redaction-placeholder-linux.png differ diff --git a/playwright/snapshots/timeline/timeline.spec.ts/hidden-event-line-padding-modern-layout-linux.png b/playwright/snapshots/timeline/timeline.spec.ts/hidden-event-line-padding-modern-layout-linux.png index e0523d6eec..74a2028787 100644 Binary files a/playwright/snapshots/timeline/timeline.spec.ts/hidden-event-line-padding-modern-layout-linux.png and b/playwright/snapshots/timeline/timeline.spec.ts/hidden-event-line-padding-modern-layout-linux.png differ diff --git a/playwright/snapshots/timeline/timeline.spec.ts/hidden-event-line-zero-padding-irc-layout-linux.png b/playwright/snapshots/timeline/timeline.spec.ts/hidden-event-line-zero-padding-irc-layout-linux.png index 5c6d7710f6..f6785d89c9 100644 Binary files a/playwright/snapshots/timeline/timeline.spec.ts/hidden-event-line-zero-padding-irc-layout-linux.png and b/playwright/snapshots/timeline/timeline.spec.ts/hidden-event-line-zero-padding-irc-layout-linux.png differ diff --git a/playwright/snapshots/timeline/timeline.spec.ts/highlighted-search-results-linux.png b/playwright/snapshots/timeline/timeline.spec.ts/highlighted-search-results-linux.png index a99cfb68cf..d8e061597f 100644 Binary files a/playwright/snapshots/timeline/timeline.spec.ts/highlighted-search-results-linux.png and b/playwright/snapshots/timeline/timeline.spec.ts/highlighted-search-results-linux.png differ diff --git a/playwright/snapshots/timeline/timeline.spec.ts/hovered-hidden-event-line-irc-layout-linux.png b/playwright/snapshots/timeline/timeline.spec.ts/hovered-hidden-event-line-irc-layout-linux.png index 5fb0c283b5..8a86ec81bb 100644 Binary files a/playwright/snapshots/timeline/timeline.spec.ts/hovered-hidden-event-line-irc-layout-linux.png and b/playwright/snapshots/timeline/timeline.spec.ts/hovered-hidden-event-line-irc-layout-linux.png differ diff --git a/playwright/snapshots/timeline/timeline.spec.ts/image-in-timeline-default-layout-linux.png b/playwright/snapshots/timeline/timeline.spec.ts/image-in-timeline-default-layout-linux.png index b1236c9ea0..8949324613 100644 Binary files a/playwright/snapshots/timeline/timeline.spec.ts/image-in-timeline-default-layout-linux.png and b/playwright/snapshots/timeline/timeline.spec.ts/image-in-timeline-default-layout-linux.png differ diff --git a/playwright/snapshots/timeline/timeline.spec.ts/long-strings-with-reply-bubble-layout-linux.png b/playwright/snapshots/timeline/timeline.spec.ts/long-strings-with-reply-bubble-layout-linux.png index 5d44a1f655..fdcbb28133 100644 Binary files a/playwright/snapshots/timeline/timeline.spec.ts/long-strings-with-reply-bubble-layout-linux.png and b/playwright/snapshots/timeline/timeline.spec.ts/long-strings-with-reply-bubble-layout-linux.png differ diff --git a/playwright/snapshots/timeline/timeline.spec.ts/long-strings-with-reply-irc-layout-linux.png b/playwright/snapshots/timeline/timeline.spec.ts/long-strings-with-reply-irc-layout-linux.png index baa75ffaba..78196d2632 100644 Binary files a/playwright/snapshots/timeline/timeline.spec.ts/long-strings-with-reply-irc-layout-linux.png and b/playwright/snapshots/timeline/timeline.spec.ts/long-strings-with-reply-irc-layout-linux.png differ diff --git a/playwright/snapshots/timeline/timeline.spec.ts/long-strings-with-reply-modern-layout-linux.png b/playwright/snapshots/timeline/timeline.spec.ts/long-strings-with-reply-modern-layout-linux.png index 4b54392a21..df6aca3083 100644 Binary files a/playwright/snapshots/timeline/timeline.spec.ts/long-strings-with-reply-modern-layout-linux.png and b/playwright/snapshots/timeline/timeline.spec.ts/long-strings-with-reply-modern-layout-linux.png differ diff --git a/playwright/snapshots/timeline/timeline.spec.ts/search-aux-panel-linux.png b/playwright/snapshots/timeline/timeline.spec.ts/search-aux-panel-linux.png index 125c47ee35..6009b554ca 100644 Binary files a/playwright/snapshots/timeline/timeline.spec.ts/search-aux-panel-linux.png and b/playwright/snapshots/timeline/timeline.spec.ts/search-aux-panel-linux.png differ diff --git a/playwright/snapshots/timeline/timeline.spec.ts/search-results-with-TextualEvent-linux.png b/playwright/snapshots/timeline/timeline.spec.ts/search-results-with-TextualEvent-linux.png index 5aa6bcea19..e742edfed9 100644 Binary files a/playwright/snapshots/timeline/timeline.spec.ts/search-results-with-TextualEvent-linux.png and b/playwright/snapshots/timeline/timeline.spec.ts/search-results-with-TextualEvent-linux.png differ diff --git a/playwright/snapshots/timeline/timeline.spec.ts/spoiler-linux.png b/playwright/snapshots/timeline/timeline.spec.ts/spoiler-linux.png index 529d254dc4..1679135c12 100644 Binary files a/playwright/snapshots/timeline/timeline.spec.ts/spoiler-linux.png and b/playwright/snapshots/timeline/timeline.spec.ts/spoiler-linux.png differ diff --git a/playwright/snapshots/timeline/timeline.spec.ts/spoiler-uncovered-linux.png b/playwright/snapshots/timeline/timeline.spec.ts/spoiler-uncovered-linux.png index 41d5bc8bee..65fe4d96e1 100644 Binary files a/playwright/snapshots/timeline/timeline.spec.ts/spoiler-uncovered-linux.png and b/playwright/snapshots/timeline/timeline.spec.ts/spoiler-uncovered-linux.png differ diff --git a/playwright/snapshots/timeline/timeline.spec.ts/url-preview-linux.png b/playwright/snapshots/timeline/timeline.spec.ts/url-preview-linux.png index 56220d88d1..0f7d951d69 100644 Binary files a/playwright/snapshots/timeline/timeline.spec.ts/url-preview-linux.png and b/playwright/snapshots/timeline/timeline.spec.ts/url-preview-linux.png differ diff --git a/playwright/snapshots/timeline/timeline.spec.ts/voice-message-linux.png b/playwright/snapshots/timeline/timeline.spec.ts/voice-message-linux.png new file mode 100644 index 0000000000..ba1cb6e3f6 Binary files /dev/null and b/playwright/snapshots/timeline/timeline.spec.ts/voice-message-linux.png differ diff --git a/playwright/snapshots/user-view/user-view.spec.ts/user-info-linux.png b/playwright/snapshots/user-view/user-view.spec.ts/user-info-linux.png index a4f6a476f6..9926b5801a 100644 Binary files a/playwright/snapshots/user-view/user-view.spec.ts/user-info-linux.png and b/playwright/snapshots/user-view/user-view.spec.ts/user-info-linux.png differ diff --git a/playwright/snapshots/voip/element-call.spec.ts/incoming-call-dm-video-toast-checked-linux.png b/playwright/snapshots/voip/element-call.spec.ts/incoming-call-dm-video-toast-checked-linux.png new file mode 100644 index 0000000000..b0fd216c56 Binary files /dev/null and b/playwright/snapshots/voip/element-call.spec.ts/incoming-call-dm-video-toast-checked-linux.png differ diff --git a/playwright/snapshots/voip/element-call.spec.ts/incoming-call-dm-video-toast-unchecked-linux.png b/playwright/snapshots/voip/element-call.spec.ts/incoming-call-dm-video-toast-unchecked-linux.png new file mode 100644 index 0000000000..a7b7aea8a9 Binary files /dev/null and b/playwright/snapshots/voip/element-call.spec.ts/incoming-call-dm-video-toast-unchecked-linux.png differ diff --git a/playwright/snapshots/voip/element-call.spec.ts/incoming-call-dm-voice-toast-linux.png b/playwright/snapshots/voip/element-call.spec.ts/incoming-call-dm-voice-toast-linux.png new file mode 100644 index 0000000000..34ddc1b02f Binary files /dev/null and b/playwright/snapshots/voip/element-call.spec.ts/incoming-call-dm-voice-toast-linux.png differ diff --git a/playwright/snapshots/voip/element-call.spec.ts/incoming-call-group-video-toast-checked-linux.png b/playwright/snapshots/voip/element-call.spec.ts/incoming-call-group-video-toast-checked-linux.png new file mode 100644 index 0000000000..d25b9467d0 Binary files /dev/null and b/playwright/snapshots/voip/element-call.spec.ts/incoming-call-group-video-toast-checked-linux.png differ diff --git a/playwright/snapshots/voip/element-call.spec.ts/incoming-call-group-video-toast-unchecked-linux.png b/playwright/snapshots/voip/element-call.spec.ts/incoming-call-group-video-toast-unchecked-linux.png new file mode 100644 index 0000000000..78a31d1662 Binary files /dev/null and b/playwright/snapshots/voip/element-call.spec.ts/incoming-call-group-video-toast-unchecked-linux.png differ diff --git a/playwright/snapshots/voip/element-call.spec.ts/incoming-call-group-voice-toast-linux.png b/playwright/snapshots/voip/element-call.spec.ts/incoming-call-group-voice-toast-linux.png new file mode 100644 index 0000000000..29cf843d7b Binary files /dev/null and b/playwright/snapshots/voip/element-call.spec.ts/incoming-call-group-voice-toast-linux.png differ diff --git a/playwright/snapshots/voip/pstn.spec.ts/dialpad-linux.png b/playwright/snapshots/voip/pstn.spec.ts/dialpad-linux.png index 3be63e2f50..ea67953d9e 100644 Binary files a/playwright/snapshots/voip/pstn.spec.ts/dialpad-linux.png and b/playwright/snapshots/voip/pstn.spec.ts/dialpad-linux.png differ diff --git a/playwright/snapshots/voip/pstn.spec.ts/dialpad-trigger-linux.png b/playwright/snapshots/voip/pstn.spec.ts/dialpad-trigger-linux.png index 17bea8979d..d1c0cad107 100644 Binary files a/playwright/snapshots/voip/pstn.spec.ts/dialpad-trigger-linux.png and b/playwright/snapshots/voip/pstn.spec.ts/dialpad-trigger-linux.png differ diff --git a/playwright/snapshots/widgets/layout.spec.ts/apps-drawer-linux.png b/playwright/snapshots/widgets/layout.spec.ts/apps-drawer-linux.png index 6331d373b3..50c0e1ed2d 100644 Binary files a/playwright/snapshots/widgets/layout.spec.ts/apps-drawer-linux.png and b/playwright/snapshots/widgets/layout.spec.ts/apps-drawer-linux.png differ diff --git a/playwright/snapshots/widgets/permissions-dialog.spec.ts/widget-capabilites-prompt-linux.png b/playwright/snapshots/widgets/permissions-dialog.spec.ts/widget-capabilites-prompt-linux.png new file mode 100644 index 0000000000..747ffb15ff Binary files /dev/null and b/playwright/snapshots/widgets/permissions-dialog.spec.ts/widget-capabilites-prompt-linux.png differ diff --git a/playwright/stale-screenshot-reporter.ts b/playwright/stale-screenshot-reporter.ts deleted file mode 100644 index 36aba56a07..0000000000 --- a/playwright/stale-screenshot-reporter.ts +++ /dev/null @@ -1,71 +0,0 @@ -/* -Copyright 2024 New Vector Ltd. -Copyright 2024 The Matrix.org Foundation C.I.C. - -SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial -Please see LICENSE files in the repository root for full details. -*/ - -/** - * Test reporter which compares the reported screenshots vs those on disk to find stale screenshots - * Only intended to run from within GitHub Actions - */ - -import path from "node:path"; -import { glob } from "glob"; - -import type { Reporter, TestCase } from "@playwright/test/reporter"; - -const snapshotRoot = path.join(__dirname, "snapshots"); - -class StaleScreenshotReporter implements Reporter { - private screenshots = new Set(); - private failing = false; - private success = true; - - public onTestEnd(test: TestCase): void { - if (!test.ok()) { - this.failing = true; - } - for (const annotation of test.annotations) { - if (annotation.type === "_screenshot") { - this.screenshots.add(annotation.description); - } - } - } - - private error(msg: string, file: string) { - if (process.env.GITHUB_ACTIONS) { - console.log(`::error file=${file}::${msg}`); - } - console.error(msg, file); - this.success = false; - } - - public async onExit(): Promise { - if (this.failing) return; - const screenshotFiles = new Set(await glob(`**/*.png`, { cwd: snapshotRoot })); - for (const screenshot of screenshotFiles) { - if (screenshot.split("-").at(-1) !== "linux.png") { - this.error( - "Found screenshot belonging to different platform, this should not be checked in", - screenshot, - ); - } - } - for (const screenshot of this.screenshots) { - screenshotFiles.delete(screenshot); - } - if (screenshotFiles.size > 0) { - for (const screenshot of screenshotFiles) { - this.error("Stale screenshot file", screenshot); - } - } - - if (!this.success) { - process.exit(1); - } - } -} - -export default StaleScreenshotReporter; diff --git a/playwright/testcontainers/mas.ts b/playwright/testcontainers/mas.ts new file mode 100644 index 0000000000..61cc53bbc9 --- /dev/null +++ b/playwright/testcontainers/mas.ts @@ -0,0 +1,24 @@ +/* +Copyright 2025 New Vector Ltd. + +SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial +Please see LICENSE files in the repository root for full details. +*/ + +import { + MatrixAuthenticationServiceContainer as BaseMatrixAuthenticationServiceContainer, + type StartedPostgreSqlContainer, +} from "@element-hq/element-web-playwright-common/lib/testcontainers"; + +const TAG = "main@sha256:2c5966c2ff06458ac5cbae959f12e19d30e3ebb63c641d31ec1ae08abccb9c6d"; + +/** + * MatrixAuthenticationServiceContainer which freezes the docker digest to + * stabilise tests, updated periodically by the `playwright-image-updates.yaml` + * workflow. + */ +export class MatrixAuthenticationServiceContainer extends BaseMatrixAuthenticationServiceContainer { + public constructor(db: StartedPostgreSqlContainer) { + super(db, `ghcr.io/element-hq/matrix-authentication-service:${TAG}`); + } +} diff --git a/playwright/testcontainers/synapse.ts b/playwright/testcontainers/synapse.ts index 19d1544196..1359cd0f69 100644 --- a/playwright/testcontainers/synapse.ts +++ b/playwright/testcontainers/synapse.ts @@ -7,7 +7,7 @@ Please see LICENSE files in the repository root for full details. import { SynapseContainer as BaseSynapseContainer } from "@element-hq/element-web-playwright-common/lib/testcontainers"; -const TAG = "develop@sha256:66955f34a593cfc3b6e77b8d5510c60c6094f5bade8a17d2feaefbb8662ccf09"; +const TAG = "develop@sha256:7c3dce1d2b44fdc4b1494c5b8f4792018733ad323f823b88aac30c883d09fb35"; /** * SynapseContainer which freezes the docker digest to stabilise tests, diff --git a/res/css/_common.pcss b/res/css/_common.pcss index 3eed8c93c6..ec83fef901 100644 --- a/res/css/_common.pcss +++ b/res/css/_common.pcss @@ -9,8 +9,6 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com Please see LICENSE files in the repository root for full details. */ -@import url("@vector-im/compound-design-tokens/assets/web/css/compound-design-tokens.css") layer(compound); -@import url("@vector-im/compound-web/dist/style.css"); @import "./_font-sizes.pcss"; @import "./_animations.pcss"; @import "./_spacing.pcss"; @@ -85,8 +83,9 @@ html { } body { - font: var(--cpd-font-body-md-regular); - letter-spacing: var(--cpd-font-letter-spacing-body-md); + /* !important because compound overrides these font settings for body */ + font: var(--cpd-font-body-md-regular) !important; + letter-spacing: var(--cpd-font-letter-spacing-body-md) !important; /** * We want to apply Inter Dynamic metrics (https://rsms.me/inter/dynmetrics/) * We need to tweak the `letter-spacing` property and doing so, disables by @@ -97,7 +96,7 @@ body { font-feature-settings: "kern" 1, "liga" 1, - "calt" 1; + "calt" 1 !important; background-color: $background; color: $primary-content; @@ -602,6 +601,8 @@ legend { .mx_AccessibleButton, .mx_IdentityServerPicker button, .mx_AccessSecretStorageDialog button, + .mx_InviteDialog_section button, + .mx_InviteDialog_editor button, [class|="maplibregl"] ), .mx_Dialog_buttons button:not(.mx_Dialog_nonDialogButton, .mx_AccessibleButton), @@ -643,7 +644,9 @@ legend { .mx_ThemeChoicePanel_CustomTheme button, .mx_UnpinAllDialog button, .mx_ShareDialog button, - .mx_EncryptionUserSettingsTab button + .mx_EncryptionUserSettingsTab button, + .mx_InviteDialog_section button, + .mx_InviteDialog_editor button ):focus, .mx_Dialog input[type="submit"]:focus, .mx_Dialog_buttons button:not(.mx_Dialog_nonDialogButton, .mx_AccessibleButton):focus, @@ -892,53 +895,23 @@ legend { @define-mixin composerButtonHighLight { background: var(--cpd-color-bg-subtle-primary); - &::before { - background-color: var(--cpd-color-icon-primary) !important; - } + color: var(--cpd-color-icon-primary) !important; } @define-mixin composerButton $border-radius, $hover-color, $hover-bg { - --size: 26px; position: relative; cursor: pointer; - height: var(--size); - line-height: var(--size); - width: auto; - padding-left: var(--size); border-radius: $border-radius; - &::before { - content: ""; - position: absolute; - top: 3px; - left: 3px; - height: 20px; - width: 20px; - background-color: $icon-button-color; - mask-repeat: no-repeat; - mask-size: contain; - mask-position: center; - z-index: 2; - } - - &::after { - content: ""; - position: absolute; - left: 0; - top: 0; - z-index: 0; - width: var(--size); - height: var(--size); - border-radius: $border-radius; + svg { + color: $icon-button-color; } &:hover { - &::after { - background: $hover-bg; - } + background-color: $hover-bg; - &::before { - background-color: $hover-color; + svg { + color: $hover-color; } } } diff --git a/res/css/_components.pcss b/res/css/_components.pcss index e212163784..11c9883bcb 100644 --- a/res/css/_components.pcss +++ b/res/css/_components.pcss @@ -53,8 +53,6 @@ @import "./components/views/settings/shared/_SettingsSubsectionHeading.pcss"; @import "./components/views/spaces/_QuickThemeSwitcher.pcss"; @import "./components/views/typography/_Caption.pcss"; -@import "./components/views/utils/_Box.pcss"; -@import "./components/views/utils/_Flex.pcss"; @import "./compound/_Icon.pcss"; @import "./compound/_SuccessDialog.pcss"; @import "./structures/_AutoHideScrollbar.pcss"; @@ -81,6 +79,7 @@ @import "./structures/_SearchBox.pcss"; @import "./structures/_SpaceHierarchy.pcss"; @import "./structures/_SpacePanel.pcss"; +@import "./structures/_SpacePillButton.pcss"; @import "./structures/_SpaceRoomView.pcss"; @import "./structures/_SplashPage.pcss"; @import "./structures/_TabbedView.pcss"; @@ -97,7 +96,6 @@ @import "./structures/auth/_Registration.pcss"; @import "./structures/auth/_SessionLockStolenView.pcss"; @import "./structures/auth/_SetupEncryptionBody.pcss"; -@import "./views/audio_messages/_AudioPlayer.pcss"; @import "./views/audio_messages/_PlayPauseButton.pcss"; @import "./views/audio_messages/_PlaybackContainer.pcss"; @import "./views/audio_messages/_SeekBar.pcss"; @@ -123,9 +121,6 @@ @import "./views/context_menus/_DeviceContextMenu.pcss"; @import "./views/context_menus/_IconizedContextMenu.pcss"; @import "./views/context_menus/_LegacyCallContextMenu.pcss"; -@import "./views/context_menus/_MessageContextMenu.pcss"; -@import "./views/context_menus/_RoomGeneralContextMenu.pcss"; -@import "./views/context_menus/_RoomNotificationContextMenu.pcss"; @import "./views/dialogs/_AddExistingToSpaceDialog.pcss"; @import "./views/dialogs/_AnalyticsLearnMoreDialog.pcss"; @import "./views/dialogs/_BugReportDialog.pcss"; @@ -145,6 +140,7 @@ @import "./views/dialogs/_GenericFeatureFeedbackDialog.pcss"; @import "./views/dialogs/_IncomingSasDialog.pcss"; @import "./views/dialogs/_InviteDialog.pcss"; +@import "./views/dialogs/_InviteProgressBody.pcss"; @import "./views/dialogs/_JoinRuleDropdown.pcss"; @import "./views/dialogs/_LeaveSpaceDialog.pcss"; @import "./views/dialogs/_LocationViewDialog.pcss"; @@ -177,7 +173,6 @@ @import "./views/dialogs/_WidgetCapabilitiesPromptDialog.pcss"; @import "./views/dialogs/security/_AccessSecretStorageDialog.pcss"; @import "./views/dialogs/security/_CreateCrossSigningDialog.pcss"; -@import "./views/dialogs/security/_CreateKeyBackupDialog.pcss"; @import "./views/dialogs/security/_CreateSecretStorageDialog.pcss"; @import "./views/dialogs/security/_KeyBackupFailedDialog.pcss"; @import "./views/dialogs/security/_RestoreKeyBackupDialog.pcss"; @@ -212,6 +207,7 @@ @import "./views/elements/_SSOButtons.pcss"; @import "./views/elements/_SearchWarning.pcss"; @import "./views/elements/_ServerPicker.pcss"; +@import "./views/elements/_SettingsDropdown.pcss"; @import "./views/elements/_SettingsFlag.pcss"; @import "./views/elements/_Spinner.pcss"; @import "./views/elements/_StyledRadioButton.pcss"; @@ -241,7 +237,6 @@ @import "./views/messages/_MLocationBody.pcss"; @import "./views/messages/_MNoticeBody.pcss"; @import "./views/messages/_MPollBody.pcss"; -@import "./views/messages/_MPollEndBody.pcss"; @import "./views/messages/_MStickerBody.pcss"; @import "./views/messages/_MTextBody.pcss"; @import "./views/messages/_MVideoBody.pcss"; @@ -280,7 +275,6 @@ @import "./views/rooms/RoomListPanel/_RoomListItemView.pcss"; @import "./views/rooms/RoomListPanel/_RoomListPanel.pcss"; @import "./views/rooms/RoomListPanel/_RoomListPrimaryFilters.pcss"; -@import "./views/rooms/RoomListPanel/_RoomListSearch.pcss"; @import "./views/rooms/RoomListPanel/_RoomListSecondaryFilters.pcss"; @import "./views/rooms/RoomListPanel/_RoomListSkeleton.pcss"; @import "./views/rooms/_AppsDrawer.pcss"; @@ -288,7 +282,6 @@ @import "./views/rooms/_AuxPanel.pcss"; @import "./views/rooms/_BasicMessageComposer.pcss"; @import "./views/rooms/_CallGuestLinkButton.pcss"; -@import "./views/rooms/_DecryptionFailureBar.pcss"; @import "./views/rooms/_E2EIcon.pcss"; @import "./views/rooms/_E2EIconView.pcss"; @import "./views/rooms/_EditMessageComposer.pcss"; @@ -390,7 +383,6 @@ @import "./views/spaces/_SpaceBasicSettings.pcss"; @import "./views/spaces/_SpaceChildrenPicker.pcss"; @import "./views/spaces/_SpaceCreateMenu.pcss"; -@import "./views/spaces/_SpacePublicShare.pcss"; @import "./views/terms/_InlineTermsAgreement.pcss"; @import "./views/toasts/_AnalyticsToast.pcss"; @import "./views/toasts/_IncomingCallToast.pcss"; diff --git a/res/css/_compound.pcss b/res/css/_compound.pcss new file mode 100644 index 0000000000..2bc482551d --- /dev/null +++ b/res/css/_compound.pcss @@ -0,0 +1,3 @@ +/* Modules bundled with compound apply compound lastly. In order to catch issue due to css class ordering, we put compound at the end */ +@import url("@vector-im/compound-design-tokens/assets/web/css/compound-design-tokens.css") layer(compound); +@import url("@vector-im/compound-web/dist/style.css"); diff --git a/res/css/_font-sizes.pcss b/res/css/_font-sizes.pcss index 528cc3c462..98ebf28af0 100644 --- a/res/css/_font-sizes.pcss +++ b/res/css/_font-sizes.pcss @@ -12,31 +12,39 @@ Please see LICENSE files in the repository root for full details. * These are defined in `rem` so that they scale with the `font-size` of the root element (which is adjustable via the * "Font size" setting). They exist to make the job of converting designs (which tend to be based in pixels) into CSS * easier. + */ + +/* + * These variables are now *deprecated* and should not be used in new code; instead Compound typographic tokens + * should be used. Direct equivalents for these old font size tokens are listed below; where no equivalent exists, + * that suggests that the design is using a non-standard font size and should be updated. * + * In fact, modern Figma designs should actually use a named Typography style such as "Web/font/heading/sm/semibold", + * translates directly to `font: var(--cpd-font-heading-sm-semibold)`. */ $font-1px: 0.0625rem; $font-8px: 0.5rem; $font-9px: 0.5625rem; $font-10px: 0.625rem; $font-10-4px: 0.6275rem; -$font-11px: 0.6875rem; +$font-11px: 0.6875rem; /* Compound equivalent: --cpd-font-size-body-xs */ $font-12px: 0.75rem; -$font-13px: 0.8125rem; +$font-13px: 0.8125rem; /* Compound equivalent: --cpd-font-size-body-sm */ $font-14px: 0.875rem; -$font-15px: 0.9375rem; +$font-15px: 0.9375rem; /* Compound equivalent: --cpd-font-size-body-md */ $font-16px: 1rem; -$font-17px: 1.0625rem; +$font-17px: 1.0625rem; /* Compound equivalent: --cpd-font-size-body-lg */ $font-18px: 1.125rem; -$font-20px: 1.25rem; +$font-20px: 1.25rem; /* Compound equivalent: --cpd-font-size-heading-sm */ $font-22px: 1.375rem; $font-23px: 1.4375rem; -$font-24px: 1.5rem; +$font-24px: 1.5rem; /* Compound equivalent: --cpd-font-size-heading-md */ $font-25px: 1.5625rem; $font-26px: 1.625rem; -$font-28px: 1.75rem; +$font-28px: 1.75rem; /* Compound equivalent: --cpd-font-size-heading-lg */ $font-29px: 1.8125rem; $font-30px: 1.875rem; -$font-32px: 2rem; +$font-32px: 2rem; /* Compound equivalent: --cpd-font-size-heading-xl */ $font-34px: 2.125rem; $font-35px: 2.1875rem; $font-39px: 2.4375rem; diff --git a/res/css/components/views/dialogs/polls/_PollListItem.pcss b/res/css/components/views/dialogs/polls/_PollListItem.pcss index 6cb46a21d2..cd24c75937 100644 --- a/res/css/components/views/dialogs/polls/_PollListItem.pcss +++ b/res/css/components/views/dialogs/polls/_PollListItem.pcss @@ -15,7 +15,7 @@ Please see LICENSE files in the repository root for full details. display: grid; justify-content: left; align-items: center; - grid-gap: $spacing-8; + gap: $spacing-8; grid-template-columns: auto auto auto; grid-template-rows: auto; cursor: pointer; diff --git a/res/css/components/views/dialogs/polls/_PollListItemEnded.pcss b/res/css/components/views/dialogs/polls/_PollListItemEnded.pcss index 772b47c9a4..2eb7a185ac 100644 --- a/res/css/components/views/dialogs/polls/_PollListItemEnded.pcss +++ b/res/css/components/views/dialogs/polls/_PollListItemEnded.pcss @@ -22,7 +22,7 @@ Please see LICENSE files in the repository root for full details. display: grid; justify-content: left; align-items: center; - grid-gap: $spacing-8; + gap: $spacing-8; grid-template-columns: min-content 1fr min-content; grid-template-rows: auto; } @@ -47,7 +47,7 @@ Please see LICENSE files in the repository root for full details. .mx_PollListItemEnded_answers { display: grid; - grid-gap: $spacing-8; + gap: $spacing-8; margin-top: $spacing-12; } diff --git a/res/css/components/views/elements/_FilterDropdown.pcss b/res/css/components/views/elements/_FilterDropdown.pcss index 22eb5f2ff1..5385a614cc 100644 --- a/res/css/components/views/elements/_FilterDropdown.pcss +++ b/res/css/components/views/elements/_FilterDropdown.pcss @@ -40,7 +40,7 @@ Please see LICENSE files in the repository root for full details. } .mx_Dropdown_arrow { - background: $secondary-content; + color: $secondary-content; } } diff --git a/res/css/components/views/polls/_PollOption.pcss b/res/css/components/views/polls/_PollOption.pcss index 42ec7c8dac..4fecce4752 100644 --- a/res/css/components/views/polls/_PollOption.pcss +++ b/res/css/components/views/polls/_PollOption.pcss @@ -50,8 +50,43 @@ Please see LICENSE files in the repository root for full details. } .mx_PollOption_checked { - border-color: var(--cpd-color-border-interactive-hovered); + .mx_PollOption_popularityBackground { + .mx_PollOption_popularityAmount { + background-color: var(--cpd-color-icon-primary); + } + } + /* override checked radio button styling to show checkmark instead */ + .mx_StyledRadioButton_checked { + input[type="radio"]:checked + div { + position: relative; + border-width: 2px; + border-color: var(--cpd-color-icon-primary); + background-color: var(--cpd-color-icon-primary); + + &::before { + content: ""; + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + mask-image: url("@vector-im/compound-design-tokens/icons/check.svg"); + mask-size: 12px 12px; + mask-repeat: no-repeat; + mask-position: center; + background-color: var(--cpd-color-icon-on-solid-primary); + width: 12px; + height: 12px; + } + + div { + visibility: hidden; + } + } + } +} + +.mx_PollOption_ended.mx_PollOption_checked { .mx_PollOption_popularityBackground { .mx_PollOption_popularityAmount { background-color: var(--cpd-color-icon-accent-tertiary); @@ -61,17 +96,8 @@ Please see LICENSE files in the repository root for full details. /* override checked radio button styling to show checkmark instead */ .mx_StyledRadioButton_checked { input[type="radio"]:checked + div { - border-width: 2px; border-color: var(--cpd-color-icon-accent-tertiary); background-color: var(--cpd-color-icon-accent-tertiary); - background-image: url("@vector-im/compound-design-tokens/icons/check.svg"); - background-size: 12px; - background-repeat: no-repeat; - background-position: center; - - div { - visibility: hidden; - } } } } @@ -94,6 +120,6 @@ Please see LICENSE files in the repository root for full details. width: 0%; height: 8px; border-radius: 8px; - background-color: $quaternary-content; + background-color: var(--cpd-color-icon-primary); } } diff --git a/res/css/components/views/settings/devices/_DeviceDetailHeading.pcss b/res/css/components/views/settings/devices/_DeviceDetailHeading.pcss index 789efa9e7f..82b19b9ff1 100644 --- a/res/css/components/views/settings/devices/_DeviceDetailHeading.pcss +++ b/res/css/components/views/settings/devices/_DeviceDetailHeading.pcss @@ -19,7 +19,7 @@ Please see LICENSE files in the repository root for full details. .mx_DeviceDetailHeading_renameForm { display: grid; - grid-gap: $spacing-16; + gap: $spacing-16; justify-content: left; grid-template-columns: 100%; } diff --git a/res/css/components/views/settings/devices/_DeviceDetails.pcss b/res/css/components/views/settings/devices/_DeviceDetails.pcss index d3635710f3..4b311d1c7c 100644 --- a/res/css/components/views/settings/devices/_DeviceDetails.pcss +++ b/res/css/components/views/settings/devices/_DeviceDetails.pcss @@ -23,7 +23,7 @@ Please see LICENSE files in the repository root for full details. border-bottom: 1px solid $quinary-content; display: grid; - grid-gap: $spacing-24; + gap: $spacing-24; justify-content: left; grid-template-columns: 100%; diff --git a/res/css/components/views/settings/devices/_DeviceTile.pcss b/res/css/components/views/settings/devices/_DeviceTile.pcss index e4096329d6..07ee70792d 100644 --- a/res/css/components/views/settings/devices/_DeviceTile.pcss +++ b/res/css/components/views/settings/devices/_DeviceTile.pcss @@ -35,7 +35,7 @@ Please see LICENSE files in the repository root for full details. .mx_DeviceTile_actions { display: grid; - grid-gap: $spacing-8; + gap: $spacing-8; grid-auto-flow: column; margin-left: $spacing-8; } diff --git a/res/css/components/views/settings/devices/_FilteredDeviceList.pcss b/res/css/components/views/settings/devices/_FilteredDeviceList.pcss index aac5986280..06f5a80b65 100644 --- a/res/css/components/views/settings/devices/_FilteredDeviceList.pcss +++ b/res/css/components/views/settings/devices/_FilteredDeviceList.pcss @@ -15,7 +15,7 @@ Please see LICENSE files in the repository root for full details. .mx_FilteredDeviceList_list { list-style-type: none; display: grid; - grid-gap: $spacing-16; + gap: $spacing-16; margin: 0; padding: 0 $spacing-16; } diff --git a/res/css/components/views/settings/shared/_SettingsSubsection.pcss b/res/css/components/views/settings/shared/_SettingsSubsection.pcss index 0d03a12b1d..77ed0f9f82 100644 --- a/res/css/components/views/settings/shared/_SettingsSubsection.pcss +++ b/res/css/components/views/settings/shared/_SettingsSubsection.pcss @@ -20,9 +20,10 @@ Please see LICENSE files in the repository root for full details. /** * The gap between subsections is 32px and inside the subsection is 24px. * The separator separates the subsections, so it should have the same gap as the subsections. - * We add 12px and the separator spacing to the top margin to make the separator visually centered between the subsections. + * We add 12px and the separator spacing (8px) to the top margin to make the separator visually centered between the subsections. + * !important because it's overriding compound margin. */ - margin-top: calc(var(--cpd-space-3x) + var(--cpd-separator-spacing)); + margin-top: var(--cpd-space-5x) !important; } } @@ -39,7 +40,7 @@ Please see LICENSE files in the repository root for full details. .mx_SettingsSubsection_content { width: 100%; display: grid; - grid-gap: $spacing-8; + gap: var(--cpd-space-4x); /* setting minwidth 0 makes columns definitely sized fixing horizontal overflow */ grid-template-columns: minmax(0, 1fr); justify-items: flex-start; diff --git a/res/css/rethemendex.sh b/res/css/rethemendex.sh index 37090b96d8..3f0ed28e3f 100755 --- a/res/css/rethemendex.sh +++ b/res/css/rethemendex.sh @@ -8,7 +8,7 @@ cd `dirname $0` # we used to have exclude /themes from the find at this point. # as themes are no longer a spurious subdirectory of css/, we don't # need it any more. - find . -iname _\*.pcss | fgrep -v _components.pcss | LC_ALL=C sort | + find . -iname _\*.pcss | fgrep -v _components.pcss | fgrep -v _compound.pcss | LC_ALL=C sort | while read i; do echo "@import \"$i\";" done diff --git a/res/css/shared.pcss b/res/css/shared.pcss new file mode 100644 index 0000000000..42f8393666 --- /dev/null +++ b/res/css/shared.pcss @@ -0,0 +1,9 @@ +/* + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial + * Please see LICENSE files in the repository root for full details. + */ + +@import url("@vector-im/compound-design-tokens/assets/web/css/compound-design-tokens.css") layer(compound); +@import url("@vector-im/compound-web/dist/style.css"); diff --git a/res/css/structures/ErrorView.pcss b/res/css/structures/ErrorView.pcss index ddc510e188..c7eebd450e 100644 --- a/res/css/structures/ErrorView.pcss +++ b/res/css/structures/ErrorView.pcss @@ -24,7 +24,7 @@ Please see LICENSE files in the repository root for full details. background-color: var(--cpd-color-theme-bg); background-image: url("../../themes/element/img/compound/fade-arc-light.png"); background-repeat: no-repeat; - background-size: 100% 100%; + background-size: 100%; .mx_ErrorView_logo { display: block; @@ -50,7 +50,7 @@ Please see LICENSE files in the repository root for full details. color: var(--cpd-color-text-secondary); } - .mx_Flex { + .mx_ErrorView_flexContainer { margin: 0 auto; max-width: max-content; flex-wrap: wrap; diff --git a/res/css/structures/_GenericDropdownMenu.pcss b/res/css/structures/_GenericDropdownMenu.pcss index 1d0fb79648..a2aa022eee 100644 --- a/res/css/structures/_GenericDropdownMenu.pcss +++ b/res/css/structures/_GenericDropdownMenu.pcss @@ -13,6 +13,12 @@ Please see LICENSE files in the repository root for full details. user-select: none; font-size: $font-12px; color: $secondary-content; + + svg { + width: 18px; + height: 18px; + float: right; + } } .mx_GenericDropdownMenu_button:hover, @@ -20,17 +26,6 @@ Please see LICENSE files in the repository root for full details. background: $quinary-content; } -.mx_GenericDropdownMenu_button::before { - content: ""; - width: 18px; - height: 18px; - background: currentColor; - mask-image: url("@vector-im/compound-design-tokens/icons/chevron-down.svg"); - mask-size: 100%; - mask-repeat: no-repeat; - float: right; -} - .mx_ContextualMenu_wrapper.mx_GenericDropdownMenu_wrapper { .mx_ContextualMenu { position: initial; @@ -97,16 +92,12 @@ Please see LICENSE files in the repository root for full details. background-color: $menu-selected-color; } - &[aria-checked="true"]::before { - content: ""; + .mx_GenericDropdownMenu_Option--checkIcon { width: 16px; height: 16px; margin-left: -22px; margin-right: 6px; - mask-image: url("@vector-im/compound-design-tokens/icons/check.svg"); - mask-size: 100%; - mask-repeat: no-repeat; - background-color: $primary-content; + color: $primary-content; display: inline-block; vertical-align: middle; } diff --git a/res/css/structures/_HomePage.pcss b/res/css/structures/_HomePage.pcss index 4999bae3fe..505fc4bad6 100644 --- a/res/css/structures/_HomePage.pcss +++ b/res/css/structures/_HomePage.pcss @@ -88,11 +88,11 @@ Please see LICENSE files in the repository root for full details. } &.mx_HomePage_button_explore::before { - mask-image: url("$(res)/img/element-icons/roomlist/explore.svg"); + mask-image: url("@vector-im/compound-design-tokens/icons/explore.svg"); } &.mx_HomePage_button_createGroup::before { - mask-image: url("$(res)/img/element-icons/group-members.svg"); + mask-image: url("@vector-im/compound-design-tokens/icons/group.svg"); } } } diff --git a/res/css/structures/_LeftPanel.pcss b/res/css/structures/_LeftPanel.pcss index c1886b6b80..f290772e20 100644 --- a/res/css/structures/_LeftPanel.pcss +++ b/res/css/structures/_LeftPanel.pcss @@ -18,9 +18,6 @@ Please see LICENSE files in the repository root for full details. flex-direction: column; max-width: 50%; position: relative; - - /* Contain the amount of layers rendered by constraining what actually needs re-layering via css */ - contain: layout paint; } .mx_LeftPanel_wrapper, @@ -28,12 +25,6 @@ Please see LICENSE files in the repository root for full details. --collapsedWidth: 68px; } -.mx_LeftPanel_newRoomList { - /* Thew new rooms list is not designed to be collapsed to just icons. */ - /* 224 + 68(spaces bar) was deemed by design to be a good minimum for the left panel. */ - --collapsedWidth: 224px; -} - .mx_LeftPanel_wrapper { display: flex; flex-direction: row; @@ -123,67 +114,30 @@ Please see LICENSE files in the repository root for full details. margin-top: 12px; } - .mx_LeftPanel_dialPadButton { - width: 32px; - height: 32px; + .mx_LeftPanel_dialPadButton, + .mx_LeftPanel_exploreButton { + width: 20px; + height: 20px; + padding: var(--cpd-space-1-5x); border-radius: 8px; background-color: $panel-actions; - position: relative; margin-left: 8px; - &::before { - content: ""; - position: absolute; - top: 8px; - left: 8px; - width: 16px; - height: 16px; - mask-image: url("$(res)/img/element-icons/call/dialpad.svg"); - mask-position: center; - mask-size: contain; - mask-repeat: no-repeat; - background-color: $secondary-content; - } - } - - .mx_LeftPanel_exploreButton, - .mx_LeftPanel_recentsButton { - width: 32px; - height: 32px; - border-radius: 8px; - background-color: $panel-actions; - position: relative; - margin-left: 8px; - - &::before { - content: ""; - position: absolute; - top: 8px; - left: 8px; - width: 16px; - height: 16px; - mask-position: center; - mask-size: contain; - mask-repeat: no-repeat; - background-color: $secondary-content; + svg { + width: inherit; + height: inherit; + display: block; + color: $secondary-content; } &:hover { background-color: $tertiary-content; - &::before { - background-color: $background; + svg { + color: $background; } } } - - .mx_LeftPanel_exploreButton::before { - mask-image: url("$(res)/img/element-icons/roomlist/explore.svg"); - } - - .mx_LeftPanel_recentsButton::before { - mask-image: url("@vector-im/compound-design-tokens/icons/time.svg"); - } } .mx_LegacyRoomListHeader:first-child { @@ -237,8 +191,7 @@ Please see LICENSE files in the repository root for full details. background-color: transparent; } - .mx_LeftPanel_exploreButton, - .mx_LeftPanel_recentsButton { + .mx_LeftPanel_exploreButton { margin-left: 0; margin-top: 8px; } @@ -246,3 +199,11 @@ Please see LICENSE files in the repository root for full details. } } } + +.mx_LeftPanel_newRoomList { + /* Thew new rooms list is not designed to be collapsed to just icons. */ + /* 224 + 68(spaces bar) was deemed by design to be a good minimum for the left panel. */ + --collapsedWidth: 224px; + /* Important to force the color on ED titlebar until we remove the old room list */ + background-color: var(--cpd-color-bg-canvas-default) !important; +} diff --git a/res/css/structures/_QuickSettingsButton.pcss b/res/css/structures/_QuickSettingsButton.pcss index f4b881f955..b681cecb30 100644 --- a/res/css/structures/_QuickSettingsButton.pcss +++ b/res/css/structures/_QuickSettingsButton.pcss @@ -7,39 +7,59 @@ Please see LICENSE files in the repository root for full details. */ .mx_QuickSettingsButton { - flex: 0 0 auto; - border-radius: 8px; - position: relative; - margin: 12px auto; - color: $secondary-content; - min-width: 32px; - min-height: 32px; - line-height: 32px; + /* !important override compound */ + border-radius: 8px !important; + margin: 12px auto 12px; - &.expanded { - margin-left: 20px; - padding-left: 44px; /* align with toggle collapse button text */ - padding-right: 8px; - } - - &::before { - content: ""; - position: absolute; - width: 32px; - height: 32px; - left: 0; - mask-image: url("@vector-im/compound-design-tokens/icons/settings-solid.svg"); - mask-repeat: no-repeat; - mask-position: center; - mask-size: 24px; - background: $secondary-content; + svg { + fill: $secondary-content; } &:not(.expanded):hover { - background-color: $quaternary-content; + /** + * override compound default background color when hovered + * should disappear when the space panel will be migrated to compound + */ + background-color: $quaternary-content !important; + color: $primary-content; - &::before { - background-color: $primary-content; + svg { + fill: $primary-content; + } + } + + &.expanded { + /** + * override compound default background color when hovered + * should disappear when the space panel will be migrated to compound + */ + background-color: transparent !important; + + /* align with settings icon */ + margin-left: 21px; + margin-right: 8px; + width: 100%; + + /** + * modify internal css of the compound component + * dirty but we need to add the label into the indicator icon button + **/ + & > div { + display: flex; + align-items: center; + } + + svg { + /* align with settings label */ + margin-right: 14px; + /* required to set the icon width when into a flex container */ + width: 24px; + flex-shrink: 0; + } + + & .mx_QuickSettingsButton_label { + /* !important override compound */ + color: $secondary-content !important; } } } diff --git a/res/css/structures/_RightPanel.pcss b/res/css/structures/_RightPanel.pcss index f96f8b7232..8c1ed8691f 100644 --- a/res/css/structures/_RightPanel.pcss +++ b/res/css/structures/_RightPanel.pcss @@ -24,36 +24,6 @@ Please see LICENSE files in the repository root for full details. } } -/** Fixme - factor this out with the main header **/ - -.mx_RightPanel_threadsButton::before { - mask-image: url("@vector-im/compound-design-tokens/icons/threads-solid.svg"); -} - -.mx_RightPanel_notifsButton::before { - mask-image: url("$(res)/img/element-icons/notifications.svg"); - mask-position: center; -} - -.mx_RightPanel_roomSummaryButton::before { - mask-image: url("@vector-im/compound-design-tokens/icons/info-solid.svg"); - mask-position: center; -} - -.mx_RightPanel_pinnedMessagesButton { - &::before { - mask-image: url("$(res)/img/element-icons/room/pin.svg"); - mask-position: center; - } -} - -.mx_RightPanel_timelineCardButton { - &::before { - mask-image: url("$(res)/img/element-icons/feedback.svg"); - mask-position: center; - } -} - .mx_RightPanel .mx_MemberList, .mx_RightPanel .mx_MemberInfo { order: 2; diff --git a/res/css/structures/_RoomSearch.pcss b/res/css/structures/_RoomSearch.pcss index 8c82f73ea4..beb60e7ef9 100644 --- a/res/css/structures/_RoomSearch.pcss +++ b/res/css/structures/_RoomSearch.pcss @@ -26,10 +26,7 @@ Please see LICENSE files in the repository root for full details. .mx_RoomSearch_icon { width: 20px; height: 20px; - mask-image: url("@vector-im/compound-design-tokens/icons/search.svg"); - mask-repeat: no-repeat; - mask-size: contain; - background-color: $secondary-content; + color: $secondary-content; margin-left: var(--cpd-space-2x); flex-shrink: 0; } @@ -90,7 +87,7 @@ Please see LICENSE files in the repository root for full details. } .mx_RoomSearch_icon { - background-color: $background; + color: $background; } } } diff --git a/res/css/structures/_RoomStatusBar.pcss b/res/css/structures/_RoomStatusBar.pcss index 19bcc4cb97..f3b1737812 100644 --- a/res/css/structures/_RoomStatusBar.pcss +++ b/res/css/structures/_RoomStatusBar.pcss @@ -99,34 +99,17 @@ Please see LICENSE files in the repository root for full details. position: relative; user-select: none; - &:nth-child(2) { + & + .mx_AccessibleButton { border-left: 1px solid $resend-button-divider-color; } - &::before { - content: ""; - position: absolute; + svg { left: 10px; /* inset for regular button padding */ - background-color: $muted-fg-color; - mask-repeat: no-repeat; - mask-position: center; - mask-size: contain; + width: 18px; height: 18px; - top: 50%; /* text sizes are dynamic */ - transform: translateY(-50%); - } - - &.mx_RoomStatusBar_unsentCancelAllBtn::before { - mask-image: url("@vector-im/compound-design-tokens/icons/delete.svg"); - } - - &.mx_RoomStatusBar_unsentRetry { - padding-left: 34px; /* 28px from above, but +6px to account for the wider icon */ - - &::before { - mask-image: url("@vector-im/compound-design-tokens/icons/restart.svg"); - } + vertical-align: middle; + color: $muted-fg-color; } } diff --git a/res/css/structures/_RoomView.pcss b/res/css/structures/_RoomView.pcss index 5012013cf3..1ff009f16c 100644 --- a/res/css/structures/_RoomView.pcss +++ b/res/css/structures/_RoomView.pcss @@ -59,18 +59,13 @@ Please see LICENSE files in the repository root for full details. background-repeat: no-repeat; position: relative; - &::before { - background-color: $info-plinth-fg-color; - mask: url("@vector-im/compound-design-tokens/icons/search.svg"); - mask-repeat: no-repeat; - mask-position: center; - mask-size: 50px; - content: ""; + svg { + color: $info-plinth-fg-color; + width: 50px; + height: 50px; position: absolute; top: 286px; - left: 0; - right: 0; - height: 50px; + left: calc(50% - 25px); } } @@ -79,6 +74,8 @@ Please see LICENSE files in the repository root for full details. flex-direction: column; flex: 1; min-width: 0; + container-type: size; + container-name: roomview; .mx_RoomView_messagePanel, .mx_RoomView_messagePanelSpinner, diff --git a/res/css/structures/_SearchBox.pcss b/res/css/structures/_SearchBox.pcss index dcb7bbb85e..b8a4f82022 100644 --- a/res/css/structures/_SearchBox.pcss +++ b/res/css/structures/_SearchBox.pcss @@ -9,6 +9,7 @@ Please see LICENSE files in the repository root for full details. .mx_SearchBox { flex: 1 1 0; min-width: 0; + min-height: 36px; /* to avoid jumping when the X to clear shows/hides */ &.mx_SearchBox_blurred:not(:hover) { background-color: transparent; @@ -16,13 +17,14 @@ Please see LICENSE files in the repository root for full details. .mx_SearchBox_closeButton { cursor: pointer; - mask-image: url("@vector-im/compound-design-tokens/icons/close.svg"); - mask-repeat: no-repeat; - mask-position: center; - mask-size: 16px; - width: 16px; height: 16px; + width: 16px; padding: 9px; - background-color: var(--cpd-color-icon-secondary); + + svg { + height: inherit; + width: inherit; + color: var(--cpd-color-icon-secondary); + } } } diff --git a/res/css/structures/_SpaceHierarchy.pcss b/res/css/structures/_SpaceHierarchy.pcss index 88c40b6fab..7f6a429543 100644 --- a/res/css/structures/_SpaceHierarchy.pcss +++ b/res/css/structures/_SpaceHierarchy.pcss @@ -109,22 +109,14 @@ Please see LICENSE files in the repository root for full details. border-radius: 4px; background-color: $background; - &::before { - content: ""; - position: absolute; - top: 0; - left: 0; - height: 16px; - width: 16px; - mask-repeat: no-repeat; - mask-position: center; - background-color: $tertiary-content; - mask-size: 16px; + svg { + width: inherit; + height: inherit; + color: $tertiary-content; transform: rotate(270deg); - mask-image: url("@vector-im/compound-design-tokens/icons/chevron-down.svg"); } - &.mx_SpaceHierarchy_subspace_toggle_shown::before { + &.mx_SpaceHierarchy_subspace_toggle_shown svg { transform: rotate(0deg); } } @@ -177,18 +169,6 @@ Please see LICENSE files in the repository root for full details. color: $tertiary-content; font-size: $font-12px; line-height: $font-15px; - - .mx_InfoTooltip_icon { - margin-right: 4px; - position: relative; - vertical-align: text-top; - - &::before { - position: absolute; - top: 0; - left: 0; - } - } } .mx_InfoTooltip { @@ -196,22 +176,14 @@ Please see LICENSE files in the repository root for full details. } .mx_SpaceHierarchy_roomTile_joined { - display: inline; - position: relative; - padding-left: 16px; + display: inline-flex; + align-items: center; + vertical-align: middle; - &::before { - content: ""; + svg { width: 20px; height: 20px; - top: -2px; - left: -4px; - position: absolute; - mask-position: center; - mask-size: contain; - mask-repeat: no-repeat; - background-color: $accent; - mask-image: url("@vector-im/compound-design-tokens/icons/check.svg"); + color: $accent; } } } diff --git a/res/css/structures/_SpacePanel.pcss b/res/css/structures/_SpacePanel.pcss index 64044c4c5c..a20e26403d 100644 --- a/res/css/structures/_SpacePanel.pcss +++ b/res/css/structures/_SpacePanel.pcss @@ -44,29 +44,23 @@ Please see LICENSE files in the repository root for full details. top: 19px; /* v-align with avatar */ right: -8px; - &::before { - content: ""; - position: absolute; - width: inherit; + svg { height: inherit; - mask-position: center; - mask-size: contain; - mask-repeat: no-repeat; - background-color: $background; - mask-image: url("@vector-im/compound-design-tokens/icons/chevron-down.svg"); - transform: rotate(270deg); + width: inherit; + display: inline-block; + color: $background; + /* Slight alignment tweak to center the asset */ + margin-left: 1px; } &:not(.expanded) { opacity: 0; - - &::before { - mask-position: center 1px; - } } - &.expanded::before { - transform: rotate(90deg); + &.expanded svg { + transform: rotate(180deg); + /* Slight alignment tweak to center the asset */ + margin-left: -1px; } } @@ -103,7 +97,6 @@ Please see LICENSE files in the repository root for full details. & > .mx_SpaceButton > .mx_SpaceButton_toggleCollapse { padding: 0 10px; margin: 0 -10px; - transform: rotate(-90deg); } & > .mx_SpaceTreeLevel { @@ -166,105 +159,67 @@ Please see LICENSE files in the repository root for full details. } .mx_SpaceButton_toggleCollapse { - width: var(--gutterSize); - padding: 10px 0; - min-width: var(--gutterSize); height: 20px; - mask-position: center; - mask-size: 20px; - mask-repeat: no-repeat; - background-color: $tertiary-content; - mask-image: url("@vector-im/compound-design-tokens/icons/chevron-down.svg"); + width: var(--gutterSize); + flex-shrink: 0; + padding: 10px 0; + + svg { + width: 20px; + height: inherit; + display: inline-block; + color: $tertiary-content; + /* Re-align with parent */ + margin-left: -3px; + } } .mx_SpaceButton_icon { - width: var(--height-topLevel); - min-width: var(--height-topLevel); - height: var(--height-topLevel); + /* Calculate height excluding padding to allow svg to inherit */ + width: calc(var(--height-topLevel) - 14px); + height: calc(var(--height-topLevel) - 14px); + flex-shrink: 0; border-radius: 8px; - position: relative; + padding: 7px; + background-color: $panel-actions; - &::before { - position: absolute; - content: ""; - width: var(--height-topLevel); - height: var(--height-topLevel); - top: 0; - left: 0; - mask-position: center; - mask-repeat: no-repeat; - mask-size: 18px; + svg { + width: inherit; + height: inherit; + display: block; + color: $secondary-content; } } - &.mx_SpaceButton_home, - &.mx_SpaceButton_favourites, - &.mx_SpaceButton_people, - &.mx_SpaceButton_orphans, - &.mx_SpaceButton_videoRooms { - .mx_SpaceButton_icon { - background-color: $panel-actions; - - &::before { - background-color: $secondary-content; - } - } - } - - &.mx_SpaceButton_home .mx_SpaceButton_icon::before { - mask-image: url("@vector-im/compound-design-tokens/icons/home-solid.svg"); - } - - &.mx_SpaceButton_favourites .mx_SpaceButton_icon::before { - mask-image: url("@vector-im/compound-design-tokens/icons/favourite-solid.svg"); - } - - &.mx_SpaceButton_people .mx_SpaceButton_icon::before { - mask-image: url("@vector-im/compound-design-tokens/icons/user-profile-solid.svg"); - } - - &.mx_SpaceButton_orphans .mx_SpaceButton_icon::before { - mask-image: url("$(res)/img/element-icons/roomlist/hash-circle.svg"); - } - - &.mx_SpaceButton_videoRooms .mx_SpaceButton_icon::before { - mask-image: url("@vector-im/compound-design-tokens/icons/video-call-solid.svg"); - } - &.mx_SpaceButton_new .mx_SpaceButton_icon { - &::before { - background-color: $primary-content; - mask-image: url("@vector-im/compound-design-tokens/icons/plus.svg"); + background-color: unset; + + svg { + color: $primary-content; transition: all 0.2s ease-in-out; /* TODO transition */ } } - &.mx_SpaceButton_newCancel .mx_SpaceButton_icon::before { + &.mx_SpaceButton_newCancel .mx_SpaceButton_icon svg { transform: rotate(45deg); } .mx_SpaceButton_menuButton { - width: 20px; - min-width: 20px; /* yay flex */ - height: 20px; + width: 16px; + height: 16px; + padding: var(--cpd-space-0-5x); + flex-shrink: 0; margin-top: auto; margin-bottom: auto; display: none; position: absolute; right: 4px; - &::before { - top: 3px; - left: 2px; - content: ""; - width: 16px; - height: 16px; - position: absolute; - mask-position: center; - mask-size: contain; - mask-repeat: no-repeat; - mask-image: url("@vector-im/compound-design-tokens/icons/overflow-horizontal.svg"); - background: $primary-content; + svg { + width: inherit; + height: inherit; + display: block; + color: $primary-content; } } } @@ -337,18 +292,6 @@ Please see LICENSE files in the repository root for full details. padding: 0 0 16px 0; scrollbar-gutter: stable; - & > .mx_SpaceButton { - height: var(--height-topLevel); - - &.mx_SpaceButton_active::before { - height: var(--height-topLevel); - } - } - - & > ul { - padding-left: 0; - } - &.mx_IndicatorScrollbar_topOverflow { mask-image: linear-gradient(to bottom, transparent, black 16px); } @@ -424,46 +367,6 @@ Please see LICENSE files in the repository root for full details. white-space: nowrap; } - .mx_SpacePanel_iconHome::before { - mask-image: url("@vector-im/compound-design-tokens/icons/home-solid.svg"); - } - - .mx_SpacePanel_iconInvite::before { - mask-image: url("$(res)/img/element-icons/room/invite.svg"); - } - - .mx_SpacePanel_iconSettings::before { - mask-image: url("@vector-im/compound-design-tokens/icons/settings-solid.svg"); - } - - .mx_SpacePanel_iconLeave::before { - mask-image: url("@vector-im/compound-design-tokens/icons/leave.svg"); - } - - .mx_SpacePanel_iconMembers::before { - mask-image: url("@vector-im/compound-design-tokens/icons/user-profile-solid.svg"); - } - - .mx_SpacePanel_iconPlus::before { - mask-image: url("@vector-im/compound-design-tokens/icons/plus.svg"); - } - - .mx_SpacePanel_iconExplore::before { - mask-image: url("$(res)/img/element-icons/roomlist/hash-search.svg"); - } - - .mx_SpacePanel_iconPreferences::before { - mask-image: url("$(res)/img/element-icons/settings/preference.svg"); - } - - .mx_SpacePanel_noIcon { - display: none; - - & + .mx_IconizedContextMenu_label { - padding-left: 5px !important; /* override default iconized label style to align with header */ - } - } - .mx_SpacePanel_contextMenu_separatorLabel { color: $tertiary-content; font-size: $font-10px; diff --git a/res/css/structures/_SpacePillButton.pcss b/res/css/structures/_SpacePillButton.pcss new file mode 100644 index 0000000000..eed2feb48a --- /dev/null +++ b/res/css/structures/_SpacePillButton.pcss @@ -0,0 +1,48 @@ +/* +Copyright 2025 Element Creations Ltd. + +SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial +Please see LICENSE files in the repository root for full details. +*/ + +.mx_SpacePillButton { + position: relative; + padding: 16px 32px 16px 72px; + width: 432px; + box-sizing: border-box; + border-radius: 8px; + border: 1px solid $input-border-color; + font-size: $font-17px; + font-weight: var(--cpd-font-weight-semibold); + margin: 20px 0; + + > div { + margin-top: 4px; + font-weight: normal; + font-size: $font-15px; + color: $secondary-content; + } + + svg { + position: absolute; + content: ""; + width: 28px; + height: 28px; + top: 50%; + transform: translateY(-50%); + left: 22px; + color: $tertiary-content; + } + + &:hover { + border-color: var(--cpd-color-bg-action-primary-rest); + + svg { + color: var(--cpd-color-icon-primary); + } + + > span { + color: $primary-content; + } + } +} diff --git a/res/css/structures/_SpaceRoomView.pcss b/res/css/structures/_SpaceRoomView.pcss index 3f981ba2de..25989fd937 100644 --- a/res/css/structures/_SpaceRoomView.pcss +++ b/res/css/structures/_SpaceRoomView.pcss @@ -6,51 +6,6 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com Please see LICENSE files in the repository root for full details. */ -@define-mixin SpacePillButton { - position: relative; - padding: 16px 32px 16px 72px; - width: 432px; - box-sizing: border-box; - border-radius: 8px; - border: 1px solid $input-border-color; - font-size: $font-17px; - font-weight: var(--cpd-font-weight-semibold); - margin: 20px 0; - - > div { - margin-top: 4px; - font-weight: normal; - font-size: $font-15px; - color: $secondary-content; - } - - &::before { - position: absolute; - content: ""; - width: 28px; - height: 28px; - top: 50%; - transform: translateY(-50%); - left: 22px; - mask-position: center; - mask-repeat: no-repeat; - mask-size: 28px; - background-color: $tertiary-content; - } - - &:hover { - border-color: var(--cpd-color-bg-action-primary-rest); - - &::before { - background-color: var(--cpd-color-icon-primary); - } - - > span { - color: $primary-content; - } - } -} - .mx_SpaceRoomView { --innerWidth: 428px; @@ -207,7 +162,7 @@ Please see LICENSE files in the repository root for full details. width: 16px; background: var(--cpd-color-icon-on-solid-primary); mask-size: 16px; - mask-image: url("$(res)/img/element-icons/room/invite.svg"); + mask-image: url("@vector-im/compound-design-tokens/icons/user-add.svg"); } } @@ -242,20 +197,6 @@ Please see LICENSE files in the repository root for full details. } } - .mx_SpaceRoomView_privateScope { - > .mx_AccessibleButton { - @mixin SpacePillButton; - } - - .mx_SpaceRoomView_privateScope_justMeButton::before { - mask-image: url("@vector-im/compound-design-tokens/icons/user-profile-solid.svg"); - } - - .mx_SpaceRoomView_privateScope_meAndMyTeammatesButton::before { - mask-image: url("$(res)/img/element-icons/group-members.svg"); - } - } - .mx_SpaceRoomView_inviteTeammates { .mx_SpaceRoomView_inviteTeammates_buttons { color: $secondary-content; @@ -291,7 +232,7 @@ Please see LICENSE files in the repository root for full details. text-decoration: underline; &::before { - mask-image: url("$(res)/img/element-icons/room/invite.svg"); + mask-image: url("@vector-im/compound-design-tokens/icons/user-add.svg"); background-color: var(--cpd-color-icon-primary); } } diff --git a/res/css/structures/_SplashPage.pcss b/res/css/structures/_SplashPage.pcss index bd57fbb48d..8f06541179 100644 --- a/res/css/structures/_SplashPage.pcss +++ b/res/css/structures/_SplashPage.pcss @@ -32,7 +32,7 @@ Please see LICENSE files in the repository root for full details. /* mask to dither resulting combined gradient */ url("$(res)/img/noise.png"), /* gradient to apply different amounts of dithering to different parts of the gradient */ - linear-gradient( + linear-gradient( to bottom, /* 10% dithering at the top */ rgb(0, 0, 0, 0.9) 20%, /* 80% dithering at the bottom */ rgb(0, 0, 0, 0.2) 100% diff --git a/res/css/structures/_ThreadsActivityCentre.pcss b/res/css/structures/_ThreadsActivityCentre.pcss index a1472108ac..a6e4627dd0 100644 --- a/res/css/structures/_ThreadsActivityCentre.pcss +++ b/res/css/structures/_ThreadsActivityCentre.pcss @@ -11,7 +11,8 @@ } .mx_ThreadsActivityCentreButton { - border-radius: 8px; + /* !important override compound */ + border-radius: 8px !important; margin: 18px auto auto auto; &.expanded { @@ -23,6 +24,8 @@ /* align with settings icon */ margin-left: 21px; + margin-right: 8px; + width: 100%; /** * modify internal css of the compound component @@ -31,30 +34,39 @@ & > div { display: flex; align-items: center; + + &::before { + inline-size: 8px; + block-size: 8px; + left: 17px; + } } & .mx_ThreadsActivityCentreButton_Icon { /* align with settings label */ margin-right: 14px; /* required to set the icon width when into a flex container */ - min-width: 24px; + width: 24px; + flex-shrink: 0; } & .mx_ThreadsActivityCentreButton_Text { - color: $secondary-content; + /* !important override compound */ + color: $secondary-content !important; } } &:not(.expanded) { &:hover, &:hover .mx_ThreadsActivityCentreButton_Icon { - background-color: $quaternary-content; - color: $primary-content; + /* !important override compound */ + background-color: $quaternary-content !important; + fill: $primary-content; } } & .mx_ThreadsActivityCentreButton_Icon { - color: $secondary-content; + fill: $secondary-content; } } diff --git a/res/css/structures/_ToastContainer.pcss b/res/css/structures/_ToastContainer.pcss index cf1d7fd4a9..68b39cae61 100644 --- a/res/css/structures/_ToastContainer.pcss +++ b/res/css/structures/_ToastContainer.pcss @@ -41,52 +41,10 @@ Please see LICENSE files in the repository root for full details. padding: var(--cpd-space-3x); &.mx_Toast_hasIcon { - &::before, - &::after { - content: ""; + svg { width: 22px; height: 22px; grid-column: 1; - grid-row: 1; - mask-size: 100%; - mask-position: center; - mask-repeat: no-repeat; - background-size: 100%; - background-repeat: no-repeat; - } - - &.mx_Toast_icon_verification::after { - mask-image: url("$(res)/img/e2e/normal.svg"); - background-color: $primary-content; - } - - &.mx_Toast_icon_verification_warning { - /* white infill for the hollow svg mask */ - &::before { - background-color: #ffffff; - mask-image: url("$(res)/img/e2e/normal.svg"); - mask-size: 80%; - } - - &::after { - mask-image: url("$(res)/img/e2e/warning.svg"); - background-color: $e2e-warning-color; - } - } - - &.mx_Toast_icon_secure_backup::after { - mask-image: url("$(res)/img/feather-customised/secure-backup.svg"); - background-color: $primary-content; - } - - &.mx_Toast_icon_key_storage::after { - mask-image: url("@vector-im/compound-design-tokens/icons/settings-solid.svg"); - background-color: $primary-content; - } - - &.mx_Toast_icon_labs::after { - mask-image: url("$(res)/img/element-icons/flask.svg"); - background-color: $secondary-content; } .mx_Toast_title, diff --git a/res/css/structures/_UploadBar.pcss b/res/css/structures/_UploadBar.pcss index 8bc341c057..2c34ce7f7e 100644 --- a/res/css/structures/_UploadBar.pcss +++ b/res/css/structures/_UploadBar.pcss @@ -7,12 +7,44 @@ Please see LICENSE files in the repository root for full details. */ .mx_UploadBar { - padding-left: 65px; /* line up with the shield area in the composer */ - padding-top: 5px; + /* line up with the shield area in the composer */ + padding: 5px 21px 0; position: relative; + display: grid; + grid-template: + "icon filename cancel" auto + "progress progress progress" auto + / min-content auto min-content; + gap: var(--cpd-space-1-5x); + + & > svg { + grid-area: icon; + height: 18px; + width: 18px; + color: $muted-fg-color; + align-self: center; + } + + .mx_UploadBar_filename { + grid-area: filename; + color: $muted-fg-color; + position: relative; + font-size: $font-15px; + vertical-align: middle; + } + + .mx_UploadBar_cancel { + grid-area: cancel; + height: 16px; + width: 16px; + color: $muted-fg-color; + align-self: center; + } + .mx_ProgressBar { - width: calc(100% - 40px); /* cheating at a right margin */ + grid-area: progress; + width: 100%; } } @@ -21,39 +53,3 @@ Please see LICENSE files in the repository root for full details. padding-left: 0; } } - -.mx_UploadBar_filename { - color: $muted-fg-color; - position: relative; - padding-right: 38px; /* 32px for cancel icon, 6px for padding */ - padding-left: 22px; /* 18px for icon, 4px for padding */ - font-size: $font-15px; - vertical-align: middle; - - &::before { - content: ""; - height: 18px; - width: 18px; - position: absolute; - top: 0; - left: 0; - mask-repeat: no-repeat; - mask-position: center; - background-color: $muted-fg-color; - mask-image: url("$(res)/img/element-icons/upload.svg"); - } -} - -.mx_UploadBar_cancel { - position: absolute; - top: 0; - right: 0; - height: 16px; - width: 16px; - margin-right: 16px; /* align over rightmost button in composer */ - margin-top: 5px; - mask-repeat: no-repeat; - mask-position: center; - background-color: $muted-fg-color; - mask-image: url("@vector-im/compound-design-tokens/icons/close.svg"); -} diff --git a/res/css/structures/_UserMenu.pcss b/res/css/structures/_UserMenu.pcss index 2f4099c893..ddefc84d8f 100644 --- a/res/css/structures/_UserMenu.pcss +++ b/res/css/structures/_UserMenu.pcss @@ -116,48 +116,7 @@ Please see LICENSE files in the repository root for full details. } } - .mx_IconizedContextMenu_icon { - width: 16px; - height: 16px; - display: block; - - &::before { - content: ""; - width: 16px; - height: 16px; - display: block; - mask-position: center; - mask-size: contain; - mask-repeat: no-repeat; - background: $icon-button-color; - } - } - - .mx_UserMenu_iconHome::before { - mask-image: url("@vector-im/compound-design-tokens/icons/home-solid.svg"); - } - - .mx_UserMenu_iconBell::before { - mask-image: url("$(res)/img/element-icons/notifications.svg"); - } - - .mx_UserMenu_iconLock::before { - mask-image: url("@vector-im/compound-design-tokens/icons/lock-solid.svg"); - } - - .mx_UserMenu_iconSettings::before { - mask-image: url("@vector-im/compound-design-tokens/icons/settings-solid.svg"); - } - - .mx_UserMenu_iconMessage::before { - mask-image: url("$(res)/img/element-icons/feedback.svg"); - } - - .mx_UserMenu_iconSignOut::before { - mask-image: url("@vector-im/compound-design-tokens/icons/leave.svg"); - } - - .mx_UserMenu_iconQr::before { - mask-image: url("@vector-im/compound-design-tokens/icons/qr-code.svg"); + .mx_IconizedContextMenu_icon svg { + color: $icon-button-color; } } diff --git a/res/css/structures/auth/_CompleteSecurity.pcss b/res/css/structures/auth/_CompleteSecurity.pcss index f0bca3f39c..d8e9e4b4af 100644 --- a/res/css/structures/auth/_CompleteSecurity.pcss +++ b/res/css/structures/auth/_CompleteSecurity.pcss @@ -11,17 +11,16 @@ Please see LICENSE files in the repository root for full details. align-items: center; } -.mx_CompleteSecurity_headerIcon { +.mx_E2EIcon.mx_CompleteSecurity_headerIcon { width: 24px; height: 24px; margin-right: 4px; - position: relative; + display: inline-block; } -.mx_CompleteSecurity_heroIcon { +.mx_E2EIcon.mx_CompleteSecurity_heroIcon { width: 128px; height: 128px; - position: relative; margin: 0 auto; } diff --git a/res/css/structures/auth/_SetupEncryptionBody.pcss b/res/css/structures/auth/_SetupEncryptionBody.pcss index ac6384313c..204e1f5c59 100644 --- a/res/css/structures/auth/_SetupEncryptionBody.pcss +++ b/res/css/structures/auth/_SetupEncryptionBody.pcss @@ -6,13 +6,6 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com Please see LICENSE files in the repository root for full details. */ -.mx_SetupEncryptionBody_reset { - color: $light-fg-color; - margin-top: $font-14px; - - .mx_SetupEncryptionBody_reset_link { - &.mx_AccessibleButton_kind_link_inline { - color: $alert; - } - } +.mx_SetupEncryptionBody { + width: 600px; } diff --git a/res/css/views/audio_messages/_AudioPlayer.pcss b/res/css/views/audio_messages/_AudioPlayer.pcss deleted file mode 100644 index 51e97611f5..0000000000 --- a/res/css/views/audio_messages/_AudioPlayer.pcss +++ /dev/null @@ -1,59 +0,0 @@ -/* -Copyright 2024 New Vector Ltd. -Copyright 2021 The Matrix.org Foundation C.I.C. - -SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial -Please see LICENSE files in the repository root for full details. -*/ - -.mx_MediaBody.mx_AudioPlayer_container { - padding: 16px 12px 12px 12px; - - .mx_AudioPlayer_primaryContainer { - display: flex; - - .mx_PlayPauseButton { - margin-right: 8px; - } - - .mx_AudioPlayer_mediaInfo { - flex: 1; - overflow: hidden; /* makes the ellipsis on the file name work */ - - & > * { - display: block; - } - - .mx_AudioPlayer_mediaName { - color: $primary-content; - font-size: $font-15px; - line-height: $font-15px; - text-overflow: ellipsis; - white-space: nowrap; - overflow: hidden; - padding-bottom: 4px; /* mimics the line-height differences in the Figma */ - } - - .mx_AudioPlayer_byline { - font-size: $font-12px; - line-height: $font-12px; - } - } - } - - .mx_AudioPlayer_seek { - display: flex; - align-items: center; - - .mx_SeekBar { - flex: 1; - } - - .mx_Clock { - min-width: $font-42px; /* for flexbox */ - padding-left: $spacing-4; /* isolate from seek bar */ - text-align: justify; - white-space: nowrap; - } - } -} diff --git a/res/css/views/audio_messages/_PlayPauseButton.pcss b/res/css/views/audio_messages/_PlayPauseButton.pcss index e932c282e3..47df79c5d3 100644 --- a/res/css/views/audio_messages/_PlayPauseButton.pcss +++ b/res/css/views/audio_messages/_PlayPauseButton.pcss @@ -14,28 +14,16 @@ Please see LICENSE files in the repository root for full details. min-height: 32px; /* for when the button is used in a flexbox */ border-radius: 32px; background-color: $system; + /* !important override compound icon button */ + padding: var(--cpd-space-1-5x) !important; + box-sizing: border-box; - &::before { - content: ""; - position: absolute; /* sizing varies by icon */ - background-color: $secondary-content; - mask-repeat: no-repeat; - mask-size: contain; - top: 6px; /* center */ - left: 6px; /* center */ + svg { width: 20px; height: 20px; } - &.mx_PlayPauseButton_disabled::before { + &[disabled] svg { opacity: 0.5; } - - &.mx_PlayPauseButton_play::before { - mask-image: url("@vector-im/compound-design-tokens/icons/play-solid.svg"); - } - - &.mx_PlayPauseButton_pause::before { - mask-image: url("@vector-im/compound-design-tokens/icons/pause-solid.svg"); - } } diff --git a/res/css/views/auth/_AuthBody.pcss b/res/css/views/auth/_AuthBody.pcss index d86089e331..f5f0f9cce5 100644 --- a/res/css/views/auth/_AuthBody.pcss +++ b/res/css/views/auth/_AuthBody.pcss @@ -12,7 +12,7 @@ Please see LICENSE files in the repository root for full details. font-size: $font-12px; color: $authpage-secondary-color; background-color: $background; - border-radius: 0 4px 4px 0; + border-radius: 0 var(--cpd-space-6x) var(--cpd-space-6x) 0; padding: 25px 60px; box-sizing: border-box; @@ -119,7 +119,7 @@ Please see LICENSE files in the repository root for full details. } .mx_Dropdown_arrow { - background: $authpage-primary-color; + color: $authpage-primary-color; } .mx_Dropdown_menu { @@ -237,7 +237,7 @@ Please see LICENSE files in the repository root for full details. font-size: $font-15px; line-height: $font-24px; - .mx_InlineSpinner img { + .mx_InlineSpinner svg { vertical-align: sub; margin-right: 5px; } diff --git a/res/css/views/auth/_AuthPage.pcss b/res/css/views/auth/_AuthPage.pcss index 3ae17122be..e7a578b2f1 100644 --- a/res/css/views/auth/_AuthPage.pcss +++ b/res/css/views/auth/_AuthPage.pcss @@ -18,8 +18,7 @@ Please see LICENSE files in the repository root for full details. .mx_AuthPage_modal { display: flex; margin: 100px auto auto; - border-radius: 4px; - box-shadow: 0 2px 4px 0 rgb(0, 0, 0, 0.33); + border-radius: var(--cpd-space-6x); background-color: $authpage-modal-bg-color; @media only screen and (max-height: 768px) { @@ -29,4 +28,14 @@ Please see LICENSE files in the repository root for full details. @media only screen and (max-width: 480px) { margin-top: 0; } + + &.mx_AuthPage_modal_withBlur { + /* Apply a blurred shadow around the modal */ + box-shadow: 0 2px 4px 0 rgb(0, 0, 0, 0.33); + + .mx_AuthPage_modalContent { + /* Make the modal's background semitransparent */ + background-color: $authpage-modal-content-with-blur-bg-color; + } + } } diff --git a/res/css/views/auth/_CompleteSecurityBody.pcss b/res/css/views/auth/_CompleteSecurityBody.pcss index f070129005..3aa9cbf518 100644 --- a/res/css/views/auth/_CompleteSecurityBody.pcss +++ b/res/css/views/auth/_CompleteSecurityBody.pcss @@ -8,11 +8,10 @@ Please see LICENSE files in the repository root for full details. */ .mx_CompleteSecurityBody { - width: 600px; color: $authpage-primary-color; background-color: $background; border-radius: 4px; - padding: 20px; + padding: 20px 20px 60px 20px; box-sizing: border-box; h2 { diff --git a/res/css/views/auth/_LanguageSelector.pcss b/res/css/views/auth/_LanguageSelector.pcss index d5002d00b2..e620cd8ab5 100644 --- a/res/css/views/auth/_LanguageSelector.pcss +++ b/res/css/views/auth/_LanguageSelector.pcss @@ -17,5 +17,5 @@ Please see LICENSE files in the repository root for full details. } .mx_AuthBody_language .mx_Dropdown_arrow { - background: $authpage-lang-color; + color: $authpage-lang-color; } diff --git a/res/css/views/auth/_Welcome.pcss b/res/css/views/auth/_Welcome.pcss index 490b6490f6..50a91aa767 100644 --- a/res/css/views/auth/_Welcome.pcss +++ b/res/css/views/auth/_Welcome.pcss @@ -20,3 +20,8 @@ Please see LICENSE files in the repository root for full details. width: 160px; margin-bottom: 10px; } + +/* Invert image colours in dark mode. */ +.cpd-theme-dark .mx_WelcomePage_logo { + filter: invert(1); +} diff --git a/res/css/views/avatars/_WidgetAvatar.pcss b/res/css/views/avatars/_WidgetAvatar.pcss index 564c9fefc2..c02b717e00 100644 --- a/res/css/views/avatars/_WidgetAvatar.pcss +++ b/res/css/views/avatars/_WidgetAvatar.pcss @@ -7,5 +7,6 @@ Please see LICENSE files in the repository root for full details. */ .mx_WidgetAvatar { - border-radius: 4px; + /* !important override compound avatar */ + border-radius: 4px !important; } diff --git a/res/css/views/context_menus/_DeviceContextMenu.pcss b/res/css/views/context_menus/_DeviceContextMenu.pcss index d70b5eb043..565b2dfd19 100644 --- a/res/css/views/context_menus/_DeviceContextMenu.pcss +++ b/res/css/views/context_menus/_DeviceContextMenu.pcss @@ -9,10 +9,6 @@ Please see LICENSE files in the repository root for full details. .mx_DeviceContextMenu { max-width: 252px; - .mx_DeviceContextMenu_device_icon { - display: none; - } - .mx_IconizedContextMenu_label { padding-left: 0 !important; } diff --git a/res/css/views/context_menus/_IconizedContextMenu.pcss b/res/css/views/context_menus/_IconizedContextMenu.pcss index 853d97c935..ca6ada17f4 100644 --- a/res/css/views/context_menus/_IconizedContextMenu.pcss +++ b/res/css/views/context_menus/_IconizedContextMenu.pcss @@ -68,14 +68,6 @@ Please see LICENSE files in the repository root for full details. cursor: not-allowed; } - img, - .mx_IconizedContextMenu_icon { - /* icons */ - width: 16px; - min-width: 16px; - max-width: 16px; - } - span.mx_IconizedContextMenu_label { /* labels */ width: 100%; @@ -87,8 +79,15 @@ Please see LICENSE files in the repository root for full details. white-space: nowrap; } - .mx_IconizedContextMenu_icon + .mx_IconizedContextMenu_label { - padding-left: 14px; + svg { + width: 16px; + height: 16px; + display: block; + flex-shrink: 0; + + & + .mx_IconizedContextMenu_label { + padding-left: 14px; + } } .mx_BetaCard_betaPill { @@ -97,38 +96,21 @@ Please see LICENSE files in the repository root for full details. } } - .mx_IconizedContextMenu_icon { - position: relative; - width: 16px; - height: 16px; - - &::before { - content: ""; - width: inherit; - height: inherit; - position: absolute; - mask-position: center; - mask-size: contain; - mask-repeat: no-repeat; - background-color: var(--cpd-color-icon-primary); - } - } - .mx_IconizedContextMenu_optionList_red { .mx_IconizedContextMenu_item { color: $alert !important; } - .mx_IconizedContextMenu_icon::before { - background-color: var(--cpd-color-icon-critical-primary); + svg { + color: var(--cpd-color-icon-critical-primary); } } .mx_IconizedContextMenu_option_red { color: $alert !important; - .mx_IconizedContextMenu_icon::before { - background-color: $alert; + svg { + color: $alert; } } @@ -136,10 +118,10 @@ Please see LICENSE files in the repository root for full details. &.mx_IconizedContextMenu_item, .mx_IconizedContextMenu_item { color: $accent !important; - } - .mx_IconizedContextMenu_icon::before { - background-color: $accent; + svg { + color: $accent; + } } } @@ -149,24 +131,11 @@ Please see LICENSE files in the repository root for full details. } } - .mx_IconizedContextMenu_checked, - .mx_IconizedContextMenu_unchecked { + svg.mx_IconizedContextMenu_checked { margin-left: 16px; margin-right: -5px; } - .mx_IconizedContextMenu_developerTools::before { - mask-image: url("$(res)/img/element-icons/settings/flask.svg"); - } - - .mx_IconizedContextMenu_checked::before { - mask-image: url("@vector-im/compound-design-tokens/icons/check.svg"); - } - - .mx_IconizedContextMenu_unchecked::before { - content: unset; - } - .mx_IconizedContextMenu_sublabel { margin-left: 20px; color: $tertiary-content; diff --git a/res/css/views/context_menus/_MessageContextMenu.pcss b/res/css/views/context_menus/_MessageContextMenu.pcss deleted file mode 100644 index 9fc454f328..0000000000 --- a/res/css/views/context_menus/_MessageContextMenu.pcss +++ /dev/null @@ -1,113 +0,0 @@ -/* -Copyright 2024 New Vector Ltd. -Copyright 2021 Michael Weimann -Copyright 2015, 2016 OpenMarket Ltd - -SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial -Please see LICENSE files in the repository root for full details. -*/ - -.mx_MessageContextMenu { - .mx_IconizedContextMenu_icon { - width: 16px; - height: 16px; - display: block; - - &::before { - content: ""; - width: 16px; - height: 16px; - display: block; - mask-position: center; - mask-size: contain; - mask-repeat: no-repeat; - } - } - - .mx_MessageContextMenu_iconCollapse::before { - mask-image: url("@vector-im/compound-design-tokens/icons/chevron-up.svg"); - } - - .mx_MessageContextMenu_iconReport::before { - mask-image: url("@vector-im/compound-design-tokens/icons/error-solid.svg"); - } - - .mx_MessageContextMenu_iconLink::before { - mask-image: url("@vector-im/compound-design-tokens/icons/link.svg"); - } - - .mx_MessageContextMenu_iconPermalink::before { - mask-image: url("$(res)/img/element-icons/room/share.svg"); - } - - .mx_MessageContextMenu_iconUnhidePreview::before { - mask-image: url("$(res)/img/element-icons/settings/appearance.svg"); - } - - .mx_MessageContextMenu_iconOpenInMapSite::before { - mask-image: url("$(res)/img/external-link.svg"); - } - - .mx_MessageContextMenu_iconEndPoll::before { - mask-image: url("@vector-im/compound-design-tokens/icons/check.svg"); - } - - .mx_MessageContextMenu_iconForward::before { - mask-image: url("@vector-im/compound-design-tokens/icons/forward.svg"); - } - - .mx_MessageContextMenu_iconRedact::before { - mask-image: url("@vector-im/compound-design-tokens/icons/delete.svg"); - } - - .mx_MessageContextMenu_iconResend::before { - mask-image: url("@vector-im/compound-design-tokens/icons/restart.svg"); - } - - .mx_MessageContextMenu_iconSource::before { - mask-image: url("@vector-im/compound-design-tokens/icons/inline-code.svg"); - } - - .mx_MessageContextMenu_iconQuote::before { - mask-image: url("@vector-im/compound-design-tokens/icons/quote.svg"); - } - - .mx_MessageContextMenu_iconPin::before { - mask-image: url("@vector-im/compound-design-tokens/icons/pin.svg"); - } - - .mx_MessageContextMenu_iconUnpin::before { - mask-image: url("@vector-im/compound-design-tokens/icons/unpin.svg"); - } - - .mx_MessageContextMenu_iconCopy::before { - height: 16px; - mask-image: url($copy-button-url); - position: relative; - width: 16px; - } - - .mx_MessageContextMenu_iconEdit::before { - mask-image: url("$(res)/img/element-icons/room/message-bar/edit.svg"); - } - - .mx_MessageContextMenu_iconReply::before { - mask-image: url("@vector-im/compound-design-tokens/icons/reply.svg"); - } - - .mx_MessageContextMenu_iconReplyInThread::before { - mask-image: url("@vector-im/compound-design-tokens/icons/threads.svg"); - } - - .mx_MessageContextMenu_iconReact::before { - mask-image: url("$(res)/img/element-icons/room/message-bar/emoji.svg"); - } - - .mx_MessageContextMenu_iconViewInRoom::before { - mask-image: url("$(res)/img/element-icons/view-in-room.svg"); - } - - .mx_MessageContextMenu_jumpToEvent::before { - mask-image: url("$(res)/img/element-icons/child-relationship.svg"); - } -} diff --git a/res/css/views/context_menus/_RoomGeneralContextMenu.pcss b/res/css/views/context_menus/_RoomGeneralContextMenu.pcss deleted file mode 100644 index 0eb51420bb..0000000000 --- a/res/css/views/context_menus/_RoomGeneralContextMenu.pcss +++ /dev/null @@ -1,71 +0,0 @@ -.mx_RoomGeneralContextMenu_iconStar::before { - mask-image: url("@vector-im/compound-design-tokens/icons/favourite-solid.svg"); -} - -.mx_RoomGeneralContextMenu_iconArrowDown::before { - mask-image: url("$(res)/img/element-icons/roomlist/low-priority.svg"); -} - -.mx_RoomGeneralContextMenu_iconMarkAsRead::before { - mask-image: url("$(res)/img/element-icons/roomlist/mark-as-read.svg"); -} - -.mx_RoomGeneralContextMenu_iconMarkAsUnread::before { - mask-image: url("$(res)/img/element-icons/roomlist/mark-as-unread.svg"); -} - -.mx_RoomGeneralContextMenu_iconNotificationsDefault::before { - mask-image: url("$(res)/img/element-icons/notifications.svg"); -} - -.mx_RoomGeneralContextMenu_iconNotificationsAllMessages::before { - mask-image: url("$(res)/img/element-icons/roomlist/notifications-default.svg"); -} - -.mx_RoomGeneralContextMenu_iconNotificationsMentionsKeywords::before { - mask-image: url("$(res)/img/element-icons/roomlist/notifications-dm.svg"); -} - -.mx_RoomGeneralContextMenu_iconNotificationsNone::before { - mask-image: url("$(res)/img/element-icons/roomlist/notifications-off.svg"); -} - -.mx_RoomGeneralContextMenu_iconPeople::before { - mask-image: url("@vector-im/compound-design-tokens/icons/user-profile-solid.svg"); -} - -.mx_RoomGeneralContextMenu_iconFiles::before { - mask-image: url("@vector-im/compound-design-tokens/icons/files.svg"); -} - -.mx_RoomGeneralContextMenu_iconPins::before { - mask-image: url("$(res)/img/element-icons/room/pin-upright.svg"); -} - -.mx_RoomGeneralContextMenu_iconWidgets::before { - mask-image: url("@vector-im/compound-design-tokens/icons/extensions-solid.svg"); -} - -.mx_RoomGeneralContextMenu_iconSettings::before { - mask-image: url("@vector-im/compound-design-tokens/icons/settings-solid.svg"); -} - -.mx_RoomGeneralContextMenu_iconExport::before { - mask-image: url("@vector-im/compound-design-tokens/icons/export-archive.svg"); -} - -.mx_RoomGeneralContextMenu_iconDeveloperTools::before { - mask-image: url("$(res)/img/element-icons/settings/flask.svg"); -} - -.mx_RoomGeneralContextMenu_iconCopyLink::before { - mask-image: url("@vector-im/compound-design-tokens/icons/link.svg"); -} - -.mx_RoomGeneralContextMenu_iconInvite::before { - mask-image: url("$(res)/img/element-icons/room/invite.svg"); -} - -.mx_RoomGeneralContextMenu_iconSignOut::before { - mask-image: url("@vector-im/compound-design-tokens/icons/leave.svg"); -} diff --git a/res/css/views/context_menus/_RoomNotificationContextMenu.pcss b/res/css/views/context_menus/_RoomNotificationContextMenu.pcss deleted file mode 100644 index baf440fd43..0000000000 --- a/res/css/views/context_menus/_RoomNotificationContextMenu.pcss +++ /dev/null @@ -1,12 +0,0 @@ -.mx_RoomNotificationContextMenu_iconBell::before { - mask-image: url("$(res)/img/element-icons/notifications.svg"); -} -.mx_RoomNotificationContextMenu_iconBellDot::before { - mask-image: url("$(res)/img/element-icons/roomlist/notifications-default.svg"); -} -.mx_RoomNotificationContextMenu_iconBellMentions::before { - mask-image: url("$(res)/img/element-icons/roomlist/notifications-dm.svg"); -} -.mx_RoomNotificationContextMenu_iconBellCrossed::before { - mask-image: url("$(res)/img/element-icons/roomlist/notifications-off.svg"); -} diff --git a/res/css/views/dialogs/_ConfirmSpaceUserActionDialog.pcss b/res/css/views/dialogs/_ConfirmSpaceUserActionDialog.pcss index 1921ef2457..e3ce9db7dc 100644 --- a/res/css/views/dialogs/_ConfirmSpaceUserActionDialog.pcss +++ b/res/css/views/dialogs/_ConfirmSpaceUserActionDialog.pcss @@ -32,27 +32,20 @@ Please see LICENSE files in the repository root for full details. } .mx_ConfirmSpaceUserActionDialog_warning { - position: relative; border-radius: 8px; - padding: 12px 8px 12px 42px; + padding: 12px 8px; background-color: $header-panel-bg-color; font-size: $font-12px; line-height: $font-15px; color: $secondary-content; - &::before { - content: ""; - position: absolute; - left: 10px; - top: calc(50% - 8px); /* vertical centering */ + svg { height: 16px; width: 16px; - background-color: $secondary-content; - mask-repeat: no-repeat; - mask-size: contain; - mask-image: url("@vector-im/compound-design-tokens/icons/info-solid.svg"); - mask-position: center; + vertical-align: -4px; + margin-right: var(--cpd-space-1-5x); + color: $secondary-content; } } } diff --git a/res/css/views/dialogs/_CreateRoomDialog.pcss b/res/css/views/dialogs/_CreateRoomDialog.pcss index a96b9ba472..1dff0896e9 100644 --- a/res/css/views/dialogs/_CreateRoomDialog.pcss +++ b/res/css/views/dialogs/_CreateRoomDialog.pcss @@ -57,16 +57,9 @@ Please see LICENSE files in the repository root for full details. width: 100%; } -/* needed to make the alias field only grow as wide as needed */ -/* as opposed to full width */ .mx_CreateRoomDialog_aliasContainer { + /* needed to make the alias field only grow as wide as needed as opposed to full width */ display: flex; - /* put margin on container so it can collapse with siblings */ - margin: 24px 0 10px; - - .mx_RoomAliasField { - margin: 0; - } } .mx_CreateRoomDialog { @@ -102,6 +95,14 @@ Please see LICENSE files in the repository root for full details. margin-top: 24px; } + .mx_Field { + margin: 0; + } + + form { + gap: var(--cpd-space-4x); + } + p { margin: 0 85px 0 0; font-size: $font-12px; diff --git a/res/css/views/dialogs/_DevtoolsDialog.pcss b/res/css/views/dialogs/_DevtoolsDialog.pcss index 3a706345c9..efcf4db372 100644 --- a/res/css/views/dialogs/_DevtoolsDialog.pcss +++ b/res/css/views/dialogs/_DevtoolsDialog.pcss @@ -22,6 +22,16 @@ Please see LICENSE files in the repository root for full details. margin-bottom: 0; } } + + .mx_DevTools_toggleForm { + gap: var(--cpd-space-2x); + } +} + +.mx_DevTools_toolHeading { + color: var(--cpd-color-text-secondary); + font-weight: var(--cpd-font-weight-semibold); + font-size: var(--cpd-font-size-heading-sm); } .mx_DevTools_content { diff --git a/res/css/views/dialogs/_FeedbackDialog.pcss b/res/css/views/dialogs/_FeedbackDialog.pcss index a9e7d97920..88cfdf33a9 100644 --- a/res/css/views/dialogs/_FeedbackDialog.pcss +++ b/res/css/views/dialogs/_FeedbackDialog.pcss @@ -55,33 +55,17 @@ Please see LICENSE files in the repository root for full details. text-decoration: underline; } - &::before, - &::after { - content: ""; + & > svg { position: absolute; - width: 40px; - height: 40px; left: 16px; top: 12px; - } - - &::before { + padding: var(--cpd-space-2x); + width: 24px; + height: 24px; background-color: $icon-button-color; + color: $avatar-initial-color; border-radius: 8px; } - - &::after { - background: $avatar-initial-color; /* TODO */ - mask-position: center; - mask-size: 24px; - mask-repeat: no-repeat; - } - } - - .mx_FeedbackDialog_reportBug { - &::after { - mask-image: url("$(res)/img/feather-customised/bug.svg"); - } } .mx_FeedbackDialog_rateApp { @@ -125,9 +109,5 @@ Please see LICENSE files in the repository root for full details. font-size: 24px; border-color: var(--cpd-color-bg-action-primary-rest); } - - &::after { - mask-image: url("$(res)/img/element-icons/feedback.svg"); - } } } diff --git a/res/css/views/dialogs/_ForwardDialog.pcss b/res/css/views/dialogs/_ForwardDialog.pcss index ecbc19d533..68132f0bbb 100644 --- a/res/css/views/dialogs/_ForwardDialog.pcss +++ b/res/css/views/dialogs/_ForwardDialog.pcss @@ -61,6 +61,11 @@ Please see LICENSE files in the repository root for full details. /* To match the space around the title */ margin: 0 0 15px 0; flex-grow: 0; + + &:not(:focus-within) + .mx_ForwardList_content { + /* Inhibit the styling if focus is not within the input which handles keyboard accessibility */ + --mx_ForwardList_entry_selectedBgColor: transparent; + } } .mx_ForwardList_content { @@ -90,7 +95,7 @@ Please see LICENSE files in the repository root for full details. &:hover, &.mx_ForwardList_entry_active { - background-color: $spacePanel-bg-color; + background-color: var(--mx_ForwardList_entry_selectedBgColor, $spacePanel-bg-color); } .mx_ForwardList_roomButton { @@ -142,7 +147,7 @@ Please see LICENSE files in the repository root for full details. &.mx_ForwardList_sending .mx_ForwardList_sendIcon { background-color: $accent; - mask-image: url("$(res)/img/element-icons/circle-sending.svg"); + mask-image: url("@vector-im/compound-design-tokens/icons/circle.svg"); mask-position: center; mask-repeat: no-repeat; mask-size: 14px; @@ -152,7 +157,7 @@ Please see LICENSE files in the repository root for full details. &.mx_ForwardList_sent .mx_ForwardList_sendIcon { background-color: $accent; - mask-image: url("$(res)/img/element-icons/circle-sent.svg"); + mask-image: url("@vector-im/compound-design-tokens/icons/check-circle.svg"); mask-position: center; mask-repeat: no-repeat; mask-size: 14px; diff --git a/res/css/views/dialogs/_InviteDialog.pcss b/res/css/views/dialogs/_InviteDialog.pcss index 70a8cdc608..cc737c9611 100644 --- a/res/css/views/dialogs/_InviteDialog.pcss +++ b/res/css/views/dialogs/_InviteDialog.pcss @@ -24,76 +24,20 @@ Please see LICENSE files in the repository root for full details. .mx_InviteDialog_editor { flex: 1; - width: 100%; /* Needed to make the Field inside grow */ - background-color: $header-panel-bg-color; - border-radius: 4px; - min-height: 25px; - padding-inline-start: $spacing-8; - overflow-x: hidden; - overflow-y: auto; - display: flex; - flex-wrap: wrap; - - .mx_InviteDialog_userTile { - margin: 6px 6px 0 0; - display: inline-block; - min-width: max-content; /* prevent manipulation by flexbox */ - } - - /* overrides bunch of our default text input styles */ - > input[type="text"] { - margin: 6px 0 !important; - height: 24px; - font: var(--cpd-font-body-md-regular); - line-height: $font-24px; - padding-inline-start: $spacing-12; - border: 0 !important; - outline: 0 !important; - resize: none; - box-sizing: border-box; - min-width: 40%; - flex: 1 !important; - color: $primary-content !important; - } + margin-left: var(--cpd-space-0-5x); } .mx_InviteDialog_goButton { - min-width: 48px; + min-width: 86px; margin-inline-start: 10px; - height: 25px; + height: 41px; line-height: $font-25px; } - - .mx_InviteDialog_buttonAndSpinner { - .mx_Spinner { - /* Width and height are required to trick the layout engine. */ - width: 20px; - height: 20px; - margin-inline-start: 5px; - display: inline-block; - vertical-align: middle; - } - } } .mx_InviteDialog_section { padding-bottom: $spacing-4; - h3 { - font-size: $font-12px; - color: $muted-fg-color; - font-weight: bold; - text-transform: uppercase; - } - - > p { - margin: 0; - } - - > span { - color: $primary-content; - } - .mx_InviteDialog_section_showMore { margin: 7px 18px; display: block; @@ -138,51 +82,6 @@ Please see LICENSE files in the repository root for full details. } } -/* Many of these styles are stolen from mx_UserPill, but adjusted for the invite dialog. */ -.mx_InviteDialog_userTile { - margin-inline-end: $spacing-8; - - .mx_InviteDialog_userTile_pill { - background-color: var(--cpd-color-bg-canvas-default); - border: 1px solid var(--cpd-color-gray-400); - border-radius: 99px; - display: inline-block; - height: 24px; - line-height: $font-24px; - padding-inline: $spacing-8; - vertical-align: middle; - color: var(--cpd-color-gray-1100); - - .mx_SearchResultAvatar { - border-radius: 20px; - position: relative; - left: -5px; - top: 2px; - } - - img.mx_SearchResultAvatar { - vertical-align: top; - } - - .mx_InviteDialog_userTile_name { - vertical-align: top; - } - - .mx_SearchResultAvatar_threepidAvatar { - background-color: #ffffff; /* this is fine without a var because it's for both themes */ - } - } - - .mx_InviteDialog_userTile_remove { - display: inline-block; - vertical-align: middle; - - svg { - vertical-align: middle; - } - } -} - .mx_InviteDialog_other { /* Prevent the dialog from jumping around randomly when elements change. */ display: flex; @@ -205,10 +104,13 @@ Please see LICENSE files in the repository root for full details. .mx_InviteDialog_userSections { flex-grow: 1; padding-inline-end: 0; + display: flex; + flex-direction: column; + margin-top: var(--cpd-space-3x); + gap: var(--cpd-space-3x); .mx_InviteDialog_section { padding-bottom: 0; - margin-top: $spacing-12; } } } @@ -218,6 +120,10 @@ Please see LICENSE files in the repository root for full details. flex-direction: column; flex-grow: 1; overflow: hidden; + + .mx_InviteProgressBody { + margin-top: var(--cpd-space-12x); + } } .mx_InviteDialog_transfer { @@ -256,7 +162,6 @@ Please see LICENSE files in the repository root for full details. } .mx_InviteDialog_userSections { - margin-top: $spacing-4; overflow-y: auto; padding: 0 45px $spacing-4 0; } @@ -318,62 +223,12 @@ Please see LICENSE files in the repository root for full details. margin-inline-start: auto; } -.mx_InviteDialog_userDirectoryIcon::before { - mask-image: url("$(res)/img/voip/tab-userdirectory.svg"); -} - -.mx_InviteDialog_dialPadIcon::before { - mask-image: url("$(res)/img/voip/tab-dialpad.svg"); -} - .mx_InviteDialog_tile { cursor: pointer; display: grid; gap: $spacing-8 $spacing-12; align-items: center; - &.mx_InviteDialog_tile--room { - /* mx_InviteDialog_tile_avatarStack, mx_InviteDialog_tile_nameStack, time */ - grid-template-columns: min-content auto auto; - padding: $spacing-4 $spacing-8; - - &:hover { - background-color: $header-panel-bg-color; - border-radius: 4px; - } - - .mx_InviteDialog_tile--room_selected { - border-radius: 36px; - background-color: var(--cpd-color-bg-success-subtle); - - &::before { - content: ""; - width: 24px; - height: 24px; - grid-column: 1; - grid-row: 1; - mask-image: url("@vector-im/compound-design-tokens/icons/check.svg"); - mask-size: 100%; - mask-repeat: no-repeat; - position: absolute; - top: 6px; /* 50% */ - left: 6px; /* 50% */ - background-color: $primary-content; - } - } - - .mx_InviteDialog_tile--room_time { - margin-inline-start: auto; - width: max-content; - font-size: $font-12px; - color: $muted-fg-color; - } - - .mx_InviteDialog_tile--room_highlight { - font-weight: 900; - } - } - &.mx_InviteDialog_tile--inviterError { grid-template-columns: max-content auto; /* max-content = avatar width */ margin-bottom: $spacing-24; @@ -395,15 +250,11 @@ Please see LICENSE files in the repository root for full details. vertical-align: middle; } - .mx_InviteDialog_tile_avatarStack, - .mx_InviteDialog_tile--room_selected { + .mx_InviteDialog_tile_avatarStack { width: 36px; height: 36px; display: inline-block; position: relative; - } - - .mx_InviteDialog_tile_avatarStack { grid-row-start: 1; grid-column-start: 1; diff --git a/res/css/views/dialogs/_InviteProgressBody.pcss b/res/css/views/dialogs/_InviteProgressBody.pcss new file mode 100644 index 0000000000..e3069a133c --- /dev/null +++ b/res/css/views/dialogs/_InviteProgressBody.pcss @@ -0,0 +1,16 @@ +/* +Copyright 2025 New Vector Ltd. + +SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial +Please see LICENSE files in the repository root for full details. +*/ + +.mx_InviteProgressBody { + text-align: center; + font: var(--cpd-font-body-lg-regular); + + h1 { + color: var(--cpd-color-text-primary); + font: var(--cpd-font-heading-sm-semibold); + } +} diff --git a/res/css/views/dialogs/_JoinRuleDropdown.pcss b/res/css/views/dialogs/_JoinRuleDropdown.pcss index 3f72b09583..602e6db44c 100644 --- a/res/css/views/dialogs/_JoinRuleDropdown.pcss +++ b/res/css/views/dialogs/_JoinRuleDropdown.pcss @@ -18,19 +18,14 @@ Please see LICENSE files in the repository root for full details. min-height: 32px; > div { - padding-left: 30px; - position: relative; + padding-left: var(--cpd-space-1-5x); - &::before { - content: ""; - position: absolute; - height: 16px; + svg { width: 16px; - left: 6px; - top: 8px; - mask-repeat: no-repeat; - mask-position: center; - background-color: $secondary-content; + height: 16px; + color: $secondary-content; + vertical-align: -2px; + margin-right: var(--cpd-space-1-5x); } &.mx_JoinRuleDropdown_knock::before { @@ -39,22 +34,6 @@ Please see LICENSE files in the repository root for full details. } } - .mx_JoinRuleDropdown_invite::before { - box-sizing: border-box; - mask-image: url("@vector-im/compound-design-tokens/icons/lock-solid.svg"); - mask-size: contain; - padding: 1px; - } - - .mx_JoinRuleDropdown_public::before { - mask-image: url("@vector-im/compound-design-tokens/icons/public.svg"); - } - - .mx_JoinRuleDropdown_restricted::before { - mask-image: url("$(res)/img/element-icons/group-members.svg"); - mask-size: contain; - } - .mx_JoinRuleDropdown_icon { color: $secondary-content; position: absolute; diff --git a/res/css/views/dialogs/_LeaveSpaceDialog.pcss b/res/css/views/dialogs/_LeaveSpaceDialog.pcss index d988466653..194f741d89 100644 --- a/res/css/views/dialogs/_LeaveSpaceDialog.pcss +++ b/res/css/views/dialogs/_LeaveSpaceDialog.pcss @@ -25,28 +25,21 @@ Please see LICENSE files in the repository root for full details. overflow-y: auto; .mx_LeaveSpaceDialog_section_warning { - position: relative; border-radius: 8px; margin: 12px 0 0; - padding: 12px 8px 12px 42px; + padding: 12px 8px; background-color: $header-panel-bg-color; font-size: $font-12px; line-height: $font-15px; color: $secondary-content; - &::before { - content: ""; - position: absolute; - left: 10px; - top: calc(50% - 8px); /* vertical centering */ + svg { height: 16px; width: 16px; - background-color: $secondary-content; - mask-repeat: no-repeat; - mask-size: contain; - mask-image: url("@vector-im/compound-design-tokens/icons/info-solid.svg"); - mask-position: center; + color: $secondary-content; + vertical-align: middle; + margin: 0 var(--cpd-space-1x); } } diff --git a/res/css/views/dialogs/_ManageRestrictedJoinRuleDialog.pcss b/res/css/views/dialogs/_ManageRestrictedJoinRuleDialog.pcss index ed51968dec..24e05e581e 100644 --- a/res/css/views/dialogs/_ManageRestrictedJoinRuleDialog.pcss +++ b/res/css/views/dialogs/_ManageRestrictedJoinRuleDialog.pcss @@ -87,25 +87,19 @@ Please see LICENSE files in the repository root for full details. position: relative; border-radius: 8px; margin: 12px 0; - padding: 8px 8px 8px 42px; + padding: 8px; background-color: $header-panel-bg-color; font-size: $font-12px; line-height: $font-15px; color: $secondary-content; - &::before { - content: ""; - position: absolute; - left: 10px; - top: calc(50% - 8px); /* vertical centering */ + svg { height: 16px; width: 16px; - background-color: $secondary-content; - mask-repeat: no-repeat; - mask-size: contain; - mask-image: url("@vector-im/compound-design-tokens/icons/info-solid.svg"); - mask-position: center; + color: $secondary-content; + vertical-align: middle; + margin: 0 var(--cpd-space-1x); } } diff --git a/res/css/views/dialogs/_RoomSettingsDialog.pcss b/res/css/views/dialogs/_RoomSettingsDialog.pcss index 7593a72bd9..bc64e9543b 100644 --- a/res/css/views/dialogs/_RoomSettingsDialog.pcss +++ b/res/css/views/dialogs/_RoomSettingsDialog.pcss @@ -5,46 +5,6 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com Please see LICENSE files in the repository root for full details. */ -/* ICONS */ -/* ========================================================== */ - -.mx_RoomSettingsDialog_settingsIcon::before { - mask-image: url("@vector-im/compound-design-tokens/icons/settings-solid.svg"); -} - -.mx_RoomSettingsDialog_voiceIcon::before { - mask-image: url("$(res)/img/element-icons/call/voice-call.svg"); -} - -.mx_RoomSettingsDialog_securityIcon::before { - mask-image: url("@vector-im/compound-design-tokens/icons/lock-solid.svg"); -} - -.mx_RoomSettingsDialog_rolesIcon::before { - mask-image: url("$(res)/img/element-icons/room/settings/roles.svg"); -} - -.mx_RoomSettingsDialog_notificationsIcon::before { - mask-image: url("$(res)/img/element-icons/notifications.svg"); -} - -.mx_RoomSettingsDialog_bridgesIcon::before { - /* This icon is pants, please improve :) */ - mask-image: url("$(res)/img/feather-customised/bridge.svg"); -} - -.mx_RoomSettingsDialog_pollsIcon::before { - mask-image: url("$(res)/img/element-icons/room/composer/poll.svg"); -} - -.mx_RoomSettingsDialog_warningIcon::before { - mask-image: url("$(res)/img/element-icons/room/settings/advanced.svg"); -} - -.mx_RoomSettingsDialog_peopleIcon::before { - mask-image: url("$(res)/img/element-icons/group-members.svg"); -} - .mx_RoomSettingsDialog .mx_Dialog_title { -ms-text-overflow: ellipsis; text-overflow: ellipsis; diff --git a/res/css/views/dialogs/_ServerPickerDialog.pcss b/res/css/views/dialogs/_ServerPickerDialog.pcss index b1346ff73f..63ad6d1942 100644 --- a/res/css/views/dialogs/_ServerPickerDialog.pcss +++ b/res/css/views/dialogs/_ServerPickerDialog.pcss @@ -64,7 +64,7 @@ Please see LICENSE files in the repository root for full details. } .mx_AccessibleButton_kind_primary { - width: calc(100% - 64px); + width: calc(100% - 26px); margin: 0 8px; padding: 15px 18px; } diff --git a/res/css/views/dialogs/_SettingsDialog.pcss b/res/css/views/dialogs/_SettingsDialog.pcss index 186a82c0f5..2b65bff63b 100644 --- a/res/css/views/dialogs/_SettingsDialog.pcss +++ b/res/css/views/dialogs/_SettingsDialog.pcss @@ -30,4 +30,28 @@ Please see LICENSE files in the repository root for full details. /* colliding harshly with the dialog when scrolled down. */ padding-bottom: 100px; } + + .mx_SettingsDialog_tabLabelsAlert::after { + display: inline-block; + content: ""; + width: 8px; + height: 8px; + background-color: var(--cpd-color-icon-critical-primary); + clip-path: circle(4px); + position: absolute; + right: var(--cpd-space-4x); + } +} + +/* On narrow viewports, the tab labels are hidden, so we need to shift the indicator so it isn't over the tab icon. */ +@media (max-width: 1024px) { + .mx_UserSettingsDialog, + .mx_RoomSettingsDialog, + .mx_SpaceSettingsDialog, + .mx_SpacePreferencesDialog { + .mx_SettingsDialog_tabLabelsAlert::after { + right: var(--cpd-space-1x); + top: var(--cpd-space-1x); + } + } } diff --git a/res/css/views/dialogs/_SpacePreferencesDialog.pcss b/res/css/views/dialogs/_SpacePreferencesDialog.pcss index 709c926760..7b969e0ce0 100644 --- a/res/css/views/dialogs/_SpacePreferencesDialog.pcss +++ b/res/css/views/dialogs/_SpacePreferencesDialog.pcss @@ -26,7 +26,3 @@ Please see LICENSE files in the repository root for full details. } } } - -.mx_SpacePreferencesDialog_appearanceIcon::before { - mask-image: url("$(res)/img/element-icons/settings/appearance.svg"); -} diff --git a/res/css/views/dialogs/_SpaceSettingsDialog.pcss b/res/css/views/dialogs/_SpaceSettingsDialog.pcss index 6c48e34b75..e83025af36 100644 --- a/res/css/views/dialogs/_SpaceSettingsDialog.pcss +++ b/res/css/views/dialogs/_SpaceSettingsDialog.pcss @@ -71,14 +71,4 @@ Please see LICENSE files in the repository root for full details. } } } - - .mx_TabbedView_tabLabel { - .mx_SpaceSettingsDialog_generalIcon::before { - mask-image: url("@vector-im/compound-design-tokens/icons/settings-solid.svg"); - } - - .mx_SpaceSettingsDialog_visibilityIcon::before { - mask-image: url("$(res)/img/element-icons/eye.svg"); - } - } } diff --git a/res/css/views/dialogs/_SpotlightDialog.pcss b/res/css/views/dialogs/_SpotlightDialog.pcss index 592431c2f1..67d2ebcc39 100644 --- a/res/css/views/dialogs/_SpotlightDialog.pcss +++ b/res/css/views/dialogs/_SpotlightDialog.pcss @@ -13,11 +13,12 @@ Please see LICENSE files in the repository root for full details. } .mx_Dialog { - width: fit-content; - border-radius: 8px; + /* !important because it's overriding compound */ + width: fit-content !important; + border-radius: 8px !important; + height: 60% !important; overflow-y: initial; position: relative; - height: 60%; padding: 0; contain: unset; /* needed for #mx_SpotlightDialog_keyboardPrompt to not be culled */ @@ -97,7 +98,7 @@ Please see LICENSE files in the repository root for full details. } &.mx_SpotlightDialog_filterPublicRooms::before { - mask-image: url("$(res)/img/element-icons/roomlist/hash-circle.svg"); + mask-image: url("@vector-im/compound-design-tokens/icons/room.svg"); } &.mx_SpotlightDialog_filterPublicSpaces::before { @@ -150,6 +151,16 @@ Please see LICENSE files in the repository root for full details. height: unset; margin-left: $spacing-16; } + + &:not(:focus-within) + #mx_SpotlightDialog_content { + /* Inhibit the styling if focus is not within the input which handles keyboard accessibility */ + --mx_SpotlightDialog_option_selectedBgColor: transparent; + + /* Hide the enter prompt as in this state pressing enter would not actuate that option */ + .mx_SpotlightDialog_enterPrompt { + visibility: hidden; + } + } } #mx_SpotlightDialog_content { @@ -193,6 +204,17 @@ Please see LICENSE files in the repository root for full details. } } + .mx_SpotlightDialog_option { + border-radius: 8px; + overflow: hidden; + text-overflow: ellipsis; + + &:hover, + &[aria-selected="true"] { + background-color: var(--mx_SpotlightDialog_option_selectedBgColor, $quinary-content); + } + } + .mx_SpotlightDialog_recentlyViewed { > div { display: flex; @@ -202,7 +224,6 @@ Please see LICENSE files in the repository root for full details. } .mx_SpotlightDialog_option { - border-radius: 8px; padding: $spacing-4; color: $primary-content; font-size: $font-12px; @@ -213,8 +234,6 @@ Please see LICENSE files in the repository root for full details. min-width: 58px; box-sizing: border-box; text-align: center; - overflow: hidden; - text-overflow: ellipsis; .mx_DecoratedRoomAvatar { margin: 0 9px $spacing-4; /* maintain centering */ @@ -223,11 +242,6 @@ Please see LICENSE files in the repository root for full details. & + .mx_SpotlightDialog_option { margin-left: $spacing-16; } - - &:hover, - &[aria-selected="true"] { - background-color: $quinary-content; - } } } @@ -236,8 +250,9 @@ Please see LICENSE files in the repository root for full details. .mx_SpotlightDialog_otherSearches, .mx_SpotlightDialog_hiddenResults { .mx_SpotlightDialog_option { + --mx_SpotlightDialog_option_selectedBgColor: $system; + padding: 6px $spacing-4; - border-radius: 8px; font-size: $font-15px; line-height: $font-24px; color: $primary-content; @@ -245,8 +260,6 @@ Please see LICENSE files in the repository root for full details. display: flex; align-items: center; white-space: nowrap; - text-overflow: ellipsis; - overflow: hidden; .mx_SpotlightDialog_option--endAdornment { display: inline-flex; @@ -328,41 +341,30 @@ Please see LICENSE files in the repository root for full details. .mx_SpotlightDialog_option--menu, .mx_SpotlightDialog_option--notifications { - width: 20px; - min-width: 20px; - height: 20px; + width: 16px; + height: 16px; + padding: var(--cpd-space-0-5x); + flex-shrink: 0; margin-top: auto; margin-bottom: auto; position: relative; display: none; - &::before { - top: 2px; - left: 2px; - content: ""; - width: 16px; - height: 16px; - position: absolute; - mask-position: center; - mask-size: contain; - mask-repeat: no-repeat; - background: $tertiary-content; + svg { + width: inherit; + height: inherit; + display: block; + color: $tertiary-content; } - &:hover::before, - &[aria-selected="true"]::before { - background-color: $secondary-content; + &:hover svg, + &:focus-visible svg { + color: $secondary-content; } } - .mx_SpotlightDialog_option--menu::before { - mask-image: url("@vector-im/compound-design-tokens/icons/overflow-horizontal.svg"); - } - &:hover, &[aria-selected="true"] { - background-color: $system; - .mx_SpotlightDialog_option--menu, .mx_SpotlightDialog_option--notifications { display: block; @@ -404,7 +406,7 @@ Please see LICENSE files in the repository root for full details. } .mx_SpotlightDialog_createRoom .mx_AccessibleButton::before { - mask-image: url("$(res)/img/element-icons/roomlist/hash.svg"); + mask-image: url("@vector-im/compound-design-tokens/icons/room.svg"); } .mx_SpotlightDialog_otherSearches { @@ -437,11 +439,11 @@ Please see LICENSE files in the repository root for full details. } .mx_SpotlightDialog_joinRoomAlias::before { - mask-image: url("$(res)/img/element-icons/roomlist/hash-circle.svg"); + mask-image: url("@vector-im/compound-design-tokens/icons/room.svg"); } .mx_SpotlightDialog_explorePublicRooms::before { - mask-image: url("$(res)/img/element-icons/roomlist/hash-circle.svg"); + mask-image: url("@vector-im/compound-design-tokens/icons/room.svg"); } .mx_SpotlightDialog_explorePublicSpaces::before { @@ -449,11 +451,11 @@ Please see LICENSE files in the repository root for full details. } .mx_SpotlightDialog_startGroupChat::before { - mask-image: url("$(res)/img/element-icons/group-members.svg"); + mask-image: url("@vector-im/compound-design-tokens/icons/group.svg"); } .mx_SpotlightDialog_searchMessages::before { - mask-image: url("$(res)/img/element-icons/room/search-inset.svg"); + mask-image: url("@vector-im/compound-design-tokens/icons/chat.svg"); } .mx_SpotlightDialog_otherSearches_messageSearchText { @@ -513,7 +515,7 @@ Please see LICENSE files in the repository root for full details. } &.mx_SpotlightDialog_metaspaceResult_orphans-space { - mask-image: url("$(res)/img/element-icons/roomlist/hash-circle.svg"); + mask-image: url("@vector-im/compound-design-tokens/icons/room.svg"); } } } diff --git a/res/css/views/dialogs/_VerifyEMailDialog.pcss b/res/css/views/dialogs/_VerifyEMailDialog.pcss index 32fc8189e9..876962bfd9 100644 --- a/res/css/views/dialogs/_VerifyEMailDialog.pcss +++ b/res/css/views/dialogs/_VerifyEMailDialog.pcss @@ -15,7 +15,8 @@ Please see LICENSE files in the repository root for full details. font: var(--cpd-font-body-md-regular); padding: $spacing-24 $spacing-24 $spacing-16; text-align: center; - width: 485px; + /* !important override compound */ + width: 485px !important; h1 { font-size: $font-24px; diff --git a/res/css/views/dialogs/security/_AccessSecretStorageDialog.pcss b/res/css/views/dialogs/security/_AccessSecretStorageDialog.pcss index 943ec3a41f..2c78a62f8d 100644 --- a/res/css/views/dialogs/security/_AccessSecretStorageDialog.pcss +++ b/res/css/views/dialogs/security/_AccessSecretStorageDialog.pcss @@ -15,6 +15,23 @@ Please see LICENSE files in the repository root for full details. } .mx_AccessSecretStorageDialog_primaryContainer { + .mx_AccessSecretStorageDialog_recoveryKeyEntry { + /* + * Be specific here to avoid "margin: 9px" from _common.pcss + */ + :not(.mx_textinput):not(.mx_Field):not(.mx_no_textinput) { + input { + /* + * From figma: https://www.figma.com/design/ZodBLtGnKmRTGJo5SGLnH3/ER-137--Excluding-Insecure-Devices?node-id=102-43729&t=QmewENUd7f6Tmw9U-1 + */ + width: 448px; + height: 70px; + margin: 0px; + border: 1px solid; + } + } + } + .mx_AccessSecretStorageDialog_recoveryKeyFeedback { &::before { content: ""; @@ -32,7 +49,7 @@ Please see LICENSE files in the repository root for full details. color: $alert; &::before { - mask-image: url("@vector-im/compound-design-tokens/icons/close.svg"); + mask-image: url("@vector-im/compound-design-tokens/icons/error-solid.svg"); background-color: $alert; } } diff --git a/res/css/views/dialogs/security/_CreateKeyBackupDialog.pcss b/res/css/views/dialogs/security/_CreateKeyBackupDialog.pcss deleted file mode 100644 index 9bd8539881..0000000000 --- a/res/css/views/dialogs/security/_CreateKeyBackupDialog.pcss +++ /dev/null @@ -1,73 +0,0 @@ -/* -Copyright 2018-2024 New Vector Ltd. - -SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial -Please see LICENSE files in the repository root for full details. -*/ - -.mx_CreateKeyBackupDialog .mx_Dialog_title { - /* TODO: Consider setting this for all dialog titles. */ - margin-bottom: 1em; -} - -.mx_CreateKeyBackupDialog_primaryContainer { - /* FIXME: plinth colour in new theme(s). background-color: $accent; */ - padding: 20px; -} - -.mx_CreateKeyBackupDialog_primaryContainer::after { - content: ""; - clear: both; - display: block; -} - -.mx_CreateKeyBackupDialog_passPhraseContainer { - display: flex; - align-items: flex-start; -} - -.mx_CreateKeyBackupDialog_passPhraseInput { - flex: none; - width: 250px; - border: 1px solid $accent; - border-radius: 5px; - padding: 10px; - margin-bottom: 1em; -} - -.mx_CreateKeyBackupDialog_passPhraseMatch { - margin-left: 20px; -} - -.mx_CreateKeyBackupDialog_recoveryKeyHeader { - margin-bottom: 1em; -} - -.mx_CreateKeyBackupDialog_recoveryKeyContainer { - display: flex; -} - -.mx_CreateKeyBackupDialog_recoveryKey { - width: 262px; - padding: 20px; - color: $info-plinth-fg-color; - background-color: $info-plinth-bg-color; - margin-right: 12px; -} - -.mx_CreateKeyBackupDialog_recoveryKeyButtons { - flex: 1; - display: flex; - align-items: center; -} - -.mx_CreateKeyBackupDialog_recoveryKeyButtons button { - flex: 1; - white-space: nowrap; -} - -.mx_CreateKeyBackupDialog { - details .mx_AccessibleButton { - margin: 1em 0; /* emulate paragraph spacing because we can't put this button in a paragraph due to HTML rules */ - } -} diff --git a/res/css/views/dialogs/security/_KeyBackupFailedDialog.pcss b/res/css/views/dialogs/security/_KeyBackupFailedDialog.pcss index b02964f64b..f4ca40c66e 100644 --- a/res/css/views/dialogs/security/_KeyBackupFailedDialog.pcss +++ b/res/css/views/dialogs/security/_KeyBackupFailedDialog.pcss @@ -10,20 +10,12 @@ Please see LICENSE files in the repository root for full details. } .mx_KeyBackupFailedDialog_title { - position: relative; - padding-left: 45px; padding-bottom: 10px; - &::before { - mask: url("$(res)/img/e2e/lock-warning-filled.svg"); - mask-repeat: no-repeat; - background-color: $primary-content; - content: ""; - position: absolute; - top: -6px; - right: 0; - bottom: 0; - left: 0; + svg { + margin-right: var(--cpd-space-2x); + vertical-align: -2px; + color: $primary-content; } } diff --git a/res/css/views/directory/_NetworkDropdown.pcss b/res/css/views/directory/_NetworkDropdown.pcss index 47180e3c2f..f728c05091 100644 --- a/res/css/views/directory/_NetworkDropdown.pcss +++ b/res/css/views/directory/_NetworkDropdown.pcss @@ -39,19 +39,19 @@ Please see LICENSE files in the repository root for full details. .mx_GenericDropdownMenu_divider { margin-top: $spacing-4; margin-bottom: $spacing-4; - } -} -.mx_NetworkDropdown_addServer { - font-weight: normal; - font-size: $font-15px; + & + .mx_GenericDropdownMenu_Option .mx_GenericDropdownMenu_Option--label { + font-size: $font-15px; + } + } } .mx_NetworkDropdown_removeServer { position: relative; display: inline-block; - width: 16px; - height: 16px; + width: 14px; + height: 14px; + padding: 1px; background: $quinary-content; border-radius: 8px; text-align: center; @@ -59,19 +59,10 @@ Please see LICENSE files in the repository root for full details. color: $secondary-content; margin-left: auto; - &::before { - background-color: $secondary-content; - content: ""; - mask-repeat: no-repeat; - mask-position: center; - mask-size: 14px; - width: inherit; + svg { + color: $secondary-content; height: inherit; - position: absolute; - left: 50%; - top: 50%; - transform: translate(-50%, -50%); - mask-image: url("@vector-im/compound-design-tokens/icons/close.svg"); + width: inherit; } } diff --git a/res/css/views/elements/_AccessibleButton.pcss b/res/css/views/elements/_AccessibleButton.pcss index c67a7441be..d9d78912f0 100644 --- a/res/css/views/elements/_AccessibleButton.pcss +++ b/res/css/views/elements/_AccessibleButton.pcss @@ -37,6 +37,7 @@ Please see LICENSE files in the repository root for full details. font: var(--cpd-font-body-md-semibold); border: none; /* override default )} {win64Url && ( - )} {win64ArmUrl && ( - )} @@ -193,7 +191,7 @@ export const UnsupportedBrowserView: React.FC<{ )} - + diff --git a/src/async-components/views/dialogs/eventindex/ManageEventIndexDialog.tsx b/src/async-components/views/dialogs/eventindex/ManageEventIndexDialog.tsx index d0680fb4cd..ad46048554 100644 --- a/src/async-components/views/dialogs/eventindex/ManageEventIndexDialog.tsx +++ b/src/async-components/views/dialogs/eventindex/ManageEventIndexDialog.tsx @@ -26,11 +26,25 @@ interface IProps { } interface IState { + /** Size of the event index, in bytes. */ eventIndexSize: number; + + /** Number of events currently indexed in the event index. */ eventCount: number; + + /** Number of rooms currently mentioned in the event index. */ + eventIndexRoomCount: number; + + /** Number of rooms awaiting crawling by the EventIndex. */ crawlingRoomsCount: number; + + /** Number of encrypted rooms known by the MatrixClient. */ roomCount: number; + + /** Room currently being crawled by the EventIndex. */ currentRoom: string | null; + + /** Time to sleep between crawlwer passes, in milliseconds. */ crawlerSleepTime: number; } @@ -44,6 +58,7 @@ export default class ManageEventIndexDialog extends React.Component => { + public updateCurrentRoom = async (room: Room | null): Promise => { const eventIndex = EventIndexPeg.get(); if (!eventIndex) return; let stats: IIndexStats | undefined; @@ -74,6 +89,7 @@ export default class ManageEventIndexDialog extends React.Component { - let eventIndexSize = 0; - let crawlingRoomsCount = 0; - let roomCount = 0; - let eventCount = 0; - let currentRoom: string | null = null; - const eventIndex = EventIndexPeg.get(); if (eventIndex !== null) { eventIndex.on("changedCheckpoint", this.updateCurrentRoom); - try { - const stats = await eventIndex.getStats(); - if (stats) { - eventIndexSize = stats.size; - eventCount = stats.eventCount; - } - } catch { - // This call may fail if sporadically, not a huge issue as we - // will try later again in the updateCurrentRoom call and - // probably succeed. - } - - const roomStats = eventIndex.crawlingRooms(); - crawlingRoomsCount = roomStats.crawlingRooms.size; - roomCount = roomStats.totalRooms.size; - const room = eventIndex.currentRoom(); - if (room) currentRoom = room.name; + await this.updateCurrentRoom(room); } - - this.setState({ - eventIndexSize, - eventCount, - crawlingRoomsCount, - roomCount, - currentRoom, - }); } private onDisable = async (): Promise => { @@ -149,7 +135,7 @@ export default class ManageEventIndexDialog extends React.Component @@ -169,6 +155,10 @@ export default class ManageEventIndexDialog extends React.Component + {_t("settings|security|message_search_pending_rooms", { + pendingRooms: formatCountLong(this.state.crawlingRoomsCount), + })} +
    { - public constructor(props: IProps) { - super(props); - - this.state = { - phase: Phase.BackingUp, - passPhrase: "", - passPhraseValid: false, - passPhraseConfirm: "", - copied: false, - downloaded: false, - }; - } - - public componentDidMount(): void { - this.createBackup(); - } - - private createBackup = async (): Promise => { - this.setState({ - error: undefined, - }); - const cli = MatrixClientPeg.safeGet(); - try { - // Check if 4S already set up - const secretStorageAlreadySetup = await cli.secretStorage.hasKey(); - - if (!secretStorageAlreadySetup) { - // bootstrap secret storage; that will also create a backup version - await accessSecretStorage(async (): Promise => { - // do nothing, all is now set up correctly - }); - } else { - await withSecretStorageKeyCache(async () => { - const crypto = cli.getCrypto(); - if (!crypto) { - throw new Error("End-to-end encryption is disabled - unable to create backup."); - } - - // Before we reset the backup, let's make sure we can access secret storage, to - // reduce the chance of us getting into a broken state where we have an outdated - // secret in secret storage. - // `SecretStorage.get` will ask the user to enter their passphrase/key if necessary; - // it will then be cached for the actual backup reset operation. - await cli.secretStorage.get("m.megolm_backup.v1"); - - // We now know we can store the new backup key in secret storage, so it is safe to - // go ahead with the reset. - await crypto.resetKeyBackup(); - }); - } - - this.setState({ - phase: Phase.Done, - }); - } catch (e) { - logger.error("Error creating key backup", e); - // TODO: If creating a version succeeds, but backup fails, should we - // delete the version, disable backup, or do nothing? If we just - // disable without deleting, we'll enable on next app reload since - // it is trusted. - this.setState({ - error: true, - }); - } - }; - - private onCancel = (): void => { - this.props.onFinished(false); - }; - - private onDone = (): void => { - this.props.onFinished(true); - }; - - private renderBusyPhase(): JSX.Element { - return ( -
    - -
    - ); - } - - private renderPhaseDone(): JSX.Element { - return ( -
    -

    {_t("settings|key_backup|backup_in_progress")}

    - -
    - ); - } - - private titleForPhase(phase: Phase): string { - switch (phase) { - case Phase.BackingUp: - return _t("settings|key_backup|backup_starting"); - case Phase.Done: - return _t("settings|key_backup|backup_success"); - default: - return _t("settings|key_backup|create_title"); - } - } - - public render(): React.ReactNode { - let content; - if (this.state.error) { - content = ( -
    -

    {_t("settings|key_backup|cannot_create_backup")}

    - -
    - ); - } else { - switch (this.state.phase) { - case Phase.BackingUp: - content = this.renderBusyPhase(); - break; - case Phase.Done: - content = this.renderPhaseDone(); - break; - } - } - - return ( - -
    {content}
    -
    - ); - } -} diff --git a/src/async-components/views/dialogs/security/CreateSecretStorageDialog.tsx b/src/async-components/views/dialogs/security/CreateSecretStorageDialog.tsx index 0d17ddaa39..ed2fbe87a5 100644 --- a/src/async-components/views/dialogs/security/CreateSecretStorageDialog.tsx +++ b/src/async-components/views/dialogs/security/CreateSecretStorageDialog.tsx @@ -25,11 +25,6 @@ import StyledRadioButton from "../../../../components/views/elements/StyledRadio import AccessibleButton from "../../../../components/views/elements/AccessibleButton"; import DialogButtons from "../../../../components/views/elements/DialogButtons"; import InlineSpinner from "../../../../components/views/elements/InlineSpinner"; -import { - getSecureBackupSetupMethods, - isSecureBackupRequired, - SecureBackupSetupMethod, -} from "../../../../utils/WellKnownUtils"; import { ModuleRunner } from "../../../../modules/ModuleRunner"; import type Field from "../../../../components/views/elements/Field"; import BaseDialog from "../../../../components/views/dialogs/BaseDialog"; @@ -39,6 +34,11 @@ import { type IValidationResult } from "../../../../components/views/elements/Va import PassphraseConfirmField from "../../../../components/views/auth/PassphraseConfirmField"; import { initialiseDehydrationIfEnabled } from "../../../../utils/device/dehydration"; +enum SecureBackupSetupMethod { + Key = "key", + Passphrase = "passphrase", +} + // I made a mistake while converting this and it has to be fixed! enum Phase { Loading = "loading", @@ -56,7 +56,6 @@ const PASSWORD_MIN_SCORE = 4; // So secure, many characters, much complex, wow, interface IProps { forceReset?: boolean; - resetCrossSigning?: boolean; onFinished(ok?: boolean): void; } @@ -69,7 +68,6 @@ interface IState { downloaded: boolean; setPassphrase: boolean; - canSkip: boolean; passPhraseKeySelected: string; error?: boolean; } @@ -80,11 +78,12 @@ interface IState { * If the user already has a key backup, follows a "migration" flow (aka "Upgrade your encryption") which * prompts the user to enter their backup decryption password (a Curve25519 private key, possibly derived * from a passphrase), and uses that as the (AES) 4S encryption key. + * + * @deprecated send the user to EncryptionUserSettingsTab instead */ export default class CreateSecretStorageDialog extends React.PureComponent { public static defaultProps: Partial = { forceReset: false, - resetCrossSigning: false, }; private recoveryKey?: GeneratedSecretStorageKey; private recoveryKeyNode = createRef(); @@ -93,16 +92,6 @@ export default class CreateSecretStorageDialog extends React.PureComponent => { const cli = MatrixClientPeg.safeGet(); const crypto = cli.getCrypto()!; - const { forceReset, resetCrossSigning } = this.props; + const { forceReset } = this.props; let backupInfo; // First, unless we know we want to do a reset, we see if there is an existing key backup @@ -246,13 +234,6 @@ export default class CreateSecretStorageDialog extends React.PureComponent this.recoveryKey!, setupNewSecretStorage: true, }); - if (resetCrossSigning) { - logger.log("Resetting cross signing"); - await crypto.bootstrapCrossSigning({ - authUploadDeviceSigningKeys: this.doBootstrapUIAuth, - setupNewCrossSigning: true, - }); - } logger.log("Resetting key backup"); await crypto.resetKeyBackup(); } else { @@ -398,11 +379,8 @@ export default class CreateSecretStorageDialog extends React.PureComponent @@ -417,7 +395,6 @@ export default class CreateSecretStorageDialog extends React.PureComponent ); @@ -608,7 +585,6 @@ export default class CreateSecretStorageDialog extends React.PureComponent
    @@ -679,7 +655,6 @@ export default class CreateSecretStorageDialog extends React.PureComponent
    diff --git a/src/async-components/views/dialogs/security/NewRecoveryMethodDialog.tsx b/src/async-components/views/dialogs/security/NewRecoveryMethodDialog.tsx index a2fef4e1c9..2642f01560 100644 --- a/src/async-components/views/dialogs/security/NewRecoveryMethodDialog.tsx +++ b/src/async-components/views/dialogs/security/NewRecoveryMethodDialog.tsx @@ -8,6 +8,7 @@ Please see LICENSE files in the repository root for full details. */ import React, { type JSX, useEffect, useState } from "react"; +import { ErrorIcon } from "@vector-im/compound-design-tokens/assets/web/icons"; import dis from "../../../../dispatcher/dispatcher"; import { _t } from "../../../../languageHandler"; @@ -60,6 +61,7 @@ export default function NewRecoveryMethodDialog({ onFinished }: NewRecoveryMetho onFinished={onFinished} title={ + {_t("encryption|new_recovery_method_detected|title")} } diff --git a/src/async-components/views/dialogs/security/RecoveryMethodRemovedDialog.tsx b/src/async-components/views/dialogs/security/RecoveryMethodRemovedDialog.tsx index 7e82ff722b..8feac6f043 100644 --- a/src/async-components/views/dialogs/security/RecoveryMethodRemovedDialog.tsx +++ b/src/async-components/views/dialogs/security/RecoveryMethodRemovedDialog.tsx @@ -7,14 +7,16 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com Please see LICENSE files in the repository root for full details. */ -import React, { lazy } from "react"; +import React from "react"; +import { ErrorIcon } from "@vector-im/compound-design-tokens/assets/web/icons"; import dis from "../../../../dispatcher/dispatcher"; import { _t } from "../../../../languageHandler"; -import Modal from "../../../../Modal"; import { Action } from "../../../../dispatcher/actions"; +import { UserTab } from "../../../../components/views/dialogs/UserTab"; import BaseDialog from "../../../../components/views/dialogs/BaseDialog"; import DialogButtons from "../../../../components/views/elements/DialogButtons"; +import { type OpenToTabPayload } from "../../../../dispatcher/payloads/OpenToTabPayload"; interface IProps { onFinished(): void; @@ -28,18 +30,20 @@ export default class RecoveryMethodRemovedDialog extends React.PureComponent { this.props.onFinished(); - Modal.createDialog( - lazy(() => import("./CreateKeyBackupDialog")), - undefined, - undefined, - /* priority = */ false, - /* static = */ true, - ); + // Open the user settings dialog to the encryption tab and start the flow to reset encryption + const payload: OpenToTabPayload = { + action: Action.ViewUserSettings, + initialTabId: UserTab.Encryption, + }; + dis.dispatch(payload); }; public render(): React.ReactNode { const title = ( - {_t("encryption|recovery_method_removed|title")} + + + {_t("encryption|recovery_method_removed|title")} + ); return ( diff --git a/src/audio/Playback.ts b/src/audio/Playback.ts index 54d2c710d0..fa0bceffdf 100644 --- a/src/audio/Playback.ts +++ b/src/audio/Playback.ts @@ -9,17 +9,18 @@ Please see LICENSE files in the repository root for full details. import EventEmitter from "events"; import { SimpleObservable } from "matrix-widget-api"; import { logger } from "matrix-js-sdk/src/logger"; +import { clamp } from "@element-hq/web-shared-components"; import { UPDATE_EVENT } from "../stores/AsyncStore"; import { arrayFastResample } from "../utils/arrays"; import { type IDestroyable } from "../utils/IDestroyable"; import { PlaybackClock } from "./PlaybackClock"; import { createAudioContext, decodeOgg } from "./compat"; -import { clamp } from "../utils/numbers"; import { DEFAULT_WAVEFORM, PLAYBACK_WAVEFORM_SAMPLES } from "./consts"; import { PlaybackEncoder } from "../PlaybackEncoder"; export enum PlaybackState { + Preparing = "preparing", // preparing to decode Decoding = "decoding", Stopped = "stopped", // no progress on timeline Paused = "paused", // some progress on timeline @@ -146,6 +147,8 @@ export class Playback extends EventEmitter implements IDestroyable, PlaybackInte return; } + this.state = PlaybackState.Preparing; + // The point where we use an audio element is fairly arbitrary, though we don't want // it to be too low. As of writing, voice messages want to show a waveform but audio // messages do not. Using an audio element means we can't show a waveform preview, so @@ -199,6 +202,7 @@ export class Playback extends EventEmitter implements IDestroyable, PlaybackInte private onPlaybackEnd = async (): Promise => { await this.context.suspend(); this.emit(PlaybackState.Stopped); + this.clock.flagStop(); }; public async play(): Promise { @@ -245,9 +249,8 @@ export class Playback extends EventEmitter implements IDestroyable, PlaybackInte this.emit(PlaybackState.Paused); } - public async stop(): Promise { - await this.onPlaybackEnd(); - this.clock.flagStop(); + public stop(): Promise { + return this.onPlaybackEnd(); } public async toggle(): Promise { diff --git a/src/audio/PlaybackClock.ts b/src/audio/PlaybackClock.ts index 4099483c8e..d279745b36 100644 --- a/src/audio/PlaybackClock.ts +++ b/src/audio/PlaybackClock.ts @@ -74,7 +74,7 @@ export class PlaybackClock implements IDestroyable { // remainder of the division operation, we're assuming that playback is // incomplete or stopped, thus giving an accurate position within the active // clip segment. - return (this.context.currentTime - this.clipStart) % this.clipDuration; + return (this.context.currentTime - this.clipStart) % this.clipDuration || 0; } public get liveData(): SimpleObservable { diff --git a/src/audio/PlaybackQueue.ts b/src/audio/PlaybackQueue.ts index fbca9d7872..025c9d2587 100644 --- a/src/audio/PlaybackQueue.ts +++ b/src/audio/PlaybackQueue.ts @@ -6,7 +6,7 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com Please see LICENSE files in the repository root for full details. */ -import { type MatrixEvent, type Room, EventType } from "matrix-js-sdk/src/matrix"; +import { EventType, type MatrixEvent, type Room } from "matrix-js-sdk/src/matrix"; import { logger } from "matrix-js-sdk/src/logger"; import { type Playback, PlaybackState } from "./Playback"; @@ -15,7 +15,7 @@ import { MatrixClientPeg } from "../MatrixClientPeg"; import { arrayFastClone } from "../utils/arrays"; import { PlaybackManager } from "./PlaybackManager"; import { isVoiceMessage } from "../utils/EventUtils"; -import { SdkContextClass } from "../contexts/SDKContext"; +import { type RoomViewStore } from "../stores/RoomViewStore"; /** * Audio playback queue management for a given room. This keeps track of where the user @@ -38,10 +38,18 @@ export class PlaybackQueue { private currentPlaybackId: string | null = null; // event ID, broken out from above for ease of use private recentFullPlays = new Set(); // event IDs - public constructor(private room: Room) { + /** + * Create a PlaybackQueue for a given room. + * @param room The room + * @param roomViewStore The RoomViewStore instance + */ + public constructor( + private room: Room, + private roomViewStore: RoomViewStore, + ) { this.loadClocks(); - SdkContextClass.instance.roomViewStore.addRoomListener(this.room.roomId, (isActive) => { + this.roomViewStore.addRoomListener(this.room.roomId, (isActive) => { if (!isActive) return; // Reset the state of the playbacks before they start mounting and enqueuing updates. @@ -53,14 +61,20 @@ export class PlaybackQueue { }); } - public static forRoom(roomId: string): PlaybackQueue { + /** + * Get the PlaybackQueue for a given room, creating it if necessary. + * @param roomId The ID of the room + * @param roomViewStore The RoomViewStore instance + * @returns The PlaybackQueue for the room + */ + public static forRoom(roomId: string, roomViewStore: RoomViewStore): PlaybackQueue { const cli = MatrixClientPeg.safeGet(); const room = cli.getRoom(roomId); if (!room) throw new Error("Unknown room"); if (PlaybackQueue.queues.has(room.roomId)) { return PlaybackQueue.queues.get(room.roomId)!; } - const queue = new PlaybackQueue(room); + const queue = new PlaybackQueue(room, roomViewStore); PlaybackQueue.queues.set(room.roomId, queue); return queue; } @@ -76,6 +90,12 @@ export class PlaybackQueue { const val = localStorage.getItem(`mx_voice_message_clocks_${this.room.roomId}`); if (!!val) { this.clockStates = new Map(JSON.parse(val)); + // Clean out any null values (from older versions) + for (const [key, value] of this.clockStates.entries()) { + if (value == null) { + this.clockStates.delete(key); + } + } } } @@ -89,9 +109,11 @@ export class PlaybackQueue { private onPlaybackStateChange(playback: Playback, mxEvent: MatrixEvent, newState: PlaybackState): void { // Remember where the user got to in playback const wasLastPlaying = this.currentPlaybackId === mxEvent.getId(); - if (newState === PlaybackState.Stopped && this.clockStates.has(mxEvent.getId()!) && !wasLastPlaying) { + const currentClockState = this.clockStates.get(mxEvent.getId()!); + + if (newState === PlaybackState.Stopped && currentClockState !== undefined && !wasLastPlaying) { // noinspection JSIgnoredPromiseFromCall - playback.skipTo(this.clockStates.get(mxEvent.getId()!)!); + playback.skipTo(currentClockState); } else if (newState === PlaybackState.Stopped) { // Remove the now-useless clock for some space savings this.clockStates.delete(mxEvent.getId()!); @@ -201,10 +223,8 @@ export class PlaybackQueue { } private onPlaybackClock(playback: Playback, mxEvent: MatrixEvent, clocks: number[]): void { - if (playback.currentState === PlaybackState.Decoding) return; // ignore pre-ready values + if (playback.currentState !== PlaybackState.Playing && playback.currentState !== PlaybackState.Paused) return; // ignore pre-ready values - if (playback.currentState !== PlaybackState.Stopped) { - this.clockStates.set(mxEvent.getId()!, clocks[0]); // [0] is the current seek position - } + this.clockStates.set(mxEvent.getId()!, clocks[0]); // [0] is the current seek position } } diff --git a/src/audio/RecorderWorklet.ts b/src/audio/RecorderWorklet.ts index ec4a143c4e..5d6ce32630 100644 --- a/src/audio/RecorderWorklet.ts +++ b/src/audio/RecorderWorklet.ts @@ -6,8 +6,9 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com Please see LICENSE files in the repository root for full details. */ +import { percentageOf } from "@element-hq/web-shared-components"; + import { type IAmplitudePayload, type ITimingPayload, PayloadEvent, WORKLET_NAME } from "./consts"; -import { percentageOf } from "../utils/numbers"; // from AudioWorkletGlobalScope: https://developer.mozilla.org/en-US/docs/Web/API/AudioWorkletGlobalScope declare const currentTime: number; diff --git a/src/audio/VoiceRecording.ts b/src/audio/VoiceRecording.ts index bde86f9dd7..705b96375a 100644 --- a/src/audio/VoiceRecording.ts +++ b/src/audio/VoiceRecording.ts @@ -11,6 +11,7 @@ import encoderPath from "opus-recorder/dist/encoderWorker.min.js"; import { SimpleObservable } from "matrix-widget-api"; import EventEmitter from "events"; import { logger } from "matrix-js-sdk/src/logger"; +import { clamp } from "@element-hq/web-shared-components"; import MediaDeviceHandler from "../MediaDeviceHandler"; import { type IDestroyable } from "../utils/IDestroyable"; @@ -19,7 +20,6 @@ import { PayloadEvent, WORKLET_NAME } from "./consts"; import { UPDATE_EVENT } from "../stores/AsyncStore"; import { createAudioContext } from "./compat"; import { FixedRollingArray } from "../utils/FixedRollingArray"; -import { clamp } from "../utils/numbers"; import recorderWorkletFactory from "./recorderWorkletFactory"; const CHANNELS = 1; // stereo isn't important diff --git a/src/autocomplete/CommandProvider.tsx b/src/autocomplete/CommandProvider.tsx index 76d53bb865..41781c0a68 100644 --- a/src/autocomplete/CommandProvider.tsx +++ b/src/autocomplete/CommandProvider.tsx @@ -25,7 +25,7 @@ const COMMAND_RE = /(^\/\w*)(?: .*)?/g; export default class CommandProvider extends AutocompleteProvider { public matcher: QueryMatcher; - + private room: Room; public constructor(room: Room, renderingType?: TimelineRenderingType) { super({ commandRegex: COMMAND_RE, renderingType }); this.matcher = new QueryMatcher(Commands, { @@ -33,6 +33,7 @@ export default class CommandProvider extends AutocompleteProvider { funcs: [({ aliases }) => aliases.join(" ")], // aliases context: renderingType, }); + this.room = room; } public async getCompletions( @@ -51,7 +52,7 @@ export default class CommandProvider extends AutocompleteProvider { if (command[0] !== command[1]) { // The input looks like a command with arguments, perform exact match const name = command[1].slice(1); // strip leading `/` - if (CommandMap.has(name) && CommandMap.get(name)!.isEnabled(cli)) { + if (CommandMap.has(name) && CommandMap.get(name)!.isEnabled(cli, this.room.roomId)) { // some commands, namely `me` don't suit having the usage shown whilst typing their arguments if (CommandMap.get(name)!.hideCompletionAfterSpace) return []; matches = [CommandMap.get(name)!]; @@ -70,7 +71,7 @@ export default class CommandProvider extends AutocompleteProvider { return matches .filter((cmd) => { const display = !cmd.renderingTypes || cmd.renderingTypes.includes(this.renderingType); - return cmd.isEnabled(cli) && display; + return cmd.isEnabled(cli, this.room.roomId) && display; }) .map((result) => { let completion = result.getCommand() + " "; diff --git a/src/call-types.ts b/src/call-types.ts index 6586bcf3b9..9ee870945f 100644 --- a/src/call-types.ts +++ b/src/call-types.ts @@ -6,6 +6,9 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com Please see LICENSE files in the repository root for full details. */ +import { EventType } from "matrix-js-sdk/src/matrix"; +import { NamespacedValue } from "matrix-js-sdk/src/NamespacedValue"; + export const JitsiCallMemberEventType = "io.element.video.member"; export interface JitsiCallMemberContent { @@ -14,3 +17,9 @@ export interface JitsiCallMemberContent { // Time at which this state event should be considered stale expires_ts: number; } + +// Element Call no longer sends this event type; it only exists to support timeline rendering of +// group calls from a previous iteration of the group VoIP MSCs (MSC3401) which used it. +export const ElementCallEventType = new NamespacedValue(null, EventType.GroupCallPrefix); + +export const ElementCallMemberEventType = new NamespacedValue(null, EventType.GroupCallMemberPrefix); diff --git a/src/components/structures/ContextMenu.tsx b/src/components/structures/ContextMenu.tsx index bdbd8b4a08..03538a21bf 100644 --- a/src/components/structures/ContextMenu.tsx +++ b/src/components/structures/ContextMenu.tsx @@ -12,6 +12,7 @@ import React, { type JSX, type CSSProperties, type RefObject, type SyntheticEven import ReactDOM from "react-dom"; import classNames from "classnames"; import FocusLock from "react-focus-lock"; +import { TooltipProvider } from "@vector-im/compound-web"; import { type Writeable } from "../../@types/common"; import UIStore from "../../stores/UIStore"; @@ -404,7 +405,7 @@ export default class ContextMenu extends React.PureComponent{body}; + body = {body}; } // filter props that are invalid for DOM elements @@ -425,15 +426,17 @@ export default class ContextMenu extends React.PureComponent {background} -
    - {body} -
    + +
    + {body} +
    +
    )} diff --git a/src/components/structures/EmbeddedPage.tsx b/src/components/structures/EmbeddedPage.tsx index f1c7b75b96..53a5d7d537 100644 --- a/src/components/structures/EmbeddedPage.tsx +++ b/src/components/structures/EmbeddedPage.tsx @@ -18,6 +18,7 @@ import { MatrixClientPeg } from "../../MatrixClientPeg"; import MatrixClientContext from "../../contexts/MatrixClientContext"; import AutoHideScrollbar from "./AutoHideScrollbar"; import { type ActionPayload } from "../../dispatcher/payloads"; +import { Action } from "../../dispatcher/actions.ts"; interface IProps { // URL to request embedded page content from @@ -72,7 +73,11 @@ export default class EmbeddedPage extends React.PureComponent { return; } - let body = (await res.text()).replace(/_t\(['"]([\s\S]*?)['"]\)/gm, (match, g1) => this.translate(g1)); + // Replace '," and HTML encoded variants + let body = (await res.text()).replace( + /_t\((?:['"]|(?:&#(?:34|27);))([\s\S]*?)(?:['"]|(?:&#(?:34|27);))\)/gm, + (match, g1) => this.translate(g1), + ); if (this.props.replaceMap) { Object.keys(this.props.replaceMap).forEach((key) => { @@ -105,7 +110,7 @@ export default class EmbeddedPage extends React.PureComponent { private onAction = (payload: ActionPayload): void => { // HACK: Workaround for the context's MatrixClient not being set up at render time. - if (payload.action === "client_started") { + if (payload.action === Action.ClientStarted) { this.forceUpdate(); } }; diff --git a/src/components/structures/FileDropTarget.tsx b/src/components/structures/FileDropTarget.tsx index dcf4a2fdb1..5ad16303bf 100644 --- a/src/components/structures/FileDropTarget.tsx +++ b/src/components/structures/FileDropTarget.tsx @@ -7,11 +7,14 @@ Please see LICENSE files in the repository root for full details. */ import React, { useEffect, useState } from "react"; +import { type Room } from "matrix-js-sdk/src/matrix"; import { _t } from "../../languageHandler"; import UploadBigSvg from "../../../res/img/upload-big.svg"; +import { useRoomState } from "../../hooks/useRoomState.ts"; interface IProps { + room: Room; parent: HTMLElement | null; onFileDrop(dataTransfer: DataTransfer): void; } @@ -21,14 +24,15 @@ interface IState { counter: number; } -const FileDropTarget: React.FC = ({ parent, onFileDrop }) => { +const FileDropTarget: React.FC = ({ parent, onFileDrop, room }) => { const [state, setState] = useState({ dragging: false, counter: 0, }); + const hasPermission = useRoomState(room, (state) => state.maySendMessage(room.client.getUserId()!)); useEffect(() => { - if (!parent || parent.ondrop) return; + if (!hasPermission || !parent || parent.ondrop) return; const onDragEnter = (ev: DragEvent): void => { ev.stopPropagation(); @@ -102,9 +106,9 @@ const FileDropTarget: React.FC = ({ parent, onFileDrop }) => { parent?.removeEventListener("dragenter", onDragEnter); parent?.removeEventListener("dragleave", onDragLeave); }; - }, [parent, onFileDrop]); + }, [parent, onFileDrop, hasPermission]); - if (state.dragging) { + if (hasPermission && state.dragging) { return (
    diff --git a/src/components/structures/FilePanel.tsx b/src/components/structures/FilePanel.tsx index 5930490c34..7f80525e29 100644 --- a/src/components/structures/FilePanel.tsx +++ b/src/components/structures/FilePanel.tsx @@ -27,7 +27,6 @@ import EventIndexPeg from "../../indexing/EventIndexPeg"; import { _t } from "../../languageHandler"; import SearchWarning, { WarningKind } from "../views/elements/SearchWarning"; import BaseCard from "../views/right_panel/BaseCard"; -import type ResizeNotifier from "../../utils/ResizeNotifier"; import TimelinePanel from "./TimelinePanel"; import Spinner from "../views/elements/Spinner"; import { Layout } from "../../settings/enums/Layout"; @@ -39,7 +38,6 @@ import { ScopedRoomContextProvider } from "../../contexts/ScopedRoomContext.tsx" interface IProps { roomId: string; onClose: () => void; - resizeNotifier: ResizeNotifier; } interface IState { @@ -294,7 +292,6 @@ class FilePanel extends React.Component { timelineSet={this.state.timelineSet} showUrlPreview={false} onPaginationRequest={this.onPaginationRequest} - resizeNotifier={this.props.resizeNotifier} empty={emptyState} layout={Layout.Group} /> diff --git a/src/components/structures/GenericDropdownMenu.tsx b/src/components/structures/GenericDropdownMenu.tsx index 78cd3d3ad4..f76e43c108 100644 --- a/src/components/structures/GenericDropdownMenu.tsx +++ b/src/components/structures/GenericDropdownMenu.tsx @@ -8,6 +8,7 @@ Please see LICENSE files in the repository root for full details. import classNames from "classnames"; import React, { type JSX, type FunctionComponent, type Key, type PropsWithChildren, type ReactNode } from "react"; +import { ChevronDownIcon, CheckIcon } from "@vector-im/compound-design-tokens/assets/web/icons"; import { MenuItemRadio } from "../../accessibility/context_menu/MenuItemRadio"; import { type ButtonEvent } from "../views/elements/AccessibleButton"; @@ -42,9 +43,10 @@ export function GenericDropdownMenuOption({ className="mx_GenericDropdownMenu_Option mx_GenericDropdownMenu_Option--item" onClick={onClick} > + {isSelected && }
    {label} - {description} + {description && {description}}
    {adornment} @@ -202,6 +204,7 @@ export function GenericDropdownMenu({ }} > {selectedLabel(selected)} + {contextMenu} diff --git a/src/components/structures/InteractiveAuth.tsx b/src/components/structures/InteractiveAuth.tsx index d1c670c27c..a4de1e4d21 100644 --- a/src/components/structures/InteractiveAuth.tsx +++ b/src/components/structures/InteractiveAuth.tsx @@ -20,7 +20,7 @@ import { logger } from "matrix-js-sdk/src/logger"; import getEntryComponentForLoginType, { type ContinueKind, - type CustomAuthType, + CustomAuthType, type IStageComponent, } from "../views/auth/InteractiveAuthEntryComponents"; import Spinner from "../views/elements/Spinner"; @@ -117,6 +117,7 @@ export default class InteractiveAuthComponent extends React.Component = ({ text }) => { return (
    - +
    {text}
    ); diff --git a/src/components/structures/LeftPanel.tsx b/src/components/structures/LeftPanel.tsx index 3bd2518c8a..52e5a87231 100644 --- a/src/components/structures/LeftPanel.tsx +++ b/src/components/structures/LeftPanel.tsx @@ -9,6 +9,7 @@ Please see LICENSE files in the repository root for full details. import React, { type JSX } from "react"; import { createRef } from "react"; import classNames from "classnames"; +import { ExploreIcon, DialPadIcon } from "@vector-im/compound-design-tokens/assets/web/icons"; import dis from "../../dispatcher/dispatcher"; import { _t } from "../../languageHandler"; @@ -344,10 +345,12 @@ export default class LeftPanel extends React.Component { if (this.state.supportsPstnProtocol) { dialPadButton = ( + > + + ); } @@ -358,7 +361,9 @@ export default class LeftPanel extends React.Component { className="mx_LeftPanel_exploreButton" onClick={this.onExplore} title={_t("action|explore_rooms")} - /> + > + + ); } diff --git a/src/components/structures/LoggedInView.tsx b/src/components/structures/LoggedInView.tsx index 26e127f21f..70258e143c 100644 --- a/src/components/structures/LoggedInView.tsx +++ b/src/components/structures/LoggedInView.tsx @@ -17,6 +17,8 @@ import { type SyncStateData, SyncState, EventType, + ProfileKeyTimezone, + ProfileKeyMSC4175Timezone, } from "matrix-js-sdk/src/matrix"; import { type MatrixCall } from "matrix-js-sdk/src/webrtc/call"; import classNames from "classnames"; @@ -30,7 +32,6 @@ import SettingsStore from "../../settings/SettingsStore"; import { SettingLevel } from "../../settings/SettingLevel"; import ResizeHandle from "../views/elements/ResizeHandle"; import { CollapseDistributor, Resizer } from "../../resizer"; -import type ResizeNotifier from "../../utils/ResizeNotifier"; import PlatformPeg from "../../PlatformPeg"; import { DefaultTagID } from "../../stores/room-list/models"; import { hideToast as hideServerLimitToast, showToast as showServerLimitToast } from "../../toasts/ServerLimitToast"; @@ -67,6 +68,8 @@ import { monitorSyncedPushRules } from "../../utils/pushRules/monitorSyncedPushR import { type ConfigOptions } from "../../SdkConfig"; import { MatrixClientContextProvider } from "./MatrixClientContextProvider"; import { Landmark, LandmarkNavigation } from "../../accessibility/LandmarkNavigation"; +import { ModuleApi } from "../../modules/Api.ts"; +import { SDKContext } from "../../contexts/SDKContext.ts"; // We need to fetch each pinned message individually (if we don't already have it) // so each pinned message may trigger a request. Limit the number per room for sanity. @@ -86,7 +89,6 @@ interface IProps { // transitioned to PWLU) onRegistered: (credentials: IMatrixClientCreds) => Promise; hideToSRUsers: boolean; - resizeNotifier: ResizeNotifier; // eslint-disable-next-line camelcase page_type?: string; autoJoin?: boolean; @@ -111,6 +113,7 @@ interface IState { backgroundImage?: string; } +const NEW_ROOM_LIST_MIN_WIDTH = 224; /** * This is what our MatrixChat shows when we are logged in. The precise view is * determined by the page_type property. @@ -133,8 +136,11 @@ class LoggedInView extends React.Component { protected timezoneProfileUpdateRef?: string[]; protected resizer?: Resizer; - public constructor(props: IProps) { - super(props); + public static contextType = SDKContext; + declare public context: React.ContextType; + + public constructor(props: IProps, context: React.ContextType) { + super(props, context); this.state = { syncErrorData: undefined, @@ -185,19 +191,37 @@ class LoggedInView extends React.Component { SettingsStore.watchSetting("userTimezone", null, this.onTimezoneUpdate), ]; - this.resizer = this.createResizer(); - this.resizer.attach(); + this.loadResizer(); OwnProfileStore.instance.on(UPDATE_EVENT, this.refreshBackgroundImage); - this.loadResizerPreferences(); this.refreshBackgroundImage(); } + /** + * Load or reload the resizer for the left panel + */ + private loadResizer(): void { + // If the resizer already exists, detach it first + this.resizer?.detach(); + + this.resizer = this.createResizer(); + this.resizer.attach(); + this.loadResizerPreferences(); + } + + public componentDidUpdate(nextProps: Readonly, nextState: Readonly, nextContext: any): void { + if (nextProps.page_type !== this.props.page_type) { + this.loadResizer(); + } + } + private onTimezoneUpdate = async (): Promise => { + // TODO: In a future app release, remove support for legacy key. if (!SettingsStore.getValue("userTimezonePublish")) { // Ensure it's deleted try { - await this._matrixClient.deleteExtendedProfileProperty("us.cloke.msc4175.tz"); + await this._matrixClient.deleteExtendedProfileProperty(ProfileKeyMSC4175Timezone); + await this._matrixClient.deleteExtendedProfileProperty(ProfileKeyTimezone); } catch (ex) { console.warn("Failed to delete timezone from user profile", ex); } @@ -212,7 +236,8 @@ class LoggedInView extends React.Component { return; } try { - await this._matrixClient.setExtendedProfileProperty("us.cloke.msc4175.tz", currentTimezone); + await this._matrixClient.setExtendedProfileProperty(ProfileKeyTimezone, currentTimezone); + await this._matrixClient.setExtendedProfileProperty(ProfileKeyMSC4175Timezone, currentTimezone); } catch (ex) { console.warn("Failed to update user profile with current timezone", ex); } @@ -261,10 +286,15 @@ class LoggedInView extends React.Component { let panelCollapsed: boolean; const useNewRoomList = SettingsStore.getValue("feature_new_room_list"); // TODO decrease this once Spaces launches as it'll no longer need to include the 56px Community Panel - const toggleSize = useNewRoomList ? 224 : 206 - 50; + const toggleSize = useNewRoomList ? NEW_ROOM_LIST_MIN_WIDTH : 206 - 50; + const collapseConfig: ICollapseConfig = { toggleSize, onCollapsed: (collapsed) => { + if (useNewRoomList) { + // The new room list does not support collapsing. + return; + } panelCollapsed = collapsed; if (collapsed) { dis.dispatch({ action: "hide_left_panel" }); @@ -275,17 +305,19 @@ class LoggedInView extends React.Component { }, onResized: (size) => { panelSize = size; - this.props.resizeNotifier.notifyLeftHandleResized(); + this.context.resizeNotifier.notifyLeftHandleResized(); }, onResizeStart: () => { - this.props.resizeNotifier.startResizing(); + this.context.resizeNotifier.startResizing(); }, onResizeStop: () => { - if (!panelCollapsed) window.localStorage.setItem("mx_lhs_size", "" + panelSize); - this.props.resizeNotifier.stopResizing(); + // Always save the lhs size for the new room list. + if (useNewRoomList || !panelCollapsed) window.localStorage.setItem("mx_lhs_size", "" + panelSize); + this.context.resizeNotifier.stopResizing(); }, isItemCollapsed: (domNode) => { - return domNode.classList.contains("mx_LeftPanel_minimized"); + // New rooms list does not support collapsing. + return !useNewRoomList && domNode.classList.contains("mx_LeftPanel_minimized"); }, handler: this.resizeHandler.current ?? undefined, }; @@ -299,8 +331,11 @@ class LoggedInView extends React.Component { } private loadResizerPreferences(): void { + const useNewRoomList = SettingsStore.getValue("feature_new_room_list"); let lhsSize = parseInt(window.localStorage.getItem("mx_lhs_size")!, 10); - if (isNaN(lhsSize)) { + // If the user has not set a size, or for the new room list if the size is less than the minimum width, + // set a default size. + if (isNaN(lhsSize) || (useNewRoomList && lhsSize < NEW_ROOM_LIST_MIN_WIDTH)) { lhsSize = 350; } this.resizer?.forHandleWithId("lp-resizer")?.resize(lhsSize); @@ -324,14 +359,15 @@ class LoggedInView extends React.Component { const newErrCode = (data?.error as MatrixError)?.errcode; if (syncState === oldSyncState && oldErrCode === newErrCode) return; + const syncErrorData = syncState === SyncState.Error ? data : undefined; this.setState({ - syncErrorData: syncState === SyncState.Error ? data : undefined, + syncErrorData, }); if (oldSyncState === SyncState.Prepared && syncState === SyncState.Syncing) { this.updateServerNoticeEvents(); } else { - this.calculateServerLimitToast(this.state.syncErrorData, this.state.usageLimitEventContent); + this.calculateServerLimitToast(syncErrorData, this.state.usageLimitEventContent); } }; @@ -356,7 +392,7 @@ class LoggedInView extends React.Component { // usageLimitDismissed is true when the user has explicitly hidden the toast // and it will be reset to false if a *new* usage alert comes in. - if (usageLimitEventContent && this.state.usageLimitDismissed) { + if (usageLimitEventContent && !this.state.usageLimitDismissed) { showServerLimitToast( usageLimitEventContent.limit_type, this.onUsageLimitDismissed, @@ -661,6 +697,10 @@ class LoggedInView extends React.Component { public render(): React.ReactNode { let pageElement; + const moduleRenderer = this.props.page_type + ? ModuleApi.instance.navigation.locationRenderers.get(this.props.page_type) + : undefined; + switch (this.props.page_type) { case PageTypes.RoomView: pageElement = ( @@ -670,7 +710,6 @@ class LoggedInView extends React.Component { threepidInvite={this.props.threepidInvite} oobData={this.props.roomOobData} key={this.props.currentRoomId || "roomview"} - resizeNotifier={this.props.resizeNotifier} justCreatedOpts={this.props.roomJustCreatedOpts} forceTimeline={this.props.forceTimeline} /> @@ -684,10 +723,17 @@ class LoggedInView extends React.Component { case PageTypes.UserView: if (!!this.props.currentUserId) { pageElement = ( - + ); } break; + default: { + if (moduleRenderer) { + pageElement = moduleRenderer(); + } else { + console.warn(`Couldn't render page type "${this.props.page_type}"`); + } + } } const wrapperClasses = classNames({ @@ -729,20 +775,22 @@ class LoggedInView extends React.Component { )} {!useNewRoomList && } -
    - -
    + {!moduleRenderer && ( +
    + +
    + )}
    - + {!moduleRenderer && }
    {pageElement}
    diff --git a/src/components/structures/MainSplit.tsx b/src/components/structures/MainSplit.tsx index 6b6736d4ec..4ffabfeafe 100644 --- a/src/components/structures/MainSplit.tsx +++ b/src/components/structures/MainSplit.tsx @@ -12,11 +12,10 @@ import { type NumberSize, Resizable } from "re-resizable"; import { type Direction } from "re-resizable/lib/resizer"; import { type WebPanelResize } from "@matrix-org/analytics-events/types/typescript/WebPanelResize"; -import type ResizeNotifier from "../../utils/ResizeNotifier"; import { PosthogAnalytics } from "../../PosthogAnalytics.ts"; +import { SDKContext } from "../../contexts/SDKContext.ts"; interface IProps { - resizeNotifier: ResizeNotifier; collapsedRhs?: boolean; panel?: JSX.Element; children: ReactNode; @@ -36,16 +35,23 @@ interface IProps { } export default class MainSplit extends React.Component { + public static contextType = SDKContext; + declare public context: React.ContextType; + public static defaultProps = { defaultSize: 320, }; + public constructor(props: IProps, context: React.ContextType) { + super(props, context); + } + private onResizeStart = (): void => { - this.props.resizeNotifier.startResizing(); + this.context.resizeNotifier.startResizing(); }; private onResize = (): void => { - this.props.resizeNotifier.notifyRightHandleResized(); + this.context.resizeNotifier.notifyRightHandleResized(); }; private get sizeSettingStorageKey(): string { @@ -63,7 +69,7 @@ export default class MainSplit extends React.Component { delta: NumberSize, ): void => { const newSize = this.loadSidePanelSize().width + delta.width; - this.props.resizeNotifier.stopResizing(); + this.context.resizeNotifier.stopResizing(); window.localStorage.setItem(this.sizeSettingStorageKey, newSize.toString()); PosthogAnalytics.instance.trackEvent({ diff --git a/src/components/structures/MatrixChat.tsx b/src/components/structures/MatrixChat.tsx index e61713ca69..8a9ffdd115 100644 --- a/src/components/structures/MatrixChat.tsx +++ b/src/components/structures/MatrixChat.tsx @@ -28,6 +28,8 @@ import { TooltipProvider } from "@vector-im/compound-web"; // what-input helps improve keyboard accessibility import "what-input"; import sanitizeHtml from "sanitize-html"; +import { I18nContext } from "@element-hq/web-shared-components"; +import { LockSolidIcon } from "@vector-im/compound-design-tokens/assets/web/icons"; import PosthogTrackers from "../../PosthogTrackers"; import { DecryptionFailureTracker } from "../../DecryptionFailureTracker"; @@ -47,11 +49,9 @@ import PageType from "../../PageTypes"; import createRoom, { type IOpts } from "../../createRoom"; import { _t, _td } from "../../languageHandler"; import SettingsStore from "../../settings/SettingsStore"; -import ThemeController from "../../settings/controllers/ThemeController"; import { startAnyRegistrationFlow } from "../../Registration"; -import ResizeNotifier from "../../utils/ResizeNotifier"; import AutoDiscoveryUtils from "../../utils/AutoDiscoveryUtils"; -import { makeRoomPermalink } from "../../utils/permalinks/Permalinks"; +import { calculateRoomVia, makeRoomPermalink } from "../../utils/permalinks/Permalinks"; import ThemeWatcher, { ThemeWatcherEvent } from "../../settings/watchers/ThemeWatcher"; import { FontWatcher } from "../../settings/watchers/FontWatcher"; import { storeRoomAliasInCache } from "../../RoomAliasCache"; @@ -130,7 +130,6 @@ import { NotificationLevel } from "../../stores/notifications/NotificationLevel" import { type UserTab } from "../views/dialogs/UserTab"; import { shouldSkipSetupEncryption } from "../../utils/crypto/shouldSkipSetupEncryption"; import { Filter } from "../views/dialogs/spotlight/Filter"; -import { checkSessionLockFree, getSessionLock } from "../../utils/SessionLock"; import { SessionLockStolenView } from "./auth/SessionLockStolenView"; import { ConfirmSessionLockTheftView } from "./auth/ConfirmSessionLockTheftView"; import { LoginSplashView } from "./auth/LoginSplashView"; @@ -141,6 +140,8 @@ import { type OpenForwardDialogPayload } from "../../dispatcher/payloads/OpenFor import { ShareFormat, type SharePayload } from "../../dispatcher/payloads/SharePayload"; import Markdown from "../../Markdown"; import { sanitizeHtmlParams } from "../../Linkify"; +import { isOnlyAdmin } from "../../utils/membership"; +import { ModuleApi } from "../../modules/Api.ts"; // legacy export export { default as Views } from "../../Views"; @@ -174,11 +175,23 @@ interface IProps { } interface IState { - // the master view we are showing. + /** + * The master view we are showing. + * + * This represents the state of a state machine: see the documentation on {@link Views} for a transition diagram. + * + * TODO: this doesn't work well, because updates to React state are not instantaneous, meaning that if several + * events or {@link Action}s happen in quick succession, we may end up following the wrong transition. + * We should probably move the view into a separate object (like a ViewModel) and have the React state subscribe + * to updates. + */ view: Views; - // What the LoggedInView would be showing if visible + + // What the LoggedInView would be showing if visible. + // A member of the enum for standard pages or a string for those provided by + // a module. // eslint-disable-next-line camelcase - page_type?: PageType; + page_type?: PageType | string; // The ID of the room we're viewing. This is either populated directly // in the case where we view a room by ID or by RoomView when it resolves // what ID an alias points at. @@ -199,9 +212,11 @@ interface IState { // and disable it when there are no dialogs hideToSRUsers: boolean; syncError: Error | null; - resizeNotifier: ResizeNotifier; serverConfig?: ValidatedServerConfig; + + /** Has our MatrixClient started? */ ready: boolean; + threepidInvite?: IThreepidInvite; roomOobData?: object; pendingInitialSync?: boolean; @@ -224,7 +239,13 @@ export default class MatrixChat extends React.PureComponent { private firstSyncPromise: PromiseWithResolvers; private screenAfterLogin?: IScreen; + + /** True if we have successfully completed an OIDC or token login. + * + * XXX it's unclear if this is ever cleared, so what happens if the user logs out and then logs back in? + */ private tokenLogin?: boolean; + // What to focus on next component update, if anything private focusNext: FocusNextType; private subTitleStatus: string; @@ -237,6 +258,8 @@ export default class MatrixChat extends React.PureComponent { private readonly stores: SdkContextClass; private loadSessionAbortController = new AbortController(); + private sessionLoadStarted = false; + public constructor(props: IProps) { super(props); this.stores = SdkContextClass.instance; @@ -252,7 +275,6 @@ export default class MatrixChat extends React.PureComponent { isMobileRegistration: false, syncError: null, // If the current syncing status is ERROR, the error object, otherwise null. - resizeNotifier: new ResizeNotifier(), ready: false, }; @@ -311,7 +333,8 @@ export default class MatrixChat extends React.PureComponent { private async initSession(): Promise { // The Rust Crypto SDK will break if two Element instances try to use the same datastore at once, so // make sure we are the only Element instance in town (on this browser/domain). - if (!(await getSessionLock(() => this.onSessionLockStolen()))) { + const platform = PlatformPeg.get(); + if (platform && !(await platform.getSessionLock(() => this.onSessionLockStolen()))) { // we failed to get the lock. onSessionLockStolen should already have been called, so nothing left to do. return; } @@ -383,11 +406,30 @@ export default class MatrixChat extends React.PureComponent { await Lifecycle.onSessionLockStolen(); } + /** + * Perform actions that are specific to a user that has just logged in. + * + * Called when: + * + * - We successfully completed an OIDC or token login, via {@link initSession}. + * - The {@link Login} or {@link Register} components notify us that we successfully completed a non-OIDC login or + * registration. + * + * In both cases, {@link Action.OnLoggedIn} will already have been emitted, but the call to {@link onShowPostLoginScreen} will + * have been suppressed (by either {@link tokenLogin} being set, or the view being set to {@link Views.LOGIN} or + * {@link Views.REGISTER}). + * + * {@link onWillStartClient} and {@link onClientStarted} will already have been called (but not necessarily + * completed). + * + * This method either calls {@link onLiggedIn} directly, or switches to {@link Views.E2E_SETUP} or + * {@link Views.COMPLETE_SECURITY}, which will later call {@link onCompleteSecurityE2eSetupFinished}. + */ private async postLoginSetup(): Promise { const cli = MatrixClientPeg.safeGet(); const cryptoEnabled = Boolean(cli.getCrypto()); if (!cryptoEnabled) { - this.onLoggedIn(); + this.onShowPostLoginScreen(); } const promisesList: Promise[] = [this.firstSyncPromise.promise]; @@ -420,14 +462,11 @@ export default class MatrixChat extends React.PureComponent { const cryptoExtension = ModuleRunner.instance.extensions.cryptoSetup; if (cryptoExtension.SHOW_ENCRYPTION_SETUP_UI == false) { - this.onLoggedIn(); + this.onShowPostLoginScreen(); } else { this.setStateForNewView({ view: Views.COMPLETE_SECURITY }); } - } else if ( - (await cli.doesServerSupportUnstableFeature("org.matrix.e2e_cross_signing")) && - !(await shouldSkipSetupEncryption(cli)) - ) { + } else if (!(await shouldSkipSetupEncryption(cli))) { // if cross-signing is not yet set up, do so now if possible. InitialCryptoSetupStore.sharedInstance().startInitialCryptoSetup( cli, @@ -435,7 +474,7 @@ export default class MatrixChat extends React.PureComponent { ); this.setStateForNewView({ view: Views.E2E_SETUP }); } else { - this.onLoggedIn(); + this.onShowPostLoginScreen(); } this.setState({ pendingInitialSync: false }); } @@ -446,6 +485,9 @@ export default class MatrixChat extends React.PureComponent { | (Pick | IState | null), callback?: () => void, ): void { + if (state && "view" in state) { + logger.debug(`MatrixChat: Queuing change of view from ${Views[this.state.view]} to ${Views[state.view]}`); + } if (this.shouldTrackPageChange(this.state, { ...this.state, ...state })) { this.startPageChangeTimer(); } @@ -456,7 +498,7 @@ export default class MatrixChat extends React.PureComponent { UIStore.instance.on(UI_EVENTS.Resize, this.handleResize); // For PersistentElement - this.state.resizeNotifier.on("middlePanelResized", this.dispatchTimelineResize); + this.stores.resizeNotifier.on("middlePanelResized", this.dispatchTimelineResize); RoomNotificationStateStore.instance.on(UPDATE_STATUS_INDICATOR, this.onUpdateStatusIndicator); @@ -469,15 +511,21 @@ export default class MatrixChat extends React.PureComponent { this.fontWatcher.start(); initSentry(SdkConfig.get("sentry")); - - if (!checkSessionLockFree()) { - // another instance holds the lock; confirm its theft before proceeding - setTimeout(() => this.setState({ view: Views.CONFIRM_LOCK_THEFT }), 0); - } else { - this.startInitSession(); - } - window.addEventListener("resize", this.onWindowResized); + + // Once we start loading the MatrixClient, we can't stop, even if MatrixChat gets unmounted (as it does + // in React's Strict Mode). So, start loading the session now, but only if this MatrixChat was not previously + // mounted. + if (!this.sessionLoadStarted) { + this.sessionLoadStarted = true; + const platform = PlatformPeg.get(); + if (platform && !platform.checkSessionLockFree()) { + // another instance holds the lock; confirm its theft before proceeding + setTimeout(() => this.setState({ view: Views.CONFIRM_LOCK_THEFT }), 0); + } else { + this.startInitSession(); + } + } } public componentDidUpdate(prevProps: IProps, prevState: IState): void { @@ -502,7 +550,7 @@ export default class MatrixChat extends React.PureComponent { this.themeWatcher?.stop(); this.fontWatcher?.stop(); UIStore.destroy(); - this.state.resizeNotifier.removeListener("middlePanelResized", this.dispatchTimelineResize); + this.stores.resizeNotifier.removeListener("middlePanelResized", this.dispatchTimelineResize); window.removeEventListener("resize", this.onWindowResized); } @@ -597,6 +645,10 @@ export default class MatrixChat extends React.PureComponent { ); } + private isLoggedInViewPageDisplayed(): boolean { + return this.loggedInView.current !== null && this.state.page_type !== undefined; + } + private setStateForNewView(state: Partial): void { if (state.view === undefined) { throw new Error("setStateForNewView with no view!"); @@ -611,9 +663,15 @@ export default class MatrixChat extends React.PureComponent { private onAction = (payload: ActionPayload): void => { // once the session lock has been stolen, don't try to do anything. if (this.state.view === Views.LOCK_STOLEN) { + logger.warn(`MatrixChat: ignoring action ${payload.action} as session lock has been stolen`); return; } + // Exclude some rather spammy actions from being logged. + if (payload.action != Action.UserActivity) { + logger.debug(`MatrixChat: handling action ${payload.action}`); + } + // Start the onboarding process for certain actions if ( MatrixClientPeg.get()?.isGuest() && @@ -806,20 +864,13 @@ export default class MatrixChat extends React.PureComponent { } break; } - case "view_last_screen": - // This function does what we want, despite the name. The idea is that it shows - // the last room we were looking at or some reasonable default/guess. We don't - // have to worry about email invites or similar being re-triggered because the - // function will have cleared that state and not execute that path. - this.showScreenAfterLogin(); - break; case "hide_left_panel": this.setState( { collapseLhs: true, }, () => { - this.state.resizeNotifier.notifyLeftHandleResized(); + this.stores.resizeNotifier.notifyLeftHandleResized(); }, ); break; @@ -829,7 +880,7 @@ export default class MatrixChat extends React.PureComponent { collapseLhs: false, }, () => { - this.state.resizeNotifier.notifyLeftHandleResized(); + this.stores.resizeNotifier.notifyLeftHandleResized(); }, ); break; @@ -837,26 +888,15 @@ export default class MatrixChat extends React.PureComponent { Modal.createDialog(DialPadModal, {}, "mx_Dialog_dialPadWrapper"); break; case Action.OnLoggedIn: - this.stores.client = MatrixClientPeg.safeGet(); - if ( - // Skip this handling for token login as that always calls onLoggedIn itself - !this.tokenLogin && - !Lifecycle.isSoftLogout() && - this.state.view !== Views.LOGIN && - this.state.view !== Views.REGISTER && - this.state.view !== Views.COMPLETE_SECURITY && - this.state.view !== Views.E2E_SETUP - ) { - this.onLoggedIn(); - } + this.onLoggedIn(); break; - case "on_client_not_viable": + case Action.ClientNotViable: this.onSoftLogout(); break; case Action.OnLoggedOut: this.onLoggedOut(); break; - case "will_start_client": + case Action.WillStartClient: this.setState({ ready: false }, () => { // if the client is about to start, we are, by definition, not ready. // Set ready to false now, then it'll be set to true when the sync @@ -864,7 +904,7 @@ export default class MatrixChat extends React.PureComponent { this.onWillStartClient(); }); break; - case "client_started": + case Action.ClientStarted: // No need to make this handler async to wait for the result of this this.onClientStarted().catch((e) => { logger.error("Exception in onClientStarted", e); @@ -984,8 +1024,6 @@ export default class MatrixChat extends React.PureComponent { newState.isMobileRegistration = isMobileRegistration; this.setStateForNewView(newState); - ThemeController.isLogin = true; - this.themeWatcher?.recheck(); this.notifyNewScreen(isMobileRegistration ? "mobile_register" : "register"); } @@ -1018,7 +1056,7 @@ export default class MatrixChat extends React.PureComponent { presentedId = theAlias; // Store display alias of the presented room in cache to speed future // navigation. - storeRoomAliasInCache(theAlias, room.roomId); + storeRoomAliasInCache(theAlias, room.roomId, calculateRoomVia(room)); } // Store this as the ID of the last room accessed. This is so that we can @@ -1057,8 +1095,6 @@ export default class MatrixChat extends React.PureComponent { roomJustCreatedOpts: roomInfo.justCreatedOpts, }, () => { - ThemeController.isLogin = false; - this.themeWatcher?.recheck(); this.notifyNewScreen("room/" + presentedId, replaceLast); }, ); @@ -1069,7 +1105,8 @@ export default class MatrixChat extends React.PureComponent { this.viewWelcome(); return; } - if (!this.state.currentRoomId && !this.state.currentUserId) { + + if (!this.state.currentRoomId && !this.state.currentUserId && !this.isLoggedInViewPageDisplayed()) { this.viewHome(); } } @@ -1082,8 +1119,6 @@ export default class MatrixChat extends React.PureComponent { view: Views.WELCOME, }); this.notifyNewScreen("welcome"); - ThemeController.isLogin = true; - this.themeWatcher?.recheck(); } private viewLogin(otherState?: any): void { @@ -1092,8 +1127,6 @@ export default class MatrixChat extends React.PureComponent { ...otherState, }); this.notifyNewScreen("login"); - ThemeController.isLogin = true; - this.themeWatcher?.recheck(); } private viewHome(justRegistered = false): void { @@ -1105,8 +1138,6 @@ export default class MatrixChat extends React.PureComponent { }); this.setPage(PageType.HomePage); this.notifyNewScreen("home"); - ThemeController.isLogin = false; - this.themeWatcher?.recheck(); } private viewUser(userId: string, subAction: string): void { @@ -1255,29 +1286,22 @@ export default class MatrixChat extends React.PureComponent { const client = MatrixClientPeg.get(); if (client && roomToLeave) { - const plEvent = roomToLeave.currentState.getStateEvents(EventType.RoomPowerLevels, ""); - const plContent = plEvent ? plEvent.getContent() : {}; - const userLevels = plContent.users || {}; - const currentUserLevel = userLevels[client.getUserId()!]; - const userLevelValues = Object.values(userLevels); - if (userLevelValues.every((x) => typeof x === "number")) { + // If the user is the only user with highest power level + if (isOnlyAdmin(roomToLeave)) { + const userLevelValues = roomToLeave.getJoinedMembers().map((m) => m.powerLevel); + const maxUserLevel = Math.max(...(userLevelValues as number[])); - // If the user is the only user with highest power level - if ( - maxUserLevel === currentUserLevel && - userLevelValues.lastIndexOf(maxUserLevel) == userLevelValues.indexOf(maxUserLevel) - ) { - const warning = - maxUserLevel >= 100 - ? _t("leave_room_dialog|room_leave_admin_warning") - : _t("leave_room_dialog|room_leave_mod_warning"); - warnings.push( - - {" " /* Whitespace, otherwise the sentences get smashed together */} - {warning} - , - ); - } + + const warning = + maxUserLevel >= 100 + ? _t("leave_room_dialog|room_leave_admin_warning") + : _t("leave_room_dialog|room_leave_mod_warning"); + warnings.push( + + {" " /* Whitespace, otherwise the sentences get smashed together */} + {warning} + , + ); } } @@ -1377,21 +1401,24 @@ export default class MatrixChat extends React.PureComponent { } /** - * Called when a new logged in session has started + * Show the first screen after the application is successfully loaded in a logged-in state. + * + * Called: + * + * - on {@link Action.OnLoggedIn}, but only when we don't expect a separate call to {@link postLoginSetup}. + * - from {@link postLoginSetup}, when we don't have crypto setup tasks to perform after the login. + * - by {@link onCompleteSecurityE2eSetupFinished} + * + * In other words, whenever we think we have completed the login and E2E setup tasks. */ - private async onLoggedIn(): Promise { - ThemeController.isLogin = false; - this.themeWatcher?.recheck(); - StorageManager.tryPersistStorage(); + private onShowPostLoginScreen(): void { + logger.debug("onShowPostLoginScreen: Transitioning to logged in view."); - await this.onShowPostLoginScreen(); - } - - private async onShowPostLoginScreen(): Promise { this.setStateForNewView({ view: Views.LOGGED_IN }); // If a specific screen is set to be shown after login, show that above // all else, as it probably means the user clicked on something already. if (this.screenAfterLogin?.screen) { + logger.debug(`onShowPostLoginScreen: showing screen ${this.screenAfterLogin.screen}`); this.showScreen(this.screenAfterLogin.screen, this.screenAfterLogin.params); this.screenAfterLogin = undefined; } else if (MatrixClientPeg.currentUserIsJustRegistered()) { @@ -1400,6 +1427,7 @@ export default class MatrixChat extends React.PureComponent { if (ThreepidInviteStore.instance.pickBestInvite()) { // The user has a 3pid invite pending - show them that const threepidInvite = ThreepidInviteStore.instance.pickBestInvite(); + logger.debug(`onShowPostLoginScreen: showing room ${threepidInvite.roomId} after registration`); // HACK: This is a pretty brutal way of threading the invite back through // our systems, but it's the safest we have for now. @@ -1408,9 +1436,11 @@ export default class MatrixChat extends React.PureComponent { } else { // The user has just logged in after registering, // so show the homepage. + logger.debug("onShowPostLoginScreen: Showing home page after registration"); dis.dispatch({ action: Action.ViewHomePage, justRegistered: true }); } - } else if (!(await this.shouldForceVerification())) { + } else { + logger.debug("onShowPostLoginScreen: showScreenAfterLogin"); this.showScreenAfterLogin(); } @@ -1474,15 +1504,19 @@ export default class MatrixChat extends React.PureComponent { // If screenAfterLogin is set, use that, then null it so that a second login will // result in view_home_page, _user_settings or _room_directory if (this.screenAfterLogin && this.screenAfterLogin.screen) { + logger.debug(`showScreenAfterLogin: showing screen ${this.screenAfterLogin.screen}`); this.showScreen(this.screenAfterLogin.screen, this.screenAfterLogin.params); this.screenAfterLogin = undefined; } else if (localStorage && localStorage.getItem("mx_last_room_id")) { // Before defaulting to directory, show the last viewed room + logger.debug("showScreenAfterLogin: showing last room"); this.viewLastRoom(); } else { if (MatrixClientPeg.safeGet().isGuest()) { + logger.debug("showScreenAfterLogin: showing guest welcome page"); dis.dispatch({ action: "view_welcome_page" }); } else { + logger.debug("showScreenAfterLogin: showing home page"); dis.dispatch({ action: Action.ViewHomePage }); } } @@ -1496,6 +1530,37 @@ export default class MatrixChat extends React.PureComponent { }); } + /** + * Handle an {@link Action.OnLoggedIn} action (i.e, we now have a client with working credentials). + */ + private onLoggedIn(): void { + this.stores.client = MatrixClientPeg.safeGet(); + StorageManager.tryPersistStorage(); + + // If we're loading the app for the first time, we can now transition to a splash screen while we wait for the + // client to start. The exceptions are: + // + // - If there is a token login in flight: in that case we wait for the login to complete (which hits + // `postLoginSetup`). + // + // - Lifecycle emits an `Action.OnLoggedIn` event during startup even if the localstorage flag indicating a + // previous soft logout is set. In that situation we actually want to wait for the `Action.ClientNotViable` + // event, which will transition us into Views.SOFT_LOGOUT. We therefore have to check for !isSoftLogout(). + // There will be a subsequent `Action.OnLoggedIn` event once the reauthentication completes. + // + // XXX: fix this properly by having Lifecycle not emit OnLoggedIn when it knows it is about to emit a + // ClientNotViable. + // + // If we're already in the SOFT_LOGOUT view, that means that reauthentication has succeeded, and we can + // transition to the splash screen. + if ( + (this.state.view === Views.LOADING && !Lifecycle.isSoftLogout() && !this.tokenLogin) || + this.state.view === Views.SOFT_LOGOUT + ) { + this.setStateForNewView({ view: Views.PENDING_CLIENT_START }); + } + } + /** * Called when the session is logged out */ @@ -1707,7 +1772,7 @@ export default class MatrixChat extends React.PureComponent { ToastStore.sharedInstance().addOrReplaceToast({ key: "verifreq_" + request.transactionId, title: _t("encryption|verification_requested_toast_title"), - icon: "verification", + icon: , props: { request }, component: VerificationRequestToast, priority: 90, @@ -1725,15 +1790,6 @@ export default class MatrixChat extends React.PureComponent { const cli = MatrixClientPeg.safeGet(); const shouldForceVerification = await this.shouldForceVerification(); - // XXX: Don't replace the screen if it's already one of these: postLoginSetup - // changes to these screens in certain circumstances so we shouldn't clobber it. - // We should probably have one place where we decide what the next screen is after - // login. - if (![Views.COMPLETE_SECURITY, Views.E2E_SETUP].includes(this.state.view)) { - if (shouldForceVerification) { - this.setStateForNewView({ view: Views.COMPLETE_SECURITY }); - } - } const crypto = cli.getCrypto(); if (crypto) { @@ -1750,13 +1806,31 @@ export default class MatrixChat extends React.PureComponent { this.setState({ ready: true, }); + + // If the view is PENDING_CLIENT_START, that means we recovered the session from localstorage, or from + // soft-logout: we can now transition to the logged-in view. + // + // If the view is something else, that probably means it's a login or registration view; we handle that in + // `postLoginSetup`. + if (this.state.view === Views.PENDING_CLIENT_START) { + if (shouldForceVerification) { + this.setStateForNewView({ view: Views.COMPLETE_SECURITY }); + } else { + this.onShowPostLoginScreen(); + } + } } public showScreen(screen: string, params?: { [key: string]: any }): void { + logger.debug(`showScreen ${screen}`); + const cli = MatrixClientPeg.get(); const isLoggedOutOrGuest = !cli || cli.isGuest(); if (!isLoggedOutOrGuest && AUTH_SCREENS.includes(screen)) { // user is logged in and landing on an auth page which will uproot their session, redirect them home instead + logger.info( + `showScreen: suppressing change to AuthScreen ${screen} for logged-in user, and going to home screen instead`, + ); dis.dispatch({ action: Action.ViewHomePage }); return; } @@ -1813,7 +1887,7 @@ export default class MatrixChat extends React.PureComponent { // if we weren't already coming at this from an existing screen // and we're logged in, then explicitly default to home. // if we're not logged in, then the login flow will do the right thing. - if (!this.state.currentRoomId && !this.state.currentUserId) { + if (!this.state.currentRoomId && !this.state.currentUserId && !this.isLoggedInViewPageDisplayed()) { this.viewHome(); } } else if (screen === "settings") { @@ -1922,8 +1996,8 @@ export default class MatrixChat extends React.PureComponent { userId: userId, subAction: params?.action, }); - } else { - logger.info(`Ignoring showScreen for '${screen}'`); + } else if (ModuleApi.instance.navigation.locationRenderers.get(screen)) { + this.setState({ page_type: screen }); } } @@ -1955,7 +2029,7 @@ export default class MatrixChat extends React.PureComponent { } this.prevWindowWidth = width; - this.state.resizeNotifier.notifyWindowResized(); + this.stores.resizeNotifier.notifyWindowResized(); }; private dispatchTimelineResize(): void { @@ -2051,7 +2125,7 @@ export default class MatrixChat extends React.PureComponent { PerformanceMonitor.instance.stop(PerformanceEntryNames.REGISTER); }; - // complete security / e2e setup has finished + /** Called when {@link Views.E2E_SETUP} or {@link Views.COMPLETE_SECURITY} have completed. */ private onCompleteSecurityE2eSetupFinished = async (): Promise => { const forceVerify = await this.shouldForceVerification(); if (forceVerify) { @@ -2062,9 +2136,7 @@ export default class MatrixChat extends React.PureComponent { } } - await this.onShowPostLoginScreen().catch((e) => { - logger.error("Exception showing post-login screen", e); - }); + this.onShowPostLoginScreen(); }; private getFragmentAfterLogin(): string { @@ -2102,7 +2174,16 @@ export default class MatrixChat extends React.PureComponent { } else if (this.state.view === Views.COMPLETE_SECURITY) { view = ; } else if (this.state.view === Views.E2E_SETUP) { - view = ; + view = ; + } else if (this.state.view === Views.PENDING_CLIENT_START) { + // we think we are logged in, but are still waiting for the /sync to complete + view = ( + + ); } else if (this.state.view === Views.LOGGED_IN) { // `ready` and `view==LOGGED_IN` may be set before `page_type` (because the // latter is set via the dispatcher). If we don't yet have a `page_type`, @@ -2193,9 +2274,11 @@ export default class MatrixChat extends React.PureComponent { return ( - - {view} - + + + {view} + + ); } diff --git a/src/components/structures/MessagePanel.tsx b/src/components/structures/MessagePanel.tsx index 2441182bc8..30f9b474a6 100644 --- a/src/components/structures/MessagePanel.tsx +++ b/src/components/structures/MessagePanel.tsx @@ -39,7 +39,6 @@ import ScrollPanel, { type IScrollState } from "./ScrollPanel"; import DateSeparator from "../views/messages/DateSeparator"; import TimelineSeparator, { SeparatorKind } from "../views/messages/TimelineSeparator"; import ErrorBoundary from "../views/elements/ErrorBoundary"; -import type ResizeNotifier from "../../utils/ResizeNotifier"; import Spinner from "../views/elements/Spinner"; import { type RoomPermalinkCreator } from "../../utils/permalinks/Permalinks"; import type EditorStateTransfer from "../../utils/EditorStateTransfer"; @@ -167,7 +166,6 @@ interface IProps { // which layout to use layout?: Layout; - resizeNotifier?: ResizeNotifier; permalinkCreator?: RoomPermalinkCreator; editState?: EditorStateTransfer; @@ -1064,7 +1062,6 @@ export default class MessagePanel extends React.Component { onUnfillRequest={this.props.onUnfillRequest} style={style} stickyBottom={this.props.stickyBottom} - resizeNotifier={this.props.resizeNotifier} fixedChildren={ircResizer} > {topSpinner} diff --git a/src/components/structures/PipContainer.tsx b/src/components/structures/PipContainer.tsx index e46267c149..9dbb6daef7 100644 --- a/src/components/structures/PipContainer.tsx +++ b/src/components/structures/PipContainer.tsx @@ -9,7 +9,6 @@ Please see LICENSE files in the repository root for full details. import React, { type RefObject, type ReactNode, useRef } from "react"; import { CallEvent, CallState, type MatrixCall } from "matrix-js-sdk/src/webrtc/call"; import { logger } from "matrix-js-sdk/src/logger"; -import { type Optional } from "matrix-events-sdk"; import LegacyCallView from "../views/voip/LegacyCallView"; import LegacyCallHandler, { LegacyCallHandlerEvent } from "../../LegacyCallHandler"; @@ -57,7 +56,7 @@ interface IState { // (which should be a single element) of other calls. // The primary will be the one not on hold, or an arbitrary one // if they're all on hold) -function getPrimarySecondaryCallsForPip(roomId: Optional): [MatrixCall | null, MatrixCall[]] { +function getPrimarySecondaryCallsForPip(roomId: string | null): [MatrixCall | null, MatrixCall[]] { if (!roomId) return [null, []]; const calls = LegacyCallHandler.instance.getAllActiveCallsForPip(roomId); @@ -245,6 +244,7 @@ class PipContainerInner extends React.Component { secondaryCall={this.state.secondaryCall} pipMode={pipMode} onResize={onResize} + sidebarShown={false} /> )); } diff --git a/src/components/structures/ReleaseAnnouncement.tsx b/src/components/structures/ReleaseAnnouncement.tsx index da41b3c1dd..5a9eea2d68 100644 --- a/src/components/structures/ReleaseAnnouncement.tsx +++ b/src/components/structures/ReleaseAnnouncement.tsx @@ -12,8 +12,10 @@ import { ReleaseAnnouncement as ReleaseAnnouncementCompound } from "@vector-im/c import { ReleaseAnnouncementStore, type Feature } from "../../stores/ReleaseAnnouncementStore"; import { useIsReleaseAnnouncementOpen } from "../../hooks/useIsReleaseAnnouncementOpen"; -interface ReleaseAnnouncementProps - extends Omit, "open" | "onClick"> { +interface ReleaseAnnouncementProps extends Omit< + ComponentProps, + "open" | "onClick" +> { feature: Feature; } diff --git a/src/components/structures/RightPanel.tsx b/src/components/structures/RightPanel.tsx index 94a5d34d60..24c1655455 100644 --- a/src/components/structures/RightPanel.tsx +++ b/src/components/structures/RightPanel.tsx @@ -215,9 +215,7 @@ export default class RightPanel extends React.Component { break; case RightPanelPhases.FilePanel: if (!!roomId) { - card = ( - - ); + card = ; } break; diff --git a/src/components/structures/RoomSearch.tsx b/src/components/structures/RoomSearch.tsx index 2130f4fa21..4c8329b6dd 100644 --- a/src/components/structures/RoomSearch.tsx +++ b/src/components/structures/RoomSearch.tsx @@ -8,6 +8,7 @@ Please see LICENSE files in the repository root for full details. import classNames from "classnames"; import React from "react"; +import { SearchIcon } from "@vector-im/compound-design-tokens/assets/web/icons"; import { ALTERNATE_KEY_NAME } from "../../accessibility/KeyboardShortcuts"; import defaultDispatcher from "../../dispatcher/dispatcher"; @@ -34,8 +35,6 @@ export default class RoomSearch extends React.PureComponent { "mx_RoomSearch_spotlightTrigger", ); - const icon =
    ; - const shortcutPrompt = ( {IS_MAC ? "⌘ K" : _t(ALTERNATE_KEY_NAME[Key.CONTROL]) + " K"} @@ -44,7 +43,7 @@ export default class RoomSearch extends React.PureComponent { return ( - {icon} + {!this.props.isMinimized && (
    {_t("action|search")}
    )} diff --git a/src/components/structures/RoomSearchView.tsx b/src/components/structures/RoomSearchView.tsx index c4d4f30b05..f19c0e0dc5 100644 --- a/src/components/structures/RoomSearchView.tsx +++ b/src/components/structures/RoomSearchView.tsx @@ -14,6 +14,7 @@ import { THREAD_RELATION_TYPE, } from "matrix-js-sdk/src/matrix"; import { logger } from "matrix-js-sdk/src/logger"; +import { SearchIcon } from "@vector-im/compound-design-tokens/assets/web/icons"; import ScrollPanel from "./ScrollPanel"; import Spinner from "../views/elements/Spinner"; @@ -21,7 +22,6 @@ import { _t } from "../../languageHandler"; import { haveRendererForEvent } from "../../events/EventTileFactory"; import SearchResultTile from "../views/rooms/SearchResultTile"; import { searchPagination, SearchScope } from "../../Searching"; -import type ResizeNotifier from "../../utils/ResizeNotifier"; import MatrixClientContext from "../../contexts/MatrixClientContext"; import { RoomPermalinkCreator } from "../../utils/permalinks/Permalinks"; import { useScopedRoomContext } from "../../contexts/ScopedRoomContext.tsx"; @@ -41,7 +41,6 @@ interface Props { inProgress: boolean; promise: Promise; abortController?: AbortController; - resizeNotifier: ResizeNotifier; className: string; onUpdate(inProgress: boolean, results: ISearchResults | null, error: Error | null): void; ref?: Ref; @@ -54,7 +53,6 @@ export const RoomSearchView = ({ scope, promise, abortController, - resizeNotifier, className, onUpdate, inProgress, @@ -156,7 +154,9 @@ export const RoomSearchView = ({
    + > + +
    ); } @@ -309,7 +309,6 @@ export const RoomSearchView = ({ ref={onRef} className={"mx_RoomView_searchResultsPanel " + className} onFillRequest={onSearchResultsFillRequest} - resizeNotifier={resizeNotifier} >
  • {ret} diff --git a/src/components/structures/RoomStatusBar.tsx b/src/components/structures/RoomStatusBar.tsx index 4793f1a65b..d9889342b1 100644 --- a/src/components/structures/RoomStatusBar.tsx +++ b/src/components/structures/RoomStatusBar.tsx @@ -17,7 +17,7 @@ import { type SyncState, type SyncStateData, } from "matrix-js-sdk/src/matrix"; -import { WarningIcon } from "@vector-im/compound-design-tokens/assets/web/icons"; +import { RestartIcon, WarningIcon, DeleteIcon } from "@vector-im/compound-design-tokens/assets/web/icons"; import { _t, _td } from "../../languageHandler"; import Resend from "../../Resend"; @@ -187,8 +187,8 @@ export default class RoomStatusBar extends React.PureComponent { // if it's a resource limit exceeded error: those are shown in the top bar. const errorIsMauError = Boolean( this.state.syncStateData && - this.state.syncStateData.error && - this.state.syncStateData.error.name === "M_RESOURCE_LIMIT_EXCEEDED", + this.state.syncStateData.error && + this.state.syncStateData.error.name === "M_RESOURCE_LIMIT_EXCEEDED", ); return this.state.syncState === "ERROR" && !errorIsMauError; } @@ -237,10 +237,12 @@ export default class RoomStatusBar extends React.PureComponent { let buttonRow = ( <> - + + {_t("room|status_bar|delete_all")} + {_t("room|status_bar|retry_all")} diff --git a/src/components/structures/RoomView.tsx b/src/components/structures/RoomView.tsx index fbff50bd01..997d86e0dc 100644 --- a/src/components/structures/RoomView.tsx +++ b/src/components/structures/RoomView.tsx @@ -44,6 +44,8 @@ import { type CallState, type MatrixCall } from "matrix-js-sdk/src/webrtc/call"; import { debounce, throttle } from "lodash"; import { CryptoEvent } from "matrix-js-sdk/src/crypto-api"; import { type ViewRoomOpts } from "@matrix-org/react-sdk-module-api/lib/lifecycles/RoomViewLifecycle"; +import { type RoomViewProps } from "@element-hq/element-web-module-api"; +import { RestartIcon } from "@vector-im/compound-design-tokens/assets/web/icons"; import shouldHideEvent from "../../shouldHideEvent"; import { _t } from "../../languageHandler"; @@ -133,6 +135,8 @@ import { PinnedMessageBanner } from "../views/rooms/PinnedMessageBanner"; import { ScopedRoomContextProvider, useScopedRoomContext } from "../../contexts/ScopedRoomContext"; import { DeclineAndBlockInviteDialog } from "../views/dialogs/DeclineAndBlockInviteDialog"; import { type FocusMessageSearchPayload } from "../../dispatcher/payloads/FocusMessageSearchPayload.ts"; +import { isRoomEncrypted } from "../../hooks/useIsEncrypted"; +import { type RoomViewStore } from "../../stores/RoomViewStore.tsx"; const DEBUG = false; const PREVENT_MULTIPLE_JITSI_WITHIN = 30_000; @@ -146,17 +150,54 @@ if (DEBUG) { debuglog = logger.log.bind(console); } -interface IRoomProps { +interface IRoomProps extends RoomViewProps { threepidInvite?: IThreepidInvite; oobData?: IOOBData; - resizeNotifier: ResizeNotifier; justCreatedOpts?: IOpts; forceTimeline?: boolean; // should we force access to the timeline, overriding (for eg) spaces // Called with the credentials of a registered user (if they were a ROU that transitioned to PWLU) onRegistered?(credentials: IMatrixClientCreds): void; + + /** + * Only necessary if RoomView should get it's RoomViewStore through the MultiRoomViewStore. + * Omitting this will mean that RoomView renders for the room held in SDKContext.RoomViewStore. + */ + roomId?: string; + + /* + * If true, hide the header + */ + hideHeader?: boolean; + + /* + * If true, hide the composer + */ + hideComposer?: boolean; + + /* + * If true, hide the right panel + */ + hideRightPanel?: boolean; + + /** + * If true, hide the pinned messages banner + */ + hidePinnedMessageBanner?: boolean; + + /** + * If true, hide the widgets + */ + hideWidgets?: boolean; + + /** + * If true, enable sending read receipts and markers on user activity in the room view. When the user interacts with the room view, read receipts and markers are sent. + * If false, the read receipts and markers are only send when the room view is focused. The user has to focus the room view in order to clear any unreads and to move the unread marker to the bottom of the view. + * @default true + */ + enableReadReceiptsAndMarkersOnActivity?: boolean; } export { MainSplitContentType }; @@ -257,6 +298,7 @@ interface LocalRoomViewProps { roomView: RefObject; onFileDrop: (dataTransfer: DataTransfer) => Promise; mainSplitContentType: MainSplitContentType; + e2eStatus?: E2EStatus; } /** @@ -289,7 +331,8 @@ function LocalRoomView(props: LocalRoomViewProps): ReactElement { if (room.isError) { const buttons = ( - + + {_t("action|retry")} ); @@ -304,6 +347,7 @@ function LocalRoomView(props: LocalRoomViewProps): ReactElement { } else { composer = ( -
    - +
    +
    - + {encryptionTile} @@ -334,7 +378,6 @@ function LocalRoomView(props: LocalRoomViewProps): ReactElement { interface ILocalRoomCreateLoaderProps { localRoom: LocalRoom; names: string; - resizeNotifier: ResizeNotifier; mainSplitContentType: MainSplitContentType; } @@ -370,14 +413,24 @@ export class RoomView extends React.Component { private unmounted = false; private permalinkCreators: Record = {}; + // The userId from which we received this invite. + // Only populated if the membership of our user is invite. + private inviter?: string; + private roomView = createRef(); private searchResultsPanel = createRef(); private messagePanel: TimelinePanel | null = null; private roomViewBody = createRef(); + private roomViewStore: RoomViewStore; + public static contextType = SDKContext; declare public context: React.ContextType; + public static readonly defaultProps = { + enableReadReceiptsAndMarkersOnActivity: true, + }; + public constructor(props: IRoomProps, context: React.ContextType) { super(props, context); @@ -387,6 +440,12 @@ export class RoomView extends React.Component { throw new Error("Unable to create RoomView without MatrixClient"); } + if (props.roomId) { + this.roomViewStore = this.context.multiRoomViewStore.getRoomViewStoreForRoom(props.roomId); + } else { + this.roomViewStore = context.roomViewStore; + } + const llMembers = context.client.hasLazyLoadMembersEnabled(); this.state = { roomId: undefined, @@ -520,7 +579,7 @@ export class RoomView extends React.Component { }; private getMainSplitContentType = (room: Room): MainSplitContentType => { - if (this.context.roomViewStore.isViewingCall() || isVideoRoom(room)) { + if (this.roomViewStore.isViewingCall() || isVideoRoom(room)) { return MainSplitContentType.Call; } if (this.context.widgetLayoutStore.hasMaximisedWidget(room)) { @@ -534,8 +593,8 @@ export class RoomView extends React.Component { return; } - const roomLoadError = this.context.roomViewStore.getRoomLoadError() ?? undefined; - if (!initial && !roomLoadError && this.state.roomId !== this.context.roomViewStore.getRoomId()) { + const roomLoadError = this.roomViewStore.getRoomLoadError() ?? undefined; + if (!initial && !roomLoadError && this.state.roomId !== this.roomViewStore.getRoomId()) { // RoomView explicitly does not support changing what room // is being viewed: instead it should just be re-mounted when // switching rooms. Therefore, if the room ID changes, we @@ -549,30 +608,38 @@ export class RoomView extends React.Component { // it was, it means we're about to be unmounted. return; } - - const roomId = this.context.roomViewStore.getRoomId() ?? null; + const roomViewStore = this.roomViewStore; + const roomId = roomViewStore.getRoomId() ?? null; + const roomAlias = roomViewStore.getRoomAlias() ?? undefined; + const roomLoading = roomViewStore.isRoomLoading(); + const joining = roomViewStore.isJoining(); + const replyToEvent = roomViewStore.getQuotingEvent() ?? undefined; + const shouldPeek = this.state.matrixClientIsReady && roomViewStore.shouldPeek(); + const wasContextSwitch = roomViewStore.getWasContextSwitch(); + const promptAskToJoin = roomViewStore.promptAskToJoin(); + const viewRoomOpts = roomViewStore.getViewRoomOpts(); const room = this.context.client?.getRoom(roomId ?? undefined) ?? undefined; const newState: Partial = { roomId: roomId ?? undefined, - roomAlias: this.context.roomViewStore.getRoomAlias() ?? undefined, - roomLoading: this.context.roomViewStore.isRoomLoading(), + roomAlias: roomAlias, + roomLoading: roomLoading, roomLoadError, - joining: this.context.roomViewStore.isJoining(), - replyToEvent: this.context.roomViewStore.getQuotingEvent() ?? undefined, + joining: joining, + replyToEvent: replyToEvent, // we should only peek once we have a ready client - shouldPeek: this.state.matrixClientIsReady && this.context.roomViewStore.shouldPeek(), + shouldPeek: shouldPeek, showReadReceipts: SettingsStore.getValue("showReadReceipts", roomId), showRedactions: SettingsStore.getValue("showRedactions", roomId), showJoinLeaves: SettingsStore.getValue("showJoinLeaves", roomId), showAvatarChanges: SettingsStore.getValue("showAvatarChanges", roomId), showDisplaynameChanges: SettingsStore.getValue("showDisplaynameChanges", roomId), - wasContextSwitch: this.context.roomViewStore.getWasContextSwitch(), + wasContextSwitch: wasContextSwitch, mainSplitContentType: room ? this.getMainSplitContentType(room) : undefined, initialEventId: undefined, // default to clearing this, will get set later in the method if needed showRightPanel: roomId ? this.context.rightPanelStore.isOpenForRoom(roomId) : false, - promptAskToJoin: this.context.roomViewStore.promptAskToJoin(), - viewRoomOpts: this.context.roomViewStore.getViewRoomOpts(), + promptAskToJoin: promptAskToJoin, + viewRoomOpts: viewRoomOpts, }; if ( @@ -588,7 +655,7 @@ export class RoomView extends React.Component { newState.showRightPanel = false; } - const initialEventId = this.context.roomViewStore.getInitialEventId() ?? this.state.initialEventId; + const initialEventId = this.roomViewStore.getInitialEventId() ?? this.state.initialEventId; if (initialEventId) { let initialEvent = room?.findEventById(initialEventId); // The event does not exist in the current sync data @@ -614,13 +681,13 @@ export class RoomView extends React.Component { action: Action.ShowThread, rootEvent: thread.rootEvent, initialEvent, - highlighted: this.context.roomViewStore.isInitialEventHighlighted(), - scroll_into_view: this.context.roomViewStore.initialEventScrollIntoView(), + highlighted: this.roomViewStore.isInitialEventHighlighted(), + scroll_into_view: this.roomViewStore.initialEventScrollIntoView(), }); } else { newState.initialEventId = initialEventId; - newState.isInitialEventHighlighted = this.context.roomViewStore.isInitialEventHighlighted(); - newState.initialEventScrollIntoView = this.context.roomViewStore.initialEventScrollIntoView(); + newState.isInitialEventHighlighted = this.roomViewStore.isInitialEventHighlighted(); + newState.initialEventScrollIntoView = this.roomViewStore.initialEventScrollIntoView(); } } @@ -848,6 +915,7 @@ export class RoomView extends React.Component { private shouldShowApps(room: Room): boolean { if (!BROWSER_SUPPORTS_SANDBOX || !room) return false; + if (this.props.hideWidgets) return false; // Check if user has previously chosen to hide the app drawer for this // room. If so, do not show apps @@ -880,14 +948,14 @@ export class RoomView extends React.Component { this.context.client.on(MatrixEventEvent.Decrypted, this.onEventDecrypted); } // Start listening for RoomViewStore updates - this.context.roomViewStore.on(UPDATE_EVENT, this.onRoomViewStoreUpdate); + this.roomViewStore.on(UPDATE_EVENT, this.onRoomViewStoreUpdate); this.context.rightPanelStore.on(UPDATE_EVENT, this.onRightPanelStoreUpdate); WidgetEchoStore.on(UPDATE_EVENT, this.onWidgetEchoStoreUpdate); this.context.widgetStore.on(UPDATE_EVENT, this.onWidgetStoreUpdate); - this.props.resizeNotifier.on("isResizing", this.onIsResizing); + this.context.resizeNotifier.on("isResizing", this.onIsResizing); this.settingWatchers = [ SettingsStore.watchSetting("layout", null, (...[, , , value]) => @@ -997,13 +1065,13 @@ export class RoomView extends React.Component { window.removeEventListener("beforeunload", this.onPageUnload); - this.context.roomViewStore.off(UPDATE_EVENT, this.onRoomViewStoreUpdate); + this.roomViewStore.off(UPDATE_EVENT, this.onRoomViewStoreUpdate); this.context.rightPanelStore.off(UPDATE_EVENT, this.onRightPanelStoreUpdate); WidgetEchoStore.removeListener(UPDATE_EVENT, this.onWidgetEchoStoreUpdate); this.context.widgetStore.removeListener(UPDATE_EVENT, this.onWidgetStoreUpdate); - this.props.resizeNotifier.off("isResizing", this.onIsResizing); + this.context.resizeNotifier.off("isResizing", this.onIsResizing); if (this.state.room) { this.context.widgetLayoutStore.off( @@ -1025,6 +1093,8 @@ export class RoomView extends React.Component { // clean up if this was a local room this.context.client?.store.removeRoom(this.state.room.roomId); } + + if (this.props.roomId) this.context.multiRoomViewStore.removeRoomViewStore(this.props.roomId); } private onRightPanelStoreUpdate = (): void => { @@ -1105,6 +1175,7 @@ export class RoomView extends React.Component { [payload.file], roomId, undefined, + this.state.replyToEvent, this.context.client, ); } @@ -1155,7 +1226,13 @@ export class RoomView extends React.Component { case Action.EditEvent: { // Quit early if we're trying to edit events in wrong rendering context if (payload.timelineRenderingType !== this.state.timelineRenderingType) return; - if (payload.event && payload.event.getRoomId() !== this.state.roomId) { + + const roomId: string | undefined = payload.event?.getRoomId(); + + if (payload.event && roomId !== this.state.roomId) { + // if the room is displayed in a module, we don't want to change the room view + if (roomId && this.roomViewStore.isRoomDisplayedInModule(roomId)) return; + // If the event is in a different room (e.g. because the event to be edited is being displayed // in the results of an all-rooms search), we need to view that room first. defaultDispatcher.dispatch({ @@ -1350,6 +1427,11 @@ export class RoomView extends React.Component { // after a successful peek, or after we join the room). private onRoomLoaded = (room: Room): void => { if (this.unmounted) return; + + // Store the inviter so that we can know who invited us to this room even if + // the membership event changes. + this.inviter = this.getInviterFromRoom(room); + // Attach a widget store listener only when we get a room this.context.widgetLayoutStore.on(WidgetLayoutStore.emissionForRoom(room), this.onWidgetLayoutChange); @@ -1388,10 +1470,13 @@ export class RoomView extends React.Component { } private async getIsRoomEncrypted(roomId = this.state.roomId): Promise { - const crypto = this.context.client?.getCrypto(); - if (!crypto || !roomId) return false; + if (!roomId) return false; - return await crypto.isEncryptionEnabledInRoom(roomId); + const room = this.context.client?.getRoom(roomId); + const crypto = this.context.client?.getCrypto(); + if (!room || !crypto) return false; + + return isRoomEncrypted(room, crypto); } private async calculateRecommendedVersion(room: Room): Promise { @@ -1729,8 +1814,20 @@ export class RoomView extends React.Component { }); }; + private getInviterFromRoom(room: Room): string | undefined { + const ownUserId = this.context.client?.getSafeUserId(); + if (!ownUserId) return; + + const myMember = room.getMember(ownUserId); + const memberEvent = myMember?.events.member; + const senderId = memberEvent?.getSender(); + + if (memberEvent?.getContent().membership === KnownMembership.Invite) return senderId; + } + private onDeclineAndBlockButtonClicked = async (): Promise => { if (!this.state.room || !this.context.client) return; + const [shouldReject, ignoreUser, reportRoom] = await Modal.createDialog(DeclineAndBlockInviteDialog, { roomName: this.state.room.name, }).finished; @@ -1745,11 +1842,20 @@ export class RoomView extends React.Component { const actions: Promise[] = []; if (ignoreUser) { - const myMember = this.state.room.getMember(this.context.client!.getSafeUserId()); - const inviteEvent = myMember!.events.member; - const ignoredUsers = this.context.client.getIgnoredUsers(); - ignoredUsers.push(inviteEvent!.getSender()!); // de-duped internally in the js-sdk - actions.push(this.context.client.setIgnoredUsers(ignoredUsers)); + const doIgnore = async (): Promise => { + const ownUserId = this.context.client!.getSafeUserId(); + if (!this.inviter || this.inviter === ownUserId) { + // This is unlikely to happen since we cache the inviter as early as possible. + // However, we still do this check here to be double sure. + throw new CannotDetermineUserError( + "Cannot determine which user to ignore since the member event has changed.", + ); + } + const ignoredUsers = this.context.client!.getIgnoredUsers(); + ignoredUsers.push(this.inviter); // de-duped internally in the js-sdk + await this.context.client!.setIgnoredUsers(ignoredUsers); + }; + actions.push(doIgnore()); } if (reportRoom !== false) { @@ -1766,7 +1872,14 @@ export class RoomView extends React.Component { } catch (error) { logger.error(`Failed to reject invite: ${error}`); - const msg = error instanceof Error ? error.message : JSON.stringify(error); + let msg: string = ""; + if (error instanceof CannotDetermineUserError) { + msg = _t("room|failed_determine_user"); + } else if (error instanceof Error) { + msg = error.message; + } else { + msg = JSON.stringify(error); + } Modal.createDialog(ErrorDialog, { title: _t("room|failed_reject_invite"), description: msg, @@ -1783,6 +1896,9 @@ export class RoomView extends React.Component { return; } try { + this.setState({ + rejecting: true, + }); await this.context.client.leave(this.state.room.roomId); defaultDispatcher.dispatch({ action: Action.ViewHomePage }); this.setState({ @@ -1985,6 +2101,7 @@ export class RoomView extends React.Component { Array.from(dataTransfer.files), roomId, undefined, + this.state.replyToEvent, this.context.client, TimelineRenderingType.Room, ); @@ -2006,11 +2123,10 @@ export class RoomView extends React.Component { if (!this.state.room || !this.context?.client) return null; const names = this.state.room.getDefaultRoomName(this.context.client.getSafeUserId()); return ( - + @@ -2019,10 +2135,11 @@ export class RoomView extends React.Component { private renderLocalRoomView(localRoom: LocalRoom): ReactNode { return ( - + { private renderWaitingForThirdPartyRoomView(inviteEvent: MatrixEvent): ReactNode { return ( - + @@ -2078,6 +2195,19 @@ export class RoomView extends React.Component { } }; + /** + * Handles the focus event on the RoomView component. + * + * Sends read receipts and updates the read marker if the + * disableReadReceiptsAndMarkersOnActivity prop is set. + */ + private onFocus = (): void => { + if (this.props.enableReadReceiptsAndMarkersOnActivity) return; + + this.messagePanel?.sendReadReceipts(); + this.messagePanel?.updateReadMarker(); + }; + public render(): ReactNode { if (!this.context.client) return null; const { isRoomEncrypted } = this.state; @@ -2355,7 +2485,7 @@ export class RoomView extends React.Component { { room={this.state.room} userId={this.context.client.getSafeUserId()} showApps={this.state.showApps} - resizeNotifier={this.props.resizeNotifier} > {aux} ); - const pinnedMessageBanner = ( - + const pinnedMessageBanner = !this.props.hidePinnedMessageBanner && ( + ); let messageComposer; const showComposer = + !this.props.hideComposer && !isRoomEncryptionLoading && // joined and not showing search results myMembership === KnownMembership.Join && @@ -2397,7 +2523,7 @@ export class RoomView extends React.Component { @@ -2419,7 +2545,6 @@ export class RoomView extends React.Component { promise={this.state.search.promise} abortController={this.state.search.abortController} inProgress={!!this.state.search.inProgress} - resizeNotifier={this.props.resizeNotifier} className={this.messagePanelClassNames} onUpdate={this.onSearchUpdate} /> @@ -2440,7 +2565,9 @@ export class RoomView extends React.Component { timelineSet={this.state.room.getUnfilteredTimelineSet()} showReadReceipts={this.state.showReadReceipts} manageReadReceipts={!this.state.isPeeking} - sendReadReceiptOnLoad={!this.state.wasContextSwitch} + sendReadReceiptOnLoad={ + !this.state.wasContextSwitch && this.props.enableReadReceiptsAndMarkersOnActivity + } manageReadMarkers={!this.state.isPeeking} hidden={hideMessagePanel} highlightedEventId={highlightedEventId} @@ -2454,10 +2581,10 @@ export class RoomView extends React.Component { className={this.messagePanelClassNames} membersLoaded={this.state.membersLoaded} permalinkCreator={this.permalinkCreator} - resizeNotifier={this.props.resizeNotifier} showReactions={true} layout={this.state.layout} editState={this.state.editState} + enableReadReceiptsAndMarkersOnActivity={this.props.enableReadReceiptsAndMarkersOnActivity} /> ); } @@ -2481,12 +2608,13 @@ export class RoomView extends React.Component { ); } - const showRightPanel = !isRoomEncryptionLoading && this.state.room && this.state.showRightPanel; + const showRightPanel = + !this.props.hideRightPanel && !isRoomEncryptionLoading && this.state.room && this.state.showRightPanel; const rightPanel = showRightPanel ? ( { {auxPanel} {pinnedMessageBanner} -
    - +
    + {topUnreadMessagesBar} {jumpToBottom} {messagePanel} @@ -2543,7 +2675,6 @@ export class RoomView extends React.Component { @@ -2558,7 +2689,6 @@ export class RoomView extends React.Component { @@ -2581,15 +2711,20 @@ export class RoomView extends React.Component { } return ( - -
    + +
    {showChatEffects && this.roomView.current && ( )} { ref={this.roomViewBody} data-layout={this.state.layout} > - + {!this.props.hideHeader && ( + + )} {mainSplitBody}
    @@ -2612,3 +2749,7 @@ export class RoomView extends React.Component { ); } } + +class CannotDetermineUserError extends Error { + public name = "CannotDetermineUserError"; +} diff --git a/src/components/structures/ScrollPanel.tsx b/src/components/structures/ScrollPanel.tsx index a92b24fc55..e206fc3c70 100644 --- a/src/components/structures/ScrollPanel.tsx +++ b/src/components/structures/ScrollPanel.tsx @@ -13,8 +13,8 @@ import SettingsStore from "../../settings/SettingsStore"; import Timer from "../../utils/Timer"; import AutoHideScrollbar from "./AutoHideScrollbar"; import { getKeyBindingsManager } from "../../KeyBindingsManager"; -import type ResizeNotifier from "../../utils/ResizeNotifier"; import { KeyBindingAction } from "../../accessibility/KeyboardShortcuts"; +import { SDKContext } from "../../contexts/SDKContext"; // The amount of extra scroll distance to allow prior to unfilling. // See getExcessHeight. @@ -58,10 +58,6 @@ interface IProps { */ style?: CSSProperties; - /* resizeNotifier: ResizeNotifier to know when middle column has changed size - */ - resizeNotifier?: ResizeNotifier; - /* fixedChildren: allows for children to be passed which are rendered outside * of the wrapper */ @@ -188,15 +184,18 @@ export default class ScrollPanel extends React.Component { private heightUpdateInProgress = false; public divScroll: HTMLDivElement | null = null; - public constructor(props: IProps) { - super(props); + public static contextType = SDKContext; + declare public context: React.ContextType; + + public constructor(props: IProps, context: React.ContextType) { + super(props, context); this.resetScrollState(); } public componentDidMount(): void { this.unmounted = false; - this.props.resizeNotifier?.on("middlePanelResizedNoisy", this.onResize); + this.context?.resizeNotifier?.on("middlePanelResizedNoisy", this.onResize); this.checkScroll(); } @@ -217,14 +216,14 @@ export default class ScrollPanel extends React.Component { // (We could use isMounted(), but facebook have deprecated that.) this.unmounted = true; - this.props.resizeNotifier?.removeListener("middlePanelResizedNoisy", this.onResize); + this.context?.resizeNotifier?.removeListener("middlePanelResizedNoisy", this.onResize); this.divScroll = null; } private onScroll = (ev: Event): void => { // skip scroll events caused by resizing - if (this.props.resizeNotifier && this.props.resizeNotifier.isResizing) return; + if (this.context?.resizeNotifier && this.context.resizeNotifier.isResizing) return; debuglog("onScroll called past resize gate; scroll node top:", this.getScrollNode().scrollTop); this.scrollTimeout?.restart(); this.saveScrollState(); diff --git a/src/components/structures/SearchBox.tsx b/src/components/structures/SearchBox.tsx index ec85314349..4f9c840cb6 100644 --- a/src/components/structures/SearchBox.tsx +++ b/src/components/structures/SearchBox.tsx @@ -10,6 +10,7 @@ Please see LICENSE files in the repository root for full details. import React, { createRef, type HTMLProps } from "react"; import { throttle } from "lodash"; import classNames from "classnames"; +import { CloseIcon } from "@vector-im/compound-design-tokens/assets/web/icons"; import AccessibleButton from "../../components/views/elements/AccessibleButton"; import { getKeyBindingsManager } from "../../KeyBindingsManager"; @@ -124,7 +125,9 @@ export default class SearchBox extends React.Component { onClick={() => { this.clearSearch("button"); }} - /> + > + + ) : undefined; // show a shorter placeholder when blurred, if requested diff --git a/src/components/structures/SpaceHierarchy.tsx b/src/components/structures/SpaceHierarchy.tsx index 0d41e56c92..e2b05c54f1 100644 --- a/src/components/structures/SpaceHierarchy.tsx +++ b/src/components/structures/SpaceHierarchy.tsx @@ -32,7 +32,6 @@ import { RoomType, GuestAccess, HistoryVisibility, - type HierarchyRelation, type HierarchyRoom, JoinRule, } from "matrix-js-sdk/src/matrix"; @@ -41,6 +40,7 @@ import classNames from "classnames"; import { sortBy, uniqBy } from "lodash"; import { logger } from "matrix-js-sdk/src/logger"; import { KnownMembership, type SpaceChildEventContent } from "matrix-js-sdk/src/types"; +import { ChevronDownIcon, CheckIcon } from "@vector-im/compound-design-tokens/assets/web/icons"; import defaultDispatcher from "../../dispatcher/dispatcher"; import { _t } from "../../languageHandler"; @@ -68,9 +68,11 @@ import { type JoinRoomReadyPayload } from "../../dispatcher/payloads/JoinRoomRea import { KeyBindingAction } from "../../accessibility/KeyboardShortcuts"; import { getKeyBindingsManager } from "../../KeyBindingsManager"; import { getTopic } from "../../hooks/room/useTopic"; -import { SdkContextClass } from "../../contexts/SDKContext"; import { getDisplayAliasForAliasSet } from "../../Rooms"; import SettingsStore from "../../settings/SettingsStore"; +import { filterBoolean } from "../../utils/arrays.ts"; +import { type RoomViewStore } from "../../stores/RoomViewStore.tsx"; +import RoomContext from "../../contexts/RoomContext.ts"; interface IProps { space: Room; @@ -148,7 +150,7 @@ const Tile: React.FC = ({ tabIndex={isActive ? 0 : -1} title={_t("space|joining_space")} > - + ); } else if (joinedRoom || room.join_rule === JoinRule.Knock) { @@ -250,7 +252,12 @@ const Tile: React.FC = ({ let joinedSection: ReactElement | undefined; if (joinedRoom) { - joinedSection =
    {_t("common|joined")}
    ; + joinedSection = ( +
    + + {_t("common|joined")} +
    + ); } let suggestedSection: ReactElement | undefined; @@ -293,7 +300,9 @@ const Tile: React.FC = ({ ev.stopPropagation(); toggleShowChildren(); }} - /> + > + +
    ); if (showChildren) { @@ -404,7 +413,20 @@ export const showRoom = (cli: MatrixClient, hierarchy: RoomHierarchy, roomId: st }); }; -export const joinRoom = async (cli: MatrixClient, hierarchy: RoomHierarchy, roomId: string): Promise => { +/** + * Join a room. + * @param cli The Matrix client + * @param roomViewStore The RoomViewStore instance + * @param hierarchy The RoomHierarchy instance + * @param roomId The ID of the room to join + * @returns A promise that resolves when the room has been joined + */ +export const joinRoom = async ( + cli: MatrixClient, + roomViewStore: RoomViewStore, + hierarchy: RoomHierarchy, + roomId: string, +): Promise => { // Don't let the user view a room they won't be able to either peek or join: // fail earlier so they don't have to click back to the directory. if (cli.isGuest()) { @@ -418,10 +440,10 @@ export const joinRoom = async (cli: MatrixClient, hierarchy: RoomHierarchy, room }); } catch (err: unknown) { if (err instanceof MatrixError) { - SdkContextClass.instance.roomViewStore.showJoinRoomError(err, roomId); + roomViewStore.showJoinRoomError(err, roomId); } else { logger.warn("Got a non-MatrixError while joining room", err); - SdkContextClass.instance.roomViewStore.showJoinRoomError( + roomViewStore.showJoinRoomError( new MatrixError({ error: _t("error|unknown"), }), @@ -504,68 +526,67 @@ export const HierarchyLevel: React.FC = ({ const space = cli.getRoom(root.room_id); const hasPermissions = space?.currentState.maySendStateEvent(EventType.SpaceChild, cli.getSafeUserId()); - const sortedChildren = sortBy(root.children_state, (ev) => { - return getChildOrder(ev.content.order, ev.origin_server_ts, ev.state_key); - }); - - const [subspaces, childRooms] = sortedChildren.reduce( - (result, ev: HierarchyRelation) => { - const room = hierarchy.roomMap.get(ev.state_key); - if (room && roomSet.has(room)) { - result[room.room_type === RoomType.Space ? 0 : 1].push(toLocalRoom(cli, room, hierarchy)); - } - return result; - }, - [[] as HierarchyRoom[], [] as HierarchyRoom[]], + const sortedChildren = filterBoolean( + sortBy(root.children_state, (ev) => { + return getChildOrder(ev.content.order, ev.origin_server_ts, ev.state_key); + }).map((ev) => { + const hierarchyRoom = hierarchy.roomMap.get(ev.state_key); + if (!hierarchyRoom || !roomSet.has(hierarchyRoom)) return null; + // Find the most up-to-date info for this room, if it has been upgraded and we know about it. + return toLocalRoom(cli, hierarchyRoom, hierarchy); + }), ); const newParents = new Set(parents).add(root.room_id); return ( - {uniqBy(childRooms, "room_id").map((room) => ( - onViewRoomClick(room.room_id, room.room_type as RoomType)} - onJoinRoomClick={() => onJoinRoomClick(room.room_id, newParents)} - hasPermissions={hasPermissions} - onToggleClick={onToggleClick ? () => onToggleClick(root.room_id, room.room_id) : undefined} - /> - ))} - - {subspaces - .filter((room) => !newParents.has(room.room_id)) - .map((space) => ( - { - const room = hierarchy.roomMap.get(ev.state_key); - return room && roomSet.has(room) && !room.room_type; - }).length - } - suggested={hierarchy.isSuggested(root.room_id, space.room_id)} - selected={selectedMap?.get(root.room_id)?.has(space.room_id)} - onViewRoomClick={() => onViewRoomClick(space.room_id, RoomType.Space)} - onJoinRoomClick={() => onJoinRoomClick(space.room_id, newParents)} - hasPermissions={hasPermissions} - onToggleClick={onToggleClick ? () => onToggleClick(root.room_id, space.room_id) : undefined} - > - { + if (room.room_type !== RoomType.Space) { + return ( + onViewRoomClick(room.room_id, room.room_type as RoomType)} + onJoinRoomClick={() => onJoinRoomClick(room.room_id, newParents)} + hasPermissions={hasPermissions} + onToggleClick={onToggleClick ? () => onToggleClick(root.room_id, room.room_id) : undefined} /> - - ))} + ); + } else { + if (newParents.has(room.room_id)) return null; // prevent cycles + return ( + { + const child = hierarchy.roomMap.get(ev.state_key); + return child && roomSet.has(child) && !child.room_type; + }).length + } + suggested={hierarchy.isSuggested(root.room_id, room.room_id)} + selected={selectedMap?.get(root.room_id)?.has(room.room_id)} + onViewRoomClick={() => onViewRoomClick(room.room_id, RoomType.Space)} + onJoinRoomClick={() => onJoinRoomClick(room.room_id, newParents)} + hasPermissions={hasPermissions} + onToggleClick={onToggleClick ? () => onToggleClick(root.room_id, room.room_id) : undefined} + > + + + ); + } + })} ); }; @@ -762,6 +783,7 @@ const ManageButtons: React.FC = ({ hierarchy, selected, set const SpaceHierarchy: React.FC = ({ space, initialText = "", showRoom, additionalButtons }) => { const cli = useContext(MatrixClientContext); + const roomContext = useContext(RoomContext); const [query, setQuery] = useState(initialText); const [selected, setSelected] = useState(new Map>()); // Map> @@ -856,10 +878,10 @@ const SpaceHierarchy: React.FC = ({ space, initialText = "", showRoom, a onJoinRoomClick={async (roomId, parents) => { for (const parent of parents) { if (cli.getRoom(parent)?.getMyMembership() !== KnownMembership.Join) { - await joinRoom(cli, hierarchy, parent); + await joinRoom(cli, roomContext.roomViewStore, hierarchy, parent); } } - await joinRoom(cli, hierarchy, roomId); + await joinRoom(cli, roomContext.roomViewStore, hierarchy, roomId); }} /> diff --git a/src/components/structures/SpacePillButton.tsx b/src/components/structures/SpacePillButton.tsx new file mode 100644 index 0000000000..0093e2911b --- /dev/null +++ b/src/components/structures/SpacePillButton.tsx @@ -0,0 +1,27 @@ +/* +Copyright 2025 Element Creations Ltd. + +SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial +Please see LICENSE files in the repository root for full details. +*/ + +import React, { type JSX } from "react"; + +import AccessibleButton from "../views/elements/AccessibleButton"; + +const SpacePillButton: React.FC<{ + title: string; + icon: JSX.Element; + description: string; + onClick(): void; +}> = ({ title, icon, description, onClick }) => { + return ( + + {icon} + {title} +
    {description}
    +
    + ); +}; + +export default SpacePillButton; diff --git a/src/components/structures/SpaceRoomView.tsx b/src/components/structures/SpaceRoomView.tsx index 59ec657b02..1df95439d4 100644 --- a/src/components/structures/SpaceRoomView.tsx +++ b/src/components/structures/SpaceRoomView.tsx @@ -10,6 +10,12 @@ import { EventType, RoomType, JoinRule, Preset, type Room, RoomEvent } from "mat import { KnownMembership } from "matrix-js-sdk/src/types"; import { logger } from "matrix-js-sdk/src/logger"; import React, { type JSX, useCallback, useContext, useRef, useState } from "react"; +import { + GroupIcon, + PlusIcon, + RoomIcon, + UserProfileSolidIcon, +} from "@vector-im/compound-design-tokens/assets/web/icons"; import MatrixClientContext from "../../contexts/MatrixClientContext"; import createRoom, { type IOpts } from "../../createRoom"; @@ -66,6 +72,8 @@ import MainSplit from "./MainSplit"; import RightPanel from "./RightPanel"; import SpaceHierarchy, { showRoom } from "./SpaceHierarchy"; import { type RoomPermalinkCreator } from "../../utils/permalinks/Permalinks"; +import { Icon as HashVideoIcon } from "../../../res/img/element-icons/roomlist/hash-video.svg"; +import SpacePillButton from "./SpacePillButton.tsx"; interface IProps { space: Room; @@ -117,7 +125,7 @@ const SpaceLandingAddButton: React.FC<{ space: Room }> = ({ space }) => { <> } onClick={async (e): Promise => { e.preventDefault(); e.stopPropagation(); @@ -132,7 +140,7 @@ const SpaceLandingAddButton: React.FC<{ space: Room }> = ({ space }) => { {videoRoomsEnabled && ( } onClick={async (e): Promise => { e.preventDefault(); e.stopPropagation(); @@ -157,7 +165,7 @@ const SpaceLandingAddButton: React.FC<{ space: Room }> = ({ space }) => { )} } onClick={(e) => { e.preventDefault(); e.stopPropagation(); @@ -168,7 +176,7 @@ const SpaceLandingAddButton: React.FC<{ space: Room }> = ({ space }) => { {canCreateSpace && ( } onClick={(e) => { e.preventDefault(); e.stopPropagation(); @@ -329,8 +337,8 @@ const SpaceSetupFirstRooms: React.FC<{ return createRoom(space.client, { createOpts: { preset: isPublic ? Preset.PublicChat : Preset.PrivateChat, - name, }, + name, spinner: false, encryption: false, andView: false, @@ -423,7 +431,7 @@ const SpaceSetupPublicShare: React.FC = ({

    {_t("create_space|share_heading", { - name: justCreatedOpts?.createOpts?.name || space.name, + name: justCreatedOpts?.name || space.name, })}

    {_t("create_space|share_description")}
    @@ -449,28 +457,26 @@ const SpaceSetupPrivateScope: React.FC<{

    {_t("create_space|private_personal_heading")}

    {_t("create_space|private_personal_description", { - name: justCreatedOpts?.createOpts?.name || space.name, + name: justCreatedOpts?.name || space.name, })}
    - } + title={_t("create_space|personal_space")} + description={_t("create_space|personal_space_description")} onClick={() => { onFinished(false); }} - > - {_t("create_space|personal_space")} -
    {_t("create_space|personal_space_description")}
    -
    - + } + title={_t("create_space|private_space")} + description={_t("create_space|private_space_description")} onClick={() => { onFinished(true); }} - > - {_t("create_space|private_space")} -
    {_t("create_space|private_space_description")}
    -
    + />
    ); }; @@ -686,7 +692,7 @@ export default class SpaceRoomView extends React.PureComponent { @@ -763,7 +769,7 @@ export default class SpaceRoomView extends React.PureComponent { return (
    - + {this.renderBody()} diff --git a/src/components/structures/TabbedView.tsx b/src/components/structures/TabbedView.tsx index b01f160551..7b11b92e9c 100644 --- a/src/components/structures/TabbedView.tsx +++ b/src/components/structures/TabbedView.tsx @@ -26,16 +26,18 @@ export class Tab { * Creates a new tab. * @param {string} id The tab's ID. * @param {string} label The untranslated tab label. - * @param {string|JSX.Element} icon An SVG element to use for the tab icon. Can also be a string for legacy icons, in which case it is the class for the tab icon. This should be a simple mask. + * @param {string|JSX.Element} icon An SVG element to use for the tab icon. * @param {JSX.Element} body The JSX for the tab container. * @param {string} screenName The screen name to report to Posthog. + * @param {string} labelClassName Additional class to add to the tab label. */ public constructor( public readonly id: T, public readonly label: TranslationKey, - public readonly icon: string | JSX.Element | null, + public readonly icon: JSX.Element | null, public readonly body: JSX.Element, public readonly screenName?: ScreenName, + public readonly labelClassName?: string, ) {} } @@ -85,7 +87,7 @@ interface ITabLabelProps { } function TabLabel({ tab, isActive, showToolip, onClick }: ITabLabelProps): JSX.Element { - const classes = classNames("mx_TabbedView_tabLabel", { + const classes = classNames("mx_TabbedView_tabLabel", tab.labelClassName, { mx_TabbedView_tabLabel_active: isActive, }); diff --git a/src/components/structures/ThreadPanel.tsx b/src/components/structures/ThreadPanel.tsx index 892dc82c75..61386574ea 100644 --- a/src/components/structures/ThreadPanel.tsx +++ b/src/components/structures/ThreadPanel.tsx @@ -6,12 +6,11 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com Please see LICENSE files in the repository root for full details. */ -import { type Optional } from "matrix-events-sdk"; import React, { useContext, useEffect, useRef, useState } from "react"; import { type EventTimelineSet, type Room, Thread } from "matrix-js-sdk/src/matrix"; import { IconButton, Tooltip } from "@vector-im/compound-web"; import { logger } from "matrix-js-sdk/src/logger"; -import ThreadsIcon from "@vector-im/compound-design-tokens/assets/web/icons/threads"; +import { ThreadsIcon, CheckIcon, ChevronDownIcon } from "@vector-im/compound-design-tokens/assets/web/icons"; import { Icon as MarkAllThreadsReadIcon } from "../../../res/img/element-icons/check-all.svg"; import BaseCard from "../views/right_panel/BaseCard"; @@ -58,6 +57,7 @@ export const ThreadPanelHeaderFilterOptionItem: React.FC< > = ({ label, description, onClick, isSelected }) => { return ( + {isSelected ? : null} {label} {description} @@ -146,6 +146,7 @@ export const ThreadPanelHeader: React.FC<{ }} > {`${_t("threads|show_thread_filter")} ${value?.label}`} + {contextMenu}
    @@ -163,14 +164,13 @@ const ThreadPanel: React.FC = ({ roomId, onClose, permalinkCreator }) => const [room, setRoom] = useState(null); const [narrow, setNarrow] = useState(false); - const timelineSet: Optional = + const timelineSet: EventTimelineSet | undefined = filterOption === ThreadFilterType.My ? room?.threadsTimelineSets[1] : room?.threadsTimelineSets[0]; const hasThreads = Boolean(room?.threadsTimelineSets?.[0]?.getLiveTimeline()?.getEvents()?.length); useEffect(() => { const room = mxClient.getRoom(roomId); - room - ?.createThreadsTimelineSets() + room?.createThreadsTimelineSets() .then(() => room.fetchRoomThreads()) .then(() => { setFilterOption(ThreadFilterType.All); diff --git a/src/components/structures/ThreadView.tsx b/src/components/structures/ThreadView.tsx index c1c4b3ff57..8ddbbf6367 100644 --- a/src/components/structures/ThreadView.tsx +++ b/src/components/structures/ThreadView.tsx @@ -49,7 +49,6 @@ import { type ButtonEvent } from "../views/elements/AccessibleButton"; import Spinner from "../views/elements/Spinner"; import { type ComposerInsertPayload, ComposerType } from "../../dispatcher/payloads/ComposerInsertPayload"; import Heading from "../views/typography/Heading"; -import { SdkContextClass } from "../../contexts/SDKContext"; import { type ThreadPayload } from "../../dispatcher/payloads/ThreadPayload"; import { ScopedRoomContextProvider } from "../../contexts/ScopedRoomContext.tsx"; @@ -124,7 +123,7 @@ export default class ThreadView extends React.Component { const roomId = this.props.mxEvent.getRoomId(); SettingsStore.unwatchSetting(this.layoutWatcherRef); - const hasRoomChanged = SdkContextClass.instance.roomViewStore.getRoomId() !== roomId; + const hasRoomChanged = this.context.roomViewStore.getRoomId() !== roomId; if (this.props.initialEvent && !hasRoomChanged) { dis.dispatch({ action: Action.ViewRoom, @@ -334,6 +333,7 @@ export default class ThreadView extends React.Component { Array.from(dataTransfer.files), roomId, this.threadRelation, + this.context.replyToEvent, MatrixClientPeg.safeGet(), TimelineRenderingType.Thread, ); @@ -388,7 +388,7 @@ export default class ThreadView extends React.Component { timeline = ( <> - + { sendReadReceiptOnLoad: true, hideThreadedMessages: true, disableGrouping: false, + enableReadReceiptsAndMarkersOnActivity: true, }; private lastRRSentEventId: string | null | undefined = undefined; @@ -304,10 +309,10 @@ class TimelinePanel extends React.Component { this.props.timelineSet.room?.on(ThreadEvent.Update, this.onThreadUpdate); - if (this.props.manageReadReceipts) { + if (this.props.manageReadReceipts && this.props.enableReadReceiptsAndMarkersOnActivity) { this.updateReadReceiptOnUserActivity(); } - if (this.props.manageReadMarkers) { + if (this.props.manageReadMarkers && this.props.enableReadReceiptsAndMarkersOnActivity) { this.updateReadMarkerOnUserActivity(); } this.initTimeline(this.props); @@ -1030,7 +1035,10 @@ class TimelinePanel extends React.Component { ); } - private sendReadReceipts = async (): Promise => { + /** + * Sends read receipts and fully read markers as appropriate. + */ + public sendReadReceipts = async (): Promise => { if (SettingsStore.getValue("lowBandwidth")) return; if (!this.messagePanel.current) return; if (!this.props.manageReadReceipts) return; @@ -1136,9 +1144,12 @@ class TimelinePanel extends React.Component { } } - // if the read marker is on the screen, we can now assume we've caught up to the end - // of the screen, so move the marker down to the bottom of the screen. - private updateReadMarker = async (): Promise => { + /** + * Move the marker to the bottom of the screen. + * If the read marker is on the screen, we can now assume we've caught up to the end + * of the screen, so move the marker down to the bottom of the screen. + */ + public updateReadMarker = async (): Promise => { if (!this.props.manageReadMarkers) return; if (this.getReadMarkerPosition() === 1) { // the read marker is at an event below the viewport, @@ -1849,7 +1860,6 @@ class TimelinePanel extends React.Component { this.state.alwaysShowTimestamps } className={this.props.className} - resizeNotifier={this.props.resizeNotifier} getRelationsForEvent={this.getRelationsForEvent} editState={this.props.editState} showReactions={this.props.showReactions} diff --git a/src/components/structures/ToastContainer.tsx b/src/components/structures/ToastContainer.tsx index 649cd1dc59..a84af5a504 100644 --- a/src/components/structures/ToastContainer.tsx +++ b/src/components/structures/ToastContainer.tsx @@ -53,8 +53,7 @@ export default class ToastContainer extends React.Component const { title, icon, key, component, className, bodyClassName, props } = topToast; const bodyClasses = classNames("mx_Toast_body", bodyClassName); const toastClasses = classNames("mx_Toast_toast", className, { - mx_Toast_hasIcon: icon, - [`mx_Toast_icon_${icon}`]: icon, + mx_Toast_hasIcon: !!icon, }); const toastProps = Object.assign({}, props, { key, @@ -81,6 +80,7 @@ export default class ToastContainer extends React.Component toast = (
    + {icon} {titleElement}
    {content}
    diff --git a/src/components/structures/UploadBar.tsx b/src/components/structures/UploadBar.tsx index a28ff2ba44..4fbe35f3fd 100644 --- a/src/components/structures/UploadBar.tsx +++ b/src/components/structures/UploadBar.tsx @@ -8,7 +8,7 @@ Please see LICENSE files in the repository root for full details. import React from "react"; import { type Room, type IEventRelation } from "matrix-js-sdk/src/matrix"; -import { type Optional } from "matrix-events-sdk"; +import { CloseIcon, ShareIcon } from "@vector-im/compound-design-tokens/assets/web/icons"; import ContentMessages from "../../ContentMessages"; import dis from "../../dispatcher/dispatcher"; @@ -45,7 +45,7 @@ function isUploadPayload(payload: ActionPayload): payload is UploadPayload { } export default class UploadBar extends React.PureComponent { - private dispatcherRef: Optional; + private dispatcherRef?: string; private unmounted = false; public constructor(props: IProps) { @@ -115,10 +115,13 @@ export default class UploadBar extends React.PureComponent { const uploadSize = fileSize(this.state.currentTotal!); return (
    +
    {uploadText} ({uploadSize})
    - + + +
    ); diff --git a/src/components/structures/UserMenu.tsx b/src/components/structures/UserMenu.tsx index 67af61f7ac..249a5f456b 100644 --- a/src/components/structures/UserMenu.tsx +++ b/src/components/structures/UserMenu.tsx @@ -8,6 +8,14 @@ Please see LICENSE files in the repository root for full details. import React, { type JSX, createRef, type ReactNode } from "react"; import { type Room } from "matrix-js-sdk/src/matrix"; +import { + ChatSolidIcon, + HomeSolidIcon, + LockSolidIcon, + QrCodeIcon, + SettingsSolidIcon, + LeaveIcon, +} from "@vector-im/compound-design-tokens/assets/web/icons"; import { MatrixClientPeg } from "../../MatrixClientPeg"; import defaultDispatcher from "../../dispatcher/dispatcher"; @@ -42,7 +50,8 @@ import PosthogTrackers from "../../PosthogTrackers"; import { type ViewHomePagePayload } from "../../dispatcher/payloads/ViewHomePagePayload"; import { SDKContext } from "../../contexts/SDKContext"; import { shouldShowFeedback } from "../../utils/Feedback"; -import DarkLightModeSvg from "../../../res/img/element-icons/roomlist/dark-light-mode.svg"; +import { Icon as DarkLightModeSvg } from "../../../res/img/element-icons/roomlist/dark-light-mode.svg"; +import { Icon as NotificationsIcon } from "../../../res/img/element-icons/notifications.svg"; interface IProps { isPanelCollapsed: boolean; @@ -297,7 +306,7 @@ export default class UserMenu extends React.Component { if (this.hasHomePage) { homeButton = ( } label={_t("common|home")} onClick={this.onHomeClick} /> @@ -308,7 +317,7 @@ export default class UserMenu extends React.Component { if (shouldShowFeedback()) { feedbackButton = ( } label={_t("common|feedback")} onClick={this.onProvideFeedback} /> @@ -317,7 +326,7 @@ export default class UserMenu extends React.Component { const linkNewDeviceButton = ( } label={_t("user_menu|link_new_device")} onClick={(e) => this.onSettingsOpen(e, UserTab.SessionManager, { showMsc4108QrCode: true })} /> @@ -328,24 +337,24 @@ export default class UserMenu extends React.Component { {homeButton} {linkNewDeviceButton} } label={_t("notifications|enable_prompt_toast_title")} onClick={(e) => this.onSettingsOpen(e, UserTab.Notifications)} /> } label={_t("room_settings|security|title")} onClick={(e) => this.onSettingsOpen(e, UserTab.Security)} /> } label={_t("user_menu|settings")} onClick={(e) => this.onSettingsOpen(e)} /> {feedbackButton} } label={_t("action|sign_out")} onClick={this.onSignOutClick} /> @@ -357,7 +366,7 @@ export default class UserMenu extends React.Component { {homeButton} } label={_t("common|settings")} onClick={(e) => this.onSettingsOpen(e)} /> @@ -398,7 +407,7 @@ export default class UserMenu extends React.Component { : _t("user_menu|switch_theme_dark") } > - +
  • {topSection} diff --git a/src/components/structures/UserView.tsx b/src/components/structures/UserView.tsx index 58aed9932b..ded92b6eb9 100644 --- a/src/components/structures/UserView.tsx +++ b/src/components/structures/UserView.tsx @@ -87,12 +87,7 @@ export default class UserView extends React.Component { /> ); return ( - + ); diff --git a/src/components/structures/WaitingForThirdPartyRoomView.tsx b/src/components/structures/WaitingForThirdPartyRoomView.tsx index 3983b286f1..a02a807c9b 100644 --- a/src/components/structures/WaitingForThirdPartyRoomView.tsx +++ b/src/components/structures/WaitingForThirdPartyRoomView.tsx @@ -41,7 +41,7 @@ export const WaitingForThirdPartyRoomView: React.FC = ({ roomView, resize
    - + void; @@ -22,15 +25,17 @@ interface IProps { interface IState { phase?: Phase; - lostKeys: boolean; } +/** + * Prompts the user to verify their device when they first log in. + */ export default class CompleteSecurity extends React.Component { public constructor(props: IProps) { super(props); const store = SetupEncryptionStore.sharedInstance(); store.start(); - this.state = { phase: store.phase, lostKeys: store.lostKeys() }; + this.state = { phase: store.phase }; } public componentDidMount(): void { @@ -40,7 +45,7 @@ export default class CompleteSecurity extends React.Component { private onStoreUpdate = (): void => { const store = SetupEncryptionStore.sharedInstance(); - this.setState({ phase: store.phase, lostKeys: store.lostKeys() }); + this.setState({ phase: store.phase }); }; private onSkipClick = (): void => { @@ -55,28 +60,22 @@ export default class CompleteSecurity extends React.Component { } public render(): React.ReactNode { - const { phase, lostKeys } = this.state; + const { phase } = this.state; let icon; let title; if (phase === Phase.Loading) { return null; } else if (phase === Phase.Intro) { - if (lostKeys) { - icon = ; - title = _t("encryption|verification|after_new_login|unable_to_verify"); - } else { - icon = ; - title = _t("encryption|verification|after_new_login|verify_this_device"); - } + // We don't specify an icon nor title since `SetupEncryptionBody` provides its own } else if (phase === Phase.Done) { - icon = ; + icon = ; title = _t("encryption|verification|after_new_login|device_verified"); } else if (phase === Phase.ConfirmSkip) { - icon = ; + icon = ; title = _t("common|are_you_sure"); } else if (phase === Phase.Busy) { - icon = ; + icon = ; title = _t("encryption|verification|after_new_login|verify_this_device"); } else if (phase === Phase.Finished) { // SetupEncryptionBody will take care of calling onFinished, we don't need to do anything @@ -98,17 +97,19 @@ export default class CompleteSecurity extends React.Component { } return ( - - -

    - {icon} - {title} - {skipButton} -

    -
    - -
    -
    + + + +

    + {icon} + {title} + {skipButton} +

    +
    + +
    +
    +
    ); } diff --git a/src/components/structures/auth/E2eSetup.tsx b/src/components/structures/auth/E2eSetup.tsx index 0fcacca66a..af3003dd27 100644 --- a/src/components/structures/auth/E2eSetup.tsx +++ b/src/components/structures/auth/E2eSetup.tsx @@ -13,15 +13,19 @@ import CompleteSecurityBody from "../../views/auth/CompleteSecurityBody"; import { InitialCryptoSetupDialog } from "../../views/dialogs/security/InitialCryptoSetupDialog"; interface IProps { - onFinished: () => void; + /** Callback which is called if the crypto setup failed, and the user clicked the 'cancel' button */ + onCancelled: () => void; } +/** + * An {@link AuthPage} which shows the {@link InitialCryptoSetupDialog}. + */ export default class E2eSetup extends React.Component { public render(): React.ReactNode { return ( - + ); diff --git a/src/components/structures/auth/ForgotPassword.tsx b/src/components/structures/auth/ForgotPassword.tsx index 171bd955a1..0a5080869b 100644 --- a/src/components/structures/auth/ForgotPassword.tsx +++ b/src/components/structures/auth/ForgotPassword.tsx @@ -373,7 +373,7 @@ export default class ForgotPassword extends React.Component { public renderSetPassword(): JSX.Element { const submitButtonChild = - this.state.phase === Phase.ResettingPassword ? : _t("auth|reset_password_action"); + this.state.phase === Phase.ResettingPassword ? : _t("auth|reset_password_action"); return ( <> diff --git a/src/components/structures/auth/SetupEncryptionBody.tsx b/src/components/structures/auth/SetupEncryptionBody.tsx index b76a623e2c..22b7a1adb5 100644 --- a/src/components/structures/auth/SetupEncryptionBody.tsx +++ b/src/components/structures/auth/SetupEncryptionBody.tsx @@ -9,7 +9,9 @@ Please see LICENSE files in the repository root for full details. import React, { type JSX } from "react"; import { type KeyBackupInfo, type VerificationRequest } from "matrix-js-sdk/src/crypto-api"; import { logger } from "matrix-js-sdk/src/logger"; -import { type SecretStorageKeyDescription } from "matrix-js-sdk/src/secret-storage"; +import DevicesIcon from "@vector-im/compound-design-tokens/assets/web/icons/devices"; +import LockIcon from "@vector-im/compound-design-tokens/assets/web/icons/lock-solid"; +import { Button } from "@vector-im/compound-web"; import { _t } from "../../../languageHandler"; import { MatrixClientPeg } from "../../../MatrixClientPeg"; @@ -17,25 +19,40 @@ import Modal from "../../../Modal"; import VerificationRequestDialog from "../../views/dialogs/VerificationRequestDialog"; import { SetupEncryptionStore, Phase } from "../../../stores/SetupEncryptionStore"; import EncryptionPanel from "../../views/right_panel/EncryptionPanel"; -import AccessibleButton, { type ButtonEvent } from "../../views/elements/AccessibleButton"; +import AccessibleButton from "../../views/elements/AccessibleButton"; import Spinner from "../../views/elements/Spinner"; import { ResetIdentityDialog } from "../../views/dialogs/ResetIdentityDialog"; - -function keyHasPassphrase(keyInfo: SecretStorageKeyDescription): boolean { - return Boolean(keyInfo.passphrase && keyInfo.passphrase.salt && keyInfo.passphrase.iterations); -} +import { EncryptionCard } from "../../views/settings/encryption/EncryptionCard"; +import { EncryptionCardButtons } from "../../views/settings/encryption/EncryptionCardButtons"; +import { EncryptionCardEmphasisedContent } from "../../views/settings/encryption/EncryptionCardEmphasisedContent"; +import ExternalLink from "../../views/elements/ExternalLink"; +import dispatcher from "../../../dispatcher/dispatcher"; +import E2EIcon from "../../views/rooms/E2EIcon.tsx"; +import { E2EStatus } from "../../../utils/ShieldUtils.ts"; interface IProps { onFinished: () => void; + /** + * Offer the user an option to log out, instead of setting up encryption. + * + * This is used when this component is shown when the user is initially + * prompted to set up encryption, before the user is shown the main chat + * interface. + * + * Defaults to `false` if omitted. + */ + allowLogout?: boolean; } interface IState { phase?: Phase; verificationRequest: VerificationRequest | null; backupInfo: KeyBackupInfo | null; - lostKeys: boolean; } +/** + * Component to set up encryption by verifying the current device. + */ export default class SetupEncryptionBody extends React.Component { public constructor(props: IProps) { super(props); @@ -48,7 +65,6 @@ export default class SetupEncryptionBody extends React.Component // Because of the latter, it lives in the state. verificationRequest: store.verificationRequest, backupInfo: store.backupInfo, - lostKeys: store.lostKeys(), }; } @@ -67,7 +83,6 @@ export default class SetupEncryptionBody extends React.Component phase: store.phase, verificationRequest: store.verificationRequest, backupInfo: store.backupInfo, - lostKeys: store.lostKeys(), }); }; @@ -112,8 +127,8 @@ export default class SetupEncryptionBody extends React.Component store.returnAfterSkip(); }; - private onResetClick = (ev: ButtonEvent): void => { - ev.preventDefault(); + private onCantConfirmClick = (): void => { + const store = SetupEncryptionStore.sharedInstance(); Modal.createDialog(ResetIdentityDialog, { onReset: () => { // The user completed the reset process - close this dialog @@ -121,10 +136,14 @@ export default class SetupEncryptionBody extends React.Component const store = SetupEncryptionStore.sharedInstance(); store.done(); }, - variant: "confirm", + variant: store.lostKeys() ? "no_verification_method" : "confirm", }); }; + private onSignOutClick = (): void => { + dispatcher.dispatch({ action: "logout" }); + }; + private onDoneClick = (): void => { const store = SetupEncryptionStore.sharedInstance(); store.done(); @@ -136,7 +155,7 @@ export default class SetupEncryptionBody extends React.Component public render(): React.ReactNode { const cli = MatrixClientPeg.safeGet(); - const { phase, lostKeys } = this.state; + const { phase } = this.state; if (this.state.verificationRequest && cli.getUser(this.state.verificationRequest.otherUserId)) { return ( @@ -149,69 +168,59 @@ export default class SetupEncryptionBody extends React.Component /> ); } else if (phase === Phase.Intro) { - if (lostKeys) { - return ( -
    -

    {_t("encryption|verification|no_key_or_device")}

    + const store = SetupEncryptionStore.sharedInstance(); -
    - - {_t("encryption|verification|reset_proceed_prompt")} - -
    -
    - ); - } else { - const store = SetupEncryptionStore.sharedInstance(); - let recoveryKeyPrompt; - if (store.keyInfo && keyHasPassphrase(store.keyInfo)) { - recoveryKeyPrompt = _t("encryption|verification|verify_using_key_or_phrase"); - } else if (store.keyInfo) { - recoveryKeyPrompt = _t("encryption|verification|verify_using_key"); - } - - let useRecoveryKeyButton; - if (recoveryKeyPrompt) { - useRecoveryKeyButton = ( - - {recoveryKeyPrompt} - - ); - } - - let verifyButton; - if (store.hasDevicesToVerifyAgainst) { - verifyButton = ( - - {_t("encryption|verification|verify_using_device")} - - ); - } - - return ( -
    -

    {_t("encryption|verification|verification_description")}

    - -
    - {verifyButton} - {useRecoveryKeyButton} -
    -
    - {_t("encryption|reset_all_button", undefined, { - a: (sub) => ( - - {sub} - - ), - })} -
    -
    + let verifyButton; + if (store.hasDevicesToVerifyAgainst) { + verifyButton = ( + ); } + + let useRecoveryKeyButton; + if (store.keyInfo) { + useRecoveryKeyButton = ( + + ); + } + + let signOutButton; + if (this.props.allowLogout) { + signOutButton = ( + + ); + } + + return ( + + + {_t("encryption|verification|confirm_identity_description")} + + + {_t("action|learn_more")} + + + + + {verifyButton} + {useRecoveryKeyButton} + + {signOutButton} + + + ); } else if (phase === Phase.Done) { let message: JSX.Element; if (this.state.backupInfo) { @@ -221,7 +230,7 @@ export default class SetupEncryptionBody extends React.Component } return (
    -
    + {message}
    diff --git a/src/components/structures/auth/SoftLogout.tsx b/src/components/structures/auth/SoftLogout.tsx index 34fabe46c7..a307078658 100644 --- a/src/components/structures/auth/SoftLogout.tsx +++ b/src/components/structures/auth/SoftLogout.tsx @@ -8,7 +8,6 @@ Please see LICENSE files in the repository root for full details. import React, { type JSX, type ChangeEvent, type SyntheticEvent } from "react"; import { logger } from "matrix-js-sdk/src/logger"; -import { type Optional } from "matrix-events-sdk"; import { type LoginFlow, MatrixError, SSOAction, type SSOFlow } from "matrix-js-sdk/src/matrix"; import { _t } from "../../../languageHandler"; @@ -217,7 +216,7 @@ export default class SoftLogout extends React.Component { }); } - private renderPasswordForm(introText: Optional): JSX.Element { + private renderPasswordForm(introText?: string): JSX.Element { let error: JSX.Element | undefined; if (this.state.errorText) { error = {this.state.errorText}; @@ -244,7 +243,7 @@ export default class SoftLogout extends React.Component { ); } - private renderSsoForm(introText: Optional): JSX.Element { + private renderSsoForm(introText?: string): JSX.Element { const loginType = this.state.loginView === LoginView.CAS ? "cas" : "sso"; const flow = this.state.flows.find((flow) => flow.type === "m.login." + loginType) as SSOFlow; @@ -284,14 +283,14 @@ export default class SoftLogout extends React.Component { return ( <>

    {_t("auth|soft_logout_intro_sso")}

    - {this.renderSsoForm(null)} + {this.renderSsoForm()}

    {_t("auth|sso_or_username_password", { ssoButtons: "", usernamePassword: "", }).trim()}

    - {this.renderPasswordForm(null)} + {this.renderPasswordForm()} ); } diff --git a/src/components/structures/auth/forgot-password/EnterEmail.tsx b/src/components/structures/auth/forgot-password/EnterEmail.tsx index 9e7d6ae5a6..643cb6c963 100644 --- a/src/components/structures/auth/forgot-password/EnterEmail.tsx +++ b/src/components/structures/auth/forgot-password/EnterEmail.tsx @@ -38,7 +38,7 @@ export const EnterEmail: React.FC = ({ onLoginClick, onSubmitForm, }) => { - const submitButtonChild = loading ? : _t("auth|forgot_password_send_email"); + const submitButtonChild = loading ? : _t("auth|forgot_password_send_email"); const emailFieldRef = useRef(null); diff --git a/src/components/structures/static-page-vars.ts b/src/components/structures/static-page-vars.ts index e27a449546..b5f21ea195 100644 --- a/src/components/structures/static-page-vars.ts +++ b/src/components/structures/static-page-vars.ts @@ -14,5 +14,5 @@ const matrixSvg = require("../../../res/img/matrix.svg").default; * Intended to replace $matrixLogo in the welcome page. */ export const MATRIX_LOGO_HTML = `
    - Matrix + `; diff --git a/src/components/utils/ListView.tsx b/src/components/utils/ListView.tsx new file mode 100644 index 0000000000..e35f7e2e47 --- /dev/null +++ b/src/components/utils/ListView.tsx @@ -0,0 +1,315 @@ +/* +Copyright 2025 New Vector Ltd. + +SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial +Please see LICENSE files in the repository root for full details. +*/ + +import React, { useRef, type JSX, useCallback, useEffect, useState } from "react"; +import { type VirtuosoHandle, type ListRange, Virtuoso, type VirtuosoProps } from "react-virtuoso"; + +import { isModifiedKeyEvent, Key } from "../../Keyboard"; +/** + * Context object passed to each list item containing the currently focused key + * and any additional context data from the parent component. + */ +export type ListContext = { + /** The key of item that should have tabIndex == 0 */ + tabIndexKey?: string; + /** Whether an item in the list is currently focused */ + focused: boolean; + /** Additional context data passed from the parent component */ + context: Context; +}; + +export interface IListViewProps extends Omit< + VirtuosoProps>, + "data" | "itemContent" | "context" +> { + /** + * The array of items to display in the virtualized list. + * Each item will be passed to getItemComponent for rendering. + */ + items: Item[]; + + /** + * Function that renders each list item as a JSX element. + * @param index - The index of the item in the list + * @param item - The data item to render + * @param context - The context object containing the focused key and any additional data + * @param onFocus - A callback that is required to be called when the item component receives focus + * @returns JSX element representing the rendered item + */ + getItemComponent: ( + index: number, + item: Item, + context: ListContext, + onFocus: (item: Item, e: React.FocusEvent) => void, + ) => JSX.Element; + + /** + * Optional additional context data to pass to each rendered item. + * This will be available in the ListContext passed to getItemComponent. + */ + context?: Context; + + /** + * Function to determine if an item can receive focus during keyboard navigation. + * @param item - The item to check for focusability + * @returns true if the item can be focused, false otherwise + */ + isItemFocusable: (item: Item) => boolean; + + /** + * Function to get the key to use for focusing an item. + * @param item - The item to get the key for + * @return The key to use for focusing the item + */ + getItemKey: (item: Item) => string; + /** + * Callback function to handle key down events on the list container. + * ListView handles keyboard navigation for focus(up, down, home, end, pageUp, pageDown) + * and stops propagation otherwise the event bubbles and this callback is called for the use of the parent. + * @param e - The keyboard event + * @returns + */ + onKeyDown?: (e: React.KeyboardEvent) => void; +} + +/** + * A generic virtualized list component built on top of react-virtuoso. + * Provides keyboard navigation and virtualized rendering for performance with large lists. + * + * @template Item - The type of data items in the list + * @template Context - The type of additional context data passed to items + */ +export function ListView(props: IListViewProps): React.ReactElement { + // Extract our custom props to avoid conflicts with Virtuoso props + const { items, getItemComponent, isItemFocusable, getItemKey, context, onKeyDown, ...virtuosoProps } = props; + /** Reference to the Virtuoso component for programmatic scrolling */ + const virtuosoHandleRef = useRef(null); + /** Reference to the DOM element containing the virtualized list */ + const virtuosoDomRef = useRef(null); + /** Key of the item that should have tabIndex == 0 */ + const [tabIndexKey, setTabIndexKey] = useState( + props.items[0] ? getItemKey(props.items[0]) : undefined, + ); + /** Range of currently visible items in the viewport */ + const [visibleRange, setVisibleRange] = useState(undefined); + /** Map from item keys to their indices in the items array */ + const [keyToIndexMap, setKeyToIndexMap] = useState>(new Map()); + /** Whether the list is currently scrolling to an item */ + const isScrollingToItem = useRef(false); + /** Whether the list is currently focused */ + const [isFocused, setIsFocused] = useState(false); + + // Update the key-to-index mapping whenever items change + useEffect(() => { + const newKeyToIndexMap = new Map(); + items.forEach((item, index) => { + const key = getItemKey(item); + newKeyToIndexMap.set(key, index); + }); + setKeyToIndexMap(newKeyToIndexMap); + }, [items, getItemKey]); + + // Ensure the tabIndexKey is set if there is none already or if the existing key is no longer displayed + useEffect(() => { + if (items.length && (!tabIndexKey || keyToIndexMap.get(tabIndexKey) === undefined)) { + setTabIndexKey(getItemKey(items[0])); + } + }, [items, getItemKey, tabIndexKey, keyToIndexMap]); + + /** + * Scrolls to a specific item index and sets it as focused. + * Uses Virtuoso's scrollIntoView method for smooth scrolling. + */ + const scrollToIndex = useCallback( + (index: number, align?: "center" | "end" | "start"): void => { + // Ensure index is within bounds + const clampedIndex = Math.max(0, Math.min(index, items.length - 1)); + if (isScrollingToItem.current) { + // If already scrolling to an item drop this request. Adding further requests + // causes the event to bubble up and be handled by other components(unintentional timeline scrolling was observed). + return; + } + if (items[clampedIndex]) { + const key = getItemKey(items[clampedIndex]); + isScrollingToItem.current = true; + virtuosoHandleRef.current?.scrollIntoView({ + index: clampedIndex, + align: align, + behavior: "auto", + done: () => { + setTabIndexKey(key); + isScrollingToItem.current = false; + }, + }); + } + }, + [items, getItemKey], + ); + + /** + * Scrolls to an item, skipping over non-focusable items if necessary. + * This is used for keyboard navigation to ensure focus lands on valid items. + */ + const scrollToItem = useCallback( + (index: number, isDirectionDown: boolean, align?: "center" | "end" | "start"): void => { + const totalRows = items.length; + let nextIndex: number | undefined; + + for (let i = index; isDirectionDown ? i < totalRows : i >= 0; i = i + (isDirectionDown ? 1 : -1)) { + if (isItemFocusable(items[i])) { + nextIndex = i; + break; + } + } + + if (nextIndex === undefined) { + return; + } + + scrollToIndex(nextIndex, align); + }, + [scrollToIndex, items, isItemFocusable], + ); + + /** + * Handles keyboard navigation for the list. + * Supports Arrow keys, Home, End, Page Up/Down, Enter, and Space. + */ + const keyDownCallback = useCallback( + (e: React.KeyboardEvent) => { + const currentIndex = tabIndexKey ? keyToIndexMap.get(tabIndexKey) : undefined; + let handled = false; + + // Guard against null/undefined events and modified keys which we don't want to handle here but do + // at the settings level shortcuts(E.g. Select next room, etc ) + // Guard against null/undefined events and modified keys + if (!e || isModifiedKeyEvent(e)) { + onKeyDown?.(e); + return; + } + + if (e.code === Key.ARROW_UP && currentIndex !== undefined) { + scrollToItem(currentIndex - 1, false); + handled = true; + } else if (e.code === Key.ARROW_DOWN && currentIndex !== undefined) { + scrollToItem(currentIndex + 1, true); + handled = true; + } else if (e.code === Key.HOME) { + scrollToIndex(0); + handled = true; + } else if (e.code === Key.END) { + scrollToIndex(items.length - 1); + handled = true; + } else if (e.code === Key.PAGE_DOWN && visibleRange && currentIndex !== undefined) { + const numberDisplayed = visibleRange.endIndex - visibleRange.startIndex; + scrollToItem(Math.min(currentIndex + numberDisplayed, items.length - 1), true, `start`); + handled = true; + } else if (e.code === Key.PAGE_UP && visibleRange && currentIndex !== undefined) { + const numberDisplayed = visibleRange.endIndex - visibleRange.startIndex; + scrollToItem(Math.max(currentIndex - numberDisplayed, 0), false, `start`); + handled = true; + } + + if (handled) { + e.stopPropagation(); + e.preventDefault(); + } else { + onKeyDown?.(e); + } + }, + [scrollToIndex, scrollToItem, tabIndexKey, keyToIndexMap, visibleRange, items, onKeyDown], + ); + + /** + * Callback ref for the Virtuoso scroller element. + * Stores the reference for use in focus management. + */ + const scrollerRef = useCallback((element: HTMLElement | Window | null) => { + virtuosoDomRef.current = element; + }, []); + + /** + * Focus handler passed to each item component. + * Don't declare inside getItemComponent to avoid re-creating on each render. + */ + const onFocusForGetItemComponent = useCallback( + (item: Item, e: React.FocusEvent) => { + // If one of the item components has been focused directly, set the focused and tabIndex state + // and stop propagation so the ListViews onFocus doesn't also handle it. + const key = getItemKey(item); + setIsFocused(true); + setTabIndexKey(key); + e.stopPropagation(); + }, + [getItemKey], + ); + + const getItemComponentInternal = useCallback( + (index: number, item: Item, context: ListContext): JSX.Element => + getItemComponent(index, item, context, onFocusForGetItemComponent), + [getItemComponent, onFocusForGetItemComponent], + ); + /** + * Handles focus events on the list. + * Sets the focused state and scrolls to the focused item if it is not currently visible. + */ + const onFocus = useCallback( + (e?: React.FocusEvent): void => { + if (e?.currentTarget !== virtuosoDomRef.current || typeof tabIndexKey !== "string") { + return; + } + + setIsFocused(true); + const index = keyToIndexMap.get(tabIndexKey); + if ( + index !== undefined && + visibleRange && + (index < visibleRange.startIndex || index > visibleRange.endIndex) + ) { + scrollToIndex(index); + } + e?.stopPropagation(); + e?.preventDefault(); + }, + [keyToIndexMap, visibleRange, scrollToIndex, tabIndexKey], + ); + + const onBlur = useCallback((event: React.FocusEvent): void => { + // Only set isFocused to false if the focus is moving outside the list + // This prevents the list from losing focus when interacting with menus inside it + if (!event.currentTarget.contains(event.relatedTarget)) { + setIsFocused(false); + } + }, []); + + const listContext: ListContext = { + tabIndexKey: tabIndexKey, + focused: isFocused, + context: props.context || ({} as Context), + }; + + return ( + + ); +} diff --git a/src/components/viewmodels/avatars/RoomAvatarViewModel.tsx b/src/components/viewmodels/avatars/RoomAvatarViewModel.tsx index 8879f5ae69..3832616a9c 100644 --- a/src/components/viewmodels/avatars/RoomAvatarViewModel.tsx +++ b/src/components/viewmodels/avatars/RoomAvatarViewModel.tsx @@ -10,26 +10,26 @@ import { useEffect, useState } from "react"; import { useTypedEventEmitter } from "../../../hooks/useEventEmitter"; import { useDmMember, usePresence, type Presence } from "../../views/avatars/WithPresenceIndicator"; +import { DefaultTagID } from "../../../stores/room-list/models"; + +export enum AvatarBadgeDecoration { + LowPriority = "LowPriority", + VideoRoom = "VideoRoom", + PublicRoom = "PublicRoom", + Presence = "Presence", +} export interface RoomAvatarViewState { - /** - * Whether the room avatar has a decoration. - * A decoration can be a public or a video call icon or an indicator of presence. - */ - hasDecoration: boolean; - /** - * Whether the room is public. - */ - isPublic: boolean; - /** - * Whether the room is a video room. - */ - isVideoRoom: boolean; /** * The presence of the user in the DM room. * If null, the user is not in a DM room or presence is not enabled. */ presence: Presence | null; + + /** + * The decoration that should be rendered. + */ + badgeDecoration?: AvatarBadgeDecoration; } /** @@ -41,10 +41,20 @@ export function useRoomAvatarViewModel(room: Room): RoomAvatarViewState { const roomMember = useDmMember(room); const presence = usePresence(room, roomMember); const isPublic = useIsPublic(room); + const isLowPriority = !!room.tags[DefaultTagID.LowPriority]; - const hasDecoration = isPublic || isVideoRoom || presence !== null; + let badgeDecoration: AvatarBadgeDecoration | undefined; + if (isLowPriority) { + badgeDecoration = AvatarBadgeDecoration.LowPriority; + } else if (isVideoRoom) { + badgeDecoration = AvatarBadgeDecoration.VideoRoom; + } else if (isPublic) { + badgeDecoration = AvatarBadgeDecoration.PublicRoom; + } else if (presence) { + badgeDecoration = AvatarBadgeDecoration.Presence; + } - return { hasDecoration, isPublic, isVideoRoom, presence }; + return { badgeDecoration, presence }; } /** diff --git a/src/components/viewmodels/memberlist/MemberListViewModel.tsx b/src/components/viewmodels/memberlist/MemberListViewModel.tsx index a03f703511..41233a4d6f 100644 --- a/src/components/viewmodels/memberlist/MemberListViewModel.tsx +++ b/src/components/viewmodels/memberlist/MemberListViewModel.tsx @@ -38,6 +38,7 @@ import { isValid3pidInvite } from "../../../RoomInvite"; import { type ThreePIDInvite } from "../../../models/rooms/ThreePIDInvite"; import { type XOR } from "../../../@types/common"; import { useTypedEventEmitter } from "../../../hooks/useEventEmitter"; +import { useRoomMemberCount } from "../../../hooks/useRoomMembers"; type Member = XOR<{ member: RoomMember }, { threePidInvite: ThreePIDInvite }>; @@ -126,6 +127,9 @@ export function useMemberListViewModel(roomId: string): MemberListViewState { const [isLoading, setIsLoading] = useState(true); // This is the last known total number of members in this room. const [totalMemberCount, setTotalMemberCount] = useState(0); + + const memberCountWithout3Pid = useRoomMemberCount(room, { includeInvited: true }); + /** * This is the current number of members in the list. * This number will be less than the total number of members @@ -168,7 +172,7 @@ export function useMemberListViewModel(roomId: string): MemberListViewState { } setMemberMap(newMemberMap); - setMemberCount(joinedSdk.length + invitedSdk.length + threePidInvited.length); + setMemberCount(memberCountWithout3Pid + threePidInvited.length); if (!searchQuery) { /** * Since searching for members only gives you the relevant @@ -180,7 +184,7 @@ export function useMemberListViewModel(roomId: string): MemberListViewState { 500, { leading: true, trailing: true }, ), - [sdkContext.memberListStore, roomId, room], + [sdkContext.memberListStore, roomId, room, memberCountWithout3Pid], ); const isPresenceEnabled = useMemo( diff --git a/src/components/viewmodels/memberlist/tiles/MemberTileViewModel.tsx b/src/components/viewmodels/memberlist/tiles/MemberTileViewModel.tsx index 4f6814caae..d99c127d85 100644 --- a/src/components/viewmodels/memberlist/tiles/MemberTileViewModel.tsx +++ b/src/components/viewmodels/memberlist/tiles/MemberTileViewModel.tsx @@ -5,7 +5,7 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com Please see LICENSE files in the repository root for full details. */ -import { useEffect, useMemo, useState } from "react"; +import { useEffect, useState } from "react"; import { RoomStateEvent, type MatrixEvent, EventType } from "matrix-js-sdk/src/matrix"; import { type UserVerificationStatus, CryptoEvent } from "matrix-js-sdk/src/crypto-api"; @@ -16,7 +16,6 @@ import { asyncSome } from "../../../../utils/arrays"; import { getUserDeviceIds } from "../../../../utils/crypto/deviceInfo"; import { type RoomMember } from "../../../../models/rooms/RoomMember"; import { _t, _td, type TranslationKey } from "../../../../languageHandler"; -import UserIdentifierCustomisations from "../../../../customisations/UserIdentifier"; import { E2EStatus } from "../../../../utils/ShieldUtils"; interface MemberTileViewModelProps { @@ -28,16 +27,17 @@ export interface MemberTileViewState extends MemberTileViewModelProps { e2eStatus?: E2EStatus; name: string; onClick: () => void; - title?: string; userLabel?: string; } export enum PowerStatus { + Creator = "creator", Admin = "admin", Moderator = "moderator", } const PowerLabel: Record = { + [PowerStatus.Creator]: _td("power_level|creator"), [PowerStatus.Admin]: _td("power_level|admin"), [PowerStatus.Moderator]: _td("power_level|moderator"), }; @@ -117,6 +117,7 @@ export function useMemberTileViewModel(props: MemberTileViewModelProps): MemberT const name = props.member.name; const powerStatusMap = new Map([ + [Infinity, PowerStatus.Creator], [100, PowerStatus.Admin], [50, PowerStatus.Moderator], ]); @@ -130,15 +131,6 @@ export function useMemberTileViewModel(props: MemberTileViewModelProps): MemberT } } - const title = useMemo(() => { - return _t("member_list|power_label", { - userName: UserIdentifierCustomisations.getDisplayUserIdentifier(member.userId, { - roomId: member.roomId, - }), - powerLevelNumber: member.powerLevel, - }).trim(); - }, [member.powerLevel, member.roomId, member.userId]); - let userLabel; const powerStatus = powerStatusMap.get(powerLevel); if (powerStatus) { @@ -149,7 +141,6 @@ export function useMemberTileViewModel(props: MemberTileViewModelProps): MemberT } return { - title, member, name, onClick, diff --git a/src/components/viewmodels/right_panel/RoomSummaryCardTopicViewModel.tsx b/src/components/viewmodels/right_panel/RoomSummaryCardTopicViewModel.tsx index 10cbc5b568..fd98a76541 100644 --- a/src/components/viewmodels/right_panel/RoomSummaryCardTopicViewModel.tsx +++ b/src/components/viewmodels/right_panel/RoomSummaryCardTopicViewModel.tsx @@ -6,7 +6,6 @@ Please see LICENSE files in the repository root for full details. import { type SyntheticEvent, useState } from "react"; import { EventType, type Room, type ContentHelpers } from "matrix-js-sdk/src/matrix"; -import { type Optional } from "matrix-events-sdk"; import { useRoomState } from "../../../hooks/useRoomState"; import defaultDispatcher from "../../../dispatcher/dispatcher"; @@ -17,7 +16,7 @@ export interface RoomTopicState { /** * The topic of the room, the value is taken from the room state */ - topic: Optional; + topic: ContentHelpers.TopicState | null; /** * Whether the topic is expanded or not */ diff --git a/src/components/viewmodels/right_panel/UserInfoPowerlevelViewModel.tsx b/src/components/viewmodels/right_panel/UserInfoPowerlevelViewModel.tsx new file mode 100644 index 0000000000..f56356ff41 --- /dev/null +++ b/src/components/viewmodels/right_panel/UserInfoPowerlevelViewModel.tsx @@ -0,0 +1,108 @@ +/* +Copyright 2025 New Vector Ltd. +SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only +Please see LICENSE files in the repository root for full details. +*/ + +import React, { useContext, useEffect, useState, useCallback } from "react"; +import { logger } from "@sentry/browser"; +import { type RoomMember, type Room } from "matrix-js-sdk/src/matrix"; + +import MatrixClientContext from "../../../contexts/MatrixClientContext"; +import { _t } from "../../../languageHandler"; +import Modal from "../../../Modal"; +import ErrorDialog from "../../views/dialogs/ErrorDialog"; +import QuestionDialog from "../../views/dialogs/QuestionDialog"; +import { warnSelfDemote } from "../../views/right_panel/UserInfo"; + +/** + * + */ +export interface UserInfoPowerLevelState { + /** + * default power level value of the selected user + */ + powerLevelUsersDefault: number; + /** + * The new power level to apply + */ + selectedPowerLevel: number; + /** + * Method to call When power level selection change + */ + onPowerChange: (powerLevel: number) => void; +} + +export const useUserInfoPowerlevelViewModel = (user: RoomMember, room: Room): UserInfoPowerLevelState => { + const [selectedPowerLevel, setSelectedPowerLevel] = useState(user.powerLevel); + + useEffect(() => { + setSelectedPowerLevel(user.powerLevel); + }, [user]); + + const cli = useContext(MatrixClientContext); + const onPowerChange = useCallback( + async (powerLevel: number) => { + setSelectedPowerLevel(powerLevel); + + const applyPowerChange = (roomId: string, target: string, powerLevel: number): Promise => { + return cli.setPowerLevel(roomId, target, powerLevel).then( + function () { + logger.info("Power change success"); + }, + function (err) { + logger.error("Failed to change power level " + err); + Modal.createDialog(ErrorDialog, { + title: _t("common|error"), + description: _t("error|update_power_level"), + }); + }, + ); + }; + + const roomId = user.roomId; + const target = user.userId; + + const powerLevelEvent = room.currentState.getStateEvents("m.room.power_levels", ""); + if (!powerLevelEvent) return; + + const myUserId = cli.getUserId(); + const myPower = powerLevelEvent.getContent().users[myUserId || ""]; + if (myPower && parseInt(myPower) <= powerLevel && myUserId !== target) { + const { finished } = Modal.createDialog(QuestionDialog, { + title: _t("common|warning"), + description: ( +
    + {_t("user_info|promote_warning")} +
    + {_t("common|are_you_sure")} +
    + ), + button: _t("action|continue"), + }); + + const [confirmed] = await finished; + if (!confirmed) return; + } else if (myUserId === target && myPower && parseInt(myPower) > powerLevel) { + // If we are changing our own PL it can only ever be decreasing, which we cannot reverse. + try { + if (!(await warnSelfDemote(room?.isSpaceRoom()))) return; + } catch (e) { + logger.error("Failed to warn about self demotion: " + e); + } + } + + await applyPowerChange(roomId, target, powerLevel); + }, + [user.roomId, user.userId, cli, room], + ); + + const powerLevelEvent = room.currentState.getStateEvents("m.room.power_levels", ""); + const powerLevelUsersDefault = powerLevelEvent ? powerLevelEvent.getContent().users_default : 0; + + return { + powerLevelUsersDefault, + onPowerChange, + selectedPowerLevel, + }; +}; diff --git a/src/components/viewmodels/right_panel/user_info/UserInfoBasicOptionsViewModel.tsx b/src/components/viewmodels/right_panel/user_info/UserInfoBasicOptionsViewModel.tsx new file mode 100644 index 0000000000..9d286125ae --- /dev/null +++ b/src/components/viewmodels/right_panel/user_info/UserInfoBasicOptionsViewModel.tsx @@ -0,0 +1,152 @@ +/* +Copyright 2025 New Vector Ltd. + +SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial +Please see LICENSE files in the repository root for full details. +*/ + +import { useContext } from "react"; +import { RoomMember, User, type Room, KnownMembership } from "matrix-js-sdk/src/matrix"; + +import Modal from "../../../../Modal"; +import ErrorDialog from "../../../views/dialogs/ErrorDialog"; +import { _t, UserFriendlyError } from "../../../../languageHandler"; +import MatrixClientContext from "../../../../contexts/MatrixClientContext"; +import dis from "../../../../dispatcher/dispatcher"; +import PosthogTrackers from "../../../../PosthogTrackers"; +import { ShareDialog } from "../../../views/dialogs/ShareDialog"; +import { type ComposerInsertPayload } from "../../../../dispatcher/payloads/ComposerInsertPayload"; +import { Action } from "../../../../dispatcher/actions"; +import { TimelineRenderingType } from "../../../../contexts/RoomContext"; +import MultiInviter from "../../../../utils/MultiInviter"; +import { type ViewRoomPayload } from "../../../../dispatcher/payloads/ViewRoomPayload"; +import { useRoomPermissions } from "./UserInfoBasicViewModel"; +import { DirectoryMember, startDmOnFirstMessage } from "../../../../utils/direct-messages"; +import { type Member } from "../../../views/right_panel/UserInfo"; + +export interface UserInfoBasicOptionsState { + // boolean to know if selected user is current user + isMe: boolean; + // boolean to display/hide invite button + showInviteButton: boolean; + // boolean to display/hide insert pill button + showInsertPillButton: boolean | ""; + // boolean to display/hide read receipt button + readReceiptButtonDisabled: boolean; + // Method called when a insert pill button is clicked + onInsertPillButton: () => void; + // Method called when a read receipt button is clicked, will add a pill in the input message field + onReadReceiptButton: () => void; + // Method called when a share user button is clicked, will display modal with profile to share + onShareUserClick: () => void; + // Method called when a invite button is clicked, will display modal to invite user + onInviteUserButton: (fallbackRoomId: string, evt: Event) => Promise; + // Method called when the DM button is clicked, will open a DM with the selected member + onOpenDmForUser: (member: Member) => Promise; +} + +export const useUserInfoBasicOptionsViewModel = (room: Room, member: User | RoomMember): UserInfoBasicOptionsState => { + const cli = useContext(MatrixClientContext); + + // selected member is current user + const isMe = member.userId === cli.getUserId(); + + // Those permissions are updated when a change is done on the room current state and the selected user + const roomPermissions = useRoomPermissions(cli, room, member as RoomMember); + + const isSpace = room?.isSpaceRoom(); + + // read receipt button stay disable for a room space or if all events where read (null) + const readReceiptButtonDisabled = isSpace || !room?.getEventReadUpTo(member.userId); + + // always show exempt when room is a space + const showInsertPillButton = member instanceof RoomMember && member.roomId && !isSpace; + + // show invite button only if current user has the permission to invite and the selected user membership is LEAVE + const showInviteButton = + member instanceof RoomMember && + roomPermissions.canInvite && + (member?.membership ?? KnownMembership.Leave) === KnownMembership.Leave; + + const onReadReceiptButton = function (): void { + const room = member instanceof RoomMember ? cli.getRoom(member.roomId) : null; + if (!room || readReceiptButtonDisabled) return; + + dis.dispatch({ + action: Action.ViewRoom, + highlighted: true, + // this could return null, the default prevents a type error + event_id: room.getEventReadUpTo(member.userId) || undefined, + room_id: room.roomId, + metricsTrigger: undefined, // room doesn't change + }); + }; + + const onInsertPillButton = function (): void { + dis.dispatch({ + action: Action.ComposerInsert, + userId: member.userId, + timelineRenderingType: TimelineRenderingType.Room, + }); + }; + + const onInviteUserButton = async (fallbackRoomId: string, ev: Event): Promise => { + try { + const roomId = member instanceof RoomMember && member.roomId ? member.roomId : fallbackRoomId; + + // We use a MultiInviter to re-use the invite logic, even though we're only inviting one user. + const inviter = new MultiInviter(cli, roomId || ""); + await inviter.invite([member.userId]).then(() => { + if (inviter.getCompletionState(member.userId) !== "invited") { + const errorStringFromInviterUtility = inviter.getErrorText(member.userId); + if (errorStringFromInviterUtility) { + throw new Error(errorStringFromInviterUtility); + } else { + throw new UserFriendlyError("slash_command|invite_failed", { + user: member.userId, + roomId, + cause: undefined, + }); + } + } + }); + } catch (err) { + const description = err instanceof Error ? err.message : _t("invite|failed_generic"); + + Modal.createDialog(ErrorDialog, { + title: _t("invite|failed_title"), + description, + }); + } + + PosthogTrackers.trackInteraction("WebRightPanelRoomUserInfoInviteButton", ev); + }; + + const onShareUserClick = (): void => { + Modal.createDialog(ShareDialog, { + target: member, + }); + }; + + const onOpenDmForUser = async (user: Member): Promise => { + const avatarUrl = user instanceof User ? user.avatarUrl : user.getMxcAvatarUrl(); + const startDmUser = new DirectoryMember({ + user_id: user.userId, + display_name: user.rawDisplayName, + avatar_url: avatarUrl, + }); + await startDmOnFirstMessage(cli, [startDmUser]); + }; + + return { + isMe, + showInviteButton, + showInsertPillButton, + readReceiptButtonDisabled, + onReadReceiptButton, + onInsertPillButton, + onInviteUserButton, + onShareUserClick, + onOpenDmForUser, + }; +}; diff --git a/src/components/viewmodels/right_panel/user_info/UserInfoBasicViewModel.tsx b/src/components/viewmodels/right_panel/user_info/UserInfoBasicViewModel.tsx new file mode 100644 index 0000000000..0773ca3cb7 --- /dev/null +++ b/src/components/viewmodels/right_panel/user_info/UserInfoBasicViewModel.tsx @@ -0,0 +1,197 @@ +/* +Copyright 2025 New Vector Ltd. + +SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial +Please see LICENSE files in the repository root for full details. +*/ + +import React, { useCallback, useEffect, useState } from "react"; +import { + EventType, + type RoomMember, + type IPowerLevelsContent, + type Room, + RoomStateEvent, + type MatrixClient, + type User, + type MatrixEvent, +} from "matrix-js-sdk/src/matrix"; +import { logger } from "matrix-js-sdk/src/logger"; + +import { useAsyncMemo } from "../../../../hooks/useAsyncMemo"; +import { useTypedEventEmitter } from "../../../../hooks/useEventEmitter"; +import Modal from "../../../../Modal"; +import ErrorDialog from "../../../views/dialogs/ErrorDialog"; +import { _t } from "../../../../languageHandler"; +import { type IRoomPermissions } from "../../../views/right_panel/UserInfo"; +import { useMatrixClientContext } from "../../../../contexts/MatrixClientContext"; +import QuestionDialog from "../../../views/dialogs/QuestionDialog"; +import DMRoomMap from "../../../../utils/DMRoomMap"; + +export interface UserInfoBasicState { + // current room powerlevels + powerLevels: IPowerLevelsContent; + // getting user permissions in this room + roomPermissions: IRoomPermissions; + // numbers of operation in progress > 0 + pendingUpdateCount: number; + // true if user is me + isMe: boolean; + // true if room is a DM for the user + isRoomDMForMember: boolean; + // Boolean to hide or show the deactivate button + showDeactivateButton: boolean; + // Method called when a deactivate user action is triggered + onSynapseDeactivate: () => void; + startUpdating: () => void; + stopUpdating: () => void; +} + +export const getPowerLevels = (room: Room): IPowerLevelsContent => + room?.currentState?.getStateEvents(EventType.RoomPowerLevels, "")?.getContent() || {}; + +export const useRoomPermissions = (cli: MatrixClient, room: Room, user: RoomMember): IRoomPermissions => { + const [roomPermissions, setRoomPermissions] = useState({ + // modifyLevelMax is the max PL we can set this user to, typically min(their PL, our PL) && canSetPL + modifyLevelMax: -1, + canEdit: false, + canInvite: false, + }); + + const updateRoomPermissions = useCallback(() => { + const powerLevels = room?.currentState.getStateEvents(EventType.RoomPowerLevels, "")?.getContent(); + if (!powerLevels) return; + + const me = room.getMember(cli.getUserId() || ""); + if (!me) return; + + const them = user; + const isMe = me.userId === them.userId; + const canAffectUser = them.powerLevel < me.powerLevel || isMe; + + let modifyLevelMax = -1; + if (canAffectUser) { + const editPowerLevel = powerLevels.events?.[EventType.RoomPowerLevels] ?? powerLevels.state_default ?? 50; + if (me.powerLevel >= editPowerLevel) { + modifyLevelMax = me.powerLevel; + } + } + + setRoomPermissions({ + canInvite: me.powerLevel >= (powerLevels.invite ?? 0), + canEdit: modifyLevelMax >= 0, + modifyLevelMax, + }); + }, [cli, user, room]); + + useTypedEventEmitter(cli, RoomStateEvent.Update, updateRoomPermissions); + useEffect(() => { + updateRoomPermissions(); + return () => { + setRoomPermissions({ + modifyLevelMax: -1, + canEdit: false, + canInvite: false, + }); + }; + }, [updateRoomPermissions]); + + return roomPermissions; +}; + +const useIsSynapseAdmin = (cli?: MatrixClient): boolean => { + return useAsyncMemo(async () => (cli ? cli.isSynapseAdministrator().catch(() => false) : false), [cli], false); +}; + +export const useRoomPowerLevels = (cli: MatrixClient, room: Room): IPowerLevelsContent => { + const [powerLevels, setPowerLevels] = useState(getPowerLevels(room)); + + const update = useCallback( + (ev?: MatrixEvent) => { + if (!room) return; + if (ev && ev.getType() !== EventType.RoomPowerLevels) return; + setPowerLevels(getPowerLevels(room)); + }, + [room], + ); + + useTypedEventEmitter(cli, RoomStateEvent.Events, update); + useEffect(() => { + update(); + return () => { + setPowerLevels({}); + }; + }, [update]); + return powerLevels; +}; + +export const useUserInfoBasicViewModel = (room: Room, member: User | RoomMember): UserInfoBasicState => { + const cli = useMatrixClientContext(); + + const powerLevels = useRoomPowerLevels(cli, room); + // Load whether or not we are a Synapse Admin + const isSynapseAdmin = useIsSynapseAdmin(cli); + + // Count of how many operations are currently in progress, if > 0 then show a Spinner + const [pendingUpdateCount, setPendingUpdateCount] = useState(0); + + const roomPermissions = useRoomPermissions(cli, room, member as RoomMember); + + // selected member is current user + const isMe = member.userId === cli.getUserId(); + + // is needed to hide the Roles section for DMs as it doesn't make sense there + const isRoomDMForMember = !!DMRoomMap.shared().getUserIdForRoomId((member as RoomMember).roomId); + + // used to check if user can deactivate another member + const isMemberSameDomain = member.userId.endsWith(`:${cli.getDomain()}`); + + // We don't need a perfect check here, just something to pass as "probably not our homeserver". If + // someone does figure out how to bypass this check the worst that happens is an error. + const showDeactivateButton = isSynapseAdmin && isMemberSameDomain; + + const startUpdating = useCallback(() => { + setPendingUpdateCount(pendingUpdateCount + 1); + }, [pendingUpdateCount]); + + const stopUpdating = useCallback(() => { + setPendingUpdateCount(pendingUpdateCount - 1); + }, [pendingUpdateCount]); + + const onSynapseDeactivate = useCallback(async () => { + const { finished } = Modal.createDialog(QuestionDialog, { + title: _t("user_info|deactivate_confirm_title"), + description:
    {_t("user_info|deactivate_confirm_description")}
    , + button: _t("user_info|deactivate_confirm_action"), + danger: true, + }); + + const [accepted] = await finished; + if (!accepted) return; + try { + await cli.deactivateSynapseUser(member.userId); + } catch (err) { + logger.error("Failed to deactivate user"); + logger.error(err); + + const description = err instanceof Error ? err.message : _t("invite|failed_generic"); + + Modal.createDialog(ErrorDialog, { + title: _t("user_info|error_deactivate"), + description, + }); + } + }, [cli, member.userId]); + + return { + showDeactivateButton, + powerLevels, + roomPermissions, + pendingUpdateCount, + isMe, + isRoomDMForMember, + onSynapseDeactivate, + startUpdating, + stopUpdating, + }; +}; diff --git a/src/components/viewmodels/right_panel/user_info/UserInfoHeaderVerificationViewModel.tsx b/src/components/viewmodels/right_panel/user_info/UserInfoHeaderVerificationViewModel.tsx new file mode 100644 index 0000000000..4b39d50353 --- /dev/null +++ b/src/components/viewmodels/right_panel/user_info/UserInfoHeaderVerificationViewModel.tsx @@ -0,0 +1,69 @@ +/* +Copyright 2025 New Vector Ltd. +SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only +Please see LICENSE files in the repository root for full details. +*/ + +import { type MatrixClient, type RoomMember, type User } from "matrix-js-sdk/src/matrix"; +import { useContext } from "react"; +import { type UserVerificationStatus } from "matrix-js-sdk/src/crypto-api"; + +import MatrixClientContext from "../../../../contexts/MatrixClientContext"; +import { type IDevice } from "../../../views/right_panel/UserInfo"; +import { useAsyncMemo } from "../../../../hooks/useAsyncMemo"; +import { verifyUser } from "../../../../verification"; + +export interface UserInfoVerificationSectionState { + /** + * variables used to check if we can verify the user and display the verify button + */ + canVerify: boolean; + hasCrossSigningKeys: boolean | undefined; + /** + * used to display correct badge value + */ + isUserVerified: boolean; + /** + * callback function when verifyUser button is clicked + */ + verifySelectedUser: () => Promise; +} + +const useHasCrossSigningKeys = (cli: MatrixClient, member: User, canVerify: boolean): boolean | undefined => { + return useAsyncMemo(async () => { + if (!canVerify) return undefined; + return cli.getCrypto()?.userHasCrossSigningKeys(member.userId, true); + }, [cli, member, canVerify]); +}; + +/** + * View model for the userInfoVerificationHeaderView + * @see {@link UserInfoVerificationSectionState} for more information about what this view model returns. + */ +export const useUserInfoVerificationViewModel = ( + member: User | RoomMember, + devices: IDevice[], +): UserInfoVerificationSectionState => { + const cli = useContext(MatrixClientContext); + + const userTrust = useAsyncMemo( + async () => cli.getCrypto()?.getUserVerificationStatus(member.userId), + [member.userId], + // the user verification status is not initialized + undefined, + ); + const hasUserVerificationStatus = Boolean(userTrust); + const isUserVerified = Boolean(userTrust?.isVerified()); + const isMe = member.userId === cli.getUserId(); + const canVerify = hasUserVerificationStatus && !isUserVerified && !isMe && devices && devices.length > 0; + + const hasCrossSigningKeys = useHasCrossSigningKeys(cli, member as User, canVerify); + const verifySelectedUser = (): Promise => verifyUser(cli, member as User); + + return { + canVerify, + hasCrossSigningKeys, + isUserVerified, + verifySelectedUser, + }; +}; diff --git a/src/components/viewmodels/right_panel/user_info/UserInfoHeaderViewModel.tsx b/src/components/viewmodels/right_panel/user_info/UserInfoHeaderViewModel.tsx new file mode 100644 index 0000000000..12899f0bc1 --- /dev/null +++ b/src/components/viewmodels/right_panel/user_info/UserInfoHeaderViewModel.tsx @@ -0,0 +1,115 @@ +/* +Copyright 2025 New Vector Ltd. +SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only +Please see LICENSE files in the repository root for full details. +*/ + +import { RoomMember, type User } from "matrix-js-sdk/src/matrix"; +import { useCallback, useContext } from "react"; + +import { mediaFromMxc } from "../../../../customisations/Media"; +import Modal from "../../../../Modal"; +import ImageView from "../../../views/elements/ImageView"; +import SdkConfig from "../../../../SdkConfig"; +import MatrixClientContext from "../../../../contexts/MatrixClientContext"; +import { type Member } from "../../../views/right_panel/UserInfo"; +import { useUserTimezone } from "../../../../hooks/useUserTimezone"; +import UserIdentifierCustomisations from "../../../../customisations/UserIdentifier"; + +export interface PresenceInfo { + lastActiveAgo: number | undefined; + currentlyActive: boolean | undefined; + state: string | undefined; +} + +export interface TimezoneInfo { + timezone: string; + friendly: string; +} + +export interface UserInfoHeaderState { + /** + * callback function when selected user avatar is clicked in user info + */ + onMemberAvatarClick: () => void; + /** + * Object containing information about the precense of the selected user + */ + precenseInfo: PresenceInfo; + /** + * Boolean that show or hide the precense information + */ + showPresence: boolean; + /** + * Timezone object + */ + timezoneInfo: TimezoneInfo | null; + /** + * Displayed identifier for the selected user + */ + userIdentifier: string | null; +} +interface UserInfoHeaderViewModelProps { + member: Member; + roomId?: string; +} + +/** + * View model for the userInfoHeaderView + * props + * @see {@link UserInfoHeaderState} for more information about what this view model returns. + */ +export function useUserfoHeaderViewModel({ member, roomId }: UserInfoHeaderViewModelProps): UserInfoHeaderState { + const cli = useContext(MatrixClientContext); + + let showPresence = true; + + const precenseInfo: PresenceInfo = { + lastActiveAgo: undefined, + currentlyActive: undefined, + state: undefined, + }; + + const enablePresenceByHsUrl = SdkConfig.get("enable_presence_by_hs_url"); + + const timezoneInfo = useUserTimezone(cli, member.userId); + + const userIdentifier = UserIdentifierCustomisations.getDisplayUserIdentifier?.(member.userId, { + roomId, + withDisplayName: true, + }); + + const onMemberAvatarClick = useCallback(() => { + const avatarUrl = (member as RoomMember).getMxcAvatarUrl + ? (member as RoomMember).getMxcAvatarUrl() + : (member as User).avatarUrl; + + const httpUrl = mediaFromMxc(avatarUrl).srcHttp; + if (!httpUrl) return; + + const params = { + src: httpUrl, + name: (member as RoomMember).name || (member as User).displayName, + }; + + Modal.createDialog(ImageView, params, "mx_Dialog_lightbox", undefined, true); + }, [member]); + + if (member instanceof RoomMember && member.user) { + precenseInfo.state = member.user.presence; + precenseInfo.lastActiveAgo = member.user.lastActiveAgo; + precenseInfo.currentlyActive = member.user.currentlyActive; + } + + if (enablePresenceByHsUrl && enablePresenceByHsUrl[cli.baseUrl] !== undefined) { + showPresence = enablePresenceByHsUrl[cli.baseUrl]; + } + + return { + onMemberAvatarClick, + showPresence, + precenseInfo, + timezoneInfo, + userIdentifier, + }; +} diff --git a/src/components/viewmodels/right_panel/user_info/UserInfoIgnoreButtonViewModel.tsx b/src/components/viewmodels/right_panel/user_info/UserInfoIgnoreButtonViewModel.tsx new file mode 100644 index 0000000000..3c243feaec --- /dev/null +++ b/src/components/viewmodels/right_panel/user_info/UserInfoIgnoreButtonViewModel.tsx @@ -0,0 +1,85 @@ +/* +Copyright 2025 New Vector Ltd. +SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only +Please see LICENSE files in the repository root for full details. +*/ + +import React, { useContext, useEffect, useState, useCallback } from "react"; +import { type RoomMember, User, ClientEvent, type MatrixEvent } from "matrix-js-sdk/src/matrix"; + +import MatrixClientContext from "../../../../contexts/MatrixClientContext"; +import { _t } from "../../../../languageHandler"; +import Modal from "../../../../Modal"; +import QuestionDialog from "../../../views/dialogs/QuestionDialog"; +import { useTypedEventEmitter } from "../../../../hooks/useEventEmitter"; + +export interface UserInfoPowerLevelState { + /** + * Weither the member is ignored by current user or not + */ + isIgnored: boolean; + /** + * Trigger the method to ignore or unignore a user + * @param ev - The click event + */ + ignoreButtonClick: (ev: Event) => void; +} + +export const useUserInfoIgnoreButtonViewModel = (member: User | RoomMember): UserInfoPowerLevelState => { + const cli = useContext(MatrixClientContext); + + const unignore = useCallback(() => { + const ignoredUsers = cli.getIgnoredUsers(); + const index = ignoredUsers.indexOf(member.userId); + if (index !== -1) ignoredUsers.splice(index, 1); + cli.setIgnoredUsers(ignoredUsers); + }, [cli, member]); + + const ignore = useCallback(async () => { + const name = (member instanceof User ? member.displayName : member.name) || member.userId; + const { finished } = Modal.createDialog(QuestionDialog, { + title: _t("user_info|ignore_confirm_title", { user: name }), + description:
    {_t("user_info|ignore_confirm_description")}
    , + button: _t("action|ignore"), + }); + const [confirmed] = await finished; + + if (confirmed) { + const ignoredUsers = cli.getIgnoredUsers(); + ignoredUsers.push(member.userId); + cli.setIgnoredUsers(ignoredUsers); + } + }, [cli, member]); + + // Check whether the user is ignored + const [isIgnored, setIsIgnored] = useState(cli.isUserIgnored(member.userId)); + // Recheck if the user or client changes + useEffect(() => { + setIsIgnored(cli.isUserIgnored(member.userId)); + }, [cli, member.userId]); + + // Recheck also if we receive new accountData m.ignored_user_list + const accountDataHandler = useCallback( + (ev: MatrixEvent) => { + if (ev.getType() === "m.ignored_user_list") { + setIsIgnored(cli.isUserIgnored(member.userId)); + } + }, + [cli, member.userId], + ); + useTypedEventEmitter(cli, ClientEvent.AccountData, accountDataHandler); + + const ignoreButtonClick = (ev: Event): void => { + ev.preventDefault(); + if (isIgnored) { + unignore(); + } else { + ignore(); + } + }; + + return { + ignoreButtonClick, + isIgnored, + }; +}; diff --git a/src/components/viewmodels/right_panel/user_info/admin/UserInfoAdminToolsContainerViewModel.tsx b/src/components/viewmodels/right_panel/user_info/admin/UserInfoAdminToolsContainerViewModel.tsx new file mode 100644 index 0000000000..54ed32ceb5 --- /dev/null +++ b/src/components/viewmodels/right_panel/user_info/admin/UserInfoAdminToolsContainerViewModel.tsx @@ -0,0 +1,82 @@ +/* +Copyright 2025 New Vector Ltd. +SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only +Please see LICENSE files in the repository root for full details. +*/ + +import { type Room, type RoomMember, type IPowerLevelsContent } from "matrix-js-sdk/src/matrix"; + +import { useMatrixClientContext } from "../../../../../contexts/MatrixClientContext"; + +/** + * Interface used by admin tools container subcomponents props + */ +export interface RoomAdminToolsProps { + room: Room; + member: RoomMember; + isUpdating: boolean; + startUpdating: () => void; + stopUpdating: () => void; +} + +/** + * Interface used by admin tools container props + */ +export interface RoomAdminToolsContainerProps { + room: Room; + member: RoomMember; + powerLevels: IPowerLevelsContent; +} + +interface UserInfoAdminToolsContainerState { + shouldShowKickButton: boolean; + shouldShowBanButton: boolean; + shouldShowMuteButton: boolean; + shouldShowRedactButton: boolean; + isCurrentUserInTheRoom: boolean; +} + +/** + * The view model for the user info admin tools container + * @param {RoomAdminToolsContainerProps} props - the object containing the necceray props for the view model + * @param {Room} props.room - the room that display the admin tools + * @param {RoomMember} props.member - the selected member + * @param {IPowerLevelsContent} props.powerLevels - current room power levels + * @returns {UserInfoAdminToolsContainerState} the user info admin tools container state + */ +export const useUserInfoAdminToolsContainerViewModel = ( + props: RoomAdminToolsContainerProps, +): UserInfoAdminToolsContainerState => { + const cli = useMatrixClientContext(); + const { room, member, powerLevels } = props; + + const editPowerLevel = + (powerLevels.events ? powerLevels.events["m.room.power_levels"] : null) || powerLevels.state_default; + + // if these do not exist in the event then they should default to 50 as per the spec + const { ban: banPowerLevel = 50, kick: kickPowerLevel = 50, redact: redactPowerLevel = 50 } = powerLevels; + + const me = room.getMember(cli.getUserId() || ""); + const isCurrentUserInTheRoom = me !== null; + + if (!isCurrentUserInTheRoom) { + return { + shouldShowKickButton: false, + shouldShowBanButton: false, + shouldShowMuteButton: false, + shouldShowRedactButton: false, + isCurrentUserInTheRoom: false, + }; + } + + const isMe = me.userId === member.userId; + const canAffectUser = member.powerLevel < me.powerLevel || isMe; + + return { + shouldShowKickButton: !isMe && canAffectUser && me.powerLevel >= kickPowerLevel, + shouldShowRedactButton: me.powerLevel >= redactPowerLevel && !room.isSpaceRoom(), + shouldShowBanButton: !isMe && canAffectUser && me.powerLevel >= banPowerLevel, + shouldShowMuteButton: !isMe && canAffectUser && me.powerLevel >= Number(editPowerLevel) && !room.isSpaceRoom(), + isCurrentUserInTheRoom, + }; +}; diff --git a/src/components/viewmodels/right_panel/user_info/admin/UserInfoBanButtonViewModel.tsx b/src/components/viewmodels/right_panel/user_info/admin/UserInfoBanButtonViewModel.tsx new file mode 100644 index 0000000000..525b10e093 --- /dev/null +++ b/src/components/viewmodels/right_panel/user_info/admin/UserInfoBanButtonViewModel.tsx @@ -0,0 +1,153 @@ +/* +Copyright 2025 New Vector Ltd. +SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only +Please see LICENSE files in the repository root for full details. +*/ + +import { logger } from "@sentry/browser"; +import { type Room } from "matrix-js-sdk/src/matrix"; +import { KnownMembership } from "matrix-js-sdk/src/types"; + +import { useMatrixClientContext } from "../../../../../contexts/MatrixClientContext"; +import { _t } from "../../../../../languageHandler"; +import Modal from "../../../../../Modal"; +import { bulkSpaceBehaviour } from "../../../../../utils/space"; +import ConfirmSpaceUserActionDialog from "../../../../views/dialogs/ConfirmSpaceUserActionDialog"; +import ConfirmUserActionDialog from "../../../../views/dialogs/ConfirmUserActionDialog"; +import ErrorDialog from "../../../../views/dialogs/ErrorDialog"; +import { type RoomAdminToolsProps } from "./UserInfoAdminToolsContainerViewModel"; + +export interface BanButtonState { + /** + * The function to call when the button is clicked + */ + onBanOrUnbanClick: () => Promise; + /** + * The label of the ban button can be ban or unban + */ + banLabel: string; +} +/** + * The view model for the room ban button used in the UserInfoAdminToolsContainer + * @param {RoomAdminToolsProps} props - the object containing the necceray props for banButton the view model + * @param {Room} props.room - the room to ban/unban the user in + * @param {RoomMember} props.member - the member to ban/unban + * @param {boolean} props.isUpdating - whether the operation is currently in progress + * @param {function} props.startUpdating - callback function to start the operation + * @param {function} props.stopUpdating - callback function to stop the operation + * @returns {BanButtonState} the room ban/unban button state + */ +export const useBanButtonViewModel = (props: RoomAdminToolsProps): BanButtonState => { + const { isUpdating, startUpdating, stopUpdating, room, member } = props; + + const cli = useMatrixClientContext(); + + const isBanned = member.membership === KnownMembership.Ban; + + let banLabel = room.isSpaceRoom() ? _t("user_info|ban_button_space") : _t("user_info|ban_button_room"); + if (isBanned) { + banLabel = room.isSpaceRoom() ? _t("user_info|unban_button_space") : _t("user_info|unban_button_room"); + } + + const onBanOrUnbanClick = async (): Promise => { + if (isUpdating) return; // only allow one operation at a time + startUpdating(); + + const commonProps = { + member, + action: room.isSpaceRoom() + ? isBanned + ? _t("user_info|unban_button_space") + : _t("user_info|ban_button_space") + : isBanned + ? _t("user_info|unban_button_room") + : _t("user_info|ban_button_room"), + title: isBanned + ? _t("user_info|unban_room_confirm_title", { roomName: room.name }) + : _t("user_info|ban_room_confirm_title", { roomName: room.name }), + askReason: !isBanned, + danger: !isBanned, + }; + + let finished: Promise<[success?: boolean, reason?: string, rooms?: Room[]]>; + + if (room.isSpaceRoom()) { + ({ finished } = Modal.createDialog( + ConfirmSpaceUserActionDialog, + { + ...commonProps, + space: room, + spaceChildFilter: isBanned + ? (child: Room) => { + // Return true if the target member is banned and we have sufficient PL to unban + const myMember = child.getMember(cli.credentials.userId || ""); + const theirMember = child.getMember(member.userId); + return ( + !!myMember && + !!theirMember && + theirMember.membership === KnownMembership.Ban && + myMember.powerLevel > theirMember.powerLevel && + child.currentState.hasSufficientPowerLevelFor("ban", myMember.powerLevel) + ); + } + : (child: Room) => { + // Return true if the target member isn't banned and we have sufficient PL to ban + const myMember = child.getMember(cli.credentials.userId || ""); + const theirMember = child.getMember(member.userId); + return ( + !!myMember && + !!theirMember && + theirMember.membership !== KnownMembership.Ban && + myMember.powerLevel > theirMember.powerLevel && + child.currentState.hasSufficientPowerLevelFor("ban", myMember.powerLevel) + ); + }, + allLabel: isBanned ? _t("user_info|unban_space_everything") : _t("user_info|ban_space_everything"), + specificLabel: isBanned ? _t("user_info|unban_space_specific") : _t("user_info|ban_space_specific"), + warningMessage: isBanned ? _t("user_info|unban_space_warning") : _t("user_info|kick_space_warning"), + }, + "mx_ConfirmSpaceUserActionDialog_wrapper", + )); + } else { + ({ finished } = Modal.createDialog(ConfirmUserActionDialog, commonProps)); + } + + const [proceed, reason, rooms = []] = await finished; + if (!proceed) { + stopUpdating(); + return; + } + + const fn = (roomId: string): Promise => { + if (isBanned) { + return cli.unban(roomId, member.userId); + } else { + return cli.ban(roomId, member.userId, reason || undefined); + } + }; + + bulkSpaceBehaviour(room, rooms, (room) => fn(room.roomId)) + .then( + () => { + // NO-OP; rely on the m.room.member event coming down else we could + // get out of sync if we force setState here! + logger.info("Ban success"); + }, + function (err) { + logger.error("Ban error: " + err); + Modal.createDialog(ErrorDialog, { + title: _t("common|error"), + description: _t("user_info|error_ban_user"), + }); + }, + ) + .finally(() => { + stopUpdating(); + }); + }; + + return { + onBanOrUnbanClick, + banLabel, + }; +}; diff --git a/src/components/viewmodels/right_panel/user_info/admin/UserInfoKickButtonViewModel.tsx b/src/components/viewmodels/right_panel/user_info/admin/UserInfoKickButtonViewModel.tsx new file mode 100644 index 0000000000..8ae179ac07 --- /dev/null +++ b/src/components/viewmodels/right_panel/user_info/admin/UserInfoKickButtonViewModel.tsx @@ -0,0 +1,142 @@ +/* +Copyright 2025 New Vector Ltd. +SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only +Please see LICENSE files in the repository root for full details. +*/ + +import { logger } from "@sentry/browser"; +import { type Room } from "matrix-js-sdk/src/matrix"; +import { KnownMembership } from "matrix-js-sdk/src/types"; + +import { useMatrixClientContext } from "../../../../../contexts/MatrixClientContext"; +import { _t } from "../../../../../languageHandler"; +import Modal from "../../../../../Modal"; +import { bulkSpaceBehaviour } from "../../../../../utils/space"; +import ConfirmSpaceUserActionDialog from "../../../../views/dialogs/ConfirmSpaceUserActionDialog"; +import ConfirmUserActionDialog from "../../../../views/dialogs/ConfirmUserActionDialog"; +import ErrorDialog from "../../../../views/dialogs/ErrorDialog"; +import { type RoomAdminToolsProps } from "./UserInfoAdminToolsContainerViewModel"; + +interface RoomKickButtonState { + /** + * The function to call when the button is clicked + */ + onKickClick: () => Promise; + /** + * Whether the user can be kicked based on membership value. If the user already join or was invited, it can be kicked + */ + canUserBeKicked: boolean; + /** + * The label of the kick button can be kick or disinvite + */ + kickLabel: string; +} + +/** + * The view model for the room kick button used in the UserInfoAdminToolsContainer + * @param {RoomAdminToolsProps} props - the object containing the necceray props for kickButton the view model + * @param {Room} props.room - the room to kick/disinvite the user from + * @param {RoomMember} props.member - the member to kick/disinvite + * @param {boolean} props.isUpdating - whether the operation is currently in progress + * @param {function} props.startUpdating - callback function to start the operation + * @param {function} props.stopUpdating - callback function to stop the operation + * @returns {KickButtonState} the room kick/disinvite button state + */ +export function useRoomKickButtonViewModel(props: RoomAdminToolsProps): RoomKickButtonState { + const { isUpdating, startUpdating, stopUpdating, room, member } = props; + + const cli = useMatrixClientContext(); + + const onKickClick = async (): Promise => { + if (isUpdating) return; // only allow one operation at a time + startUpdating(); + + const commonProps = { + member, + action: room.isSpaceRoom() + ? member.membership === KnownMembership.Invite + ? _t("user_info|disinvite_button_space") + : _t("user_info|kick_button_space") + : member.membership === KnownMembership.Invite + ? _t("user_info|disinvite_button_room") + : _t("user_info|kick_button_room"), + title: + member.membership === KnownMembership.Invite + ? _t("user_info|disinvite_button_room_name", { roomName: room.name }) + : _t("user_info|kick_button_room_name", { roomName: room.name }), + askReason: member.membership === KnownMembership.Join, + danger: true, + }; + + let finished: Promise<[success?: boolean, reason?: string, rooms?: Room[]]>; + + if (room.isSpaceRoom()) { + ({ finished } = Modal.createDialog( + ConfirmSpaceUserActionDialog, + { + ...commonProps, + space: room, + spaceChildFilter: (child: Room) => { + // Return true if the target member is not banned and we have sufficient PL to ban them + const myMember = child.getMember(cli.credentials.userId || ""); + const theirMember = child.getMember(member.userId); + return ( + !!myMember && + !!theirMember && + theirMember.membership === member.membership && + myMember.powerLevel > theirMember.powerLevel && + child.currentState.hasSufficientPowerLevelFor("kick", myMember.powerLevel) + ); + }, + allLabel: _t("user_info|kick_button_space_everything"), + specificLabel: _t("user_info|kick_space_specific"), + warningMessage: _t("user_info|kick_space_warning"), + }, + "mx_ConfirmSpaceUserActionDialog_wrapper", + )); + } else { + ({ finished } = Modal.createDialog(ConfirmUserActionDialog, commonProps)); + } + + const [proceed, reason, rooms = []] = await finished; + if (!proceed) { + stopUpdating(); + return; + } + + bulkSpaceBehaviour(room, rooms, (room) => cli.kick(room.roomId, member.userId, reason || undefined)) + .then( + () => { + // NO-OP; rely on the m.room.member event coming down else we could + // get out of sync if we force setState here! + logger.info("Kick success"); + }, + function (err) { + logger.error("Kick error: " + err); + Modal.createDialog(ErrorDialog, { + title: _t("user_info|error_kicking_user"), + description: err?.message ?? "Operation failed", + }); + }, + ) + .finally(() => { + stopUpdating(); + }); + }; + + const canUserBeKicked = member.membership === KnownMembership.Invite || member.membership === KnownMembership.Join; + + const kickLabel = room.isSpaceRoom() + ? member.membership === KnownMembership.Invite + ? _t("user_info|disinvite_button_space") + : _t("user_info|kick_button_space") + : member.membership === KnownMembership.Invite + ? _t("user_info|disinvite_button_room") + : _t("user_info|kick_button_room"); + + return { + onKickClick, + canUserBeKicked, + kickLabel, + }; +} diff --git a/src/components/viewmodels/right_panel/user_info/admin/UserInfoMuteButtonViewModel.tsx b/src/components/viewmodels/right_panel/user_info/admin/UserInfoMuteButtonViewModel.tsx new file mode 100644 index 0000000000..1608628198 --- /dev/null +++ b/src/components/viewmodels/right_panel/user_info/admin/UserInfoMuteButtonViewModel.tsx @@ -0,0 +1,120 @@ +/* +Copyright 2025 New Vector Ltd. +SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only +Please see LICENSE files in the repository root for full details. +*/ + +import { logger } from "@sentry/browser"; +import { type RoomMember, type IPowerLevelsContent } from "matrix-js-sdk/src/matrix"; +import { KnownMembership } from "matrix-js-sdk/src/types"; + +import { useMatrixClientContext } from "../../../../../contexts/MatrixClientContext"; +import { _t } from "../../../../../languageHandler"; +import Modal from "../../../../../Modal"; +import ErrorDialog from "../../../../views/dialogs/ErrorDialog"; +import { type RoomAdminToolsProps } from "./UserInfoAdminToolsContainerViewModel"; + +interface MuteButtonState { + /** + * Whether the member is in the roomn based on the membership value + */ + isMemberInTheRoom: boolean; + /** + * The label of the mute button can be mute or unmute + */ + muteLabel: string; + /** + * The function to call when the mute button is clicked + */ + onMuteButtonClick: () => Promise; +} + +/** + * The view model for the room mute button used in the UserInfoAdminToolsContainer + * @param {RoomAdminToolsProps} props - the object containing the necceray props for muteButton the view model + * @param {Room} props.room - the room to mute/unmute the user in + * @param {RoomMember} props.member - the member to mute/unmute + * @param {boolean} props.isUpdating - whether the operation is currently in progress + * @param {function} props.startUpdating - callback function to start the operation + * @param {function} props.stopUpdating - callback function to stop the operation + * @returns {MuteButtonState} the room mute/unmute button state + */ +export const useMuteButtonViewModel = (props: RoomAdminToolsProps): MuteButtonState => { + const { isUpdating, startUpdating, stopUpdating, room, member } = props; + + const cli = useMatrixClientContext(); + + const isMuted = (member: RoomMember, powerLevelContent: IPowerLevelsContent): boolean => { + if (!powerLevelContent || !member) return false; + + const levelToSend = + (powerLevelContent.events ? powerLevelContent.events["m.room.message"] : null) || + powerLevelContent.events_default; + + // levelToSend could be undefined as .events_default is optional. Coercing in this case using + // Number() would always return false, so this preserves behaviour + // FIXME: per the spec, if `events_default` is unset, it defaults to zero. If + // the member has a negative powerlevel, this will give an incorrect result. + if (levelToSend === undefined) return false; + + return member.powerLevel < levelToSend; + }; + + const muted = isMuted(member, room.currentState.getStateEvents("m.room.power_levels", "")?.getContent() || {}); + const muteLabel = muted ? _t("common|unmute") : _t("common|mute"); + + const isMemberInTheRoom = member.membership == KnownMembership.Join; + + const onMuteButtonClick = async (): Promise => { + if (isUpdating) return; // only allow one operation at a time + startUpdating(); + + const roomId = member.roomId; + const target = member.userId; + + const powerLevelEvent = room.currentState.getStateEvents("m.room.power_levels", ""); + const powerLevels = powerLevelEvent?.getContent(); + const levelToSend = powerLevels?.events?.["m.room.message"] ?? powerLevels?.events_default; + + let level; + if (muted) { + // unmute + level = levelToSend; + } else { + // mute + level = levelToSend - 1; + } + level = parseInt(level); + + console.log("level", level); + if (isNaN(level)) { + stopUpdating(); + return; + } + + cli.setPowerLevel(roomId, target, level) + .then( + () => { + // NO-OP; rely on the m.room.member event coming down else we could + // get out of sync if we force setState here! + logger.info("Mute toggle success"); + }, + function (err) { + logger.error("Mute error: " + err); + Modal.createDialog(ErrorDialog, { + title: _t("common|error"), + description: _t("user_info|error_mute_user"), + }); + }, + ) + .finally(() => { + stopUpdating(); + }); + }; + + return { + isMemberInTheRoom, + onMuteButtonClick, + muteLabel, + }; +}; diff --git a/src/components/viewmodels/right_panel/user_info/admin/UserInfoRedactButtonViewModel.tsx b/src/components/viewmodels/right_panel/user_info/admin/UserInfoRedactButtonViewModel.tsx new file mode 100644 index 0000000000..73b8ea70f0 --- /dev/null +++ b/src/components/viewmodels/right_panel/user_info/admin/UserInfoRedactButtonViewModel.tsx @@ -0,0 +1,39 @@ +/* +Copyright 2025 New Vector Ltd. +SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only +Please see LICENSE files in the repository root for full details. +*/ + +import { type RoomMember } from "matrix-js-sdk/src/matrix"; + +import { useMatrixClientContext } from "../../../../../contexts/MatrixClientContext"; +import Modal from "../../../../../Modal"; +import BulkRedactDialog from "../../../../views/dialogs/BulkRedactDialog"; + +export interface RedactMessagesButtonState { + onRedactAllMessagesClick: () => void; +} + +/** + * The view model for the redact messages button used in the UserInfoAdminToolsContainer + * @param {RoomMember} member - the selected member to redact messages for + * @returns {RedactMessagesButtonState} the redact messages button state + */ +export const useRedactMessagesButtonViewModel = (member: RoomMember): RedactMessagesButtonState => { + const cli = useMatrixClientContext(); + + const onRedactAllMessagesClick = (): void => { + const room = cli.getRoom(member.roomId); + if (!room) return; + + Modal.createDialog(BulkRedactDialog, { + matrixClient: cli, + room, + member, + }); + }; + + return { + onRedactAllMessagesClick, + }; +}; diff --git a/src/components/viewmodels/roomlist/RoomListHeaderViewModel.tsx b/src/components/viewmodels/roomlist/RoomListHeaderViewModel.tsx index d6f54fcae7..451a4898b7 100644 --- a/src/components/viewmodels/roomlist/RoomListHeaderViewModel.tsx +++ b/src/components/viewmodels/roomlist/RoomListHeaderViewModel.tsx @@ -32,7 +32,6 @@ import { useMatrixClientContext } from "../../../contexts/MatrixClientContext"; import type { ViewRoomPayload } from "../../../dispatcher/payloads/ViewRoomPayload"; import { createRoom, hasCreateRoomRights } from "./utils"; import { type SortOption, useSorter } from "./useSorter"; -import { useMessagePreviewToggle } from "./useMessagePreviewToggle"; /** * Hook to get the active space and its title. @@ -127,14 +126,6 @@ export interface RoomListHeaderViewState { * The currently active sort option. */ activeSortOption: SortOption; - /** - * Whether message previews must be shown or not. - */ - shouldShowMessagePreview: boolean; - /** - * A function to turn on/off message previews. - */ - toggleMessagePreview: () => void; } /** @@ -157,7 +148,6 @@ export function useRoomListHeaderViewModel(): RoomListHeaderViewState { /* Actions */ const { activeSortOption, sort } = useSorter(); - const { shouldShowMessagePreview, toggleMessagePreview } = useMessagePreviewToggle(); const createChatRoom = useCallback((e: Event) => { defaultDispatcher.fire(Action.CreateChat); @@ -230,7 +220,5 @@ export function useRoomListHeaderViewModel(): RoomListHeaderViewState { openSpaceSettings, activeSortOption, sort, - shouldShowMessagePreview, - toggleMessagePreview, }; } diff --git a/src/components/viewmodels/roomlist/RoomListItemMenuViewModel.tsx b/src/components/viewmodels/roomlist/RoomListItemMenuViewModel.tsx index 997b515f27..738a05b8c3 100644 --- a/src/components/viewmodels/roomlist/RoomListItemMenuViewModel.tsx +++ b/src/components/viewmodels/roomlist/RoomListItemMenuViewModel.tsx @@ -37,6 +37,10 @@ export interface RoomListItemMenuViewState { * Whether the room is a favourite room. */ isFavourite: boolean; + /** + * Whether the room is a low priority room. + */ + isLowPriority: boolean; /** * Can invite other user's in the room. */ @@ -117,6 +121,7 @@ export function useRoomListItemMenuViewModel(room: Room): RoomListItemMenuViewSt const isDm = Boolean(DMRoomMap.shared().getUserIdForRoomId(room.roomId)); const isFavourite = Boolean(roomTags[DefaultTagID.Favourite]); + const isLowPriority = Boolean(roomTags[DefaultTagID.LowPriority]); const isArchived = Boolean(roomTags[DefaultTagID.Archived]); const showMoreOptionsMenu = hasAccessToOptionsMenu(room); @@ -200,6 +205,7 @@ export function useRoomListItemMenuViewModel(room: Room): RoomListItemMenuViewSt showMoreOptionsMenu, showNotificationMenu, isFavourite, + isLowPriority, canInvite, canCopyRoomLink, canMarkAsRead, diff --git a/src/components/viewmodels/roomlist/RoomListItemViewModel.tsx b/src/components/viewmodels/roomlist/RoomListItemViewModel.tsx index e009033875..30576e2dc2 100644 --- a/src/components/viewmodels/roomlist/RoomListItemViewModel.tsx +++ b/src/components/viewmodels/roomlist/RoomListItemViewModel.tsx @@ -7,6 +7,7 @@ import { useCallback, useEffect, useMemo, useState } from "react"; import { type Room, RoomEvent } from "matrix-js-sdk/src/matrix"; +import { CallType } from "matrix-js-sdk/src/webrtc/call"; import dispatcher from "../../../dispatcher/dispatcher"; import type { ViewRoomPayload } from "../../../dispatcher/payloads/ViewRoomPayload"; @@ -19,7 +20,7 @@ import { useMatrixClientContext } from "../../../contexts/MatrixClientContext"; import { useEventEmitter, useEventEmitterState, useTypedEventEmitter } from "../../../hooks/useEventEmitter"; import { DefaultTagID } from "../../../stores/room-list/models"; import { useCall, useConnectionState, useParticipantCount } from "../../../hooks/useCall"; -import { type ConnectionState } from "../../../models/Call"; +import { CallEvent, type ConnectionState } from "../../../models/Call"; import { NotificationStateEvents } from "../../../stores/notifications/NotificationState"; import DMRoomMap from "../../../utils/DMRoomMap"; import { MessagePreviewStore } from "../../../stores/room-list/MessagePreviewStore"; @@ -30,6 +31,10 @@ export interface RoomListItemViewState { * The name of the room. */ name: string; + /** + * Whether the context menu should be shown. + */ + showContextMenu: boolean; /** * Whether the hover menu should be shown. */ @@ -63,6 +68,10 @@ export interface RoomListItemViewState { * Whether there are participants in the call. */ hasParticipantInCall: boolean; + /** + * Whether the call is a voice or video call. + */ + callType: CallType | undefined; /** * Pre-rendered and translated preview for the latest message in the room, or undefined * if no preview should be shown. @@ -105,12 +114,12 @@ export function useRoomListItemViewModel(room: Room): RoomListItemViewState { setNotificationValues(getNotificationValues(notificationState)); }, [notificationState]); - // We don't want to show the hover menu if + // We don't want to show the menus if // - there is an invitation for this room - // - the user doesn't have access to both notification and more options menus + // - the user doesn't have access to notification and more options menus + const showContextMenu = !invited && hasAccessToOptionsMenu(room); const showHoverMenu = - !invited && - (hasAccessToOptionsMenu(room) || hasAccessToNotificationMenu(room, matrixClient.isGuest(), isArchived)); + !invited && (showContextMenu || hasAccessToNotificationMenu(room, matrixClient.isGuest(), isArchived)); const messagePreview = useRoomMessagePreview(room); @@ -119,10 +128,10 @@ export function useRoomListItemViewModel(room: Room): RoomListItemViewState { // EC video call or video room const call = useCall(room.roomId); const connectionState = useConnectionState(call); - const hasParticipantInCall = useParticipantCount(call) > 0; + const participantCount = useParticipantCount(call); const callConnectionState = call ? connectionState : null; - const showNotificationDecoration = hasVisibleNotification || hasParticipantInCall; + const showNotificationDecoration = hasVisibleNotification || participantCount > 0; // Actions @@ -134,18 +143,23 @@ export function useRoomListItemViewModel(room: Room): RoomListItemViewState { }); }, [room]); + const [callType, setCallType] = useState(CallType.Video); + useTypedEventEmitter(call ?? undefined, CallEvent.CallTypeChanged, setCallType); + return { name, notificationState, + showContextMenu, showHoverMenu, openRoom, a11yLabel, isBold, isVideoRoom, callConnectionState, - hasParticipantInCall, + hasParticipantInCall: participantCount > 0, messagePreview, showNotificationDecoration, + callType: call ? callType : undefined, }; } diff --git a/src/components/viewmodels/roomlist/RoomListViewModel.tsx b/src/components/viewmodels/roomlist/RoomListViewModel.tsx index e4e5093782..a48d973b23 100644 --- a/src/components/viewmodels/roomlist/RoomListViewModel.tsx +++ b/src/components/viewmodels/roomlist/RoomListViewModel.tsx @@ -18,6 +18,7 @@ import { Action } from "../../../dispatcher/actions"; import { useMatrixClientContext } from "../../../contexts/MatrixClientContext"; import { useStickyRoomList } from "./useStickyRoomList"; import { useRoomListNavigation } from "./useRoomListNavigation"; +import { type RoomsResult } from "../../../stores/room-list-v3/RoomListStoreV3"; export interface RoomListViewState { /** @@ -26,9 +27,9 @@ export interface RoomListViewState { isLoadingRooms: boolean; /** - * A list of rooms to be displayed in the left panel. + * The room results to be displayed (along with the spaceId and filter keys at the time of query) */ - rooms: Room[]; + roomsResult: RoomsResult; /** * Create a chat room @@ -71,10 +72,10 @@ export interface RoomListViewState { */ export function useRoomListViewModel(): RoomListViewState { const matrixClient = useMatrixClientContext(); - const { isLoadingRooms, primaryFilters, activePrimaryFilter, rooms: filteredRooms } = useFilteredRooms(); - const { activeIndex, rooms } = useStickyRoomList(filteredRooms); + const { isLoadingRooms, primaryFilters, activePrimaryFilter, roomsResult: filteredRooms } = useFilteredRooms(); + const { activeIndex, roomsResult } = useStickyRoomList(filteredRooms); - useRoomListNavigation(rooms); + useRoomListNavigation(roomsResult.rooms); const currentSpace = useEventEmitterState( SpaceStore.instance, @@ -88,7 +89,7 @@ export function useRoomListViewModel(): RoomListViewState { return { isLoadingRooms, - rooms, + roomsResult, canCreateRoom, createRoom, createChatRoom, diff --git a/src/components/viewmodels/roomlist/useFilteredRooms.tsx b/src/components/viewmodels/roomlist/useFilteredRooms.tsx index c3ed4df2ae..4e311f39db 100644 --- a/src/components/viewmodels/roomlist/useFilteredRooms.tsx +++ b/src/components/viewmodels/roomlist/useFilteredRooms.tsx @@ -5,15 +5,16 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com Please see LICENSE files in the repository root for full details. */ -import { useCallback, useMemo, useState } from "react"; +import { useCallback, useEffect, useMemo, useState } from "react"; -import type { Room } from "matrix-js-sdk/src/matrix"; import { FilterKey } from "../../../stores/room-list-v3/skip-list/filters"; import { _t, _td, type TranslationKey } from "../../../languageHandler"; -import RoomListStoreV3, { LISTS_LOADED_EVENT, LISTS_UPDATE_EVENT } from "../../../stores/room-list-v3/RoomListStoreV3"; +import RoomListStoreV3, { + LISTS_LOADED_EVENT, + LISTS_UPDATE_EVENT, + type RoomsResult, +} from "../../../stores/room-list-v3/RoomListStoreV3"; import { useEventEmitter } from "../../../hooks/useEventEmitter"; -import SpaceStore from "../../../stores/spaces/SpaceStore"; -import { UPDATE_SELECTED_SPACE } from "../../../stores/spaces"; /** * Provides information about a primary filter. @@ -35,7 +36,7 @@ export interface PrimaryFilter { interface FilteredRooms { primaryFilters: PrimaryFilter[]; isLoadingRooms: boolean; - rooms: Room[]; + roomsResult: RoomsResult; /** * The currently active primary filter. * If no primary filter is active, this will be undefined. @@ -47,9 +48,10 @@ const filterKeyToNameMap: Map = new Map([ [FilterKey.UnreadFilter, _td("room_list|filters|unread")], [FilterKey.PeopleFilter, _td("room_list|filters|people")], [FilterKey.RoomsFilter, _td("room_list|filters|rooms")], + [FilterKey.FavouriteFilter, _td("room_list|filters|favourite")], [FilterKey.MentionsFilter, _td("room_list|filters|mentions")], [FilterKey.InvitesFilter, _td("room_list|filters|invites")], - [FilterKey.FavouriteFilter, _td("room_list|filters|favourite")], + [FilterKey.LowPriorityFilter, _td("room_list|filters|low_priority")], ]); /** @@ -62,23 +64,26 @@ export function useFilteredRooms(): FilteredRooms { */ const [primaryFilter, setPrimaryFilter] = useState(); - const [rooms, setRooms] = useState(() => RoomListStoreV3.instance.getSortedRoomsInActiveSpace()); + const [roomsResult, setRoomsResult] = useState(() => RoomListStoreV3.instance.getSortedRoomsInActiveSpace()); const [isLoadingRooms, setIsLoadingRooms] = useState(() => RoomListStoreV3.instance.isLoadingRooms); const updateRoomsFromStore = useCallback((filters: FilterKey[] = []): void => { const newRooms = RoomListStoreV3.instance.getSortedRoomsInActiveSpace(filters); - setRooms(newRooms); + setRoomsResult(newRooms); }, []); - // Reset filters when active space changes - useEventEmitter(SpaceStore.instance, UPDATE_SELECTED_SPACE, () => setPrimaryFilter(undefined)); - const filterUndefined = (array: (FilterKey | undefined)[]): FilterKey[] => array.filter((f) => f !== undefined) as FilterKey[]; - const getAppliedFilters = (): FilterKey[] => { + const getAppliedFilters = useCallback((): FilterKey[] => { return filterUndefined([primaryFilter]); - }; + }, [primaryFilter]); + + useEffect(() => { + // Update the rooms state when the primary filter changes + const filters = getAppliedFilters(); + updateRoomsFromStore(filters); + }, [getAppliedFilters, updateRoomsFromStore]); useEventEmitter(RoomListStoreV3.instance, LISTS_UPDATE_EVENT, () => { const filters = getAppliedFilters(); @@ -121,6 +126,6 @@ export function useFilteredRooms(): FilteredRooms { isLoadingRooms, primaryFilters, activePrimaryFilter, - rooms, + roomsResult, }; } diff --git a/src/components/viewmodels/roomlist/useStickyRoomList.tsx b/src/components/viewmodels/roomlist/useStickyRoomList.tsx index 06feb58581..355e09a292 100644 --- a/src/components/viewmodels/roomlist/useStickyRoomList.tsx +++ b/src/components/viewmodels/roomlist/useStickyRoomList.tsx @@ -12,10 +12,10 @@ import { useDispatcher } from "../../../hooks/useDispatcher"; import dispatcher from "../../../dispatcher/dispatcher"; import { Action } from "../../../dispatcher/actions"; import type { Room } from "matrix-js-sdk/src/matrix"; -import type { Optional } from "matrix-events-sdk"; import SpaceStore from "../../../stores/spaces/SpaceStore"; +import { type RoomsResult } from "../../../stores/room-list-v3/RoomListStoreV3"; -function getIndexByRoomId(rooms: Room[], roomId: Optional): number | undefined { +function getIndexByRoomId(rooms: Room[], roomId: string): number | undefined { const index = rooms.findIndex((room) => room.roomId === roomId); return index === -1 ? undefined : index; } @@ -67,11 +67,11 @@ function getRoomsWithStickyRoom( return { newIndex: oldIndex, newRooms }; } -interface StickyRoomListResult { +export interface StickyRoomListResult { /** - * List of rooms with sticky active room. + * The rooms result with the active sticky room applied */ - rooms: Room[]; + roomsResult: RoomsResult; /** * Index of the active room in the room list. */ @@ -85,10 +85,10 @@ interface StickyRoomListResult { * @param rooms list of rooms * @see {@link StickyRoomListResult} details what this hook returns.. */ -export function useStickyRoomList(rooms: Room[]): StickyRoomListResult { - const [listState, setListState] = useState<{ index: number | undefined; roomsWithStickyRoom: Room[] }>({ - index: undefined, - roomsWithStickyRoom: rooms, +export function useStickyRoomList(roomsResult: RoomsResult): StickyRoomListResult { + const [listState, setListState] = useState({ + activeIndex: getIndexByRoomId(roomsResult.rooms, SdkContextClass.instance.roomViewStore.getRoomId()!), + roomsResult: roomsResult, }); const currentSpaceRef = useRef(SpaceStore.instance.activeSpace); @@ -97,13 +97,18 @@ export function useStickyRoomList(rooms: Room[]): StickyRoomListResult { (newRoomId: string | null, isRoomChange: boolean = false) => { setListState((current) => { const activeRoomId = newRoomId ?? SdkContextClass.instance.roomViewStore.getRoomId(); - const newActiveIndex = getIndexByRoomId(rooms, activeRoomId); - const oldIndex = current.index; - const { newIndex, newRooms } = getRoomsWithStickyRoom(rooms, oldIndex, newActiveIndex, isRoomChange); - return { index: newIndex, roomsWithStickyRoom: newRooms }; + const newActiveIndex = getIndexByRoomId(roomsResult.rooms, activeRoomId!); + const oldIndex = current.activeIndex; + const { newIndex, newRooms } = getRoomsWithStickyRoom( + roomsResult.rooms, + oldIndex, + newActiveIndex, + isRoomChange, + ); + return { activeIndex: newIndex, roomsResult: { ...roomsResult, rooms: newRooms } }; }); }, - [rooms], + [roomsResult], ); // Re-calculate the index when the active room has changed. @@ -115,20 +120,19 @@ export function useStickyRoomList(rooms: Room[]): StickyRoomListResult { useEffect(() => { let newRoomId: string | null = null; let isRoomChange = false; - const newSpace = SpaceStore.instance.activeSpace; - if (currentSpaceRef.current !== newSpace) { + if (currentSpaceRef.current !== roomsResult.spaceId) { /* If the space has changed, we check if we can immediately set the active index to the last opened room in that space. Otherwise, we might see a flicker because of the delay between the space change event and active room change dispatch. */ - newRoomId = SpaceStore.instance.getLastSelectedRoomIdForSpace(newSpace); + newRoomId = SpaceStore.instance.getLastSelectedRoomIdForSpace(roomsResult.spaceId); isRoomChange = true; - currentSpaceRef.current = newSpace; + currentSpaceRef.current = roomsResult.spaceId; } updateRoomsAndIndex(newRoomId, isRoomChange); - }, [rooms, updateRoomsAndIndex]); + }, [roomsResult, updateRoomsAndIndex]); - return { activeIndex: listState.index, rooms: listState.roomsWithStickyRoom }; + return listState; } diff --git a/src/components/viewmodels/settings/encryption/KeyStoragePanelViewModel.ts b/src/components/viewmodels/settings/encryption/KeyStoragePanelViewModel.ts index ee301bd27f..e0979d8dbb 100644 --- a/src/components/viewmodels/settings/encryption/KeyStoragePanelViewModel.ts +++ b/src/components/viewmodels/settings/encryption/KeyStoragePanelViewModel.ts @@ -5,11 +5,14 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com Please see LICENSE files in the repository root for full details. */ -import { useCallback, useEffect, useState } from "react"; +import { useCallback, useState } from "react"; +import { CryptoEvent } from "matrix-js-sdk/src/crypto-api"; import { logger } from "matrix-js-sdk/src/logger"; import { useMatrixClientContext } from "../../../../contexts/MatrixClientContext"; import DeviceListener, { BACKUP_DISABLED_ACCOUNT_DATA_KEY } from "../../../../DeviceListener"; +import { useEventEmitterAsyncState } from "../../../../hooks/useEventEmitter"; +import { resetKeyBackupAndWait } from "../../../../utils/crypto/resetKeyBackup"; interface KeyStoragePanelState { /** @@ -37,79 +40,97 @@ interface KeyStoragePanelState { /** Returns a ViewModel for use in {@link KeyStoragePanel} and {@link DeleteKeyStoragePanel}. */ export function useKeyStoragePanelViewModel(): KeyStoragePanelState { - const [isEnabled, setIsEnabled] = useState(undefined); const [loading, setLoading] = useState(true); // Whilst the change is being made, the toggle will reflect the pending value rather than the actual state const [pendingValue, setPendingValue] = useState(undefined); const matrixClient = useMatrixClientContext(); - const checkStatus = useCallback(async () => { - const crypto = matrixClient.getCrypto(); - if (!crypto) { - logger.error("Can't check key backup status: no crypto module available"); - return; - } - // The toggle is enabled only if this device will upload megolm keys to the backup. - // This is consistent with EX. - const activeBackupVersion = await crypto.getActiveSessionBackupVersion(); - setIsEnabled(activeBackupVersion !== null); - }, [matrixClient]); + const isEnabled = useEventEmitterAsyncState( + matrixClient, + CryptoEvent.KeyBackupStatus, + async (enabled?: boolean) => { + // If we're called as a result of an event, rather than during + // initialisation, we can get the backup status from the event + // instead of having to query the backup version. + if (enabled !== undefined) { + return enabled; + } - useEffect(() => { - (async () => { - await checkStatus(); + const crypto = matrixClient.getCrypto(); + if (!crypto) { + logger.error("Can't check key backup status: no crypto module available"); + return; + } + // The toggle is enabled only if this device will upload megolm keys to the backup. + // This is consistent with EX. + const activeBackupVersion = await crypto.getActiveSessionBackupVersion(); setLoading(false); - })(); - }, [checkStatus]); + return activeBackupVersion !== null; + }, + [matrixClient], + undefined, + ); const setEnabled = useCallback( async (enable: boolean) => { setPendingValue(enable); try { - // stop the device listener since enabling or (especially) disabling key storage must be + // pause the device listener since enabling or (especially) disabling key storage must be // done with a sequence of API calls that will put the account in a slightly different - // state each time, so suppress any warning toasts until the process is finished (when - // we'll turn it back on again.) - DeviceListener.sharedInstance().stop(); - - const crypto = matrixClient.getCrypto(); - if (!crypto) { - logger.error("Can't change key backup status: no crypto module available"); - return; - } - if (enable) { - // If there is no existing key backup on the server, create one. - // `resetKeyBackup` will delete any existing backup, so we only do this if there is no existing backup. - const currentKeyBackup = await crypto.checkKeyBackupAndEnable(); - if (currentKeyBackup === null) { - await crypto.resetKeyBackup(); - - // resetKeyBackup fires this off in the background without waiting, so we need to do it - // explicitly and wait for it, otherwise it won't be enabled yet when we check again. - await crypto.checkKeyBackupAndEnable(); + // state each time, so suppress any warning toasts until the process is finished + await DeviceListener.sharedInstance().whilePaused(async () => { + const crypto = matrixClient.getCrypto(); + if (!crypto) { + logger.error("Can't change key backup status: no crypto module available"); + return; } + if (enable) { + const childLogger = logger.getChild("[enable key storage]"); + childLogger.info("User requested enabling key storage"); + let currentKeyBackup = await crypto.checkKeyBackupAndEnable(); + if (currentKeyBackup) { + logger.info( + `Existing key backup is present. version: ${currentKeyBackup.backupInfo.version}`, + currentKeyBackup.trustInfo, + ); + // Check if the current key backup can be used. Either of these properties causes the key backup to be used. + if (currentKeyBackup.trustInfo.trusted || currentKeyBackup.trustInfo.matchesDecryptionKey) { + logger.info("Existing key backup can be used"); + } else { + logger.warn("Existing key backup cannot be used, creating new backup"); + // There aren't any *usable* backups, so we need to create a new one. + currentKeyBackup = null; + } + } else { + logger.info("No existing key backup versions are present, creating new backup"); + } - // Set the flag so that EX no longer thinks the user wants backup disabled - await matrixClient.setAccountData(BACKUP_DISABLED_ACCOUNT_DATA_KEY, { disabled: false }); - } else { - // This method will delete the key backup as well as server side recovery keys and other - // server-side crypto data. - await crypto.disableKeyStorage(); + // If there is no usable key backup on the server, create one. + // `resetKeyBackup` will delete any existing backup, so we only do this if there is no usable backup. + if (currentKeyBackup === null) { + await resetKeyBackupAndWait(crypto); + } - // Set a flag to say that the user doesn't want key backup. - // Element X uses this to determine whether to set up automatically, - // so this will stop EX turning it back on spontaneously. - await matrixClient.setAccountData(BACKUP_DISABLED_ACCOUNT_DATA_KEY, { disabled: true }); - } + // Set the flag so that EX no longer thinks the user wants backup disabled + await matrixClient.setAccountData(BACKUP_DISABLED_ACCOUNT_DATA_KEY, { disabled: false }); + } else { + logger.info("User requested disabling key backup"); + // This method will delete the key backup as well as server side recovery keys and other + // server-side crypto data. + await crypto.disableKeyStorage(); - await checkStatus(); + // Set a flag to say that the user doesn't want key backup. + // Element X uses this to determine whether to set up automatically, + // so this will stop EX turning it back on spontaneously. + await matrixClient.setAccountData(BACKUP_DISABLED_ACCOUNT_DATA_KEY, { disabled: true }); + } + }); } finally { setPendingValue(undefined); - DeviceListener.sharedInstance().start(matrixClient); } }, - [setPendingValue, checkStatus, matrixClient], + [setPendingValue, matrixClient], ); return { isEnabled: pendingValue ?? isEnabled, setEnabled, loading, busy: pendingValue !== undefined }; diff --git a/src/components/views/audio_messages/AudioPlayer.tsx b/src/components/views/audio_messages/AudioPlayer.tsx deleted file mode 100644 index 6f674e504d..0000000000 --- a/src/components/views/audio_messages/AudioPlayer.tsx +++ /dev/null @@ -1,65 +0,0 @@ -/* -Copyright 2024 New Vector Ltd. -Copyright 2021-2023 The Matrix.org Foundation C.I.C. - -SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial -Please see LICENSE files in the repository root for full details. -*/ - -import React, { type ReactNode } from "react"; - -import PlayPauseButton from "./PlayPauseButton"; -import { formatBytes } from "../../../utils/FormattingUtils"; -import DurationClock from "./DurationClock"; -import { _t } from "../../../languageHandler"; -import SeekBar from "./SeekBar"; -import PlaybackClock from "./PlaybackClock"; -import AudioPlayerBase from "./AudioPlayerBase"; -import { PlaybackState } from "../../../audio/Playback"; - -export default class AudioPlayer extends AudioPlayerBase { - protected renderFileSize(): string | null { - const bytes = this.props.playback.sizeBytes; - if (!bytes) return null; - - // Not translated here - we're just presenting the data which should already - // be translated if needed. - return `(${formatBytes(bytes)})`; - } - - protected renderComponent(): ReactNode { - // tabIndex=0 to ensure that the whole component becomes a tab stop, where we handle keyboard - // events for accessibility - return ( -
    -
    - -
    - - {this.props.mediaName || _t("timeline|m.audio|unnamed_audio")} - -
    - -   {/* easiest way to introduce a gap between the components */} - {this.renderFileSize()} -
    -
    -
    -
    - - -
    -
    - ); - } -} diff --git a/src/components/views/audio_messages/AudioPlayerBase.tsx b/src/components/views/audio_messages/AudioPlayerBase.tsx index 71bea008a2..9da1020634 100644 --- a/src/components/views/audio_messages/AudioPlayerBase.tsx +++ b/src/components/views/audio_messages/AudioPlayerBase.tsx @@ -14,7 +14,7 @@ import { UPDATE_EVENT } from "../../../stores/AsyncStore"; import { _t } from "../../../languageHandler"; import { getKeyBindingsManager } from "../../../KeyBindingsManager"; import { KeyBindingAction } from "../../../accessibility/KeyboardShortcuts"; -import type SeekBar from "./SeekBar"; +import type LegacySeekBar from "./LegacySeekBar"; import type PlayPauseButton from "./PlayPauseButton"; export interface IProps { @@ -31,7 +31,7 @@ interface IState { } export default abstract class AudioPlayerBase extends React.PureComponent { - protected seekRef = createRef(); + protected seekRef = createRef(); protected playPauseRef = createRef(); public constructor(props: T) { diff --git a/src/components/views/audio_messages/DurationClock.tsx b/src/components/views/audio_messages/DurationClock.tsx deleted file mode 100644 index 920baa99be..0000000000 --- a/src/components/views/audio_messages/DurationClock.tsx +++ /dev/null @@ -1,49 +0,0 @@ -/* -Copyright 2024 New Vector Ltd. -Copyright 2021 The Matrix.org Foundation C.I.C. - -SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial -Please see LICENSE files in the repository root for full details. -*/ - -import React from "react"; - -import Clock from "./Clock"; -import { type Playback } from "../../../audio/Playback"; - -interface IProps { - playback: Playback; -} - -interface IState { - durationSeconds: number; -} - -/** - * A clock which shows a clip's maximum duration. - */ -export default class DurationClock extends React.PureComponent { - public constructor(props: IProps) { - super(props); - - this.state = { - // we track the duration on state because we won't really know what the clip duration - // is until the first time update, and as a PureComponent we are trying to dedupe state - // updates as much as possible. This is just the easiest way to avoid a forceUpdate() or - // member property to track "did we get a duration". - durationSeconds: this.props.playback.clockInfo.durationSeconds, - }; - } - - public componentDidMount(): void { - this.props.playback.clockInfo.liveData.onUpdate(this.onTimeUpdate); - } - - private onTimeUpdate = (time: number[]): void => { - this.setState({ durationSeconds: time[1] }); - }; - - public render(): React.ReactNode { - return ; - } -} diff --git a/src/components/views/audio_messages/SeekBar.tsx b/src/components/views/audio_messages/LegacySeekBar.tsx similarity index 94% rename from src/components/views/audio_messages/SeekBar.tsx rename to src/components/views/audio_messages/LegacySeekBar.tsx index 587975ce1b..fe9e1dacd8 100644 --- a/src/components/views/audio_messages/SeekBar.tsx +++ b/src/components/views/audio_messages/LegacySeekBar.tsx @@ -7,10 +7,10 @@ Please see LICENSE files in the repository root for full details. */ import React, { type ChangeEvent, type CSSProperties, type ReactNode } from "react"; +import { percentageOf } from "@element-hq/web-shared-components"; import { type PlaybackInterface } from "../../../audio/Playback"; import { MarkedExecution } from "../../../utils/MarkedExecution"; -import { percentageOf } from "../../../utils/numbers"; import { _t } from "../../../languageHandler"; interface IProps { @@ -35,7 +35,10 @@ interface ISeekCSS extends CSSProperties { const ARROW_SKIP_SECONDS = 5; // arbitrary -export default class SeekBar extends React.PureComponent { +/** + * @deprecated Use {@link SeekBar} instead. + */ +export default class LegacySeekBar extends React.PureComponent { // We use an animation frame request to avoid overly spamming prop updates, even if we aren't // really using anything demanding on the CSS front. diff --git a/src/components/views/audio_messages/LiveRecordingClock.tsx b/src/components/views/audio_messages/LiveRecordingClock.tsx index 74415566a6..9ea00c2d67 100644 --- a/src/components/views/audio_messages/LiveRecordingClock.tsx +++ b/src/components/views/audio_messages/LiveRecordingClock.tsx @@ -7,9 +7,9 @@ Please see LICENSE files in the repository root for full details. */ import React from "react"; +import { Clock } from "@element-hq/web-shared-components"; import { type IRecordingUpdate } from "../../../audio/VoiceRecording"; -import Clock from "./Clock"; import { MarkedExecution } from "../../../utils/MarkedExecution"; import { type VoiceMessageRecording } from "../../../audio/VoiceMessageRecording"; diff --git a/src/components/views/audio_messages/PlayPauseButton.tsx b/src/components/views/audio_messages/PlayPauseButton.tsx index a4457b2230..1af6971d2c 100644 --- a/src/components/views/audio_messages/PlayPauseButton.tsx +++ b/src/components/views/audio_messages/PlayPauseButton.tsx @@ -6,14 +6,12 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com Please see LICENSE files in the repository root for full details. */ -import React, { type ReactNode } from "react"; -import classNames from "classnames"; +import React, { type HTMLAttributes, type ReactNode } from "react"; +import { PlayPauseButton as SharedPlayPauseButton } from "@element-hq/web-shared-components"; -import { _t } from "../../../languageHandler"; import { type Playback, PlaybackState } from "../../../audio/Playback"; -import AccessibleButton, { type ButtonProps } from "../elements/AccessibleButton"; -type Props = Omit, "title" | "onClick" | "disabled" | "element" | "ref"> & { +type Props = HTMLAttributes & { // Playback instance to manipulate. Cannot change during the component lifecycle. playback: Playback; @@ -27,8 +25,7 @@ type Props = Omit, "title" | "onClick" | "disabled" | "elemen */ export default class PlayPauseButton extends React.PureComponent { private onClick = (): void => { - // noinspection JSIgnoredPromiseFromCall - this.toggleState(); + void this.toggleState(); }; public async toggleState(): Promise { @@ -37,21 +34,14 @@ export default class PlayPauseButton extends React.PureComponent { public render(): ReactNode { const { playback, playbackPhase, ...restProps } = this.props; - const isPlaying = playback.isPlaying; - const isDisabled = playbackPhase === PlaybackState.Decoding; - const classes = classNames("mx_PlayPauseButton", { - mx_PlayPauseButton_play: !isPlaying, - mx_PlayPauseButton_pause: isPlaying, - mx_PlayPauseButton_disabled: isDisabled, - }); return ( - ); diff --git a/src/components/views/audio_messages/PlaybackClock.tsx b/src/components/views/audio_messages/PlaybackClock.tsx index 0d69793379..78ac81f25c 100644 --- a/src/components/views/audio_messages/PlaybackClock.tsx +++ b/src/components/views/audio_messages/PlaybackClock.tsx @@ -7,8 +7,8 @@ Please see LICENSE files in the repository root for full details. */ import React from "react"; +import { Clock } from "@element-hq/web-shared-components"; -import Clock from "./Clock"; import { type Playback, PlaybackState } from "../../../audio/Playback"; import { UPDATE_EVENT } from "../../../stores/AsyncStore"; diff --git a/src/components/views/audio_messages/PlaybackWaveform.tsx b/src/components/views/audio_messages/PlaybackWaveform.tsx index c1f470f4b1..215011e6cb 100644 --- a/src/components/views/audio_messages/PlaybackWaveform.tsx +++ b/src/components/views/audio_messages/PlaybackWaveform.tsx @@ -7,11 +7,11 @@ Please see LICENSE files in the repository root for full details. */ import React from "react"; +import { percentageOf } from "@element-hq/web-shared-components"; import { arraySeed, arrayTrimFill } from "../../../utils/arrays"; import Waveform from "./Waveform"; import { type Playback } from "../../../audio/Playback"; -import { percentageOf } from "../../../utils/numbers"; import { PLAYBACK_WAVEFORM_SAMPLES } from "../../../audio/consts"; interface IProps { diff --git a/src/components/views/audio_messages/RecordingPlayback.tsx b/src/components/views/audio_messages/RecordingPlayback.tsx index c0e3337787..4f5c392d41 100644 --- a/src/components/views/audio_messages/RecordingPlayback.tsx +++ b/src/components/views/audio_messages/RecordingPlayback.tsx @@ -11,7 +11,7 @@ import React, { type ReactNode } from "react"; import PlayPauseButton from "./PlayPauseButton"; import PlaybackClock from "./PlaybackClock"; import AudioPlayerBase, { type IProps as IAudioPlayerBaseProps } from "./AudioPlayerBase"; -import SeekBar from "./SeekBar"; +import LegacySeekBar from "./LegacySeekBar"; import PlaybackWaveform from "./PlaybackWaveform"; import { PlaybackState } from "../../../audio/Playback"; @@ -49,7 +49,7 @@ export default class RecordingPlayback extends AudioPlayerBase { <>
    - { } return ( -
    +
    ): JSX.Element { - return
    {children}
    ; + return
    {children}
    ; } diff --git a/src/components/views/auth/AuthPage.tsx b/src/components/views/auth/AuthPage.tsx index dd3facaa79..adc901f6c9 100644 --- a/src/components/views/auth/AuthPage.tsx +++ b/src/components/views/auth/AuthPage.tsx @@ -8,11 +8,22 @@ Please see LICENSE files in the repository root for full details. */ import React from "react"; +import classNames from "classnames"; import SdkConfig from "../../../SdkConfig"; import AuthFooter from "./AuthFooter"; -export default class AuthPage extends React.PureComponent { +interface IProps { + /** + * Whether to add a blurred shadow around the modal. + * + * If the modal component provides its own shadow or blurring, this can be + * disabled. Defaults to `true`. + */ + addBlur?: boolean; +} + +export default class AuthPage extends React.PureComponent> { private static welcomeBackgroundUrl?: string; // cache the url as a static to prevent it changing without refreshing @@ -58,17 +69,33 @@ export default class AuthPage extends React.PureComponent; + } + + const modalClasses = classNames({ + mx_AuthPage_modal: true, + mx_AuthPage_modal_withBlur: this.props.addBlur !== false, + }); + return (
    -
    -
    -
    +
    + {modalBlur} +
    {this.props.children} -
    +
    diff --git a/src/components/views/auth/InteractiveAuthEntryComponents.tsx b/src/components/views/auth/InteractiveAuthEntryComponents.tsx index a9bf8b7597..6f1e74b63d 100644 --- a/src/components/views/auth/InteractiveAuthEntryComponents.tsx +++ b/src/components/views/auth/InteractiveAuthEntryComponents.tsx @@ -460,7 +460,7 @@ export class EmailIdentityAuthEntry extends React.Component< a: (text: string) => ( - {text} + {text} ), @@ -875,7 +875,7 @@ export class SSOAuthEntry extends React.Component {this.props.busy ? ( - + ) : ( <> {cancelButton} diff --git a/src/components/views/auth/LoginWithQRFlow.tsx b/src/components/views/auth/LoginWithQRFlow.tsx index 3a5a88f78d..a432baac72 100644 --- a/src/components/views/auth/LoginWithQRFlow.tsx +++ b/src/components/views/auth/LoginWithQRFlow.tsx @@ -290,7 +290,7 @@ export default class LoginWithQRFlow extends React.Component { data-testid="back-button" className="mx_LoginWithQR_BackButton" onClick={this.handleClick(Click.Back)} - title="Back" + title={_t("action|back")} > diff --git a/src/components/views/auth/Welcome.tsx b/src/components/views/auth/Welcome.tsx index 59fd1533e3..e2b64028e4 100644 --- a/src/components/views/auth/Welcome.tsx +++ b/src/components/views/auth/Welcome.tsx @@ -26,6 +26,7 @@ export default class Welcome extends React.PureComponent { } const replaceMap: Record = { + "$brand": SdkConfig.get("brand"), "$riot:ssoUrl": "#/start_sso", "$riot:casUrl": "#/start_cas", "$matrixLogo": MATRIX_LOGO_HTML, diff --git a/src/components/views/avatars/RoomAvatarView.tsx b/src/components/views/avatars/RoomAvatarView.tsx index 8810d073c5..408690fa1d 100644 --- a/src/components/views/avatars/RoomAvatarView.tsx +++ b/src/components/views/avatars/RoomAvatarView.tsx @@ -9,13 +9,15 @@ import React, { type JSX } from "react"; import { type Room } from "matrix-js-sdk/src/matrix"; import PublicIcon from "@vector-im/compound-design-tokens/assets/web/icons/public"; import VideoIcon from "@vector-im/compound-design-tokens/assets/web/icons/video-call-solid"; +import ArrowDownIcon from "@vector-im/compound-design-tokens/assets/web/icons/arrow-down"; import OnlineOrUnavailableIcon from "@vector-im/compound-design-tokens/assets/web/icons/presence-solid-8x8"; import OfflineIcon from "@vector-im/compound-design-tokens/assets/web/icons/presence-outline-8x8"; import BusyIcon from "@vector-im/compound-design-tokens/assets/web/icons/presence-strikethrough-8x8"; import classNames from "classnames"; +import { Tooltip } from "@vector-im/compound-web"; import RoomAvatar from "./RoomAvatar"; -import { useRoomAvatarViewModel } from "../../viewmodels/avatars/RoomAvatarViewModel"; +import { AvatarBadgeDecoration, useRoomAvatarViewModel } from "../../viewmodels/avatars/RoomAvatarViewModel"; import { _t } from "../../../languageHandler"; import { Presence } from "./WithPresenceIndicator"; @@ -33,56 +35,30 @@ interface RoomAvatarViewProps { export function RoomAvatarView({ room }: RoomAvatarViewProps): JSX.Element { const vm = useRoomAvatarViewModel(room); // No decoration, we just show the avatar - if (!vm.hasDecoration) return ; + if (!vm.badgeDecoration) return ; + + const icon = getAvatarDecoration(vm.badgeDecoration, vm.presence); + const label = getDecorationLabel(vm.badgeDecoration, vm.presence); + + // Presence indicator and video/public icons don't have the same size + // We use different masks + const maskClass = + vm.badgeDecoration === AvatarBadgeDecoration.Presence + ? "mx_RoomAvatarView_RoomAvatar_presence" + : "mx_RoomAvatarView_RoomAvatar_icon"; return (
    - - - {/* If the room is a public video room, we prefer to display only the video icon */} - {vm.isPublic && !vm.isVideoRoom && ( - - )} - {vm.isVideoRoom && ( - - )} - {vm.presence && } + + {label ? {icon} : icon}
    ); } -type PresenceDecorationProps = { - /** - * The presence of the user in the DM room. - */ - presence: NonNullable; -}; - /** - * Component to display the presence of a user in a DM room. + * Get the decoration for the avatar based on the presence. */ -function PresenceDecoration({ presence }: PresenceDecorationProps): JSX.Element { +function getPresenceDecoration(presence: Presence): JSX.Element { switch (presence) { case Presence.Online: return ( @@ -91,7 +67,7 @@ function PresenceDecoration({ presence }: PresenceDecorationProps): JSX.Element height="8px" className="mx_RoomAvatarView_PresenceDecoration" color="var(--cpd-color-icon-accent-primary)" - aria-label={_t("presence|online")} + aria-label={getPresenceLabel(presence)} /> ); case Presence.Away: @@ -101,7 +77,7 @@ function PresenceDecoration({ presence }: PresenceDecorationProps): JSX.Element height="8px" className="mx_RoomAvatarView_PresenceDecoration" color="var(--cpd-color-icon-quaternary)" - aria-label={_t("presence|away")} + aria-label={getPresenceLabel(presence)} /> ); case Presence.Offline: @@ -111,7 +87,7 @@ function PresenceDecoration({ presence }: PresenceDecorationProps): JSX.Element height="8px" className="mx_RoomAvatarView_PresenceDecoration" color="var(--cpd-color-icon-tertiary)" - aria-label={_t("presence|offline")} + aria-label={getPresenceLabel(presence)} /> ); case Presence.Busy: @@ -121,8 +97,78 @@ function PresenceDecoration({ presence }: PresenceDecorationProps): JSX.Element height="8px" className="mx_RoomAvatarView_PresenceDecoration" color="var(--cpd-color-icon-tertiary)" - aria-label={_t("presence|busy")} + aria-label={getPresenceLabel(presence)} /> ); } } + +function getAvatarDecoration(decoration: AvatarBadgeDecoration, presence: Presence | null): React.ReactNode { + if (decoration === AvatarBadgeDecoration.LowPriority) { + return ( + + ); + } else if (decoration === AvatarBadgeDecoration.VideoRoom) { + return ( + + ); + } else if (decoration === AvatarBadgeDecoration.PublicRoom) { + return ( + + ); + } else if (decoration === AvatarBadgeDecoration.Presence) { + return getPresenceDecoration(presence!); + } +} + +/** + * Get the label for the avatar decoration. + * This is used for the tooltip and a11y label. + */ +function getDecorationLabel(decoration: AvatarBadgeDecoration, presence: Presence | null): string | undefined { + switch (decoration) { + case AvatarBadgeDecoration.LowPriority: + return _t("room|room_is_low_priority"); + case AvatarBadgeDecoration.VideoRoom: + return _t("room|video_room"); + case AvatarBadgeDecoration.PublicRoom: + return _t("room|header|room_is_public"); + case AvatarBadgeDecoration.Presence: + return getPresenceLabel(presence!); + } +} + +/** + * Get the label for the presence. + * This is used for the tooltip and a11y label. + */ +function getPresenceLabel(presence: Presence): string { + switch (presence) { + case Presence.Online: + return _t("presence|online"); + case Presence.Away: + return _t("presence|away"); + case Presence.Offline: + return _t("presence|offline"); + case Presence.Busy: + return _t("presence|busy"); + } +} diff --git a/src/components/views/avatars/WidgetAvatar.tsx b/src/components/views/avatars/WidgetAvatar.tsx index c43cd98216..5baba7ca01 100644 --- a/src/components/views/avatars/WidgetAvatar.tsx +++ b/src/components/views/avatars/WidgetAvatar.tsx @@ -37,6 +37,8 @@ const WidgetAvatar: React.FC = ({ app, className, size = "20px", ...prop return ( = ({ roomId, call }) => action: Action.ViewRoom, room_id: roomId, view_call: true, - skipLobby: "shiftKey" in ev ? ev.shiftKey : false, + skipLobby: ("shiftKey" in ev && ev.shiftKey) || undefined, metricsTrigger: undefined, }); }, @@ -83,7 +83,7 @@ interface Props { const RoomCallBanner: React.FC = ({ roomId }) => { const call = useCall(roomId); - + const { roomViewStore } = useScopedRoomContext("roomViewStore"); // this section is to check if we have a live location share. If so, we dont show the call banner const isMonitoringLiveLocation = useEventEmitterState( OwnBeaconStore.instance, @@ -100,7 +100,7 @@ const RoomCallBanner: React.FC = ({ roomId }) => { } // Check if the call is already showing. No banner is needed in this case. - if (SdkContextClass.instance.roomViewStore.isViewingCall()) { + if (roomViewStore.isViewingCall()) { return null; } diff --git a/src/components/views/beacon/ShareLatestLocation.tsx b/src/components/views/beacon/ShareLatestLocation.tsx index b18cdff839..9eee972419 100644 --- a/src/components/views/beacon/ShareLatestLocation.tsx +++ b/src/components/views/beacon/ShareLatestLocation.tsx @@ -9,8 +9,8 @@ Please see LICENSE files in the repository root for full details. import React, { useEffect, useState } from "react"; import { type ContentHelpers } from "matrix-js-sdk/src/matrix"; import { Tooltip } from "@vector-im/compound-web"; +import { PopOutIcon } from "@vector-im/compound-design-tokens/assets/web/icons"; -import { Icon as ExternalLinkIcon } from "../../../../res/img/external-link.svg"; import { _t } from "../../../languageHandler"; import { makeMapSiteLink, parseGeoUri } from "../../../utils/location"; import CopyableText from "../elements/CopyableText"; @@ -40,7 +40,7 @@ const ShareLatestLocation: React.FC = ({ latestLocationState }) => { <> - + latLonString} /> diff --git a/src/components/views/composer/HistoryVisibleBanner.tsx b/src/components/views/composer/HistoryVisibleBanner.tsx new file mode 100644 index 0000000000..85a6bd7fb9 --- /dev/null +++ b/src/components/views/composer/HistoryVisibleBanner.tsx @@ -0,0 +1,30 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial + * Please see LICENSE files in the repository root for full details. + */ + +import { HistoryVisibleBannerView, useCreateAutoDisposedViewModel } from "@element-hq/web-shared-components"; +import React from "react"; +import { type Room } from "matrix-js-sdk/src/matrix"; + +import { HistoryVisibleBannerViewModel } from "../../../viewmodels/composer/HistoryVisibleBannerViewModel"; + +/** Wrapper around {@link HistoryVisibleBannerViewModel} for the creation of an auto-disposed view model. */ +export const HistoryVisibleBanner: React.FC<{ + /** The room instance associated with this banner view model. */ + room: Room; + + /** Whether the current user can send messages in the room. */ + canSendMessages: boolean; + + /** + * If not null, specifies the ID of the thread currently being viewed in the thread timeline side view, + * where the banner view is displayed as a child of the message composer. + */ + threadId: string | null; +}> = (props) => { + const vm = useCreateAutoDisposedViewModel(() => new HistoryVisibleBannerViewModel(props)); + return ; +}; diff --git a/src/components/views/context_menus/DeveloperToolsOption.tsx b/src/components/views/context_menus/DeveloperToolsOption.tsx index 53a5d7283d..b1b8a5b136 100644 --- a/src/components/views/context_menus/DeveloperToolsOption.tsx +++ b/src/components/views/context_menus/DeveloperToolsOption.tsx @@ -7,6 +7,7 @@ Please see LICENSE files in the repository root for full details. */ import React from "react"; +import { LabsIcon } from "@vector-im/compound-design-tokens/assets/web/icons"; import Modal from "../../../Modal"; import DevtoolsDialog from "../dialogs/DevtoolsDialog"; @@ -32,7 +33,7 @@ export const DeveloperToolsOption: React.FC = ({ onFinished, roomId }) => onFinished(); }} label={_t("devtools|title")} - iconClassName="mx_IconizedContextMenu_developerTools" + icon={} /> ); }; diff --git a/src/components/views/context_menus/DeviceContextMenu.tsx b/src/components/views/context_menus/DeviceContextMenu.tsx index b6646c05ec..7560b4a7d5 100644 --- a/src/components/views/context_menus/DeviceContextMenu.tsx +++ b/src/components/views/context_menus/DeviceContextMenu.tsx @@ -26,14 +26,7 @@ interface IDeviceContextMenuDeviceProps { } const DeviceContextMenuDevice: React.FC = ({ label, selected, onClick }) => { - return ( - - ); + return ; }; interface IDeviceContextMenuSectionProps { diff --git a/src/components/views/context_menus/IconizedContextMenu.tsx b/src/components/views/context_menus/IconizedContextMenu.tsx index b6372a96c6..b7d07cae3f 100644 --- a/src/components/views/context_menus/IconizedContextMenu.tsx +++ b/src/components/views/context_menus/IconizedContextMenu.tsx @@ -8,6 +8,7 @@ Please see LICENSE files in the repository root for full details. import React, { type JSX, type ReactNode } from "react"; import classNames from "classnames"; +import { CheckIcon } from "@vector-im/compound-design-tokens/assets/web/icons"; import ContextMenu, { ChevronFace, @@ -32,26 +33,20 @@ interface IOptionListProps { } interface IOptionProps extends React.ComponentProps { - iconClassName?: string; + icon?: ReactNode; isDestructive?: boolean; } interface ICheckboxProps extends React.ComponentProps { - iconClassName: string; + icon?: ReactNode; words?: boolean; } interface IRadioProps extends React.ComponentProps { - iconClassName?: string; + icon?: ReactNode; } -export const IconizedContextMenuRadio: React.FC = ({ - label, - iconClassName, - active, - className, - ...props -}) => { +export const IconizedContextMenuRadio: React.FC = ({ label, icon, active, className, ...props }) => { return ( = ({ active={active} label={label} > - {iconClassName && } + {icon} {label} - {active && } + {active && } ); }; export const IconizedContextMenuCheckbox: React.FC = ({ label, - iconClassName, + icon, active, className, words, ...props }) => { - let marker: JSX.Element; + let marker: JSX.Element | undefined; if (words) { marker = ( {active ? _t("common|on") : _t("common|off")} ); - } else { - marker = ( - - ); + } else if (active) { + marker = ; } return ( @@ -103,7 +91,7 @@ export const IconizedContextMenuCheckbox: React.FC = ({ active={active} label={label} > - + {icon} {label} {marker} @@ -113,7 +101,7 @@ export const IconizedContextMenuCheckbox: React.FC = ({ export const IconizedContextMenuOption: React.FC = ({ label, className, - iconClassName, + icon, children, isDestructive, ...props @@ -128,7 +116,7 @@ export const IconizedContextMenuOption: React.FC = ({ })} label={label} > - {iconClassName && } + {icon} {label} {children} diff --git a/src/components/views/context_menus/MessageContextMenu.tsx b/src/components/views/context_menus/MessageContextMenu.tsx index 76f6b31989..4f1a6a962a 100644 --- a/src/components/views/context_menus/MessageContextMenu.tsx +++ b/src/components/views/context_menus/MessageContextMenu.tsx @@ -20,6 +20,27 @@ import { Thread, M_POLL_START, } from "matrix-js-sdk/src/matrix"; +import { + CheckIcon, + ChevronUpIcon, + EditIcon, + ErrorSolidIcon, + InlineCodeIcon, + LinkIcon, + PinIcon, + QuoteIcon, + ReactionAddIcon, + ReplyIcon, + RestartIcon, + ThreadsIcon, + UnpinIcon, + DeleteIcon, + ForwardIcon, + PopOutIcon, + VisibilityOnIcon, + ShareIcon, + CopyIcon, +} from "@vector-im/compound-design-tokens/assets/web/icons"; import { MatrixClientPeg } from "../../../MatrixClientPeg"; import dis from "../../../dispatcher/dispatcher"; @@ -53,6 +74,8 @@ import { type ShowThreadPayload } from "../../../dispatcher/payloads/ShowThreadP import { CardContext } from "../right_panel/context"; import PinningUtils from "../../../utils/PinningUtils"; import PosthogTrackers from "../../../PosthogTrackers.ts"; +import { Icon as ViewInRoomIcon } from "../../../../res/img/element-icons/view-in-room.svg"; +import { Icon as ChildRelationshipIcon } from "../../../../res/img/element-icons/child-relationship.svg"; interface IReplyInThreadButton { mxEvent: MatrixEvent; @@ -86,13 +109,7 @@ const ReplyInThreadButton: React.FC = ({ mxEvent, closeMen closeMenu(); }; - return ( - - ); + return } label={_t("action|reply_in_thread")} onClick={onClick} />; }; interface IProps extends MenuProps { @@ -183,6 +200,30 @@ export default class MessageContextMenu extends React.Component ); } + /** + * Returns true if the current selection is entirely within a single "mx_MTextBody" element. + */ + private isSelectionWithinSingleTextBody(): boolean { + const selection = window.getSelection(); + if (!selection || selection.rangeCount === 0) return false; + const range = selection.getRangeAt(0); + + function getParentByClass(node: Node | null, className: string): HTMLElement | null { + while (node) { + if (node instanceof HTMLElement && node.classList.contains(className)) { + return node; + } + node = node.parentNode; + } + return null; + } + + const startTextBody = getParentByClass(range.startContainer, "mx_MTextBody"); + const endTextBody = getParentByClass(range.endContainer, "mx_MTextBody"); + + return !!startTextBody && startTextBody === endTextBody; + } + private onResendReactionsClick = (): void => { for (const reaction of this.getUnsentReactions()) { Resend.resend(MatrixClientPeg.safeGet(), reaction); @@ -231,7 +272,7 @@ export default class MessageContextMenu extends React.Component dis.dispatch({ action: Action.OpenForwardDialog, event: forwardableEvent, - permalinkCreator: this.props.permalinkCreator, + permalinkCreator: this.props.permalinkCreator ?? null, }); this.closeMenu(); }; @@ -279,6 +320,24 @@ export default class MessageContextMenu extends React.Component this.closeMenu(); }; + private onQuoteClick = (): void => { + const selectedText = getSelectedText(); + if (selectedText) { + // Format as markdown quote + const quotedText = selectedText + .trim() + .split(/\r?\n/) + .map((line) => `> ${line}`) + .join("\n"); + dis.dispatch({ + action: Action.ComposerInsert, + text: "\n" + quotedText + "\n\n ", + timelineRenderingType: this.context.timelineRenderingType, + }); + } + this.closeMenu(); + }; + private onEditClick = (): void => { editEvent( MatrixClientPeg.safeGet(), @@ -371,7 +430,7 @@ export default class MessageContextMenu extends React.Component if (!mxEvent.isRedacted() && unsentReactionsCount !== 0) { resendReactionsButton = ( } label={_t("timeline|context_menu|resent_unsent_reactions", { unsentCount: unsentReactionsCount })} onClick={this.onResendReactionsClick} /> @@ -382,7 +441,7 @@ export default class MessageContextMenu extends React.Component if (isSent && this.state.canRedact) { redactButton = ( } label={_t("action|remove")} onClick={this.onRedactClick} /> @@ -395,7 +454,7 @@ export default class MessageContextMenu extends React.Component const mapSiteLink = createMapSiteLinkFromEvent(shareableLocationEvent); openInMapSiteButton = ( } onClick={null} label={_t("timeline|context_menu|open_in_osm")} element="a" @@ -413,7 +472,7 @@ export default class MessageContextMenu extends React.Component if (contentActionable && forwardableEvent) { forwardButton = ( } label={_t("action|forward")} onClick={this.onForwardClick(forwardableEvent)} /> @@ -423,7 +482,7 @@ export default class MessageContextMenu extends React.Component // This is specifically not behind the developerMode flag to give people insight into the Matrix const viewSourceButton = ( } label={_t("timeline|context_menu|view_source")} onClick={this.onViewSourceClick} /> @@ -433,7 +492,7 @@ export default class MessageContextMenu extends React.Component if (eventTileOps?.isWidgetHidden()) { unhidePreviewButton = ( } label={_t("timeline|context_menu|show_url_preview")} onClick={this.onUnhidePreviewClick} /> @@ -444,7 +503,7 @@ export default class MessageContextMenu extends React.Component if (permalink) { permalinkButton = ( } onClick={this.onShareClick} label={_t("action|share")} element="a" @@ -464,7 +523,7 @@ export default class MessageContextMenu extends React.Component if (this.canEndPoll(mxEvent)) { endPollButton = ( } label={_t("poll|end_title")} onClick={this.onEndPollClick} /> @@ -479,7 +538,7 @@ export default class MessageContextMenu extends React.Component ) { externalURLButton = ( } onClick={this.closeMenu} label={_t("timeline|context_menu|external_url")} element="a" @@ -499,7 +558,7 @@ export default class MessageContextMenu extends React.Component if (collapseReplyChain) { collapseReplyChainButton = ( } label={_t("timeline|context_menu|collapse_reply_thread")} onClick={this.onCollapseReplyChainClick} /> @@ -511,7 +570,7 @@ export default class MessageContextMenu extends React.Component if (relatedEventId && SettingsStore.getValue("developerMode")) { jumpToRelatedEventButton = ( } label={_t("timeline|context_menu|view_related_event")} onClick={() => this.onJumpToRelatedEventClick(relatedEventId)} /> @@ -522,7 +581,7 @@ export default class MessageContextMenu extends React.Component if (mxEvent.getSender() !== me) { reportEventButton = ( } label={_t("timeline|context_menu|report")} onClick={this.onReportEventClick} /> @@ -533,7 +592,7 @@ export default class MessageContextMenu extends React.Component if (link) { copyLinkButton = ( } onClick={this.onCopyLinkClick} label={_t("action|copy_link")} element="a" @@ -549,11 +608,13 @@ export default class MessageContextMenu extends React.Component ); } + const selectedText = getSelectedText(); + let copyButton: JSX.Element | undefined; - if (rightClick && getSelectedText()) { + if (rightClick && selectedText) { copyButton = ( } label={_t("action|copy")} triggerOnMouseDown={true} // We use onMouseDown so that the selection isn't cleared when we click onClick={this.onCopyClick} @@ -561,14 +622,22 @@ export default class MessageContextMenu extends React.Component ); } + let quoteButton: JSX.Element | undefined; + if (rightClick && selectedText && selectedText.trim().length > 0 && this.isSelectionWithinSingleTextBody()) { + quoteButton = ( + } + label={_t("action|quote")} + triggerOnMouseDown={true} + onClick={this.onQuoteClick} + /> + ); + } + let editButton: JSX.Element | undefined; if (rightClick && canEditContent(cli, mxEvent)) { editButton = ( - + } label={_t("action|edit")} onClick={this.onEditClick} /> ); } @@ -576,7 +645,7 @@ export default class MessageContextMenu extends React.Component if (rightClick && contentActionable && canSendMessages) { replyButton = ( } label={_t("action|reply")} onClick={this.onReplyClick} /> @@ -598,7 +667,7 @@ export default class MessageContextMenu extends React.Component if (rightClick && contentActionable && canReact) { reactButton = ( } label={_t("action|react")} onClick={this.onReactClick} inputRef={this.reactButtonRef} @@ -611,7 +680,7 @@ export default class MessageContextMenu extends React.Component const isPinned = PinningUtils.isPinned(MatrixClientPeg.safeGet(), this.props.mxEvent); pinButton = ( : } label={isPinned ? _t("action|unpin") : _t("action|pin")} onClick={() => this.onPinClick(isPinned)} /> @@ -622,7 +691,7 @@ export default class MessageContextMenu extends React.Component if (isThreadRootEvent) { viewInRoomButton = ( } label={_t("timeline|mab|view_in_room")} onClick={this.viewInRoom} /> @@ -630,10 +699,11 @@ export default class MessageContextMenu extends React.Component } let nativeItemsList: JSX.Element | undefined; - if (copyButton || copyLinkButton) { + if (copyButton || quoteButton || copyLinkButton) { nativeItemsList = ( {copyButton} + {quoteButton} {copyLinkButton} ); @@ -678,7 +748,7 @@ export default class MessageContextMenu extends React.Component if (this.state.reactionPickerDisplayed) { const buttonRect = (this.reactButtonRef.current as HTMLElement)?.getBoundingClientRect(); reactionPicker = ( - + ); diff --git a/src/components/views/context_menus/RoomGeneralContextMenu.tsx b/src/components/views/context_menus/RoomGeneralContextMenu.tsx index 7c21d09853..fdfabdeee9 100644 --- a/src/components/views/context_menus/RoomGeneralContextMenu.tsx +++ b/src/components/views/context_menus/RoomGeneralContextMenu.tsx @@ -9,6 +9,16 @@ Please see LICENSE files in the repository root for full details. import { logger } from "matrix-js-sdk/src/logger"; import { type Room } from "matrix-js-sdk/src/matrix"; import React, { type JSX, useContext } from "react"; +import { + FavouriteSolidIcon, + LinkIcon, + SettingsSolidIcon, + ArrowDownIcon, + MarkAsReadIcon, + MarkAsUnreadIcon, + LeaveIcon, + UserAddIcon, +} from "@vector-im/compound-design-tokens/assets/web/icons"; import { KeyBindingAction } from "../../../accessibility/KeyboardShortcuts"; import RoomListActions from "../../../actions/RoomListActions"; @@ -153,7 +163,7 @@ export const RoomGeneralContextMenu: React.FC = ({ onClick={wrapHandler((ev) => onTagRoom(ev, DefaultTagID.Favourite), onPostFavoriteClick, true)} active={isFavorite} label={isFavorite ? _t("room|context_menu|unfavourite") : _t("room|context_menu|favourite")} - iconClassName="mx_RoomGeneralContextMenu_iconStar" + icon={} /> ); @@ -163,7 +173,7 @@ export const RoomGeneralContextMenu: React.FC = ({ onClick={wrapHandler((ev) => onTagRoom(ev, DefaultTagID.LowPriority), onPostLowPriorityClick, true)} active={isLowPriority} label={_t("room|context_menu|low_priority")} - iconClassName="mx_RoomGeneralContextMenu_iconArrowDown" + icon={} /> ); @@ -180,7 +190,7 @@ export const RoomGeneralContextMenu: React.FC = ({ onPostInviteClick, )} label={_t("action|invite")} - iconClassName="mx_RoomGeneralContextMenu_iconInvite" + icon={} /> ); } @@ -198,7 +208,7 @@ export const RoomGeneralContextMenu: React.FC = ({ onPostCopyLinkClick, )} label={_t("room|context_menu|copy_link")} - iconClassName="mx_RoomGeneralContextMenu_iconCopyLink" + icon={} /> ); } @@ -214,7 +224,7 @@ export const RoomGeneralContextMenu: React.FC = ({ onPostSettingsClick, )} label={_t("common|settings")} - iconClassName="mx_RoomGeneralContextMenu_iconSettings" + icon={} /> ); @@ -222,7 +232,7 @@ export const RoomGeneralContextMenu: React.FC = ({ if (roomTags.includes(DefaultTagID.Archived)) { leaveOption = ( } label={_t("room|context_menu|forget")} className="mx_IconizedContextMenu_option_red" onClick={wrapHandler( @@ -248,7 +258,7 @@ export const RoomGeneralContextMenu: React.FC = ({ )} label={_t("action|leave")} className="mx_IconizedContextMenu_option_red" - iconClassName="mx_RoomGeneralContextMenu_iconSignOut" + icon={} /> ); } @@ -263,7 +273,7 @@ export const RoomGeneralContextMenu: React.FC = ({ onFinished?.(); }, onPostMarkAsReadClick)} label={_t("room|context_menu|mark_read")} - iconClassName="mx_RoomGeneralContextMenu_iconMarkAsRead" + icon={} /> ); } else if (!roomTags.includes(DefaultTagID.Archived)) { @@ -274,7 +284,7 @@ export const RoomGeneralContextMenu: React.FC = ({ onFinished?.(); }, onPostMarkAsUnreadClick)} label={_t("room|context_menu|mark_unread")} - iconClassName="mx_RoomGeneralContextMenu_iconMarkAsUnread" + icon={} /> ); } else { diff --git a/src/components/views/context_menus/RoomNotificationContextMenu.tsx b/src/components/views/context_menus/RoomNotificationContextMenu.tsx index 9844f27695..9ad381736a 100644 --- a/src/components/views/context_menus/RoomNotificationContextMenu.tsx +++ b/src/components/views/context_menus/RoomNotificationContextMenu.tsx @@ -20,6 +20,10 @@ import IconizedContextMenu, { IconizedContextMenuRadio, } from "../context_menus/IconizedContextMenu"; import { type ButtonEvent } from "../elements/AccessibleButton"; +import { Icon as NotificationsIcon } from "../../../../res/img/element-icons/notifications.svg"; +import { Icon as NotificationsDefaultIcon } from "../../../../res/img/element-icons/roomlist/notifications-default.svg"; +import { Icon as NotificationsDmIcon } from "../../../../res/img/element-icons/roomlist/notifications-dm.svg"; +import { Icon as NotificationsOffIcon } from "../../../../res/img/element-icons/roomlist/notifications-off.svg"; interface IProps extends IContextMenuProps { room: Room; @@ -46,7 +50,7 @@ export const RoomNotificationContextMenu: React.FC = ({ room, onFinished } onClick={wrapHandler(() => setNotificationState(RoomNotifState.AllMessages))} /> ); @@ -55,7 +59,7 @@ export const RoomNotificationContextMenu: React.FC = ({ room, onFinished } onClick={wrapHandler(() => setNotificationState(RoomNotifState.AllMessagesLoud))} /> ); @@ -64,7 +68,7 @@ export const RoomNotificationContextMenu: React.FC = ({ room, onFinished } onClick={wrapHandler(() => setNotificationState(RoomNotifState.MentionsOnly))} /> ); @@ -73,7 +77,7 @@ export const RoomNotificationContextMenu: React.FC = ({ room, onFinished } onClick={wrapHandler(() => setNotificationState(RoomNotifState.Mute))} /> ); diff --git a/src/components/views/context_menus/SpaceContextMenu.tsx b/src/components/views/context_menus/SpaceContextMenu.tsx index eab9c1d011..49d8275ab6 100644 --- a/src/components/views/context_menus/SpaceContextMenu.tsx +++ b/src/components/views/context_menus/SpaceContextMenu.tsx @@ -8,6 +8,15 @@ Please see LICENSE files in the repository root for full details. import React, { type JSX, useContext } from "react"; import { type Room, EventType, RoomType } from "matrix-js-sdk/src/matrix"; +import { + HomeSolidIcon, + PlusIcon, + SettingsSolidIcon, + LeaveIcon, + SearchIcon, + PreferencesIcon, + UserAddIcon, +} from "@vector-im/compound-design-tokens/assets/web/icons"; import { type IProps as IContextMenuProps } from "../../structures/ContextMenu"; import IconizedContextMenu, { IconizedContextMenuOption, IconizedContextMenuOptionList } from "./IconizedContextMenu"; @@ -60,7 +69,7 @@ const SpaceContextMenu: React.FC = ({ space, hideHeader, onFinished, ... } label={_t("action|invite")} onClick={onInviteClick} /> @@ -81,7 +90,7 @@ const SpaceContextMenu: React.FC = ({ space, hideHeader, onFinished, ... settingsOption = ( } label={_t("common|settings")} onClick={onSettingsClick} /> @@ -98,7 +107,7 @@ const SpaceContextMenu: React.FC = ({ space, hideHeader, onFinished, ... leaveOption = ( } className="mx_IconizedContextMenu_option_red" label={_t("space|leave_dialog_action")} onClick={onLeaveClick} @@ -123,7 +132,7 @@ const SpaceContextMenu: React.FC = ({ space, hideHeader, onFinished, ... devtoolsOption = ( } label={_t("space|context_menu|devtools_open_timeline")} onClick={onViewTimelineClick} /> @@ -170,7 +179,7 @@ const SpaceContextMenu: React.FC = ({ space, hideHeader, onFinished, ... {canAddRooms && ( } label={_t("common|room")} onClick={onNewRoomClick} /> @@ -178,7 +187,7 @@ const SpaceContextMenu: React.FC = ({ space, hideHeader, onFinished, ... {canAddVideoRooms && ( } label={_t("common|video_room")} onClick={onNewVideoRoomClick} > @@ -188,7 +197,7 @@ const SpaceContextMenu: React.FC = ({ space, hideHeader, onFinished, ... {canAddSubSpaces && ( } label={_t("common|space")} onClick={onNewSubspaceClick} > @@ -234,18 +243,18 @@ const SpaceContextMenu: React.FC = ({ space, hideHeader, onFinished, ... {!hideHeader &&
    {space.name}
    } } label={_t("space|context_menu|home")} onClick={onHomeClick} /> {inviteOption} } label={canAddRooms ? _t("space|context_menu|manage_and_explore") : _t("space|context_menu|explore")} onClick={onExploreRoomsClick} /> } label={_t("common|preferences")} onClick={onPreferencesClick} /> diff --git a/src/components/views/context_menus/ThreadListContextMenu.tsx b/src/components/views/context_menus/ThreadListContextMenu.tsx index ef32d62282..11292f2a4f 100644 --- a/src/components/views/context_menus/ThreadListContextMenu.tsx +++ b/src/components/views/context_menus/ThreadListContextMenu.tsx @@ -8,6 +8,7 @@ Please see LICENSE files in the repository root for full details. import React, { useCallback, useEffect } from "react"; import { type MatrixEvent } from "matrix-js-sdk/src/matrix"; +import { LinkIcon } from "@vector-im/compound-design-tokens/assets/web/icons"; import { type ButtonEvent } from "../elements/AccessibleButton"; import dis from "../../../dispatcher/dispatcher"; @@ -20,6 +21,7 @@ import IconizedContextMenu, { IconizedContextMenuOption, IconizedContextMenuOpti import { WidgetLayoutStore } from "../../../stores/widgets/WidgetLayoutStore"; import { MatrixClientPeg } from "../../../MatrixClientPeg"; import { type ViewRoomPayload } from "../../../dispatcher/payloads/ViewRoomPayload"; +import { Icon as ViewInRoomIcon } from "../../../../res/img/element-icons/view-in-room.svg"; export interface ThreadListContextMenuProps { mxEvent: MatrixEvent; @@ -102,7 +104,7 @@ const ThreadListContextMenu: React.FC = ({ viewInRoom(e)} label={_t("timeline|mab|view_in_room")} - iconClassName="mx_ThreadPanel_viewInRoom" + icon={} /> )} {permalinkCreator && ( @@ -110,7 +112,7 @@ const ThreadListContextMenu: React.FC = ({ data-testid="copy-thread-link" onClick={(e) => copyLinkToThread(e)} label={_t("timeline|mab|copy_link_thread")} - iconClassName="mx_ThreadPanel_copyLinkToThread" + icon={} /> )} diff --git a/src/components/views/context_menus/WidgetContextMenu.tsx b/src/components/views/context_menus/WidgetContextMenu.tsx index 5b03d54e17..ff74e25d38 100644 --- a/src/components/views/context_menus/WidgetContextMenu.tsx +++ b/src/components/views/context_menus/WidgetContextMenu.tsx @@ -7,7 +7,7 @@ Please see LICENSE files in the repository root for full details. */ import React, { type JSX, type ComponentProps, useContext } from "react"; -import { type ClientWidgetApi, type IWidget, MatrixCapabilities } from "matrix-widget-api"; +import { type IWidget, MatrixCapabilities } from "matrix-widget-api"; import { logger } from "matrix-js-sdk/src/logger"; import { type ApprovalOpts, WidgetLifecycle } from "@matrix-org/react-sdk-module-api/lib/lifecycles/WidgetLifecycle"; import { type MatrixClient, type Room } from "matrix-js-sdk/src/matrix"; @@ -28,7 +28,7 @@ import MatrixClientContext from "../../../contexts/MatrixClientContext"; import { Container, WidgetLayoutStore } from "../../../stores/widgets/WidgetLayoutStore"; import { getConfigLivestreamUrl, startJitsiAudioLivestream } from "../../../Livestream"; import { ModuleRunner } from "../../../modules/ModuleRunner"; -import { ElementWidget } from "../../../stores/widgets/StopGapWidget"; +import { ElementWidget, type WidgetMessaging } from "../../../stores/widgets/WidgetMessaging"; import { useScopedRoomContext } from "../../../contexts/ScopedRoomContext.tsx"; interface IProps extends Omit, "children"> { @@ -69,10 +69,10 @@ const showDeleteButton = (canModify: boolean, onDeleteClick: undefined | (() => return !!onDeleteClick || canModify; }; -const showSnapshotButton = (widgetMessaging: ClientWidgetApi | undefined): boolean => { +const showSnapshotButton = (widgetMessaging: WidgetMessaging | undefined): boolean => { return ( SettingsStore.getValue("enableWidgetScreenshots") && - !!widgetMessaging?.hasCapability(MatrixCapabilities.Screenshots) + !!widgetMessaging?.widgetApi?.hasCapability(MatrixCapabilities.Screenshots) ); }; @@ -123,7 +123,7 @@ export const WidgetContextMenu: React.FC = ({ if (roomId && showStreamAudioStreamButton(app)) { const onStreamAudioClick = async (): Promise => { try { - await startJitsiAudioLivestream(cli, widgetMessaging!, roomId); + await startJitsiAudioLivestream(cli, widgetMessaging!.widgetApi!, roomId); } catch (err) { logger.error("Failed to start livestream", err); // XXX: won't i18n well, but looks like widget api only support 'message'? @@ -161,7 +161,7 @@ export const WidgetContextMenu: React.FC = ({ let snapshotButton: JSX.Element | undefined; if (showSnapshotButton(widgetMessaging)) { const onSnapshotClick = (): void => { - widgetMessaging + widgetMessaging?.widgetApi ?.takeScreenshot() .then((data) => { dis.dispatch({ diff --git a/src/components/views/dialogs/BaseDialog.tsx b/src/components/views/dialogs/BaseDialog.tsx index bf23919771..8eba4d3fc6 100644 --- a/src/components/views/dialogs/BaseDialog.tsx +++ b/src/components/views/dialogs/BaseDialog.tsx @@ -12,6 +12,7 @@ import React, { type JSX } from "react"; import FocusLock from "react-focus-lock"; import classNames from "classnames"; import { type MatrixClient } from "matrix-js-sdk/src/matrix"; +import { I18nContext } from "@element-hq/web-shared-components"; import AccessibleButton from "../elements/AccessibleButton"; import { MatrixClientPeg } from "../../../MatrixClientPeg"; @@ -23,13 +24,25 @@ import { getKeyBindingsManager } from "../../../KeyBindingsManager"; import { KeyBindingAction } from "../../../accessibility/KeyboardShortcuts"; interface IProps { - // Whether the dialog should have a 'close' button that will - // cause the dialog to be cancelled. This should only be set - // to false if there is nothing the app can sensibly do if the - // dialog is cancelled, eg. "We can't restore your session and - // the app cannot work". Default: true. + /** + * Whether the dialog should have a 'close' button and a keyDown handler which + * will intercept 'Escape'. + * + * This should only be set to `false` if there is nothing the app can sensibly do if the + * dialog is cancelled, eg. "We can't restore your session and + * the app cannot work". + * + * Default: `true`. + */ "hasCancel"?: boolean; + /** + * Callback that will be called when the 'close' button is clicked or 'Escape' is pressed. + * + * Not used if `hasCancel` is false. + */ + "onFinished"?: () => void; + // called when a key is pressed "onKeyDown"?: (e: KeyboardEvent | React.KeyboardEvent) => void; @@ -66,7 +79,6 @@ interface IProps { // optional Posthog ScreenName to supply during the lifetime of this dialog "screenName"?: ScreenName; - onFinished(): void; } /* @@ -103,13 +115,13 @@ export default class BaseDialog extends React.Component { e.stopPropagation(); e.preventDefault(); - this.props.onFinished(); + this.props.onFinished?.(); break; } }; private onCancelClick = (): void => { - this.props.onFinished(); + this.props.onFinished?.(); }; public render(): React.ReactNode { @@ -134,6 +146,9 @@ export default class BaseDialog extends React.Component { const lockProps: Record = { "onKeyDown": this.onKeyDown, "role": "dialog", + // Allow the dialog to be keyboard focusable + // So the escape key handling works in more cases (say you select the header) + "tabIndex": -1, // This should point to a node describing the dialog. // If we were about to completely follow this recommendation we'd need to // make all the components relying on BaseDialog to be aware of it. @@ -150,38 +165,42 @@ export default class BaseDialog extends React.Component { } return ( - - {this.props.screenName && } - - {this.props.top} -
    + + {this.props.screenName && } + - {!!(this.props.title || headerImage) && ( - - {headerImage} - {this.props.title} - - )} - {this.props.headerButton} -
    - {this.props.children} - {cancelButton} -
    -
    + {this.props.top} +
    + {!!(this.props.title || headerImage) && ( + + {headerImage} + {this.props.title} + + )} + {this.props.headerButton} +
    + {this.props.children} + {cancelButton} + + + ); } } diff --git a/src/components/views/dialogs/ConfirmKeyStorageOffDialog.tsx b/src/components/views/dialogs/ConfirmKeyStorageOffDialog.tsx index 49e7cad17b..d6a5f79aeb 100644 --- a/src/components/views/dialogs/ConfirmKeyStorageOffDialog.tsx +++ b/src/components/views/dialogs/ConfirmKeyStorageOffDialog.tsx @@ -17,6 +17,7 @@ import { EncryptionCardButtons } from "../settings/encryption/EncryptionCardButt import { type OpenToTabPayload } from "../../../dispatcher/payloads/OpenToTabPayload"; import { Action } from "../../../dispatcher/actions"; import { UserTab } from "./UserTab"; +import SdkConfig from "../../../SdkConfig"; interface Props { onFinished: (dismissed: boolean) => void; @@ -60,7 +61,7 @@ export default class ConfirmKeyStorageOffDialog extends React.Component { a: (sub) => ( <>
    - + {sub} diff --git a/src/components/views/dialogs/ConfirmSpaceUserActionDialog.tsx b/src/components/views/dialogs/ConfirmSpaceUserActionDialog.tsx index 3851460bdd..6d1bb38956 100644 --- a/src/components/views/dialogs/ConfirmSpaceUserActionDialog.tsx +++ b/src/components/views/dialogs/ConfirmSpaceUserActionDialog.tsx @@ -8,6 +8,7 @@ Please see LICENSE files in the repository root for full details. import React, { type JSX, type ComponentProps, useMemo, useState } from "react"; import { type Room } from "matrix-js-sdk/src/matrix"; +import { InfoSolidIcon } from "@vector-im/compound-design-tokens/assets/web/icons"; import ConfirmUserActionDialog from "./ConfirmUserActionDialog"; import SpaceStore from "../../../stores/spaces/SpaceStore"; @@ -47,7 +48,12 @@ const ConfirmSpaceUserActionDialog: React.FC = ({ let warning: JSX.Element | undefined; if (warningMessage) { - warning =
    {warningMessage}
    ; + warning = ( +
    + + {warningMessage} +
    + ); } return ( diff --git a/src/components/views/dialogs/CreateRoomDialog.tsx b/src/components/views/dialogs/CreateRoomDialog.tsx index 3ff42cde22..5321390fca 100644 --- a/src/components/views/dialogs/CreateRoomDialog.tsx +++ b/src/components/views/dialogs/CreateRoomDialog.tsx @@ -7,8 +7,16 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com Please see LICENSE files in the repository root for full details. */ -import React, { type JSX, type ChangeEvent, createRef, type KeyboardEvent, type SyntheticEvent } from "react"; +import React, { + type JSX, + type ChangeEvent, + createRef, + type KeyboardEvent, + type SyntheticEvent, + type ChangeEventHandler, +} from "react"; import { type Room, RoomType, JoinRule, Preset, Visibility } from "matrix-js-sdk/src/matrix"; +import { Form, SettingsToggleInput } from "@vector-im/compound-web"; import SdkConfig from "../../../SdkConfig"; import withValidation, { type IFieldState, type IValidationResult } from "../elements/Validation"; @@ -17,7 +25,6 @@ import { MatrixClientPeg } from "../../../MatrixClientPeg"; import { checkUserIsAllowedToChangeEncryption, type IOpts } from "../../../createRoom"; import Field from "../elements/Field"; import RoomAliasField from "../elements/RoomAliasField"; -import LabelledToggleSwitch from "../elements/LabelledToggleSwitch"; import DialogButtons from "../elements/DialogButtons"; import BaseDialog from "../dialogs/BaseDialog"; import JoinRuleDropdown from "../elements/JoinRuleDropdown"; @@ -25,7 +32,7 @@ import { getKeyBindingsManager } from "../../../KeyBindingsManager"; import { KeyBindingAction } from "../../../accessibility/KeyboardShortcuts"; import { privateShouldBeEncrypted } from "../../../utils/rooms"; import SettingsStore from "../../../settings/SettingsStore"; -import LabelledCheckbox from "../elements/LabelledCheckbox"; +import { UIFeature } from "../../../settings/UIFeature"; interface IProps { type?: RoomType; @@ -33,6 +40,7 @@ interface IProps { defaultName?: string; parentSpace?: Room; defaultEncrypted?: boolean; + defaultStateEncrypted?: boolean; onFinished(proceed?: false): void; onFinished(proceed: true, opts: IOpts): void; } @@ -51,6 +59,11 @@ interface IState { * Indicates whether end-to-end encryption is enabled for the room. */ isEncrypted: boolean; + /** + * Indicates whether end-to-end state encryption is enabled for this room. + * See MSC4362. Available if feature_msc4362_encrypted_state_events is enabled. + */ + isStateEncrypted: boolean; /** * The room name. */ @@ -83,6 +96,8 @@ interface IState { export default class CreateRoomDialog extends React.Component { private readonly askToJoinEnabled: boolean; + private readonly advancedSettingsEnabled: boolean; + private readonly allowCreatingPublicRooms: boolean; private readonly supportsRestricted: boolean; private nameField = createRef(); private aliasField = createRef(); @@ -91,10 +106,14 @@ export default class CreateRoomDialog extends React.Component { super(props); this.askToJoinEnabled = SettingsStore.getValue("feature_ask_to_join"); + this.advancedSettingsEnabled = SettingsStore.getValue(UIFeature.AdvancedSettings); + this.allowCreatingPublicRooms = SettingsStore.getValue(UIFeature.AllowCreatingPublicRooms); + this.supportsRestricted = !!this.props.parentSpace; + const defaultPublic = this.allowCreatingPublicRooms && this.props.defaultPublic; let joinRule = JoinRule.Invite; - if (this.props.defaultPublic) { + if (defaultPublic) { joinRule = JoinRule.Public; } else if (this.supportsRestricted) { joinRule = JoinRule.Restricted; @@ -102,8 +121,9 @@ export default class CreateRoomDialog extends React.Component { const cli = MatrixClientPeg.safeGet(); this.state = { - isPublicKnockRoom: this.props.defaultPublic || false, + isPublicKnockRoom: defaultPublic || false, isEncrypted: this.props.defaultEncrypted ?? privateShouldBeEncrypted(cli), + isStateEncrypted: this.props.defaultStateEncrypted ?? false, joinRule, name: this.props.defaultName || "", topic: "", @@ -119,7 +139,7 @@ export default class CreateRoomDialog extends React.Component { const opts: IOpts = {}; const createOpts: IOpts["createOpts"] = (opts.createOpts = {}); opts.roomType = this.props.type; - createOpts.name = this.state.name; + opts.name = this.state.name; if (this.state.joinRule === JoinRule.Public) { createOpts.visibility = Visibility.Public; @@ -128,11 +148,14 @@ export default class CreateRoomDialog extends React.Component { const { alias } = this.state; createOpts.room_alias_name = alias.substring(1, alias.indexOf(":")); } else { + const encryptedStateFeature = SettingsStore.getValue("feature_msc4362_encrypted_state_events", null, false); + opts.encryption = this.state.isEncrypted; + opts.stateEncryption = encryptedStateFeature && this.state.isStateEncrypted; } if (this.state.topic) { - createOpts.topic = this.state.topic; + opts.topic = this.state.topic; } if (this.state.noFederate) { createOpts.creation_content = { "m.federate": false }; @@ -219,8 +242,12 @@ export default class CreateRoomDialog extends React.Component { this.setState({ joinRule }); }; - private onEncryptedChange = (isEncrypted: boolean): void => { - this.setState({ isEncrypted }); + private onEncryptedChange: ChangeEventHandler = (evt): void => { + this.setState({ isEncrypted: evt.target.checked }); + }; + + private onStateEncryptedChange: ChangeEventHandler = (evt): void => { + this.setState({ isStateEncrypted: evt.target.checked }); }; private onAliasChange = (alias: string): void => { @@ -231,8 +258,8 @@ export default class CreateRoomDialog extends React.Component { this.setState({ detailsOpen: (ev.target as HTMLDetailsElement).open }); }; - private onNoFederateChange = (noFederate: boolean): void => { - this.setState({ noFederate }); + private onNoFederateChange: ChangeEventHandler = (evt): void => { + this.setState({ noFederate: evt.target.checked }); }; private onNameValidate = async (fieldState: IFieldState): Promise => { @@ -241,8 +268,8 @@ export default class CreateRoomDialog extends React.Component { return result; }; - private onIsPublicKnockRoomChange = (isPublicKnockRoom: boolean): void => { - this.setState({ isPublicKnockRoom }); + private onIsPublicKnockRoomChange: ChangeEventHandler = (evt): void => { + this.setState({ isPublicKnockRoom: evt.target.checked }); }; private static validateRoomName = withValidation({ @@ -329,11 +356,12 @@ export default class CreateRoomDialog extends React.Component { let visibilitySection: JSX.Element | undefined; if (this.state.joinRule === JoinRule.Knock) { visibilitySection = ( - ); } @@ -353,16 +381,37 @@ export default class CreateRoomDialog extends React.Component { microcopy = _t("settings|security|e2ee_default_disabled_warning"); } e2eeSection = ( - - -

    {microcopy}

    -
    + + ); + } + + let e2eeStateSection: JSX.Element | undefined; + if ( + SettingsStore.getValue("feature_msc4362_encrypted_state_events", null, false) && + this.state.joinRule !== JoinRule.Public + ) { + let microcopy: string; + if (!this.state.canChangeEncryption) { + microcopy = _t("create_room|encryption_forced"); + } else { + microcopy = _t("create_room|state_encrypted_warning"); + } + e2eeStateSection = ( + ); } @@ -392,8 +441,8 @@ export default class CreateRoomDialog extends React.Component { title={title} screenName="CreateRoom" > -
    -
    +
    + { className="mx_CreateRoomDialog_topic" /> - +
    + + + {publicPrivateLabel} +
    - {publicPrivateLabel} {visibilitySection} {e2eeSection} + {e2eeStateSection} {aliasField} -
    - - {this.state.detailsOpen ? _t("action|hide_advanced") : _t("action|show_advanced")} - - -

    {federateLabel}

    -
    -
    - + {this.advancedSettingsEnabled && ( +
    + + {this.state.detailsOpen ? _t("action|hide_advanced") : _t("action|show_advanced")} + + +
    + )} + +
    void; @@ -22,6 +21,14 @@ export const DeclineAndBlockInviteDialog: React.FunctionComponent = ({ o const [shouldReport, setShouldReport] = useState(false); const [ignoreUser, setIgnoreUser] = useState(false); + const onShouldReportChanged = useCallback>( + (e) => setShouldReport(e.target.checked), + [setShouldReport], + ); + const onIgnoreUserChanged = useCallback>( + (e) => setIgnoreUser(e.target.checked), + [setIgnoreUser], + ); const [reportReason, setReportReason] = useState(""); const reportReasonChanged = useCallback>( (e) => setReportReason(e.target.value), @@ -43,17 +50,19 @@ export const DeclineAndBlockInviteDialog: React.FunctionComponent = ({ o >

    {_t("decline_invitation_dialog|confirm", { roomName })}

    - -
    {isExporting ? (
    - +

    {exportProgressText}

    = (props: IProps) => { if (hasFeedback) { feedbackSection = (
    +

    {_t("feedback|comment_label")}

    {_t("feedback|platform_username")}

    @@ -111,7 +114,8 @@ const FeedbackDialog: React.FC = (props: IProps) => { title={_t("common|feedback")} description={ -
    +
    +

    {_t("common|report_a_bug")}

    {_t( diff --git a/src/components/views/dialogs/ForwardDialog.tsx b/src/components/views/dialogs/ForwardDialog.tsx index 512f0ad697..32aab5d6e9 100644 --- a/src/components/views/dialogs/ForwardDialog.tsx +++ b/src/components/views/dialogs/ForwardDialog.tsx @@ -57,6 +57,7 @@ import { import { getKeyBindingsManager } from "../../../KeyBindingsManager"; import { KeyBindingAction } from "../../../accessibility/KeyboardShortcuts"; import { OverflowTileView } from "../rooms/OverflowTileView"; +import { attachMentions } from "../../../utils/messages"; const AVATAR_SIZE = 30; @@ -178,7 +179,18 @@ const Entry: React.FC> = ({ room, type, content, matrixClient: ); }; -const transformEvent = (event: MatrixEvent): { type: string; content: IContent } => { +/** + * Transform content of a MatrixEvent before forwarding: + * 1. Strip all relations. + * 2. Convert location events into a static pin-drop location share, + * and remove description from self-location shares. + * 3. Pass through attachMentions() to strip mentions (as no EditorModel is present to recalculate from). + * + * @param event - The MatrixEvent to transform. + * @param userId - Current user MXID (passed through to attachMentions()). + * @returns The transformed event type and content. + */ +const transformEvent = (event: MatrixEvent, userId: string): { type: string; content: IContent } => { const { // eslint-disable-next-line @typescript-eslint/no-unused-vars "m.relates_to": _, // strip relations - in future we will attach a relation pointing at the original event @@ -213,6 +225,13 @@ const transformEvent = (event: MatrixEvent): { type: string; content: IContent } }; } + // Mentions can leak information about the context of the original message, + // so pass through attachMentions() to recalculate mentions. + // Currently, this strips all mentions (forces an empty m.mentions), + // as there is no EditorModel to parse pills from. + // Future improvements could actually recalculate mentions based on the message body. + attachMentions(userId, content, null, undefined); + return { type, content }; }; @@ -223,7 +242,7 @@ const ForwardDialog: React.FC = ({ matrixClient: cli, event, permalinkCr cli.getProfileInfo(userId).then((info) => setProfileInfo(info)); }, [cli, userId]); - const { type, content } = transformEvent(event); + const { type, content } = transformEvent(event, userId); // For the message preview we fake the sender as ourselves const mockEvent = new MatrixEvent({ diff --git a/src/components/views/dialogs/IntegrationsDisabledDialog.tsx b/src/components/views/dialogs/IntegrationsDisabledDialog.tsx index ad0f765413..93cd5f82c9 100644 --- a/src/components/views/dialogs/IntegrationsDisabledDialog.tsx +++ b/src/components/views/dialogs/IntegrationsDisabledDialog.tsx @@ -1,55 +1,53 @@ /* -Copyright 2024 New Vector Ltd. +Copyright 2024, 2025 New Vector Ltd. Copyright 2019 The Matrix.org Foundation C.I.C. SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial Please see LICENSE files in the repository root for full details. */ -import React from "react"; +import React, { useCallback } from "react"; import { _t } from "../../../languageHandler"; import dis from "../../../dispatcher/dispatcher"; import { Action } from "../../../dispatcher/actions"; import BaseDialog from "./BaseDialog"; import DialogButtons from "../elements/DialogButtons"; +import { UserTab } from "./UserTab"; interface IProps { onFinished(): void; } -export default class IntegrationsDisabledDialog extends React.Component { - private onAcknowledgeClick = (): void => { - this.props.onFinished(); - }; +export const IntegrationsDisabledDialog: React.FC = ({ onFinished }) => { + const onOpenSettingsClick = useCallback(() => { + onFinished(); + dis.dispatch({ + action: Action.ViewUserSettings, + initialTabId: UserTab.Security, + }); + }, [onFinished]); - private onOpenSettingsClick = (): void => { - this.props.onFinished(); - dis.fire(Action.ViewUserSettings); - }; - - public render(): React.ReactNode { - return ( - -

    -

    - {_t("integrations|disabled_dialog_description", { - manageIntegrations: _t("integration_manager|manage_title"), - })} -

    -
    - - - ); - } -} + return ( + +
    +

    + {_t("integrations|disabled_dialog_description", { + manageIntegrations: _t("integration_manager|manage_title"), + })} +

    +
    + +
    + ); +}; diff --git a/src/components/views/dialogs/InteractiveAuthDialog.tsx b/src/components/views/dialogs/InteractiveAuthDialog.tsx index d4ea7218c7..1aa3e7fe68 100644 --- a/src/components/views/dialogs/InteractiveAuthDialog.tsx +++ b/src/components/views/dialogs/InteractiveAuthDialog.tsx @@ -34,8 +34,10 @@ type DialogAesthetics = Partial<{ }; }>; -export interface InteractiveAuthDialogProps - extends Pick, "makeRequest" | "authData"> { +export interface InteractiveAuthDialogProps extends Pick< + InteractiveAuthProps, + "makeRequest" | "authData" +> { // matrix client to use for UI auth requests matrixClient: MatrixClient; diff --git a/src/components/views/dialogs/InviteDialog.tsx b/src/components/views/dialogs/InviteDialog.tsx index 62536acfd6..6f60f56fdb 100644 --- a/src/components/views/dialogs/InviteDialog.tsx +++ b/src/components/views/dialogs/InviteDialog.tsx @@ -6,14 +6,14 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com Please see LICENSE files in the repository root for full details. */ -import React, { type JSX, createRef, type ReactNode, type SyntheticEvent } from "react"; -import classNames from "classnames"; -import { RoomMember, type Room, MatrixError, EventType } from "matrix-js-sdk/src/matrix"; +import React, { createRef, type JSX, type ReactNode, type SyntheticEvent } from "react"; +import { EventType, MatrixError, type Room, RoomMember } from "matrix-js-sdk/src/matrix"; import { KnownMembership } from "matrix-js-sdk/src/types"; import { type MatrixCall } from "matrix-js-sdk/src/webrtc/call"; import { logger } from "matrix-js-sdk/src/logger"; import { uniqBy } from "lodash"; -import { CloseIcon } from "@vector-im/compound-design-tokens/assets/web/icons"; +import { RichList, RichItem, PillInput, Pill } from "@element-hq/web-shared-components"; +import { DialPadIcon, UserProfileSolidIcon } from "@vector-im/compound-design-tokens/assets/web/icons"; import { Icon as EmailPillAvatarIcon } from "../../../../res/img/icon-email-pill-avatar.svg"; import { _t, _td } from "../../../languageHandler"; @@ -25,7 +25,6 @@ import { getDefaultIdentityServerUrl, setToDefaultIdentityServer } from "../../. import { buildActivityScores, buildMemberScores, compareMembers } from "../../../utils/SortMembers"; import { abbreviateUrl } from "../../../utils/UrlUtils"; import IdentityAuthClient from "../../../IdentityAuthClient"; -import { humanizeTime } from "../../../utils/humanize"; import { type IInviteResult, inviteMultipleToRoom, showAnyInviteErrors } from "../../../RoomInvite"; import { Action } from "../../../dispatcher/actions"; import { DefaultTagID } from "../../../stores/room-list/models"; @@ -41,7 +40,6 @@ import Field from "../elements/Field"; import TabbedView, { Tab, TabLocation } from "../../structures/TabbedView"; import Dialpad from "../voip/DialPad"; import QuestionDialog from "./QuestionDialog"; -import Spinner from "../elements/Spinner"; import BaseDialog from "./BaseDialog"; import DialPadBackspaceButton from "../elements/DialPadBackspaceButton"; import LegacyCallHandler from "../../../LegacyCallHandler"; @@ -66,6 +64,7 @@ import { UNKNOWN_PROFILE_ERRORS } from "../../../utils/MultiInviter"; import AskInviteAnywayDialog, { type UnknownProfiles } from "./AskInviteAnywayDialog"; import { SdkContextClass } from "../../../contexts/SDKContext"; import { type UserProfilesStore } from "../../../stores/UserProfilesStore"; +import InviteProgressBody from "./InviteProgressBody.tsx"; // we have a number of types defined from the Matrix spec which can't reasonably be altered here. /* eslint-disable camelcase */ @@ -121,27 +120,10 @@ class DMUserTile extends React.PureComponent { const avatarSize = "20px"; const avatar = ; - let closeButton; - if (this.props.onRemove) { - closeButton = ( - - - - ); - } - return ( - - - {avatar} - {this.props.member.name} - - {closeButton} - + + {avatar} + ); } } @@ -164,7 +146,6 @@ interface IDMRoomTileProps { member: Member; lastActiveTs?: number; onToggle(member: Member): void; - highlightWord: string; isSelected: boolean; } @@ -177,54 +158,8 @@ class DMRoomTile extends React.PureComponent { this.props.onToggle(this.props.member); }; - private highlightName(str: string): ReactNode { - if (!this.props.highlightWord) return str; - - // We convert things to lowercase for index searching, but pull substrings from - // the submitted text to preserve case. Note: we don't need to htmlEntities the - // string because React will safely encode the text for us. - const lowerStr = str.toLowerCase(); - const filterStr = this.props.highlightWord.toLowerCase(); - - const result: JSX.Element[] = []; - - let i = 0; - let ii: number; - while ((ii = lowerStr.indexOf(filterStr, i)) >= 0) { - // Push any text we missed (first bit/middle of text) - if (ii > i) { - // Push any text we aren't highlighting (middle of text match, or beginning of text) - result.push({str.substring(i, ii)}); - } - - i = ii; // copy over ii only if we have a match (to preserve i for end-of-text matching) - - // Highlight the word the user entered - const substr = str.substring(i, filterStr.length + i); - result.push( - - {substr} - , - ); - i += substr.length; - } - - // Push any text we missed (end of text) - if (i < str.length) { - result.push({str.substring(i)}); - } - - return result; - } - public render(): React.ReactNode { - let timestamp: JSX.Element | undefined; - if (this.props.lastActiveTs) { - const humanTs = humanizeTime(this.props.lastActiveTs); - timestamp = {humanTs}; - } - - const avatarSize = "36px"; + const avatarSize = "32px"; const avatar = (this.props.member as ThreepidMember).isEmail ? ( ) : ( @@ -242,40 +177,23 @@ class DMRoomTile extends React.PureComponent { /> ); - let checkmark: JSX.Element | undefined; - if (this.props.isSelected) { - // To reduce flickering we put the 'selected' room tile above the real avatar - checkmark =
    ; - } - - // To reduce flickering we put the checkmark on top of the actual avatar (prevents - // the browser from reloading the image source when the avatar remounts). - const stackedAvatar = ( - - {avatar} - {checkmark} - - ); - const userIdentifier = UserIdentifierCustomisations.getDisplayUserIdentifier(this.props.member.userId, { withDisplayName: true, }); const caption = (this.props.member as ThreepidMember).isEmail ? _t("invite|email_caption") - : this.highlightName(userIdentifier || this.props.member.userId); + : userIdentifier || this.props.member.userId; return ( - - {stackedAvatar} - -
    - {this.highlightName(this.props.member.name)} -
    -
    {caption}
    -
    - {timestamp} -
    + ); } } @@ -330,8 +248,14 @@ interface IInviteDialogState { dialPadValue: string; currentTabId: TabId; - // These two flags are used for the 'Go' button to communicate what is going on. + /** + * True if we are sending the invites. + * + * We will grey out the action button, hide the suggestions, and display a spinner. + */ busy: boolean; + + /** Error from the last attempt to send invites. */ errorText?: string; } @@ -358,20 +282,22 @@ export default class InviteDialog extends React.PureComponent excludedIds.add(m.userId)); room.getMembersWithMembership(KnownMembership.Join).forEach((m) => excludedIds.add(m.userId)); // add banned users, so we don't try to invite them room.getMembersWithMembership(KnownMembership.Ban).forEach((m) => excludedIds.add(m.userId)); - if (isFederated === false) { + const ourHomeserver = cli.getDomain(); + if (isFederated === false && ourHomeserver) { + // If this room isn't federated, we must be on the same server. // exclude users from external servers - const homeserver = props.roomId.split(":")[1]; - this.excludeExternals(homeserver, excludedIds); + this.excludeExternals(ourHomeserver, excludedIds); } } @@ -385,7 +311,7 @@ export default class InviteDialog extends React.PureComponent b.lastActive - a.lastActive); @@ -616,7 +542,10 @@ export default class InviteDialog extends React.PureComponent { - // Stop the browser from highlighting text - e.preventDefault(); - e.stopPropagation(); - - if (this.editorRef && this.editorRef.current) { - this.editorRef.current.focus(); - } - }; - private onUseDefaultIdentityServerClick = (e: ButtonEvent): void => { e.preventDefault(); @@ -1038,8 +950,13 @@ export default class InviteDialog extends React.PureComponent -

    {sectionName}

    -

    {_t("common|no_results")}

    + + {_t("common|no_results")} +
    ); } @@ -1074,49 +991,48 @@ export default class InviteDialog extends React.PureComponent t.userId === r.userId)} /> )); + return (
    -

    {sectionName}

    - {tiles} + + {tiles} + {showMore}
    ); } private renderEditor(): JSX.Element { - const hasPlaceholder = - this.props.kind == InviteKind.CallTransfer && - this.state.targets.length === 0 && - this.state.filterText.length === 0; const targets = this.state.targets.map((t) => ( )); - const input = ( - 0) - } - autoComplete="off" - placeholder={hasPlaceholder ? _t("action|search") : undefined} - data-testid="invite-dialog-input" - /> - ); + return ( -
    + 0), + "data-testid": "invite-dialog-input", + }} + onRemoveChildren={() => + !this.state.busy && this.removeMember(this.state.targets[this.state.targets.length - 1]) + } + > {targets} - {input} -
    + ); } @@ -1258,30 +1174,84 @@ export default class InviteDialog extends React.PureComponent; + private hasSelection(): boolean { + return this.state.targets.length > 0 || (!!this.state.filterText && this.state.filterText.includes("@")); + } + + /** + * Render the "suggestions" section, which shows a list of people you might want to invite, together with any + * errors from the previous iteration. + */ + private renderSuggestions(): JSX.Element { + // If we're starting a DM, add a footer which showing our matrix.to link, for copying & pasting. + let footer; + if (this.props.kind === InviteKind.Dm) { + const link = makeUserPermalink(MatrixClientPeg.safeGet().getSafeUserId()); + footer = ( +
    +

    {_t("invite|send_link_prompt")}

    + makeUserPermalink(MatrixClientPeg.safeGet().getSafeUserId())}> + + {link} + + +
    + ); } - let title; + let results: React.ReactNode | null = null; + let onlyOneThreepidNote: React.ReactNode | null = null; + + if (!this.canInviteMore() || (this.hasFilterAtLeastOneEmail() && !this.canInviteThirdParty())) { + // We are in DM case here, because of the checks in canInviteMore() / canInviteThirdParty(). + // Show a note saying "Invites by email can only be sent one at a time". + onlyOneThreepidNote =
    {_t("invite|email_limit_one")}
    ; + } else { + let extraSection; + if (this.props.kind === InviteKind.Dm) { + // Some extra words saying "Some suggestions may be hidden for privacy" + extraSection = ( +
    + {_t("invite|suggestions_disclaimer")} +

    {_t("invite|suggestions_disclaimer_prompt")}

    +
    + ); + } + + results = ( +
    + {this.renderSection("recents")} + {this.renderSection("suggestions")} + {extraSection} +
    + ); + } + + return ( + + {this.renderIdentityServerWarning()} +
    {this.state.errorText}
    + {onlyOneThreepidNote} + {results} + {footer} +
    + ); + } + + /** + * Render content of the common "users" tab that is shown whether we have a regular invite dialog or a + * "CallTransfer" one. + */ + private renderMainTab(): JSX.Element { let helpText; let buttonText; let goButtonFn: (() => Promise) | null = null; - let consultConnectSection; - let extraSection; - let footer; const identityServersEnabled = SettingsStore.getValue(UIFeature.IdentityServer); - const hasSelection = - this.state.targets.length > 0 || (this.state.filterText && this.state.filterText.includes("@")); - const cli = MatrixClientPeg.safeGet(); const userId = cli.getUserId()!; if (this.props.kind === InviteKind.Dm) { - title = _t("space|add_existing_room_space|dm_heading"); - if (identityServersEnabled) { helpText = _t( "invite|start_conversation_name_email_mxid_prompt", @@ -1314,34 +1284,10 @@ export default class InviteDialog extends React.PureComponent - {_t("invite|suggestions_disclaimer")} -

    {_t("invite|suggestions_disclaimer_prompt")}

    -
    - ); - const link = makeUserPermalink(MatrixClientPeg.safeGet().getSafeUserId()); - footer = ( -
    -

    {_t("invite|send_link_prompt")}

    - makeUserPermalink(MatrixClientPeg.safeGet().getSafeUserId())}> - - {link} - - -
    - ); } else if (this.props.kind === InviteKind.Invite) { const roomId = this.props.roomId; const room = MatrixClientPeg.get()?.getRoom(roomId); const isSpace = room?.isSpaceRoom(); - title = isSpace - ? _t("invite|to_space", { - spaceName: room?.name || _t("common|unnamed_space"), - }) - : _t("invite|to_room", { - roomName: room?.name || _t("common|unnamed_room"), - }); let helpTextUntranslated; if (isSpace) { @@ -1382,31 +1328,6 @@ export default class InviteDialog extends React.PureComponent - - - {_t("action|cancel")} - - - {_t("action|transfer")} - -
    - ); } const goButton = @@ -1415,134 +1336,166 @@ export default class InviteDialog extends React.PureComponent {buttonText} ); - let results: React.ReactNode | null = null; - let onlyOneThreepidNote: React.ReactNode | null = null; - - if (!this.canInviteMore() || (this.hasFilterAtLeastOneEmail() && !this.canInviteThirdParty())) { - // We are in DM case here, because of the checks in canInviteMore() / canInviteThirdParty(). - onlyOneThreepidNote =
    {_t("invite|email_limit_one")}
    ; - } else { - results = ( -
    - {this.renderSection("recents")} - {this.renderSection("suggestions")} - {extraSection} -
    - ); - } - - const usersSection = ( + return (

    {helpText}

    {this.renderEditor()} -
    - {goButton} - {spinner} -
    + {goButton}
    - {this.renderIdentityServerWarning()} -
    {this.state.errorText}
    - {onlyOneThreepidNote} - {results} - {footer} + {this.state.busy ? : this.renderSuggestions()}
    ); + } - let dialogContent; - if (this.props.kind === InviteKind.CallTransfer) { - const tabs: NonEmptyArray> = [ - new Tab( - TabId.UserDirectory, - _td("invite|transfer_user_directory_tab"), - "mx_InviteDialog_userDirectoryIcon", - usersSection, - ), - ]; - - const backspaceButton = ; - - // Only show the backspace button if the field has content - let dialPadField; - if (this.state.dialPadValue.length !== 0) { - dialPadField = ( - - ); - } else { - dialPadField = ( - - ); - } - - const dialPadSection = ( -
    -
    {dialPadField}
    - -
    - ); - tabs.push( - new Tab( - TabId.DialPad, - _td("invite|transfer_dial_pad_tab"), - "mx_InviteDialog_dialPadIcon", - dialPadSection, - ), - ); - dialogContent = ( - - - tabs={tabs} - activeTabId={this.state.currentTabId} - tabLocation={TabLocation.TOP} - onChange={this.onTabChange} - /> - {consultConnectSection} - - ); - } else { - dialogContent = ( - - {usersSection} - {consultConnectSection} - - ); + /** + * Render the complete dialog, given this is not a call transfer dialog. + * + * See also: {@link renderCallTransferDialog}. + */ + private renderRegularDialog(): React.ReactNode { + let title; + if (this.props.kind === InviteKind.Dm) { + title = _t("space|add_existing_room_space|dm_heading"); + } else if (this.props.kind === InviteKind.Invite) { + const roomId = this.props.roomId; + const room = MatrixClientPeg.get()?.getRoom(roomId); + const isSpace = room?.isSpaceRoom(); + title = isSpace + ? _t("invite|to_space", { + spaceName: room?.name || _t("common|unnamed_space"), + }) + : _t("invite|to_room", { + roomName: room?.name || _t("common|unnamed_room"), + }); } return ( +
    {this.renderMainTab()}
    +
    + ); + } + + /** + * Render the complete call transfer dialog. + * + * See also: {@link renderRegularDialog}. + */ + private renderCallTransferDialog(): React.ReactNode { + const usersSection = this.renderMainTab(); + + const tabs: NonEmptyArray> = [ + new Tab( + TabId.UserDirectory, + _td("invite|transfer_user_directory_tab"), + , + usersSection, + ), + ]; + + const backspaceButton = ; + + // Only show the backspace button if the field has content + let dialPadField; + if (this.state.dialPadValue.length !== 0) { + dialPadField = ( + + ); + } else { + dialPadField = ( + + ); + } + + const dialPadSection = ( +
    +
    {dialPadField}
    + +
    + ); + tabs.push(new Tab(TabId.DialPad, _td("invite|transfer_dial_pad_tab"), , dialPadSection)); + + const consultConnectSection = ( +
    + + + {_t("action|cancel")} + + + {_t("action|transfer")} + +
    + ); + + const dialogContent = ( + + + tabs={tabs} + activeTabId={this.state.currentTabId} + tabLocation={TabLocation.TOP} + onChange={this.onTabChange} + /> + {consultConnectSection} + + ); + + return ( +
    {dialogContent}
    ); } + + public render(): React.ReactNode { + if (this.props.kind === InviteKind.CallTransfer) { + return this.renderCallTransferDialog(); + } else { + return this.renderRegularDialog(); + } + } } diff --git a/src/components/views/dialogs/InviteProgressBody.tsx b/src/components/views/dialogs/InviteProgressBody.tsx new file mode 100644 index 0000000000..a61c0d5922 --- /dev/null +++ b/src/components/views/dialogs/InviteProgressBody.tsx @@ -0,0 +1,24 @@ +/* +Copyright 2025 New Vector Ltd. + +SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial +Please see LICENSE files in the repository root for full details. +*/ + +import React from "react"; + +import InlineSpinner from "../elements/InlineSpinner"; +import { _t } from "../../../languageHandler"; + +/** The common body of components that show the progress of sending room invites. */ +const InviteProgressBody: React.FC = () => { + return ( +
    + +

    {_t("invite|progress|preparing")}

    + {_t("invite|progress|dont_close")} +
    + ); +}; + +export default InviteProgressBody; diff --git a/src/components/views/dialogs/InviteProgressDialog.tsx b/src/components/views/dialogs/InviteProgressDialog.tsx new file mode 100644 index 0000000000..fe62afa8d8 --- /dev/null +++ b/src/components/views/dialogs/InviteProgressDialog.tsx @@ -0,0 +1,38 @@ +/* +Copyright 2025 New Vector Ltd. + +SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial +Please see LICENSE files in the repository root for full details. +*/ + +import React from "react"; + +import Modal from "../../../Modal.tsx"; +import InviteProgressBody from "./InviteProgressBody.tsx"; + +/** A Modal dialog that pops up while room invites are being sent. */ +const InviteProgressDialog: React.FC = (_) => { + return ; +}; + +/** + * Open the invite progress dialog. + * + * Returns a callback which will close the dialog again. + */ +export function openInviteProgressDialog(): () => void { + const onBeforeClose = async (reason?: string): Promise => { + // Inhibit closing via background click + return reason != "backgroundClick"; + }; + + const { close } = Modal.createDialog( + InviteProgressDialog, + /* props */ {}, + /* className */ undefined, + /* isPriorityModal */ false, + /* isStaticModal */ false, + { onBeforeClose }, + ); + return close; +} diff --git a/src/components/views/dialogs/LeaveSpaceDialog.tsx b/src/components/views/dialogs/LeaveSpaceDialog.tsx index e81606db79..37838292ff 100644 --- a/src/components/views/dialogs/LeaveSpaceDialog.tsx +++ b/src/components/views/dialogs/LeaveSpaceDialog.tsx @@ -8,6 +8,7 @@ Please see LICENSE files in the repository root for full details. import React, { useMemo, useState } from "react"; import { type Room, JoinRule } from "matrix-js-sdk/src/matrix"; +import { InfoSolidIcon } from "@vector-im/compound-design-tokens/assets/web/icons"; import { _t } from "../../../languageHandler"; import DialogButtons from "../elements/DialogButtons"; @@ -15,23 +16,13 @@ import BaseDialog from "../dialogs/BaseDialog"; import SpaceStore from "../../../stores/spaces/SpaceStore"; import SpaceChildrenPicker from "../spaces/SpaceChildrenPicker"; import { filterBoolean } from "../../../utils/arrays"; +import { isOnlyAdmin } from "../../../utils/membership"; interface IProps { space: Room; onFinished(leave: boolean, rooms?: Room[]): void; } -const isOnlyAdmin = (room: Room): boolean => { - const userId = room.client.getSafeUserId(); - if (room.getMember(userId)?.powerLevelNorm !== 100) { - return false; // user is not an admin - } - return room.getJoinedMembers().every((member) => { - // return true if every other member has a lower power level (we are highest) - return member.userId === userId || member.powerLevelNorm < 100; - }); -}; - const LeaveSpaceDialog: React.FC = ({ space, onFinished }) => { const spaceChildren = useMemo(() => { const roomSet = new Set(SpaceStore.instance.getSpaceFilteredRoomIds(space.roomId)); @@ -98,7 +89,12 @@ const LeaveSpaceDialog: React.FC = ({ space, onFinished }) => { /> )} - {onlyAdminWarning &&
    {onlyAdminWarning}
    } + {onlyAdminWarning && ( +
    + + {onlyAdminWarning} +
    + )}
    { }; private onSetRecoveryMethodClick = (): void => { - if (this.state.backupStatus === BackupStatus.SERVER_BACKUP_BUT_DISABLED) { - // A key backup exists for this account, but the creating device is not - // verified, so restore the backup which will give us the keys from it and - // allow us to trust it (ie. upload keys to it) - Modal.createDialog( - RestoreKeyBackupDialog, - undefined, - undefined, - /* priority = */ false, - /* static = */ true, - ); - } else { - Modal.createDialog( - lazy(() => import("../../../async-components/views/dialogs/security/CreateKeyBackupDialog")), - undefined, - undefined, - /* priority = */ false, - /* static = */ true, - ); - } + // Open the user settings dialog to the encryption tab and start the flow to reset encryption + const payload: OpenToTabPayload = { + action: Action.ViewUserSettings, + initialTabId: UserTab.Encryption, + }; + dis.dispatch(payload); // close dialog this.props.onFinished(true); @@ -190,22 +178,13 @@ export default class LogoutDialog extends React.Component {
    ); - let setupButtonCaption; - if (this.state.backupStatus === BackupStatus.SERVER_BACKUP_BUT_DISABLED) { - setupButtonCaption = _t("settings|security|key_backup_connect"); - } else { - // if there's an error fetching the backup info, we'll just assume there's - // no backup for the purpose of the button caption - setupButtonCaption = _t("auth|logout_dialog|use_key_backup"); - } - const dialogContent = (
    {description}
    = ({ room, selected = [], if (newSelected.size < 1) { inviteOnlyWarning = (
    + {_t("room_settings|security|join_rule_restricted_dialog_empty_warning")}
    ); @@ -183,7 +185,8 @@ const ManageRestrictedJoinRuleDialog: React.FC = ({ room, selected = [],

    {_t("room_settings|security|join_rule_restricted_dialog_heading_other")}

    -
    {_t("room_settings|security|join_rule_restricted_dialog_heading_unknown")}
    + + {_t("room_settings|security|join_rule_restricted_dialog_heading_unknown")}
    {filteredOtherEntries.map((space) => { return ( diff --git a/src/components/views/dialogs/ManualDeviceKeyVerificationDialog.tsx b/src/components/views/dialogs/ManualDeviceKeyVerificationDialog.tsx new file mode 100644 index 0000000000..566061f73b --- /dev/null +++ b/src/components/views/dialogs/ManualDeviceKeyVerificationDialog.tsx @@ -0,0 +1,171 @@ +/* +Copyright 2024-2025 New Vector Ltd. +Copyright 2020 The Matrix.org Foundation C.I.C. +Copyright 2019 New Vector Ltd +Copyright 2019 Michael Telatynski <7t3chguy@gmail.com> +Copyright 2017 Vector Creations Ltd +Copyright 2016 OpenMarket Ltd + +SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only +Please see LICENSE files in the repository root for full details. +*/ + +import React, { type ChangeEvent, type JSX, useCallback, useState } from "react"; +import { type MatrixClient } from "matrix-js-sdk/src/matrix"; + +import { _t, UserFriendlyError } from "../../../languageHandler"; +import { getDeviceCryptoInfo } from "../../../utils/crypto/deviceInfo"; +import QuestionDialog from "./QuestionDialog"; +import Modal from "../../../Modal"; +import InfoDialog from "./InfoDialog"; +import Field from "../elements/Field"; +import ErrorDialog from "./ErrorDialog"; +import { MatrixClientPeg } from "../../../MatrixClientPeg"; + +interface Props { + onFinished(confirm?: boolean): void; +} + +/** + * A dialog to allow us to verify devices logged in with clients that can't do + * the verification themselves. Intended for use as a dev tool. + * + * Requires entering the fingerprint ("session key") of the device in an attempt + * to prevent users being tricked into verifying a malicious device. + */ +export function ManualDeviceKeyVerificationDialog({ onFinished }: Readonly): JSX.Element { + const [deviceId, setDeviceId] = useState(""); + const [fingerprint, setFingerprint] = useState(""); + + const client = MatrixClientPeg.safeGet(); + + const onDialogFinished = useCallback( + async (confirm: boolean) => { + if (confirm) { + await manuallyVerifyDevice(client, deviceId, fingerprint); + } + onFinished(confirm); + }, + [client, deviceId, fingerprint, onFinished], + ); + + const onDeviceIdChange = useCallback((e: ChangeEvent) => { + setDeviceId(e.target.value); + }, []); + + const onFingerprintChange = useCallback((e: ChangeEvent) => { + setFingerprint(e.target.value); + }, []); + + const body = ( +
    +

    {_t("encryption|verification|manual|text")}

    +
    + + +
    +
    + ); + + return ( + + ); +} + +/** + * Check the supplied fingerprint matches the fingerprint ("session key") of the + * device with the supplied device ID, and if so, mark the device as verified. + */ +export async function manuallyVerifyDevice(client: MatrixClient, deviceId: string, fingerprint: string): Promise { + try { + await doManuallyVerifyDevice(client, deviceId, fingerprint); + + // Tell the user we verified everything + Modal.createDialog(InfoDialog, { + title: _t("encryption|verification|manual|success_title"), + description: ( +
    +

    {_t("encryption|verification|manual|success_description", { deviceId })}

    +
    + ), + }); + } catch (e: any) { + // Display an error + const error = e instanceof UserFriendlyError ? e.translatedMessage : e.toString(); + Modal.createDialog(ErrorDialog, { + title: _t("encryption|verification|manual|failure_title"), + description: ( +
    +

    {_t("encryption|verification|manual|failure_description", { deviceId, error })}

    +
    + ), + }); + } +} + +async function doManuallyVerifyDevice(client: MatrixClient, deviceId: string, fingerprint: string): Promise { + const userId = client.getUserId(); + if (!userId) { + throw new UserFriendlyError("encryption|verification|manual|no_userid", { + cause: undefined, + }); + } + + const crypto = client.getCrypto(); + if (!crypto) { + throw new UserFriendlyError("encryption|verification|manual|no_crypto"); + } + + const device = await getDeviceCryptoInfo(client, userId, deviceId); + if (!device) { + throw new UserFriendlyError("encryption|verification|manual|no_device", { + deviceId, + cause: undefined, + }); + } + const deviceTrust = await crypto.getDeviceVerificationStatus(userId, deviceId); + + if (deviceTrust?.isVerified()) { + if (device.getFingerprint() === fingerprint) { + throw new UserFriendlyError("encryption|verification|manual|already_verified", { + deviceId, + cause: undefined, + }); + } else { + throw new UserFriendlyError("encryption|verification|manual|already_verified_and_wrong_fingerprint", { + deviceId, + cause: undefined, + }); + } + } + + if (device.getFingerprint() !== fingerprint) { + const fprint = device.getFingerprint(); + throw new UserFriendlyError("encryption|verification|manual|wrong_fingerprint", { + fprint, + deviceId, + fingerprint, + cause: undefined, + }); + } + + // We've passed all the checks - do the device verification + await crypto.crossSignDevice(deviceId); +} diff --git a/src/components/views/dialogs/ModalWidgetDialog.tsx b/src/components/views/dialogs/ModalWidgetDialog.tsx index 250a438c13..c0af2b632b 100644 --- a/src/components/views/dialogs/ModalWidgetDialog.tsx +++ b/src/components/views/dialogs/ModalWidgetDialog.tsx @@ -27,11 +27,11 @@ import { ErrorIcon } from "@vector-im/compound-design-tokens/assets/web/icons"; import BaseDialog from "./BaseDialog"; import { _t, getUserLanguage } from "../../../languageHandler"; import AccessibleButton, { type AccessibleButtonKind } from "../elements/AccessibleButton"; -import { StopGapWidgetDriver } from "../../../stores/widgets/StopGapWidgetDriver"; +import { ElementWidgetDriver } from "../../../stores/widgets/ElementWidgetDriver"; import { MatrixClientPeg } from "../../../MatrixClientPeg"; import { OwnProfileStore } from "../../../stores/OwnProfileStore"; import { arrayFastClone } from "../../../utils/arrays"; -import { ElementWidget } from "../../../stores/widgets/StopGapWidget"; +import { ElementWidget } from "../../../stores/widgets/WidgetMessaging"; import { ELEMENT_CLIENT_ID } from "../../../identifiers"; import ThemeWatcher, { ThemeWatcherEvent } from "../../../settings/watchers/ThemeWatcher"; @@ -72,7 +72,7 @@ export default class ModalWidgetDialog extends React.PureComponent = function ({ roomId, onFinished const client = MatrixClientPeg.safeGet(); const onReasonChange = useCallback>((e) => setReason(e.target.value), []); + const onLeaveRoomChanged = useCallback>( + (e) => setLeaveRoom(e.target.checked), + [setLeaveRoom], + ); const onCancel = useCallback(() => onFinished(false), [onFinished]); const onSubmit = useCallback(async () => { setBusy(true); @@ -78,10 +89,11 @@ export const ReportRoomDialog: React.FC = function ({ roomId, onFinished {adminMessage} {busy ? : null} - { new Tab( RoomSettingsTab.General, _td("common|general"), - "mx_RoomSettingsDialog_settingsIcon", + , , "RoomSettingsGeneral", ), @@ -139,7 +150,7 @@ class RoomSettingsDialog extends React.Component { new Tab( RoomSettingsTab.People, _td("common|people"), - "mx_RoomSettingsDialog_peopleIcon", + , , ), ); @@ -149,7 +160,7 @@ class RoomSettingsDialog extends React.Component { new Tab( RoomSettingsTab.Voip, _td("settings|voip|title"), - "mx_RoomSettingsDialog_voiceIcon", + , , ), ); @@ -158,7 +169,7 @@ class RoomSettingsDialog extends React.Component { new Tab( RoomSettingsTab.Security, _td("room_settings|security|title"), - "mx_RoomSettingsDialog_securityIcon", + , this.props.onFinished(true)} />, "RoomSettingsSecurityPrivacy", ), @@ -167,7 +178,7 @@ class RoomSettingsDialog extends React.Component { new Tab( RoomSettingsTab.Roles, _td("room_settings|permissions|title"), - "mx_RoomSettingsDialog_rolesIcon", + , , "RoomSettingsRolesPermissions", ), @@ -176,13 +187,11 @@ class RoomSettingsDialog extends React.Component { new Tab( RoomSettingsTab.Notifications, _td("notifications|enable_prompt_toast_title"), - "mx_RoomSettingsDialog_notificationsIcon", - ( - this.props.onFinished(true)} - /> - ), + , + this.props.onFinished(true)} + />, "RoomSettingsNotifications", ), ); @@ -192,7 +201,7 @@ class RoomSettingsDialog extends React.Component { new Tab( RoomSettingsTab.Bridges, _td("room_settings|bridges|title"), - "mx_RoomSettingsDialog_bridgesIcon", + , , "RoomSettingsBridges", ), @@ -203,7 +212,7 @@ class RoomSettingsDialog extends React.Component { new Tab( RoomSettingsTab.PollHistory, _td("right_panel|polls_button"), - "mx_RoomSettingsDialog_pollsIcon", + , this.props.onFinished(true)} />, ), ); @@ -213,13 +222,11 @@ class RoomSettingsDialog extends React.Component { new Tab( RoomSettingsTab.Advanced, _td("common|advanced"), - "mx_RoomSettingsDialog_warningIcon", - ( - this.props.onFinished(true)} - /> - ), + , + this.props.onFinished(true)} + />, "RoomSettingsAdvanced", ), ); diff --git a/src/components/views/dialogs/RoomUpgradeWarningDialog.tsx b/src/components/views/dialogs/RoomUpgradeWarningDialog.tsx index 4eeb6677f1..caaf41eaaa 100644 --- a/src/components/views/dialogs/RoomUpgradeWarningDialog.tsx +++ b/src/components/views/dialogs/RoomUpgradeWarningDialog.tsx @@ -6,12 +6,12 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com Please see LICENSE files in the repository root for full details. */ -import React, { type JSX, type ReactNode, type SyntheticEvent } from "react"; +import React, { type ChangeEventHandler, type JSX, type ReactNode, type SyntheticEvent } from "react"; import { EventType, JoinRule } from "matrix-js-sdk/src/matrix"; +import { Form, SettingsToggleInput } from "@vector-im/compound-web"; import { _t } from "../../../languageHandler"; import SdkConfig from "../../../SdkConfig"; -import LabelledToggleSwitch from "../elements/LabelledToggleSwitch"; import { MatrixClientPeg } from "../../../MatrixClientPeg"; import Modal from "../../../Modal"; import BugReportDialog from "./BugReportDialog"; @@ -87,8 +87,8 @@ export default class RoomUpgradeWarningDialog extends React.Component { - this.setState({ inviteUsersToNewRoom }); + private onInviteUsersToggle: ChangeEventHandler = (evt): void => { + this.setState({ inviteUsersToNewRoom: evt.target.checked }); }; private openBugReportDialog = (e: SyntheticEvent): void => { @@ -104,11 +104,19 @@ export default class RoomUpgradeWarningDialog extends React.Component + { + evt.preventDefault(); + evt.stopPropagation(); + }} + > + + ); } diff --git a/src/components/views/dialogs/ServerOfflineDialog.tsx b/src/components/views/dialogs/ServerOfflineDialog.tsx index 45a1e80714..f403ecb790 100644 --- a/src/components/views/dialogs/ServerOfflineDialog.tsx +++ b/src/components/views/dialogs/ServerOfflineDialog.tsx @@ -52,7 +52,7 @@ export default class ServerOfflineDialog extends React.PureComponent { const entries = c.transactions .filter((t) => t.status === TransactionStatus.Error || t.didPreviouslyFail) .map((t, j) => { - let button = ; + let button = ; if (t.status === TransactionStatus.Error) { button = ( t.run()}> diff --git a/src/components/views/dialogs/SlashCommandHelpDialog.tsx b/src/components/views/dialogs/SlashCommandHelpDialog.tsx index 819d28513e..0ac1a0de0d 100644 --- a/src/components/views/dialogs/SlashCommandHelpDialog.tsx +++ b/src/components/views/dialogs/SlashCommandHelpDialog.tsx @@ -13,14 +13,20 @@ import { type Command, CommandCategories, Commands } from "../../../SlashCommand import InfoDialog from "./InfoDialog"; import { MatrixClientPeg } from "../../../MatrixClientPeg"; +/** + * Props for {@link SlashCommandHelpDialog} + * @param roomId - The room ID to check whether commands are enabled + * @param onFinished - Callback called when the dialog is closed + */ interface IProps { + roomId: string; onFinished(): void; } -const SlashCommandHelpDialog: React.FC = ({ onFinished }) => { +const SlashCommandHelpDialog: React.FC = ({ roomId, onFinished }) => { const categories: Record = {}; Commands.forEach((cmd) => { - if (!cmd.isEnabled(MatrixClientPeg.get())) return; + if (!cmd.isEnabled(MatrixClientPeg.get(), roomId)) return; if (!categories[cmd.category]) { categories[cmd.category] = []; } diff --git a/src/components/views/dialogs/SpacePreferencesDialog.tsx b/src/components/views/dialogs/SpacePreferencesDialog.tsx index 3bb3416524..69fa897ec3 100644 --- a/src/components/views/dialogs/SpacePreferencesDialog.tsx +++ b/src/components/views/dialogs/SpacePreferencesDialog.tsx @@ -8,6 +8,7 @@ Please see LICENSE files in the repository root for full details. import React, { type ChangeEvent } from "react"; import { type Room } from "matrix-js-sdk/src/matrix"; +import { VisibilityOnIcon } from "@vector-im/compound-design-tokens/assets/web/icons"; import { _t, _td } from "../../../languageHandler"; import BaseDialog from "../dialogs/BaseDialog"; @@ -63,7 +64,7 @@ const SpacePreferencesDialog: React.FC = ({ space, onFinished }) => { new Tab( SpacePreferenceTab.Appearance, _td("common|appearance"), - "mx_SpacePreferencesDialog_appearanceIcon", + , , ), ]; diff --git a/src/components/views/dialogs/SpaceSettingsDialog.tsx b/src/components/views/dialogs/SpaceSettingsDialog.tsx index 797622a271..336dee0c87 100644 --- a/src/components/views/dialogs/SpaceSettingsDialog.tsx +++ b/src/components/views/dialogs/SpaceSettingsDialog.tsx @@ -8,6 +8,7 @@ Please see LICENSE files in the repository root for full details. import React, { useMemo } from "react"; import { type Room, type MatrixClient } from "matrix-js-sdk/src/matrix"; +import { AdminIcon, SettingsSolidIcon, VisibilityOnIcon } from "@vector-im/compound-design-tokens/assets/web/icons"; import { _t, _td } from "../../../languageHandler"; import BaseDialog from "./BaseDialog"; @@ -22,6 +23,7 @@ import AdvancedRoomSettingsTab from "../settings/tabs/room/AdvancedRoomSettingsT import RolesRoomSettingsTab from "../settings/tabs/room/RolesRoomSettingsTab"; import { Action } from "../../../dispatcher/actions"; import { type NonEmptyArray } from "../../../@types/common"; +import { Icon as AdvancedIcon } from "../../../../res/img/element-icons/room/settings/advanced.svg"; export enum SpaceSettingsTab { General = "SPACE_GENERAL_TAB", @@ -48,26 +50,26 @@ const SpaceSettingsDialog: React.FC = ({ matrixClient: cli, space, onFin new Tab( SpaceSettingsTab.General, _td("common|general"), - "mx_SpaceSettingsDialog_generalIcon", + , , ), new Tab( SpaceSettingsTab.Visibility, _td("room_settings|visibility|title"), - "mx_SpaceSettingsDialog_visibilityIcon", + , , ), new Tab( SpaceSettingsTab.Roles, _td("room_settings|permissions|title"), - "mx_RoomSettingsDialog_rolesIcon", + , , ), SettingsStore.getValue(UIFeature.AdvancedSettings) ? new Tab( SpaceSettingsTab.Advanced, _td("common|advanced"), - "mx_RoomSettingsDialog_warningIcon", + , , ) : null, diff --git a/src/components/views/dialogs/UploadConfirmDialog.tsx b/src/components/views/dialogs/UploadConfirmDialog.tsx index 36a05733a3..820b047c38 100644 --- a/src/components/views/dialogs/UploadConfirmDialog.tsx +++ b/src/components/views/dialogs/UploadConfirmDialog.tsx @@ -11,7 +11,6 @@ import React, { type JSX } from "react"; import { FilesIcon } from "@vector-im/compound-design-tokens/assets/web/icons"; import { _t } from "../../../languageHandler"; -import { getBlobSafeMimeType } from "../../../utils/blobs"; import BaseDialog from "./BaseDialog"; import DialogButtons from "../elements/DialogButtons"; import { fileSize } from "../../../utils/FileUtils"; @@ -23,10 +22,11 @@ interface IProps { onFinished: (uploadConfirmed: boolean, uploadAll?: boolean) => void; } -export default class UploadConfirmDialog extends React.Component { - private readonly objectUrl: string; - private readonly mimeType: string; +interface IState { + objectUrl?: string; +} +export default class UploadConfirmDialog extends React.Component { public static defaultProps: Partial = { totalFiles: 1, currentIndex: 0, @@ -35,15 +35,22 @@ export default class UploadConfirmDialog extends React.Component { public constructor(props: IProps) { super(props); - // Create a fresh `Blob` for previewing (even though `File` already is - // one) so we can adjust the MIME type if needed. - this.mimeType = getBlobSafeMimeType(props.file.type); - const blob = new Blob([props.file], { type: this.mimeType }); - this.objectUrl = URL.createObjectURL(blob); + this.state = {}; + } + + public componentDidMount(): void { + if (this.props.file.type.startsWith("image/") || this.props.file.type.startsWith("video/")) { + this.setState({ + // We do not filter the mimetype using getBlobSafeMimeType here as if the user is uploading the file + // themselves they should be trusting it enough to open/load it, and it will be rendered into a hidden + // canvas for thumbnail generation anyway + objectUrl: URL.createObjectURL(this.props.file), + }); + } } public componentWillUnmount(): void { - if (this.objectUrl) URL.revokeObjectURL(this.objectUrl); + if (this.state.objectUrl) URL.revokeObjectURL(this.state.objectUrl); } private onCancelClick = (): void => { @@ -70,17 +77,23 @@ export default class UploadConfirmDialog extends React.Component { } const fileId = `mx-uploadconfirmdialog-${this.props.file.name}`; + const mimeType = this.props.file.type; + let preview: JSX.Element | undefined; let placeholder: JSX.Element | undefined; - if (this.mimeType.startsWith("image/")) { + if (mimeType.startsWith("image/")) { preview = ( - + ); - } else if (this.mimeType.startsWith("video/")) { + } else if (mimeType.startsWith("video/")) { preview = (
    ; + // The entered key is not (yet) correct. Tell them so. + validationText = _t("encryption|access_secret_storage_dialog|key_validation_text|wrong_security_key"); + classes = classNames({ + "mx_AccessSecretStorageDialog_recoveryKeyFeedback": true, + "mx_AccessSecretStorageDialog_recoveryKeyFeedback--invalid": true, + }); } + + return ( + + {validationText} + + ); } public render(): React.ReactNode { @@ -178,17 +180,14 @@ export default class AccessSecretStorageDialog extends React.PureComponent
    -
    @@ -205,15 +204,19 @@ export default class AccessSecretStorageDialog extends React.PureComponent ); + // We wrap the content in `BaseDialog` mostly so that we get a `FocusLock` container; otherwise, if the + // SettingsDialog is open, then the `FocusLock` in *that* stops us getting the focus. return ( - - {content} - + + + {content} + + ); } } diff --git a/src/components/views/dialogs/security/InitialCryptoSetupDialog.tsx b/src/components/views/dialogs/security/InitialCryptoSetupDialog.tsx index bda09f02a6..a4c9e3a4c8 100644 --- a/src/components/views/dialogs/security/InitialCryptoSetupDialog.tsx +++ b/src/components/views/dialogs/security/InitialCryptoSetupDialog.tsx @@ -16,23 +16,21 @@ import Spinner from "../../elements/Spinner"; import { InitialCryptoSetupStore, useInitialCryptoSetupStatus } from "../../../../stores/InitialCryptoSetupStore"; interface Props { - onFinished: (success?: boolean) => void; + /** Callback which is called if the crypto setup failed, and the user clicked the 'cancel' button */ + onCancelled: () => void; } -/* - * Walks the user through the process of creating a cross-signing keys. +/** + * Walks the user through the process of creating cross-signing keys. + * * In most cases, only a spinner is shown, but for more * complex auth like SSO, the user may need to complete some steps to proceed. */ -export const InitialCryptoSetupDialog: React.FC = ({ onFinished }) => { +export const InitialCryptoSetupDialog: React.FC = ({ onCancelled }) => { const onRetryClick = useCallback(() => { InitialCryptoSetupStore.sharedInstance().retry(); }, []); - const onCancelClick = useCallback(() => { - onFinished(false); - }, [onFinished]); - const status = useInitialCryptoSetupStatus(InitialCryptoSetupStore.sharedInstance()); let content; @@ -44,7 +42,7 @@ export const InitialCryptoSetupDialog: React.FC = ({ onFinished }) => {
    @@ -60,7 +58,6 @@ export const InitialCryptoSetupDialog: React.FC = ({ onFinished }) => { return ( { - private store: SetupEncryptionStore; +export default class SetupEncryptionDialog extends React.Component { public constructor(props: IProps) { super(props); - - this.store = SetupEncryptionStore.sharedInstance(); - this.state = { icon: iconFromPhase(this.store.phase) }; } - public componentDidMount(): void { - this.store.on("update", this.onStoreUpdate); - } - - public componentWillUnmount(): void { - this.store.removeListener("update", this.onStoreUpdate); - } - - private onStoreUpdate = (): void => { - this.setState({ icon: iconFromPhase(this.store.phase) }); - }; - public render(): React.ReactNode { return ( - + ); diff --git a/src/components/views/dialogs/spotlight/RoomResultContextMenus.tsx b/src/components/views/dialogs/spotlight/RoomResultContextMenus.tsx index f7fd5c6cb5..8bf5c1e22b 100644 --- a/src/components/views/dialogs/spotlight/RoomResultContextMenus.tsx +++ b/src/components/views/dialogs/spotlight/RoomResultContextMenus.tsx @@ -6,9 +6,9 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com Please see LICENSE files in the repository root for full details. */ -import classNames from "classnames"; import { type Room } from "matrix-js-sdk/src/matrix"; -import React, { type JSX, Fragment, useState } from "react"; +import React, { type JSX, Fragment, useState, type ReactNode } from "react"; +import { OverflowHorizontalIcon } from "@vector-im/compound-design-tokens/assets/web/icons"; import { ContextMenuTooltipButton } from "../../../../accessibility/context_menu/ContextMenuTooltipButton"; import { useNotificationState } from "../../../../hooks/useRoomNotificationState"; @@ -21,11 +21,25 @@ import { type ButtonEvent } from "../../elements/AccessibleButton"; import { contextMenuBelow } from "../../rooms/RoomTile"; import { shouldShowComponent } from "../../../../customisations/helpers/UIComponents"; import { UIComponent } from "../../../../settings/UIFeature"; +import { Icon as NotificationsIcon } from "../../../../../res/img/element-icons/notifications.svg"; +import { Icon as NotificationsDefaultIcon } from "../../../../../res/img/element-icons/roomlist/notifications-default.svg"; +import { Icon as NotificationsDmIcon } from "../../../../../res/img/element-icons/roomlist/notifications-dm.svg"; +import { Icon as NotificationsOffIcon } from "../../../../../res/img/element-icons/roomlist/notifications-off.svg"; interface Props { room: Room; } +export function getNotificationIcon(state: RoomNotifState): ReactNode { + const icons: Record = { + [RoomNotifState.AllMessages]: , + [RoomNotifState.AllMessagesLoud]: , + [RoomNotifState.MentionsOnly]: , + [RoomNotifState.Mute]: , + }; + return icons[state]; +} + export function RoomResultContextMenus({ room }: Props): JSX.Element { const [notificationState] = useNotificationState(room); @@ -64,14 +78,6 @@ export function RoomResultContextMenus({ room }: Props): JSX.Element { ); } - const notificationMenuClasses = classNames("mx_SpotlightDialog_option--notifications", { - // Show bell icon for the default case too. - mx_RoomNotificationContextMenu_iconBell: notificationState === RoomNotifState.AllMessages, - mx_RoomNotificationContextMenu_iconBellDot: notificationState === RoomNotifState.AllMessagesLoud, - mx_RoomNotificationContextMenu_iconBellMentions: notificationState === RoomNotifState.MentionsOnly, - mx_RoomNotificationContextMenu_iconBellCrossed: notificationState === RoomNotifState.Mute, - }); - return ( {shouldShowComponent(UIComponent.RoomOptionsMenu) && ( @@ -86,11 +92,13 @@ export function RoomResultContextMenus({ room }: Props): JSX.Element { }} title={room.isSpaceRoom() ? _t("space|context_menu|options") : _t("room|context_menu|title")} isExpanded={generalMenuPosition !== null} - /> + > + + )} {!room.isSpaceRoom() && ( { ev.preventDefault(); ev.stopPropagation(); @@ -100,7 +108,9 @@ export function RoomResultContextMenus({ room }: Props): JSX.Element { }} title={_t("room_list|notification_options")} isExpanded={notificationMenuPosition !== null} - /> + > + {getNotificationIcon(notificationState!)} + )} {generalMenu} {notificationMenu} diff --git a/src/components/views/dialogs/spotlight/SpotlightDialog.tsx b/src/components/views/dialogs/spotlight/SpotlightDialog.tsx index 419a12df89..f4e33ce155 100644 --- a/src/components/views/dialogs/spotlight/SpotlightDialog.tsx +++ b/src/components/views/dialogs/spotlight/SpotlightDialog.tsx @@ -53,7 +53,7 @@ import { getKeyBindingsManager } from "../../../../KeyBindingsManager"; import { _t } from "../../../../languageHandler"; import { MatrixClientPeg } from "../../../../MatrixClientPeg"; import { PosthogAnalytics } from "../../../../PosthogAnalytics"; -import { getCachedRoomIDForAlias } from "../../../../RoomAliasCache"; +import { getCachedRoomIdForAlias } from "../../../../RoomAliasCache"; import { showStartChatInviteDialog } from "../../../../RoomInvite"; import { SettingLevel } from "../../../../settings/SettingLevel"; import SettingsStore from "../../../../settings/SettingsStore"; @@ -912,7 +912,7 @@ const SpotlightDialog: React.FC = ({ initialText = "", initialFilter = n if ( trimmedQuery.startsWith("#") && trimmedQuery.includes(":") && - (!getCachedRoomIDForAlias(trimmedQuery) || !cli.getRoom(getCachedRoomIDForAlias(trimmedQuery))) + (!getCachedRoomIdForAlias(trimmedQuery) || !cli.getRoom(getCachedRoomIdForAlias(trimmedQuery)!.roomId)) ) { joinRoomSection = (
    @@ -1144,7 +1144,7 @@ const SpotlightDialog: React.FC = ({ initialText = "", initialFilter = n if (rovingContext.state.activeNode && rovingContext.state.nodes.length > 0) { let nodes = rovingContext.state.nodes; - if (!query && !filter !== null) { + if (!query && filter === null) { // If the current selection is not in the recently viewed row then only include the // first recently viewed so that is the target when the user is switching into recently viewed. const keptRecentlyViewedRef = nodeIsForRecentlyViewed(rovingContext.state.activeNode) @@ -1164,7 +1164,7 @@ const SpotlightDialog: React.FC = ({ initialText = "", initialFilter = n // only handle these keys when we are in the recently viewed row of options if ( !query && - !filter !== null && + filter === null && rovingContext.state.activeNode && rovingContext.state.nodes.length > 0 && nodeIsForRecentlyViewed(rovingContext.state.activeNode) @@ -1226,8 +1226,8 @@ const SpotlightDialog: React.FC = ({ initialText = "", initialFilter = n <> - {!filter !== null && !query && } - {!filter !== null && !query && } + {filter === null && !query && } + {filter === null && !query && } ), }, @@ -1279,7 +1279,7 @@ const SpotlightDialog: React.FC = ({ initialText = "", initialFilter = n aria-label={_t("action|search")} aria-describedby="mx_SpotlightDialog_keyboardPrompt" /> - {(publicRoomsLoading || peopleLoading || profileLoading) && } + {(publicRoomsLoading || peopleLoading || profileLoading) && }
    = ({ protocols, config, setConfig className="mx_NetworkDropdown_removeServer" title={_t("spotlight|public_rooms|network_dropdown_remove_server_adornment", { roomServer })} onClick={() => setUserDefinedServers(without(userDefinedServers, roomServer))} - /> + > + + ), } : {}), @@ -200,9 +203,8 @@ export const NetworkDropdown: React.FC = ({ protocols, config, setConfig ({ closeMenu }: AdditionalOptionsProps) => ( <> - => { closeMenu(); const { finished } = Modal.createDialog( @@ -229,13 +231,9 @@ export const NetworkDropdown: React.FC = ({ protocols, config, setConfig }); } }} - > -
    - - {_t("spotlight|public_rooms|network_dropdown_add_server_option")} - -
    -
    + isSelected={false} + label={_t("spotlight|public_rooms|network_dropdown_add_server_option")} + /> ), [allServers, setConfig, setUserDefinedServers, userDefinedServers], diff --git a/src/components/views/elements/AppTile.tsx b/src/components/views/elements/AppTile.tsx index cfb95abcc3..c56a23e43d 100644 --- a/src/components/views/elements/AppTile.tsx +++ b/src/components/views/elements/AppTile.tsx @@ -18,7 +18,7 @@ import React, { type ReactNode, } from "react"; import classNames from "classnames"; -import { type IWidget, MatrixCapabilities } from "matrix-widget-api"; +import { type IWidget, MatrixCapabilities, type ClientWidgetApi } from "matrix-widget-api"; import { type Room, RoomEvent } from "matrix-js-sdk/src/matrix"; import { KnownMembership } from "matrix-js-sdk/src/types"; import { logger } from "matrix-js-sdk/src/logger"; @@ -28,6 +28,7 @@ import { MinusIcon, ExpandIcon, CollapseIcon, + PopOutIcon, } from "@vector-im/compound-design-tokens/assets/web/icons"; import AccessibleButton from "./AccessibleButton"; @@ -41,12 +42,11 @@ import SettingsStore from "../../../settings/SettingsStore"; import { aboveLeftOf, ContextMenuButton } from "../../structures/ContextMenu"; import PersistedElement, { getPersistKey } from "./PersistedElement"; import { WidgetType } from "../../../widgets/WidgetType"; -import { ElementWidget, StopGapWidget } from "../../../stores/widgets/StopGapWidget"; +import { ElementWidget, WidgetMessaging, WidgetMessagingEvent } from "../../../stores/widgets/WidgetMessaging"; import { showContextMenu, WidgetContextMenu } from "../context_menus/WidgetContextMenu"; import WidgetAvatar from "../avatars/WidgetAvatar"; import LegacyCallHandler from "../../../LegacyCallHandler"; import { type IApp, isAppWidget } from "../../../stores/WidgetStore"; -import { Icon as PopoutIcon } from "../../../../res/img/external-link.svg"; import { Container, WidgetLayoutStore } from "../../../stores/widgets/WidgetLayoutStore"; import { OwnProfileStore } from "../../../stores/OwnProfileStore"; import { UPDATE_EVENT } from "../../../stores/AsyncStore"; @@ -62,6 +62,20 @@ import { parseUrl } from "../../../utils/UrlUtils"; import RightPanelStore from "../../../stores/right-panel/RightPanelStore.ts"; import { RightPanelPhases } from "../../../stores/right-panel/RightPanelStorePhases.ts"; +// Note that there is advice saying allow-scripts shouldn't be used with allow-same-origin +// because that would allow the iframe to programmatically remove the sandbox attribute, but +// this would only be for content hosted on the same origin as the element client: anything +// hosted on the same origin as the client will get the same access as if you clicked +// a link to it. +const sandboxFlags = + "allow-forms allow-popups allow-popups-to-escape-sandbox " + + "allow-same-origin allow-scripts allow-presentation allow-downloads"; + +// Additional iframe feature permissions +// (see - https://sites.google.com/a/chromium.org/dev/Home/chromium-security/deprecating-permissions-in-cross-origin-iframes and https://wicg.github.io/feature-policy/) +const iframeFeatures = + "microphone; camera; encrypted-media; autoplay; display-capture; clipboard-write; clipboard-read;"; + interface IProps { app: IWidget | IApp; // If room is not specified then it is an account level widget @@ -137,11 +151,12 @@ export default class AppTile extends React.Component { showLayoutButtons: true, }; + private readonly widget: ElementWidget; private contextMenuButton = createRef(); - private iframe?: HTMLIFrameElement; // ref to the iframe (callback style) + private iframeParent: HTMLElement | null = null; // parent div of the iframe private allowedWidgetsWatchRef?: string; private persistKey: string; - private sgWidget?: StopGapWidget; + private messaging?: WidgetMessaging; private dispatcherRef?: string; private unmounted = false; @@ -150,11 +165,16 @@ export default class AppTile extends React.Component { // The key used for PersistedElement this.persistKey = getPersistKey(WidgetUtils.getWidgetUid(this.props.app)); - try { - this.sgWidget = new StopGapWidget(this.props); - } catch (e) { - logger.log("Failed to construct widget", e); - this.sgWidget = undefined; + + this.widget = new ElementWidget(props.app); + this.messaging = WidgetMessagingStore.instance.getMessaging(this.widget, props.room?.roomId); + if (this.messaging === undefined) { + try { + this.messaging = new WidgetMessaging(this.widget, props); + WidgetMessagingStore.instance.storeMessaging(this.widget, props.room?.roomId, this.messaging); + } catch (e) { + logger.error("Failed to construct widget", e); + } } this.state = this.getNewState(props); @@ -221,11 +241,11 @@ export default class AppTile extends React.Component { private determineInitialRequiresClientState(): boolean { try { - const mockWidget = new ElementWidget(this.props.app); - const widgetApi = WidgetMessagingStore.instance.getMessaging(mockWidget, this.props.room?.roomId); - if (widgetApi) { + const widget = new ElementWidget(this.props.app); + const messaging = WidgetMessagingStore.instance.getMessaging(widget, this.props.room?.roomId); + if (messaging?.widgetApi) { // Load value from existing API to prevent resetting the requiresClient value on layout changes. - return widgetApi.hasCapability(ElementWidgetCapabilities.RequiresClient); + return messaging.widgetApi.hasCapability(ElementWidgetCapabilities.RequiresClient); } } catch { // fallback to true @@ -277,7 +297,7 @@ export default class AppTile extends React.Component { isAppWidget(this.props.app) ? this.props.app.roomId : null, ); PersistedElement.destroyElement(this.persistKey); - this.sgWidget?.stopMessaging(); + this.messaging?.stop(); } this.setState({ hasPermissionToLoad }); @@ -311,12 +331,12 @@ export default class AppTile extends React.Component { ); } - if (this.sgWidget) { - this.setupSgListeners(); + if (this.messaging) { + this.setupMessagingListeners(); } // Only fetch IM token on mount if we're showing and have permission to load - if (this.sgWidget && this.state.hasPermissionToLoad) { + if (this.messaging && this.state.hasPermissionToLoad) { this.startWidget(); } this.watchUserReady(); @@ -362,56 +382,101 @@ export default class AppTile extends React.Component { OwnProfileStore.instance.removeListener(UPDATE_EVENT, this.onUserReady); } - private setupSgListeners(): void { - this.sgWidget?.on("ready", this.onWidgetReady); - this.sgWidget?.on("error:preparing", this.updateRequiresClient); - // emits when the capabilities have been set up or changed - this.sgWidget?.on("capabilitiesNotified", this.updateRequiresClient); + private setupMessagingListeners(): void { + this.messaging?.on(WidgetMessagingEvent.Start, this.onMessagingStart); + this.messaging?.on(WidgetMessagingEvent.Stop, this.onMessagingStop); } - private stopSgListeners(): void { - if (!this.sgWidget) return; - this.sgWidget?.off("ready", this.onWidgetReady); - this.sgWidget.off("error:preparing", this.updateRequiresClient); - this.sgWidget.off("capabilitiesNotified", this.updateRequiresClient); + private stopMessagingListeners(): void { + this.messaging?.off(WidgetMessagingEvent.Start, this.onMessagingStart); + this.messaging?.off(WidgetMessagingEvent.Stop, this.onMessagingStop); } + private readonly onMessagingStart = (widgetApi: ClientWidgetApi): void => { + widgetApi.on("ready", this.onWidgetReady); + widgetApi.on("error:preparing", this.updateRequiresClient); + // emits when the capabilities have been set up or changed + widgetApi.on("capabilitiesNotified", this.updateRequiresClient); + }; + + private readonly onMessagingStop = (widgetApi: ClientWidgetApi): void => { + widgetApi.off("ready", this.onWidgetReady); + widgetApi.off("error:preparing", this.updateRequiresClient); + widgetApi.off("capabilitiesNotified", this.updateRequiresClient); + }; + private resetWidget(newProps: IProps): void { - this.sgWidget?.stopMessaging(); - this.stopSgListeners(); + this.messaging?.stop(); + this.stopMessagingListeners(); try { - this.sgWidget = new StopGapWidget(newProps); - this.setupSgListeners(); + WidgetMessagingStore.instance.stopMessaging(this.widget, this.props.room?.roomId); + this.messaging = new WidgetMessaging(this.widget, newProps); + WidgetMessagingStore.instance.storeMessaging(this.widget, this.props.room?.roomId, this.messaging); + this.setupMessagingListeners(); this.startWidget(); } catch (e) { logger.error("Failed to construct widget", e); - this.sgWidget = undefined; + this.messaging = undefined; } } private startWidget(): void { - this.sgWidget?.prepare().then(() => { + this.messaging?.prepare().then(() => { if (this.unmounted) return; this.setState({ initialising: false }); }); } - private startMessaging(): void { - try { - this.sgWidget?.startMessaging(this.iframe!); - } catch (e) { - logger.error("Failed to start widget", e); - } - } - - private iframeRefChange = (ref: HTMLIFrameElement): void => { - this.iframe = ref; + /** + * A callback ref receiving the current parent div of the iframe. This is + * responsible for creating the iframe and starting or resetting + * communication with the widget. + */ + private iframeParentRef = (element: HTMLElement | null): void => { if (this.unmounted) return; - if (ref) { - this.startMessaging(); - } else { + // Detach the existing iframe (if any) from the document so we know not + // to do anything further with it, like starting up the messaging + this.iframeParent?.querySelector("iframe")?.remove(); + this.iframeParent = element; + + if (this.iframeParent === null) { + // The component is trying to unmount the iframe. We could reach + // this path if the widget definition was updated, for example. The + // iframe parent will later be remounted and widget communications + // reopened after this.state.initializing resets to false. this.resetWidget(this.props); + } else if ( + this.messaging && + // Check whether an iframe already exists (it totally could exist, + // seeing as it is a persisted element which might have hopped + // between React components) + this.iframeParent.querySelector("iframe") === null + ) { + // We create the iframe ourselves rather than leaving the job to React, + // because we need the lifetime of the messaging and the iframe to be + // the same; we don't want strict mode, for instance, to cause the + // messaging to restart (lose its state) without also killing the widget + const iframe = document.createElement("iframe"); + iframe.title = WidgetUtils.getWidgetName(this.props.app); + iframe.allow = iframeFeatures; + iframe.src = this.messaging.embedUrl; + iframe.allowFullscreen = true; + iframe.sandbox = sandboxFlags; + this.iframeParent.appendChild(iframe); + // In order to start the widget messaging we need iframe.contentWindow + // to exist. Waiting until the next layout gives the browser a chance to + // initialize it. + requestAnimationFrame(() => { + // Handle the race condition (seen in strict mode) where the element is + // added and then removed from the DOM before we enter this callback + if (iframe.parentElement === null) return; + try { + this.messaging?.start(iframe); + } catch (e) { + logger.error("Failed to start widget", e); + } + }); } }; @@ -426,44 +491,31 @@ export default class AppTile extends React.Component { /** * Ends all widget interaction, such as cancelling calls and disabling webcams. - * @private - * @returns {Promise<*>} Resolves when the widget is terminated, or timeout passed. */ - private async endWidgetActions(): Promise { - // widget migration dev note: async to maintain signature - // HACK: This is a really dirty way to ensure that Jitsi cleans up - // its hold on the webcam. Without this, the widget holds a media - // stream open, even after death. See https://github.com/vector-im/element-web/issues/7351 - if (this.iframe) { - // In practice we could just do `+= ''` to trick the browser - // into thinking the URL changed, however I can foresee this - // being optimized out by a browser. Instead, we'll just point - // the iframe at a page that is reasonably safe to use in the - // event the iframe doesn't wink away. - // This is relative to where the Element instance is located. - this.iframe.src = "about:blank"; - } - + private endWidgetActions(): void { if (WidgetType.JITSI.matches(this.props.app.type) && this.props.room) { LegacyCallHandler.instance.hangupCallApp(this.props.room.roomId); } // Delete the widget from the persisted store for good measure. + // XXX: This removes persistent elements from the DOM entirely, which feels like + // a lot more than this function claims to be doing. PersistedElement.destroyElement(this.persistKey); ActiveWidgetStore.instance.destroyPersistentWidget( this.props.app.id, isAppWidget(this.props.app) ? this.props.app.roomId : null, ); - this.sgWidget?.stopMessaging({ forceDestroy: true }); + this.messaging?.stop({ forceDestroy: true }); } + private onWidgetReady = (): void => { this.setState({ loading: false }); }; private updateRequiresClient = (): void => { this.setState({ - requiresClient: !!this.sgWidget?.widgetApi?.hasCapability(ElementWidgetCapabilities.RequiresClient), + requiresClient: !!this.messaging?.widgetApi?.hasCapability(ElementWidgetCapabilities.RequiresClient), }); }; @@ -472,7 +524,7 @@ export default class AppTile extends React.Component { case "m.sticker": if ( payload.widgetId === this.props.app.id && - this.sgWidget?.widgetApi?.hasCapability(MatrixCapabilities.StickerSending) + this.messaging?.widgetApi?.hasCapability(MatrixCapabilities.StickerSending) ) { dis.dispatch({ action: "post_sticker_message", @@ -544,7 +596,7 @@ export default class AppTile extends React.Component { return ( -

    {name}

    +

    {name}

    {title ? titleSpacer : ""} {title} @@ -554,16 +606,11 @@ export default class AppTile extends React.Component { } private reload(): void { - this.endWidgetActions().then(() => { - // reset messaging - this.resetWidget(this.props); - this.startMessaging(); - - if (this.iframe && this.sgWidget) { - // Reload iframe - this.iframe.src = this.sgWidget.embedUrl; - } - }); + this.endWidgetActions(); + // reset messaging + this.resetWidget(this.props); + this.iframeParent?.querySelector("iframe")?.remove(); + // iframeParent will now be re-mounted, at which point startMessaging will be called } // TODO replace with full screen interactions @@ -577,7 +624,7 @@ export default class AppTile extends React.Component { // window.open(this._getPopoutUrl(), '_blank', 'noopener=yes'); Object.assign(document.createElement("a"), { target: "_blank", - href: this.sgWidget?.popoutUrl, + href: this.messaging?.popoutUrl, rel: "noreferrer noopener", }).click(); }; @@ -621,20 +668,6 @@ export default class AppTile extends React.Component { public render(): React.ReactNode { let appTileBody: JSX.Element | undefined; - // Note that there is advice saying allow-scripts shouldn't be used with allow-same-origin - // because that would allow the iframe to programmatically remove the sandbox attribute, but - // this would only be for content hosted on the same origin as the element client: anything - // hosted on the same origin as the client will get the same access as if you clicked - // a link to it. - const sandboxFlags = - "allow-forms allow-popups allow-popups-to-escape-sandbox " + - "allow-same-origin allow-scripts allow-presentation allow-downloads"; - - // Additional iframe feature permissions - // (see - https://sites.google.com/a/chromium.org/dev/Home/chromium-security/deprecating-permissions-in-cross-origin-iframes and https://wicg.github.io/feature-policy/) - const iframeFeatures = - "microphone; camera; encrypted-media; autoplay; display-capture; clipboard-write; " + "clipboard-read;"; - const appTileBodyClass = classNames({ "mx_AppTileBody": true, "mx_AppTileBody--large": !this.props.miniMode, @@ -654,15 +687,13 @@ export default class AppTile extends React.Component {
    ); - const widgetTitle = WidgetUtils.getWidgetName(this.props.app); - - if (this.sgWidget === null) { + if (this.messaging === null) { appTileBody = (
    ); - } else if (!this.state.hasPermissionToLoad && this.props.room && this.sgWidget) { + } else if (!this.state.hasPermissionToLoad && this.props.room && this.messaging) { // only possible for room widgets, can assert this.props.room here const isEncrypted = this.context.isRoomEncrypted(this.props.room.roomId); appTileBody = ( @@ -670,7 +701,7 @@ export default class AppTile extends React.Component { @@ -689,19 +720,11 @@ export default class AppTile extends React.Component { ); - } else if (this.sgWidget) { + } else if (this.messaging) { appTileBody = ( <> -
    +
    {this.state.loading && loadingElement} -