diff --git a/.eslintrc.js b/.eslintrc.js index 892d7cdbb1..26865d55ec 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -30,6 +30,10 @@ module.exports = { ["window.innerHeight", "window.innerWidth", "window.visualViewport"], "Use UIStore to access window dimensions instead.", ), + ...buildRestrictedPropertiesOptions( + ["React.forwardRef", "*.forwardRef", "forwardRef"], + "Use ref props instead.", + ), ...buildRestrictedPropertiesOptions( ["*.mxcUrlToHttp", "*.getHttpUriForMxc"], "Use Media helper instead to centralise access for customisation.", @@ -55,6 +59,11 @@ module.exports = { "error", { paths: [ + { + name: "react", + importNames: ["forwardRef"], + message: "Use ref props instead.", + }, { name: "@testing-library/react", message: "Please use jest-matrix-react instead", diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 21a6b0e7ab..68b9f4e703 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -43,9 +43,9 @@ jobs: run: shell: bash steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 - - uses: actions/setup-node@v4 + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 with: # Disable cache on Windows as it is slower than not caching # https://github.com/actions/setup-node/issues/975 @@ -77,7 +77,7 @@ jobs: yarn build - name: Upload Artifact - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 with: name: webapp-${{ matrix.image }} path: webapp diff --git a/.github/workflows/build_debian.yaml b/.github/workflows/build_debian.yaml index f46678512a..247e5604ee 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@v4 + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 - 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@v4 + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 with: name: element-web.deb path: element-web.deb diff --git a/.github/workflows/build_develop.yml b/.github/workflows/build_develop.yml index bbb52de5a2..9550bf9139 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@v4 + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 - - uses: actions/setup-node@v4 + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 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@v4 + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 with: name: webapp path: dist/develop.tar.gz diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 14fbd22086..8b52e6764c 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@v4 + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 - name: Load GPG key run: | diff --git a/.github/workflows/docker.yaml b/.github/workflows/docker.yaml index 1cf24facde..75a00af7aa 100644 --- a/.github/workflows/docker.yaml +++ b/.github/workflows/docker.yaml @@ -20,12 +20,12 @@ jobs: env: TEST_TAG: vectorim/element-web:test steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 with: fetch-depth: 0 # needed for docker-package to be able to calculate the version - name: Install Cosign - uses: sigstore/cosign-installer@d7d6bc7722e3daa8354c50bcb52f4837da5e9b6a # v3 + uses: sigstore/cosign-installer@3454372f43399081ed03b604cb2d021dabca52bb # v3 if: github.event_name != 'pull_request' - name: Set up QEMU @@ -132,7 +132,7 @@ jobs: cosign sign --yes ${images} - name: Update repo description - uses: peter-evans/dockerhub-description@e98e4d1628a5f3be2be7c231e50981aee98723ae # v4 + uses: peter-evans/dockerhub-description@432a30c9e07499fd01da9f8a49f0faf9e0ca5b77 # v4 if: github.event_name != 'pull_request' continue-on-error: true with: diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index a301b6daf6..e7d69cf477 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@v4 + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 with: repository: element-hq/element-desktop path: element-desktop - name: Fetch element-web - uses: actions/checkout@v4 + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 with: path: element-web - name: Fetch matrix-js-sdk - uses: actions/checkout@v4 + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 with: repository: matrix-org/matrix-js-sdk path: matrix-js-sdk - - uses: actions/setup-node@v4 + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 with: cache: "yarn" cache-dependency-path: element-web/yarn.lock @@ -47,7 +47,7 @@ jobs: echo "- [Automations](automations.md)" >> docs/SUMMARY.md - name: Setup mdBook - uses: peaceiris/actions-mdbook@v2 + uses: peaceiris/actions-mdbook@ee69d230fe19748b7abf22df32acaa93833fad08 # v2 with: mdbook-version: "0.4.10" @@ -88,7 +88,7 @@ jobs: run: mdbook build - name: Upload artifact - uses: actions/upload-pages-artifact@v3 + uses: actions/upload-pages-artifact@56afc609e74202658d3ffba0e8f6dda462b719fa # v3 with: path: ./book @@ -104,4 +104,4 @@ jobs: steps: - name: Deploy to GitHub Pages id: deployment - uses: actions/deploy-pages@v4 + uses: actions/deploy-pages@d6db90164ac5ed86f2b6aed7e0febac5b3c0c03e # v4 diff --git a/.github/workflows/end-to-end-tests-netlify.yaml b/.github/workflows/end-to-end-tests-netlify.yaml index e25994ec9d..90a8c3d24b 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@v4 + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 with: github-token: ${{ secrets.GITHUB_TOKEN }} run-id: ${{ github.event.workflow_run.id }} @@ -33,7 +33,7 @@ jobs: path: playwright-report - name: 📤 Deploy to Netlify - uses: matrix-org/netlify-pr-preview@v3 + uses: matrix-org/netlify-pr-preview@9805cd123fc9a7e421e35340a05e1ebc5dee46b5 # v3 with: path: playwright-report owner: ${{ github.event.workflow_run.head_repository.owner.login }} diff --git a/.github/workflows/end-to-end-tests.yaml b/.github/workflows/end-to-end-tests.yaml index bd7872ee12..b2d49d7703 100644 --- a/.github/workflows/end-to-end-tests.yaml +++ b/.github/workflows/end-to-end-tests.yaml @@ -50,11 +50,11 @@ jobs: runners-matrix: ${{ steps.runner-vars.outputs.matrix }} steps: - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 with: repository: element-hq/element-web - - uses: actions/setup-node@v4 + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 with: cache: "yarn" node-version: "lts/*" @@ -81,7 +81,7 @@ jobs: yarn build - name: Upload Artifact - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 with: name: webapp path: webapp @@ -89,7 +89,7 @@ jobs: - name: Calculate runner variables id: runner-vars - uses: actions/github-script@v7 + uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7 with: script: | const numRunners = parseInt(process.env.NUM_RUNNERS, 10); @@ -129,18 +129,18 @@ jobs: - runAllTests: false project: Pinecone steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 with: persist-credentials: false repository: element-hq/element-web - name: 📥 Download artifact - uses: actions/download-artifact@v4 + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 with: name: webapp path: webapp - - uses: actions/setup-node@v4 + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 with: cache: "yarn" cache-dependency-path: yarn.lock @@ -154,12 +154,11 @@ 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@v4 + uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4 id: playwright-cache with: - path: | - ~/.cache/ms-playwright - key: ${{ runner.os }}-playwright-${{ steps.playwright.outputs.version }} + path: ~/.cache/ms-playwright + key: ${{ runner.os }}-${{ runner.arch }}-playwright-${{ steps.playwright.outputs.version }} - name: Install Playwright browsers if: steps.playwright-cache.outputs.cache-hit != 'true' @@ -180,25 +179,35 @@ jobs: - name: Upload blob report to GitHub Actions Artifacts if: always() - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 with: name: all-blob-reports-${{ matrix.project }}-${{ matrix.runner }} path: blob-report retention-days: 1 + downstream-modules: + name: Downstream Playwright tests [element-modules] + needs: build + if: inputs.skip != true && github.event_name == 'merge_group' + uses: element-hq/element-modules/.github/workflows/reusable-playwright-tests.yml@main + with: + webapp-artifact: webapp + complete: name: end-to-end-tests - needs: playwright + needs: + - playwright + - downstream-modules if: always() runs-on: ubuntu-24.04 steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 if: inputs.skip != true with: persist-credentials: false repository: element-hq/element-web - - uses: actions/setup-node@v4 + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 if: inputs.skip != true with: cache: "yarn" @@ -210,7 +219,7 @@ jobs: - name: Download blob reports from GitHub Actions Artifacts if: inputs.skip != true - uses: actions/download-artifact@v4 + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 with: pattern: all-blob-reports-* path: all-blob-reports @@ -226,11 +235,11 @@ 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@v4 + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 with: name: html-report path: playwright-report retention-days: 14 - - if: needs.playwright.result != 'skipped' && needs.playwright.result != 'success' + - if: contains(needs.*.result, 'failure') || contains(needs.*.result, 'cancelled') run: exit 1 diff --git a/.github/workflows/issue_closed.yml b/.github/workflows/issue_closed.yml index 2cffae0011..249f1eb342 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@v7 + - uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7 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@v7 + - uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7 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 cd03ca5140..a7909265c7 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@v4 + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 with: github-token: ${{ secrets.GITHUB_TOKEN }} run-id: ${{ github.event.workflow_run.id }} @@ -36,7 +36,7 @@ jobs: path: webapp - name: 📤 Deploy to Netlify - uses: matrix-org/netlify-pr-preview@v3 + uses: matrix-org/netlify-pr-preview@9805cd123fc9a7e421e35340a05e1ebc5dee46b5 # v3 with: path: webapp owner: ${{ github.event.workflow_run.head_repository.owner.login }} diff --git a/.github/workflows/pending-reviews.yaml b/.github/workflows/pending-reviews.yaml index c96ed3f17e..199eb60daa 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@v7 + - uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7 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 7681600dde..4cbdb17bbd 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@v4 + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 - name: Update synapse image run: | diff --git a/.github/workflows/pull_request_base_branch.yaml b/.github/workflows/pull_request_base_branch.yaml index 6610ee4879..fbdebfbed0 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@v7 + - uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7 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 031221041a..2f36644c2e 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@v4 + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 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@v4 + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 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@v4 + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 if: inputs.matrix-js-sdk with: repository: matrix-org/matrix-js-sdk @@ -100,7 +100,7 @@ jobs: repo: matrix-org/matrix-js-sdk repo-token: ${{ secrets.ELEMENT_BOT_TOKEN }} wait-interval: 10 - check-name: draft + check-name: "draft / draft" allowed-conclusions: success - name: Wait for element-web draft @@ -111,7 +111,7 @@ jobs: repo: element-hq/element-web repo-token: ${{ secrets.ELEMENT_BOT_TOKEN }} wait-interval: 10 - check-name: draft + check-name: "draft / draft" allowed-conclusions: success - name: Wait for element-desktop draft @@ -122,5 +122,5 @@ jobs: repo: element-hq/element-desktop repo-token: ${{ secrets.ELEMENT_BOT_TOKEN }} wait-interval: 10 - check-name: draft + check-name: "draft / draft" allowed-conclusions: success diff --git a/.github/workflows/static_analysis.yaml b/.github/workflows/static_analysis.yaml index 8b15fdfe76..d63e0da8ed 100644 --- a/.github/workflows/static_analysis.yaml +++ b/.github/workflows/static_analysis.yaml @@ -23,9 +23,9 @@ jobs: name: "Typescript Syntax Check" runs-on: ubuntu-24.04 steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 - - uses: actions/setup-node@v4 + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 with: cache: "yarn" node-version: "lts/*" @@ -57,7 +57,7 @@ jobs: name: "Rethemendex Check" runs-on: ubuntu-24.04 steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 - run: ./res/css/rethemendex.sh @@ -67,9 +67,9 @@ jobs: name: "ESLint" runs-on: ubuntu-24.04 steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 - - uses: actions/setup-node@v4 + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 with: cache: "yarn" node-version: "lts/*" @@ -85,9 +85,9 @@ jobs: name: "Style Lint" runs-on: ubuntu-24.04 steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 - - uses: actions/setup-node@v4 + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 with: cache: "yarn" node-version: "lts/*" @@ -103,9 +103,9 @@ jobs: name: "Workflow Lint" runs-on: ubuntu-24.04 steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 - - uses: actions/setup-node@v4 + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 with: cache: "yarn" node-version: "lts/*" @@ -121,9 +121,9 @@ jobs: name: "Analyse Dead Code" runs-on: ubuntu-24.04 steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 - - uses: actions/setup-node@v4 + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 with: cache: "yarn" node-version: "lts/*" diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index e94c35299a..276c53c098 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -39,12 +39,12 @@ jobs: runner: [1, 2] steps: - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 with: repository: ${{ inputs.matrix-js-sdk-sha && 'element-hq/element-web' || github.repository }} - name: Yarn cache - uses: actions/setup-node@v4 + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 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@v4 + uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # 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@v4 + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 with: name: coverage-${{ matrix.runner }} path: | @@ -104,7 +104,7 @@ jobs: - name: Skip SonarCloud in merge queue if: github.event_name == 'merge_group' || inputs.disable_coverage == 'true' - uses: guibranco/github-status-action-v2@fe98467f9071758c7fc214af9dbac7f301bd23d4 + uses: guibranco/github-status-action-v2@5f2b01ce1394109f70954ae6b69ef41cf7928e63 with: authToken: ${{ secrets.GITHUB_TOKEN }} state: success diff --git a/.github/workflows/triage-assigned.yml b/.github/workflows/triage-assigned.yml index e43eb94618..f190122a1c 100644 --- a/.github/workflows/triage-assigned.yml +++ b/.github/workflows/triage-assigned.yml @@ -11,7 +11,8 @@ jobs: runs-on: ubuntu-24.04 if: | contains(github.event.issue.assignees.*.login, 't3chguy') || - contains(github.event.issue.assignees.*.login, 'andybalaam') || + contains(github.event.issue.assignees.*.login, 'florianduros') || + contains(github.event.issue.assignees.*.login, 'dbkr') || contains(github.event.issue.assignees.*.login, 'MidhunSureshR') steps: - uses: actions/add-to-project@main diff --git a/.github/workflows/triage-labelled.yml b/.github/workflows/triage-labelled.yml index 2cb05a8bcf..e1849e0efc 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@v7 + - uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7 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@v7 + - uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7 with: script: | github.rest.issues.addLabels({ @@ -61,7 +61,7 @@ jobs: contains(github.event.issue.labels.*.name, 'X-Needs-Info') steps: - id: add_to_project - uses: actions/add-to-project@v1.0.2 + uses: actions/add-to-project@244f685bbc3b7adfa8466e08b698b5577571133e # v1.0.2 with: project-url: ${{ env.PROJECT_URL }} github-token: ${{ secrets.ELEMENT_BOT_TOKEN }} @@ -84,7 +84,7 @@ jobs: contains(github.event.issue.labels.*.name, 'Z-Flaky-Test') steps: - id: add_to_project - uses: actions/add-to-project@v1.0.2 + uses: actions/add-to-project@244f685bbc3b7adfa8466e08b698b5577571133e # v1.0.2 with: project-url: ${{ env.PROJECT_URL }} github-token: ${{ secrets.ELEMENT_BOT_TOKEN }} @@ -150,15 +150,15 @@ jobs: project-url: https://github.com/orgs/element-hq/projects/41 github-token: ${{ secrets.ELEMENT_BOT_TOKEN }} - verticals_feature: - name: Add labelled issues to Verticals Feature project + crypto: + name: Add labelled issues to Crypto project runs-on: ubuntu-24.04 if: > - contains(github.event.issue.labels.*.name, 'Team: Verticals Feature') + contains(github.event.issue.labels.*.name, 'Team: Crypto') steps: - uses: actions/add-to-project@main with: - project-url: https://github.com/orgs/element-hq/projects/57 + project-url: https://github.com/orgs/element-hq/projects/76 github-token: ${{ secrets.ELEMENT_BOT_TOKEN }} tech_debt: diff --git a/.github/workflows/triage-stale.yml b/.github/workflows/triage-stale.yml index b52f4e59da..f76cd299cc 100644 --- a/.github/workflows/triage-stale.yml +++ b/.github/workflows/triage-stale.yml @@ -12,7 +12,7 @@ jobs: issues: write pull-requests: write steps: - - uses: actions/stale@v9 + - uses: actions/stale@5bef64f19d7facfb25b37b414482c7164d639639 # v9 with: operations-per-run: 100 # Flaky test issue closing diff --git a/.github/workflows/triage-unlabelled.yml b/.github/workflows/triage-unlabelled.yml index efbf80eea9..d3bda6df1f 100644 --- a/.github/workflows/triage-unlabelled.yml +++ b/.github/workflows/triage-unlabelled.yml @@ -62,7 +62,7 @@ jobs: contains(github.event.issue.labels.*.name, 'A-Element-Call')) && contains(github.event.issue.labels.*.name, 'Z-Labs') steps: - - uses: actions/github-script@v7 + - uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7 with: script: | github.rest.issues.removeLabel({ diff --git a/.github/workflows/update-jitsi.yml b/.github/workflows/update-jitsi.yml index f4fd13892b..da386c544d 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@v4 + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 - - uses: actions/setup-node@v4 + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 with: cache: "yarn" node-version: "lts/*" diff --git a/.github/workflows/update-topics.yaml b/.github/workflows/update-topics.yaml index cd6c2fc553..5ee9f2b608 100644 --- a/.github/workflows/update-topics.yaml +++ b/.github/workflows/update-topics.yaml @@ -22,7 +22,7 @@ jobs: runs-on: ubuntu-24.04 environment: Matrix steps: - - uses: actions/github-script@v7 + - uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7 env: HS_URL: ${{ secrets.BETABOT_HS_URL }} LOBBY_ROOM_ID: ${{ secrets.ROOM_ID }} diff --git a/.gitignore b/.gitignore index 3e9dc5e135..429b317a4f 100644 --- a/.gitignore +++ b/.gitignore @@ -25,7 +25,6 @@ electron/pub .env /coverage # Auto-generated file -/src/modules.ts /src/modules.js /build_config.yaml /book diff --git a/CHANGELOG.md b/CHANGELOG.md index 01c67c34c0..a138735650 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,148 @@ +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 + +* New room list: add keyboard navigation support ([#29805](https://github.com/element-hq/element-web/pull/29805)). Contributed by @florianduros. +* Use the JoinRuleSettings component for the guest link access prompt. ([#28614](https://github.com/element-hq/element-web/pull/28614)). Contributed by @toger5. +* Add loading state to the new room list view ([#29725](https://github.com/element-hq/element-web/pull/29725)). Contributed by @langleyd. +* Make OIDC identity reset consistent with EX ([#29854](https://github.com/element-hq/element-web/pull/29854)). Contributed by @andybalaam. +* Support error code for email / phone adding unsupported (MSC4178) ([#29855](https://github.com/element-hq/element-web/pull/29855)). Contributed by @dbkr. +* Update identity reset UI (Make consistent with EX) ([#29701](https://github.com/element-hq/element-web/pull/29701)). Contributed by @andybalaam. +* Add secondary filters to the new room list ([#29818](https://github.com/element-hq/element-web/pull/29818)). Contributed by @dbkr. +* Fix battery drain from Web Audio ([#29203](https://github.com/element-hq/element-web/pull/29203)). Contributed by @mbachry. + +## 🐛 Bug Fixes + +* Fix go home shortcut on macos and change toggle action events shortcut ([#29929](https://github.com/element-hq/element-web/pull/29929)). Contributed by @florianduros. +* New room list: fix outdated message preview when space or filter change ([#29925](https://github.com/element-hq/element-web/pull/29925)). Contributed by @florianduros. +* Stop migrating to MSC4278 if the config exists. ([#29924](https://github.com/element-hq/element-web/pull/29924)). Contributed by @Half-Shot. +* Ensure consistent download file name on download from ImageView ([#29913](https://github.com/element-hq/element-web/pull/29913)). Contributed by @t3chguy. +* Add error toast when service worker registration fails ([#29895](https://github.com/element-hq/element-web/pull/29895)). Contributed by @t3chguy. +* New Room List: Prevent old tombstoned rooms from appearing in the list ([#29881](https://github.com/element-hq/element-web/pull/29881)). Contributed by @MidhunSureshR. +* Remove lag in search field ([#29885](https://github.com/element-hq/element-web/pull/29885)). Contributed by @florianduros. +* Respect UIFeature.Voip ([#29873](https://github.com/element-hq/element-web/pull/29873)). Contributed by @langleyd. +* Allow jumping to message search from spotlight ([#29850](https://github.com/element-hq/element-web/pull/29850)). Contributed by @t3chguy. + + +Changes in [1.11.100](https://github.com/element-hq/element-web/releases/tag/v1.11.100) (2025-05-06) +==================================================================================================== +## ✨ Features + +* Move rich topics out of labs / stabilise MSC3765 ([#29817](https://github.com/element-hq/element-web/pull/29817)). Contributed by @Johennes. +* Spell out that Element Web does \*not\* work on mobile. ([#29211](https://github.com/element-hq/element-web/pull/29211)). Contributed by @ara4n. +* Add message preview support to the new room list ([#29784](https://github.com/element-hq/element-web/pull/29784)). Contributed by @dbkr. +* Global configuration flag for media previews ([#29582](https://github.com/element-hq/element-web/pull/29582)). Contributed by @Half-Shot. +* New room list: add partial keyboard shortcuts support ([#29783](https://github.com/element-hq/element-web/pull/29783)). Contributed by @florianduros. +* MVVM RoomSummaryCard Topic ([#29710](https://github.com/element-hq/element-web/pull/29710)). Contributed by @MarcWadai. +* Warn on self change from settings > roles ([#28926](https://github.com/element-hq/element-web/pull/28926)). Contributed by @MarcWadai. +* New room list: new visual for invitation ([#29773](https://github.com/element-hq/element-web/pull/29773)). Contributed by @florianduros. + +## 🐛 Bug Fixes + +* Fix incorrect display of the user info display name ([#29826](https://github.com/element-hq/element-web/pull/29826)). Contributed by @langleyd. +* RoomListStore: Remove invite rooms on decline ([#29804](https://github.com/element-hq/element-web/pull/29804)). Contributed by @MidhunSureshR. +* Fix the buttons not being displayed with long preview text ([#29811](https://github.com/element-hq/element-web/pull/29811)). Contributed by @dbkr. +* New room list: fix missing/incorrect notification decoration ([#29796](https://github.com/element-hq/element-web/pull/29796)). Contributed by @florianduros. +* New Room List: Prevent potential scroll jump/flicker when switching spaces ([#29781](https://github.com/element-hq/element-web/pull/29781)). Contributed by @MidhunSureshR. +* New room list: fix incorrect decoration ([#29770](https://github.com/element-hq/element-web/pull/29770)). Contributed by @florianduros. + + +Changes in [1.11.99](https://github.com/element-hq/element-web/releases/tag/v1.11.99) (2025-04-23) +================================================================================================== +No changes, just bumping the version to accommodate a new Element Desktop release + +Changes in [1.11.98](https://github.com/element-hq/element-web/releases/tag/v1.11.98) (2025-04-22) +================================================================================================== +## ✨ Features + +* print better errors in the search view instead of a blocking modal ([#29724](https://github.com/element-hq/element-web/pull/29724)). Contributed by @Jujure. +* New room list: video room and video call decoration ([#29693](https://github.com/element-hq/element-web/pull/29693)). Contributed by @florianduros. +* Remove Secure Backup, Cross-signing and Cryptography sections in `Security & Privacy` user settings ([#29088](https://github.com/element-hq/element-web/pull/29088)). Contributed by @florianduros. +* Allow reporting a room when rejecting an invite. ([#29570](https://github.com/element-hq/element-web/pull/29570)). Contributed by @Half-Shot. +* RoomListViewModel: Reset primary and secondary filters on space change ([#29672](https://github.com/element-hq/element-web/pull/29672)). Contributed by @MidhunSureshR. +* RoomListStore: Support specific sorting requirements for muted rooms ([#29665](https://github.com/element-hq/element-web/pull/29665)). Contributed by @MidhunSureshR. +* New room list: add notification options menu ([#29639](https://github.com/element-hq/element-web/pull/29639)). Contributed by @florianduros. +* Room List: Scroll to top of the list when active room is not in the list ([#29650](https://github.com/element-hq/element-web/pull/29650)). Contributed by @MidhunSureshR. + +## 🐛 Bug Fixes + +* Fix unwanted form submit behaviour in memberlist ([#29747](https://github.com/element-hq/element-web/pull/29747)). Contributed by @MidhunSureshR. +* New room list: fix public room icon visibility when filter change ([#29737](https://github.com/element-hq/element-web/pull/29737)). Contributed by @florianduros. +* Fix custom theme support for short hex \& rgba hex strings ([#29726](https://github.com/element-hq/element-web/pull/29726)). Contributed by @t3chguy. +* New room list: minor visual fixes ([#29723](https://github.com/element-hq/element-web/pull/29723)). Contributed by @florianduros. +* Fix getOidcCallbackUrl for Element Desktop ([#29711](https://github.com/element-hq/element-web/pull/29711)). Contributed by @t3chguy. +* Fix some webp images improperly marked as animated ([#29713](https://github.com/element-hq/element-web/pull/29713)). Contributed by @Petersmit27. +* Revert deletion of hydrateSession ([#29703](https://github.com/element-hq/element-web/pull/29703)). Contributed by @Jujure. +* Fix converttoroom \& converttodm not working ([#29705](https://github.com/element-hq/element-web/pull/29705)). Contributed by @t3chguy. +* Ensure forceCloseAllModals also closes priority/static modals ([#29706](https://github.com/element-hq/element-web/pull/29706)). Contributed by @t3chguy. +* Continue button is disabled when uploading a recovery key file ([#29695](https://github.com/element-hq/element-web/pull/29695)). Contributed by @Giwayume. +* Catch errors after syncing recovery ([#29691](https://github.com/element-hq/element-web/pull/29691)). Contributed by @andybalaam. +* New room list: fix multiple visual issues ([#29673](https://github.com/element-hq/element-web/pull/29673)). Contributed by @florianduros. +* New Room List: Fix mentions filter matching rooms with any highlight ([#29668](https://github.com/element-hq/element-web/pull/29668)). Contributed by @MidhunSureshR. +* Fix truncated emoji label during emoji SAS ([#29643](https://github.com/element-hq/element-web/pull/29643)). Contributed by @florianduros. +* Remove duplicate jitsi link ([#29642](https://github.com/element-hq/element-web/pull/29642)). Contributed by @dbkr. + + Changes in [1.11.97](https://github.com/element-hq/element-web/releases/tag/v1.11.97) (2025-04-08) ================================================================================================== ## ✨ Features diff --git a/Dockerfile b/Dockerfile index 0258850d89..a1813cf66d 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,7 +1,7 @@ -# syntax=docker.io/docker/dockerfile:1.14-labs +# syntax=docker.io/docker/dockerfile:1.16-labs@sha256:bb5e2b225985193779991f3256d1901a0b3e6a0b284c7bffa0972064f4a6d458 # Builder -FROM --platform=$BUILDPLATFORM node:22-bullseye AS builder +FROM --platform=$BUILDPLATFORM node:22-bullseye@sha256:f16d8e8af67bb6361231e932b8b3e7afa040cbfed181719a450b02c3821b26c1 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 +FROM nginxinc/nginx-unprivileged:alpine-slim@sha256:2acffd86b1bdefb8fa6b48b6e9aadf75430e8ab9c43c54c515ea7df77897f987 # Need root user to install packages & manipulate the usr directory USER root diff --git a/README.md b/README.md index 00f8d6d89c..0f8a721f90 100644 --- a/README.md +++ b/README.md @@ -126,7 +126,7 @@ guide](https://classic.yarnpkg.com/en/docs/install) if you do not have it alread 1. Install the prerequisites: `yarn install`. - If you're using the `develop` branch, then it is recommended to set up a proper development environment (see [Setting up a dev - environment](#setting-up-a-dev-environment) below). Alternatively, you + environment](./developer_guide.md#setting-up-a-dev-environment) below). Alternatively, you can use - the continuous integration release of the develop branch. 1. Configure the app by copying `config.sample.json` to `config.json` and diff --git a/contribute.json b/contribute.json deleted file mode 100644 index fdd84bd8a0..0000000000 --- a/contribute.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "name": "Element", - "description": "A glossy Matrix collaboration client for the web.", - "repository": { - "url": "https://github.com/element-hq/element-web", - "license": "AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial" - }, - "bugs": { - "list": "https://github.com/element-hq/element-web/issues", - "report": "https://github.com/element-hq/element-web/issues/new/choose" - }, - "keywords": ["chat", "riot", "matrix"] -} diff --git a/docs/SUMMARY.md b/docs/SUMMARY.md index 57b017bc1c..de93ce2812 100644 --- a/docs/SUMMARY.md +++ b/docs/SUMMARY.md @@ -46,7 +46,6 @@ - [Skinning](skinning.md) - [Cider editor](ciderEditor.md) - [Iconography](icons.md) -- [Jitsi](jitsi.md) - [Local echo](local-echo-dev.md) - [Media](media-handling.md) - [Room List Store](room-list-store.md) diff --git a/docs/labs.md b/docs/labs.md index 7f69fca6e9..60f35dd4a4 100644 --- a/docs/labs.md +++ b/docs/labs.md @@ -101,10 +101,6 @@ Under the hood this stops Element Web from adding the `perParticipantE2EE` flag This is useful while we experiment with encryption and to make calling compatible with platforms that don't use encryption yet. -## Rich text in room topics (`feature_html_topic`) [In Development] - -Enables rendering of MD / HTML in room topics. - ## Enable the notifications panel in the room header (`feature_notifications`) Unreliable in encrypted rooms. diff --git a/package.json b/package.json index 06ce879b01..2245d033c4 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "element-web", - "version": "1.11.97", + "version": "1.11.104", "description": "Element: the future of secure communication", "author": "New Vector Ltd.", "repository": { @@ -22,8 +22,7 @@ "LICENSE", "README.md", "AUTHORS.rst", - "package.json", - "contribute.json" + "package.json" ], "style": "bundle.css", "matrix_i18n_extra_translation_funcs": [ @@ -69,19 +68,20 @@ "postinstall": "patch-package" }, "resolutions": { - "@playwright/test": "1.51.1", - "@types/react": "18.3.18", - "@types/react-dom": "18.3.5", - "oidc-client-ts": "3.2.0", + "**/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", "jwt-decode": "4.0.0", - "caniuse-lite": "1.0.30001704", - "testcontainers": "10.21.0", + "caniuse-lite": "1.0.30001720", + "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": "^0.1.1", + "@element-hq/element-web-module-api": "1.0.0", "@fontsource/inconsolata": "^5", "@fontsource/inter": "^5", "@formatjs/intl-segmenter": "^11.5.7", @@ -93,8 +93,8 @@ "@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.9.0", - "@vector-im/matrix-wysiwyg": "2.38.2", + "@vector-im/compound-web": "^7.11.0", + "@vector-im/matrix-wysiwyg": "2.38.3", "@zxcvbn-ts/core": "^3.0.4", "@zxcvbn-ts/language-common": "^3.0.4", "@zxcvbn-ts/language-en": "^3.0.2", @@ -123,14 +123,14 @@ "jsrsasign": "^11.0.0", "jszip": "^3.7.0", "katex": "^0.16.0", - "linkify-react": "4.2.0", - "linkify-string": "4.2.0", - "linkifyjs": "4.2.0", + "linkify-react": "4.3.1", + "linkify-string": "4.3.1", + "linkifyjs": "4.3.1", "lodash": "^4.17.21", "maplibre-gl": "^5.0.0", "matrix-encrypt-attachment": "^1.0.3", "matrix-events-sdk": "0.0.1", - "matrix-js-sdk": "37.3.0", + "matrix-js-sdk": "37.9.0", "matrix-widget-api": "^1.10.0", "memoize-one": "^6.0.0", "mime": "^4.0.4", @@ -138,22 +138,22 @@ "opus-recorder": "^8.0.3", "pako": "^2.0.3", "png-chunks-extract": "^1.0.0", - "posthog-js": "1.157.2", + "posthog-js": "1.248.1", "qrcode": "1.5.4", "re-resizable": "6.11.2", - "react": "^18.3.1", + "react": "^19.0.0", "react-beautiful-dnd": "^13.1.0", "react-blurhash": "^0.3.0", - "react-dom": "^18.3.1", + "react-dom": "^19.0.0", "react-focus-lock": "^2.5.1", "react-string-replace": "^1.1.1", "react-transition-group": "^4.4.1", "react-virtualized": "^9.22.5", "rfc4648": "^1.4.0", "sanitize-filename": "^1.6.3", - "sanitize-html": "2.14.0", + "sanitize-html": "2.17.0", "tar-js": "^0.3.0", - "temporal-polyfill": "^0.2.5", + "temporal-polyfill": "^0.3.0", "ua-parser-js": "^1.0.2", "uuid": "^11.0.0", "what-input": "^5.2.10" @@ -180,13 +180,14 @@ "@babel/preset-typescript": "^7.12.7", "@babel/runtime": "^7.12.5", "@casualbot/jest-sonar-reporter": "2.2.7", - "@element-hq/element-call-embedded": "0.9.0", + "@element-hq/element-call-embedded": "0.12.0", "@element-hq/element-web-playwright-common": "^1.1.5", "@peculiar/webcrypto": "^1.4.3", "@playwright/test": "^1.50.1", "@principalstudio/html-webpack-inject-preload": "^1.2.7", + "@rrweb/types": "^2.0.0-alpha.18", "@sentry/webpack-plugin": "^3.0.0", - "@stylistic/eslint-plugin": "^3.0.0", + "@stylistic/eslint-plugin": "^4.0.0", "@svgr/webpack": "^8.0.0", "@testing-library/dom": "^10.4.0", "@testing-library/jest-dom": "^6.4.8", @@ -211,11 +212,11 @@ "@types/node-fetch": "^2.6.2", "@types/pako": "^2.0.0", "@types/qrcode": "^1.3.5", - "@types/react": "18.3.18", + "@types/react": "19.1.6", "@types/react-beautiful-dnd": "^13.0.0", - "@types/react-dom": "18.3.5", + "@types/react-dom": "19.1.5", "@types/react-transition-group": "^4.4.0", - "@types/sanitize-html": "2.13.0", + "@types/sanitize-html": "2.16.0", "@types/semver": "^7.5.8", "@types/tar-js": "^0.3.5", "@types/ua-parser-js": "^0.7.36", @@ -246,7 +247,7 @@ "eslint-plugin-react-compiler": "^19.0.0-beta-df7b47d-20241124", "eslint-plugin-react-hooks": "^5.0.0", "eslint-plugin-unicorn": "^56.0.0", - "express": "^4.18.2", + "express": "^5.0.0", "fake-indexeddb": "^6.0.0", "fetch-mock": "9.11.0", "fetch-mock-jest": "^1.5.1", @@ -261,7 +262,7 @@ "jest-raw-loader": "^1.0.1", "jsqr": "^1.4.0", "knip": "^5.36.2", - "lint-staged": "^15.0.2", + "lint-staged": "^16.0.0", "matrix-web-i18n": "^3.2.1", "mini-css-extract-plugin": "2.9.2", "minimist": "^1.2.6", @@ -286,13 +287,13 @@ "semver": "^7.5.2", "source-map-loader": "^5.0.0", "stylelint": "^16.13.0", - "stylelint-config-standard": "^37.0.0", + "stylelint-config-standard": "^38.0.0", "stylelint-scss": "^6.0.0", "stylelint-value-no-unknown-custom-properties": "^6.0.1", "terser-webpack-plugin": "^5.3.9", - "testcontainers": "^10.20.0", + "testcontainers": "^11.0.0", "ts-node": "^10.9.1", - "typescript": "5.8.2", + "typescript": "5.8.3", "util": "^0.12.5", "web-streams-polyfill": "^4.0.0", "webpack": "^5.89.0", @@ -310,5 +311,6 @@ }, "engines": { "node": ">=20.0.0" - } + }, + "packageManager": "yarn@1.22.22+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e" } diff --git a/patches/@types+react+18.3.18.patch b/patches/@types+react+18.3.18.patch deleted file mode 100644 index 18a1e64524..0000000000 --- a/patches/@types+react+18.3.18.patch +++ /dev/null @@ -1,76 +0,0 @@ -diff --git a/node_modules/@types/react/index.d.ts b/node_modules/@types/react/index.d.ts -index 6ea73ef..cb51757 100644 ---- a/node_modules/@types/react/index.d.ts -+++ b/node_modules/@types/react/index.d.ts -@@ -151,7 +151,7 @@ declare namespace React { - /** - * The current value of the ref. - */ -- readonly current: T | null; -+ current: T; - } - - interface DO_NOT_USE_OR_YOU_WILL_BE_FIRED_CALLBACK_REF_RETURN_VALUES { -@@ -186,7 +186,7 @@ declare namespace React { - * @see {@link RefObject} - */ - -- type Ref = RefCallback | RefObject | null; -+ type Ref = RefCallback | RefObject | null; - /** - * A legacy implementation of refs where you can pass a string to a ref prop. - * -@@ -300,7 +300,7 @@ declare namespace React { - * - * @see {@link https://react.dev/learn/referencing-values-with-refs#refs-and-the-dom React Docs} - */ -- ref?: LegacyRef | undefined; -+ ref?: LegacyRef | undefined; - } - - /** -@@ -1234,7 +1234,7 @@ declare namespace React { - * - * @see {@link ForwardRefRenderFunction} - */ -- type ForwardedRef = ((instance: T | null) => void) | MutableRefObject | null; -+ type ForwardedRef = ((instance: T | null) => void) | RefObject | null; - - /** - * The type of the function passed to {@link forwardRef}. This is considered different -@@ -1565,7 +1565,7 @@ declare namespace React { - [propertyName: string]: any; - } - -- function createRef(): RefObject; -+ function createRef(): RefObject; - - /** - * The type of the component returned from {@link forwardRef}. -@@ -1989,7 +1989,7 @@ declare namespace React { - * @version 16.8.0 - * @see {@link https://react.dev/reference/react/useRef} - */ -- function useRef(initialValue: T): MutableRefObject; -+ function useRef(initialValue: T): RefObject; - // convenience overload for refs given as a ref prop as they typically start with a null value - /** - * `useRef` returns a mutable ref object whose `.current` property is initialized to the passed argument -@@ -2004,7 +2004,7 @@ declare namespace React { - * @version 16.8.0 - * @see {@link https://react.dev/reference/react/useRef} - */ -- function useRef(initialValue: T | null): RefObject; -+ function useRef(initialValue: T | null): RefObject; - // convenience overload for potentially undefined initialValue / call with 0 arguments - // has a default to stop it from defaulting to {} instead - /** -@@ -2017,7 +2017,7 @@ declare namespace React { - * @version 16.8.0 - * @see {@link https://react.dev/reference/react/useRef} - */ -- function useRef(initialValue?: undefined): MutableRefObject; -+ function useRef(initialValue: T | undefined): RefObject; - /** - * The signature is identical to `useEffect`, but it fires synchronously after all DOM mutations. - * Use this to read layout from the DOM and synchronously re-render. Updates scheduled inside diff --git a/patches/@types+react+19.1.4.patch b/patches/@types+react+19.1.4.patch new file mode 100644 index 0000000000..ceba85b000 --- /dev/null +++ b/patches/@types+react+19.1.4.patch @@ -0,0 +1,31 @@ +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/react-blurhash+0.3.0.patch b/patches/react-blurhash+0.3.0.patch new file mode 100644 index 0000000000..93e4b05d6e --- /dev/null +++ b/patches/react-blurhash+0.3.0.patch @@ -0,0 +1,22 @@ +diff --git a/node_modules/react-blurhash/dist/index.d.ts b/node_modules/react-blurhash/dist/index.d.ts +index 3adbd0a..32e8c13 100644 +--- a/node_modules/react-blurhash/dist/index.d.ts ++++ b/node_modules/react-blurhash/dist/index.d.ts +@@ -19,7 +19,7 @@ declare class Blurhash extends React.PureComponent { + resolutionY: number; + }; + componentDidUpdate(): void; +- render(): JSX.Element; ++ render(): React.JSX.Element; + } + + declare type Props = React.CanvasHTMLAttributes & { +@@ -37,7 +37,7 @@ declare class BlurhashCanvas extends React.PureComponent { + componentDidUpdate(): void; + handleRef: (canvas: HTMLCanvasElement) => void; + draw: () => void; +- render(): JSX.Element; ++ render(): React.JSX.Element; + } + + export { Blurhash, BlurhashCanvas }; diff --git a/playwright/e2e/crypto/backups-mas.spec.ts b/playwright/e2e/crypto/backups-mas.spec.ts index 6519a484e9..6a7fff6672 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,7 +41,13 @@ 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("menuitem", { name: "New room" }).click(); @@ -64,7 +76,7 @@ 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("menuitem", { name: "New room" }).click(); diff --git a/playwright/e2e/crypto/backups.spec.ts b/playwright/e2e/crypto/backups.spec.ts deleted file mode 100644 index e0cf1cc60b..0000000000 --- a/playwright/e2e/crypto/backups.spec.ts +++ /dev/null @@ -1,108 +0,0 @@ -/* -Copyright 2024 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 { type Page } from "@playwright/test"; - -import { test, expect } from "../../element-web-test"; -import { isDendrite } from "../../plugins/homeserver/dendrite"; -import { completeCreateSecretStorageDialog } from "./utils.ts"; - -async function expectBackupVersionToBe(page: Page, version: string) { - await expect(page.locator(".mx_SecureBackupPanel_statusList tr:nth-child(5) td")).toHaveText( - version + " (Algorithm: m.megolm_backup.v1.curve25519-aes-sha2)", - ); - - await expect(page.locator(".mx_SecureBackupPanel_statusList tr:nth-child(6) td")).toHaveText(version); -} - -test.describe("Backups", () => { - test.skip(isDendrite, "Dendrite lacks support for MSC3967 so requires additional auth here"); - test.use({ - displayName: "Hanako", - }); - - test( - "Create, delete and recreate a keys backup", - { tag: "@no-webkit" }, - async ({ page, user, app }, workerInfo) => { - // Create a backup - const securityTab = await app.settings.openUserSettings("Security & Privacy"); - - await expect(securityTab.getByRole("heading", { name: "Secure Backup" })).toBeVisible(); - await securityTab.getByRole("button", { name: "Set up", exact: true }).click(); - - const securityKey = await completeCreateSecretStorageDialog(page); - - // Open the settings again - await app.settings.openUserSettings("Security & Privacy"); - await expect(securityTab.getByRole("heading", { name: "Secure Backup" })).toBeVisible(); - - // expand the advanced section to see the active version in the reports - await page - .locator(".mx_Dialog .mx_SettingsSubsection_content details .mx_SecureBackupPanel_advanced") - .locator("..") - .click(); - - await expectBackupVersionToBe(page, "1"); - - await securityTab.getByRole("button", { name: "Delete Backup", exact: true }).click(); - const currentDialogLocator = page.locator(".mx_Dialog"); - await expect(currentDialogLocator.getByRole("heading", { name: "Delete Backup" })).toBeVisible(); - // Delete it - await currentDialogLocator.getByTestId("dialog-primary-button").click(); // Click "Delete Backup" - - // Create another - await securityTab.getByRole("button", { name: "Set up", exact: true }).click(); - await expect(currentDialogLocator.getByRole("heading", { name: "Recovery Key" })).toBeVisible(); - await currentDialogLocator.getByLabel("Recovery Key").fill(securityKey); - await currentDialogLocator.getByRole("button", { name: "Continue", exact: true }).click(); - - // Should be successful - await expect(currentDialogLocator.getByRole("heading", { name: "Success!" })).toBeVisible(); - await currentDialogLocator.getByRole("button", { name: "OK", exact: true }).click(); - - // Open the settings again - await app.settings.openUserSettings("Security & Privacy"); - await expect(securityTab.getByRole("heading", { name: "Secure Backup" })).toBeVisible(); - - // expand the advanced section to see the active version in the reports - await page - .locator(".mx_Dialog .mx_SettingsSubsection_content details .mx_SecureBackupPanel_advanced") - .locator("..") - .click(); - - await expectBackupVersionToBe(page, "2"); - - // == - // Ensure that if you don't have the secret storage passphrase the backup won't be created - // == - - // First delete version 2 - await securityTab.getByRole("button", { name: "Delete Backup", exact: true }).click(); - await expect(currentDialogLocator.getByRole("heading", { name: "Delete Backup" })).toBeVisible(); - // Click "Delete Backup" - await currentDialogLocator.getByTestId("dialog-primary-button").click(); - - // Try to create another - await securityTab.getByRole("button", { name: "Set up", exact: true }).click(); - await expect(currentDialogLocator.getByRole("heading", { name: "Recovery Key" })).toBeVisible(); - // But cancel the recovery key dialog, to simulate not having the secret storage passphrase - await currentDialogLocator.getByTestId("dialog-cancel-button").click(); - - await expect(currentDialogLocator.getByRole("heading", { name: "Starting backup…" })).toBeVisible(); - // check that it failed - await expect(currentDialogLocator.getByText("Unable to create key backup")).toBeVisible(); - // cancel - await currentDialogLocator.getByTestId("dialog-cancel-button").click(); - - // go back to the settings to check that no backup was created (the setup button should still be there) - await app.settings.openUserSettings("Security & Privacy"); - await expect(securityTab.getByRole("button", { name: "Set up", exact: true })).toBeVisible(); - }, - ); -}); diff --git a/playwright/e2e/crypto/crypto.spec.ts b/playwright/e2e/crypto/crypto.spec.ts index 6adef5be60..bad9072f0c 100644 --- a/playwright/e2e/crypto/crypto.spec.ts +++ b/playwright/e2e/crypto/crypto.spec.ts @@ -8,14 +8,7 @@ Please see LICENSE files in the repository root for full details. import type { Page } from "@playwright/test"; import { expect, test } from "../../element-web-test"; -import { - autoJoin, - completeCreateSecretStorageDialog, - copyAndContinue, - createSharedRoomWithUser, - enableKeyBackup, - verify, -} from "./utils"; +import { autoJoin, createSharedRoomWithUser, enableKeyBackup, verify } from "./utils"; import { type Bot } from "../../pages/bot"; import { type ElementAppPage } from "../../pages/ElementAppPage"; import { isDendrite } from "../../plugins/homeserver/dendrite"; @@ -84,86 +77,43 @@ test.describe("Cryptography", function () { }, }); - for (const isDeviceVerified of [true, false]) { - test.describe(`setting up secure key backup should work isDeviceVerified=${isDeviceVerified}`, () => { - /** - * Verify that the `m.cross_signing.${keyType}` key is available on the account data on the server - * @param keyType - */ - async function verifyKey(app: ElementAppPage, keyType: "master" | "self_signing" | "user_signing") { - const accountData: { encrypted: Record> } = await app.client.evaluate( - (cli, keyType) => cli.getAccountDataFromServer(`m.cross_signing.${keyType}`), - keyType, - ); - expect(accountData.encrypted).toBeDefined(); - const keys = Object.keys(accountData.encrypted); - const key = accountData.encrypted[keys[0]]; - expect(key.ciphertext).toBeDefined(); - expect(key.iv).toBeDefined(); - expect(key.mac).toBeDefined(); - } + /** + * Verify that the `m.cross_signing.${keyType}` key is available on the account data on the server + * @param keyType + */ + async function verifyKey(app: ElementAppPage, keyType: "master" | "self_signing" | "user_signing") { + const accountData: { encrypted: Record> } = await app.client.evaluate( + (cli, keyType) => cli.getAccountDataFromServer(`m.cross_signing.${keyType}`), + keyType, + ); - test("by recovery code", async ({ page, app, user: aliceCredentials }) => { - // Verified the device - if (isDeviceVerified) { - await app.client.bootstrapCrossSigning(aliceCredentials); - } - - await page.route("**/_matrix/client/v3/keys/signatures/upload", async (route) => { - // We delay this API otherwise the `Setting up keys` may happen too quickly and cause flakiness - await new Promise((resolve) => setTimeout(resolve, 500)); - await route.continue(); - }); - - await app.settings.openUserSettings("Security & Privacy"); - await page.getByRole("button", { name: "Set up Secure Backup" }).click(); - - await completeCreateSecretStorageDialog(page); - - // Verify that the SSSS keys are in the account data stored in the server - await verifyKey(app, "master"); - await verifyKey(app, "self_signing"); - await verifyKey(app, "user_signing"); - }); - - test("by passphrase", async ({ page, app, user: aliceCredentials }) => { - // Verified the device - if (isDeviceVerified) { - await app.client.bootstrapCrossSigning(aliceCredentials); - } - - await app.settings.openUserSettings("Security & Privacy"); - await page.getByRole("button", { name: "Set up Secure Backup" }).click(); - - const dialog = page.locator(".mx_Dialog"); - // Select passphrase option - await dialog.getByText("Enter a Security Phrase").click(); - await dialog.getByRole("button", { name: "Continue" }).click(); - - // Fill passphrase input - await dialog.locator("input").fill("new passphrase for setting up a secure key backup"); - await dialog.locator(".mx_Dialog_primary:not([disabled])", { hasText: "Continue" }).click(); - // Confirm passphrase - await dialog.locator("input").fill("new passphrase for setting up a secure key backup"); - await dialog.locator(".mx_Dialog_primary:not([disabled])", { hasText: "Continue" }).click(); - - await copyAndContinue(page); - - await expect(dialog.getByText("Secure Backup successful")).toBeVisible(); - await dialog.getByRole("button", { name: "Done" }).click(); - await expect(dialog.getByText("Secure Backup successful")).not.toBeVisible(); - - // Verify that the SSSS keys are in the account data stored in the server - await verifyKey(app, "master"); - await verifyKey(app, "self_signing"); - await verifyKey(app, "user_signing"); - }); - }); + expect(accountData.encrypted).toBeDefined(); + const keys = Object.keys(accountData.encrypted); + const key = accountData.encrypted[keys[0]]; + expect(key.ciphertext).toBeDefined(); + expect(key.iv).toBeDefined(); + expect(key.mac).toBeDefined(); } + test("Setting up key backup by recovery key", async ({ page, app, user: aliceCredentials }) => { + await app.client.bootstrapCrossSigning(aliceCredentials); + + await enableKeyBackup(app); + + // Wait for the cross signing keys to be uploaded + // Waiting for "Change the recovery key" button ensure that all the secrets are uploaded and cached locally + const encryptionTab = await app.settings.openUserSettings("Encryption"); + await expect(encryptionTab.getByRole("button", { name: "Change recovery key" })).toBeVisible(); + + // Verify that the SSSS keys are in the account data stored in the server + await verifyKey(app, "master"); + await verifyKey(app, "self_signing"); + await verifyKey(app, "user_signing"); + }); + test("Can reset cross-signing keys", async ({ page, app, user: aliceCredentials }) => { await app.client.bootstrapCrossSigning(aliceCredentials); - const secretStorageKey = await enableKeyBackup(app); + await enableKeyBackup(app); // Fetch the current cross-signing keys async function fetchMasterKey() { @@ -177,18 +127,15 @@ test.describe("Cryptography", function () { return k; }); } + const masterKey1 = await fetchMasterKey(); - // Find the "reset cross signing" button, and click it - await app.settings.openUserSettings("Security & Privacy"); - await page.locator("div.mx_CrossSigningPanel_buttonRow").getByRole("button", { name: "Reset" }).click(); + // Find "the Reset cryptographic identity" button + const encryptionTab = await app.settings.openUserSettings("Encryption"); + await encryptionTab.getByRole("button", { name: "Reset cryptographic identity" }).click(); // Confirm - await page.getByRole("button", { name: "Clear cross-signing keys" }).click(); - - // Enter the 4S key - await page.getByPlaceholder("Recovery Key").fill(secretStorageKey); - await page.getByRole("button", { name: "Continue" }).click(); + await encryptionTab.getByRole("button", { name: "Continue" }).click(); // Enter the password await page.getByPlaceholder("Password").fill(aliceCredentials.password); @@ -198,9 +145,6 @@ test.describe("Cryptography", function () { const masterKey2 = await fetchMasterKey(); expect(masterKey1).not.toEqual(masterKey2); }).toPass(); - - // The dialog should have gone away - await expect(page.locator(".mx_Dialog")).toHaveCount(1); }); test( diff --git a/playwright/e2e/crypto/dehydration.spec.ts b/playwright/e2e/crypto/dehydration.spec.ts index 2f545b8d14..379fc36cf9 100644 --- a/playwright/e2e/crypto/dehydration.spec.ts +++ b/playwright/e2e/crypto/dehydration.spec.ts @@ -8,7 +8,7 @@ Please see LICENSE files in the repository root for full details. import { test, expect } from "../../element-web-test"; import { isDendrite } from "../../plugins/homeserver/dendrite"; -import { completeCreateSecretStorageDialog, createBot, logIntoElement } from "./utils.ts"; +import { createBot, logIntoElement } from "./utils.ts"; import { type Client } from "../../pages/client.ts"; import { type ElementAppPage } from "../../pages/ElementAppPage.ts"; @@ -28,21 +28,27 @@ test.describe("Dehydration", () => { test.skip(isDendrite, "does not yet support dehydration v2"); test("Verify device and reset creates dehydrated device", async ({ page, user, credentials, app }, workerInfo) => { - // Verify the device by resetting the key (which will create SSSS, and dehydrated device) + // Verify the device by resetting the identity key, and then set up recovery (which will create SSSS, and dehydrated device) const securityTab = await app.settings.openUserSettings("Security & Privacy"); await expect(securityTab.getByText("Offline device enabled")).not.toBeVisible(); await app.closeDialog(); - // Verify the device by resetting the key + // 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: "Continue" }).click(); - await page.getByRole("button", { name: "Copy" }).click(); + + // Set up recovery + await page.getByRole("button", { name: "Set up recovery" }).click(); await page.getByRole("button", { name: "Continue" }).click(); - await page.getByRole("button", { name: "Done" }).click(); + const recoveryKey = await page.getByTestId("recoveryKey").innerText(); + await page.getByRole("button", { name: "Continue" }).click(); + await page.getByRole("textbox").fill(recoveryKey); + await page.getByRole("button", { name: "Finish set up" }).click(); + await page.getByRole("button", { name: "Close" }).click(); await expectDehydratedDeviceEnabled(app); @@ -80,7 +86,7 @@ test.describe("Dehydration", () => { await expectDehydratedDeviceEnabled(app); }); - test("Reset recovery key during login re-creates dehydrated device", async ({ + test("Reset identity during login and set up recovery re-creates dehydrated device", async ({ page, homeserver, app, @@ -99,16 +105,26 @@ test.describe("Dehydration", () => { // Log in our client await logIntoElement(page, credentials); - // Oh no, we forgot our recovery key + // 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: "Proceed with reset" }).click(); + await expect( + page.getByRole("heading", { name: "Are you sure you want to reset your identity?" }), + ).toBeVisible(); + await page.getByRole("button", { name: "Continue", exact: true }).click(); + await page.getByPlaceholder("Password").fill(credentials.password); + await page.getByRole("button", { name: "Continue" }).click(); - await completeCreateSecretStorageDialog(page, { accountPassword: credentials.password }); + // And set up recovery + const settings = await app.settings.openUserSettings("Encryption"); + await settings.getByRole("button", { name: "Set up recovery" }).click(); + await settings.getByRole("button", { name: "Continue" }).click(); + const recoveryKey = await settings.getByTestId("recoveryKey").innerText(); + await settings.getByRole("button", { name: "Continue" }).click(); + await settings.getByRole("textbox").fill(recoveryKey); + await settings.getByRole("button", { name: "Finish set up" }).click(); // There should be a brand new dehydrated device - const dehydratedDeviceIds = await getDehydratedDeviceIds(app.client); - expect(dehydratedDeviceIds.length).toBe(1); - expect(dehydratedDeviceIds[0]).not.toEqual(initialDehydratedDeviceIds[0]); + await expectDehydratedDeviceEnabled(app); }); test("'Reset cryptographic identity' removes dehydrated device", async ({ page, homeserver, app, credentials }) => { diff --git a/playwright/e2e/crypto/device-verification.spec.ts b/playwright/e2e/crypto/device-verification.spec.ts index 9be79452c4..5ae53f649e 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; @@ -163,39 +164,44 @@ test.describe("Device verification", { tag: "@no-webkit" }, () => { 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("input").fill("new passphrase"); - await dialog.locator(".mx_Dialog_primary:not([disabled])", { hasText: "Continue" }).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); + await enterRecoveryKeyAndCheckVerified(page, app, "new passphrase"); }); 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); + }); + + /** Helper for the three tests above which verify by recovery key */ + async function enterRecoveryKeyAndCheckVerified(page: Page, app: ElementAppPage, recoveryKey: string) { + await page.getByRole("button", { name: "Verify with Recovery Key or Phrase" }).click(); + + // Enter the recovery key const dialog = page.locator(".mx_Dialog"); - await dialog.getByRole("button", { name: "use your Recovery Key" }).click(); - const aliceRecoveryKey = await aliceBotClient.getRecoveryKey(); - await dialog.locator("#mx_securityKey").fill(aliceRecoveryKey.encodedPrivateKey); - await dialog.locator(".mx_Dialog_primary:not([disabled])", { hasText: "Continue" }).click(); + // 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.locator("textarea").pressSequentially(recoveryKey); + 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); @@ -203,7 +209,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); diff --git a/playwright/e2e/crypto/event-shields.spec.ts b/playwright/e2e/crypto/event-shields.spec.ts index f73f190e10..e577a66467 100644 --- a/playwright/e2e/crypto/event-shields.spec.ts +++ b/playwright/e2e/crypto/event-shields.spec.ts @@ -67,8 +67,9 @@ test.describe("Cryptography", function () { // Bob has a second, not cross-signed, device const bobSecondDevice = await createSecondBotDevice(page, homeserver, bob); - // Dismiss the toast nagging us to set up recovery otherwise it gets in the way of clicking the room list - await page.getByRole("button", { name: "Not now" }).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", diff --git a/playwright/e2e/crypto/toasts.spec.ts b/playwright/e2e/crypto/toasts.spec.ts index 5b80f822a0..7b838edaac 100644 --- a/playwright/e2e/crypto/toasts.spec.ts +++ b/playwright/e2e/crypto/toasts.spec.ts @@ -8,7 +8,8 @@ import { type GeneratedSecretStorageKey } from "matrix-js-sdk/src/crypto-api"; import { test, expect } from "../../element-web-test"; -import { createBot, deleteCachedSecrets, logIntoElement } from "./utils"; +import { createBot, deleteCachedSecrets, disableKeyBackup, logIntoElement } from "./utils"; +import { type Bot } from "../../pages/bot"; test.describe("Key storage out of sync toast", () => { let recoveryKey: GeneratedSecretStorageKey; @@ -53,3 +54,114 @@ test.describe("Key storage out of sync toast", () => { ).toBeVisible(); }); }); + +test.describe("'Turn on key storage' toast", () => { + let botClient: Bot | undefined; + + test.beforeEach(async ({ page, homeserver, credentials, toasts }) => { + // Set up all crypto stuff. Key storage defaults to on. + + const res = await createBot(page, homeserver, credentials); + const recoveryKey = res.recoveryKey; + botClient = res.botClient; + + await logIntoElement(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("menuitem", { name: "New room" }).click(); + await page.getByRole("textbox", { name: "Name" }).fill("Test room"); + await page.getByRole("button", { name: "Create room" }).click(); + + await toasts.rejectToast("Notifications"); + }); + + test("should not show toast if key storage is on", async ({ page, toasts }) => { + // Given the default situation after signing in + // Then no toast is shown (because key storage is on) + await toasts.assertNoToasts(); + + // When we reload + await page.reload(); + + // Give the toasts time to appear + await new Promise((resolve) => setTimeout(resolve, 2000)); + + // Then still no toast is shown + await toasts.assertNoToasts(); + }); + + test("should not show toast if key storage is off because we turned it off", async ({ app, page, toasts }) => { + // Given the backup is disabled because we disabled it + await disableKeyBackup(app); + + // Then no toast is shown + await toasts.assertNoToasts(); + + // When we reload + await page.reload(); + + // Give the toasts time to appear + await new Promise((resolve) => setTimeout(resolve, 2000)); + + // Then still no toast is shown + await toasts.assertNoToasts(); + }); + + test("should show toast if key storage is off but account data is missing", async ({ app, page, toasts }) => { + // Given the backup is disabled but we didn't set account data saying that is expected + await disableKeyBackup(app); + await botClient.setAccountData("m.org.matrix.custom.backup_disabled", { disabled: false }); + + // Wait for the account data setting to stick + await new Promise((resolve) => setTimeout(resolve, 2000)); + + // When we enter the app + await page.reload(); + + // Then the toast is displayed + let toast = await toasts.getToast("Turn on key storage"); + + // And when we click "Continue" + 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(); + + // And when we close that + await page.getByRole("button", { name: "Close dialog" }).click(); + + // Then we see the toast again + toast = await toasts.getToast("Turn on key storage"); + + // And when we click "Dismiss" + await toast.getByRole("button", { name: "Dismiss" }).click(); + + // Then we see the "are you sure?" dialog + await expect( + page.getByRole("heading", { name: "Are you sure you want to keep key storage turned off?" }), + ).toBeVisible(); + + // And when we close it by clicking away + await page.getByTestId("dialog-background").click({ force: true, position: { x: 10, y: 10 } }); + + // Then we see the toast again + toast = await toasts.getToast("Turn on key storage"); + + // And when we click Dismiss and then "Go to Settings" + await toast.getByRole("button", { name: "Dismiss" }).click(); + 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(); + + // And when we close that, see the toast, click Dismiss, and Yes, Dismiss + await page.getByRole("button", { name: "Close dialog" }).click(); + toast = await toasts.getToast("Turn on key storage"); + await toast.getByRole("button", { name: "Dismiss" }).click(); + await page.getByRole("button", { name: "Yes, dismiss" }).click(); + + // Then the toast is gone + await toasts.assertNoToasts(); + }); +}); diff --git a/playwright/e2e/crypto/utils.ts b/playwright/e2e/crypto/utils.ts index 77f1b747a1..289b123e86 100644 --- a/playwright/e2e/crypto/utils.ts +++ b/playwright/e2e/crypto/utils.ts @@ -228,8 +228,8 @@ export async function logIntoElement(page: Page, credentials: Credentials, secur await useSecurityKey.click(); } // Fill in the recovery key - await page.locator(".mx_Dialog").locator('input[type="password"]').fill(securityKey); - await page.locator(".mx_Dialog_primary:not([disabled])", { hasText: "Continue" }).click(); + 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(); } } @@ -263,7 +263,7 @@ 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('input[type="password"]').fill(securityKey); + await app.page.locator(".mx_Dialog").locator("textarea").fill(securityKey); await app.page.getByRole("button", { name: "Continue", disabled: false }).click(); await app.page.getByRole("button", { name: "Done" }).click(); await app.settings.closeDialog(); @@ -316,6 +316,25 @@ export async function enableKeyBackup(app: ElementAppPage): Promise { return recoveryKey; } +/** + * Open the encryption settings and disable key storage (and recovery) + * Assumes that the current device has been verified + */ +export async function disableKeyBackup(app: ElementAppPage): Promise { + const encryptionTab = await app.settings.openUserSettings("Encryption"); + + const keyStorageToggle = encryptionTab.getByRole("checkbox", { name: "Allow key storage" }); + if (await keyStorageToggle.isChecked()) { + await encryptionTab.getByRole("checkbox", { name: "Allow key storage" }).click(); + await encryptionTab.getByRole("button", { name: "Delete key storage" }).click(); + await encryptionTab.getByRole("checkbox", { name: "Allow key storage" }).isVisible(); + + // Wait for the update to account data to stick + await new Promise((resolve) => setTimeout(resolve, 2000)); + } + await app.settings.closeDialog(); +} + /** * Go through the "Set up Secure Backup" dialog (aka the `CreateSecretStorageDialog`). * 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 3ebfb22de1..d3aa060a27 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 @@ -5,8 +5,11 @@ * Please see LICENSE files in the repository root for full details. */ +import { type Visibility } from "matrix-js-sdk/src/matrix"; +import { type Locator, type Page } from "@playwright/test"; + import { expect, test } from "../../../element-web-test"; -import type { Page } from "@playwright/test"; +import { SettingLevel } from "../../../../src/settings/SettingLevel"; test.describe("Room list filters and sort", () => { test.use({ @@ -18,24 +21,134 @@ test.describe("Room list filters and sort", () => { labsFlags: ["feature_new_room_list"], }); - function getPrimaryFilters(page: Page) { + function getPrimaryFilters(page: Page): Locator { return page.getByRole("listbox", { name: "Room list filters" }); } + function getRoomOptionsMenu(page: Page): Locator { + return page.getByRole("button", { name: "Room Options" }); + } + + /** + * Get the room list + * @param page + */ + function getRoomList(page: Page) { + return page.getByTestId("room-list"); + } + test.beforeEach(async ({ page, app, bot, user }) => { // The notification toast is displayed above the search section await app.closeNotificationToast(); }); - test.describe("Room list", () => { - /** - * Get the room list - * @param page - */ - function getRoomList(page: Page) { - return page.getByTestId("room-list"); - } + test("Tombstoned rooms are not shown even when they receive updates", async ({ page, app, bot }) => { + // This bug shows up with this setting turned on + await app.settings.setValue("Spaces.allRoomsInHome", null, SettingLevel.DEVICE, true); + /* + We will first create a room named 'Old Room' and will invite the bot user to this room. + We will also send a simple message in this room. + */ + const oldRoomId = await app.client.createRoom({ name: "Old Room" }); + await app.client.inviteUser(oldRoomId, bot.credentials.userId); + await bot.joinRoom(oldRoomId); + const response = await app.client.sendMessage(oldRoomId, "Hello!"); + + /* + At this point, we haven't done anything interesting. + 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" }); + await expect(oldRoomTile).toBeVisible(); + + /* + Now let's tombstone 'Old Room'. + First we create a new room ('New Room') with the predecessor set to the old room.. + */ + const newRoomId = await bot.createRoom({ + name: "New Room", + creation_content: { + predecessor: { + event_id: response.event_id, + room_id: oldRoomId, + }, + }, + visibility: "public" as Visibility, + }); + + /* + Now we can send the tombstone event itself to the 'Old Room'. + */ + await app.client.sendStateEvent(oldRoomId, "m.room.tombstone", { + body: "This room has been replaced", + replacement_room: newRoomId, + }); + + // Let's join the replaced room. + await app.client.joinRoom(newRoomId); + + // We expect 'Old Room' to be hidden from the room list. + await expect(oldRoomTile).not.toBeVisible(); + + /* + Let's say some user in the 'Old Room' changes their display name. + This will send events to the all the rooms including 'Old Room'. + Nevertheless, the replaced room should not be shown in the room list. + */ + await bot.setDisplayName("MyNewName"); + await expect(oldRoomTile).not.toBeVisible(); + }); + + test.describe("Scroll behaviour", () => { + test("should scroll to the top of list when filter is applied and active room is not in filtered list", async ({ + page, + app, + }) => { + const createFavouriteRoom = async (name: string) => { + const id = await app.client.createRoom({ + name, + }); + await app.client.evaluate(async (client, favouriteId) => { + await client.setRoomTag(favouriteId, "m.favourite", { order: 0.5 }); + }, id); + }; + + // Create 5 favourite rooms + let i = 0; + for (; i < 5; i++) { + await createFavouriteRoom(`room${i}-fav`); + } + + // Create a non-favourite room + await app.client.createRoom({ name: `room-non-fav` }); + + // Create rest of the favourite rooms + for (; i < 20; i++) { + await createFavouriteRoom(`room${i}-fav`); + } + + // Open the non-favourite room + const roomListView = getRoomList(page); + const tile = roomListView.getByRole("gridcell", { name: "Open room room-non-fav" }); + await tile.scrollIntoViewIfNeeded(); + await tile.click(); + + // Enable Favourite filter + 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" }) + .evaluate((e) => e.scrollTop !== 0); + expect(isScrolledDown).toStrictEqual(false); + }); + }); + + test.describe("Room list", () => { let unReadDmId: string | undefined; let unReadRoomId: string | undefined; @@ -59,6 +172,38 @@ test.describe("Room list filters and sort", () => { await app.client.evaluate(async (client, favouriteId) => { await client.setRoomTag(favouriteId, "m.favourite", { order: 0.5 }); }, favouriteId); + + const lowPrioId = await app.client.createRoom({ name: "Low prio room" }); + await app.client.evaluate(async (client, id) => { + await client.setRoomTag(id, "m.lowpriority", { order: 0.5 }); + }, lowPrioId); + + await bot.createRoom({ + name: "invited room", + invite: [user.userId], + is_direct: true, + }); + + const mentionRoomId = await app.client.createRoom({ name: "room with mention" }); + await app.client.inviteUser(mentionRoomId, bot.credentials.userId); + await bot.joinRoom(mentionRoomId); + + const clientBot = await bot.prepareClient(); + await clientBot.evaluate( + async (client, { mentionRoomId, userId }) => { + await client.sendMessage(mentionRoomId, { + // @ts-ignore ignore usage of MsgType.text + "msgtype": "m.text", + "body": "User", + "format": "org.matrix.custom.html", + "formatted_body": `User`, + "m.mentions": { + user_ids: [userId], + }, + }); + }, + { mentionRoomId, userId: user.userId }, + ); }); test("should filter the list (with primary filters)", { tag: "@screenshot" }, async ({ page, app, user }) => { @@ -75,7 +220,7 @@ test.describe("Room list filters and sort", () => { // 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(2); + expect(await roomList.locator("role=gridcell").count()).toBe(4); await expect(primaryFilters).toMatchScreenshot("unread-primary-filters.png"); await primaryFilters.getByRole("option", { name: "Favourite" }).click(); @@ -84,37 +229,71 @@ test.describe("Room list filters and sort", () => { await primaryFilters.getByRole("option", { name: "People" }).click(); await expect(roomList.getByRole("gridcell", { name: "unread dm" })).toBeVisible(); - expect(await roomList.locator("role=gridcell").count()).toBe(1); + await expect(roomList.getByRole("gridcell", { name: "invited room" })).toBeVisible(); + expect(await roomList.locator("role=gridcell").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(); - expect(await roomList.locator("role=gridcell").count()).toBe(3); + 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 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 primaryFilters.getByRole("option", { name: "Invites" }).click(); + await expect(roomList.getByRole("gridcell", { name: "invited room" })).toBeVisible(); + expect(await roomList.locator("role=gridcell").count()).toBe(1); }); - test("unread filter should only match unread rooms that have a count", async ({ page, app, bot }) => { + test( + "unread filter should only match unread rooms that have a count", + { tag: "@screenshot" }, + async ({ page, app, bot }) => { + const roomListView = getRoomList(page); + + // Let's configure unread dm room so that we only get notification for mentions and keywords + await app.viewRoomById(unReadDmId); + await app.settings.openRoomSettings("Notifications"); + await page.getByText("@mentions & keywords").click(); + 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(); + + // 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(); + + // Unread filter should only show unread room and not unread dm! + const unreadDm = roomListView.getByRole("gridcell", { 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(); + }, + ); + + test("should sort the room list alphabetically", async ({ page }) => { const roomListView = getRoomList(page); - // Let's configure unread dm room so that we only get notification for mentions and keywords - await app.viewRoomById(unReadDmId); - await app.settings.openRoomSettings("Notifications"); - await page.getByText("@mentions & keywords").click(); - await app.settings.closeDialog(); + await getRoomOptionsMenu(page).click(); + await page.getByRole("menuitemradio", { name: "A-Z" }).click(); - // Let's open a room other than unread room or unread dm - await roomListView.getByRole("gridcell", { name: "Open room favourite room" }).click(); + await expect(roomListView.getByRole("gridcell").first()).toHaveText(/empty room/); + }); + + test("should move room to the top on message when sorting by activity", async ({ page, bot }) => { + const roomListView = getRoomList(page); - // 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(); - - // Unread filter should only show unread room and not unread dm! - await expect(roomListView.getByRole("gridcell", { name: "Open room unread room" })).toBeVisible(); - await expect(roomListView.getByRole("gridcell", { name: "Open room unread dm" })).not.toBeVisible(); + await expect(roomListView.getByRole("gridcell").first()).toHaveText(/unread dm/); }); }); @@ -137,15 +316,25 @@ test.describe("Room list filters and sort", () => { }, ); - test("should render the placeholder for unread filter", { tag: "@screenshot" }, async ({ page, app, user }) => { - const primaryFilters = getPrimaryFilters(page); - await primaryFilters.getByRole("option", { name: "Unread" }).click(); + [ + { filter: "Unreads", action: "Show all chats" }, + { filter: "Mentions", action: "See all activity" }, + { filter: "Invites", action: "See all activity" }, + ].forEach(({ filter, action }) => { + test( + `should render the placeholder for ${filter} filter`, + { tag: "@screenshot" }, + async ({ page, app, user }) => { + const primaryFilters = getPrimaryFilters(page); + await primaryFilters.getByRole("option", { name: filter }).click(); - const emptyRoomList = getEmptyRoomList(page); - await expect(emptyRoomList).toMatchScreenshot("unread-empty-room-list.png"); + const emptyRoomList = getEmptyRoomList(page); + await expect(emptyRoomList).toMatchScreenshot(`${filter}-empty-room-list.png`); - await emptyRoomList.getByRole("button", { name: "show all chats" }).click(); - await expect(primaryFilters.getByRole("option", { name: "Unread" })).not.toBeChecked(); + await emptyRoomList.getByRole("button", { name: action }).click(); + await expect(primaryFilters.getByRole("option", { name: filter })).not.toBeChecked(); + }, + ); }); ["People", "Rooms", "Favourite"].forEach((filter) => { 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 2ffbc608c2..8ca138a707 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 @@ -30,6 +30,9 @@ test.describe("Room list panel", () => { for (let i = 0; i < 20; i++) { await app.client.createRoom({ name: `room${i}` }); } + + // focus the user menu to avoid to have hover decoration + await page.getByRole("button", { name: "User menu" }).focus(); }); test("should render the room list panel", { tag: "@screenshot" }, async ({ page, app, user }) => { @@ -38,4 +41,10 @@ test.describe("Room list panel", () => { await expect(roomListView.getByRole("gridcell", { 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"); + 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 ebca899a13..73eb98512b 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 @@ -7,7 +7,7 @@ import { type Page } from "@playwright/test"; -import { test, expect } from "../../../element-web-test"; +import { expect, test } from "../../../element-web-test"; test.describe("Room list", () => { test.use({ @@ -29,6 +29,9 @@ test.describe("Room list", () => { test.beforeEach(async ({ page, app, user }) => { // The notification toast is displayed above the search section await app.closeNotificationToast(); + + // focus the user menu to avoid to have hover decoration + await page.getByRole("button", { name: "User menu" }).focus(); }); test.describe("Room list", () => { @@ -43,7 +46,8 @@ test.describe("Room list", () => { await expect(roomListView.getByRole("gridcell", { name: "Open room room29" })).toBeVisible(); await expect(roomListView).toMatchScreenshot("room-list.png"); - await roomListView.hover(); + // Put focus on the room list + await roomListView.getByRole("gridcell", { 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(); @@ -85,9 +89,49 @@ test.describe("Room list", () => { await expect(roomItem).not.toBeVisible(); }); + 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" }); + await roomItem.hover(); + + await expect(roomItem).toMatchScreenshot("room-list-item-hover.png"); + let roomItemMenu = roomItem.getByRole("button", { name: "Notification options" }); + await roomItemMenu.click(); + + // Default settings should be selected + await expect(page.getByRole("menuitem", { name: "Match default settings" })).toHaveAttribute( + "aria-selected", + "true", + ); + await expect(page).toMatchScreenshot("room-list-item-open-notification-options.png"); + + // It should make the room muted + await page.getByRole("menuitem", { name: "Mute room" }).click(); + + // Put focus on the room list + await roomListView.getByRole("gridcell", { name: "Open room room28" }).click(); + // Scroll to the end of the room list + await page.mouse.wheel(0, 1000); + + // The room decoration should have the muted icon + await expect(roomItem.getByTestId("notification-decoration")).toBeVisible(); + + await roomItem.hover(); + // On hover, the room should show the muted icon + await expect(roomItem).toMatchScreenshot("room-list-item-hover-silent.png"); + + roomItemMenu = roomItem.getByRole("button", { name: "Notification options" }); + await roomItemMenu.click(); + // The Mute room option should be selected + await expect(page.getByRole("menuitem", { name: "Mute room" })).toHaveAttribute("aria-selected", "true"); + await expect(page).toMatchScreenshot("room-list-item-open-notification-options-selection.png"); + }); + test("should scroll to the current room", async ({ page, app, user }) => { const roomListView = getRoomList(page); - await roomListView.hover(); + // Put focus on the room list + await roomListView.getByRole("gridcell", { name: "Open room room29" }).click(); // Scroll to the end of the room list await page.mouse.wheel(0, 1000); @@ -100,6 +144,154 @@ test.describe("Room list", () => { await filters.getByRole("option", { name: "People" }).click(); await expect(roomListView.getByRole("gridcell", { 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 page.keyboard.press("Alt+ArrowDown"); + + await expect(page.getByRole("heading", { name: "room28", level: 1 })).toBeVisible(); + }); + + test("should select the previous room", async ({ page, app, user }) => { + const roomListView = getRoomList(page); + await roomListView.getByRole("gridcell", { name: "Open room room28" }).click(); + await page.keyboard.press("Alt+ArrowUp"); + + await expect(page.getByRole("heading", { name: "room29", level: 1 })).toBeVisible(); + }); + + test("should select the last room", async ({ page, app, user }) => { + const roomListView = getRoomList(page); + await roomListView.getByRole("gridcell", { name: "Open room room29" }).click(); + await page.keyboard.press("Alt+ArrowUp"); + + await expect(page.getByRole("heading", { name: "room0", level: 1 })).toBeVisible(); + }); + + test("should select the next unread room", async ({ page, app, user, bot }) => { + const roomListView = getRoomList(page); + + const roomId = await app.client.createRoom({ name: "1 notification" }); + await app.client.inviteUser(roomId, bot.credentials.userId); + await bot.joinRoom(roomId); + await bot.sendMessage(roomId, "I am a robot. Beep."); + + await roomListView.getByRole("gridcell", { name: "Open room room20" }).click(); + + await page.keyboard.press("Alt+Shift+ArrowDown"); + + await expect(page.getByRole("heading", { name: "1 notification", level: 1 })).toBeVisible(); + }); + }); + + test.describe("Keyboard navigation", () => { + 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" }); + + // open the room + await room29.click(); + // put focus back on the room list item + await room29.click(); + await expect(room29).toBeFocused(); + + await page.keyboard.press("ArrowDown"); + await expect(room28).toBeFocused(); + await expect(room29).not.toBeFocused(); + + await page.keyboard.press("ArrowUp"); + await expect(room29).toBeFocused(); + await expect(room28).not.toBeFocused(); + }); + + 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 moreButton = room29.getByRole("button", { name: "More options" }); + const notificationButton = room29.getByRole("button", { name: "Notification options" }); + + await room29.click(); + // put focus back on the room list item + await room29.click(); + await page.keyboard.press("Tab"); + await expect(moreButton).toBeFocused(); + await page.keyboard.press("Tab"); + await expect(notificationButton).toBeFocused(); + + // Open the menu + await notificationButton.click(); + // 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("Escape"); + // Focus should be back on the room list item + await expect(room29).toBeFocused(); + }); + }); + }); + + test.describe("Avatar decoration", () => { + test.use({ labsFlags: ["feature_video_rooms", "feature_new_room_list"] }); + + test("should be a public room", { tag: "@screenshot" }, async ({ page, app, user }) => { + // @ts-ignore Visibility enum is not accessible + await app.client.createRoom({ name: "public room", visibility: "public" }); + + // focus the user menu to avoid to have hover decoration + await page.getByRole("button", { name: "User menu" }).focus(); + + const roomListView = getRoomList(page); + const publicRoom = roomListView.getByRole("gridcell", { 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("gridcell", { name: "low priority room" }); + + // Make room low priority + await publicRoom.hover(); + const roomItemMenu = publicRoom.getByRole("button", { name: "More Options" }); + await roomItemMenu.click(); + 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 user menu to avoid to have hover decoration + await page.getByRole("button", { name: "User menu" }).focus(); + 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("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" }); + + // focus the user menu to avoid to have hover decoration + await page.getByRole("button", { name: "User menu" }).focus(); + + await expect(videoRoom).toBeVisible(); + await expect(videoRoom).toMatchScreenshot("room-list-item-video.png"); + }); }); test.describe("Notification decoration", () => { @@ -162,6 +354,27 @@ test.describe("Room list", () => { await expect(room).toMatchScreenshot("room-list-item-mention.png"); }); + test("should render a message preview", { tag: "@screenshot" }, async ({ page, app, user, bot }) => { + await app.settings.openUserSettings("Preferences"); + await page.getByRole("switch", { name: "Show message previews" }).click(); + await app.closeDialog(); + + const roomListView = getRoomList(page); + + const roomId = await app.client.createRoom({ name: "activity" }); + + // focus the user menu to avoid to have hover decoration + await page.getByRole("button", { name: "User menu" }).focus(); + + await app.client.inviteUser(roomId, bot.credentials.userId); + await bot.joinRoom(roomId); + await bot.sendMessage(roomId, "I am a robot. Beep."); + + const room = roomListView.getByRole("gridcell", { name: "activity" }); + await expect(room.getByText("I am a robot. Beep.")).toBeVisible(); + await expect(room).toMatchScreenshot("room-list-item-message-preview.png"); + }); + test("should render an activity decoration", { tag: "@screenshot" }, async ({ page, app, user, bot }) => { const roomListView = getRoomList(page); @@ -201,8 +414,8 @@ test.describe("Room list", () => { await room.getByRole("button", { name: "More Options" }).click(); await page.getByRole("menuitem", { name: "mark as unread" }).click(); - // Remove hover on the room list item - await roomListView.hover(); + // focus the user menu to avoid to have hover decoration + await page.getByRole("button", { name: "User menu" }).focus(); await expect(room).toMatchScreenshot("room-list-item-mark-as-unread.png"); }); diff --git a/playwright/e2e/login/login-consent.spec.ts b/playwright/e2e/login/login-consent.spec.ts index c4a4b1409f..23baf023fa 100644 --- a/playwright/e2e/login/login-consent.spec.ts +++ b/playwright/e2e/login/login-consent.spec.ts @@ -288,6 +288,43 @@ test.describe("Login", () => { await expect(h1).toBeVisible(); }); }); + + test("Can reset identity to become verified", async ({ page, homeserver, request, credentials }) => { + // Log in + const res = await request.post(`${homeserver.baseUrl}/_matrix/client/v3/keys/device_signing/upload`, { + headers: { Authorization: `Bearer ${credentials.accessToken}` }, + data: DEVICE_SIGNING_KEYS_BODY, + }); + if (!res.ok()) { + console.log(`Uploading dummy keys failed with HTTP status ${res.status}`, await res.json()); + throw new Error("Uploading dummy keys failed"); + } + + await page.goto("/"); + await login(page, homeserver, credentials); + + await expect(page.getByRole("heading", { name: "Verify this device", level: 1 })).toBeVisible(); + + // Start the reset process + await page.getByRole("button", { name: "Proceed with reset" }).click(); + + // First try cancelling and restarting + await page.getByRole("button", { name: "Cancel" }).click(); + await page.getByRole("button", { name: "Proceed with reset" }).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(); + + // Finally we actually continue + await page.getByRole("button", { name: "Continue" }).click(); + await page.getByPlaceholder("Password").fill(credentials.password); + await page.getByRole("button", { name: "Continue" }).click(); + + // We end up at the Home screen + await expect(page).toHaveURL(/\/#\/home$/, { timeout: 10000 }); + await expect(page.getByRole("heading", { name: "Welcome Dave", exact: true })).toBeVisible(); + }); }); }); diff --git a/playwright/e2e/modules/loader.spec.ts b/playwright/e2e/modules/loader.spec.ts index e21b5c2d92..52c7a02988 100644 --- a/playwright/e2e/modules/loader.spec.ts +++ b/playwright/e2e/modules/loader.spec.ts @@ -15,6 +15,7 @@ test.describe("Module loading", () => { test.describe("Example Module", () => { test.use({ config: { + brand: "TestBrand", modules: ["/modules/example-module.js"], }, page: async ({ page }, use) => { @@ -25,11 +26,31 @@ test.describe("Module loading", () => { }, }); - test("should show alert", async ({ page }) => { - const dialogPromise = page.waitForEvent("dialog"); - await page.goto("/"); - const dialog = await dialogPromise; - expect(dialog.message()).toBe("Testing module loading successful!"); - }); + const testCases = [ + ["en", "TestBrand module loading successful!"], + ["de", "TestBrand-Module erfolgreich geladen!"], + ]; + + for (const [lang, message] of testCases) { + test.describe(`language-${lang}`, () => { + test.use({ + config: async ({ config }, use) => { + await use({ + ...config, + setting_defaults: { + language: lang, + }, + }); + }, + }); + + test("should show alert", async ({ page }) => { + const dialogPromise = page.waitForEvent("dialog"); + await page.goto("/"); + const dialog = await dialogPromise; + expect(dialog.message()).toBe(message); + }); + }); + } }); }); diff --git a/playwright/e2e/oidc/index.ts b/playwright/e2e/oidc/index.ts index 1989e8764f..02de6e2f03 100644 --- a/playwright/e2e/oidc/index.ts +++ b/playwright/e2e/oidc/index.ts @@ -37,6 +37,8 @@ export async function registerAccountMas( await page.getByRole("textbox", { name: "6-digit code" }).fill(code); 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 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 2592311554..a6fbf231ce 100644 --- a/playwright/e2e/oidc/oidc-native.spec.ts +++ b/playwright/e2e/oidc/oidc-native.spec.ts @@ -33,7 +33,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 +55,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(); @@ -83,12 +83,11 @@ 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!"); await expect(page.getByText("Welcome")).toBeVisible(); await page.goto("about:blank"); - // @ts-expect-error const result = await mas.manage("kill-sessions", userId); expect(result.output).toContain("Ended 1 active OAuth 2.0 session"); 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 7486af7033..3670e64308 100644 --- a/playwright/e2e/regression-tests/pills-click-in-app.spec.ts +++ b/playwright/e2e/regression-tests/pills-click-in-app.spec.ts @@ -43,6 +43,7 @@ test.describe("Pills", () => { // go back to the message room and try to click on the pill text, as a user would await app.viewRoomByName(messageRoom); + await expect(page).toHaveURL(new RegExp(`/#/room/${messageRoomId}`)); const pillText = page.locator(".mx_EventTile_body .mx_Pill .mx_Pill_text"); await expect(pillText).toHaveCSS("pointer-events", "none"); await pillText.click({ force: true }); // force is to ensure we bypass pointer-events diff --git a/playwright/e2e/release-announcement/releaseAnnouncement.spec.ts b/playwright/e2e/release-announcement/releaseAnnouncement.spec.ts index c2ebd5b853..812b66b796 100644 --- a/playwright/e2e/release-announcement/releaseAnnouncement.spec.ts +++ b/playwright/e2e/release-announcement/releaseAnnouncement.spec.ts @@ -15,20 +15,34 @@ test.describe("Release announcement", () => { feature_release_announcement: true, }, }, - labsFlags: ["threadsActivityCentre"], + room: async ({ app, user }, use) => { + const roomId = await app.client.createRoom({ + name: "Test room", + }); + await app.viewRoomById(roomId); + await use({ roomId }); + }, }); - test("should display the release announcement process", { tag: "@screenshot" }, async ({ page, app, util }) => { - // The TAC release announcement should be displayed - await util.assertReleaseAnnouncementIsVisible("Threads Activity Centre"); - // Hide the release announcement - await util.markReleaseAnnouncementAsRead("Threads Activity Centre"); - await util.assertReleaseAnnouncementIsNotVisible("Threads Activity Centre"); + test( + "should display the pinned messages release announcement", + { tag: "@screenshot" }, + async ({ page, app, room, util }) => { + await app.toggleRoomInfoPanel(); - await page.reload(); - // Wait for EW to load - await expect(page.getByRole("navigation", { name: "Spaces" })).toBeVisible(); - // Check that once the release announcement has been marked as viewed, it does not appear again - await util.assertReleaseAnnouncementIsNotVisible("Threads Activity Centre"); - }); + const name = "All new pinned messages"; + + // The release announcement should be displayed + await util.assertReleaseAnnouncementIsVisible(name); + // Hide the release announcement + await util.markReleaseAnnouncementAsRead(name); + await util.assertReleaseAnnouncementIsNotVisible(name); + + 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); + }, + ); }); diff --git a/playwright/e2e/right-panel/right-panel.spec.ts b/playwright/e2e/right-panel/right-panel.spec.ts index 44b051c987..0fbd306d86 100644 --- a/playwright/e2e/right-panel/right-panel.spec.ts +++ b/playwright/e2e/right-panel/right-panel.spec.ts @@ -11,6 +11,7 @@ import { type Locator, type Page } from "@playwright/test"; import { test, expect } from "../../element-web-test"; import { checkRoomSummaryCard, viewRoomSummaryByName } from "./utils"; import { isDendrite } from "../../plugins/homeserver/dendrite"; +import { Bot } from "../../pages/bot"; const ROOM_NAME = "Test room"; const ROOM_NAME_LONG = @@ -21,20 +22,23 @@ const ROOM_NAME_LONG = "officia deserunt mollit anim id est laborum."; const SPACE_NAME = "Test space"; const NAME = "Alice"; +const LONG_NAME = "Bob long long long long long long long long long long long long long long long name"; + const ROOM_ADDRESS_LONG = "loremIpsumDolorSitAmetConsecteturAdipisicingElitSedDoEiusmodTemporIncididuntUtLaboreEtDoloreMagnaAliqua"; function getMemberTileByName(page: Page, name: string): Locator { - return page.locator(`.mx_MemberTileView, [title="${name}"]`); + return page.locator(".mx_MemberListView .mx_MemberTileView_name").filter({ hasText: name }); } test.describe("RightPanel", () => { + let testRoomId: string; test.use({ displayName: NAME, }); test.beforeEach(async ({ app, user }) => { - await app.client.createRoom({ name: ROOM_NAME }); + testRoomId = await app.client.createRoom({ name: ROOM_NAME }); await app.client.createSpace({ name: SPACE_NAME }); }); @@ -77,10 +81,12 @@ test.describe("RightPanel", () => { await expect(page.locator(".mx_RightPanel")).toMatchScreenshot("with-leave-room.png"); }); - test("should handle clicking add widgets", async ({ page, app }) => { + test("should handle clicking add widgets", { tag: "@screenshot" }, async ({ page, app }) => { await viewRoomSummaryByName(page, app, ROOM_NAME); await page.getByRole("menuitem", { name: "Extensions" }).click(); + await expect(page.getByTestId("right-panel")).toMatchScreenshot("with-extensions.png"); + await page.getByRole("button", { name: "Add extensions" }).click(); await expect(page.locator(".mx_IntegrationManager")).toBeVisible(); }); @@ -134,15 +140,63 @@ test.describe("RightPanel", () => { await page.getByLabel("Room info").nth(1).click(); await checkRoomSummaryCard(page, ROOM_NAME); }); + + test( + "should handle viewing long room member name", + { tag: "@screenshot" }, + async ({ page, homeserver, app }) => { + const bobLongName = new Bot(page, homeserver, { displayName: LONG_NAME }); + await bobLongName.prepareClient(); + await app.client.inviteUser(testRoomId, bobLongName.credentials.userId); + await bobLongName.joinRoom(testRoomId); + + await viewRoomSummaryByName(page, app, ROOM_NAME); + + await page.locator(".mx_RightPanel").getByRole("menuitem", { name: "People" }).click(); + await expect(page.locator(".mx_MemberListView")).toBeVisible(); + + await getMemberTileByName(page, LONG_NAME).click(); + await expect(page.locator(".mx_UserInfo")).toBeVisible(); + await expect(page.locator(".mx_UserInfo_profile").getByText(LONG_NAME)).toBeVisible(); + + await expect(page.locator(".mx_UserInfo")).toMatchScreenshot("with-long-name.png", { + mask: [page.locator(".mx_UserInfo_profile_mxid")], + css: ` + /* Use monospace font for consistent mask width */ + .mx_UserInfo_profile_mxid { + font-family: Inconsolata !important; + } + `, + }); + }, + ); + test.describe("room reporting", () => { test.skip(isDendrite, "Dendrite does not implement room reporting"); - test("should handle reporting a room", async ({ page, app }) => { + test("should handle reporting a room", { tag: "@screenshot" }, async ({ page, app }) => { await viewRoomSummaryByName(page, app, ROOM_NAME); + await page.getByRole("menuitem", { name: "Report room" }).click(); const dialog = await page.getByRole("dialog", { name: "Report Room" }); await dialog.getByLabel("reason").fill("This room should be reported"); + await expect(dialog).toMatchScreenshot("room-report-dialog.png"); await dialog.getByRole("button", { name: "Send report" }).click(); - await expect(page.getByText("Your report was sent.")).toBeVisible(); + + // Dialog should have gone + await expect(page.locator(".mx_Dialog")).toHaveCount(0); + }); + test("should handle reporting a room and leaving the room", async ({ page, app }) => { + await viewRoomSummaryByName(page, app, ROOM_NAME); + + await page.getByRole("menuitem", { name: "Report room" }).click(); + const dialog = await page.getByRole("dialog", { name: "Report room" }); + await dialog.getByRole("switch", { name: "Leave room" }).click(); + await dialog.getByLabel("reason").fill("This room should be reported"); + await dialog.getByRole("button", { name: "Send report" }).click(); + await page.getByRole("dialog", { name: "Leave room" }).getByRole("button", { name: "Leave" }).click(); + + // Dialog should have gone + await expect(page.locator(".mx_Dialog")).toHaveCount(0); }); }); }); diff --git a/playwright/e2e/room/invites.spec.ts b/playwright/e2e/room/invites.spec.ts new file mode 100644 index 0000000000..9312b24445 --- /dev/null +++ b/playwright/e2e/room/invites.spec.ts @@ -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 { test, expect } from "../../element-web-test"; + +test.describe("Invites", () => { + test.use({ + displayName: "Alice", + botCreateOpts: { + displayName: "Bob", + }, + }); + + test("should render an invite view", { tag: "@screenshot" }, async ({ page, homeserver, user, bot, app }) => { + const roomId = await bot.createRoom({ is_direct: true }); + await bot.inviteUser(roomId, user.userId); + await app.viewRoomByName("Bob"); + await expect(page.locator(".mx_RoomView")).toMatchScreenshot("Invites_room_view.png", { + // Hide the mxid, which is not stable. + css: ` + .mx_RoomPreviewBar_inviter_mxid { + display: none !important; + } + `, + }); + }); + + test("should be able to decline an invite", async ({ page, homeserver, user, bot, app }) => { + const roomId = await bot.createRoom({ is_direct: true }); + await bot.inviteUser(roomId, user.userId); + await app.viewRoomByName("Bob"); + await page.getByRole("button", { name: "Decline", exact: true }).click(); + await expect(page.getByRole("heading", { name: "Welcome Alice", exact: true })).toBeVisible(); + await expect( + page.getByRole("tree", { name: "Rooms" }).getByRole("treeitem", { name: "Bob", exact: true }), + ).not.toBeVisible(); + }); + + test( + "should be able to decline an invite, report the room and ignore the user", + { tag: "@screenshot" }, + async ({ page, homeserver, user, bot, app }) => { + const roomId = await bot.createRoom({ is_direct: true }); + await bot.inviteUser(roomId, user.userId); + await app.viewRoomByName("Bob"); + await page.getByRole("button", { name: "Decline and block" }).click(); + await page.getByLabel("Ignore user").click(); + await page.getByLabel("Report room").click(); + await page.getByLabel("Reason").fill("Do not want the room"); + const roomReported = page.waitForRequest( + (req) => + req.url().endsWith(`/_matrix/client/v3/rooms/${encodeURIComponent(roomId)}/report`) && + req.method() === "POST", + ); + await expect(page.getByRole("dialog", { name: "Decline invitation" })).toMatchScreenshot( + "Invites_reject_dialog.png", + ); + await page.getByRole("button", { name: "Decline invite" }).click(); + + // Check room was reported. + await roomReported; + + // Check user is ignored. + await app.settings.openUserSettings("Security & Privacy"); + const ignoredUsersList = page.getByRole("list", { name: "Ignored users" }); + await ignoredUsersList.scrollIntoViewIfNeeded(); + await expect(ignoredUsersList.getByRole("listitem", { name: bot.credentials.userId })).toBeVisible(); + }, + ); +}); 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 427c801ef4..ed1daecf35 100644 --- a/playwright/e2e/settings/encryption-user-tab/encryption-tab.spec.ts +++ b/playwright/e2e/settings/encryption-user-tab/encryption-tab.spec.ts @@ -19,126 +19,162 @@ import { test.describe("Encryption tab", () => { test.use({ displayName: "Alice" }); - let recoveryKey: GeneratedSecretStorageKey; - let expectedBackupVersion: string; + test.describe("when encryption is set up", () => { + let recoveryKey: GeneratedSecretStorageKey; + let expectedBackupVersion: string; - 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); - recoveryKey = res.recoveryKey; - expectedBackupVersion = res.expectedBackupVersion; - }); + 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); + recoveryKey = res.recoveryKey; + expectedBackupVersion = res.expectedBackupVersion; + }); - test( - "should show a 'Verify this device' button if the device is unverified", - { tag: "@screenshot" }, - async ({ page, app, util }) => { - const dialog = await util.openEncryptionTab(); - const content = util.getEncryptionTabContent(); + test( + "should show a 'Verify this device' button if the device is unverified", + { tag: "@screenshot" }, + async ({ page, app, util }) => { + const dialog = await util.openEncryptionTab(); + const content = util.getEncryptionTabContent(); - // The user's device is in an unverified state, therefore the only option available to them here is to verify it - const verifyButton = dialog.getByRole("button", { name: "Verify this device" }); - await expect(verifyButton).toBeVisible(); - await expect(content).toMatchScreenshot("verify-device-encryption-tab.png"); - await verifyButton.click(); + // The user's device is in an unverified state, therefore the only option available to them here is to verify it + const verifyButton = dialog.getByRole("button", { name: "Verify this device" }); + await expect(verifyButton).toBeVisible(); + await expect(content).toMatchScreenshot("verify-device-encryption-tab.png"); + await verifyButton.click(); - await util.verifyDevice(recoveryKey); + await util.verifyDevice(recoveryKey); - await expect(content).toMatchScreenshot("default-tab.png", { - mask: [content.getByTestId("deviceId"), content.getByTestId("sessionKey")], - }); + await expect(content).toMatchScreenshot("default-tab.png", { + mask: [content.getByTestId("deviceId"), content.getByTestId("sessionKey")], + }); - // 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 - // The backup decryption key should be in cache also, as we got it directly from the 4S - await checkDeviceIsConnectedKeyBackup(app, expectedBackupVersion, true); - }, - ); + // 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 what happens if the cross-signing secrets are in secret storage but are not cached in the local DB. - // - // This can happen if we verified another device and secret-gossiping failed, or the other device itself lacked the secrets. - // We simulate this case by deleting the cached secrets in the indexedDB. - test( - "should prompt to enter the recovery key when the secrets are not cached locally", - { tag: "@screenshot" }, - async ({ page, app, util }) => { + // Test what happens if the cross-signing secrets are in secret storage but are not cached in the local DB. + // + // This can happen if we verified another device and secret-gossiping failed, or the other device itself lacked the secrets. + // We simulate this case by deleting the cached secrets in the indexedDB. + test( + "should prompt to enter the recovery key when the secrets are not cached locally", + { tag: "@screenshot" }, + async ({ page, app, util }) => { + await verifySession(app, recoveryKey.encodedPrivateKey); + // We need to delete the cached secrets + await deleteCachedSecrets(page); + + await util.openEncryptionTab(); + // We ask the user to enter the recovery key + const dialog = util.getEncryptionTabContent(); + const enterKeyButton = dialog.getByRole("button", { name: "Enter recovery key" }); + await expect(enterKeyButton).toBeVisible(); + await expect(dialog).toMatchScreenshot("out-of-sync-recovery.png"); + await enterKeyButton.click(); + + // Fill the recovery key + await util.enterRecoveryKey(recoveryKey); + await expect(dialog).toMatchScreenshot("default-tab.png", { + mask: [dialog.getByTestId("deviceId"), dialog.getByTestId("sessionKey")], + }); + + // 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("should display the reset identity panel when the user clicks on 'Forgot recovery key?'", async ({ + page, + app, + util, + }) => { await verifySession(app, recoveryKey.encodedPrivateKey); // We need to delete the cached secrets await deleteCachedSecrets(page); + // The "Key storage is out sync" section is displayed and the user click on the "Forgot recovery key?" button await util.openEncryptionTab(); - // We ask the user to enter the recovery key const dialog = util.getEncryptionTabContent(); - const enterKeyButton = dialog.getByRole("button", { name: "Enter recovery key" }); - await expect(enterKeyButton).toBeVisible(); - await expect(dialog).toMatchScreenshot("out-of-sync-recovery.png"); - await enterKeyButton.click(); + await dialog.getByRole("button", { name: "Forgot recovery key?" }).click(); - // Fill the recovery key - await util.enterRecoveryKey(recoveryKey); - await expect(dialog).toMatchScreenshot("default-tab.png", { - mask: [dialog.getByTestId("deviceId"), dialog.getByTestId("sessionKey")], - }); + // The user is prompted to reset their identity + await expect( + dialog.getByText("Forgot your recovery key? You’ll need to reset your identity."), + ).toBeVisible(); + }); - // Check that our device is now cross-signed - await checkDeviceIsCrossSigned(app); + test("should warn before turning off key storage", { tag: "@screenshot" }, async ({ page, app, util }) => { + await verifySession(app, recoveryKey.encodedPrivateKey); + await util.openEncryptionTab(); - // 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); - }, - ); + await page.getByRole("checkbox", { name: "Allow key storage" }).click(); - test("should display the reset identity panel when the user clicks on 'Forgot recovery key?'", async ({ - page, - app, - util, - }) => { - await verifySession(app, recoveryKey.encodedPrivateKey); - // We need to delete the cached secrets - await deleteCachedSecrets(page); + await expect( + page.getByRole("heading", { name: "Are you sure you want to turn off key storage and delete it?" }), + ).toBeVisible(); - // The "Key storage is out sync" section is displayed and the user click on the "Forgot recovery key?" button - await util.openEncryptionTab(); - const dialog = util.getEncryptionTabContent(); - await dialog.getByRole("button", { name: "Forgot recovery key?" }).click(); + await expect(util.getEncryptionTabContent()).toMatchScreenshot("delete-key-storage-confirm.png"); - // The user is prompted to reset their identity - await expect(dialog.getByText("Forgot your recovery key? You’ll need to reset your identity.")).toBeVisible(); + const deleteRequestPromises = [ + page.waitForRequest((req) => req.url().endsWith("/account_data/m.cross_signing.master")), + page.waitForRequest((req) => req.url().endsWith("/account_data/m.cross_signing.self_signing")), + page.waitForRequest((req) => req.url().endsWith("/account_data/m.cross_signing.user_signing")), + page.waitForRequest((req) => req.url().endsWith("/account_data/m.megolm_backup.v1")), + page.waitForRequest((req) => req.url().endsWith("/account_data/m.secret_storage.default_key")), + page.waitForRequest((req) => req.url().includes("/account_data/m.secret_storage.key.")), + ]; + + await page.getByRole("button", { name: "Delete key storage" }).click(); + + await expect(page.getByRole("checkbox", { name: "Allow key storage" })).not.toBeChecked(); + + for (const prom of deleteRequestPromises) { + const request = await prom; + expect(request.method()).toBe("PUT"); + expect(request.postData()).toBe(JSON.stringify({})); + } + }); }); - test("should warn before turning off key storage", { tag: "@screenshot" }, async ({ page, app, util }) => { - await verifySession(app, recoveryKey.encodedPrivateKey); - await util.openEncryptionTab(); + test.describe("when encryption is not set up", () => { + test("'Verify this device' allows us to become verified", async ({ + page, + user, + credentials, + app, + }, workerInfo) => { + const settings = await app.settings.openUserSettings("Encryption"); - await page.getByRole("checkbox", { name: "Allow key storage" }).click(); + // Initially, our device is not verified + await expect(settings.getByRole("heading", { name: "Device not verified" })).toBeVisible(); - await expect( - page.getByRole("heading", { name: "Are you sure you want to turn off key storage and delete it?" }), - ).toBeVisible(); + // We will reset our identity + await settings.getByRole("button", { name: "Verify this device" }).click(); + await page.getByRole("button", { name: "Proceed with reset" }).click(); - await expect(util.getEncryptionTabContent()).toMatchScreenshot("delete-key-storage-confirm.png"); + // First try cancelling and restarting + await page.getByRole("button", { name: "Cancel" }).click(); + await page.getByRole("button", { name: "Proceed with reset" }).click(); - const deleteRequestPromises = [ - page.waitForRequest((req) => req.url().endsWith("/account_data/m.cross_signing.master")), - page.waitForRequest((req) => req.url().endsWith("/account_data/m.cross_signing.self_signing")), - page.waitForRequest((req) => req.url().endsWith("/account_data/m.cross_signing.user_signing")), - page.waitForRequest((req) => req.url().endsWith("/account_data/m.megolm_backup.v1")), - page.waitForRequest((req) => req.url().endsWith("/account_data/m.secret_storage.default_key")), - page.waitForRequest((req) => req.url().includes("/account_data/m.secret_storage.key.")), - ]; + // 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: "Delete key storage" }).click(); + // Finally we actually continue + await page.getByRole("button", { name: "Continue" }).click(); - await expect(page.getByRole("checkbox", { name: "Allow key storage" })).not.toBeChecked(); - - for (const prom of deleteRequestPromises) { - const request = await prom; - expect(request.method()).toBe("PUT"); - expect(request.postData()).toBe(JSON.stringify({})); - } + // Now we are verified, so we see the Key storage toggle + await expect(settings.getByRole("heading", { name: "Key storage" })).toBeVisible(); + }); }); }); diff --git a/playwright/e2e/settings/preferences-user-settings-tab.spec.ts b/playwright/e2e/settings/preferences-user-settings-tab.spec.ts index 33392d8848..e295eceb44 100644 --- a/playwright/e2e/settings/preferences-user-settings-tab.spec.ts +++ b/playwright/e2e/settings/preferences-user-settings-tab.spec.ts @@ -21,10 +21,12 @@ 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(); diff --git a/playwright/e2e/settings/roles-permissions-room-settings-tab.spec.ts b/playwright/e2e/settings/roles-permissions-room-settings-tab.spec.ts index 7cc4001c58..06ee25e9cb 100644 --- a/playwright/e2e/settings/roles-permissions-room-settings-tab.spec.ts +++ b/playwright/e2e/settings/roles-permissions-room-settings-tab.spec.ts @@ -37,6 +37,15 @@ test.describe("Roles & Permissions room settings tab", () => { // Change the role of Alice to Moderator (50) await combobox.selectOption("Moderator"); await expect(combobox).toHaveValue("50"); + + // Should display a modal to warn that we are demoting the only admin user + const modal = await page.locator(".mx_Dialog", { + hasText: "Warning", + }); + await expect(modal).toBeVisible(); + // Click on the continue button in the modal + await modal.getByRole("button", { name: "Continue" }).click(); + const respPromise = page.waitForRequest("**/state/**"); await applyButton.click(); await respPromise; diff --git a/playwright/e2e/share-dialog/share-by-url.spec.ts b/playwright/e2e/share-dialog/share-by-url.spec.ts new file mode 100644 index 0000000000..c5b9174782 --- /dev/null +++ b/playwright/e2e/share-dialog/share-by-url.spec.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 { test, expect } from "../../element-web-test"; + +test.describe("share from URL", () => { + test.use({ + displayName: "Alice", + room: async ({ app }, use) => { + const roomId = await app.client.createRoom({ name: "A test room" }); + await use({ roomId }); + }, + }); + + test("should share message when users navigates to share URL", async ({ page, user, room, app }) => { + await page.goto("/#/share?msg=Hello+world"); + // 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 app.viewRoomByName("A test room"); + const lastMessage = page.locator(".mx_RoomView_MessageList .mx_EventTile_last"); + await expect(lastMessage).toBeVisible(); + const lastMessageText = await lastMessage.locator(".mx_EventTile_body").innerText(); + await expect(lastMessageText).toBe("Hello world"); + }); +}); diff --git a/playwright/e2e/sliding-sync/sliding-sync.spec.ts b/playwright/e2e/sliding-sync/sliding-sync.spec.ts index 118bd4585e..b540cd11d5 100644 --- a/playwright/e2e/sliding-sync/sliding-sync.spec.ts +++ b/playwright/e2e/sliding-sync/sliding-sync.spec.ts @@ -255,8 +255,8 @@ test.describe("Sliding Sync", () => { // Select the room to reject await page.getByRole("treeitem", { name: "Room to Reject" }).click(); - // Reject the invite - await page.locator(".mx_RoomView").getByRole("button", { name: "Reject", exact: true }).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"), diff --git a/playwright/e2e/spaces/threads-activity-centre/threadsActivityCentre.spec.ts b/playwright/e2e/spaces/threads-activity-centre/threadsActivityCentre.spec.ts index 683577dce4..eec28099a5 100644 --- a/playwright/e2e/spaces/threads-activity-centre/threadsActivityCentre.spec.ts +++ b/playwright/e2e/spaces/threads-activity-centre/threadsActivityCentre.spec.ts @@ -19,7 +19,6 @@ test.describe("Threads Activity Centre", { tag: "@no-firefox" }, () => { test.use({ displayName: "Alice", botCreateOpts: { displayName: "Other User" }, - labsFlags: ["threadsActivityCentre"], }); test( diff --git a/playwright/e2e/timeline/media-preview-settings.spec.ts b/playwright/e2e/timeline/media-preview-settings.spec.ts new file mode 100644 index 0000000000..e32a7dbc82 --- /dev/null +++ b/playwright/e2e/timeline/media-preview-settings.spec.ts @@ -0,0 +1,139 @@ +/* +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 * as fs from "node:fs"; +import { type EventType, type MsgType, type RoomJoinRulesEventContent } from "matrix-js-sdk/src/types"; + +import { test, expect } from "../../element-web-test"; + +const MEDIA_FILE = fs.readFileSync("playwright/sample-files/riot.png"); + +test.describe("Media preview settings", () => { + test.use({ + displayName: "Alan", + botCreateOpts: { + displayName: "Bob", + }, + room: async ({ app, page, homeserver, bot, user }, use) => { + const mxc = (await bot.uploadContent(MEDIA_FILE, { name: "image.png", type: "image/png" })).content_uri; + const roomId = await bot.createRoom({ + name: "Test room", + invite: [user.userId], + initial_state: [{ type: "m.room.avatar", content: { url: mxc }, state_key: "" }], + }); + await bot.sendEvent(roomId, null, "m.room.message" as EventType, { + msgtype: "m.image" as MsgType, + body: "image.png", + url: mxc, + }); + + await use({ roomId }); + }, + }); + + test("should be able to hide avatars of inviters", { tag: "@screenshot" }, async ({ page, app, room, user }) => { + let settings = await app.settings.openUserSettings("Preferences"); + await settings.getByLabel("Hide avatars of room and inviter").click(); + await app.closeDialog(); + await app.viewRoomById(room.roomId); + await expect( + page.getByRole("complementary").filter({ hasText: "Do you want to join Test room" }), + ).toMatchScreenshot("invite-no-avatar.png", { + // Hide the mxid, which is not stable. + css: ` + .mx_RoomPreviewBar_inviter_mxid { + display: none !important; + } + `, + }); + await expect( + page.getByRole("tree", { name: "Rooms" }).getByRole("treeitem", { name: "Test room" }), + ).toMatchScreenshot("invite-room-tree-no-avatar.png"); + + // And then go back to being visible + settings = await app.settings.openUserSettings("Preferences"); + await settings.getByLabel("Hide avatars of room and inviter").click(); + await app.closeDialog(); + await page.goto("#/home"); + await app.viewRoomById(room.roomId); + await expect( + page.getByRole("complementary").filter({ hasText: "Do you want to join Test room" }), + ).toMatchScreenshot("invite-with-avatar.png", { + // Hide the mxid, which is not stable. + css: ` + .mx_RoomPreviewBar_inviter_mxid { + display: none !important; + } + `, + }); + await expect( + page.getByRole("tree", { name: "Rooms" }).getByRole("treeitem", { name: "Test room" }), + ).toMatchScreenshot("invite-room-tree-with-avatar.png"); + }); + + test("should be able to hide media in rooms globally", async ({ page, app, room, user }) => { + const settings = await app.settings.openUserSettings("Preferences"); + await settings.getByLabel("Show media in timeline").getByRole("radio", { name: "Always hide" }).click(); + await app.closeDialog(); + await app.viewRoomById(room.roomId); + await page.getByRole("button", { name: "Accept" }).click(); + await expect(page.getByText("Show image")).toBeVisible(); + }); + test("should be able to hide media in non-private rooms globally", async ({ page, app, room, user, bot }) => { + await bot.sendStateEvent(room.roomId, "m.room.join_rules", { + join_rule: "public", + }); + const settings = await app.settings.openUserSettings("Preferences"); + await settings.getByLabel("Show media in timeline").getByLabel("In private rooms").click(); + await app.closeDialog(); + await app.viewRoomById(room.roomId); + await page.getByRole("button", { name: "Accept" }).click(); + await expect(page.getByText("Show image")).toBeVisible(); + for (const joinRule of ["invite", "knock", "restricted"] as RoomJoinRulesEventContent["join_rule"][]) { + await bot.sendStateEvent(room.roomId, "m.room.join_rules", { + join_rule: joinRule, + } satisfies RoomJoinRulesEventContent); + await expect(page.getByText("Show image")).not.toBeVisible(); + } + }); + test("should be able to show media in rooms globally", async ({ page, app, room, user }) => { + const settings = await app.settings.openUserSettings("Preferences"); + await settings.getByLabel("Show media in timeline").getByRole("radio", { name: "Always show" }).click(); + await app.closeDialog(); + await app.viewRoomById(room.roomId); + await page.getByRole("button", { name: "Accept" }).click(); + await expect(page.getByText("Show image")).not.toBeVisible(); + }); + test("should be able to hide media in an individual room", async ({ page, app, room, user }) => { + const settings = await app.settings.openUserSettings("Preferences"); + await settings.getByLabel("Show media in timeline").getByRole("radio", { name: "Always show" }).click(); + await app.closeDialog(); + + await app.viewRoomById(room.roomId); + await page.getByRole("button", { name: "Accept" }).click(); + + const roomSettings = await app.settings.openRoomSettings("General"); + await roomSettings.getByLabel("Show media in timeline").getByRole("radio", { name: "Always hide" }).click(); + await app.closeDialog(); + + await expect(page.getByText("Show image")).toBeVisible(); + }); + test("should be able to show media in an individual room", async ({ page, app, room, user }) => { + const settings = await app.settings.openUserSettings("Preferences"); + await settings.getByLabel("Show media in timeline").getByRole("radio", { name: "Always hide" }).click(); + await app.closeDialog(); + + await app.viewRoomById(room.roomId); + await page.getByRole("button", { name: "Accept" }).click(); + + const roomSettings = await app.settings.openRoomSettings("General"); + await roomSettings.getByLabel("Show media in timeline").getByRole("radio", { name: "Always show" }).click(); + await app.closeDialog(); + + await expect(page.getByText("Show image")).not.toBeVisible(); + }); +}); diff --git a/playwright/sample-files/example-module.js b/playwright/sample-files/example-module.js index cb9b80a93b..561dea5fd3 100644 --- a/playwright/sample-files/example-module.js +++ b/playwright/sample-files/example-module.js @@ -6,11 +6,19 @@ Please see LICENSE files in the repository root for full details. */ export default class ExampleModule { - static moduleApiVersion = "^0.1.0"; + static moduleApiVersion = "^1.0.0"; constructor(api) { this.api = api; + + this.api.i18n.register({ + key: { + en: "%(brand)s module loading successful!", + de: "%(brand)s-Module erfolgreich geladen!", + }, + }); } async load() { - alert("Testing module loading successful!"); + const brand = this.api.config.get("brand"); + alert(this.api.i18n.translate("key", { brand })); } } diff --git a/playwright/snapshots/app-loading/feature-detection.spec.ts/unsupported-browser-linux.png b/playwright/snapshots/app-loading/feature-detection.spec.ts/unsupported-browser-linux.png index 9a7760bfd0..6a72dec4e2 100644 Binary files a/playwright/snapshots/app-loading/feature-detection.spec.ts/unsupported-browser-linux.png and b/playwright/snapshots/app-loading/feature-detection.spec.ts/unsupported-browser-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 7a108b441c..26c506097a 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/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 95b10570a0..1a66050e5f 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 8f2a488c00..86cb11aad9 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 9bc23433a5..043e35b6be 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/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 8cf4a3c97b..6c71c11b1d 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 new file mode 100644 index 0000000000..c7396da41d Binary files /dev/null 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 new file mode 100644 index 0000000000..15620d3612 Binary files /dev/null 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 3f700037cb..7b73e0b819 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 e01564eeb4..eb3add5733 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 new file mode 100644 index 0000000000..70ed4bb782 Binary files /dev/null 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/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 45d2a775ea..6781c1d364 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/filter-menu-linux.png b/playwright/snapshots/left-panel/room-list-panel/room-list-filter-sort.spec.ts/filter-menu-linux.png new file mode 100644 index 0000000000..fe056af3d3 Binary files /dev/null and b/playwright/snapshots/left-panel/room-list-panel/room-list-filter-sort.spec.ts/filter-menu-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 a84f3ac260..4c82dc21e7 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 new file mode 100644 index 0000000000..2f12ee4e41 Binary files /dev/null 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-empty-room-list-linux.png b/playwright/snapshots/left-panel/room-list-panel/room-list-filter-sort.spec.ts/unread-empty-room-list-linux.png deleted file mode 100644 index 94b09ac14f..0000000000 Binary files a/playwright/snapshots/left-panel/room-list-panel/room-list-filter-sort.spec.ts/unread-empty-room-list-linux.png and /dev/null 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 8c056bc754..5458395e67 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 06314b5213..878818f820 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 8382d3b184..9f501a58d4 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 6c871b6b9c..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 49cf6ef08a..c706e71b0f 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 bdb7f8fa2a..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 313f6a1d14..bada16f154 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 new file mode 100644 index 0000000000..b0de5b5253 Binary files /dev/null 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.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 8da208a85d..aa73d79988 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 eccf48a528..55f4af3972 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 new file mode 100644 index 0000000000..36b7304a01 Binary files /dev/null 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 d6e1d2e549..1f2b691b4a 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..cc2adcc598 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 ded76f85e3..310912e50d 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 ae5bfdabc1..9fa531f5b1 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 new file mode 100644 index 0000000000..dac349eb2d Binary files /dev/null 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 d48bf49aa4..144604ffeb 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 40206cc22b..126f5d718d 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 new file mode 100644 index 0000000000..a8fbeb0b03 Binary files /dev/null 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 new file mode 100644 index 0000000000..724218249b Binary files /dev/null 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 new file mode 100644 index 0000000000..d6ecbc1c40 Binary files /dev/null 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 35bcbea2a1..1bd0eaab1b 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 new file mode 100644 index 0000000000..b654a1f592 Binary files /dev/null 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 35de485457..1a53f6151e 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 87c88e8e46..05f824a2c8 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/messages/messages.spec.ts/basic-message-ltr-rtldisplayname-linux.png b/playwright/snapshots/messages/messages.spec.ts/basic-message-ltr-rtldisplayname-linux.png index 4bf016b825..35523b7db8 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-rtldisplayname-linux.png b/playwright/snapshots/messages/messages.spec.ts/basic-message-rtl-rtldisplayname-linux.png index ee69990523..40a096409e 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-rtldisplayname-linux.png b/playwright/snapshots/messages/messages.spec.ts/edited-message-ltr-rtldisplayname-linux.png index eea2b47469..557613e7e6 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-rtldisplayname-linux.png b/playwright/snapshots/messages/messages.spec.ts/edited-message-rtl-rtldisplayname-linux.png index 4d2e9c593d..bd26e84628 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-rtldisplayname-linux.png b/playwright/snapshots/messages/messages.spec.ts/emote-ltr-rtldisplayname-linux.png index 1bb3582578..055ad23a81 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-rtldisplayname-linux.png b/playwright/snapshots/messages/messages.spec.ts/emote-rich-ltr-rtldisplayname-linux.png index 0e65830b51..cfb905b689 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-rtldisplayname-linux.png b/playwright/snapshots/messages/messages.spec.ts/emote-rich-rtl-rtldisplayname-linux.png index 26bd9f7523..ea29e98a75 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-rtldisplayname-linux.png b/playwright/snapshots/messages/messages.spec.ts/emote-rtl-rtldisplayname-linux.png index 3f50a1406f..d84780f530 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-rtldisplayname-linux.png b/playwright/snapshots/messages/messages.spec.ts/reply-message-ltr-rtldisplayname-linux.png index 37096f025c..b59d960f4f 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-rtldisplayname-linux.png b/playwright/snapshots/messages/messages.spec.ts/reply-message-trl-rtldisplayname-linux.png index 52ced56c83..f6eaea241a 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/oidc/oidc-native.spec.ts/token-expired-linux.png b/playwright/snapshots/oidc/oidc-native.spec.ts/token-expired-linux.png index 17479bb870..e85b83f2f1 100644 Binary files a/playwright/snapshots/oidc/oidc-native.spec.ts/token-expired-linux.png and b/playwright/snapshots/oidc/oidc-native.spec.ts/token-expired-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 6818add73e..7b7f296be1 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/register/register.spec.ts/email-prompt-linux.png b/playwright/snapshots/register/register.spec.ts/email-prompt-linux.png index dd458f3157..9bc2ed101b 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/server-picker-linux.png b/playwright/snapshots/register/register.spec.ts/server-picker-linux.png index 16b686e101..db3ec188d1 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/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 new file mode 100644 index 0000000000..f466c17d64 Binary files /dev/null and b/playwright/snapshots/release-announcement/releaseAnnouncement.spec.ts/release-announcement-All-new-pinned-messages-linux.png differ diff --git a/playwright/snapshots/release-announcement/releaseAnnouncement.spec.ts/release-announcement-Threads-Activity-Centre-linux.png b/playwright/snapshots/release-announcement/releaseAnnouncement.spec.ts/release-announcement-Threads-Activity-Centre-linux.png deleted file mode 100644 index fee99165ab..0000000000 Binary files a/playwright/snapshots/release-announcement/releaseAnnouncement.spec.ts/release-announcement-Threads-Activity-Centre-linux.png and /dev/null differ diff --git a/playwright/snapshots/right-panel/file-panel.spec.ts/empty-linux.png b/playwright/snapshots/right-panel/file-panel.spec.ts/empty-linux.png index c1fced1938..2dbd40ffd9 100644 Binary files a/playwright/snapshots/right-panel/file-panel.spec.ts/empty-linux.png and b/playwright/snapshots/right-panel/file-panel.spec.ts/empty-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 new file mode 100644 index 0000000000..627071591c Binary files /dev/null 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-extensions-linux.png b/playwright/snapshots/right-panel/right-panel.spec.ts/with-extensions-linux.png new file mode 100644 index 0000000000..8333d55b46 Binary files /dev/null and b/playwright/snapshots/right-panel/right-panel.spec.ts/with-extensions-linux.png differ diff --git a/playwright/snapshots/right-panel/right-panel.spec.ts/with-leave-room-linux.png b/playwright/snapshots/right-panel/right-panel.spec.ts/with-leave-room-linux.png index 931e9f9491..febb73c235 100644 Binary files a/playwright/snapshots/right-panel/right-panel.spec.ts/with-leave-room-linux.png and b/playwright/snapshots/right-panel/right-panel.spec.ts/with-leave-room-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 new file mode 100644 index 0000000000..9a3479a1e6 Binary files /dev/null and b/playwright/snapshots/right-panel/right-panel.spec.ts/with-long-name-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 new file mode 100644 index 0000000000..22ef895d1a Binary files /dev/null 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 new file mode 100644 index 0000000000..86d0e37b93 Binary files /dev/null and b/playwright/snapshots/room/invites.spec.ts/Invites-room-view-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 e0d8b80639..3b4031063c 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/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 88d7919dcc..20518942b0 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 cc3c32ee95..a847075a4d 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/appearance-user-settings-tab/message-layout-panel.spec.ts/message-layout-panel-bubble-linux.png b/playwright/snapshots/settings/appearance-user-settings-tab/message-layout-panel.spec.ts/message-layout-panel-bubble-linux.png index 6e0cc3e06f..a913d4ab19 100644 Binary files a/playwright/snapshots/settings/appearance-user-settings-tab/message-layout-panel.spec.ts/message-layout-panel-bubble-linux.png and b/playwright/snapshots/settings/appearance-user-settings-tab/message-layout-panel.spec.ts/message-layout-panel-bubble-linux.png differ diff --git a/playwright/snapshots/settings/appearance-user-settings-tab/message-layout-panel.spec.ts/message-layout-panel-modern-linux.png b/playwright/snapshots/settings/appearance-user-settings-tab/message-layout-panel.spec.ts/message-layout-panel-modern-linux.png index 8e29fd26b8..b2167e0a12 100644 Binary files a/playwright/snapshots/settings/appearance-user-settings-tab/message-layout-panel.spec.ts/message-layout-panel-modern-linux.png and b/playwright/snapshots/settings/appearance-user-settings-tab/message-layout-panel.spec.ts/message-layout-panel-modern-linux.png differ diff --git a/playwright/snapshots/settings/appearance-user-settings-tab/theme-choice-panel.spec.ts/theme-panel-dark-linux.png b/playwright/snapshots/settings/appearance-user-settings-tab/theme-choice-panel.spec.ts/theme-panel-dark-linux.png index eb249d0b24..1acda5968e 100644 Binary files a/playwright/snapshots/settings/appearance-user-settings-tab/theme-choice-panel.spec.ts/theme-panel-dark-linux.png and b/playwright/snapshots/settings/appearance-user-settings-tab/theme-choice-panel.spec.ts/theme-panel-dark-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 6a95f36da7..e18821b774 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/advanced.spec.ts/reset-cryptographic-identity-linux.png b/playwright/snapshots/settings/encryption-user-tab/advanced.spec.ts/reset-cryptographic-identity-linux.png index 18213b5375..6e0b105c24 100644 Binary files a/playwright/snapshots/settings/encryption-user-tab/advanced.spec.ts/reset-cryptographic-identity-linux.png and b/playwright/snapshots/settings/encryption-user-tab/advanced.spec.ts/reset-cryptographic-identity-linux.png differ diff --git a/playwright/snapshots/settings/encryption-user-tab/encryption-tab.spec.ts/default-tab-linux.png b/playwright/snapshots/settings/encryption-user-tab/encryption-tab.spec.ts/default-tab-linux.png index bf57e8c875..c999365534 100644 Binary files a/playwright/snapshots/settings/encryption-user-tab/encryption-tab.spec.ts/default-tab-linux.png and b/playwright/snapshots/settings/encryption-user-tab/encryption-tab.spec.ts/default-tab-linux.png differ diff --git a/playwright/snapshots/settings/encryption-user-tab/encryption-tab.spec.ts/delete-key-storage-confirm-linux.png b/playwright/snapshots/settings/encryption-user-tab/encryption-tab.spec.ts/delete-key-storage-confirm-linux.png index 10ece913d4..237c853a1b 100644 Binary files a/playwright/snapshots/settings/encryption-user-tab/encryption-tab.spec.ts/delete-key-storage-confirm-linux.png and b/playwright/snapshots/settings/encryption-user-tab/encryption-tab.spec.ts/delete-key-storage-confirm-linux.png differ diff --git a/playwright/snapshots/settings/encryption-user-tab/encryption-tab.spec.ts/out-of-sync-recovery-linux.png b/playwright/snapshots/settings/encryption-user-tab/encryption-tab.spec.ts/out-of-sync-recovery-linux.png index ba4ccfe18e..0ad192f2c2 100644 Binary files a/playwright/snapshots/settings/encryption-user-tab/encryption-tab.spec.ts/out-of-sync-recovery-linux.png and b/playwright/snapshots/settings/encryption-user-tab/encryption-tab.spec.ts/out-of-sync-recovery-linux.png differ diff --git a/playwright/snapshots/settings/encryption-user-tab/encryption-tab.spec.ts/verify-device-encryption-tab-linux.png b/playwright/snapshots/settings/encryption-user-tab/encryption-tab.spec.ts/verify-device-encryption-tab-linux.png index dcc1f25008..0bb5fad763 100644 Binary files a/playwright/snapshots/settings/encryption-user-tab/encryption-tab.spec.ts/verify-device-encryption-tab-linux.png and b/playwright/snapshots/settings/encryption-user-tab/encryption-tab.spec.ts/verify-device-encryption-tab-linux.png differ diff --git a/playwright/snapshots/settings/encryption-user-tab/recovery.spec.ts/change-key-1-encryption-tab-linux.png b/playwright/snapshots/settings/encryption-user-tab/recovery.spec.ts/change-key-1-encryption-tab-linux.png index 8d6f2a74f4..a42cb394d7 100644 Binary files a/playwright/snapshots/settings/encryption-user-tab/recovery.spec.ts/change-key-1-encryption-tab-linux.png and b/playwright/snapshots/settings/encryption-user-tab/recovery.spec.ts/change-key-1-encryption-tab-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 e8def33311..ec0207a227 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/default-recovery-linux.png b/playwright/snapshots/settings/encryption-user-tab/recovery.spec.ts/default-recovery-linux.png index 9c23f7ea20..1f34a3f2e0 100644 Binary files a/playwright/snapshots/settings/encryption-user-tab/recovery.spec.ts/default-recovery-linux.png and b/playwright/snapshots/settings/encryption-user-tab/recovery.spec.ts/default-recovery-linux.png differ diff --git a/playwright/snapshots/settings/encryption-user-tab/recovery.spec.ts/set-up-key-1-encryption-tab-linux.png b/playwright/snapshots/settings/encryption-user-tab/recovery.spec.ts/set-up-key-1-encryption-tab-linux.png index a1f286ac73..3835cfe727 100644 Binary files a/playwright/snapshots/settings/encryption-user-tab/recovery.spec.ts/set-up-key-1-encryption-tab-linux.png and b/playwright/snapshots/settings/encryption-user-tab/recovery.spec.ts/set-up-key-1-encryption-tab-linux.png differ diff --git a/playwright/snapshots/settings/encryption-user-tab/recovery.spec.ts/set-up-key-2-encryption-tab-linux.png b/playwright/snapshots/settings/encryption-user-tab/recovery.spec.ts/set-up-key-2-encryption-tab-linux.png index 0d8aa341c0..6c3e9eab2c 100644 Binary files a/playwright/snapshots/settings/encryption-user-tab/recovery.spec.ts/set-up-key-2-encryption-tab-linux.png and b/playwright/snapshots/settings/encryption-user-tab/recovery.spec.ts/set-up-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 00a96ce522..cb0bc78e00 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 e7dcea9436..e26d001a90 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 833dc3a8c7..c326c3f8e4 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/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 91d402ed8a..c484a47fc9 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 b40d183830..c6605b5b64 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/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 748bdd5c21..9cd18bee3e 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/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 new file mode 100644 index 0000000000..377282a2c8 Binary files /dev/null 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 new file mode 100644 index 0000000000..29d129f73f Binary files /dev/null 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 new file mode 100644 index 0000000000..452f08d3e2 Binary files /dev/null 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 new file mode 100644 index 0000000000..93459507d0 Binary files /dev/null and b/playwright/snapshots/timeline/media-preview-settings.spec.ts/invite-with-avatar-linux.png differ diff --git a/playwright/snapshots/user-menu/user-menu.spec.ts/user-menu-linux.png b/playwright/snapshots/user-menu/user-menu.spec.ts/user-menu-linux.png index 73c9ce49ac..79335b66ae 100644 Binary files a/playwright/snapshots/user-menu/user-menu.spec.ts/user-menu-linux.png and b/playwright/snapshots/user-menu/user-menu.spec.ts/user-menu-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 dc832f31c9..a4f6a476f6 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/res/apple-app-site-association b/res/apple-app-site-association index 94869effab..0235e6ada1 100644 --- a/res/apple-app-site-association +++ b/res/apple-app-site-association @@ -5,8 +5,7 @@ "appIDs": [ "7J4U792NQT.im.vector.app", "7J4U792NQT.io.element.elementx", - "7J4U792NQT.io.element.elementx.nightly", - "7J4U792NQT.io.element.elementx.pr" + "7J4U792NQT.io.element.elementx.nightly" ], "components": [ { @@ -28,8 +27,7 @@ "apps": [ "7J4U792NQT.im.vector.app", "7J4U792NQT.io.element.elementx", - "7J4U792NQT.io.element.elementx.nightly", - "7J4U792NQT.io.element.elementx.pr" + "7J4U792NQT.io.element.elementx.nightly" ] } } diff --git a/res/css/_common.pcss b/res/css/_common.pcss index 75180013f6..3eed8c93c6 100644 --- a/res/css/_common.pcss +++ b/res/css/_common.pcss @@ -593,6 +593,7 @@ legend { .mx_Dialog button:not( .mx_EncryptionUserSettingsTab button, + .mx_EncryptionCard button, .mx_UserProfileSettings button, .mx_ShareDialog button, .mx_UnpinAllDialog button, @@ -600,6 +601,7 @@ legend { .mx_Dialog_nonDialogButton, .mx_AccessibleButton, .mx_IdentityServerPicker button, + .mx_AccessSecretStorageDialog button, [class|="maplibregl"] ), .mx_Dialog_buttons button:not(.mx_Dialog_nonDialogButton, .mx_AccessibleButton), diff --git a/res/css/_components.pcss b/res/css/_components.pcss index d622f69b84..247dd7edab 100644 --- a/res/css/_components.pcss +++ b/res/css/_components.pcss @@ -116,6 +116,7 @@ @import "./views/auth/_Welcome.pcss"; @import "./views/avatars/_BaseAvatar.pcss"; @import "./views/avatars/_DecoratedRoomAvatar.pcss"; +@import "./views/avatars/_RoomAvatarView.pcss"; @import "./views/avatars/_WidgetAvatar.pcss"; @import "./views/avatars/_WithPresenceIndicator.pcss"; @import "./views/beta/_BetaCard.pcss"; @@ -130,6 +131,7 @@ @import "./views/dialogs/_BugReportDialog.pcss"; @import "./views/dialogs/_ChangelogDialog.pcss"; @import "./views/dialogs/_CompoundDialog.pcss"; +@import "./views/dialogs/_ConfirmKeyStorageOffDialog.pcss"; @import "./views/dialogs/_ConfirmSpaceUserActionDialog.pcss"; @import "./views/dialogs/_ConfirmUserActionDialog.pcss"; @import "./views/dialogs/_CreateRoomDialog.pcss"; @@ -278,6 +280,8 @@ @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"; @import "./views/rooms/_Autocomplete.pcss"; @import "./views/rooms/_AuxPanel.pcss"; @@ -340,8 +344,6 @@ @import "./views/rooms/wysiwyg_composer/components/_FormattingButtons.pcss"; @import "./views/rooms/wysiwyg_composer/components/_LinkModal.pcss"; @import "./views/settings/_AvatarSetting.pcss"; -@import "./views/settings/_CrossSigningPanel.pcss"; -@import "./views/settings/_CryptographyPanel.pcss"; @import "./views/settings/_FontScalingPanel.pcss"; @import "./views/settings/_ImageSizePanel.pcss"; @import "./views/settings/_IntegrationManager.pcss"; @@ -354,7 +356,6 @@ @import "./views/settings/_PhoneNumbers.pcss"; @import "./views/settings/_PowerLevelSelector.pcss"; @import "./views/settings/_RoomProfileSettings.pcss"; -@import "./views/settings/_SecureBackupPanel.pcss"; @import "./views/settings/_SetIntegrationManager.pcss"; @import "./views/settings/_SettingsFieldset.pcss"; @import "./views/settings/_SettingsHeader.pcss"; @@ -380,6 +381,7 @@ @import "./views/settings/tabs/user/_AppearanceUserSettingsTab.pcss"; @import "./views/settings/tabs/user/_HelpUserSettingsTab.pcss"; @import "./views/settings/tabs/user/_KeyboardUserSettingsTab.pcss"; +@import "./views/settings/tabs/user/_MediaPreviewAccountSettings.pcss"; @import "./views/settings/tabs/user/_MjolnirUserSettingsTab.pcss"; @import "./views/settings/tabs/user/_PreferencesUserSettingsTab.pcss"; @import "./views/settings/tabs/user/_SecurityUserSettingsTab.pcss"; diff --git a/res/css/components/views/elements/_FilterDropdown.pcss b/res/css/components/views/elements/_FilterDropdown.pcss index a2682213c6..22eb5f2ff1 100644 --- a/res/css/components/views/elements/_FilterDropdown.pcss +++ b/res/css/components/views/elements/_FilterDropdown.pcss @@ -16,7 +16,7 @@ Please see LICENSE files in the repository root for full details. border: 1px solid $quinary-content; border-radius: 8px; - box-shadow: 0px 1px 3px rgba(23, 25, 28, 0.05); + box-shadow: 0px 1px 3px rgb(23, 25, 28, 0.05); background-color: $system; diff --git a/res/css/components/views/location/_Marker.pcss b/res/css/components/views/location/_Marker.pcss index 5a8fef9199..a3bfc10f52 100644 --- a/res/css/components/views/location/_Marker.pcss +++ b/res/css/components/views/location/_Marker.pcss @@ -14,7 +14,7 @@ Please see LICENSE files in the repository root for full details. width: 42px; height: 42px; border-radius: 50%; - filter: drop-shadow(0px 3px 5px rgba(0, 0, 0, 0.2)); + filter: drop-shadow(0px 3px 5px rgb(0, 0, 0, 0.2)); background-color: currentColor; display: flex; diff --git a/res/css/components/views/location/_ZoomButtons.pcss b/res/css/components/views/location/_ZoomButtons.pcss index f98c7bf1b0..bb7d72e4e8 100644 --- a/res/css/components/views/location/_ZoomButtons.pcss +++ b/res/css/components/views/location/_ZoomButtons.pcss @@ -24,7 +24,7 @@ Please see LICENSE files in the repository root for full details. height: $ZoomButtons_button-size; width: $ZoomButtons_button-size; background: $background; - box-shadow: 0px 4px 12px rgba(0, 0, 0, 0.25); + box-shadow: 0px 4px 12px rgb(0, 0, 0, 0.25); .mx_ZoomButtons_icon { $ZoomButtons_icon-size: 12px; diff --git a/res/css/components/views/pips/_WidgetPip.pcss b/res/css/components/views/pips/_WidgetPip.pcss index e515e3eec0..b9ad791021 100644 --- a/res/css/components/views/pips/_WidgetPip.pcss +++ b/res/css/components/views/pips/_WidgetPip.pcss @@ -48,7 +48,7 @@ $height: 220px; display: flex; font-size: $font-12px; font-weight: var(--cpd-font-weight-semibold); - background: linear-gradient(rgba(0, 0, 0, 0.9), rgba(0, 0, 0, 0)); + background: linear-gradient(rgb(0, 0, 0, 0.9), rgb(0, 0, 0, 0)); } .mx_WidgetPip_backButton { @@ -69,5 +69,5 @@ $height: 220px; display: flex; justify-content: flex-end; align-items: flex-end; - background: linear-gradient(rgba(0, 0, 0, 0), rgba(0, 0, 0, 0.9)); + background: linear-gradient(rgb(0, 0, 0, 0), rgb(0, 0, 0, 0.9)); } diff --git a/res/css/structures/_ContextualMenu.pcss b/res/css/structures/_ContextualMenu.pcss index 9e6be31ac0..3c1363f896 100644 --- a/res/css/structures/_ContextualMenu.pcss +++ b/res/css/structures/_ContextualMenu.pcss @@ -23,7 +23,7 @@ Please see LICENSE files in the repository root for full details. .mx_ContextualMenu { border-radius: 12px; - box-shadow: 0px 4px 24px rgba(0, 0, 0, 0.1); + box-shadow: 0px 4px 24px rgb(0, 0, 0, 0.1); background-color: var(--cpd-color-bg-canvas-default); border: var(--cpd-border-width-1) solid var(--cpd-color-border-interactive-secondary); color: $primary-content; diff --git a/res/css/structures/_GenericDropdownMenu.pcss b/res/css/structures/_GenericDropdownMenu.pcss index dd9dc0ade1..1d0fb79648 100644 --- a/res/css/structures/_GenericDropdownMenu.pcss +++ b/res/css/structures/_GenericDropdownMenu.pcss @@ -41,7 +41,7 @@ Please see LICENSE files in the repository root for full details. padding-bottom: 10px; border: 1px solid $quinary-content; - box-shadow: 0 1px 3px rgba(23, 25, 28, 0.05); + box-shadow: 0 1px 3px rgb(23, 25, 28, 0.05); } .mx_ContextualMenu_chevron_top { diff --git a/res/css/structures/_LeftPanel.pcss b/res/css/structures/_LeftPanel.pcss index c76fd5da02..c1886b6b80 100644 --- a/res/css/structures/_LeftPanel.pcss +++ b/res/css/structures/_LeftPanel.pcss @@ -28,6 +28,12 @@ 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; diff --git a/res/css/structures/_SpaceHierarchy.pcss b/res/css/structures/_SpaceHierarchy.pcss index c1be2eb142..88c40b6fab 100644 --- a/res/css/structures/_SpaceHierarchy.pcss +++ b/res/css/structures/_SpaceHierarchy.pcss @@ -293,7 +293,7 @@ Please see LICENSE files in the repository root for full details. > hr { border: none; height: 1px; - background-color: rgba(141, 151, 165, 0.2); + background-color: rgb(141, 151, 165, 0.2); margin: 20px 0; } diff --git a/res/css/structures/_SpacePanel.pcss b/res/css/structures/_SpacePanel.pcss index 664d781829..64044c4c5c 100644 --- a/res/css/structures/_SpacePanel.pcss +++ b/res/css/structures/_SpacePanel.pcss @@ -30,6 +30,11 @@ Please see LICENSE files in the repository root for full details. width: 68px; } + &.newUi { + background-color: var(--cpd-color-bg-canvas-default); + border-right: 1px solid var(--cpd-color-bg-subtle-primary); + } + .mx_SpacePanel_toggleCollapse { position: absolute; width: 18px; @@ -352,9 +357,9 @@ Please see LICENSE files in the repository root for full details. mask-image: linear-gradient( to top, transparent, - rgba(255, 255, 255, 30%) 4px, - rgba(255, 255, 255, 55%) 8px, - rgba(255, 255, 255, 75%) 12px, + rgb(255, 255, 255, 30%) 4px, + rgb(255, 255, 255, 55%) 8px, + rgb(255, 255, 255, 75%) 12px, black 16px ); } @@ -370,9 +375,9 @@ Please see LICENSE files in the repository root for full details. linear-gradient( to top, transparent, - rgba(255, 255, 255, 30%) 4px, - rgba(255, 255, 255, 55%) 8px, - rgba(255, 255, 255, 75%) 12px, + rgb(255, 255, 255, 30%) 4px, + rgb(255, 255, 255, 55%) 8px, + rgb(255, 255, 255, 75%) 12px, black 16px ); mask-position: @@ -399,6 +404,11 @@ Please see LICENSE files in the repository root for full details. display: block; } } + + &.newUi .mx_UserMenu { + margin-top: var(--cpd-space-4x); + border-bottom: none; + } } .mx_SpacePanel_contextMenu { diff --git a/res/css/structures/_SplashPage.pcss b/res/css/structures/_SplashPage.pcss index 26eec65a93..bd57fbb48d 100644 --- a/res/css/structures/_SplashPage.pcss +++ b/res/css/structures/_SplashPage.pcss @@ -19,12 +19,12 @@ Please see LICENSE files in the repository root for full details. background-image: radial-gradient( 53.85% 66.75% at 87.55% 0%, - hsla(250deg, 76%, 71%, 0.261) 0%, - hsla(250deg, 100%, 88%, 0) 100% + hsl(250deg, 76%, 71%, 0.261) 0%, + hsl(250deg, 100%, 88%, 0) 100% ), - radial-gradient(41.93% 41.93% at 0% 0%, hsla(222deg, 29%, 20%, 0.28) 0%, hsla(250deg, 100%, 88%, 0) 100%), - radial-gradient(100% 100% at 0% 0%, hsla(250deg, 100%, 88%, 0.174) 0%, hsla(0deg, 100%, 86%, 0) 100%), - radial-gradient(106.35% 96.26% at 100% 0%, hsla(250deg, 100%, 88%, 0.4) 0%, hsla(167deg, 76%, 82%, 0) 100%); + radial-gradient(41.93% 41.93% at 0% 0%, hsl(222deg, 29%, 20%, 0.28) 0%, hsl(250deg, 100%, 88%, 0) 100%), + radial-gradient(100% 100% at 0% 0%, hsl(250deg, 100%, 88%, 0.174) 0%, hsl(0deg, 100%, 86%, 0) 100%), + radial-gradient(106.35% 96.26% at 100% 0%, hsl(250deg, 100%, 88%, 0.4) 0%, hsl(167deg, 76%, 82%, 0) 100%); /* blur to reduce color banding issues due to alpha-blending multiple gradients */ filter: blur(8px); inset: -9px; @@ -34,8 +34,8 @@ Please see LICENSE files in the repository root for full details. /* gradient to apply different amounts of dithering to different parts of the gradient */ linear-gradient( to bottom, - /* 10% dithering at the top */ rgba(0, 0, 0, 0.9) 20%, - /* 80% dithering at the bottom */ rgba(0, 0, 0, 0.2) 100% + /* 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/_ToastContainer.pcss b/res/css/structures/_ToastContainer.pcss index 6022c01c99..cf1d7fd4a9 100644 --- a/res/css/structures/_ToastContainer.pcss +++ b/res/css/structures/_ToastContainer.pcss @@ -21,7 +21,7 @@ Please see LICENSE files in the repository root for full details. grid-row: 2 / 4; grid-column: 1; background-color: $system; - box-shadow: 0px 4px 20px rgba(0, 0, 0, 0.5); + box-shadow: 0px 4px 20px rgb(0, 0, 0, 0.5); border-radius: 8px; } @@ -30,7 +30,7 @@ Please see LICENSE files in the repository root for full details. grid-column: 1; background-color: var(--cpd-color-bg-canvas-default); color: $primary-content; - box-shadow: 0px 4px 24px rgba(0, 0, 0, 0.1); + box-shadow: 0px 4px 24px rgb(0, 0, 0, 0.1); border: var(--cpd-border-width-1) solid var(--cpd-color-border-interactive-secondary); border-radius: 12px; overflow: hidden; @@ -79,6 +79,11 @@ Please see LICENSE files in the repository root for full details. 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; diff --git a/res/css/structures/auth/_Registration.pcss b/res/css/structures/auth/_Registration.pcss index 409767e1da..a1833df1d0 100644 --- a/res/css/structures/auth/_Registration.pcss +++ b/res/css/structures/auth/_Registration.pcss @@ -37,7 +37,7 @@ Please see LICENSE files in the repository root for full details. justify-content: space-between; padding-top: 16px; margin-top: 16px; - border-top: 1px solid rgba(141, 151, 165, 0.2); + border-top: 1px solid rgb(141, 151, 165, 0.2); > * { flex-basis: content; diff --git a/res/css/views/auth/_AuthFooter.pcss b/res/css/views/auth/_AuthFooter.pcss index 96b41406ad..57545e7691 100644 --- a/res/css/views/auth/_AuthFooter.pcss +++ b/res/css/views/auth/_AuthFooter.pcss @@ -11,7 +11,7 @@ Please see LICENSE files in the repository root for full details. font: var(--cpd-font-body-md-regular); opacity: 0.72; padding: 20px 0; - background: linear-gradient(rgba(0, 0, 0, 0), rgba(0, 0, 0, 0.8)); + background: linear-gradient(rgb(0, 0, 0, 0), rgb(0, 0, 0, 0.8)); } .mx_AuthFooter a:link, diff --git a/res/css/views/auth/_AuthPage.pcss b/res/css/views/auth/_AuthPage.pcss index 469bca5053..3ae17122be 100644 --- a/res/css/views/auth/_AuthPage.pcss +++ b/res/css/views/auth/_AuthPage.pcss @@ -19,7 +19,7 @@ Please see LICENSE files in the repository root for full details. display: flex; margin: 100px auto auto; border-radius: 4px; - box-shadow: 0 2px 4px 0 rgba(0, 0, 0, 0.33); + box-shadow: 0 2px 4px 0 rgb(0, 0, 0, 0.33); background-color: $authpage-modal-bg-color; @media only screen and (max-height: 768px) { diff --git a/res/css/views/avatars/_RoomAvatarView.pcss b/res/css/views/avatars/_RoomAvatarView.pcss new file mode 100644 index 0000000000..0d5523a9f1 --- /dev/null +++ b/res/css/views/avatars/_RoomAvatarView.pcss @@ -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. + */ + +.mx_RoomAvatarView { + --room-avatar-size: 32px; + + position: relative; + + /* Keep the container to the same size than the avatar */ + inline-size: var(--room-avatar-size); + block-size: var(--room-avatar-size); + + .mx_RoomAvatarView_RoomAvatar { + mask-position: center; + mask-size: contain; + mask-repeat: no-repeat; + } + + .mx_RoomAvatarView_RoomAvatar_icon { + mask-image: url("$(res)/img/element-icons/roomlist/room-avatar-view-icon-mask.svg"); + } + + .mx_RoomAvatarView_RoomAvatar_presence { + mask-image: url("$(res)/img/element-icons/roomlist/room-avatar-view-presence-mask.svg"); + } + + .mx_RoomAvatarView_icon { + position: absolute; + + /* Place half the icon inside the avatar */ + /* Avatar size - (icon size (16px) / 2) */ + left: calc((var(--room-avatar-size) - 8px)); + bottom: var(--cpd-space-0-5x); + } + + .mx_RoomAvatarView_PresenceDecoration { + position: absolute; + + /* Place half the icon inside the avatar */ + /* Avatar size - (icon size (8px) / 2) */ + left: calc((var(--room-avatar-size) - 4px)); + bottom: var(--cpd-space-0-5x); + } +} diff --git a/res/css/views/dialogs/_CompoundDialog.pcss b/res/css/views/dialogs/_CompoundDialog.pcss index b43fa1f14e..1c3ba01f1c 100644 --- a/res/css/views/dialogs/_CompoundDialog.pcss +++ b/res/css/views/dialogs/_CompoundDialog.pcss @@ -77,7 +77,7 @@ Please see LICENSE files in the repository root for full details. max-height: 80%; .mx_CompoundDialog_footer { - box-shadow: 0px -4px 4px rgba(0, 0, 0, 0.05); /* hardcoded colour for both themes */ + box-shadow: 0px -4px 4px rgb(0, 0, 0, 0.05); /* hardcoded colour for both themes */ z-index: 1; /* needed to make footer & shadow appear above dialog content */ } } diff --git a/res/css/views/dialogs/_ConfirmKeyStorageOffDialog.pcss b/res/css/views/dialogs/_ConfirmKeyStorageOffDialog.pcss new file mode 100644 index 0000000000..5ac53c7b70 --- /dev/null +++ b/res/css/views/dialogs/_ConfirmKeyStorageOffDialog.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_ConfirmKeyStorageOffDialog { + .mx_Dialog_border { + width: 600px; + } + + .mx_EncryptionCard { + text-align: center; + } +} diff --git a/res/css/views/dialogs/_MessageEditHistoryDialog.pcss b/res/css/views/dialogs/_MessageEditHistoryDialog.pcss index 8105833830..5d06545ae8 100644 --- a/res/css/views/dialogs/_MessageEditHistoryDialog.pcss +++ b/res/css/views/dialogs/_MessageEditHistoryDialog.pcss @@ -34,13 +34,13 @@ Please see LICENSE files in the repository root for full details. .mx_EditHistoryMessage_deletion { color: rgb(255, 76, 85); - background-color: rgba(255, 76, 85, 0.1); + background-color: rgb(255, 76, 85, 0.1); text-decoration: line-through; } .mx_EditHistoryMessage_insertion { color: rgb(26, 169, 123); - background-color: rgba(26, 169, 123, 0.1); + background-color: rgb(26, 169, 123, 0.1); text-decoration: underline; } diff --git a/res/css/views/dialogs/_ReportRoomDialog.pcss b/res/css/views/dialogs/_ReportRoomDialog.pcss index 7323ac65c9..b656638d9c 100644 --- a/res/css/views/dialogs/_ReportRoomDialog.pcss +++ b/res/css/views/dialogs/_ReportRoomDialog.pcss @@ -5,7 +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. */ -.mx_ReportRoomDialog { +.mx_ReportRoomDialog, +.mx_DeclineAndBlockInviteDialog { textarea { font: var(--cpd-font-body-md-regular); border: 1px solid var(--cpd-color-border-interactive-primary); @@ -13,4 +14,28 @@ Please see LICENSE files in the repository root for full details. border-radius: 0.5rem; padding: var(--cpd-space-3x) var(--cpd-space-4x); } + + /* + Workaround to fix labels appearing with the wrong color. + + .mx_Dialog (in res/css/_common.pcss) redefines the body color + as $light-fg-color rather than the standard primary color. + + This forces the colour to match the Compound style, but + in the future the Dialogs should not force a color. + */ + form label { + color: var(--cpd-color-text-primary); + } +} + +.mx_DeclineAndBlockInviteDialog { + div[aria-disabled="true"] > label { + color: var(--cpd-color-text-secondary); + } + + .mx_SettingsFlag_label { + color: var(--cpd-color-text-primary); + font-weight: var(--cpd-font-weight-semibold); + } } diff --git a/res/css/views/dialogs/_SpotlightDialog.pcss b/res/css/views/dialogs/_SpotlightDialog.pcss index d00acd6786..592431c2f1 100644 --- a/res/css/views/dialogs/_SpotlightDialog.pcss +++ b/res/css/views/dialogs/_SpotlightDialog.pcss @@ -412,7 +412,8 @@ Please see LICENSE files in the repository root for full details. .mx_SpotlightDialog_joinRoomAlias, .mx_SpotlightDialog_explorePublicRooms, .mx_SpotlightDialog_explorePublicSpaces, - .mx_SpotlightDialog_startGroupChat { + .mx_SpotlightDialog_startGroupChat, + .mx_SpotlightDialog_searchMessages { padding-left: $spacing-32; position: relative; @@ -451,22 +452,14 @@ Please see LICENSE files in the repository root for full details. mask-image: url("$(res)/img/element-icons/group-members.svg"); } + .mx_SpotlightDialog_searchMessages::before { + mask-image: url("$(res)/img/element-icons/room/search-inset.svg"); + } + .mx_SpotlightDialog_otherSearches_messageSearchText { font-size: $font-15px; line-height: $font-24px; } - - .mx_SpotlightDialog_otherSearches_messageSearchIcon { - display: inline-block; - width: 24px; - height: 24px; - background-color: $secondary-content; - vertical-align: text-bottom; - mask-repeat: no-repeat; - mask-position: center; - mask-size: contain; - mask-image: url("$(res)/img/element-icons/room/search-inset.svg"); - } } .mx_SpotlightDialog_result_details { diff --git a/res/css/views/dialogs/security/_AccessSecretStorageDialog.pcss b/res/css/views/dialogs/security/_AccessSecretStorageDialog.pcss index 83b9fe96b4..9d81097d60 100644 --- a/res/css/views/dialogs/security/_AccessSecretStorageDialog.pcss +++ b/res/css/views/dialogs/security/_AccessSecretStorageDialog.pcss @@ -7,62 +7,14 @@ Please see LICENSE files in the repository root for full details. */ .mx_AccessSecretStorageDialog { - .mx_AccessSecretStorageDialog_titleWithIcon { - &::before { - content: ""; - display: inline-block; - width: 24px; - height: 24px; - margin-inline-end: $spacing-8; - position: relative; - top: 5px; - background-color: $primary-content; - } - - &.mx_AccessSecretStorageDialog_resetBadge::before { - /* The image isn't capable of masking, so we use a background instead. */ - background-image: url("@vector-im/compound-design-tokens/icons/error-solid.svg"); - background-size: 24px; - background-color: transparent; - } - - &.mx_AccessSecretStorageDialog_secureBackupTitle::before { - mask-image: url("$(res)/img/feather-customised/secure-backup.svg"); - } - - &.mx_AccessSecretStorageDialog_securePhraseTitle::before { - mask-image: url("$(res)/img/feather-customised/secure-phrase.svg"); - } + &.mx_EncryptionCard { + /* override some styles that we don't need */ + border: 0px none; + box-shadow: none; + padding: 0px; } .mx_AccessSecretStorageDialog_primaryContainer { - .mx_AccessSecretStorageDialog_passPhraseInput { - width: 300px; - border: 1px solid $accent; - border-radius: 5px; - } - - .mx_AccessSecretStorageDialog_keyStatus { - height: 30px; - } - - .mx_AccessSecretStorageDialog_recoveryKeyEntry { - display: flex; - align-items: center; - - .mx_AccessSecretStorageDialog_recoveryKeyEntry_textInput { - flex-grow: 1; - } - - .mx_AccessSecretStorageDialog_recoveryKeyEntry_entryControlSeparatorText { - margin: $spacing-16; - } - - .mx_AccessSecretStorageDialog_recoveryKeyEntry_fileInput { - display: none; - } - } - .mx_AccessSecretStorageDialog_recoveryKeyFeedback { &::before { content: ""; @@ -76,64 +28,18 @@ Please see LICENSE files in the repository root for full details. margin-inline-end: 5px; } - &.mx_AccessSecretStorageDialog_recoveryKeyFeedback--valid { - color: $accent; - - &::before { - mask-image: url("@vector-im/compound-design-tokens/icons/check.svg"); - background-color: $accent; - } - } - &.mx_AccessSecretStorageDialog_recoveryKeyFeedback--invalid { 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; } } } + } - .mx_Dialog_buttons { - $spacingStart: $spacing-24; /* 16px icon + 8px padding */ - - text-align: initial; - display: flex; - flex-flow: column; - gap: 14px; - - .mx_Dialog_buttons_additive { - float: none; - - .mx_AccessSecretStorageDialog_reset { - position: relative; - padding-inline-start: $spacingStart; - /* To avoid bold styling inherent with elements */ - font-weight: inherit; - - &::before { - content: ""; - display: inline-block; - position: absolute; - height: 16px; - width: 16px; - left: 0; - top: 2px; /* alignment */ - background-image: url("@vector-im/compound-design-tokens/icons/error-solid.svg"); - background-size: contain; - } - - .mx_AccessSecretStorageDialog_reset_link { - color: $alert; - } - } - } - - .mx_Dialog_buttons_row { - gap: $spacing-16; /* TODO: needs normalization */ - padding-inline-start: $spacingStart; - } - } + .mx_EncryptionCard_buttons { + margin-top: var(--cpd-space-20x); } } diff --git a/res/css/views/elements/_ServerPicker.pcss b/res/css/views/elements/_ServerPicker.pcss index fa0b46599f..aced0976ce 100644 --- a/res/css/views/elements/_ServerPicker.pcss +++ b/res/css/views/elements/_ServerPicker.pcss @@ -9,7 +9,7 @@ Please see LICENSE files in the repository root for full details. .mx_ServerPicker { margin-bottom: 14px; padding-bottom: $spacing-16; - border-bottom: 1px solid rgba(141, 151, 165, 0.2); + border-bottom: 1px solid rgb(141, 151, 165, 0.2); display: grid; grid-template-columns: auto min-content; grid-template-rows: auto auto auto; diff --git a/res/css/views/emojipicker/_EmojiPicker.pcss b/res/css/views/emojipicker/_EmojiPicker.pcss index c1a666a672..7542424605 100644 --- a/res/css/views/emojipicker/_EmojiPicker.pcss +++ b/res/css/views/emojipicker/_EmojiPicker.pcss @@ -20,7 +20,7 @@ Please see LICENSE files in the repository root for full details. flex: 1; overflow-y: scroll; scrollbar-width: thin; - scrollbar-color: rgba(0, 0, 0, 0.2) transparent; + scrollbar-color: rgb(0, 0, 0, 0.2) transparent; } .mx_EmojiPicker_header { @@ -201,7 +201,7 @@ Please see LICENSE files in the repository root for full details. } .mx_EmojiPicker_item_selected { - color: rgba(0, 0, 0, 0.5); + color: rgb(0, 0, 0, 0.5); border: 1px solid $accent; padding: 4px; } diff --git a/res/css/views/location/_LocationPicker.pcss b/res/css/views/location/_LocationPicker.pcss index 9384411cf8..c103f7d68a 100644 --- a/res/css/views/location/_LocationPicker.pcss +++ b/res/css/views/location/_LocationPicker.pcss @@ -76,7 +76,7 @@ Please see LICENSE files in the repository root for full details. pointer-events: none; span { - box-shadow: 0px 4px 15px rgba(0, 0, 0, 0.15); + box-shadow: 0px 4px 15px rgb(0, 0, 0, 0.15); border-radius: 8px; padding: $spacing-8; background-color: $background; diff --git a/res/css/views/messages/_MImageBody.pcss b/res/css/views/messages/_MImageBody.pcss index bb5f879568..0e73c1d55c 100644 --- a/res/css/views/messages/_MImageBody.pcss +++ b/res/css/views/messages/_MImageBody.pcss @@ -25,7 +25,7 @@ Please see LICENSE files in the repository root for full details. overflow: hidden; /* Hardcoded colours because it's the same on all themes */ - background-color: rgba(0, 0, 0, 0.6); + background-color: rgb(0, 0, 0, 0.6); color: #ffffff; } diff --git a/res/css/views/right_panel/_BaseCard.pcss b/res/css/views/right_panel/_BaseCard.pcss index 83e1fc28d4..035485428a 100644 --- a/res/css/views/right_panel/_BaseCard.pcss +++ b/res/css/views/right_panel/_BaseCard.pcss @@ -99,7 +99,7 @@ Please see LICENSE files in the repository root for full details. .mx_AccessibleButton_kind_secondary { color: $secondary-content; - background-color: rgba(141, 151, 165, 0.2); + background-color: rgb(141, 151, 165, 0.2); font: var(--cpd-font-body-md-semibold); } @@ -125,7 +125,7 @@ Please see LICENSE files in the repository root for full details. padding-bottom: 10px; border: var(--cpd-border-width-1) solid var(--cpd-color-border-interactive-secondary); - box-shadow: 0px 4px 24px rgba(0, 0, 0, 0.1); + box-shadow: 0px 4px 24px rgb(0, 0, 0, 0.1); } .mx_ContextualMenu_chevron_top { diff --git a/res/css/views/right_panel/_EmptyState.pcss b/res/css/views/right_panel/_EmptyState.pcss index 05bc2882b8..15e78414c9 100644 --- a/res/css/views/right_panel/_EmptyState.pcss +++ b/res/css/views/right_panel/_EmptyState.pcss @@ -30,7 +30,7 @@ Please see LICENSE files in the repository root for full details. height: 775px; right: -253.77px; top: 0; - background: radial-gradient(49.95% 49.95% at 50% 50%, rgba(13, 189, 139, 0.12) 0%, rgba(18, 115, 235, 0) 100%); + background: radial-gradient(49.95% 49.95% at 50% 50%, rgb(13, 189, 139, 0.12) 0%, rgb(18, 115, 235, 0) 100%); transform: rotate(-89.69deg); overflow: hidden; } diff --git a/res/css/views/right_panel/_ExtensionsCard.pcss b/res/css/views/right_panel/_ExtensionsCard.pcss index 65dc23f667..c98fa3e9dc 100644 --- a/res/css/views/right_panel/_ExtensionsCard.pcss +++ b/res/css/views/right_panel/_ExtensionsCard.pcss @@ -7,12 +7,11 @@ Please see LICENSE files in the repository root for full details. */ .mx_ExtensionsCard { - --cpd-separator-inset: var(--cpd-space-4x); - --cpd-separator-spacing: var(--cpd-space-4x); - + --cpd-separator-spacing: var(--cpd-space-6x); + --AddExtension-overlap: -76px; .mx_AutoHideScrollbar { padding: 0 var(--cpd-space-4x); - margin-top: var(--cpd-space-3x); + margin-top: var(--cpd-space-6x); box-sizing: border-box; /* Styling for the "Add extensions" button */ @@ -70,7 +69,7 @@ Please see LICENSE files in the repository root for full details. top: var(--cpd-space-2x); /* equal to padding-top of parent */ left: 0; border-radius: 12px; - background-color: rgba(141, 151, 165, 0.1); + background-color: rgb(141, 151, 165, 0.1); } } @@ -128,6 +127,11 @@ Please see LICENSE files in the repository root for full details. .mx_EmptyState::before { /* Overlap the Add extensions button */ - top: -76px; + top: var(--AddExtension-overlap); + } + + .mx_EmptyState { + /* Stop empty state scrolling */ + height: calc(100% + var(--AddExtension-overlap)); } } diff --git a/res/css/views/right_panel/_PinnedMessagesCard.pcss b/res/css/views/right_panel/_PinnedMessagesCard.pcss index 6a15653398..1b73787f22 100644 --- a/res/css/views/right_panel/_PinnedMessagesCard.pcss +++ b/res/css/views/right_panel/_PinnedMessagesCard.pcss @@ -37,7 +37,7 @@ Please see LICENSE files in the repository root for full details. display: flex; justify-content: center; align-items: center; - box-shadow: 0 4px 24px 0 rgba(27, 29, 34, 0.1); + box-shadow: 0 4px 24px 0 rgb(27, 29, 34, 0.1); background: var(--cpd-color-bg-canvas-default); } diff --git a/res/css/views/right_panel/_UserInfo.pcss b/res/css/views/right_panel/_UserInfo.pcss index 7fccd6e2d1..3030b93c03 100644 --- a/res/css/views/right_panel/_UserInfo.pcss +++ b/res/css/views/right_panel/_UserInfo.pcss @@ -109,6 +109,15 @@ Please see LICENSE files in the repository root for full details. font-size: $font-20px; line-height: $font-25px; + /* E2E icon wrapper */ + .mx_Flex > span { + display: inline-block; + } + } + + .mx_UserInfo_profile_name { + min-height: 30px; + /* limit to 2 lines, show an ellipsis if it overflows */ /* this looks webkit specific but is supported by Firefox 68+ */ display: -webkit-box; @@ -118,15 +127,6 @@ Please see LICENSE files in the repository root for full details. overflow: hidden; word-break: break-all; text-overflow: ellipsis; - - /* E2E icon wrapper */ - .mx_Flex > span { - display: inline-block; - } - } - - .mx_UserInfo_profile_name { - height: 30px; } .mx_UserInfo_profile_mxid { diff --git a/res/css/views/rooms/RoomListPanel/_RoomListHeaderView.pcss b/res/css/views/rooms/RoomListPanel/_RoomListHeaderView.pcss index 595f47f9c8..9af934c308 100644 --- a/res/css/views/rooms/RoomListPanel/_RoomListHeaderView.pcss +++ b/res/css/views/rooms/RoomListPanel/_RoomListHeaderView.pcss @@ -21,10 +21,6 @@ } } - button { - color: var(--cpd-color-icon-secondary); - } - .mx_SpaceMenu_button { svg { transition: transform 0.1s linear; diff --git a/res/css/views/rooms/RoomListPanel/_RoomListItemView.pcss b/res/css/views/rooms/RoomListPanel/_RoomListItemView.pcss index 1594781dbd..ac58a69bef 100644 --- a/res/css/views/rooms/RoomListPanel/_RoomListItemView.pcss +++ b/res/css/views/rooms/RoomListPanel/_RoomListItemView.pcss @@ -7,27 +7,19 @@ /** * The RoomListItemView has the following structure: - * button----------------------------------------| - * | <-12px-> container--------------------------| - * | | room avatar <-12px-> content-----| - * | | | room_name | - * | | | ----------| <-- border - * |---------------------------------------------| + * button--------------------------------------------------| + * | <-12px-> container------------------------------------| + * | | room avatar <-8px-> content----------------| + * | | | room_name <- 20px ->| + * | | | --------------------| <-- border + * |-------------------------------------------------------| */ .mx_RoomListItemView { all: unset; cursor: pointer; - &:hover { - background-color: var(--cpd-color-bg-action-secondary-hovered); - - .mx_RoomListItemView_content { - padding-right: var(--cpd-space-1-5x); - } - } - .mx_RoomListItemView_container { - padding-left: var(--cpd-space-2x); + padding-left: var(--cpd-space-3x); font: var(--cpd-font-body-md-regular); height: 100%; @@ -38,8 +30,21 @@ border-bottom: var(--cpd-border-width-0-5) solid var(--cpd-color-bg-subtle-secondary); box-sizing: border-box; min-width: 0; + padding-right: var(--cpd-space-5x); - span { + .mx_RoomListItemView_text { + min-width: 0; + } + + .mx_RoomListItemView_roomName { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + + .mx_RoomListItemView_messagePreview { + font: var(--cpd-font-body-sm-regular); + color: var(--cpd-color-text-secondary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; @@ -48,26 +53,24 @@ } } -.mx_RoomListItemView_menu_open { +.mx_RoomListItemView_hover { background-color: var(--cpd-color-bg-action-secondary-hovered); +} - .mx_RoomListItemView_content { - padding-right: var(--cpd-space-1-5x); - } +.mx_RoomListItemView_menu_open .mx_RoomListItemView_container .mx_RoomListItemView_content { + /** + * The figma uses 16px padding (--cpd-space-4x) but due to https://github.com/element-hq/compound-web/issues/331 + * the icon size of the menu is 18px instead of 20px with a different internal padding + * We need to use 18px to align the icon with the others icons + * 18px is not available in compound spacing + */ + padding-right: 18px; } .mx_RoomListItemView_selected { background-color: var(--cpd-color-bg-action-secondary-pressed); } -.mx_RoomListItemView_notification_decoration { - .mx_RoomListItemView_content { - padding-right: var(--cpd-space-2x); - } -} - -.mx_RoomListItemView_empty { - .mx_RoomListItemView_content { - padding-right: var(--cpd-space-3x); - } +.mx_RoomListItemView_bold .mx_RoomListItemView_roomName { + font: var(--cpd-font-body-md-semibold); } diff --git a/res/css/views/rooms/RoomListPanel/_RoomListSearch.pcss b/res/css/views/rooms/RoomListPanel/_RoomListSearch.pcss index 8a97086df8..f6f98e1efe 100644 --- a/res/css/views/rooms/RoomListPanel/_RoomListSearch.pcss +++ b/res/css/views/rooms/RoomListPanel/_RoomListSearch.pcss @@ -21,6 +21,7 @@ flex: 1; font: var(--cpd-font-body-md-regular); color: var(--cpd-color-text-secondary); + min-width: 0; span { flex: 1; @@ -28,6 +29,17 @@ kbd { font-family: inherit; } + + /* Shrink and truncate the search text */ + white-space: nowrap; + overflow: hidden; + .mx_RoomListSearch_search_text { + min-width: 0; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + text-align: start; + } } } diff --git a/res/css/views/rooms/RoomListPanel/_RoomListSecondaryFilters.pcss b/res/css/views/rooms/RoomListPanel/_RoomListSecondaryFilters.pcss new file mode 100644 index 0000000000..0fa8dc12ae --- /dev/null +++ b/res/css/views/rooms/RoomListPanel/_RoomListSecondaryFilters.pcss @@ -0,0 +1,12 @@ +/* + * 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_RoomListSecondaryFilters { + font: var(--cpd-font-body-md-medium); + margin: var(--cpd-space-2x); + margin-left: var(--cpd-space-1x); +} diff --git a/res/css/views/rooms/RoomListPanel/_RoomListSkeleton.pcss b/res/css/views/rooms/RoomListPanel/_RoomListSkeleton.pcss new file mode 100644 index 0000000000..2e644cbba1 --- /dev/null +++ b/res/css/views/rooms/RoomListPanel/_RoomListSkeleton.pcss @@ -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. + */ + +.mx_RoomListSkeleton { + position: relative; + margin-left: 4px; + height: 100%; + + &::before { + background-color: var(--cpd-color-bg-subtle-secondary); + width: 100%; + height: 100%; + + content: ""; + position: absolute; + mask-repeat: repeat-y; + mask-size: auto 96px; + mask-image: url("$(res)/img/element-icons/roomlist/room-list-item-skeleton.svg"); + } +} diff --git a/res/css/views/rooms/_EventBubbleTile.pcss b/res/css/views/rooms/_EventBubbleTile.pcss index c25cbfcec4..1607c45924 100644 --- a/res/css/views/rooms/_EventBubbleTile.pcss +++ b/res/css/views/rooms/_EventBubbleTile.pcss @@ -312,7 +312,7 @@ Please see LICENSE files in the repository root for full details. .mx_MessageTimestamp { border-radius: var(--MBody-border-radius); /* Hardcoded colours because it's the same on all themes */ - background-color: rgba(0, 0, 0, 0.6); + background-color: rgb(0, 0, 0, 0.6); color: #ffffff; padding: 0px 4px 0px 4px; } diff --git a/res/css/views/rooms/_PinnedMessageBanner.pcss b/res/css/views/rooms/_PinnedMessageBanner.pcss index da9b75b17d..d7f3ddd1fb 100644 --- a/res/css/views/rooms/_PinnedMessageBanner.pcss +++ b/res/css/views/rooms/_PinnedMessageBanner.pcss @@ -19,7 +19,7 @@ border-bottom: 1px solid var(--cpd-color-gray-400); /* From figma */ - box-shadow: 0 var(--cpd-space-2x) var(--cpd-space-6x) calc(var(--cpd-space-2x) * -1) rgba(27, 29, 34, 0.1); + box-shadow: 0 var(--cpd-space-2x) var(--cpd-space-6x) calc(var(--cpd-space-2x) * -1) rgb(27, 29, 34, 0.1); .mx_PinnedMessageBanner_main { background: transparent; diff --git a/res/css/views/rooms/_RoomSublist.pcss b/res/css/views/rooms/_RoomSublist.pcss index 7b0da5608e..3361bce4bb 100644 --- a/res/css/views/rooms/_RoomSublist.pcss +++ b/res/css/views/rooms/_RoomSublist.pcss @@ -404,8 +404,7 @@ Please see LICENSE files in the repository root for full details. height: 240px; &::before { - background: $roomsublist-skeleton-ui-bg; - + background-color: var(--cpd-color-bg-subtle-secondary); width: 100%; height: 100%; diff --git a/res/css/views/settings/_CrossSigningPanel.pcss b/res/css/views/settings/_CrossSigningPanel.pcss deleted file mode 100644 index e9b2aa0c29..0000000000 --- a/res/css/views/settings/_CrossSigningPanel.pcss +++ /dev/null @@ -1,36 +0,0 @@ -/* -Copyright 2024 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. -*/ - -.mx_CrossSigningPanel_statusList { - border-spacing: 0; - - th { - text-align: start; - } - - td, - th { - padding: 0; - - &:first-of-type { - padding-inline-end: 1em; - } - } -} - -.mx_CrossSigningPanel_buttonRow { - margin: 1em 0; - - :nth-child(n + 1) { - margin-inline-end: 10px; - } -} - -.mx_CrossSigningPanel_advanced { - width: fit-content; -} diff --git a/res/css/views/settings/_CryptographyPanel.pcss b/res/css/views/settings/_CryptographyPanel.pcss deleted file mode 100644 index 9c174ceaab..0000000000 --- a/res/css/views/settings/_CryptographyPanel.pcss +++ /dev/null @@ -1,32 +0,0 @@ -/* -Copyright 2024 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. -*/ - -.mx_CryptographyPanel_sessionInfo { - padding: 0em; - border-spacing: 0px; -} -.mx_CryptographyPanel_sessionInfo > tr { - vertical-align: baseline; - padding: 0em; - - th { - text-align: start; - } - - td, - th { - padding: 0 1em 0 0; - } -} - -.mx_CryptographyPanel_importExportButtons { - display: inline-flex; - flex-flow: wrap; - row-gap: $spacing-8; - column-gap: $spacing-8; -} diff --git a/res/css/views/settings/_JoinRuleSettings.pcss b/res/css/views/settings/_JoinRuleSettings.pcss index 485434f0da..fcb21fca96 100644 --- a/res/css/views/settings/_JoinRuleSettings.pcss +++ b/res/css/views/settings/_JoinRuleSettings.pcss @@ -53,6 +53,14 @@ Please see LICENSE files in the repository root for full details. display: block; } + &.mx_StyledRadioButton_disabled { + opacity: 0.5; + } + + &.mx_StyledRadioButton_disabled + span { + opacity: 0.5; + } + & + span { display: inline-block; margin-left: 34px; @@ -71,3 +79,7 @@ Please see LICENSE files in the repository root for full details. font: var(--cpd-font-body-md-regular); margin-top: var(--cpd-space-2x); } + +.mx_JoinRuleSettings_recommended { + color: $accent-1000; +} diff --git a/res/css/views/settings/_SecureBackupPanel.pcss b/res/css/views/settings/_SecureBackupPanel.pcss deleted file mode 100644 index 6e571af339..0000000000 --- a/res/css/views/settings/_SecureBackupPanel.pcss +++ /dev/null @@ -1,44 +0,0 @@ -/* -Copyright 2024 New Vector Ltd. -Copyright 2019, 2020 The Matrix.org Foundation C.I.C. -Copyright 2018 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_SecureBackupPanel_deviceName { - font-style: italic; -} - -.mx_SecureBackupPanel_buttonRow { - margin: 1em 0; - display: inline-flex; - flex-flow: wrap; - row-gap: 10px; - - :nth-child(n + 1) { - margin-inline-end: 10px; - } -} - -.mx_SecureBackupPanel_statusList { - border-spacing: 0; - - th { - text-align: start; - } - - td, - th { - padding: 0; - - &:first-of-type { - padding-inline-end: 1em; - } - } -} - -.mx_SecureBackupPanel_advanced { - width: fit-content; -} diff --git a/res/css/views/settings/encryption/_EncryptionCard.pcss b/res/css/views/settings/encryption/_EncryptionCard.pcss index 87118135e5..5598facfa9 100644 --- a/res/css/views/settings/encryption/_EncryptionCard.pcss +++ b/res/css/views/settings/encryption/_EncryptionCard.pcss @@ -12,7 +12,7 @@ padding: var(--cpd-space-10x); border-radius: var(--cpd-space-4x); /* From figma */ - box-shadow: 0 1.2px 2.4px 0 rgba(27, 29, 34, 0.15); + box-shadow: 0 1.2px 2.4px 0 rgb(27, 29, 34, 0.15); border: 1px solid var(--cpd-color-gray-400); .mx_EncryptionCard_header { diff --git a/res/css/views/settings/tabs/user/_MediaPreviewAccountSettings.pcss b/res/css/views/settings/tabs/user/_MediaPreviewAccountSettings.pcss new file mode 100644 index 0000000000..68187854c1 --- /dev/null +++ b/res/css/views/settings/tabs/user/_MediaPreviewAccountSettings.pcss @@ -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. +*/ + +.mx_MediaPreviewAccountSetting_Radio { + margin: var(--cpd-space-1x) 0; +} + +.mx_MediaPreviewAccountSetting { + margin-top: var(--cpd-space-1x); +} + +.mx_MediaPreviewAccountSetting_RadioHelp { + margin-top: 0; + margin-bottom: var(--cpd-space-1x); +} + +.mx_MediaPreviewAccountSetting_Form { + width: 100%; +} + +.mx_MediaPreviewAccountSetting_ToggleSwitch { + font: var(--cpd-font-body-md-medium); + letter-spacing: var(--cpd-font-letter-spacing-body-md); +} diff --git a/res/css/views/settings/tabs/user/_SecurityUserSettingsTab.pcss b/res/css/views/settings/tabs/user/_SecurityUserSettingsTab.pcss index cb5d1fbc94..82f839042b 100644 --- a/res/css/views/settings/tabs/user/_SecurityUserSettingsTab.pcss +++ b/res/css/views/settings/tabs/user/_SecurityUserSettingsTab.pcss @@ -11,6 +11,12 @@ Please see LICENSE files in the repository root for full details. column-gap: $spacing-8; } +.mx_SecurityUserSettingsTab_ignoredUsers { + padding-left: 0; + margin: 0; + list-style: none; +} + .mx_SecurityUserSettingsTab_ignoredUser { margin-bottom: $spacing-4; } diff --git a/res/css/views/verification/_VerificationShowSas.pcss b/res/css/views/verification/_VerificationShowSas.pcss index 5e007ed234..1a24519cbf 100644 --- a/res/css/views/verification/_VerificationShowSas.pcss +++ b/res/css/views/verification/_VerificationShowSas.pcss @@ -46,10 +46,8 @@ Please see LICENSE files in the repository root for full details. } .mx_VerificationShowSas_emojiSas_label { - overflow: hidden; - white-space: nowrap; - text-overflow: ellipsis; font-size: $font-12px; + word-break: break-all; } .mx_VerificationShowSas_emojiSas_break { diff --git a/res/css/views/voip/_LegacyCallView.pcss b/res/css/views/voip/_LegacyCallView.pcss index 007e8b5000..93d3e23f8b 100644 --- a/res/css/views/voip/_LegacyCallView.pcss +++ b/res/css/views/voip/_LegacyCallView.pcss @@ -95,7 +95,7 @@ Please see LICENSE files in the repository root for full details. height: 100%; left: 0; right: 0; - background-color: rgba(0, 0, 0, 0.6); + background-color: rgb(0, 0, 0, 0.6); } } @@ -144,7 +144,7 @@ Please see LICENSE files in the repository root for full details. border-radius: 8px; background-color: $system; - box-shadow: 0px 4px 20px rgba(0, 0, 0, 0.2); + box-shadow: 0px 4px 20px rgb(0, 0, 0, 0.2); .mx_LegacyCallViewButtons { bottom: 13px; diff --git a/res/css/views/voip/_VideoFeed.pcss b/res/css/views/voip/_VideoFeed.pcss index 6abe390126..8522fbb4f0 100644 --- a/res/css/views/voip/_VideoFeed.pcss +++ b/res/css/views/voip/_VideoFeed.pcss @@ -56,7 +56,7 @@ Please see LICENSE files in the repository root for full details. width: 24px; height: 24px; - background-color: rgba(0, 0, 0, 0.5); /* Same on both themes */ + background-color: rgb(0, 0, 0, 0.5); /* Same on both themes */ border-radius: 100%; &::before { diff --git a/res/img/element-icons/roomlist/room-avatar-view-icon-mask.svg b/res/img/element-icons/roomlist/room-avatar-view-icon-mask.svg new file mode 100644 index 0000000000..8eaf2f1831 --- /dev/null +++ b/res/img/element-icons/roomlist/room-avatar-view-icon-mask.svg @@ -0,0 +1,3 @@ + + + diff --git a/res/img/element-icons/roomlist/room-avatar-view-presence-mask.svg b/res/img/element-icons/roomlist/room-avatar-view-presence-mask.svg new file mode 100644 index 0000000000..b40f382d4b --- /dev/null +++ b/res/img/element-icons/roomlist/room-avatar-view-presence-mask.svg @@ -0,0 +1,3 @@ + + + diff --git a/res/img/element-icons/roomlist/room-list-item-skeleton.svg b/res/img/element-icons/roomlist/room-list-item-skeleton.svg new file mode 100644 index 0000000000..adf56e4ed8 --- /dev/null +++ b/res/img/element-icons/roomlist/room-list-item-skeleton.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/res/themes/light-high-contrast/css/_light-high-contrast.pcss b/res/themes/light-high-contrast/css/_light-high-contrast.pcss index 213c641440..94774bc5b8 100644 --- a/res/themes/light-high-contrast/css/_light-high-contrast.pcss +++ b/res/themes/light-high-contrast/css/_light-high-contrast.pcss @@ -163,7 +163,8 @@ $accent-1400: var(--cpd-color-green-1400); &.mx_SpotlightDialog_startChat::before, &.mx_SpotlightDialog_joinRoomAlias::before, &.mx_SpotlightDialog_explorePublicRooms::before, - &.mx_SpotlightDialog_startGroupChat::before { + &.mx_SpotlightDialog_startGroupChat::before, + &.mx_SpotlightDialog_searchMessages::before { background-color: $background !important; } diff --git a/res/welcome.html b/res/welcome.html index ef2d43bd8f..9fdd60a7c0 100644 --- a/res/welcome.html +++ b/res/welcome.html @@ -3,7 +3,7 @@ * voodoo where we have to set display: none by default */ - h1::after { + .mx_Header_title::after { content: "!"; } diff --git a/scripts/docker-package.sh b/scripts/docker-package.sh index 0920587117..14444de556 100755 --- a/scripts/docker-package.sh +++ b/scripts/docker-package.sh @@ -3,7 +3,6 @@ set -ex BRANCH=$(git rev-parse --abbrev-ref HEAD) -DIST_VERSION=$(git describe --abbrev=0 --tags) DIR=$(dirname "$0") @@ -13,6 +12,8 @@ DIR=$(dirname "$0") if [[ $BRANCH != HEAD && ! $BRANCH =~ heads/v.+ ]] then DIST_VERSION=$("$DIR"/get-version-from-git.sh) +else + DIST_VERSION=$(git describe --abbrev=0 --tags) fi DIST_VERSION=$("$DIR"/normalize-version.sh "$DIST_VERSION") diff --git a/src/@types/global.d.ts b/src/@types/global.d.ts index 3bbeda067b..d4ae6c897b 100644 --- a/src/@types/global.d.ts +++ b/src/@types/global.d.ts @@ -29,7 +29,6 @@ import type LegacyCallHandler from "../LegacyCallHandler"; import type UserActivity from "../UserActivity"; import { type ModalWidgetStore } from "../stores/ModalWidgetStore"; import { type WidgetLayoutStore } from "../stores/widgets/WidgetLayoutStore"; -import type VoipUserMapper from "../VoipUserMapper"; import { type SpaceStoreClass } from "../stores/spaces/SpaceStore"; import type TypingStore from "../stores/TypingStore"; import { type EventIndexPeg } from "../indexing/EventIndexPeg"; @@ -83,19 +82,10 @@ declare global { mxMatrixClientPeg: IMatrixClientPeg; mxReactSdkConfig: DeepReadonly; - // Needed for Safari, unknown to TypeScript - webkitAudioContext: typeof AudioContext; - // https://docs.microsoft.com/en-us/previous-versions/hh772328(v=vs.85) // we only ever check for its existence, so we can ignore its actual type MSStream?: unknown; - // https://github.com/microsoft/TypeScript-DOM-lib-generator/issues/1029#issuecomment-869224737 - // https://developer.mozilla.org/en-US/docs/Web/API/OffscreenCanvas - OffscreenCanvas?: { - new (width: number, height: number): OffscreenCanvas; - }; - mxContentMessages: ContentMessages; mxToastStore: ToastStore; mxDeviceListener: DeviceListener; @@ -113,7 +103,6 @@ declare global { mxLegacyCallHandler: LegacyCallHandler; mxUserActivity: UserActivity; mxModalWidgetStore: ModalWidgetStore; - mxVoipUserMapper: VoipUserMapper; mxSpaceStore: SpaceStoreClass; mxVoiceRecordingStore: VoiceRecordingStore; mxTypingStore: TypingStore; @@ -139,8 +128,19 @@ declare global { } interface Electron { + // Legacy on(channel: ElectronChannel, listener: (event: Event, ...args: any[]) => void): void; send(channel: ElectronChannel, ...args: any[]): void; + // Initialisation + initialise(): Promise<{ + protocol: string; + sessionId: string; + config: IConfigOptions; + supportedSettings: Record; + }>; + // Settings + setSettingValue(settingName: string, value: any): Promise; + getSettingValue(settingName: string): Promise; } interface DesktopCapturerSource { @@ -158,31 +158,10 @@ declare global { fetchWindowIcons?: boolean; } - interface Document { - // Safari & IE11 only have this prefixed: we used prefixed versions - // previously so let's continue to support them for now - webkitExitFullscreen(): Promise; - msExitFullscreen(): Promise; - readonly webkitFullscreenElement: Element | null; - readonly msFullscreenElement: Element | null; - } - - interface Navigator { - userLanguage?: string; - } - interface StorageEstimate { usageDetails?: { [key: string]: number }; } - interface Element { - // Safari & IE11 only have this prefixed: we used prefixed versions - // previously so let's continue to support them for now - webkitRequestFullScreen(options?: FullscreenOptions): Promise; - msRequestFullscreen(options?: FullscreenOptions): Promise; - // scrollIntoView(arg?: boolean | _ScrollIntoViewOptions): void; - } - // https://github.com/microsoft/TypeScript/issues/28308#issuecomment-650802278 interface AudioWorkletProcessor { readonly port: MessagePort; @@ -241,11 +220,4 @@ declare global { var mx_rage_store: IndexedDBLogStore; } -// add method which is missing from the node typing -declare module "url" { - interface Url { - format(): string; - } -} - /* eslint-enable @typescript-eslint/naming-convention */ diff --git a/src/@types/invite-rules.ts b/src/@types/invite-rules.ts new file mode 100644 index 0000000000..bc72a5e922 --- /dev/null +++ b/src/@types/invite-rules.ts @@ -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. +*/ + +export const INVITE_RULES_ACCOUNT_DATA_TYPE = "org.matrix.msc4155.invite_permission_config"; + +export interface InviteConfigAccountData { + allowed_users?: string[]; + blocked_users?: string[]; + ignored_users?: string[]; + allowed_servers?: string[]; + blocked_servers?: string[]; + ignored_servers?: string[]; +} + +/** + * Computed values based on MSC4155. Currently Element Web only supports + * blocking all invites. + */ +export interface ComputedInviteConfig extends Record { + /** + * Are all invites blocked. This is only about blocking all invites, + * but this being false may still block invites through other rules. + */ + allBlocked: boolean; +} diff --git a/src/@types/matrix-js-sdk.d.ts b/src/@types/matrix-js-sdk.d.ts index 92b76c4c4d..e1f8c4562e 100644 --- a/src/@types/matrix-js-sdk.d.ts +++ b/src/@types/matrix-js-sdk.d.ts @@ -1,5 +1,5 @@ /* -Copyright 2024 New Vector Ltd. +Copyright 2024, 2025 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 @@ -14,6 +14,8 @@ import type { EncryptedFile } from "matrix-js-sdk/src/types"; import type { EmptyObject } from "matrix-js-sdk/src/matrix"; import type { DeviceClientInformation } from "../utils/device/types.ts"; import type { UserWidget } from "../utils/WidgetUtils-types.ts"; +import { type MediaPreviewConfig } from "./media_preview.ts"; +import { type INVITE_RULES_ACCOUNT_DATA_TYPE, type InviteConfigAccountData } from "./invite-rules.ts"; // Extend Matrix JS SDK types via Typescript declaration merging to support unspecced event fields and types declare module "matrix-js-sdk/src/types" { @@ -59,7 +61,6 @@ declare module "matrix-js-sdk/src/types" { }; }; } - export interface AccountDataEvents { // Analytics account data event "im.vector.analytics": { @@ -87,6 +88,10 @@ declare module "matrix-js-sdk/src/types" { "m.accepted_terms": { accepted: string[]; }; + + // MSC4155: Invite filtering + [INVITE_RULES_ACCOUNT_DATA_TYPE]: InviteConfigAccountData; + "io.element.msc4278.media_preview_config": MediaPreviewConfig; } export interface AudioContent { diff --git a/src/@types/media_preview.ts b/src/@types/media_preview.ts new file mode 100644 index 0000000000..d340e64caf --- /dev/null +++ b/src/@types/media_preview.ts @@ -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. +*/ + +export enum MediaPreviewValue { + /** + * Media previews should be enabled. + */ + On = "on", + /** + * Media previews should only be enabled for rooms with non-public join rules. + */ + Private = "private", + /** + * Media previews should be disabled. + */ + Off = "off", +} + +export const MEDIA_PREVIEW_ACCOUNT_DATA_TYPE = "io.element.msc4278.media_preview_config"; +export interface MediaPreviewConfig extends Record { + /** + * Media preview setting for thumbnails of media in rooms. + */ + media_previews: MediaPreviewValue; + /** + * Media preview settings for avatars of rooms we have been invited to. + */ + invite_avatars: MediaPreviewValue.On | MediaPreviewValue.Off; +} diff --git a/src/@types/react.d.ts b/src/@types/react.d.ts index d66c22e56f..61a4c59992 100644 --- a/src/@types/react.d.ts +++ b/src/@types/react.d.ts @@ -6,16 +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 { type PropsWithChildren } from "react"; - -import type React from "react"; +import { type ComponentType } from "react"; declare module "react" { - // Fix forwardRef types for Generic components - https://stackoverflow.com/a/58473012 - function forwardRef( - render: (props: PropsWithChildren

, ref: React.ForwardedRef) => React.ReactElement | null, - ): (props: P & React.RefAttributes) => React.ReactElement | null; - // Fix lazy types - https://stackoverflow.com/a/71017028 function lazy>(factory: () => Promise<{ default: T }>): T; + + // Standardize defaultProps for FunctionComponent so we can write generics assuming `defaultProps` exists on ComponentType + interface FunctionComponent { + defaultProps?: unknown; + } } diff --git a/src/AddThreepid.ts b/src/AddThreepid.ts index 7d2c4cefb5..877a0c5c4a 100644 --- a/src/AddThreepid.ts +++ b/src/AddThreepid.ts @@ -79,6 +79,8 @@ export default class AddThreepid { } catch (err) { if (err instanceof MatrixError && err.errcode === "M_THREEPID_IN_USE") { throw new UserFriendlyError("settings|general|email_address_in_use", { cause: err }); + } else if (err instanceof MatrixError && err.errcode === "M_THREEPID_MEDIUM_NOT_SUPPORTED") { + throw new UserFriendlyError("settings|general|email_adding_unsupported_by_hs", { cause: err }); } // Otherwise, just blurt out the same error throw err; @@ -121,6 +123,8 @@ export default class AddThreepid { * @param {string} phoneCountry The ISO 2 letter code of the country to resolve phoneNumber in * @param {string} phoneNumber The national or international formatted phone number to add * @return {Promise} Resolves when the text message has been sent. Then call haveMsisdnToken(). + * + * @throws {UserFriendlyError} An appropriate user-friendly error if the verification code could not be sent. */ public async addMsisdn(phoneCountry: string, phoneNumber: string): Promise { try { @@ -136,6 +140,10 @@ export default class AddThreepid { } catch (err) { if (err instanceof MatrixError && err.errcode === "M_THREEPID_IN_USE") { throw new UserFriendlyError("settings|general|msisdn_in_use", { cause: err }); + } else if (err instanceof MatrixError && err.errcode === "M_THREEPID_MEDIUM_NOT_SUPPORTED") { + throw new UserFriendlyError("settings|general|msisdn_adding_unsupported_by_hs", { cause: err }); + } else if (err instanceof MatrixError && err.errcode === "M_INVALID_PARAM") { + throw new UserFriendlyError("settings|general|invalid_phone_number", { cause: err }); } // Otherwise, just blurt out the same error throw err; diff --git a/src/Avatar.ts b/src/Avatar.ts index abddfd87f1..921332c250 100644 --- a/src/Avatar.ts +++ b/src/Avatar.ts @@ -147,11 +147,12 @@ export function avatarUrlForRoom( width?: number, height?: number, resizeMethod?: ResizeMethod, + avatarMxcOverride?: string, ): string | null { if (!room) return null; // null-guard - - if (room.getMxcAvatarUrl()) { - const media = mediaFromMxc(room.getMxcAvatarUrl() ?? undefined); + const mxc = avatarMxcOverride ?? room.getMxcAvatarUrl(); + if (mxc) { + const media = mediaFromMxc(mxc); if (width !== undefined && height !== undefined) { return media.getThumbnailOfSourceHttp(width, height, resizeMethod); } diff --git a/src/BasePlatform.ts b/src/BasePlatform.ts index c7b7825fe6..87635a421c 100644 --- a/src/BasePlatform.ts +++ b/src/BasePlatform.ts @@ -72,7 +72,7 @@ export default abstract class BasePlatform { protected _favicon?: Favicon; protected constructor() { - dis.register(this.onAction); + dis.register(this.onAction.bind(this)); this.startUpdateCheck = this.startUpdateCheck.bind(this); } @@ -85,14 +85,14 @@ export default abstract class BasePlatform { */ public abstract getDefaultDeviceDisplayName(): string; - protected onAction = (payload: ActionPayload): void => { + protected onAction(payload: ActionPayload): void { switch (payload.action) { case "on_client_not_viable": case Action.OnLoggedOut: this.setNotificationCount(0); break; } - }; + } // Used primarily for Analytics public abstract getHumanReadableName(): string; diff --git a/src/ContentMessages.ts b/src/ContentMessages.ts index c5e34d7130..54aaea3ae1 100644 --- a/src/ContentMessages.ts +++ b/src/ContentMessages.ts @@ -63,6 +63,7 @@ import { blobIsAnimated } from "./utils/Image.ts"; const PHYS_HIDPI = [0x00, 0x00, 0x16, 0x25, 0x00, 0x00, 0x16, 0x25, 0x01]; export class UploadCanceledError extends Error {} +export class UploadFailedError extends Error {} interface IMediaConfig { "m.upload.size"?: number; @@ -355,12 +356,19 @@ export async function uploadFile( // Pass the encrypted data as a Blob to the uploader. const blob = new Blob([encryptResult.data]); - const { content_uri: url } = await matrixClient.uploadContent(blob, { - progressHandler, - abortController, - includeFilename: false, - type: "application/octet-stream", - }); + let url: string; + try { + ({ content_uri: url } = await matrixClient.uploadContent(blob, { + progressHandler, + abortController, + includeFilename: false, + type: "application/octet-stream", + })); + } catch (e) { + if (abortController.signal.aborted) throw new UploadCanceledError(); + console.error("Failed to upload file", e); + throw new UploadFailedError(); + } if (abortController.signal.aborted) throw new UploadCanceledError(); // If the attachment is encrypted then bundle the URL along with the information @@ -372,7 +380,14 @@ export async function uploadFile( } as EncryptedFile, }; } else { - const { content_uri: url } = await matrixClient.uploadContent(file, { progressHandler, abortController }); + let url: string; + try { + ({ content_uri: url } = await matrixClient.uploadContent(file, { progressHandler, abortController })); + } catch (e) { + if (abortController.signal.aborted) throw new UploadCanceledError(); + console.error("Failed to upload file", e); + throw new UploadFailedError(); + } if (abortController.signal.aborted) throw new UploadCanceledError(); // If the attachment isn't encrypted then include the URL directly. return { url }; @@ -570,7 +585,7 @@ export default class ContentMessages { const imageInfo = await infoForImageFile(matrixClient, roomId, file); Object.assign(content.info, imageInfo); } catch (e) { - if (e instanceof HTTPError) { + if (e instanceof UploadFailedError) { // re-throw to main upload error handler throw e; } diff --git a/src/CreateCrossSigning.ts b/src/CreateCrossSigning.ts index db9bc3e3fe..5f6f3e48aa 100644 --- a/src/CreateCrossSigning.ts +++ b/src/CreateCrossSigning.ts @@ -38,10 +38,10 @@ export async function createCrossSigning(cli: MatrixClient): Promise { export async function uiAuthCallback( matrixClient: MatrixClient, - makeRequest: (authData: AuthDict) => Promise, + makeRequest: (authData: AuthDict | null) => Promise, ): Promise { try { - await makeRequest({}); + await makeRequest(null); } catch (error) { if (!(error instanceof MatrixError) || !error.data || !error.data.flows) { // Not a UIA response @@ -64,7 +64,7 @@ export async function uiAuthCallback( }; const { finished } = Modal.createDialog(InteractiveAuthDialog, { - title: _t("encryption|bootstrap_title"), + title: "", matrixClient, makeRequest, aestheticsForStagePhases: { diff --git a/src/DeviceListener.ts b/src/DeviceListener.ts index 751e71dd9f..e13d296bc1 100644 --- a/src/DeviceListener.ts +++ b/src/DeviceListener.ts @@ -97,6 +97,7 @@ export default class DeviceListener { this.client.on(CryptoEvent.DevicesUpdated, this.onDevicesUpdated); this.client.on(CryptoEvent.UserTrustStatusChanged, this.onUserTrustStatusChanged); this.client.on(CryptoEvent.KeysChanged, this.onCrossSingingKeysChanged); + this.client.on(CryptoEvent.KeyBackupStatus, this.onKeyBackupStatusChanged); this.client.on(ClientEvent.AccountData, this.onAccountData); this.client.on(ClientEvent.Sync, this.onSync); this.client.on(RoomStateEvent.Events, this.onRoomStateEvents); @@ -132,7 +133,7 @@ export default class DeviceListener { this.dismissedThisDeviceToast = false; this.keyBackupInfo = null; this.keyBackupFetchedAt = null; - this.keyBackupStatusChecked = false; + this.cachedKeyBackupUploadActive = undefined; this.ourDeviceIdsAtStart = null; this.displayingToastsForDeviceIds = new Set(); this.client = undefined; @@ -157,6 +158,13 @@ export default class DeviceListener { this.recheck(); } + /** + * Set the account data "m.org.matrix.custom.backup_disabled" to { "disabled": true }. + */ + public async recordKeyBackupDisabled(): Promise { + await this.client?.setAccountData(BACKUP_DISABLED_ACCOUNT_DATA_KEY, { disabled: true }); + } + private async ensureDeviceIdsAtStartPopulated(): Promise { if (this.ourDeviceIdsAtStart === null) { this.ourDeviceIdsAtStart = await this.getDeviceIds(); @@ -192,6 +200,11 @@ export default class DeviceListener { this.recheck(); }; + private onKeyBackupStatusChanged = (): void => { + this.cachedKeyBackupUploadActive = undefined; + this.recheck(); + }; + private onCrossSingingKeysChanged = (): void => { this.recheck(); }; @@ -201,11 +214,13 @@ export default class DeviceListener { // * migrated SSSS to symmetric // * uploaded keys to secret storage // * completed secret storage creation + // * disabled key backup // which result in account data changes affecting checks below. if ( ev.getType().startsWith("m.secret_storage.") || ev.getType().startsWith("m.cross_signing.") || - ev.getType() === "m.megolm_backup.v1" + ev.getType() === "m.megolm_backup.v1" || + ev.getType() === BACKUP_DISABLED_ACCOUNT_DATA_KEY ) { this.recheck(); } @@ -324,7 +339,16 @@ export default class DeviceListener { (await crypto.getDeviceVerificationStatus(cli.getSafeUserId(), cli.deviceId!))?.crossSigningVerified, ); - const allSystemsReady = crossSigningReady && secretStorageReady && allCrossSigningSecretsCached; + const keyBackupUploadActive = await this.isKeyBackupUploadActive(); + const backupDisabled = await this.recheckBackupDisabled(cli); + + // We warn if key backup upload is turned off and we have not explicitly + // said we are OK with that. + const keyBackupIsOk = keyBackupUploadActive || backupDisabled; + + const allSystemsReady = + crossSigningReady && keyBackupIsOk && secretStorageReady && allCrossSigningSecretsCached; + await this.reportCryptoSessionStateToAnalytics(cli); if (this.dismissedThisDeviceToast || allSystemsReady) { @@ -353,14 +377,19 @@ export default class DeviceListener { crossSigningStatus.privateKeysCachedLocally, ); showSetupEncryptionToast(SetupKind.KEY_STORAGE_OUT_OF_SYNC); + } else if (!keyBackupIsOk) { + logSpan.info("Key backup upload is unexpectedly turned off: showing TURN_ON_KEY_STORAGE toast"); + showSetupEncryptionToast(SetupKind.TURN_ON_KEY_STORAGE); } else if (defaultKeyId === null) { - // the user just hasn't set up 4S yet: prompt them to do so (unless they've explicitly said no to key storage) - const disabledEvent = cli.getAccountData(BACKUP_DISABLED_ACCOUNT_DATA_KEY); - if (!disabledEvent?.getContent().disabled) { + // The user just hasn't set up 4S yet: if they have key + // backup, prompt them to turn on recovery too. (If not, they + // have explicitly opted out, so don't hassle them.) + if (keyBackupUploadActive) { logSpan.info("No default 4S key: showing SET_UP_RECOVERY toast"); showSetupEncryptionToast(SetupKind.SET_UP_RECOVERY); } else { logSpan.info("No default 4S key but backup disabled: no toast needed"); + hideSetupEncryptionToast(); } } else { // some other condition... yikes! Show the 'set up encryption' toast: this is what we previously did @@ -443,6 +472,16 @@ export default class DeviceListener { this.displayingToastsForDeviceIds = newUnverifiedDeviceIds; } + /** + * Fetch the account data for `backup_disabled`. If this is the first time, + * fetch it from the server (in case the initial sync has not finished). + * Otherwise, fetch it from the store as normal. + */ + private async recheckBackupDisabled(cli: MatrixClient): Promise { + const backupDisabled = await cli.getAccountDataFromServer(BACKUP_DISABLED_ACCOUNT_DATA_KEY); + return !!backupDisabled?.disabled; + } + /** * Reports current recovery state to analytics. * Checks if the session is verified and if the recovery is correctly set up (i.e all secrets known locally and in 4S). @@ -512,18 +551,42 @@ export default class DeviceListener { * trigger an auto-rageshake). */ private checkKeyBackupStatus = async (): Promise => { - if (this.keyBackupStatusChecked || !this.client) { - return; - } - const activeKeyBackupVersion = await this.client.getCrypto()?.getActiveSessionBackupVersion(); - // if key backup is enabled, no need to check this ever again (XXX: why only when it is enabled?) - this.keyBackupStatusChecked = !!activeKeyBackupVersion; - - if (!activeKeyBackupVersion) { + if (!(await this.isKeyBackupUploadActive())) { dis.dispatch({ action: Action.ReportKeyBackupNotEnabled }); } }; - private keyBackupStatusChecked = false; + + /** + * Is key backup enabled? Use a cached answer if we have one. + */ + private isKeyBackupUploadActive = async (): Promise => { + if (!this.client) { + // To preserve existing behaviour, if there is no client, we + // pretend key backup upload is on. + // + // Someone looking to improve this code could try throwing an error + // here since we don't expect client to be undefined. + return true; + } + + const crypto = this.client.getCrypto(); + if (!crypto) { + // If there is no crypto, there is no key backup + return false; + } + + // If we've already cached the answer, return it. + if (this.cachedKeyBackupUploadActive !== undefined) { + return this.cachedKeyBackupUploadActive; + } + + // Fetch the answer and cache it + const activeKeyBackupVersion = await crypto.getActiveSessionBackupVersion(); + this.cachedKeyBackupUploadActive = !!activeKeyBackupVersion; + + return this.cachedKeyBackupUploadActive; + }; + private cachedKeyBackupUploadActive: boolean | undefined = undefined; private onRecordClientInformationSettingChange: CallbackFn = ( _originalSettingName, diff --git a/src/HtmlUtils.tsx b/src/HtmlUtils.tsx index 5bc20095f3..1d17710dab 100644 --- a/src/HtmlUtils.tsx +++ b/src/HtmlUtils.tsx @@ -1,5 +1,5 @@ /* -Copyright 2024 New Vector Ltd. +Copyright 2024, 2025 New Vector Ltd. Copyright 2019 Michael Telatynski <7t3chguy@gmail.com> Copyright 2019 The Matrix.org Foundation C.I.C. Copyright 2017, 2018 New Vector Ltd @@ -9,7 +9,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 React, { type JSX, type LegacyRef, type ReactNode } from "react"; +import React, { type JSX, type Key, type LegacyRef, type ReactNode } from "react"; import sanitizeHtml, { type IOptions } from "sanitize-html"; import classNames from "classnames"; import katex from "katex"; @@ -239,7 +239,7 @@ class HtmlHighlighter extends BaseHighlighter { const emojiToHtmlSpan = (emoji: string): string => `${emoji}`; -const emojiToJsxSpan = (emoji: string, key: number): JSX.Element => ( +const emojiToJsxSpan = (emoji: string, key: Key): JSX.Element => ( {emoji} @@ -294,6 +294,10 @@ export interface EventRenderOpts { disableBigEmoji?: boolean; stripReplyFallback?: boolean; forComposerQuote?: boolean; + /** + * Should inline media be rendered? + */ + mediaIsVisible?: boolean; } function analyseEvent(content: IContent, highlights: Optional, opts: EventRenderOpts = {}): EventAnalysis { @@ -302,6 +306,20 @@ function analyseEvent(content: IContent, highlights: Optional, opts: E sanitizeParams = composerSanitizeHtmlParams; } + if (opts.mediaIsVisible === false && sanitizeParams.transformTags?.["img"]) { + // Prevent mutating the source of sanitizeParams. + sanitizeParams = { + ...sanitizeParams, + transformTags: { + ...sanitizeParams.transformTags, + img: (tagName) => { + // Remove element + return { tagName, attribs: {} }; + }, + }, + }; + } + try { const isFormattedBody = content.format === "org.matrix.custom.html" && typeof content.formatted_body === "string"; @@ -462,10 +480,6 @@ export function topicToHtml( ref?: LegacyRef, allowExtendedHtml = false, ): ReactNode { - if (!SettingsStore.getValue("feature_html_topic")) { - htmlTopic = undefined; - } - let isFormattedTopic = !!htmlTopic; let topicHasEmoji = false; let safeTopic = ""; diff --git a/src/LegacyCallHandler.tsx b/src/LegacyCallHandler.tsx index 608d396dda..9c3e7073d5 100644 --- a/src/LegacyCallHandler.tsx +++ b/src/LegacyCallHandler.tsx @@ -39,14 +39,12 @@ import { WidgetMessagingStore } from "./stores/widgets/WidgetMessagingStore"; import { ElementWidgetActions } from "./stores/widgets/ElementWidgetActions"; import { UIFeature } from "./settings/UIFeature"; import { Action } from "./dispatcher/actions"; -import VoipUserMapper from "./VoipUserMapper"; import { addManagedHybridWidget, isManagedHybridWidgetEnabled } from "./widgets/ManagedHybrid"; import SdkConfig from "./SdkConfig"; import { ensureDMExists } from "./createRoom"; import { Container, WidgetLayoutStore } from "./stores/widgets/WidgetLayoutStore"; import IncomingLegacyCallToast, { getIncomingLegacyCallToastKey } from "./toasts/IncomingLegacyCallToast"; import ToastStore from "./stores/ToastStore"; -import Resend from "./Resend"; import { type ViewRoomPayload } from "./dispatcher/payloads/ViewRoomPayload"; import { InviteKind } from "./components/views/dialogs/InviteDialogTypes"; import { type OpenInviteDialogPayload } from "./dispatcher/payloads/OpenInviteDialogPayload"; @@ -59,8 +57,6 @@ import { Jitsi } from "./widgets/Jitsi.ts"; export const PROTOCOL_PSTN = "m.protocol.pstn"; export const PROTOCOL_PSTN_PREFIXED = "im.vector.protocol.pstn"; -export const PROTOCOL_SIP_NATIVE = "im.vector.protocol.sip_native"; -export const PROTOCOL_SIP_VIRTUAL = "im.vector.protocol.sip_virtual"; const CHECK_PROTOCOLS_ATTEMPTS = 3; @@ -107,27 +103,9 @@ const debuglog = (...args: any[]): void => { } }; -interface ThirdpartyLookupResponseFields { - /* eslint-disable camelcase */ - - // im.vector.sip_native - virtual_mxid?: string; - is_virtual?: boolean; - - // im.vector.sip_virtual - native_mxid?: string; - is_native?: boolean; - - // common - lookup_success?: boolean; - - /* eslint-enable camelcase */ -} - interface ThirdpartyLookupResponse { userid: string; protocol: string; - fields: ThirdpartyLookupResponseFields; } export enum LegacyCallHandlerEvent { @@ -158,7 +136,6 @@ export default class LegacyCallHandler extends TypedEventEmitter(); // callId (target) -> call (transferee) private supportsPstnProtocol: boolean | null = null; private pstnSupportPrefixed: boolean | null = null; // True if the server only support the prefixed pstn protocol - private supportsSipNativeVirtual: boolean | null = null; // im.vector.protocol.sip_virtual and im.vector.protocol.sip_native // Map of the asserted identity users after we've looked them up using the API. // We need to be be able to determine the mapped room synchronously, so we @@ -179,8 +156,7 @@ export default class LegacyCallHandler extends TypedEventEmitter { try { return await MatrixClientPeg.safeGet().getThirdpartyUser( @@ -323,28 +289,6 @@ export default class LegacyCallHandler extends TypedEventEmitter { - try { - return await MatrixClientPeg.safeGet().getThirdpartyUser(PROTOCOL_SIP_VIRTUAL, { - native_mxid: nativeMxid, - }); - } catch (e) { - logger.warn("Failed to query SIP identity for user", e); - return Promise.resolve([]); - } - } - - public async sipNativeLookup(virtualMxid: string): Promise { - try { - return await MatrixClientPeg.safeGet().getThirdpartyUser(PROTOCOL_SIP_NATIVE, { - virtual_mxid: virtualMxid, - }); - } catch (e) { - logger.warn("Failed to query identity for SIP user", e); - return Promise.resolve([]); - } - } - private onCallIncoming = (call: MatrixCall): void => { // if the runtime env doesn't do VoIP, stop here. if (!MatrixClientPeg.get()?.supportsVoip()) { @@ -537,24 +481,16 @@ export default class LegacyCallHandler extends TypedEventEmitter { - SettingsStore.setValue("fallbackICEServerAllowed", null, SettingLevel.DEVICE, allow); - }, }, undefined, true, ); + + finished.then(([allow]) => { + SettingsStore.setValue("fallbackICEServerAllowed", null, SettingLevel.DEVICE, allow); + }); } private showMediaCaptureError(call: MatrixCall): void { @@ -810,24 +747,10 @@ export default class LegacyCallHandler extends TypedEventEmitter { const cli = MatrixClientPeg.safeGet(); - const mappedRoomId = (await VoipUserMapper.sharedInstance().getOrCreateVirtualRoomForRoom(roomId)) || roomId; - logger.debug("Mapped real room " + roomId + " to room ID " + mappedRoomId); - - // If we're using a virtual room nd there are any events pending, try to resend them, - // otherwise the call will fail and because its a virtual room, the user won't be able - // to see it to either retry or clear the pending events. There will only be call events - // in this queue, and since we're about to place a new call, they can only be events from - // previous calls that are probably stale by now, so just cancel them. - if (mappedRoomId !== roomId) { - const mappedRoom = cli.getRoom(mappedRoomId); - if (mappedRoom?.getPendingEvents().length) { - Resend.cancelUnsentEvents(mappedRoom); - } - } const timeUntilTurnCresExpire = cli.getTurnServersExpiry() - Date.now(); logger.log("Current turn creds expire in " + timeUntilTurnCresExpire + " ms"); - const call = cli.createCall(mappedRoomId)!; + const call = cli.createCall(roomId)!; try { this.addCallForRoom(roomId, call); @@ -978,19 +901,7 @@ export default class LegacyCallHandler extends TypedEventEmitter 0 && nativeLookupResults[0].fields.lookup_success; - nativeUserId = lookupSuccess ? nativeLookupResults[0].userid : userId; - logger.log("Looked up " + number + " to " + userId + " and mapped to native user " + nativeUserId); - } else { - nativeUserId = userId; - } - - const roomId = await ensureDMExists(MatrixClientPeg.safeGet(), nativeUserId); + const roomId = await ensureDMExists(MatrixClientPeg.safeGet(), userId); if (!roomId) { throw new Error("Failed to ensure DM exists for dialing number"); } diff --git a/src/Lifecycle.ts b/src/Lifecycle.ts index 8ac235f78a..5641f936ae 100644 --- a/src/Lifecycle.ts +++ b/src/Lifecycle.ts @@ -321,7 +321,7 @@ async function attemptOidcNativeLogin(queryParams: QueryDict): Promise } catch (error) { logger.error("Failed to login via OIDC", error); - await onFailedDelegatedAuthLogin(getOidcErrorMessage(error as Error)); + onFailedDelegatedAuthLogin(getOidcErrorMessage(error as Error)); return false; } } @@ -468,13 +468,16 @@ type TryAgainFunction = () => void; * @param description error description * @param tryAgain OPTIONAL function to call on try again button from error dialog */ -async function onFailedDelegatedAuthLogin(description: string | ReactNode, tryAgain?: TryAgainFunction): Promise { - Modal.createDialog(ErrorDialog, { +function onFailedDelegatedAuthLogin(description: string | ReactNode, tryAgain?: TryAgainFunction): void { + const { finished } = Modal.createDialog(ErrorDialog, { title: _t("auth|oidc|error_title"), description, button: _t("action|try_again"), + }); + + finished.then(([shouldTryAgain]) => { // if we have a tryAgain callback, call it the primary 'try again' button was clicked in the dialog - onFinished: tryAgain ? (shouldTryAgain?: boolean) => shouldTryAgain && tryAgain() : undefined, + if (shouldTryAgain) tryAgain?.(); }); } @@ -701,6 +704,43 @@ export async function setLoggedIn(credentials: IMatrixClientCreds): Promise { + const oldUserId = MatrixClientPeg.safeGet().getUserId(); + const oldDeviceId = MatrixClientPeg.safeGet().getDeviceId(); + + stopMatrixClient(); // unsets MatrixClientPeg.get() + localStorage.removeItem("mx_soft_logout"); + _isLoggingOut = false; + + const overwrite = credentials.userId !== oldUserId || credentials.deviceId !== oldDeviceId; + if (overwrite) { + logger.warn("Clearing all data: Old session belongs to a different user/session"); + } + + if (!credentials.pickleKey && credentials.deviceId !== undefined) { + logger.info("Lifecycle#hydrateSession: Pickle key not provided - trying to get one"); + credentials.pickleKey = + (await PlatformPeg.get()?.getPickleKey(credentials.userId, credentials.deviceId)) ?? undefined; + } + + return doSetLoggedIn(credentials, overwrite, false); +} + /** * When we have a authenticated via OIDC-native flow and have a refresh token * try to create a token refresher. @@ -1075,7 +1115,9 @@ export async function onLoggedOut(): Promise { * @param {object} opts Options for how to clear storage. * @returns {Promise} promise which resolves once the stores have been cleared */ -async function clearStorage(opts?: { deleteEverything?: boolean }): Promise { +export async function clearStorage(opts?: { deleteEverything?: boolean }): Promise { + logger.info(`Clearing storage, deleteEverything=${opts?.deleteEverything}`); + if (window.localStorage) { // get the currently defined device language, if set, so we can restore it later const language = SettingsStore.getValueAt(SettingLevel.DEVICE, "language", null, true, true); diff --git a/src/Linkify.tsx b/src/Linkify.tsx index 27dd4783be..846bf8e82d 100644 --- a/src/Linkify.tsx +++ b/src/Linkify.tsx @@ -1,5 +1,5 @@ /* -Copyright 2024 New Vector Ltd. +Copyright 2024, 2025 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 @@ -12,7 +12,6 @@ import { merge } from "lodash"; import _Linkify from "linkify-react"; import { _linkifyString, ELEMENT_URL_PATTERN, options as linkifyMatrixOptions } from "./linkify-matrix"; -import SettingsStore from "./settings/SettingsStore"; import { tryTransformPermalinkToLocalHref } from "./utils/permalinks/Permalinks"; import { mediaFromMxc } from "./customisations/Media"; import { PERMITTED_URL_SCHEMES } from "./utils/UrlUtils"; @@ -47,10 +46,7 @@ export const transformTags: NonNullable = { // Strip out imgs that aren't `mxc` here instead of using allowedSchemesByTag // because transformTags is used _before_ we filter by allowedSchemesByTag and // we don't want to allow images with `https?` `src`s. - // We also drop inline images (as if they were not present at all) when the "show - // images" preference is disabled. Future work might expose some UI to reveal them - // like standalone image events have. - if (!src || !SettingsStore.getValue("showImages")) { + if (!src) { return { tagName, attribs: {} }; } @@ -78,7 +74,6 @@ export const transformTags: NonNullable = { if (requestedHeight) { attribs.style += "height: 100%;"; } - attribs.src = mediaFromMxc(src).getThumbnailOfSourceHttp(width, height)!; return { tagName, attribs }; }, diff --git a/src/Modal.tsx b/src/Modal.tsx index d40f286da7..e2873783ea 100644 --- a/src/Modal.tsx +++ b/src/Modal.tsx @@ -10,7 +10,6 @@ Please see LICENSE files in the repository root for full details. import React, { StrictMode } from "react"; import { createRoot, type Root } from "react-dom/client"; import classNames from "classnames"; -import { type IDeferred, defer } from "matrix-js-sdk/src/utils"; import { TypedEventEmitter } from "matrix-js-sdk/src/matrix"; import { Glass, TooltipProvider } from "@vector-im/compound-web"; @@ -18,6 +17,7 @@ import defaultDispatcher from "./dispatcher/dispatcher"; import AsyncWrapper from "./AsyncWrapper"; import { type Defaultize } from "./@types/common"; import { type ActionPayload } from "./dispatcher/payloads"; +import { filterBoolean } from "./utils/arrays.ts"; const DIALOG_CONTAINER_ID = "mx_Dialog_Container"; const STATIC_DIALOG_CONTAINER_ID = "mx_Dialog_StaticContainer"; @@ -29,12 +29,25 @@ export type ComponentType = }> | React.ComponentType; -// Generic type which returns the props of the Modal component with the onFinished being optional. +/** + * The parameter types of the `onFinished` callback property exposed by the component which forms the + * body of the dialog. + * + * @typeParam C - The type of the React component which forms the body of the dialog. + */ +type OnFinishedParams = Parameters["onFinished"]>; + +/** + * The properties exposed by the `props` argument to {@link Modal.createDialog}: the same as + * those exposed by the underlying component, with the exception of `onFinished`, which is provided by + * `createDialog`. + * + * @typeParam C - The type of the React component which forms the body of the dialog. + */ export type ComponentProps = Defaultize< Omit, "onFinished">, C["defaultProps"] -> & - Partial, "onFinished">>; +>; export interface IModal { elem: React.ReactNode; @@ -42,15 +55,44 @@ export interface IModal { beforeClosePromise?: Promise; closeReason?: ModalCloseReason; onBeforeClose?(reason?: ModalCloseReason): Promise; - onFinished: ComponentProps["onFinished"]; - close(...args: Parameters["onFinished"]>): void; + + /** + * Run the {@link deferred} with the given arguments, and close this modal. + * + * This method is passed as the `onFinished` callback to the underlying component, + * as well as being returned by {@link Modal.createDialog} to the caller. + */ + close(...args: OnFinishedParams | []): void; + hidden?: boolean; - deferred?: IDeferred["onFinished"]>>; + + /** A deferred to resolve when the dialog closes, with the results as provided by + * the call to {@link close} (normally from the `onFinished` callback). + */ + deferred?: PromiseWithResolvers | []>; } +/** The result of {@link Modal.createDialog}. + * + * @typeParam C - The type of the React component which forms the body of the dialog. + */ export interface IHandle { - finished: Promise["onFinished"]>>; - close(...args: Parameters["onFinished"]>): void; + /** + * A promise which will resolve when the dialog closes. + * + * If the dialog body component calls the `onFinished` property, or the caller calls {@link close}, + * the promise resolves with an array holding the arguments to that call. + * + * If the dialog is closed by clicking in the background, the promise resolves with an empty array. + */ + finished: Promise | []>; + + /** + * A function which, if called, will close the dialog. + * + * @param args - Arguments to return to {@link finished}. + */ + close(...args: OnFinishedParams): void; } interface IOptions { @@ -160,13 +202,15 @@ export class ModalManager extends TypedEventEmitter["finished"]; } { const modal = { - onFinished: props?.onFinished, onBeforeClose: options?.onBeforeClose, className, @@ -192,8 +235,7 @@ export class ModalManager extends TypedEventEmitter; - // never call this from onFinished() otherwise it will loop - const [closeDialog, onFinishedProm] = this.getCloseFn(modal, props); + const [closeDialog, onFinishedProm] = this.getCloseFn(modal); // don't attempt to reuse the same AsyncWrapper for different dialogs, // otherwise we'll get confused. @@ -210,13 +252,10 @@ export class ModalManager extends TypedEventEmitter( - modal: IModal, - props?: ComponentProps, - ): [IHandle["close"], IHandle["finished"]] { - modal.deferred = defer["onFinished"]>>(); + private getCloseFn(modal: IModal): [IHandle["close"], IHandle["finished"]] { + modal.deferred = Promise.withResolvers | []>(); return [ - async (...args: Parameters["onFinished"]>): Promise => { + async (...args: OnFinishedParams): Promise => { if (modal.beforeClosePromise) { await modal.beforeClosePromise; } else if (modal.onBeforeClose) { @@ -228,7 +267,6 @@ export class ModalManager extends TypedEventEmitter= 0) { this.modals.splice(i, 1); @@ -276,7 +314,8 @@ export class ModalManager extends TypedEventEmitter import('./MyComponent'))` * - * @param props properties to pass to the displayed component. (We will also pass an 'onFinished' property.) + * @param props properties to pass to the displayed component. (We will also pass an `onFinished` property; when + * called, that property will close the dialog and return the results to the caller via {@link IHandle.finished}.) * * @param className CSS class to apply to the modal wrapper * @@ -291,7 +330,7 @@ export class ModalManager extends TypedEventEmitter( component: C, diff --git a/src/NodeAnimator.tsx b/src/NodeAnimator.tsx index d350de8dd8..2bcacdfbce 100644 --- a/src/NodeAnimator.tsx +++ b/src/NodeAnimator.tsx @@ -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 React, { type Key, type RefObject, type ReactElement, type RefCallback } from "react"; +import React, { type Key, type RefObject, type ReactElement, type RefCallback, type HTMLAttributes } from "react"; interface IChildProps { style: React.CSSProperties; @@ -57,7 +57,8 @@ export default class NodeAnimator extends React.Component { * @param {React.CSSProperties} styles a key/value pair of CSS properties * @returns {void} */ - private applyStyles(node: HTMLElement, styles: React.CSSProperties): void { + private applyStyles(node: HTMLElement, styles?: React.CSSProperties): void { + if (!styles) return; Object.entries(styles).forEach(([property, value]) => { node.style[property as keyof Omit] = value; }); @@ -68,21 +69,22 @@ export default class NodeAnimator extends React.Component { this.children = {}; React.Children.toArray(newChildren).forEach((c) => { if (!isReactElement(c)) return; + const props = c.props as HTMLAttributes; if (oldChildren[c.key!]) { const old = oldChildren[c.key!]; const oldNode = this.nodes[old.key!]; - if (oldNode && oldNode.style.left !== c.props.style.left) { - this.applyStyles(oldNode, { left: c.props.style.left }); + if (oldNode && props.style && oldNode.style.left !== props.style.left) { + this.applyStyles(oldNode, { left: props.style.left }); } // clone the old element with the props (and children) of the new element // so prop updates are still received by the children. - this.children[c.key!] = React.cloneElement(old, c.props, c.props.children); + this.children[c.key!] = React.cloneElement(old, props, props.children); } else { // new element. If we have a startStyle, use that as the style and go through // the enter animations const newProps: Partial = {}; - const restingStyle = c.props.style; + const restingStyle = props.style; const startStyles = this.props.startStyles; if (startStyles.length > 0) { @@ -97,7 +99,7 @@ export default class NodeAnimator extends React.Component { }); } - private collectNode(k: Key, domNode: HTMLElement | null, restingStyle: React.CSSProperties): void { + private collectNode(k: Key, domNode: HTMLElement | null, restingStyle?: React.CSSProperties): void { const key = typeof k === "bigint" ? Number(k) : k; if (domNode && this.nodes[key] === undefined && this.props.startStyles.length > 0) { const startStyles = this.props.startStyles; diff --git a/src/Notifier.ts b/src/Notifier.ts index b24e146fbc..7dce26d6bd 100644 --- a/src/Notifier.ts +++ b/src/Notifier.ts @@ -43,8 +43,6 @@ import { isPushNotifyDisabled } from "./settings/controllers/NotificationControl import UserActivity from "./UserActivity"; import { mediaFromMxc } from "./customisations/Media"; import ErrorDialog from "./components/views/dialogs/ErrorDialog"; -import LegacyCallHandler from "./LegacyCallHandler"; -import VoipUserMapper from "./VoipUserMapper"; import { SdkContextClass } from "./contexts/SDKContext"; import { localNotificationsAreSilenced, createLocalNotificationSettingsIfNeeded } from "./utils/notifications"; import { getIncomingCallToastKey, IncomingCallToast } from "./toasts/IncomingCallToast"; @@ -447,14 +445,7 @@ class NotifierClass extends TypedEventEmitter, ] : [], - onFinished: (proceed) => { - if (proceed) { - dis.dispatch({ action: "start_login", screenAfterLogin: options.screen_after }); - } else if (options.go_home_on_cancel) { - dis.dispatch({ action: Action.ViewHomePage }); - } else if (options.go_welcome_on_cancel) { - dis.dispatch({ action: "view_welcome_page" }); - } - }, + }); + modal.finished.then(([proceed]) => { + if (proceed) { + dis.dispatch({ action: "start_login", screenAfterLogin: options.screen_after }); + } else if (options.go_home_on_cancel) { + dis.dispatch({ action: Action.ViewHomePage }); + } else if (options.go_welcome_on_cancel) { + dis.dispatch({ action: "view_welcome_page" }); + } }); } diff --git a/src/Rooms.ts b/src/Rooms.ts index cf458fef84..b29fa1c659 100644 --- a/src/Rooms.ts +++ b/src/Rooms.ts @@ -9,6 +9,7 @@ Please see LICENSE files in the repository root for full details. import { type Room, EventType, type RoomMember, type MatrixClient } from "matrix-js-sdk/src/matrix"; import AliasCustomisations from "./customisations/Alias"; +import { filterValidMDirect } from "./utils/dm/filterValidMDirect.ts"; /** * Given a room object, return the alias we should use for it, @@ -56,39 +57,23 @@ export async function setDMRoom(client: MatrixClient, roomId: string, userId: st if (client.isGuest()) return; const mDirectEvent = client.getAccountData(EventType.Direct); - const currentContent = mDirectEvent?.getContent() || {}; + const { filteredContent } = filterValidMDirect(mDirectEvent?.getContent() ?? {}); - const dmRoomMap = new Map(Object.entries(currentContent)); - let modified = false; - - // remove it from the lists of any others users - // (it can only be a DM room for one person) - for (const thisUserId of dmRoomMap.keys()) { - const roomList = dmRoomMap.get(thisUserId) || []; - - if (thisUserId != userId) { - const indexOfRoom = roomList.indexOf(roomId); - if (indexOfRoom > -1) { - roomList.splice(indexOfRoom, 1); - modified = true; - } - } + // remove it from the lists of all users (it can only be a DM room for one person) + for (const thisUserId in filteredContent) { + if (!filteredContent[thisUserId]) continue; + filteredContent[thisUserId] = filteredContent[thisUserId].filter((room) => room !== roomId); } - // now add it, if it's not already there + // now add it if the caller asked for it to be a DM room if (userId) { - const roomList = dmRoomMap.get(userId) || []; - if (roomList.indexOf(roomId) == -1) { - roomList.push(roomId); - modified = true; + if (!filteredContent[userId]) { + filteredContent[userId] = []; } - dmRoomMap.set(userId, roomList); + filteredContent[userId].push(roomId); } - // prevent unnecessary calls to setAccountData - if (!modified) return; - - await client.setAccountData(EventType.Direct, Object.fromEntries(dmRoomMap)); + await client.setAccountData(EventType.Direct, filteredContent); } /** diff --git a/src/Searching.ts b/src/Searching.ts index f9b2830e85..d507bd10ef 100644 --- a/src/Searching.ts +++ b/src/Searching.ts @@ -718,4 +718,8 @@ export interface SearchInfo { * The total count of matching results as returned by the backend. */ count?: number; + /** + * Describe the error if any occured. + */ + error?: Error; } diff --git a/src/SecurityManager.ts b/src/SecurityManager.ts index b497c57a10..bfd0eb237d 100644 --- a/src/SecurityManager.ts +++ b/src/SecurityManager.ts @@ -19,7 +19,6 @@ import AccessSecretStorageDialog, { type KeyParams, } from "./components/views/dialogs/security/AccessSecretStorageDialog"; import { ModuleRunner } from "./modules/ModuleRunner"; -import QuestionDialog from "./components/views/dialogs/QuestionDialog"; import InteractiveAuthDialog from "./components/views/dialogs/InteractiveAuthDialog"; // This stores the secret storage private keys in memory for the JS SDK. This is @@ -50,17 +49,6 @@ export class AccessCancelledError extends Error { } } -async function confirmToDismiss(): Promise { - const [sure] = await Modal.createDialog(QuestionDialog, { - title: _t("encryption|cancel_entering_passphrase_title"), - description: _t("encryption|cancel_entering_passphrase_description"), - danger: false, - button: _t("action|go_back"), - cancelButton: _t("action|cancel"), - }).finished; - return !sure; -} - function makeInputToKey( keyInfo: SecretStorage.SecretStorageKeyDescription, ): (keyParams: KeyParams) => Promise { @@ -134,17 +122,6 @@ async function getSecretStorageKey( return MatrixClientPeg.safeGet().secretStorage.checkKey(key, keyInfo); }, }, - /* className= */ undefined, - /* isPriorityModal= */ false, - /* isStaticModal= */ false, - /* options= */ { - onBeforeClose: async (reason): Promise => { - if (reason === "backgroundClick") { - return confirmToDismiss(); - } - return true; - }, - }, ); const [keyParams] = await finished; if (!keyParams) { diff --git a/src/SlashCommands.tsx b/src/SlashCommands.tsx index 865da9e51c..c8563509f2 100644 --- a/src/SlashCommands.tsx +++ b/src/SlashCommands.tsx @@ -52,7 +52,6 @@ import SlashCommandHelpDialog from "./components/views/dialogs/SlashCommandHelpD import { shouldShowComponent } from "./customisations/helpers/UIComponents"; import { TimelineRenderingType } from "./contexts/RoomContext"; import { type ViewRoomPayload } from "./dispatcher/payloads/ViewRoomPayload"; -import VoipUserMapper from "./VoipUserMapper"; import { htmlSerializeFromMdIfNeeded } from "./editor/serialize"; import { leaveRoomBehaviour } from "./utils/leave-behaviour"; import { MatrixClientPeg } from "./MatrixClientPeg"; @@ -61,6 +60,7 @@ import { deop, op } from "./slash-commands/op"; import { CommandCategories } from "./slash-commands/interface"; import { Command } from "./slash-commands/command"; import { goto, join } from "./slash-commands/join"; +import { manuallyVerifyDevice } from "./components/views/dialogs/ManualDeviceKeyVerificationDialog"; export { CommandCategories, Command }; @@ -664,6 +664,24 @@ export const Commands = [ category: CommandCategories.admin, renderingTypes: [TimelineRenderingType.Room], }), + new Command({ + command: "verify", + args: " ", + description: _td("slash_command|verify"), + runFn: function (cli, _roomId, _threadId, args) { + if (args) { + const matches = args.match(/^(\S+) +(\S+)$/); + if (matches) { + const deviceId = matches[1]; + const fingerprint = matches[2]; + return success(manuallyVerifyDevice(cli, deviceId, fingerprint)); + } + } + return reject(this.getUsage()); + }, + category: CommandCategories.advanced, + renderingTypes: [TimelineRenderingType.Room], + }), new Command({ command: "discardsession", description: _td("slash_command|discardsession"), @@ -743,28 +761,6 @@ export const Commands = [ }, category: CommandCategories.advanced, }), - new Command({ - command: "tovirtual", - description: _td("slash_command|tovirtual"), - category: CommandCategories.advanced, - isEnabled(cli): boolean { - return !!LegacyCallHandler.instance.getSupportsVirtualRooms() && !isCurrentLocalRoom(cli); - }, - runFn: (cli, roomId) => { - return success( - (async (): Promise => { - const room = await VoipUserMapper.sharedInstance().getVirtualRoomForRoom(roomId); - if (!room) throw new UserFriendlyError("slash_command|tovirtual_not_found"); - dis.dispatch({ - action: Action.ViewRoom, - room_id: room.roomId, - metricsTrigger: "SlashCommand", - metricsViaKeyboard: true, - }); - })(), - ); - }, - }), new Command({ command: "query", description: _td("slash_command|query"), diff --git a/src/SlidingSyncManager.ts b/src/SlidingSyncManager.ts index 88b839312d..815e438da7 100644 --- a/src/SlidingSyncManager.ts +++ b/src/SlidingSyncManager.ts @@ -49,7 +49,7 @@ import { SlidingSyncState, } from "matrix-js-sdk/src/sliding-sync"; import { logger } from "matrix-js-sdk/src/logger"; -import { defer, sleep } from "matrix-js-sdk/src/utils"; +import { sleep } from "matrix-js-sdk/src/utils"; // how long to long poll for const SLIDING_SYNC_TIMEOUT_MS = 20 * 1000; @@ -184,7 +184,7 @@ export class SlidingSyncManager { public slidingSync?: SlidingSync; private client?: MatrixClient; - private configureDefer = defer(); + private configureDefer = Promise.withResolvers(); public static get instance(): SlidingSyncManager { return SlidingSyncManager.internalInstance; diff --git a/src/VoipUserMapper.ts b/src/VoipUserMapper.ts deleted file mode 100644 index c2a7810d96..0000000000 --- a/src/VoipUserMapper.ts +++ /dev/null @@ -1,150 +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 { type Room, EventType } from "matrix-js-sdk/src/matrix"; -import { KnownMembership } from "matrix-js-sdk/src/types"; -import { logger } from "matrix-js-sdk/src/logger"; - -import { ensureVirtualRoomExists } from "./createRoom"; -import { MatrixClientPeg } from "./MatrixClientPeg"; -import DMRoomMap from "./utils/DMRoomMap"; -import LegacyCallHandler from "./LegacyCallHandler"; -import { VIRTUAL_ROOM_EVENT_TYPE } from "./call-types"; -import { findDMForUser } from "./utils/dm/findDMForUser"; - -// Functions for mapping virtual users & rooms. Currently the only lookup -// is sip virtual: there could be others in the future. - -export default class VoipUserMapper { - // We store mappings of virtual -> native room IDs here until the local echo for the - // account data arrives. - private virtualToNativeRoomIdCache = new Map(); - - public static sharedInstance(): VoipUserMapper { - if (window.mxVoipUserMapper === undefined) window.mxVoipUserMapper = new VoipUserMapper(); - return window.mxVoipUserMapper; - } - - private async userToVirtualUser(userId: string): Promise { - const results = await LegacyCallHandler.instance.sipVirtualLookup(userId); - if (results.length === 0 || !results[0].fields.lookup_success) return null; - return results[0].userid; - } - - private async getVirtualUserForRoom(roomId: string): Promise { - const userId = DMRoomMap.shared().getUserIdForRoomId(roomId); - if (!userId) return null; - - const virtualUser = await this.userToVirtualUser(userId); - if (!virtualUser) return null; - - return virtualUser; - } - - public async getOrCreateVirtualRoomForRoom(roomId: string): Promise { - const virtualUser = await this.getVirtualUserForRoom(roomId); - if (!virtualUser) return null; - - const cli = MatrixClientPeg.safeGet(); - const virtualRoomId = await ensureVirtualRoomExists(cli, virtualUser, roomId); - cli.setRoomAccountData(virtualRoomId!, VIRTUAL_ROOM_EVENT_TYPE, { - native_room: roomId, - }); - - this.virtualToNativeRoomIdCache.set(virtualRoomId!, roomId); - - return virtualRoomId; - } - - /** - * Gets the ID of the virtual room for a room, or null if the room has no - * virtual room - */ - public async getVirtualRoomForRoom(roomId: string): Promise { - const virtualUser = await this.getVirtualUserForRoom(roomId); - if (!virtualUser) return undefined; - - return findDMForUser(MatrixClientPeg.safeGet(), virtualUser); - } - - public nativeRoomForVirtualRoom(roomId: string): string | null { - const cachedNativeRoomId = this.virtualToNativeRoomIdCache.get(roomId); - if (cachedNativeRoomId) { - logger.log( - "Returning native room ID " + cachedNativeRoomId + " for virtual room ID " + roomId + " from cache", - ); - return cachedNativeRoomId; - } - - const cli = MatrixClientPeg.safeGet(); - const virtualRoom = cli.getRoom(roomId); - if (!virtualRoom) return null; - const virtualRoomEvent = virtualRoom.getAccountData(VIRTUAL_ROOM_EVENT_TYPE); - if (!virtualRoomEvent || !virtualRoomEvent.getContent()) return null; - const nativeRoomID = virtualRoomEvent.getContent()["native_room"]; - const nativeRoom = cli.getRoom(nativeRoomID); - if (!nativeRoom || nativeRoom.getMyMembership() !== KnownMembership.Join) return null; - - return nativeRoomID; - } - - public isVirtualRoom(room: Room): boolean { - if (this.nativeRoomForVirtualRoom(room.roomId)) return true; - - if (this.virtualToNativeRoomIdCache.has(room.roomId)) return true; - - // also look in the create event for the claimed native room ID, which is the only - // way we can recognise a virtual room we've created when it first arrives down - // our stream. We don't trust this in general though, as it could be faked by an - // inviter: our main source of truth is the DM state. - const roomCreateEvent = room.currentState.getStateEvents(EventType.RoomCreate, ""); - if (!roomCreateEvent || !roomCreateEvent.getContent()) return false; - // we only look at this for rooms we created (so inviters can't just cause rooms - // to be invisible) - if (roomCreateEvent.getSender() !== MatrixClientPeg.safeGet().getUserId()) return false; - const claimedNativeRoomId = roomCreateEvent.getContent()[VIRTUAL_ROOM_EVENT_TYPE]; - return Boolean(claimedNativeRoomId); - } - - public async onNewInvitedRoom(invitedRoom: Room): Promise { - if (!LegacyCallHandler.instance.getSupportsVirtualRooms()) return; - - const inviterId = invitedRoom.getDMInviter(); - if (!inviterId) { - logger.error("Could not find DM inviter for room id: " + invitedRoom.roomId); - } - - logger.log(`Checking virtual-ness of room ID ${invitedRoom.roomId}, invited by ${inviterId}`); - const result = await LegacyCallHandler.instance.sipNativeLookup(inviterId!); - if (result.length === 0) { - return; - } - - if (result[0].fields.is_virtual) { - const cli = MatrixClientPeg.safeGet(); - const nativeUser = result[0].userid; - const nativeRoom = findDMForUser(cli, nativeUser); - if (nativeRoom) { - // It's a virtual room with a matching native room, so set the room account data. This - // will make sure we know where how to map calls and also allow us know not to display - // it in the future. - cli.setRoomAccountData(invitedRoom.roomId, VIRTUAL_ROOM_EVENT_TYPE, { - native_room: nativeRoom.roomId, - }); - // also auto-join the virtual room if we have a matching native room - // (possibly we should only join if we've also joined the native room, then we'd also have - // to make sure we joined virtual rooms on joining a native one) - cli.joinRoom(invitedRoom.roomId); - - // also put this room in the virtual room ID cache so isVirtualRoom return the right answer - // in however long it takes for the echo of setAccountData to come down the sync - this.virtualToNativeRoomIdCache.set(invitedRoom.roomId, nativeRoom.roomId); - } - } - } -} diff --git a/src/WorkerManager.ts b/src/WorkerManager.ts index a8a95ca727..1d403be88c 100644 --- a/src/WorkerManager.ts +++ b/src/WorkerManager.ts @@ -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 { defer, type IDeferred } from "matrix-js-sdk/src/utils"; - import { type WorkerPayload } from "./workers/worker"; export class WorkerManager { private readonly worker: Worker; private seq = 0; - private pendingDeferredMap = new Map>(); + private pendingDeferredMap = new Map>(); public constructor(worker: Worker) { this.worker = worker; @@ -30,7 +28,7 @@ export class WorkerManager { public call(request: Request): Promise { const seq = this.seq++; - const deferred = defer(); + const deferred = Promise.withResolvers(); this.pendingDeferredMap.set(seq, deferred); this.worker.postMessage({ seq, ...request }); return deferred.promise; diff --git a/src/accessibility/KeyboardShortcuts.ts b/src/accessibility/KeyboardShortcuts.ts index da0097f4b2..ec5b8312b9 100644 --- a/src/accessibility/KeyboardShortcuts.ts +++ b/src/accessibility/KeyboardShortcuts.ts @@ -521,7 +521,8 @@ export const KEYBOARD_SHORTCUTS: IKeyboardShortcuts = { [KeyBindingAction.GoToHome]: { default: { ctrlKey: true, - altKey: true, + altKey: !IS_MAC, + shiftKey: IS_MAC, key: Key.H, }, displayName: _td("keyboard|go_home_view"), @@ -586,7 +587,7 @@ export const KEYBOARD_SHORTCUTS: IKeyboardShortcuts = { default: { ctrlKey: true, shiftKey: true, - key: Key.H, + key: Key.J, }, displayName: _td("keyboard|toggle_hidden_events"), }, diff --git a/src/accessibility/RovingTabIndex.tsx b/src/accessibility/RovingTabIndex.tsx index 7d4e291e5b..9942b4398c 100644 --- a/src/accessibility/RovingTabIndex.tsx +++ b/src/accessibility/RovingTabIndex.tsx @@ -212,7 +212,7 @@ export const RovingTabIndexProvider: React.FC = ({ scrollIntoView, onKeyDown, }) => { - const [state, dispatch] = useReducer>(reducer, { + const [state, dispatch] = useReducer(reducer, { nodes: [], }); diff --git a/src/accessibility/Toolbar.tsx b/src/accessibility/Toolbar.tsx index 317ff8b936..5583292e48 100644 --- a/src/accessibility/Toolbar.tsx +++ b/src/accessibility/Toolbar.tsx @@ -6,18 +6,20 @@ 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, { forwardRef } from "react"; +import React, { type Ref, type JSX } from "react"; import { RovingTabIndexProvider } from "./RovingTabIndex"; import { getKeyBindingsManager } from "../KeyBindingsManager"; import { KeyBindingAction } from "./KeyboardShortcuts"; -interface IProps extends Omit, "onKeyDown"> {} +interface IProps extends Omit, "onKeyDown"> { + ref?: Ref; +} // This component implements the Toolbar design pattern from the WAI-ARIA Authoring Practices guidelines. // https://www.w3.org/TR/wai-aria-practices-1.1/#toolbar // All buttons passed in children must use RovingTabIndex to set `onFocus`, `isActive`, `ref` -const Toolbar = forwardRef(({ children, ...props }, ref) => { +const Toolbar = ({ children, ref, ...props }: IProps): JSX.Element => { const onKeyDown = (ev: React.KeyboardEvent): void => { const target = ev.target as HTMLElement; // Don't interfere with input default keydown behaviour @@ -55,6 +57,6 @@ const Toolbar = forwardRef(({ children, ...props }, ref) )} ); -}); +}; export default Toolbar; diff --git a/src/accessibility/context_menu/ContextMenuButton.tsx b/src/accessibility/context_menu/ContextMenuButton.tsx index 8096203d0e..df16d15821 100644 --- a/src/accessibility/context_menu/ContextMenuButton.tsx +++ b/src/accessibility/context_menu/ContextMenuButton.tsx @@ -8,7 +8,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 React, { forwardRef, type Ref } from "react"; +import React, { type Ref, type JSX } from "react"; import AccessibleButton, { type ButtonProps } from "../../components/views/elements/AccessibleButton"; @@ -16,13 +16,19 @@ type Props = ButtonProps & { label?: string; // whether the context menu is currently open isExpanded: boolean; + ref?: Ref; }; // Semantic component for representing the AccessibleButton which launches a -export const ContextMenuButton = forwardRef(function ( - { label, isExpanded, children, onClick, onContextMenu, ...props }: Props, - ref: Ref, -) { +export const ContextMenuButton = function ({ + label, + isExpanded, + children, + onClick, + onContextMenu, + ref, + ...props +}: Props): JSX.Element { return ( ); -}); +}; diff --git a/src/accessibility/context_menu/ContextMenuTooltipButton.tsx b/src/accessibility/context_menu/ContextMenuTooltipButton.tsx index f58fbea171..b0d0156c0f 100644 --- a/src/accessibility/context_menu/ContextMenuTooltipButton.tsx +++ b/src/accessibility/context_menu/ContextMenuTooltipButton.tsx @@ -8,7 +8,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 React, { forwardRef, type Ref } from "react"; +import React, { type JSX } from "react"; import AccessibleButton, { type ButtonProps } from "../../components/views/elements/AccessibleButton"; @@ -18,10 +18,14 @@ type Props = ButtonProps & { }; // Semantic component for representing the AccessibleButton which launches a -export const ContextMenuTooltipButton = forwardRef(function ( - { isExpanded, children, onClick, onContextMenu, ...props }: Props, - ref: Ref, -) { +export const ContextMenuTooltipButton = function ({ + isExpanded, + children, + onClick, + onContextMenu, + ref, + ...props +}: Props): JSX.Element { return ( ); -}); +}; diff --git a/src/async-components/views/dialogs/security/NewRecoveryMethodDialog.tsx b/src/async-components/views/dialogs/security/NewRecoveryMethodDialog.tsx index 9b9683b798..a2fef4e1c9 100644 --- a/src/async-components/views/dialogs/security/NewRecoveryMethodDialog.tsx +++ b/src/async-components/views/dialogs/security/NewRecoveryMethodDialog.tsx @@ -49,15 +49,8 @@ export default function NewRecoveryMethodDialog({ onFinished }: NewRecoveryMetho if (isKeyBackupEnabled) { onFinished(); } else { - Modal.createDialog( - RestoreKeyBackupDialog, - { - onFinished, - }, - undefined, - false, - true, - ); + const { finished } = Modal.createDialog(RestoreKeyBackupDialog, {}, undefined, false, true); + finished.then(onFinished); } } diff --git a/src/audio/BackgroundAudio.ts b/src/audio/BackgroundAudio.ts index c90016eef9..eda10c0904 100644 --- a/src/audio/BackgroundAudio.ts +++ b/src/audio/BackgroundAudio.ts @@ -48,6 +48,12 @@ export class BackgroundAudio { source.buffer = this.sounds[url]; source.loop = loop; source.connect(this.audioContext.destination); + + await this.audioContext.resume(); + source.onended = () => { + this.audioContext.suspend(); + }; + source.start(); return source; } diff --git a/src/audio/Playback.ts b/src/audio/Playback.ts index 58b8a23c22..54d2c710d0 100644 --- a/src/audio/Playback.ts +++ b/src/audio/Playback.ts @@ -9,7 +9,6 @@ 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 { defer } from "matrix-js-sdk/src/utils"; import { UPDATE_EVENT } from "../stores/AsyncStore"; import { arrayFastResample } from "../utils/arrays"; @@ -158,42 +157,27 @@ export class Playback extends EventEmitter implements IDestroyable, PlaybackInte // 5mb logger.log("Audio file too large: processing through

before rendering but I think this is the better */ interface ITextualCompletionProps { - title?: string; - subtitle?: string; - description?: string; - className?: string; + "title"?: string; + "subtitle"?: string; + "description"?: string; + "className"?: string; + "aria-selected"?: boolean; + "ref"?: Ref; } -export const TextualCompletion = forwardRef((props, ref) => { - const { title, subtitle, description, className, "aria-selected": ariaSelectedAttribute, ...restProps } = props; +export const TextualCompletion = (props: ITextualCompletionProps): JSX.Element => { + const { + title, + subtitle, + description, + className, + "aria-selected": ariaSelectedAttribute, + ref, + ...restProps + } = props; return (
((props {description}
); -}); +}; interface IPillCompletionProps extends ITextualCompletionProps { children?: React.ReactNode; } -export const PillCompletion = forwardRef((props, ref) => { +export const PillCompletion = (props: IPillCompletionProps): JSX.Element => { const { title, subtitle, @@ -51,6 +61,7 @@ export const PillCompletion = forwardRef((props, ref) className, children, "aria-selected": ariaSelectedAttribute, + ref, ...restProps } = props; return ( @@ -67,4 +78,4 @@ export const PillCompletion = forwardRef((props, ref) {description}
); -}); +}; diff --git a/src/autocomplete/UserProvider.tsx b/src/autocomplete/UserProvider.tsx index 9cd5564795..39accdc8da 100644 --- a/src/autocomplete/UserProvider.tsx +++ b/src/autocomplete/UserProvider.tsx @@ -127,7 +127,7 @@ export default class UserProvider extends AutocompleteProvider { suffix: selection.beginning && range!.start === 0 ? ": " : " ", href: makeUserPermalink(user.userId), component: ( - + ), diff --git a/src/call-types.ts b/src/call-types.ts index 2a263174ef..6586bcf3b9 100644 --- a/src/call-types.ts +++ b/src/call-types.ts @@ -6,10 +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. */ -// Event type for room account data and room creation content used to mark rooms as virtual rooms -// (and store the ID of their native room) -export const VIRTUAL_ROOM_EVENT_TYPE = "im.vector.is_virtual_room"; - export const JitsiCallMemberEventType = "io.element.video.member"; export interface JitsiCallMemberContent { diff --git a/src/components/structures/IndicatorScrollbar.tsx b/src/components/structures/IndicatorScrollbar.tsx index 1667d73336..f566e2d256 100644 --- a/src/components/structures/IndicatorScrollbar.tsx +++ b/src/components/structures/IndicatorScrollbar.tsx @@ -21,8 +21,6 @@ export type IProps = Omit { } public render(): React.ReactNode { + const useNewRoomList = SettingsStore.getValue("feature_new_room_list"); const containerClasses = classNames({ mx_LeftPanel: true, + mx_LeftPanel_newRoomList: useNewRoomList, mx_LeftPanel_minimized: this.props.isMinimized, }); const roomListClasses = classNames("mx_LeftPanel_actualRoomListContainer", "mx_AutoHideScrollbar"); - const useNewRoomList = SettingsStore.getValue("feature_new_room_list"); if (useNewRoomList) { return (
diff --git a/src/components/structures/LoggedInView.tsx b/src/components/structures/LoggedInView.tsx index cf68f85b4c..26e127f21f 100644 --- a/src/components/structures/LoggedInView.tsx +++ b/src/components/structures/LoggedInView.tsx @@ -259,9 +259,11 @@ class LoggedInView extends React.Component { private createResizer(): Resizer { let panelSize: number | null; 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 collapseConfig: ICollapseConfig = { - // TODO decrease this once Spaces launches as it'll no longer need to include the 56px Community Panel - toggleSize: 206 - 50, + toggleSize, onCollapsed: (collapsed) => { panelCollapsed = collapsed; if (collapsed) { @@ -697,10 +699,18 @@ class LoggedInView extends React.Component { "mx_MatrixChat--with-avatar": this.state.backgroundImage, }); + const useNewRoomList = SettingsStore.getValue("feature_new_room_list"); + + const leftPanelWrapperClasses = classNames({ + mx_LeftPanel_wrapper: true, + mx_LeftPanel_newRoomList: useNewRoomList, + }); + const audioFeedArraysForCalls = this.state.activeCalls.map((call) => { return ; }); + const shouldUseMinimizedUI = !useNewRoomList && this.props.collapseLhs; return (
{
- -
- + +
+ {!useNewRoomList && ( + + )} - + {!useNewRoomList && }
diff --git a/src/components/structures/MatrixChat.tsx b/src/components/structures/MatrixChat.tsx index 0f722ae53d..e61713ca69 100644 --- a/src/components/structures/MatrixChat.tsx +++ b/src/components/structures/MatrixChat.tsx @@ -13,20 +13,21 @@ import { EventType, HttpApiEvent, type MatrixClient, - type MatrixEvent, + MatrixEvent, + MsgType, type RoomType, SyncState, type SyncStateData, type TimelineEvents, } from "matrix-js-sdk/src/matrix"; -import { defer, type IDeferred, type QueryDict } from "matrix-js-sdk/src/utils"; +import { type QueryDict } from "matrix-js-sdk/src/utils"; import { logger } from "matrix-js-sdk/src/logger"; import { throttle } from "lodash"; import { CryptoEvent, type KeyBackupInfo } from "matrix-js-sdk/src/crypto-api"; import { TooltipProvider } from "@vector-im/compound-web"; - // what-input helps improve keyboard accessibility import "what-input"; +import sanitizeHtml from "sanitize-html"; import PosthogTrackers from "../../PosthogTrackers"; import { DecryptionFailureTracker } from "../../DecryptionFailureTracker"; @@ -50,6 +51,7 @@ 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 ThemeWatcher, { ThemeWatcherEvent } from "../../settings/watchers/ThemeWatcher"; import { FontWatcher } from "../../settings/watchers/FontWatcher"; import { storeRoomAliasInCache } from "../../RoomAliasCache"; @@ -94,7 +96,6 @@ import VerificationRequestToast from "../views/toasts/VerificationRequestToast"; import PerformanceMonitor, { PerformanceEntryNames } from "../../performance"; import UIStore, { UI_EVENTS } from "../../stores/UIStore"; import SoftLogout from "./auth/SoftLogout"; -import { makeRoomPermalink } from "../../utils/permalinks/Permalinks"; import { copyPlaintext } from "../../utils/strings"; import { PosthogAnalytics } from "../../PosthogAnalytics"; import { initSentry } from "../../sentry"; @@ -107,6 +108,7 @@ import Views from "../../Views"; import { type FocusNextType, type ViewRoomPayload } from "../../dispatcher/payloads/ViewRoomPayload"; import { type ViewHomePagePayload } from "../../dispatcher/payloads/ViewHomePagePayload"; import { type AfterLeaveRoomPayload } from "../../dispatcher/payloads/AfterLeaveRoomPayload"; +import { type AfterForgetRoomPayload } from "../../dispatcher/payloads/AfterForgetRoomPayload"; import { type DoAfterSyncPreparedPayload } from "../../dispatcher/payloads/DoAfterSyncPreparedPayload"; import { type ViewStartChatOrReusePayload } from "../../dispatcher/payloads/ViewStartChatOrReusePayload"; import { leaveRoomBehaviour } from "../../utils/leave-behaviour"; @@ -123,7 +125,7 @@ import { viewUserDeviceSettings } from "../../actions/handlers/viewUserDeviceSet import GenericToast from "../views/toasts/GenericToast"; import RovingSpotlightDialog from "../views/dialogs/spotlight/SpotlightDialog"; import { findDMForUser } from "../../utils/dm/findDMForUser"; -import { Linkify } from "../../HtmlUtils"; +import { getHtmlText, Linkify } from "../../HtmlUtils"; import { NotificationLevel } from "../../stores/notifications/NotificationLevel"; import { type UserTab } from "../views/dialogs/UserTab"; import { shouldSkipSetupEncryption } from "../../utils/crypto/shouldSkipSetupEncryption"; @@ -135,6 +137,10 @@ import { LoginSplashView } from "./auth/LoginSplashView"; import { cleanUpDraftsIfRequired } from "../../DraftCleaner"; import { InitialCryptoSetupStore } from "../../stores/InitialCryptoSetupStore"; import { setTheme } from "../../theme"; +import { type OpenForwardDialogPayload } from "../../dispatcher/payloads/OpenForwardDialogPayload"; +import { ShareFormat, type SharePayload } from "../../dispatcher/payloads/SharePayload"; +import Markdown from "../../Markdown"; +import { sanitizeHtmlParams } from "../../Linkify"; // legacy export export { default as Views } from "../../Views"; @@ -165,12 +171,6 @@ interface IProps { initialScreenAfterLogin?: IScreen; // displayname, if any, to set on the device when logging in/registering. defaultDeviceDisplayName?: string; - - // Used by tests, this function is called when session initialisation starts - // with a promise that resolves or rejects once the initialiation process - // has finished, so that tests can wait for this to avoid them executing over - // each other. - initPromiseCallback?: (p: Promise) => void; } interface IState { @@ -221,7 +221,7 @@ export default class MatrixChat extends React.PureComponent { }; private firstSyncComplete = false; - private firstSyncPromise: IDeferred; + private firstSyncPromise: PromiseWithResolvers; private screenAfterLogin?: IScreen; private tokenLogin?: boolean; @@ -260,7 +260,7 @@ export default class MatrixChat extends React.PureComponent { // Used by _viewRoom before getting state from sync this.firstSyncComplete = false; - this.firstSyncPromise = defer(); + this.firstSyncPromise = Promise.withResolvers(); if (this.props.config.sync_timeline_limit) { MatrixClientPeg.opts.initialSyncLimit = this.props.config.sync_timeline_limit; @@ -291,9 +291,6 @@ export default class MatrixChat extends React.PureComponent { */ private startInitSession = (): void => { const initProm = this.initSession(); - if (this.props.initPromiseCallback) { - this.props.initPromiseCallback(initProm); - } initProm.catch((err) => { // TODO: show an error screen, rather than a spinner of doom @@ -711,36 +708,6 @@ export default class MatrixChat extends React.PureComponent { case "copy_room": this.copyRoom(payload.room_id); break; - case "reject_invite": - Modal.createDialog(QuestionDialog, { - title: _t("reject_invitation_dialog|title"), - description: _t("reject_invitation_dialog|confirmation"), - onFinished: (confirm) => { - if (confirm) { - // FIXME: controller shouldn't be loading a view :( - const modal = Modal.createDialog(Spinner, undefined, "mx_Dialog_spinner"); - - MatrixClientPeg.safeGet() - .leave(payload.room_id) - .then( - () => { - modal.close(); - if (this.state.currentRoomId === payload.room_id) { - dis.dispatch({ action: Action.ViewHomePage }); - } - }, - (err) => { - modal.close(); - Modal.createDialog(ErrorDialog, { - title: _t("reject_invitation_dialog|failed"), - description: err.toString(), - }); - }, - ); - } - }, - }); - break; case "view_user_info": this.viewUser(payload.userId, payload.subAction); break; @@ -818,6 +785,9 @@ export default class MatrixChat extends React.PureComponent { case Action.ViewHomePage: this.viewHome(payload.justRegistered); break; + case Action.Share: + this.viewShare(payload.format, payload.msg); + break; case Action.ViewStartChatOrReuse: this.chatCreateOrReuse(payload.user_id); break; @@ -1032,10 +1002,6 @@ export default class MatrixChat extends React.PureComponent { // Wait for the first sync to complete so that if a room does have an alias, // it would have been retrieved. if (!this.firstSyncComplete) { - if (!this.firstSyncPromise) { - logger.warn("Cannot view a room before first sync. room_id:", roomInfo.room_id); - return; - } await this.firstSyncPromise.promise; } @@ -1146,8 +1112,7 @@ export default class MatrixChat extends React.PureComponent { private viewUser(userId: string, subAction: string): void { // Wait for the first sync so that `getRoom` gives us a room object if it's // in the sync response - const waitForSync = this.firstSyncPromise ? this.firstSyncPromise.promise : Promise.resolve(); - waitForSync.then(() => { + this.firstSyncPromise.promise.then(() => { if (subAction === "chat") { this.chatCreateOrReuse(userId); return; @@ -1158,6 +1123,58 @@ export default class MatrixChat extends React.PureComponent { }); } + private viewShare(format: ShareFormat, msg: string): void { + // Wait for the first sync so we can present possible rooms to share into + this.firstSyncPromise.promise.then(() => { + this.notifyNewScreen("share"); + let rawEvent; + switch (format) { + case ShareFormat.Html: { + rawEvent = { + type: "m.room.message", + content: { + msgtype: MsgType.Text, + body: getHtmlText(msg), + format: "org.matrix.custom.html", + formatted_body: sanitizeHtml(msg, sanitizeHtmlParams), + }, + origin_server_ts: Date.now(), + }; + break; + } + case ShareFormat.Markdown: { + const html = new Markdown(msg).toHTML({ externalLinks: true }); + rawEvent = { + type: "m.room.message", + content: { + msgtype: MsgType.Text, + body: msg, + format: "org.matrix.custom.html", + formatted_body: html, + }, + origin_server_ts: Date.now(), + }; + break; + } + default: + rawEvent = { + type: "m.room.message", + content: { + msgtype: MsgType.Text, + body: msg, + }, + origin_server_ts: Date.now(), + }; + } + const event = new MatrixEvent(rawEvent); + dis.dispatch({ + action: Action.OpenForwardDialog, + event: event, + permalinkCreator: null, + }); + }); + } + private async createRoom(defaultPublic = false, defaultName?: string, type?: RoomType): Promise { const modal = Modal.createDialog(CreateRoomDialog, { type, @@ -1273,7 +1290,7 @@ export default class MatrixChat extends React.PureComponent { const warnings = this.leaveRoomWarnings(roomId); const isSpace = roomToLeave?.isSpaceRoom(); - Modal.createDialog(QuestionDialog, { + const { finished } = Modal.createDialog(QuestionDialog, { title: isSpace ? _t("space|leave_dialog_action") : _t("action|leave_room"), description: ( @@ -1289,16 +1306,17 @@ export default class MatrixChat extends React.PureComponent { ), button: _t("action|leave"), danger: warnings.length > 0, - onFinished: async (shouldLeave) => { - if (shouldLeave) { - await leaveRoomBehaviour(cli, roomId); + }); - dis.dispatch({ - action: Action.AfterLeaveRoom, - room_id: roomId, - }); - } - }, + finished.then(async ([shouldLeave]) => { + if (shouldLeave) { + await leaveRoomBehaviour(cli, roomId); + + dis.dispatch({ + action: Action.AfterLeaveRoom, + room_id: roomId, + }); + } }); } @@ -1312,10 +1330,12 @@ export default class MatrixChat extends React.PureComponent { dis.dispatch({ action: Action.ViewHomePage }); } - // We have to manually update the room list because the forgotten room will not - // be notified to us, therefore the room list will have no other way of knowing - // the room is forgotten. - if (room) RoomListStore.instance.manualRoomUpdate(room, RoomUpdateCause.RoomRemoved); + if (room) { + // Legacy room list store needs to be told to manually remove this room + RoomListStore.instance.manualRoomUpdate(room, RoomUpdateCause.RoomRemoved); + // New room list store will remove the room on the following dispatch + dis.dispatch({ action: Action.AfterForgetRoom, room }); + } }) .catch((err) => { const errCode = err.errcode || _td("error|unknown_error_code"); @@ -1510,11 +1530,17 @@ export default class MatrixChat extends React.PureComponent { * (useful for setting listeners) */ private onWillStartClient(): void { - // reset the 'have completed first sync' flag, - // since we're about to start the client and therefore about - // to do the first sync + // Reset the 'have completed first sync' flag, + // since we're about to start the client and therefore about to do the first sync + // We resolve the existing promise with the new one to update any existing listeners + if (!this.firstSyncComplete) { + const firstSyncPromise = Promise.withResolvers(); + this.firstSyncPromise.resolve(firstSyncPromise.promise); + this.firstSyncPromise = firstSyncPromise; + } else { + this.firstSyncPromise = Promise.withResolvers(); + } this.firstSyncComplete = false; - this.firstSyncPromise = defer(); const cli = MatrixClientPeg.safeGet(); // Allow the JS SDK to reap timeline events. This reduces the amount of @@ -1596,7 +1622,7 @@ export default class MatrixChat extends React.PureComponent { }); }); cli.on(HttpApiEvent.NoConsent, function (message, consentUri) { - Modal.createDialog( + const { finished } = Modal.createDialog( QuestionDialog, { title: _t("terms|tac_title"), @@ -1607,16 +1633,16 @@ export default class MatrixChat extends React.PureComponent { ), button: _t("terms|tac_button"), cancelButton: _t("action|dismiss"), - onFinished: (confirmed) => { - if (confirmed) { - const wnd = window.open(consentUri, "_blank")!; - wnd.opener = null; - } - }, }, undefined, true, ); + finished.then(([confirmed]) => { + if (confirmed) { + const wnd = window.open(consentUri, "_blank")!; + wnd.opener = null; + } + }); }); DecryptionFailureTracker.instance @@ -1776,6 +1802,20 @@ export default class MatrixChat extends React.PureComponent { dis.dispatch({ action: Action.CreateChat, }); + } else if (screen === "share") { + if (params && params["msg"] !== undefined) { + dis.dispatch({ + action: Action.Share, + msg: params["msg"], + format: params["format"], + }); + } + // 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) { + this.viewHome(); + } } else if (screen === "settings") { dis.fire(Action.ViewUserSettings); } else if (screen === "welcome") { diff --git a/src/components/structures/MatrixClientContextProvider.tsx b/src/components/structures/MatrixClientContextProvider.tsx index e22d6bfcde..7d555f5809 100644 --- a/src/components/structures/MatrixClientContextProvider.tsx +++ b/src/components/structures/MatrixClientContextProvider.tsx @@ -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 React, { type PropsWithChildren, useEffect, useState } from "react"; +import React, { type PropsWithChildren, useEffect, useState, type JSX } from "react"; import { type MatrixClient } from "matrix-js-sdk/src/matrix"; import { CryptoEvent } from "matrix-js-sdk/src/crypto-api"; import { logger } from "matrix-js-sdk/src/logger"; @@ -85,7 +85,7 @@ interface Props { * A React component which exposes a {@link MatrixClientContext} and a {@link LocalDeviceVerificationStateContext} * to its children. */ -export function MatrixClientContextProvider(props: PropsWithChildren): React.JSX.Element { +export function MatrixClientContextProvider(props: PropsWithChildren): JSX.Element { const verificationState = useLocalVerificationState(props.client); return ( diff --git a/src/components/structures/RightPanel.tsx b/src/components/structures/RightPanel.tsx index 21e2a2ee71..94a5d34d60 100644 --- a/src/components/structures/RightPanel.tsx +++ b/src/components/structures/RightPanel.tsx @@ -7,7 +7,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 React, { type ChangeEvent } from "react"; +import React from "react"; import { type Room, type RoomState, RoomStateEvent, RoomMember, type MatrixEvent } from "matrix-js-sdk/src/matrix"; import { throttle } from "lodash"; @@ -15,7 +15,7 @@ import dis from "../../dispatcher/dispatcher"; import { RightPanelPhases } from "../../stores/right-panel/RightPanelStorePhases"; import RightPanelStore from "../../stores/right-panel/RightPanelStore"; import MatrixClientContext from "../../contexts/MatrixClientContext"; -import RoomSummaryCard from "../views/right_panel/RoomSummaryCard"; +import RoomSummaryCardView from "../views/right_panel/RoomSummaryCardView"; import WidgetCard from "../views/right_panel/WidgetCard"; import UserInfo from "../views/right_panel/UserInfo"; import ThirdPartyMemberInfo from "../views/rooms/ThirdPartyMemberInfo"; @@ -49,8 +49,9 @@ interface RoomlessProps extends BaseProps { interface RoomProps extends BaseProps { room: Room; permalinkCreator: RoomPermalinkCreator; - onSearchChange?: (e: ChangeEvent) => void; + onSearchChange?: (term: string) => void; onSearchCancel?: () => void; + searchTerm?: string; } type Props = XOR; @@ -254,12 +255,13 @@ export default class RightPanel extends React.Component { case RightPanelPhases.RoomSummary: if (!!this.props.room) { card = ( - ); @@ -280,7 +282,7 @@ export default class RightPanel extends React.Component { } return ( -
); - } else if (phase === Phase.ConfirmReset) { - return ( -
-

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

-

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

- -
- - {_t("encryption|verification|reset_proceed_prompt")} - - - {_t("action|go_back")} - -
-
- ); } else if (phase === Phase.Busy || phase === Phase.Loading) { return ; } else { diff --git a/src/components/structures/auth/SoftLogout.tsx b/src/components/structures/auth/SoftLogout.tsx index 7ebcc7af6a..34fabe46c7 100644 --- a/src/components/structures/auth/SoftLogout.tsx +++ b/src/components/structures/auth/SoftLogout.tsx @@ -89,13 +89,12 @@ export default class SoftLogout extends React.Component { } private onClearAll = (): void => { - Modal.createDialog(ConfirmWipeDeviceDialog, { - onFinished: (wipeData) => { - if (!wipeData) return; + const { finished } = Modal.createDialog(ConfirmWipeDeviceDialog); + finished.then(([wipeData]) => { + if (!wipeData) return; - logger.log("Clearing data from soft-logged-out session"); - Lifecycle.logout(this.context.oidcClientStore); - }, + logger.log("Clearing data from soft-logged-out session"); + Lifecycle.logout(this.context.oidcClientStore); }); }; @@ -168,7 +167,7 @@ export default class SoftLogout extends React.Component { return; } - Lifecycle.setLoggedIn(credentials).catch((e) => { + Lifecycle.hydrateSession(credentials).catch((e) => { logger.error(e); this.setState({ busy: false, errorText: _t("auth|failed_soft_logout_auth") }); }); @@ -204,7 +203,7 @@ export default class SoftLogout extends React.Component { return false; } - return Lifecycle.setLoggedIn(credentials) + return Lifecycle.hydrateSession(credentials) .then(() => { if (this.props.onTokenLoginCompleted) { this.props.onTokenLoginCompleted(); diff --git a/src/components/structures/auth/header/AuthHeaderContext.tsx b/src/components/structures/auth/header/AuthHeaderContext.tsx index 768060b654..a45233564b 100644 --- a/src/components/structures/auth/header/AuthHeaderContext.tsx +++ b/src/components/structures/auth/header/AuthHeaderContext.tsx @@ -6,10 +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 { createContext, type Dispatch, type ReducerAction, type ReducerState } from "react"; +import { createContext, type Dispatch, type Reducer, type ReducerState } from "react"; import type { AuthHeaderReducer } from "./AuthHeaderProvider"; +type ReducerAction> = R extends Reducer ? A : never; + interface AuthHeaderContextType { state: ReducerState; dispatch: Dispatch>; diff --git a/src/components/structures/auth/header/AuthHeaderProvider.tsx b/src/components/structures/auth/header/AuthHeaderProvider.tsx index ad1ff5b0cc..50a53b95c3 100644 --- a/src/components/structures/auth/header/AuthHeaderProvider.tsx +++ b/src/components/structures/auth/header/AuthHeaderProvider.tsx @@ -25,7 +25,7 @@ interface AuthHeaderAction { export type AuthHeaderReducer = Reducer[], AuthHeaderAction>; export function AuthHeaderProvider({ children }: PropsWithChildren): JSX.Element { - const [state, dispatch] = useReducer( + const [state, dispatch] = useReducer[], [AuthHeaderAction]>( (state: ComponentProps[], action: AuthHeaderAction) => { switch (action.type) { case AuthHeaderActionType.Add: diff --git a/src/components/viewmodels/avatars/RoomAvatarViewModel.tsx b/src/components/viewmodels/avatars/RoomAvatarViewModel.tsx new file mode 100644 index 0000000000..3832616a9c --- /dev/null +++ b/src/components/viewmodels/avatars/RoomAvatarViewModel.tsx @@ -0,0 +1,89 @@ +/* + * 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 { EventType, JoinRule, type MatrixEvent, type Room, RoomEvent } from "matrix-js-sdk/src/matrix"; +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 { + /** + * 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; +} + +/** + * Hook to get the state of the room avatar. + * @param room + */ +export function useRoomAvatarViewModel(room: Room): RoomAvatarViewState { + const isVideoRoom = room.isElementVideoRoom() || room.isCallRoom(); + const roomMember = useDmMember(room); + const presence = usePresence(room, roomMember); + const isPublic = useIsPublic(room); + const isLowPriority = !!room.tags[DefaultTagID.LowPriority]; + + 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 { badgeDecoration, presence }; +} + +/** + * Hook listening to the room join rules. + * Return true if the room is public. + * @param room + */ +function useIsPublic(room: Room): boolean { + const [isPublic, setIsPublic] = useState(isRoomPublic(room)); + // We don't use `useTypedEventEmitterState` because we don't want to update `isPublic` value at every `RoomEvent.Timeline` event. + useTypedEventEmitter(room, RoomEvent.Timeline, (ev: MatrixEvent, _room: Room) => { + if (room.roomId !== _room.roomId) return; + if (ev.getType() !== EventType.RoomJoinRules && ev.getType() !== EventType.RoomMember) return; + + setIsPublic(isRoomPublic(_room)); + }); + + // Reset the value when the room changes + useEffect(() => { + setIsPublic(isRoomPublic(room)); + }, [room]); + + return isPublic; +} + +/** + * Whether the room is public. + * @param room + */ +function isRoomPublic(room: Room): boolean { + return room.getJoinRule() === JoinRule.Public; +} diff --git a/src/components/viewmodels/right_panel/RoomSummaryCardTopicViewModel.tsx b/src/components/viewmodels/right_panel/RoomSummaryCardTopicViewModel.tsx new file mode 100644 index 0000000000..10cbc5b568 --- /dev/null +++ b/src/components/viewmodels/right_panel/RoomSummaryCardTopicViewModel.tsx @@ -0,0 +1,84 @@ +/* +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 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"; +import { onRoomTopicLinkClick } from "../../views/elements/RoomTopic"; +import { useTopic } from "../../../hooks/room/useTopic"; + +export interface RoomTopicState { + /** + * The topic of the room, the value is taken from the room state + */ + topic: Optional; + /** + * Whether the topic is expanded or not + */ + expanded: boolean; + /** + * Whether the user have the permission to edit the topic + */ + canEditTopic: boolean; + /** + * The callback when the edit button is clicked + */ + onEditClick: (e: SyntheticEvent) => void; + /** + * When the expand button is clicked, it changes expanded state + */ + onExpandedClick: (ev: SyntheticEvent) => void; + /** + * The callback when the topic link is clicked + */ + onTopicLinkClick: React.MouseEventHandler; +} + +/** + * The view model for the room topic used in the RoomSummaryCard + * @param room - the room to get the topic from + * @returns the room topic state + */ +export function useRoomTopicViewModel(room: Room): RoomTopicState { + const [expanded, setExpanded] = useState(true); + + const topic = useTopic(room); + + const canEditTopic = useRoomState(room, (state) => + state.maySendStateEvent(EventType.RoomTopic, room.client.getSafeUserId()), + ); + + const onEditClick = (e: SyntheticEvent): void => { + e.preventDefault(); + e.stopPropagation(); + defaultDispatcher.dispatch({ action: "open_room_settings" }); + }; + + const onExpandedClick = (e: SyntheticEvent): void => { + e.preventDefault(); + e.stopPropagation(); + setExpanded((_expanded) => !_expanded); + }; + + const onTopicLinkClick = (e: React.MouseEvent): void => { + if (e.target instanceof HTMLAnchorElement) { + onRoomTopicLinkClick(e); + return; + } + }; + + return { + topic, + expanded, + canEditTopic, + onEditClick, + onExpandedClick, + onTopicLinkClick, + }; +} diff --git a/src/components/viewmodels/right_panel/RoomSummaryCardViewModel.tsx b/src/components/viewmodels/right_panel/RoomSummaryCardViewModel.tsx new file mode 100644 index 0000000000..143f3fca18 --- /dev/null +++ b/src/components/viewmodels/right_panel/RoomSummaryCardViewModel.tsx @@ -0,0 +1,278 @@ +/* +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 { useEffect, useRef, useState } from "react"; +import { EventType, type JoinRule, type Room, RoomStateEvent } from "matrix-js-sdk/src/matrix"; + +import { useMatrixClientContext } from "../../../contexts/MatrixClientContext"; +import { useIsEncrypted } from "../../../hooks/useIsEncrypted"; +import { useScopedRoomContext } from "../../../contexts/ScopedRoomContext"; +import { type E2EStatus } from "../../../utils/ShieldUtils"; +import { isVideoRoom as calcIsVideoRoom } from "../../../utils/video-rooms"; +import { useRoomState } from "../../../hooks/useRoomState"; +import { useAccountData } from "../../../hooks/useAccountData"; +import { useDispatcher } from "../../../hooks/useDispatcher"; +import defaultDispatcher from "../../../dispatcher/dispatcher"; +import { Action } from "../../../dispatcher/actions"; +import RoomListStore, { LISTS_UPDATE_EVENT } from "../../../stores/room-list/RoomListStore"; +import { canInviteTo } from "../../../utils/room/canInviteTo"; +import { DefaultTagID } from "../../../stores/room-list/models"; +import { useEventEmitterState } from "../../../hooks/useEventEmitter"; +import RightPanelStore from "../../../stores/right-panel/RightPanelStore"; +import { RightPanelPhases } from "../../../stores/right-panel/RightPanelStorePhases"; +import PosthogTrackers from "../../../PosthogTrackers"; +import { PollHistoryDialog } from "../../views/dialogs/PollHistoryDialog"; +import Modal from "../../../Modal"; +import ExportDialog from "../../views/dialogs/ExportDialog"; +import { ShareDialog } from "../../views/dialogs/ShareDialog"; +import { type RoomPermalinkCreator } from "../../../utils/permalinks/Permalinks"; +import { ReportRoomDialog } from "../../views/dialogs/ReportRoomDialog"; +import { Key } from "../../../Keyboard"; +import { usePinnedEvents } from "../../../hooks/usePinnedEvents"; +import { tagRoom } from "../../../utils/room/tagRoom"; +import { inviteToRoom } from "../../../utils/room/inviteToRoom"; + +export interface RoomSummaryCardState { + isDirectMessage: boolean; + /** + * Whether the room is encrypted, used to display the correct badge and icon + */ + isRoomEncrypted: boolean; + /** + * The e2e status of the room, used to display the correct badge and icon + */ + e2eStatus: E2EStatus | undefined; + /** + * The join rule of the room, used to display the correct badge and icon + */ + roomJoinRule: JoinRule; + /** + * if it is a video room, it should not display export chat, polls, files, extensions + */ + isVideoRoom: boolean; + /** + * display the alias of the room, if it exists + */ + alias: string; + /** + * value to check if the room is a favorite or not + */ + isFavorite: boolean; + /** + * value to check if we disable invite button or not + */ + canInviteToState: boolean; + /** + * Getting the number of pinned messages in the room, next to the pin button + */ + pinCount: number; + searchInputRef: React.RefObject; + /** + * The callback when new value is entered in the search input + */ + onUpdateSearchInput: (e: React.KeyboardEvent) => void; + /** + * Callbacks to all the actions button in the right panel + */ + onRoomMembersClick: () => void; + onRoomThreadsClick: () => void; + onRoomFilesClick: () => void; + onRoomExtensionsClick: () => void; + onRoomPinsClick: () => void; + onRoomSettingsClick: (ev: Event) => void; + onLeaveRoomClick: () => void; + onShareRoomClick: () => void; + onRoomExportClick: () => Promise; + onRoomPollHistoryClick: () => void; + onReportRoomClick: () => Promise; + onFavoriteToggleClick: () => void; + onInviteToRoomClick: () => void; +} + +/** + * Hook to check if the room is a direct message or not + * @param room - The room to check + * @returns Whether the room is a direct message + */ +const useIsDirectMessage = (room: Room): boolean => { + const directRoomsList = useAccountData>(room.client, EventType.Direct); + const [isDirectMessage, setDirectMessage] = useState(false); + + useEffect(() => { + for (const [, dmRoomList] of Object.entries(directRoomsList)) { + if (dmRoomList.includes(room?.roomId ?? "")) { + setDirectMessage(true); + break; + } + } + }, [room, directRoomsList]); + + return isDirectMessage; +}; + +/** + * Hook to handle the search input in the right panel + * @param onSearchCancel - The callback when the search input is cancelled + * @returns The search input ref and the callback when the search input is updated + */ +const useSearchInput = ( + onSearchCancel?: () => void, +): { + searchInputRef: React.RefObject; + onUpdateSearchInput: (e: React.KeyboardEvent) => void; +} => { + const searchInputRef = useRef(null); + + const onUpdateSearchInput = (e: React.KeyboardEvent): void => { + if (searchInputRef.current && e.key === Key.ESCAPE) { + searchInputRef.current.value = ""; + onSearchCancel?.(); + } + }; + + // Focus the search field when the user clicks on the search button component + useDispatcher(defaultDispatcher, (payload) => { + if (payload.action === Action.FocusMessageSearch) { + searchInputRef.current?.focus(); + } + }); + + return { + searchInputRef, + onUpdateSearchInput, + }; +}; + +export function useRoomSummaryCardViewModel( + room: Room, + permalinkCreator: RoomPermalinkCreator, + onSearchCancel?: () => void, +): RoomSummaryCardState { + const cli = useMatrixClientContext(); + + const isRoomEncrypted = useIsEncrypted(cli, room) ?? false; + const roomContext = useScopedRoomContext("e2eStatus", "timelineRenderingType"); + const e2eStatus = roomContext.e2eStatus; + const isVideoRoom = calcIsVideoRoom(room); + + const roomState = useRoomState(room); + // used to check if the room is public or not + const roomJoinRule = roomState.getJoinRule(); + const alias = room.getCanonicalAlias() || room.getAltAliases()[0] || ""; + const pinCount = usePinnedEvents(room).length; + // value to check if the user can invite to the room + const canInviteToState = useEventEmitterState(room, RoomStateEvent.Update, () => canInviteTo(room)); + + const roomTags = useEventEmitterState(RoomListStore.instance, LISTS_UPDATE_EVENT, () => + RoomListStore.instance.getTagsForRoom(room), + ); + const isFavorite = roomTags.includes(DefaultTagID.Favourite); + + const isDirectMessage = useIsDirectMessage(room); + + const onRoomMembersClick = (): void => { + RightPanelStore.instance.pushCard({ phase: RightPanelPhases.MemberList }, true); + }; + + const onRoomThreadsClick = (): void => { + RightPanelStore.instance.pushCard({ phase: RightPanelPhases.ThreadPanel }, true); + }; + + const onRoomFilesClick = (): void => { + RightPanelStore.instance.pushCard({ phase: RightPanelPhases.FilePanel }, true); + }; + + const onRoomExtensionsClick = (): void => { + RightPanelStore.instance.pushCard({ phase: RightPanelPhases.Extensions }, true); + }; + + const onRoomPinsClick = (): void => { + PosthogTrackers.trackInteraction("PinnedMessageRoomInfoButton"); + RightPanelStore.instance.pushCard({ phase: RightPanelPhases.PinnedMessages }, true); + }; + + const onRoomSettingsClick = (ev: Event): void => { + defaultDispatcher.dispatch({ action: "open_room_settings" }); + PosthogTrackers.trackInteraction("WebRightPanelRoomInfoSettingsButton", ev); + }; + + const onShareRoomClick = (): void => { + Modal.createDialog(ShareDialog, { + target: room, + }); + }; + + const onRoomExportClick = async (): Promise => { + Modal.createDialog(ExportDialog, { + room, + }); + }; + + const onRoomPollHistoryClick = (): void => { + Modal.createDialog(PollHistoryDialog, { + room, + matrixClient: cli, + permalinkCreator, + }); + }; + + const onLeaveRoomClick = (): void => { + defaultDispatcher.dispatch({ + action: "leave_room", + room_id: room.roomId, + }); + }; + + const onReportRoomClick = async (): Promise => { + const [leave] = await Modal.createDialog(ReportRoomDialog, { + roomId: room.roomId, + }).finished; + if (leave) { + defaultDispatcher.dispatch({ + action: "leave_room", + room_id: room.roomId, + }); + } + }; + + const onFavoriteToggleClick = (): void => { + tagRoom(room, DefaultTagID.Favourite); + }; + + const onInviteToRoomClick = (): void => { + inviteToRoom(room); + }; + + // Room Search element ref + const { searchInputRef, onUpdateSearchInput } = useSearchInput(onSearchCancel); + + return { + isDirectMessage, + isRoomEncrypted, + roomJoinRule, + e2eStatus, + isVideoRoom, + alias, + isFavorite, + canInviteToState, + searchInputRef, + pinCount, + onRoomMembersClick, + onRoomThreadsClick, + onRoomFilesClick, + onRoomExtensionsClick, + onRoomPinsClick, + onRoomSettingsClick, + onLeaveRoomClick, + onShareRoomClick, + onRoomExportClick, + onRoomPollHistoryClick, + onReportRoomClick, + onUpdateSearchInput, + onFavoriteToggleClick, + onInviteToRoomClick, + }; +} diff --git a/src/components/viewmodels/roomlist/RoomListHeaderViewModel.tsx b/src/components/viewmodels/roomlist/RoomListHeaderViewModel.tsx index 8a1fdb1fe7..451a4898b7 100644 --- a/src/components/viewmodels/roomlist/RoomListHeaderViewModel.tsx +++ b/src/components/viewmodels/roomlist/RoomListHeaderViewModel.tsx @@ -31,6 +31,7 @@ import { import { useMatrixClientContext } from "../../../contexts/MatrixClientContext"; import type { ViewRoomPayload } from "../../../dispatcher/payloads/ViewRoomPayload"; import { createRoom, hasCreateRoomRights } from "./utils"; +import { type SortOption, useSorter } from "./useSorter"; /** * Hook to get the active space and its title. @@ -117,6 +118,14 @@ export interface RoomListHeaderViewState { * Open the space settings */ openSpaceSettings: () => void; + /** + * Change the sort order of the room-list. + */ + sort: (option: SortOption) => void; + /** + * The currently active sort option. + */ + activeSortOption: SortOption; } /** @@ -128,8 +137,8 @@ export function useRoomListHeaderViewModel(): RoomListHeaderViewState { const isSpaceRoom = Boolean(activeSpace); const canCreateRoom = hasCreateRoomRights(matrixClient, activeSpace); - const canCreateVideoRoom = useFeatureEnabled("feature_video_rooms"); - const displayComposeMenu = canCreateRoom || canCreateVideoRoom; + const canCreateVideoRoom = useFeatureEnabled("feature_video_rooms") && canCreateRoom; + const displayComposeMenu = canCreateRoom; const displaySpaceMenu = isSpaceRoom; const canInviteInSpace = Boolean( activeSpace?.getJoinRule() === JoinRule.Public || activeSpace?.canInvite(matrixClient.getSafeUserId()), @@ -138,6 +147,8 @@ export function useRoomListHeaderViewModel(): RoomListHeaderViewState { /* Actions */ + const { activeSortOption, sort } = useSorter(); + const createChatRoom = useCallback((e: Event) => { defaultDispatcher.fire(Action.CreateChat); PosthogTrackers.trackInteraction("WebRoomListHeaderPlusMenuCreateChatItem", e); @@ -207,5 +218,7 @@ export function useRoomListHeaderViewModel(): RoomListHeaderViewState { inviteInSpace, openSpacePreferences, openSpaceSettings, + activeSortOption, + sort, }; } diff --git a/src/components/viewmodels/roomlist/RoomListItemMenuViewModel.tsx b/src/components/viewmodels/roomlist/RoomListItemMenuViewModel.tsx index 6b089495a0..738a05b8c3 100644 --- a/src/components/viewmodels/roomlist/RoomListItemMenuViewModel.tsx +++ b/src/components/viewmodels/roomlist/RoomListItemMenuViewModel.tsx @@ -11,7 +11,7 @@ import { type Room, RoomEvent } from "matrix-js-sdk/src/matrix"; import { useMatrixClientContext } from "../../../contexts/MatrixClientContext"; import { useEventEmitterState } from "../../../hooks/useEventEmitter"; import { useUnreadNotifications } from "../../../hooks/useUnreadNotifications"; -import { hasAccessToOptionsMenu } from "./utils"; +import { hasAccessToNotificationMenu, hasAccessToOptionsMenu } from "./utils"; import DMRoomMap from "../../../utils/DMRoomMap"; import { DefaultTagID } from "../../../stores/room-list/models"; import { NotificationLevel } from "../../../stores/notifications/NotificationLevel"; @@ -21,16 +21,26 @@ import dispatcher from "../../../dispatcher/dispatcher"; import { clearRoomNotification, setMarkedUnreadState } from "../../../utils/notifications"; import PosthogTrackers from "../../../PosthogTrackers"; import { tagRoom } from "../../../utils/room/tagRoom"; +import { RoomNotifState } from "../../../RoomNotifs"; +import { useNotificationState } from "../../../hooks/useRoomNotificationState"; export interface RoomListItemMenuViewState { /** * Whether the more options menu should be shown. */ showMoreOptionsMenu: boolean; + /** + * Whether the notification menu should be shown. + */ + showNotificationMenu: boolean; /** * 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. */ @@ -47,6 +57,22 @@ export interface RoomListItemMenuViewState { * Can mark the room as unread. */ canMarkAsUnread: boolean; + /** + * Whether the notification is set to all messages. + */ + isNotificationAllMessage: boolean; + /** + * Whether the notification is set to all messages loud. + */ + isNotificationAllMessageLoud: boolean; + /** + * Whether the notification is set to mentions and keywords only. + */ + isNotificationMentionOnly: boolean; + /** + * Whether the notification is muted. + */ + isNotificationMute: boolean; /** * Mark the room as read. * @param evt @@ -81,6 +107,11 @@ export interface RoomListItemMenuViewState { * @param evt */ leaveRoom: (evt: Event) => void; + /** + * Set the room notification state. + * @param state + */ + setRoomNotifState: (state: RoomNotifState) => void; } export function useRoomListItemMenuViewModel(room: Room): RoomListItemMenuViewState { @@ -88,12 +119,14 @@ export function useRoomListItemMenuViewModel(room: Room): RoomListItemMenuViewSt const roomTags = useEventEmitterState(room, RoomEvent.Tags, () => room.tags); const { level: notificationLevel } = useUnreadNotifications(room); - const showMoreOptionsMenu = hasAccessToOptionsMenu(room); - 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); + const showNotificationMenu = hasAccessToNotificationMenu(room, matrixClient.isGuest(), isArchived); + const canMarkAsRead = notificationLevel > NotificationLevel.None; const canMarkAsUnread = !canMarkAsRead && !isArchived; @@ -101,6 +134,12 @@ export function useRoomListItemMenuViewModel(room: Room): RoomListItemMenuViewSt room.canInvite(matrixClient.getUserId()!) && !isDm && shouldShowComponent(UIComponent.InviteUsers); const canCopyRoomLink = !isDm; + const [roomNotifState, setRoomNotifState] = useNotificationState(room); + const isNotificationAllMessage = roomNotifState === RoomNotifState.AllMessages; + const isNotificationAllMessageLoud = roomNotifState === RoomNotifState.AllMessagesLoud; + const isNotificationMentionOnly = roomNotifState === RoomNotifState.MentionsOnly; + const isNotificationMute = roomNotifState === RoomNotifState.Mute; + // Actions const markAsRead = useCallback( @@ -164,11 +203,17 @@ export function useRoomListItemMenuViewModel(room: Room): RoomListItemMenuViewSt return { showMoreOptionsMenu, + showNotificationMenu, isFavourite, + isLowPriority, canInvite, canCopyRoomLink, canMarkAsRead, canMarkAsUnread, + isNotificationAllMessage, + isNotificationAllMessageLoud, + isNotificationMentionOnly, + isNotificationMute, markAsRead, markAsUnread, toggleFavorite, @@ -176,5 +221,6 @@ export function useRoomListItemMenuViewModel(room: Room): RoomListItemMenuViewSt invite, copyRoomLink, leaveRoom, + setRoomNotifState, }; } diff --git a/src/components/viewmodels/roomlist/RoomListItemViewModel.tsx b/src/components/viewmodels/roomlist/RoomListItemViewModel.tsx index 1d5d9aba11..e009033875 100644 --- a/src/components/viewmodels/roomlist/RoomListItemViewModel.tsx +++ b/src/components/viewmodels/roomlist/RoomListItemViewModel.tsx @@ -5,18 +5,31 @@ * Please see LICENSE files in the repository root for full details. */ -import { useCallback, useMemo } from "react"; -import { type Room } from "matrix-js-sdk/src/matrix"; +import { useCallback, useEffect, useMemo, useState } from "react"; +import { type Room, RoomEvent } from "matrix-js-sdk/src/matrix"; import dispatcher from "../../../dispatcher/dispatcher"; import type { ViewRoomPayload } from "../../../dispatcher/payloads/ViewRoomPayload"; import { Action } from "../../../dispatcher/actions"; -import { hasAccessToOptionsMenu } from "./utils"; +import { hasAccessToNotificationMenu, hasAccessToOptionsMenu } from "./utils"; import { _t } from "../../../languageHandler"; import { type RoomNotificationState } from "../../../stores/notifications/RoomNotificationState"; import { RoomNotificationStateStore } from "../../../stores/notifications/RoomNotificationStateStore"; +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 { NotificationStateEvents } from "../../../stores/notifications/NotificationState"; +import DMRoomMap from "../../../utils/DMRoomMap"; +import { MessagePreviewStore } from "../../../stores/room-list/MessagePreviewStore"; +import { useMessagePreviewToggle } from "./useMessagePreviewToggle"; export interface RoomListItemViewState { + /** + * The name of the room. + */ + name: string; /** * Whether the hover menu should be shown. */ @@ -33,6 +46,32 @@ export interface RoomListItemViewState { * The notification state of the room. */ notificationState: RoomNotificationState; + /** + * Whether the room should be bolded. + */ + isBold: boolean; + /** + * Whether the room is a video room + */ + isVideoRoom: boolean; + /** + * The connection state of the call. + * `null` if there is no call in the room. + */ + callConnectionState: ConnectionState | null; + /** + * Whether there are participants in the call. + */ + hasParticipantInCall: boolean; + /** + * Pre-rendered and translated preview for the latest message in the room, or undefined + * if no preview should be shown. + */ + messagePreview: string | undefined; + /** + * Whether the notification decoration should be shown. + */ + showNotificationDecoration: boolean; } /** @@ -40,10 +79,50 @@ export interface RoomListItemViewState { * @see {@link RoomListItemViewState} for more information about what this view model returns. */ export function useRoomListItemViewModel(room: Room): RoomListItemViewState { - // incoming: Check notification menu rights - const showHoverMenu = hasAccessToOptionsMenu(room); + const matrixClient = useMatrixClientContext(); + const roomTags = useEventEmitterState(room, RoomEvent.Tags, () => room.tags); + const isArchived = Boolean(roomTags[DefaultTagID.Archived]); + const name = useEventEmitterState(room, RoomEvent.Name, () => room.name); + const notificationState = useMemo(() => RoomNotificationStateStore.instance.getRoomState(room), [room]); - const a11yLabel = getA11yLabel(room, notificationState); + + const [a11yLabel, setA11yLabel] = useState(getA11yLabel(name, notificationState)); + const [{ isBold, invited, hasVisibleNotification }, setNotificationValues] = useState( + getNotificationValues(notificationState), + ); + useEffect(() => { + setA11yLabel(getA11yLabel(name, notificationState)); + }, [name, notificationState]); + + // Listen to changes in the notification state and update the values + useTypedEventEmitter(notificationState, NotificationStateEvents.Update, () => { + setA11yLabel(getA11yLabel(name, notificationState)); + setNotificationValues(getNotificationValues(notificationState)); + }); + + // If the notification reference change due to room change, update the values + useEffect(() => { + setNotificationValues(getNotificationValues(notificationState)); + }, [notificationState]); + + // We don't want to show the hover menu if + // - there is an invitation for this room + // - the user doesn't have access to both notification and more options menus + const showHoverMenu = + !invited && + (hasAccessToOptionsMenu(room) || hasAccessToNotificationMenu(room, matrixClient.isGuest(), isArchived)); + + const messagePreview = useRoomMessagePreview(room); + + // Video room + const isVideoRoom = room.isElementVideoRoom() || room.isCallRoom(); + // EC video call or video room + const call = useCall(room.roomId); + const connectionState = useConnectionState(call); + const hasParticipantInCall = useParticipantCount(call) > 0; + const callConnectionState = call ? connectionState : null; + + const showNotificationDecoration = hasVisibleNotification || hasParticipantInCall; // Actions @@ -56,38 +135,102 @@ export function useRoomListItemViewModel(room: Room): RoomListItemViewState { }, [room]); return { + name, notificationState, showHoverMenu, openRoom, a11yLabel, + isBold, + isVideoRoom, + callConnectionState, + hasParticipantInCall, + messagePreview, + showNotificationDecoration, + }; +} + +/** + * Calculate the values from the notification state + * @param notificationState + */ +function getNotificationValues(notificationState: RoomNotificationState): { + computeA11yLabel: (name: string) => string; + isBold: boolean; + invited: boolean; + hasVisibleNotification: boolean; +} { + const invited = notificationState.invited; + const computeA11yLabel = (name: string): string => getA11yLabel(name, notificationState); + const isBold = notificationState.hasAnyNotificationOrActivity; + + const hasVisibleNotification = notificationState.hasAnyNotificationOrActivity || notificationState.muted; + + return { + computeA11yLabel, + isBold, + invited, + hasVisibleNotification, }; } /** * Get the a11y label for the room list item - * @param room + * @param roomName * @param notificationState */ -function getA11yLabel(room: Room, notificationState: RoomNotificationState): string { - if (notificationState.isUnsetMessage) { +function getA11yLabel(roomName: string, notificationState: RoomNotificationState): string { + if (notificationState.isUnsentMessage) { return _t("a11y|room_messsage_not_sent", { - roomName: room.name, + roomName, }); } else if (notificationState.invited) { return _t("a11y|room_n_unread_invite", { - roomName: room.name, + roomName, }); } else if (notificationState.isMention) { return _t("a11y|room_n_unread_messages_mentions", { - roomName: room.name, + roomName, count: notificationState.count, }); } else if (notificationState.hasUnreadCount) { return _t("a11y|room_n_unread_messages", { - roomName: room.name, + roomName, count: notificationState.count, }); } else { - return _t("room_list|room|open_room", { roomName: room.name }); + return _t("room_list|room|open_room", { roomName }); } } + +function useRoomMessagePreview(room: Room): string | undefined { + const { shouldShowMessagePreview } = useMessagePreviewToggle(); + const [previewText, setPreviewText] = useState(undefined); + + const updatePreview = useCallback(async () => { + if (!shouldShowMessagePreview) { + setPreviewText(undefined); + return; + } + + const roomIsDM = Boolean(DMRoomMap.shared().getUserIdForRoomId(room.roomId)); + // For the tag, we only care about whether the room is a DM or not as we don't show + // display names in previewsd for DMs, so anything else we just say is 'untagged' + // (even though it could actually be have other tags: we don't care about them). + const messagePreview = await MessagePreviewStore.instance.getPreviewForRoom( + room, + roomIsDM ? DefaultTagID.DM : DefaultTagID.Untagged, + ); + setPreviewText(messagePreview?.text); + }, [room, shouldShowMessagePreview]); + + // MessagePreviewStore and the other AsyncStores need to be converted to TypedEventEmitter + useEventEmitter(MessagePreviewStore.instance, MessagePreviewStore.getPreviewChangedEventName(room), () => { + updatePreview(); + }); + + useEffect(() => { + updatePreview(); + }, [updatePreview]); + + return previewText; +} diff --git a/src/components/viewmodels/roomlist/RoomListViewModel.tsx b/src/components/viewmodels/roomlist/RoomListViewModel.tsx index 217eaefbd9..e4e5093782 100644 --- a/src/components/viewmodels/roomlist/RoomListViewModel.tsx +++ b/src/components/viewmodels/roomlist/RoomListViewModel.tsx @@ -8,9 +8,7 @@ Please see LICENSE files in the repository root for full details. import { useCallback } from "react"; import type { Room } from "matrix-js-sdk/src/matrix"; -import { type PrimaryFilter, type SecondaryFilters, useFilteredRooms } from "./useFilteredRooms"; -import { type SortOption, useSorter } from "./useSorter"; -import { useMessagePreviewToggle } from "./useMessagePreviewToggle"; +import { type PrimaryFilter, useFilteredRooms } from "./useFilteredRooms"; import { createRoom as createRoomFunc, hasCreateRoomRights } from "./utils"; import { useEventEmitterState } from "../../../hooks/useEventEmitter"; import { UPDATE_SELECTED_SPACE } from "../../../stores/spaces"; @@ -19,8 +17,14 @@ import dispatcher from "../../../dispatcher/dispatcher"; import { Action } from "../../../dispatcher/actions"; import { useMatrixClientContext } from "../../../contexts/MatrixClientContext"; import { useStickyRoomList } from "./useStickyRoomList"; +import { useRoomListNavigation } from "./useRoomListNavigation"; export interface RoomListViewState { + /** + * Whether the list of rooms is being loaded. + */ + isLoadingRooms: boolean; + /** * A list of rooms to be displayed in the left panel. */ @@ -55,36 +59,6 @@ export interface RoomListViewState { */ activePrimaryFilter?: PrimaryFilter; - /** - * A function to activate a given secondary filter. - */ - activateSecondaryFilter: (filter: SecondaryFilters) => void; - - /** - * The currently active secondary filter. - */ - activeSecondaryFilter: SecondaryFilters; - - /** - * Change the sort order of the room-list. - */ - sort: (option: SortOption) => void; - - /** - * 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; - /** * The index of the active room in the room list. */ @@ -97,15 +71,11 @@ export interface RoomListViewState { */ export function useRoomListViewModel(): RoomListViewState { const matrixClient = useMatrixClientContext(); - const { - primaryFilters, - activePrimaryFilter, - rooms: filteredRooms, - activateSecondaryFilter, - activeSecondaryFilter, - } = useFilteredRooms(); + const { isLoadingRooms, primaryFilters, activePrimaryFilter, rooms: filteredRooms } = useFilteredRooms(); const { activeIndex, rooms } = useStickyRoomList(filteredRooms); + useRoomListNavigation(rooms); + const currentSpace = useEventEmitterState( SpaceStore.instance, UPDATE_SELECTED_SPACE, @@ -113,25 +83,17 @@ export function useRoomListViewModel(): RoomListViewState { ); const canCreateRoom = hasCreateRoomRights(matrixClient, currentSpace); - const { activeSortOption, sort } = useSorter(); - const { shouldShowMessagePreview, toggleMessagePreview } = useMessagePreviewToggle(); - const createChatRoom = useCallback(() => dispatcher.fire(Action.CreateChat), []); const createRoom = useCallback(() => createRoomFunc(currentSpace), [currentSpace]); return { + isLoadingRooms, rooms, canCreateRoom, createRoom, createChatRoom, primaryFilters, activePrimaryFilter, - activateSecondaryFilter, - activeSecondaryFilter, - activeSortOption, - sort, - shouldShowMessagePreview, - toggleMessagePreview, activeIndex, }; } diff --git a/src/components/viewmodels/roomlist/useFilteredRooms.tsx b/src/components/viewmodels/roomlist/useFilteredRooms.tsx index 5e1554fcdc..736f75aa57 100644 --- a/src/components/viewmodels/roomlist/useFilteredRooms.tsx +++ b/src/components/viewmodels/roomlist/useFilteredRooms.tsx @@ -10,9 +10,10 @@ import { useCallback, 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 from "../../../stores/room-list-v3/RoomListStoreV3"; -import { LISTS_UPDATE_EVENT } from "../../../stores/room-list/RoomListStore"; +import RoomListStoreV3, { LISTS_LOADED_EVENT, LISTS_UPDATE_EVENT } 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. @@ -33,9 +34,8 @@ export interface PrimaryFilter { interface FilteredRooms { primaryFilters: PrimaryFilter[]; + isLoadingRooms: boolean; rooms: Room[]; - activateSecondaryFilter: (filter: SecondaryFilters) => void; - activeSecondaryFilter: SecondaryFilters; /** * The currently active primary filter. * If no primary filter is active, this will be undefined. @@ -45,53 +45,14 @@ interface FilteredRooms { const filterKeyToNameMap: Map = new Map([ [FilterKey.UnreadFilter, _td("room_list|filters|unread")], - [FilterKey.FavouriteFilter, _td("room_list|filters|favourite")], [FilterKey.PeopleFilter, _td("room_list|filters|people")], [FilterKey.RoomsFilter, _td("room_list|filters|rooms")], + [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")], ]); -/** - * These are the secondary filters which are not prominently shown - * in the UI. - */ -export const enum SecondaryFilters { - AllActivity, - MentionsOnly, - InvitesOnly, - LowPriority, -} - -/** - * A map from {@link SecondaryFilters} which the UI understands to - * {@link FilterKey} which the store understands. - */ -const secondaryFiltersToFilterKeyMap = new Map([ - [SecondaryFilters.AllActivity, undefined], - [SecondaryFilters.MentionsOnly, FilterKey.MentionsFilter], - [SecondaryFilters.InvitesOnly, FilterKey.InvitesFilter], - [SecondaryFilters.LowPriority, FilterKey.LowPriorityFilter], -]); - -/** - * Use this function to determine if a given primary filter is compatible with - * a given secondary filter. Practically, this determines whether it makes sense - * to expose two filters together in the UI - for eg, it does not make sense to show the - * favourite primary filter if the active secondary filter is low priority. - * @param primary Primary filter key - * @param secondary Secondary filter key - * @returns true if compatible, false otherwise - */ -function isPrimaryFilterCompatible(primary: FilterKey, secondary: FilterKey): boolean { - if (secondary === FilterKey.MentionsFilter) { - if (primary === FilterKey.UnreadFilter) return false; - } else if (secondary === FilterKey.InvitesFilter) { - if (primary === FilterKey.UnreadFilter || primary === FilterKey.FavouriteFilter) return false; - } else if (secondary === FilterKey.LowPriorityFilter) { - if (primary === FilterKey.FavouriteFilter) return false; - } - return true; -} - /** * Track available filters and provide a filtered list of rooms. */ @@ -101,29 +62,23 @@ export function useFilteredRooms(): FilteredRooms { * rendered above the room list. */ const [primaryFilter, setPrimaryFilter] = useState(); - /** - * Secondary filters are also filters but they are hidden - * away in a popup menu. - */ - const [activeSecondaryFilter, setActiveSecondaryFilter] = useState(SecondaryFilters.AllActivity); - - const secondaryFilter = useMemo( - () => secondaryFiltersToFilterKeyMap.get(activeSecondaryFilter), - [activeSecondaryFilter], - ); const [rooms, setRooms] = 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); }, []); + // 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[] => { - return filterUndefined([primaryFilter, secondaryFilter]); + return filterUndefined([primaryFilter]); }; useEventEmitter(RoomListStoreV3.instance, LISTS_UPDATE_EVENT, () => { @@ -131,29 +86,9 @@ export function useFilteredRooms(): FilteredRooms { updateRoomsFromStore(filters); }); - /** - * Secondary filters are activated using this function. - * This is different to how primary filters work because the secondary - * filters are static i.e they are always available and don't need to be - * hidden. - */ - const activateSecondaryFilter = useCallback( - (filter: SecondaryFilters): void => { - // If the filter is already active, just return. - if (filter === activeSecondaryFilter) return; - - // SecondaryFilter is an enum for the UI, let's convert it to something - // that the store will understand. - const secondary = secondaryFiltersToFilterKeyMap.get(filter); - setActiveSecondaryFilter(filter); - - // Reset any active primary filters. - setPrimaryFilter(undefined); - - updateRoomsFromStore(filterUndefined([secondary])); - }, - [activeSecondaryFilter, updateRoomsFromStore], - ); + useEventEmitter(RoomListStoreV3.instance, LISTS_LOADED_EVENT, () => { + setIsLoadingRooms(false); + }); /** * This tells the view which primary filters are available, how to toggle them @@ -165,7 +100,7 @@ export function useFilteredRooms(): FilteredRooms { toggle: () => { setPrimaryFilter((currentFilter) => { const filter = currentFilter === key ? undefined : key; - updateRoomsFromStore(filterUndefined([filter, secondaryFilter])); + updateRoomsFromStore(filterUndefined([filter])); return filter; }); }, @@ -176,15 +111,17 @@ export function useFilteredRooms(): FilteredRooms { }; const filters: PrimaryFilter[] = []; for (const [key, name] of filterKeyToNameMap.entries()) { - if (secondaryFilter && !isPrimaryFilterCompatible(key, secondaryFilter)) { - continue; - } filters.push(createPrimaryFilter(key, _t(name))); } return filters; - }, [primaryFilter, updateRoomsFromStore, secondaryFilter]); + }, [primaryFilter, updateRoomsFromStore]); const activePrimaryFilter = useMemo(() => primaryFilters.find((filter) => filter.active), [primaryFilters]); - return { primaryFilters, activePrimaryFilter, rooms, activateSecondaryFilter, activeSecondaryFilter }; + return { + isLoadingRooms, + primaryFilters, + activePrimaryFilter, + rooms, + }; } diff --git a/src/components/viewmodels/roomlist/useMessagePreviewToggle.tsx b/src/components/viewmodels/roomlist/useMessagePreviewToggle.tsx index 27ccd40106..efb58b3e04 100644 --- a/src/components/viewmodels/roomlist/useMessagePreviewToggle.tsx +++ b/src/components/viewmodels/roomlist/useMessagePreviewToggle.tsx @@ -4,10 +4,11 @@ * 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, useState } from "react"; +import { useCallback } from "react"; import SettingsStore from "../../../settings/SettingsStore"; import { SettingLevel } from "../../../settings/SettingLevel"; +import { useSettingValue } from "../../../hooks/useSettings"; interface MessagePreviewToggleState { shouldShowMessagePreview: boolean; @@ -20,17 +21,12 @@ interface MessagePreviewToggleState { * - Provides a function to toggle message previews. */ export function useMessagePreviewToggle(): MessagePreviewToggleState { - const [shouldShowMessagePreview, setShouldShowMessagePreview] = useState(() => - SettingsStore.getValue("RoomList.showMessagePreview"), - ); + const shouldShowMessagePreview = useSettingValue("RoomList.showMessagePreview"); const toggleMessagePreview = useCallback((): void => { - setShouldShowMessagePreview((current) => { - const toggled = !current; - SettingsStore.setValue("RoomList.showMessagePreview", null, SettingLevel.DEVICE, toggled); - return toggled; - }); - }, []); + const toggled = !shouldShowMessagePreview; + SettingsStore.setValue("RoomList.showMessagePreview", null, SettingLevel.DEVICE, toggled); + }, [shouldShowMessagePreview]); return { toggleMessagePreview, shouldShowMessagePreview }; } diff --git a/src/components/viewmodels/roomlist/useRoomListNavigation.ts b/src/components/viewmodels/roomlist/useRoomListNavigation.ts new file mode 100644 index 0000000000..5ef979e79c --- /dev/null +++ b/src/components/viewmodels/roomlist/useRoomListNavigation.ts @@ -0,0 +1,56 @@ +/* + * 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 Room } from "matrix-js-sdk/src/matrix"; + +import dispatcher from "../../../dispatcher/dispatcher"; +import { useDispatcher } from "../../../hooks/useDispatcher"; +import { Action } from "../../../dispatcher/actions"; +import { type ViewRoomDeltaPayload } from "../../../dispatcher/payloads/ViewRoomDeltaPayload"; +import type { ViewRoomPayload } from "../../../dispatcher/payloads/ViewRoomPayload"; +import { SdkContextClass } from "../../../contexts/SDKContext"; +import { RoomNotificationStateStore } from "../../../stores/notifications/RoomNotificationStateStore"; + +/** + * Hook to navigate the room list using keyboard shortcuts. + * It listens to the ViewRoomDelta action and updates the room list accordingly. + * @param rooms + */ +export function useRoomListNavigation(rooms: Room[]): void { + useDispatcher(dispatcher, (payload) => { + if (payload.action !== Action.ViewRoomDelta) return; + const roomId = SdkContextClass.instance.roomViewStore.getRoomId(); + if (!roomId) return; + + const { delta, unread } = payload as ViewRoomDeltaPayload; + const filteredRooms = unread + ? // Filter the rooms to only include unread ones and the active room + rooms.filter((room) => { + const state = RoomNotificationStateStore.instance.getRoomState(room); + return room.roomId === roomId || state.isUnread; + }) + : rooms; + + const currentIndex = filteredRooms.findIndex((room) => room.roomId === roomId); + if (currentIndex === -1) return; + + // Get the next/previous new room according to the delta + // Use slice to loop on the list + // If delta is -1 at the start of the list, it will go to the end + // If delta is 1 at the end of the list, it will go to the start + const [newRoom] = filteredRooms.slice((currentIndex + delta) % filteredRooms.length); + if (!newRoom) return; + + dispatcher.dispatch({ + action: Action.ViewRoom, + room_id: newRoom.roomId, + show_room_tile: true, // to make sure the room gets scrolled into view + metricsTrigger: "WebKeyboardShortcut", + metricsViaKeyboard: true, + }); + }); +} diff --git a/src/components/viewmodels/roomlist/useStickyRoomList.tsx b/src/components/viewmodels/roomlist/useStickyRoomList.tsx index e8234d14ae..06feb58581 100644 --- a/src/components/viewmodels/roomlist/useStickyRoomList.tsx +++ b/src/components/viewmodels/roomlist/useStickyRoomList.tsx @@ -5,7 +5,7 @@ * Please see LICENSE files in the repository root for full details. */ -import { useCallback, useEffect, useState } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; import { SdkContextClass } from "../../../contexts/SDKContext"; import { useDispatcher } from "../../../hooks/useDispatcher"; @@ -13,6 +13,7 @@ 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"; function getIndexByRoomId(rooms: Room[], roomId: Optional): number | undefined { const index = rooms.findIndex((room) => room.roomId === roomId); @@ -90,8 +91,10 @@ export function useStickyRoomList(rooms: Room[]): StickyRoomListResult { roomsWithStickyRoom: rooms, }); + const currentSpaceRef = useRef(SpaceStore.instance.activeSpace); + const updateRoomsAndIndex = useCallback( - (newRoomId?: string, isRoomChange: boolean = false) => { + (newRoomId: string | null, isRoomChange: boolean = false) => { setListState((current) => { const activeRoomId = newRoomId ?? SdkContextClass.instance.roomViewStore.getRoomId(); const newActiveIndex = getIndexByRoomId(rooms, activeRoomId); @@ -110,7 +113,21 @@ export function useStickyRoomList(rooms: Room[]): StickyRoomListResult { // Re-calculate the index when the list of rooms has changed. useEffect(() => { - updateRoomsAndIndex(); + let newRoomId: string | null = null; + let isRoomChange = false; + const newSpace = SpaceStore.instance.activeSpace; + if (currentSpaceRef.current !== newSpace) { + /* + 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); + isRoomChange = true; + currentSpaceRef.current = newSpace; + } + updateRoomsAndIndex(newRoomId, isRoomChange); }, [rooms, updateRoomsAndIndex]); return { activeIndex: listState.index, rooms: listState.roomsWithStickyRoom }; diff --git a/src/components/viewmodels/roomlist/utils.ts b/src/components/viewmodels/roomlist/utils.ts index 6220c3b961..dfa20e0d1c 100644 --- a/src/components/viewmodels/roomlist/utils.ts +++ b/src/components/viewmodels/roomlist/utils.ts @@ -27,6 +27,16 @@ export function hasAccessToOptionsMenu(room: Room): boolean { ); } +/** + * Check if the user has access to the notification menu. + * @param room + * @param isGuest + * @param isArchived + */ +export function hasAccessToNotificationMenu(room: Room, isGuest: boolean, isArchived: boolean): boolean { + return !isGuest && !isArchived && hasAccessToOptionsMenu(room); +} + /** * Create a room * @param space - The space to create the room in diff --git a/src/components/views/auth/InteractiveAuthEntryComponents.tsx b/src/components/views/auth/InteractiveAuthEntryComponents.tsx index 321cb15ec3..a9bf8b7597 100644 --- a/src/components/views/auth/InteractiveAuthEntryComponents.tsx +++ b/src/components/views/auth/InteractiveAuthEntryComponents.tsx @@ -11,8 +11,9 @@ import { type InternationalisedPolicy, type Terms, type MatrixClient } from "mat import { AuthType, type AuthDict, type IInputs, type IStageStatus } from "matrix-js-sdk/src/interactive-auth"; import { logger } from "matrix-js-sdk/src/logger"; import React, { type JSX, type ChangeEvent, createRef, type FormEvent, Fragment } from "react"; -import { Button, Text } from "@vector-im/compound-web"; +import { Button } from "@vector-im/compound-web"; import PopOutIcon from "@vector-im/compound-design-tokens/assets/web/icons/pop-out"; +import UserProfileSolidIcon from "@vector-im/compound-design-tokens/assets/web/icons/user-profile-solid"; import EmailPromptIcon from "../../../../res/img/element-icons/email-prompt.svg"; import { _t } from "../../../languageHandler"; @@ -21,8 +22,9 @@ import AccessibleButton, { type AccessibleButtonKind, type ButtonEvent } from ". import Field from "../elements/Field"; import Spinner from "../elements/Spinner"; import CaptchaForm from "./CaptchaForm"; -import { Flex } from "../../utils/Flex"; import { pickBestPolicyLanguage } from "../../../Terms.ts"; +import { EncryptionCardButtons } from "../settings/encryption/EncryptionCardButtons.tsx"; +import { EncryptionCard } from "../settings/encryption/EncryptionCard.tsx"; /* This file contains a collection of components which are used by the * InteractiveAuth to prompt the user to enter the information needed @@ -971,9 +973,14 @@ export class MasUnlockCrossSigningAuthEntry extends FallbackAuthEntry<{ public render(): React.ReactNode { return ( -
- {_t("auth|uia|mas_cross_signing_reset_description")} - + + - - -
+ + ); } } diff --git a/src/components/views/auth/RegistrationForm.tsx b/src/components/views/auth/RegistrationForm.tsx index 783c15ed3f..88e9aa0615 100644 --- a/src/components/views/auth/RegistrationForm.tsx +++ b/src/components/views/auth/RegistrationForm.tsx @@ -127,19 +127,11 @@ export default class RegistrationForm extends React.PureComponent => { - if (confirmed && email !== undefined) { - this.setState( - { - email, - }, - () => { - this.doSubmit(ev); - }, - ); - } - }, + const { finished } = Modal.createDialog(RegistrationEmailPromptDialog); + finished.then(async ([confirmed, email]) => { + if (confirmed && email !== undefined) { + this.setState({ email }, () => this.doSubmit(ev)); + } }); } else { // user can't set an e-mail so don't prompt them to diff --git a/src/components/views/avatars/BaseAvatar.tsx b/src/components/views/avatars/BaseAvatar.tsx index 8aabf60149..0837bf1dc8 100644 --- a/src/components/views/avatars/BaseAvatar.tsx +++ b/src/components/views/avatars/BaseAvatar.tsx @@ -9,7 +9,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 React, { type AriaRole, forwardRef, useCallback, useContext, useEffect, useState } from "react"; +import React, { type AriaRole, type JSX, type Ref, useCallback, useContext, useEffect, useState } from "react"; import classNames from "classnames"; import { ClientEvent, type SyncState } from "matrix-js-sdk/src/matrix"; import { Avatar } from "@vector-im/compound-web"; @@ -34,6 +34,7 @@ interface IProps { tabIndex?: number; altText?: string; role?: AriaRole; + ref?: Ref; } const calculateUrls = (url?: string | null, urls?: string[], lowBandwidth = false): string[] => { @@ -87,7 +88,7 @@ const useImageUrl = ({ url, urls }: { url?: string | null; urls?: string[] }): [ return [imageUrl, onError]; }; -const BaseAvatar = forwardRef((props, ref) => { +const BaseAvatar = (props: IProps): JSX.Element => { const { name, idName, @@ -99,6 +100,7 @@ const BaseAvatar = forwardRef((props, ref) => { className, type = "round", altText = _t("common|avatar"), + ref, ...otherProps } = props; @@ -134,7 +136,7 @@ const BaseAvatar = forwardRef((props, ref) => { data-testid="avatar-img" /> ); -}); +}; export default BaseAvatar; export type BaseAvatarType = React.FC; diff --git a/src/components/views/avatars/DecoratedRoomAvatar.tsx b/src/components/views/avatars/DecoratedRoomAvatar.tsx index c4b17aea53..321c0501dc 100644 --- a/src/components/views/avatars/DecoratedRoomAvatar.tsx +++ b/src/components/views/avatars/DecoratedRoomAvatar.tsx @@ -79,6 +79,9 @@ function tooltipText(variant: Icon): string | undefined { } } +/** + * @deprecated Use {@link RoomAvatarView} instead. + */ export default class DecoratedRoomAvatar extends React.PureComponent { private _dmUser: User | null = null; private isUnmounted = false; diff --git a/src/components/views/avatars/MemberAvatar.tsx b/src/components/views/avatars/MemberAvatar.tsx index aed1e61180..a62594f038 100644 --- a/src/components/views/avatars/MemberAvatar.tsx +++ b/src/components/views/avatars/MemberAvatar.tsx @@ -7,7 +7,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 React, { type JSX, forwardRef, type ReactNode, type Ref, useContext } from "react"; +import React, { type JSX, type ReactNode, type Ref, useContext } from "react"; import { type RoomMember, type ResizeMethod } from "matrix-js-sdk/src/matrix"; import dis from "../../../dispatcher/dispatcher"; @@ -18,6 +18,7 @@ import { CardContext } from "../right_panel/context"; import UserIdentifierCustomisations from "../../../customisations/UserIdentifier"; import { useRoomMemberProfile } from "../../../hooks/room/useRoomMemberProfile"; import { _t } from "../../../languageHandler"; +import MatrixClientContext from "../../../contexts/MatrixClientContext.tsx"; interface IProps extends Omit, "name" | "idName" | "url"> { member: RoomMember | null; @@ -32,21 +33,21 @@ interface IProps extends Omit, "name" | forceHistorical?: boolean; // true to deny `useOnlyCurrentProfiles` usage. Default false. hideTitle?: boolean; children?: ReactNode; + ref?: Ref; } -function MemberAvatar( - { - size, - resizeMethod = "crop", - viewUserOnClick, - forceHistorical, - fallbackUserId, - hideTitle, - member: propsMember, - ...props - }: IProps, - ref: Ref, -): JSX.Element { +export default function MemberAvatar({ + size, + resizeMethod = "crop", + viewUserOnClick, + forceHistorical, + fallbackUserId, + hideTitle, + member: propsMember, + ref, + ...props +}: IProps): JSX.Element { + const cli = useContext(MatrixClientContext); const card = useContext(CardContext); const member = useRoomMemberProfile({ @@ -60,7 +61,7 @@ function MemberAvatar( let imageUrl: string | null | undefined; if (member?.name) { if (member.getMxcAvatarUrl()) { - imageUrl = mediaFromMxc(member.getMxcAvatarUrl() ?? "").getThumbnailOfSourceHttp( + imageUrl = mediaFromMxc(member.getMxcAvatarUrl() ?? "", cli).getThumbnailOfSourceHttp( parseInt(size, 10), parseInt(size, 10), resizeMethod, @@ -99,5 +100,3 @@ function MemberAvatar( /> ); } - -export default forwardRef(MemberAvatar); diff --git a/src/components/views/avatars/RoomAvatar.tsx b/src/components/views/avatars/RoomAvatar.tsx index e1e3e9157d..6490378731 100644 --- a/src/components/views/avatars/RoomAvatar.tsx +++ b/src/components/views/avatars/RoomAvatar.tsx @@ -6,156 +6,92 @@ 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 ComponentProps } from "react"; -import { - type Room, - RoomStateEvent, - type MatrixEvent, - EventType, - RoomType, - KnownMembership, -} from "matrix-js-sdk/src/matrix"; +import React, { useCallback, useMemo, type ComponentProps } from "react"; +import { type Room, RoomType, KnownMembership, EventType } from "matrix-js-sdk/src/matrix"; +import { type RoomAvatarEventContent } from "matrix-js-sdk/src/types"; import BaseAvatar from "./BaseAvatar"; import ImageView from "../elements/ImageView"; -import { MatrixClientPeg } from "../../../MatrixClientPeg"; import Modal from "../../../Modal"; import * as Avatar from "../../../Avatar"; -import DMRoomMap from "../../../utils/DMRoomMap"; import { mediaFromMxc } from "../../../customisations/Media"; import { type IOOBData } from "../../../stores/ThreepidInviteStore"; -import { LocalRoom } from "../../../models/LocalRoom"; import { filterBoolean } from "../../../utils/arrays"; -import SettingsStore from "../../../settings/SettingsStore"; +import { useSettingValue } from "../../../hooks/useSettings"; +import { useRoomState } from "../../../hooks/useRoomState"; +import { useRoomIdName } from "../../../hooks/room/useRoomIdName"; +import { MediaPreviewValue } from "../../../@types/media_preview"; -interface IProps extends Omit, "name" | "idName" | "url" | "onClick"> { +interface IProps extends Omit, "name" | "idName" | "url" | "onClick" | "size"> { // Room may be left unset here, but if it is, // oobData.avatarUrl should be set (else there // would be nowhere to get the avatar from) room?: Room; - oobData: IOOBData & { + // Optional here. + size?: ComponentProps["size"]; + oobData?: IOOBData & { roomId?: string; }; viewAvatarOnClick?: boolean; onClick?(): void; } -interface IState { - urls: string[]; -} +const RoomAvatar: React.FC = ({ room, viewAvatarOnClick, onClick, oobData, size = "36px", ...otherProps }) => { + const roomName = room?.name ?? oobData?.name ?? "?"; + const avatarEvent = useRoomState(room, (state) => state.getStateEvents(EventType.RoomAvatar, "")); + const roomIdName = useRoomIdName(room, oobData); -export function idNameForRoom(room: Room): string { - const dmMapUserId = DMRoomMap.shared().getUserIdForRoomId(room.roomId); - // If the room is a DM, we use the other user's ID for the color hash - // in order to match the room avatar with their avatar - if (dmMapUserId) return dmMapUserId; + const showAvatarsOnInvites = + useSettingValue("mediaPreviewConfig", room?.roomId).invite_avatars === MediaPreviewValue.On; - if (room instanceof LocalRoom && room.targets.length === 1) { - return room.targets[0].userId; - } - - return room.roomId; -} - -export default class RoomAvatar extends React.Component { - public static defaultProps = { - size: "36px", - oobData: {}, - }; - - public constructor(props: IProps) { - super(props); - - this.state = { - urls: RoomAvatar.getImageUrls(this.props), + const onRoomAvatarClick = useCallback(() => { + const avatarUrl = Avatar.avatarUrlForRoom(room ?? null); + if (!avatarUrl) return; + const params = { + src: avatarUrl, + name: room?.name, }; - } - public componentDidMount(): void { - MatrixClientPeg.safeGet().on(RoomStateEvent.Events, this.onRoomStateEvents); - } + Modal.createDialog(ImageView, params, "mx_Dialog_lightbox", undefined, true); + }, [room]); - public componentWillUnmount(): void { - MatrixClientPeg.get()?.removeListener(RoomStateEvent.Events, this.onRoomStateEvents); - } - - public static getDerivedStateFromProps(nextProps: IProps): IState { - return { - urls: RoomAvatar.getImageUrls(nextProps), - }; - } - - private onRoomStateEvents = (ev: MatrixEvent): void => { - if (ev.getRoomId() !== this.props.room?.roomId || ev.getType() !== EventType.RoomAvatar) return; - - this.setState({ - urls: RoomAvatar.getImageUrls(this.props), - }); - }; - - private static getImageUrls(props: IProps): string[] { - const myMembership = props.room?.getMyMembership(); - if (myMembership === KnownMembership.Invite || !myMembership) { - if (SettingsStore.getValue("showAvatarsOnInvites") === false) { - // The user has opted out of showing avatars, so return no urls here. - return []; - } + const urls = useMemo(() => { + const myMembership = room?.getMyMembership(); + if (!showAvatarsOnInvites && (myMembership === KnownMembership.Invite || !myMembership)) { + // The user has opted out of showing avatars, so return no urls here. + return []; } + + // parseInt ignores suffixes. + const sizeInt = parseInt(size, 10); let oobAvatar: string | null = null; - if (props.oobData.avatarUrl) { - oobAvatar = mediaFromMxc(props.oobData.avatarUrl).getThumbnailOfSourceHttp( - parseInt(props.size, 10), - parseInt(props.size, 10), - "crop", - ); + if (oobData?.avatarUrl) { + oobAvatar = mediaFromMxc(oobData?.avatarUrl).getThumbnailOfSourceHttp(sizeInt, sizeInt, "crop"); } return filterBoolean([ oobAvatar, // highest priority - RoomAvatar.getRoomAvatarUrl(props), + Avatar.avatarUrlForRoom( + room ?? null, + sizeInt, + sizeInt, + "crop", + avatarEvent?.getContent().url, + ), ]); - } + }, [showAvatarsOnInvites, room, size, avatarEvent, oobData]); - private static getRoomAvatarUrl(props: IProps): string | null { - if (!props.room) return null; + return ( + + ); +}; - return Avatar.avatarUrlForRoom(props.room, parseInt(props.size, 10), parseInt(props.size, 10), "crop"); - } - - private onRoomAvatarClick = (): void => { - const avatarUrl = Avatar.avatarUrlForRoom(this.props.room ?? null, undefined, undefined, undefined); - if (!avatarUrl) return; - const params = { - src: avatarUrl, - name: this.props.room?.name, - }; - - Modal.createDialog(ImageView, params, "mx_Dialog_lightbox", undefined, true); - }; - - private get roomIdName(): string | undefined { - const room = this.props.room; - - if (room) { - return idNameForRoom(room); - } else { - return this.props.oobData?.roomId; - } - } - - public render(): React.ReactNode { - const { room, oobData, viewAvatarOnClick, onClick, className, ...otherProps } = this.props; - const roomName = room?.name ?? oobData.name ?? "?"; - - return ( - - ); - } -} +export default RoomAvatar; diff --git a/src/components/views/avatars/RoomAvatarView.tsx b/src/components/views/avatars/RoomAvatarView.tsx new file mode 100644 index 0000000000..5ddf355d6f --- /dev/null +++ b/src/components/views/avatars/RoomAvatarView.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 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 RoomAvatar from "./RoomAvatar"; +import { AvatarBadgeDecoration, useRoomAvatarViewModel } from "../../viewmodels/avatars/RoomAvatarViewModel"; +import { _t } from "../../../languageHandler"; +import { Presence } from "./WithPresenceIndicator"; + +interface RoomAvatarViewProps { + /** + * The room to display the avatar for. + */ + room: Room; +} + +/** + * Component to display the avatar of a room. + * Currently only 32px size is supported. + */ +export function RoomAvatarView({ room }: RoomAvatarViewProps): JSX.Element { + const vm = useRoomAvatarViewModel(room); + // No decoration, we just show the avatar + if (!vm.badgeDecoration) return ; + + const icon = getAvatarDecoration(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 ( +
+ + {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. + */ +function PresenceDecoration({ presence }: PresenceDecorationProps): JSX.Element { + switch (presence) { + case Presence.Online: + return ( + + ); + case Presence.Away: + return ( + + ); + case Presence.Offline: + return ( + + ); + case Presence.Busy: + return ( + + ); + } +} + +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 ; + } +} diff --git a/src/components/views/avatars/WithPresenceIndicator.tsx b/src/components/views/avatars/WithPresenceIndicator.tsx index a08250a17c..4c4caecd69 100644 --- a/src/components/views/avatars/WithPresenceIndicator.tsx +++ b/src/components/views/avatars/WithPresenceIndicator.tsx @@ -26,7 +26,7 @@ interface Props { children: ReactNode; } -enum Presence { +export enum Presence { // Note: the names here are used in CSS class names Online = "ONLINE", Away = "AWAY", @@ -52,14 +52,14 @@ function getDmMember(room: Room): RoomMember | null { return otherUserId ? room.getMember(otherUserId) : null; } -export const useDmMember = (room: Room): RoomMember | null => { - const [dmMember, setDmMember] = useState(getDmMember(room)); +export const useDmMember = (room?: Room): RoomMember | null => { + const [dmMember, setDmMember] = useState(room ? getDmMember(room) : null); const updateDmMember = (): void => { - setDmMember(getDmMember(room)); + setDmMember(room ? getDmMember(room) : null); }; - useEventEmitter(room.currentState, RoomStateEvent.Members, updateDmMember); - useEventEmitter(room.client, ClientEvent.AccountData, updateDmMember); + useEventEmitter(room?.currentState, RoomStateEvent.Members, updateDmMember); + useEventEmitter(room?.client, ClientEvent.AccountData, updateDmMember); useEffect(updateDmMember, [room]); return dmMember; @@ -86,7 +86,7 @@ function getPresence(member: RoomMember | null): Presence | null { return null; } -const usePresence = (room: Room, member: RoomMember | null): Presence | null => { +export const usePresence = (room: Room, member: RoomMember | null): Presence | null => { const [presence, setPresence] = useState(getPresence(member)); const updatePresence = (): void => { setPresence(getPresence(member)); diff --git a/src/components/views/context_menus/DeveloperToolsOption.tsx b/src/components/views/context_menus/DeveloperToolsOption.tsx index fc77a2e284..53a5d7283d 100644 --- a/src/components/views/context_menus/DeveloperToolsOption.tsx +++ b/src/components/views/context_menus/DeveloperToolsOption.tsx @@ -25,7 +25,6 @@ export const DeveloperToolsOption: React.FC = ({ onFinished, roomId }) => Modal.createDialog( DevtoolsDialog, { - onFinished: () => {}, roomId: roomId, }, "mx_DevtoolsDialog_wrapper", diff --git a/src/components/views/context_menus/WidgetContextMenu.tsx b/src/components/views/context_menus/WidgetContextMenu.tsx index 627e0814e2..5b03d54e17 100644 --- a/src/components/views/context_menus/WidgetContextMenu.tsx +++ b/src/components/views/context_menus/WidgetContextMenu.tsx @@ -187,14 +187,15 @@ export const WidgetContextMenu: React.FC = ({ onDeleteClick(); } else if (roomId) { // Show delete confirmation dialog - Modal.createDialog(QuestionDialog, { + const { finished } = Modal.createDialog(QuestionDialog, { title: _t("widget|context_menu|delete"), description: _t("widget|context_menu|delete_warning"), button: _t("widget|context_menu|delete"), - onFinished: (confirmed) => { - if (!confirmed) return; - WidgetUtils.setRoomWidget(cli, roomId, app.id); - }, + }); + + finished.then(([confirmed]) => { + if (!confirmed) return; + WidgetUtils.setRoomWidget(cli, roomId, app.id); }); } diff --git a/src/components/views/dialogs/AnalyticsLearnMoreDialog.tsx b/src/components/views/dialogs/AnalyticsLearnMoreDialog.tsx index 90681556e8..d80f03b7d4 100644 --- a/src/components/views/dialogs/AnalyticsLearnMoreDialog.tsx +++ b/src/components/views/dialogs/AnalyticsLearnMoreDialog.tsx @@ -11,7 +11,7 @@ import React from "react"; import BaseDialog from "./BaseDialog"; import { _t } from "../../../languageHandler"; import DialogButtons from "../elements/DialogButtons"; -import Modal, { type ComponentProps } from "../../../Modal"; +import Modal, { type ComponentProps, type IHandle } from "../../../Modal"; import SdkConfig from "../../../SdkConfig"; import { getPolicyUrl } from "../../../toasts/AnalyticsToast"; import ExternalLink from "../elements/ExternalLink"; @@ -91,10 +91,10 @@ export const AnalyticsLearnMoreDialog: React.FC = ({ export const showDialog = ( props: Omit, "cookiePolicyUrl" | "analyticsOwner">, -): void => { +): IHandle => { const privacyPolicyUrl = getPolicyUrl(); const analyticsOwner = SdkConfig.get("analytics_owner") ?? SdkConfig.get("brand"); - Modal.createDialog( + return Modal.createDialog( AnalyticsLearnMoreDialog, { privacyPolicyUrl, diff --git a/src/components/views/dialogs/BaseDialog.tsx b/src/components/views/dialogs/BaseDialog.tsx index bf23919771..56f6d0c528 100644 --- a/src/components/views/dialogs/BaseDialog.tsx +++ b/src/components/views/dialogs/BaseDialog.tsx @@ -23,13 +23,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 +78,6 @@ interface IProps { // optional Posthog ScreenName to supply during the lifetime of this dialog "screenName"?: ScreenName; - onFinished(): void; } /* @@ -103,13 +114,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 { diff --git a/src/components/views/dialogs/ConfirmKeyStorageOffDialog.tsx b/src/components/views/dialogs/ConfirmKeyStorageOffDialog.tsx new file mode 100644 index 0000000000..49e7cad17b --- /dev/null +++ b/src/components/views/dialogs/ConfirmKeyStorageOffDialog.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 from "react"; +import ErrorIcon from "@vector-im/compound-design-tokens/assets/web/icons/error"; +import { Button } from "@vector-im/compound-web"; +import { PopOutIcon } from "@vector-im/compound-design-tokens/assets/web/icons"; + +import { _t } from "../../../languageHandler"; +import defaultDispatcher from "../../../dispatcher/dispatcher"; +import { EncryptionCard } from "../settings/encryption/EncryptionCard"; +import { EncryptionCardButtons } from "../settings/encryption/EncryptionCardButtons"; +import { type OpenToTabPayload } from "../../../dispatcher/payloads/OpenToTabPayload"; +import { Action } from "../../../dispatcher/actions"; +import { UserTab } from "./UserTab"; + +interface Props { + onFinished: (dismissed: boolean) => void; +} + +/** + * Ask the user whether they really want to dismiss the toast about key storage. + * + * Launched from the {@link SetupEncryptionToast} in mode `TURN_ON_KEY_STORAGE`, + * when the user clicks "Dismiss". The caller handles any action via the + * `onFinished` prop which takes a boolean that is true if the user clicked + * "Yes, dismiss". + */ +export default class ConfirmKeyStorageOffDialog extends React.Component { + public constructor(props: Props) { + super(props); + } + + private onGoToSettingsClick = (): void => { + // Open Settings at the Encryption tab + const payload: OpenToTabPayload = { + action: Action.ViewUserSettings, + initialTabId: UserTab.Encryption, + }; + defaultDispatcher.dispatch(payload); + this.props.onFinished(false); + }; + + private onDismissClick = (): void => { + this.props.onFinished(true); + }; + + public render(): React.ReactNode { + return ( + + {_t("settings|encryption|confirm_key_storage_off_description", undefined, { + a: (sub) => ( + <> +
+ + {sub} + + + ), + })} + + + + +
+ ); + } +} diff --git a/src/components/views/dialogs/ConfirmRedactDialog.tsx b/src/components/views/dialogs/ConfirmRedactDialog.tsx index d3fba9b475..17bf75ccad 100644 --- a/src/components/views/dialogs/ConfirmRedactDialog.tsx +++ b/src/components/views/dialogs/ConfirmRedactDialog.tsx @@ -58,37 +58,32 @@ export function createRedactEventDialog({ const roomId = mxEvent.getRoomId(); if (!roomId) throw new Error(`cannot redact event ${mxEvent.getId()} without room ID`); - Modal.createDialog( - ConfirmRedactDialog, - { - event: mxEvent, - onFinished: async (proceed, reason): Promise => { - if (!proceed) return; + const { finished } = Modal.createDialog(ConfirmRedactDialog, { event: mxEvent }, "mx_Dialog_confirmredact"); - const cli = MatrixClientPeg.safeGet(); - const withRelTypes: Pick = {}; + finished.then(async ([proceed, reason]) => { + if (!proceed) return; - try { - onCloseDialog?.(); - await cli.redactEvent(roomId, eventId, undefined, { - ...(reason ? { reason } : {}), - ...withRelTypes, - }); - } catch (e: any) { - const code = e.errcode || e.statusCode; - // only show the dialog if failing for something other than a network error - // (e.g. no errcode or statusCode) as in that case the redactions end up in the - // detached queue and we show the room status bar to allow retry - if (typeof code !== "undefined") { - // display error message stating you couldn't delete this. - Modal.createDialog(ErrorDialog, { - title: _t("common|error"), - description: _t("redact|error", { code }), - }); - } - } - }, - }, - "mx_Dialog_confirmredact", - ); + const cli = MatrixClientPeg.safeGet(); + const withRelTypes: Pick = {}; + + try { + onCloseDialog?.(); + await cli.redactEvent(roomId, eventId, undefined, { + ...(reason ? { reason } : {}), + ...withRelTypes, + }); + } catch (e: any) { + const code = e.errcode || e.statusCode; + // only show the dialog if failing for something other than a network error + // (e.g. no errcode or statusCode) as in that case the redactions end up in the + // detached queue and we show the room status bar to allow retry + if (typeof code !== "undefined") { + // display error message stating you couldn't delete this. + Modal.createDialog(ErrorDialog, { + title: _t("common|error"), + description: _t("redact|error", { code }), + }); + } + } + }); } diff --git a/src/components/views/dialogs/DeclineAndBlockInviteDialog.tsx b/src/components/views/dialogs/DeclineAndBlockInviteDialog.tsx new file mode 100644 index 0000000000..a10dfbe42b --- /dev/null +++ b/src/components/views/dialogs/DeclineAndBlockInviteDialog.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 React, { type ChangeEventHandler, useCallback, useState } from "react"; +import { Field, Label, Root } from "@vector-im/compound-web"; + +import { _t } from "../../../languageHandler"; +import BaseDialog from "./BaseDialog"; +import DialogButtons from "../elements/DialogButtons"; +import LabelledToggleSwitch from "../elements/LabelledToggleSwitch"; + +interface IProps { + onFinished: (shouldReject: boolean, ignoreUser: boolean, reportRoom: false | string) => void; + roomName: string; +} + +export const DeclineAndBlockInviteDialog: React.FunctionComponent = ({ onFinished, roomName }) => { + const [shouldReport, setShouldReport] = useState(false); + const [ignoreUser, setIgnoreUser] = useState(false); + + const [reportReason, setReportReason] = useState(""); + const reportReasonChanged = useCallback>( + (e) => setReportReason(e.target.value), + [setReportReason], + ); + + const onCancel = useCallback(() => onFinished(false, false, false), [onFinished]); + const onOk = useCallback( + () => onFinished(true, ignoreUser, shouldReport ? reportReason : false), + [onFinished, ignoreUser, shouldReport, reportReason], + ); + + return ( + + +

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

+ + + + + +
+
+ + + + +
+ +
+
+
+
+`; diff --git a/test/unit-tests/components/views/dialogs/__snapshots__/DevtoolsDialog-test.tsx.snap b/test/unit-tests/components/views/dialogs/__snapshots__/DevtoolsDialog-test.tsx.snap index b468763bbe..2342bab71d 100644 --- a/test/unit-tests/components/views/dialogs/__snapshots__/DevtoolsDialog-test.tsx.snap +++ b/test/unit-tests/components/views/dialogs/__snapshots__/DevtoolsDialog-test.tsx.snap @@ -198,7 +198,7 @@ exports[`DevtoolsDialog renders the devtools dialog 1`] = ` > @@ -207,7 +207,7 @@ exports[`DevtoolsDialog renders the devtools dialog 1`] = ` > should list spaces which are not par class="_container_1hel1_10" > should list spaces which are not par 0 members diff --git a/test/unit-tests/components/views/dialogs/__snapshots__/ManualDeviceKeyVerificationDialog-test.tsx.snap b/test/unit-tests/components/views/dialogs/__snapshots__/ManualDeviceKeyVerificationDialog-test.tsx.snap new file mode 100644 index 0000000000..75c2870126 --- /dev/null +++ b/test/unit-tests/components/views/dialogs/__snapshots__/ManualDeviceKeyVerificationDialog-test.tsx.snap @@ -0,0 +1,107 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`ManualDeviceKeyVerificationDialog should render correctly 1`] = ` + +
+