diff --git a/.eslintignore b/.eslintignore index e1b0ceb50c..08ec761fb3 100644 --- a/.eslintignore +++ b/.eslintignore @@ -7,3 +7,7 @@ test/end-to-end-tests/lib/ src/component-index.js # Auto-generated file src/modules.ts +src/modules.js +# Test result files +/playwright/test-results/ +/playwright/html-report/ diff --git a/.eslintrc.js b/.eslintrc.js index 2b0dd2c186..892d7cdbb1 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -1,5 +1,5 @@ module.exports = { - plugins: ["matrix-org"], + plugins: ["matrix-org", "eslint-plugin-react-compiler"], extends: ["plugin:matrix-org/babel", "plugin:matrix-org/react", "plugin:matrix-org/a11y"], parserOptions: { project: ["./tsconfig.json"], @@ -170,6 +170,8 @@ module.exports = { "jsx-a11y/role-supports-aria-props": "off", "matrix-org/require-copyright-header": "error", + + "react-compiler/react-compiler": "error", }, overrides: [ { @@ -198,8 +200,13 @@ module.exports = { "@typescript-eslint/ban-ts-comment": "off", // We're okay with assertion errors when we ask for them "@typescript-eslint/no-non-null-assertion": "off", - // We do this sometimes to brand interfaces - "@typescript-eslint/no-empty-object-type": "off", + "@typescript-eslint/no-empty-object-type": [ + "error", + { + // We do this sometimes to brand interfaces + allowInterfaces: "with-single-extends", + }, + ], }, }, // temporary override for offending icon require files @@ -245,6 +252,7 @@ module.exports = { // We don't need super strict typing in test utilities "@typescript-eslint/explicit-function-return-type": "off", "@typescript-eslint/explicit-member-accessibility": "off", + "@typescript-eslint/no-empty-object-type": "off", // Jest/Playwright specific @@ -262,6 +270,7 @@ module.exports = { // These are fine in tests "no-restricted-globals": "off", + "react-compiler/react-compiler": "off", }, }, { @@ -271,6 +280,7 @@ module.exports = { }, rules: { "react-hooks/rules-of-hooks": ["off"], + "@typescript-eslint/no-floating-promises": ["error"], }, }, { diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 695a94254e..34431799f4 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -3,13 +3,19 @@ /package.json @element-hq/element-web-team /yarn.lock @element-hq/element-web-team -/src/SecurityManager.ts @element-hq/element-crypto-web-reviewers -/test/SecurityManager-test.ts @element-hq/element-crypto-web-reviewers -/src/async-components/views/dialogs/security/ @element-hq/element-crypto-web-reviewers -/src/components/views/dialogs/security/ @element-hq/element-crypto-web-reviewers -/test/components/views/dialogs/security/ @element-hq/element-crypto-web-reviewers -/src/stores/SetupEncryptionStore.ts @element-hq/element-crypto-web-reviewers -/test/stores/SetupEncryptionStore-test.ts @element-hq/element-crypto-web-reviewers +/src/SecurityManager.ts @element-hq/element-crypto-web-reviewers +/test/SecurityManager-test.ts @element-hq/element-crypto-web-reviewers +/src/async-components/views/dialogs/security/ @element-hq/element-crypto-web-reviewers +/src/components/views/dialogs/security/ @element-hq/element-crypto-web-reviewers +/test/components/views/dialogs/security/ @element-hq/element-crypto-web-reviewers +/src/stores/SetupEncryptionStore.ts @element-hq/element-crypto-web-reviewers +/test/stores/SetupEncryptionStore-test.ts @element-hq/element-crypto-web-reviewers +/src/components/views/settings/tabs/user/EncryptionUserSettingsTab.tsx @element-hq/element-crypto-web-reviewers +/src/components/views/settings/encryption/ @element-hq/element-crypto-web-reviewers +/test/unit-tests/components/views/settings/encryption/ @element-hq/element-crypto-web-reviewers +/src/components/views/dialogs/devtools/Crypto.tsx @element-hq/element-crypto-web-reviewers +/playwright/e2e/crypto/ @element-hq/element-crypto-web-reviewers +/playwright/e2e/settings/encryption-user-tab/ @element-hq/element-crypto-web-reviewers # Ignore translations as those will be updated by GHA for Localazy download /src/i18n/strings diff --git a/.github/labels.yml b/.github/labels.yml index c8a34c4771..f8adbe8e53 100644 --- a/.github/labels.yml +++ b/.github/labels.yml @@ -235,6 +235,15 @@ - name: "Z-Flaky-Test" description: "A test is raising false alarms" color: "ededed" +- name: "Z-Flaky-Test-Chrome" + description: "Flaky playwright test in Chrome" + color: "ededed" +- name: "Z-Flaky-Test-Firefox" + description: "Flaky playwright test in Firefox" + color: "ededed" +- name: "Z-Flaky-Test-Webkit" + description: "Flaky playwright test in Webkit" + color: "ededed" - name: "Z-Flaky-Jest-Test" description: "A Jest test is raising false alarms" color: "ededed" diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 7e3b820762..21a6b0e7ab 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -27,10 +27,17 @@ jobs: - macos-14 isDevelop: - ${{ github.event_name == 'push' && github.ref_name == 'develop' }} + isPullRequest: + - ${{ github.event_name == 'pull_request' }} # Skip the ubuntu-24.04 build for the develop branch as the dedicated CD build_develop workflow handles that + # Skip the non-linux builds for pull requests as Windows is awfully slow, so run in merge queue only exclude: - isDevelop: true image: ubuntu-24.04 + - isPullRequest: true + image: windows-2022 + - isPullRequest: true + image: macos-14 runs-on: ${{ matrix.image }} defaults: run: diff --git a/.github/workflows/build_develop.yml b/.github/workflows/build_develop.yml index 8bbcfe726f..55091363d7 100644 --- a/.github/workflows/build_develop.yml +++ b/.github/workflows/build_develop.yml @@ -26,6 +26,12 @@ jobs: R2_URL: ${{ vars.CF_R2_S3_API }} R2_PUBLIC_URL: "https://element-web-develop.element.io" steps: + # Workaround for https://www.cloudflarestatus.com/incidents/t5nrjmpxc1cj + - uses: unfor19/install-aws-cli-action@v1 + with: + version: 2.22.35 + verbose: false + arch: amd64 - uses: actions/checkout@v4 - uses: actions/setup-node@v4 diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 2b3603549d..14fbd22086 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -96,3 +96,4 @@ jobs: projectName: ${{ env.SITE == 'staging.element.io' && 'element-web-staging' || 'element-web' }} directory: _deploy gitHubToken: ${{ secrets.GITHUB_TOKEN }} + branch: main diff --git a/.github/workflows/docker.yaml b/.github/workflows/docker.yaml new file mode 100644 index 0000000000..35630e4c2e --- /dev/null +++ b/.github/workflows/docker.yaml @@ -0,0 +1,141 @@ +name: Docker +on: + workflow_dispatch: {} + push: + tags: [v*] + pull_request: {} + schedule: + # This job can take a while, and we have usage limits, so just publish develop only twice a day + - cron: "0 7/12 * * *" +concurrency: ${{ github.workflow }}-${{ github.ref_name }} +permissions: {} +jobs: + buildx: + name: Docker Buildx + runs-on: ubuntu-24.04 + environment: ${{ github.event_name != 'pull_request' && 'dockerhub' || '' }} + permissions: + id-token: write # needed for signing the images with GitHub OIDC Token + packages: write # needed for publishing packages to GHCR + env: + TEST_TAG: vectorim/element-web:test + steps: + - uses: actions/checkout@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 + if: github.event_name != 'pull_request' + + - name: Set up QEMU + uses: docker/setup-qemu-action@29109295f81e9208d7d86ff1c6c12d2833863392 # v3 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@b5ca514318bd6ebac0fb2aedd5d36ec1b5c232a2 # v3 + with: + install: true + + - name: Login to Docker Hub + uses: docker/login-action@9780b0c442fbb1117ed29e0efdff1e18412f7567 # v3 + if: github.event_name != 'pull_request' + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + - name: Login to GitHub Container Registry + uses: docker/login-action@9780b0c442fbb1117ed29e0efdff1e18412f7567 # v3 + if: github.event_name != 'pull_request' + with: + registry: ghcr.io + username: ${{ github.repository_owner }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Build and load + id: test-build + uses: docker/build-push-action@471d1dc4e07e5cdedd4c2171150001c434f0b7a4 # v6 + with: + context: . + load: true + + - name: Test the image + env: + IMAGEID: ${{ steps.test-build.outputs.imageid }} + timeout-minutes: 2 + run: | + set -x + + # Make a fake module to test the image + MODULE_PATH="modules/module_name/index.js" + mkdir -p $(dirname $MODULE_PATH) + echo 'alert("Testing");' > $MODULE_PATH + + # Spin up a container of the image + ELEMENT_WEB_PORT=8181 + CONTAINER_ID=$( + docker run \ + --rm \ + -e "ELEMENT_WEB_PORT=$ELEMENT_WEB_PORT" \ + -dp "$ELEMENT_WEB_PORT:$ELEMENT_WEB_PORT" \ + -v $(pwd)/modules:/modules \ + "$IMAGEID" \ + ) + + # Run some smoke tests + wget --retry-connrefused --tries=5 -q --wait=3 --spider "http://localhost:$ELEMENT_WEB_PORT/modules/module_name/index.js" + MODULE_0=$(curl "http://localhost:$ELEMENT_WEB_PORT/config.json" | jq -r .modules[0]) + test "$MODULE_0" = "/${MODULE_PATH}" + + # Check healthcheck + until test "$(docker inspect -f {{.State.Health.Status}} $CONTAINER_ID)" == "healthy"; do + sleep 1 + done + + # Clean up + docker stop "$CONTAINER_ID" + + - name: Docker meta + id: meta + uses: docker/metadata-action@902fa8ec7d6ecbf8d84d538b9b233a880e428804 # v5 + if: github.event_name != 'pull_request' + with: + images: | + vectorim/element-web + ghcr.io/element-hq/element-web + tags: | + type=ref,event=branch + type=ref,event=tag + flavor: | + latest=${{ contains(github.ref_name, '-rc.') && 'false' || 'auto' }} + + - name: Build and push + id: build-and-push + uses: docker/build-push-action@471d1dc4e07e5cdedd4c2171150001c434f0b7a4 # v6 + if: github.event_name != 'pull_request' + with: + context: . + push: true + platforms: linux/amd64,linux/arm64 + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + + - name: Sign the images with GitHub OIDC Token + env: + DIGEST: ${{ steps.build-and-push.outputs.digest }} + TAGS: ${{ steps.meta.outputs.tags }} + if: github.event_name != 'pull_request' + run: | + images="" + for tag in ${TAGS}; do + images+="${tag}@${DIGEST} " + done + cosign sign --yes ${images} + + - name: Update repo description + uses: peter-evans/dockerhub-description@e98e4d1628a5f3be2be7c231e50981aee98723ae # v4 + if: github.event_name != 'pull_request' + continue-on-error: true + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + repository: vectorim/element-web diff --git a/.github/workflows/dockerhub.yaml b/.github/workflows/dockerhub.yaml deleted file mode 100644 index 6cf8b44876..0000000000 --- a/.github/workflows/dockerhub.yaml +++ /dev/null @@ -1,79 +0,0 @@ -name: Dockerhub -on: - workflow_dispatch: {} - push: - tags: [v*] - schedule: - # This job can take a while, and we have usage limits, so just publish develop only twice a day - - cron: "0 7/12 * * *" -concurrency: ${{ github.workflow }}-${{ github.ref_name }} -permissions: {} -jobs: - buildx: - name: Docker Buildx - runs-on: ubuntu-24.04 - environment: dockerhub - permissions: - id-token: write # needed for signing the images with GitHub OIDC Token - steps: - - uses: actions/checkout@v4 - with: - fetch-depth: 0 # needed for docker-package to be able to calculate the version - - - name: Install Cosign - uses: sigstore/cosign-installer@dc72c7d5c4d10cd6bcb8cf6e3fd625a9e5e537da # v3 - - - name: Set up QEMU - uses: docker/setup-qemu-action@53851d14592bedcffcf25ea515637cff71ef929a # v3 - - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@6524bf65af31da8d45b59e8c27de4bd072b392f5 # v3 - with: - install: true - - - name: Login to Docker Hub - uses: docker/login-action@9780b0c442fbb1117ed29e0efdff1e18412f7567 # v3 - with: - username: ${{ secrets.DOCKERHUB_USERNAME }} - password: ${{ secrets.DOCKERHUB_TOKEN }} - - - name: Docker meta - id: meta - uses: docker/metadata-action@369eb591f429131d6889c46b94e711f089e6ca96 # v5 - with: - images: | - vectorim/element-web - tags: | - type=ref,event=branch - type=ref,event=tag - flavor: | - latest=${{ contains(github.ref_name, '-rc.') && 'false' || 'auto' }} - - - name: Build and push - id: build-and-push - uses: docker/build-push-action@b32b51a8eda65d6793cd0494a773d4f6bcef32dc # v6 - with: - context: . - push: true - platforms: linux/amd64,linux/arm64 - tags: ${{ steps.meta.outputs.tags }} - labels: ${{ steps.meta.outputs.labels }} - - - name: Sign the images with GitHub OIDC Token - env: - DIGEST: ${{ steps.build-and-push.outputs.digest }} - TAGS: ${{ steps.meta.outputs.tags }} - run: | - images="" - for tag in ${TAGS}; do - images+="${tag}@${DIGEST} " - done - cosign sign --yes ${images} - - - name: Update repo description - uses: peter-evans/dockerhub-description@e98e4d1628a5f3be2be7c231e50981aee98723ae # v4 - continue-on-error: true - with: - username: ${{ secrets.DOCKERHUB_USERNAME }} - password: ${{ secrets.DOCKERHUB_TOKEN }} - repository: vectorim/element-web diff --git a/.github/workflows/end-to-end-tests.yaml b/.github/workflows/end-to-end-tests.yaml index 0a7d95e6dc..34b778da99 100644 --- a/.github/workflows/end-to-end-tests.yaml +++ b/.github/workflows/end-to-end-tests.yaml @@ -127,6 +127,8 @@ jobs: - Chrome - Firefox - WebKit + - Dendrite + - Pinecone runAllTests: - ${{ github.event_name == 'schedule' || contains(github.event.pull_request.labels.*.name, 'X-Run-All-Tests') }} # Skip the Firefox & Safari runs unless this was a cron trigger or PR has X-Run-All-Tests label @@ -135,6 +137,10 @@ jobs: project: Firefox - runAllTests: false project: WebKit + - runAllTests: false + project: Dendrite + - runAllTests: false + project: Pinecone env: SHARD_BLOB_NAME: blob-report-${{ matrix.project }}-${{ matrix.runner }} steps: diff --git a/.github/workflows/playwright-image-updates.yaml b/.github/workflows/playwright-image-updates.yaml index e5e2f739c0..7681600dde 100644 --- a/.github/workflows/playwright-image-updates.yaml +++ b/.github/workflows/playwright-image-updates.yaml @@ -23,7 +23,7 @@ jobs: - name: Create Pull Request id: cpr - uses: peter-evans/create-pull-request@67ccf781d68cd99b580ae25a5c18a1cc84ffff1f # v7 + uses: peter-evans/create-pull-request@271a8d0340265f705b14b6d32b9829c1cb33d45e # v7 with: token: ${{ secrets.ELEMENT_BOT_TOKEN }} branch: actions/playwright-image-updates diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 019bc1b9ce..78383e8bf5 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -19,6 +19,7 @@ jobs: contents: write issues: write pull-requests: read + id-token: write secrets: ELEMENT_BOT_TOKEN: ${{ secrets.ELEMENT_BOT_TOKEN }} GPG_PRIVATE_KEY: ${{ secrets.GPG_PRIVATE_KEY }} @@ -50,7 +51,7 @@ jobs: permissions: checks: read steps: - - name: Wait for dockerhub + - name: Wait for docker build uses: t3chguy/wait-on-check-action@18541021811b56544d90e0f073401c2b99e249d6 # fork with: ref: master diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 209a2a5d82..2d08c116aa 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -121,7 +121,7 @@ jobs: - name: Skip SonarCloud in merge queue if: github.event_name == 'merge_group' || inputs.disable_coverage == 'true' - uses: guibranco/github-status-action-v2@56cd38caf0615dd03f49d42ed301f1469911ac61 + uses: guibranco/github-status-action-v2@5ef6e175c333bc629f3718b083c8a2ff6e0bbfbc with: authToken: ${{ secrets.GITHUB_TOKEN }} state: success diff --git a/.github/workflows/triage-stale-flaky-tests.yml b/.github/workflows/triage-stale-flaky-tests.yml deleted file mode 100644 index 3d3bcb0b13..0000000000 --- a/.github/workflows/triage-stale-flaky-tests.yml +++ /dev/null @@ -1,21 +0,0 @@ -name: Close stale flaky issues -on: - workflow_dispatch: {} - schedule: - - cron: "30 1 * * *" -permissions: {} -jobs: - close: - runs-on: ubuntu-24.04 - permissions: - actions: write - issues: write - steps: - - uses: actions/stale@v9 - with: - only-labels: "Z-Flaky-Test" - days-before-stale: 14 - days-before-close: 0 - close-issue-message: "This flaky test issue has not been updated in 14 days. It is being closed as presumed resolved." - exempt-issue-labels: "Z-Flaky-Test-Disabled" - operations-per-run: 100 diff --git a/.github/workflows/triage-stale.yml b/.github/workflows/triage-stale.yml new file mode 100644 index 0000000000..b52f4e59da --- /dev/null +++ b/.github/workflows/triage-stale.yml @@ -0,0 +1,27 @@ +name: Close stale issues & PRs +on: + workflow_dispatch: {} + schedule: + - cron: "30 1 * * *" +permissions: {} +jobs: + close: + runs-on: ubuntu-24.04 + permissions: + actions: write + issues: write + pull-requests: write + steps: + - uses: actions/stale@v9 + with: + operations-per-run: 100 + # Flaky test issue closing + only-issue-labels: "Z-Flaky-Test" + days-before-issue-stale: 14 + days-before-issue-close: 0 + close-issue-message: "This flaky test issue has not been updated in 14 days. It is being closed as presumed resolved." + exempt-issue-labels: "Z-Flaky-Test-Disabled" + # Stale PR closing + days-before-pr-stale: 180 + days-before-pr-close: 0 + close-pr-message: "This PR has been automatically closed because it has been stale for 180 days. If you wish to continue working on this PR, please ping a maintainer to reopen it." diff --git a/.github/workflows/update-jitsi.yml b/.github/workflows/update-jitsi.yml index a3abcb002f..f4fd13892b 100644 --- a/.github/workflows/update-jitsi.yml +++ b/.github/workflows/update-jitsi.yml @@ -23,7 +23,7 @@ jobs: run: "yarn update:jitsi" - name: Create Pull Request - uses: peter-evans/create-pull-request@67ccf781d68cd99b580ae25a5c18a1cc84ffff1f # v7 + uses: peter-evans/create-pull-request@271a8d0340265f705b14b6d32b9829c1cb33d45e # v7 with: token: ${{ secrets.ELEMENT_BOT_TOKEN }} branch: actions/jitsi-update diff --git a/.gitignore b/.gitignore index 685a2cc317..3e9dc5e135 100644 --- a/.gitignore +++ b/.gitignore @@ -26,6 +26,7 @@ electron/pub /coverage # Auto-generated file /src/modules.ts +/src/modules.js /build_config.yaml /book /index.html diff --git a/.prettierignore b/.prettierignore index 418329cf28..46b1ac5b54 100644 --- a/.prettierignore +++ b/.prettierignore @@ -17,6 +17,7 @@ electron/pub /coverage # Auto-generated file /src/modules.ts +/src/modules.js /src/i18n/strings /build_config.yaml # Raises an error because it contains a template var breaking the script tag diff --git a/.stylelintrc.js b/.stylelintrc.js index fa36402ff1..ffc6c345b9 100644 --- a/.stylelintrc.js +++ b/.stylelintrc.js @@ -33,19 +33,15 @@ module.exports = { "import-notation": null, "value-keyword-case": null, "declaration-block-no-redundant-longhand-properties": null, - "declaration-block-no-duplicate-properties": [ - true, - // useful for fallbacks - { ignore: ["consecutive-duplicates-with-different-values"] }, - ], "shorthand-property-no-redundant-values": null, "property-no-vendor-prefix": null, - "value-no-vendor-prefix": null, "selector-no-vendor-prefix": null, "media-feature-name-no-vendor-prefix": null, "number-max-precision": null, "no-invalid-double-slash-comments": true, "media-feature-range-notation": null, + "declaration-property-value-no-unknown": null, + "declaration-property-value-keyword-no-deprecated": null, "csstools/value-no-unknown-custom-properties": [ true, { diff --git a/CHANGELOG.md b/CHANGELOG.md index d5e000f494..5b458cd6bb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,134 @@ +Changes in [1.11.95](https://github.com/element-hq/element-web/releases/tag/v1.11.95) (2025-03-11) +================================================================================================== +## ✨ Features + +* Room List Store: Filter rooms by active space ([#29399](https://github.com/element-hq/element-web/pull/29399)). Contributed by @MidhunSureshR. +* Room List - Update the room list store on actions from the dispatcher ([#29397](https://github.com/element-hq/element-web/pull/29397)). Contributed by @MidhunSureshR. +* Room List - Implement a minimal view model ([#29357](https://github.com/element-hq/element-web/pull/29357)). Contributed by @MidhunSureshR. +* New room list: add space menu in room header ([#29352](https://github.com/element-hq/element-web/pull/29352)). Contributed by @florianduros. +* Room List - Store sorted rooms in skip list ([#29345](https://github.com/element-hq/element-web/pull/29345)). Contributed by @MidhunSureshR. +* New room list: add dial to search section ([#29359](https://github.com/element-hq/element-web/pull/29359)). Contributed by @florianduros. +* New room list: add compose menu for spaces in header ([#29347](https://github.com/element-hq/element-web/pull/29347)). Contributed by @florianduros. +* Use EditInPlace control for Identity Server picker to improve a11y ([#29280](https://github.com/element-hq/element-web/pull/29280)). Contributed by @Half-Shot. +* First step to add header to new room list ([#29320](https://github.com/element-hq/element-web/pull/29320)). Contributed by @florianduros. +* Add Windows 64-bit arm link and remove 32-bit link on compatibility page ([#29312](https://github.com/element-hq/element-web/pull/29312)). Contributed by @t3chguy. +* Honour the backup disable flag from Element X ([#29290](https://github.com/element-hq/element-web/pull/29290)). Contributed by @dbkr. + +## 🐛 Bug Fixes + +* Fix edited code block width ([#29394](https://github.com/element-hq/element-web/pull/29394)). Contributed by @florianduros. +* new room list: keep space name in one line in header ([#29369](https://github.com/element-hq/element-web/pull/29369)). Contributed by @florianduros. +* Dismiss "Key storage out of sync" toast when secrets received ([#29348](https://github.com/element-hq/element-web/pull/29348)). Contributed by @richvdh. +* Minor CSS fixes for the new room list ([#29334](https://github.com/element-hq/element-web/pull/29334)). Contributed by @florianduros. +* Add padding to room header icon ([#29271](https://github.com/element-hq/element-web/pull/29271)). Contributed by @langleyd. + + +Changes in [1.11.94](https://github.com/element-hq/element-web/releases/tag/v1.11.94) (2025-02-27) +================================================================================================== +## 🐛 Bug Fixes + +* [Backport staging] fix: /tmp/element-web-config may already exist preventing the container from booting up ([#29377](https://github.com/element-hq/element-web/pull/29377)). Contributed by @RiotRobot. + + +Changes in [1.11.93](https://github.com/element-hq/element-web/releases/tag/v1.11.93) (2025-02-25) +================================================================================================== +## ✨ Features + +* [backport] Dynamically load Element Web modules in Docker entrypoint ([#29358](https://github.com/element-hq/element-web/pull/29358)). Contributed by @t3chguy. +* ChangeRecoveryKey: error handling ([#29262](https://github.com/element-hq/element-web/pull/29262)). Contributed by @richvdh. +* Dehydration: enable dehydrated device on "Set up recovery" ([#29265](https://github.com/element-hq/element-web/pull/29265)). Contributed by @richvdh. +* Render reason for invite rejection. ([#29257](https://github.com/element-hq/element-web/pull/29257)). Contributed by @Half-Shot. +* New room list: add search section ([#29251](https://github.com/element-hq/element-web/pull/29251)). Contributed by @florianduros. +* New room list: hide favourites and people meta spaces ([#29241](https://github.com/element-hq/element-web/pull/29241)). Contributed by @florianduros. +* New Room List: Create new labs flag ([#29239](https://github.com/element-hq/element-web/pull/29239)). Contributed by @MidhunSureshR. +* Stop URl preview from covering message box ([#29215](https://github.com/element-hq/element-web/pull/29215)). Contributed by @edent. +* Rename "security key" into "recovery key" ([#29217](https://github.com/element-hq/element-web/pull/29217)). Contributed by @florianduros. +* Add new verification section to user profile ([#29200](https://github.com/element-hq/element-web/pull/29200)). Contributed by @MidhunSureshR. +* Initial support for runtime modules ([#29104](https://github.com/element-hq/element-web/pull/29104)). Contributed by @t3chguy. +* Add `Forgot recovery key?` button to encryption tab ([#29202](https://github.com/element-hq/element-web/pull/29202)). Contributed by @florianduros. +* Add KeyIcon to key storage out of sync toast ([#29201](https://github.com/element-hq/element-web/pull/29201)). Contributed by @florianduros. +* Improve rendering of empty topics in the timeline ([#29152](https://github.com/element-hq/element-web/pull/29152)). Contributed by @Half-Shot. + +## 🐛 Bug Fixes + +* Fix font scaling in member list ([#29285](https://github.com/element-hq/element-web/pull/29285)). Contributed by @florianduros. +* Grow member list search field when resizing the right panel ([#29267](https://github.com/element-hq/element-web/pull/29267)). Contributed by @langleyd. +* Don't reload roomview on offline connectivity check ([#29243](https://github.com/element-hq/element-web/pull/29243)). Contributed by @dbkr. +* Respect user's 12/24 hour preference consistently ([#29237](https://github.com/element-hq/element-web/pull/29237)). Contributed by @t3chguy. +* Restore the accessibility role on call views ([#29225](https://github.com/element-hq/element-web/pull/29225)). Contributed by @robintown. +* Revert `GoToHome` keyboard shortcut to `Ctrl`–`Shift`–`H` on macOS ([#28577](https://github.com/element-hq/element-web/pull/28577)). Contributed by @gy-mate. +* Encryption tab: display correct encryption panel when user cancels the reset identity flow ([#29216](https://github.com/element-hq/element-web/pull/29216)). Contributed by @florianduros. + + +Changes in [1.11.92](https://github.com/element-hq/element-web/releases/tag/v1.11.92) (2025-02-11) +================================================================================================== +## ✨ Features + +* [Backport staging] Log when we show, and hide, encryption setup toasts ([#29238](https://github.com/element-hq/element-web/pull/29238)). Contributed by @richvdh. +* Make profile header section match the designs ([#29163](https://github.com/element-hq/element-web/pull/29163)). Contributed by @MidhunSureshR. +* Always show back button in the right panel ([#29128](https://github.com/element-hq/element-web/pull/29128)). Contributed by @MidhunSureshR. +* Schedule dehydration on reload if the dehydration key is already cached locally ([#29021](https://github.com/element-hq/element-web/pull/29021)). Contributed by @uhoreg. +* update to twemoji 15.1.0 ([#29115](https://github.com/element-hq/element-web/pull/29115)). Contributed by @ara4n. +* Update matrix-widget-api ([#29112](https://github.com/element-hq/element-web/pull/29112)). Contributed by @toger5. +* Allow navigating through the memberlist using up/down keys ([#28949](https://github.com/element-hq/element-web/pull/28949)). Contributed by @MidhunSureshR. +* Style room header icons and facepile for toggled state ([#28968](https://github.com/element-hq/element-web/pull/28968)). Contributed by @MidhunSureshR. +* Move threads header below base card header ([#28969](https://github.com/element-hq/element-web/pull/28969)). Contributed by @MidhunSureshR. +* Add `Advanced` section to the user settings encryption tab ([#28804](https://github.com/element-hq/element-web/pull/28804)). Contributed by @florianduros. +* Fix outstanding UX issues with replies/mentions/keyword notifs ([#28270](https://github.com/element-hq/element-web/pull/28270)). Contributed by @taffyko. +* Distinguish room state and timeline events when dealing with widgets ([#28681](https://github.com/element-hq/element-web/pull/28681)). Contributed by @robintown. +* Switch OIDC primarily to new `/auth_metadata` API ([#29019](https://github.com/element-hq/element-web/pull/29019)). Contributed by @t3chguy. +* More memberlist changes ([#29069](https://github.com/element-hq/element-web/pull/29069)). Contributed by @MidhunSureshR. + +## 🐛 Bug Fixes + +* [Backport staging] Wire up the "Forgot recovery key" button for the "Key storage out of sync" toast ([#29190](https://github.com/element-hq/element-web/pull/29190)). Contributed by @RiotRobot. +* Encryption tab: hide `Advanced` section when the key storage is out of sync ([#29129](https://github.com/element-hq/element-web/pull/29129)). Contributed by @florianduros. +* Fix share button in discovery settings being disabled incorrectly ([#29151](https://github.com/element-hq/element-web/pull/29151)). Contributed by @t3chguy. +* Ensure switching rooms does not wrongly focus timeline search ([#29153](https://github.com/element-hq/element-web/pull/29153)). Contributed by @t3chguy. +* Stop showing a dialog prompting the user to enter an old recovery key ([#29143](https://github.com/element-hq/element-web/pull/29143)). Contributed by @richvdh. +* Make themed widgets reflect the effective theme ([#28342](https://github.com/element-hq/element-web/pull/28342)). Contributed by @robintown. +* support non-VS16 emoji ligatures in TwemojiMozilla ([#29100](https://github.com/element-hq/element-web/pull/29100)). Contributed by @ara4n. +* e2e test: Verify session with the encryption tab instead of the security \& privacy tab ([#29090](https://github.com/element-hq/element-web/pull/29090)). Contributed by @florianduros. +* Work around cloudflare R2 / aws client incompatability ([#29086](https://github.com/element-hq/element-web/pull/29086)). Contributed by @dbkr. +* Fix identity server settings visibility ([#29083](https://github.com/element-hq/element-web/pull/29083)). Contributed by @dbkr. + + +Changes in [1.11.91](https://github.com/element-hq/element-web/releases/tag/v1.11.91) (2025-01-28) +================================================================================================== +## ✨ Features + +* Implement changes to memberlist from feedback ([#29029](https://github.com/element-hq/element-web/pull/29029)). Contributed by @MidhunSureshR. +* Add toast for recovery keys being out of sync ([#28946](https://github.com/element-hq/element-web/pull/28946)). Contributed by @dbkr. +* Refactor LegacyCallHandler event emitter to use TypedEventEmitter ([#29008](https://github.com/element-hq/element-web/pull/29008)). Contributed by @t3chguy. +* Add `Recovery` section in the new user settings `Encryption` tab ([#28673](https://github.com/element-hq/element-web/pull/28673)). Contributed by @florianduros. +* Retry loading chunks to make the app more resilient ([#29001](https://github.com/element-hq/element-web/pull/29001)). Contributed by @t3chguy. +* Clear account idb table on logout ([#28996](https://github.com/element-hq/element-web/pull/28996)). Contributed by @t3chguy. +* Implement new memberlist design with MVVM architecture ([#28874](https://github.com/element-hq/element-web/pull/28874)). Contributed by @MidhunSureshR. + +## 🐛 Bug Fixes + +* [Backport staging] Switch to secure random strings ([#29035](https://github.com/element-hq/element-web/pull/29035)). Contributed by @RiotRobot. +* React to MatrixEvent sender/target being updated for rendering state events ([#28947](https://github.com/element-hq/element-web/pull/28947)). Contributed by @t3chguy. + + +Changes in [1.11.90](https://github.com/element-hq/element-web/releases/tag/v1.11.90) (2025-01-14) +================================================================================================== +## ✨ Features + +* Docker: run as non-root ([#28849](https://github.com/element-hq/element-web/pull/28849)). Contributed by @richvdh. +* Docker: allow configuration of HTTP listen port via env var ([#28840](https://github.com/element-hq/element-web/pull/28840)). Contributed by @richvdh. +* Update matrix-wysiwyg to consume WASM asset ([#28838](https://github.com/element-hq/element-web/pull/28838)). Contributed by @t3chguy. +* OIDC settings tweaks ([#28787](https://github.com/element-hq/element-web/pull/28787)). Contributed by @t3chguy. +* Delabs native OIDC support ([#28615](https://github.com/element-hq/element-web/pull/28615)). Contributed by @t3chguy. +* Move room header info button to right-most position ([#28754](https://github.com/element-hq/element-web/pull/28754)). Contributed by @t3chguy. +* Enable key backup by default ([#28691](https://github.com/element-hq/element-web/pull/28691)). Contributed by @dbkr. + +## 🐛 Bug Fixes + +* Fix building the automations mermaid diagram ([#28881](https://github.com/element-hq/element-web/pull/28881)). Contributed by @dbkr. +* Playwright: wait for the network listener on the postgres db ([#28808](https://github.com/element-hq/element-web/pull/28808)). Contributed by @dbkr. + + Changes in [1.11.89](https://github.com/element-hq/element-web/releases/tag/v1.11.89) (2024-12-18) ================================================================================================== This is a patch release to fix a bug which could prevent loading stored crypto state from storage, and also to fix URL previews when switching back to a room. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index fa887929fb..fd94f18f85 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -189,89 +189,6 @@ give away to contributors - if you feel that Matrix-branded apparel is missing from your life, please mail us your shipping address to matrix at matrix.org and we'll try to fix it :) -## Sign off - -In order to have a concrete record that your contribution is intentional -and you agree to license it under the same terms as the project's license, we've -adopted the same lightweight approach that the Linux Kernel -(https://www.kernel.org/doc/html/latest/process/submitting-patches.html), Docker -(https://github.com/docker/docker/blob/master/CONTRIBUTING.md), and many other -projects use: the DCO (Developer Certificate of Origin: -http://developercertificate.org/). This is a simple declaration that you wrote -the contribution or otherwise have the right to contribute it to Matrix: - -``` -Developer Certificate of Origin -Version 1.1 - -Copyright (C) 2004, 2006 The Linux Foundation and its contributors. -660 York Street, Suite 102, -San Francisco, CA 94110 USA - -Everyone is permitted to copy and distribute verbatim copies of this -license document, but changing it is not allowed. - -Developer's Certificate of Origin 1.1 - -By making a contribution to this project, I certify that: - -(a) The contribution was created in whole or in part by me and I - have the right to submit it under the open source license - indicated in the file; or - -(b) The contribution is based upon previous work that, to the best - of my knowledge, is covered under an appropriate open source - license and I have the right under that license to submit that - work with modifications, whether created in whole or in part - by me, under the same open source license (unless I am - permitted to submit under a different license), as indicated - in the file; or - -(c) The contribution was provided directly to me by some other - person who certified (a), (b) or (c) and I have not modified - it. - -(d) I understand and agree that this project and the contribution - are public and that a record of the contribution (including all - personal information I submit with it, including my sign-off) is - maintained indefinitely and may be redistributed consistent with - this project or the open source license(s) involved. -``` - -If you agree to this for your contribution, then all that's needed is to -include the line in your commit or pull request comment: - -``` -Signed-off-by: Your Name -``` - -We accept contributions under a legally identifiable name, such as your name on -government documentation or common-law names (names claimed by legitimate usage -or repute). Unfortunately, we cannot accept anonymous contributions at this -time. - -Git allows you to add this signoff automatically when using the `-s` flag to -`git commit`, which uses the name and email set in your `user.name` and -`user.email` git configs. - -If you forgot to sign off your commits before making your pull request and are -on Git 2.17+ you can mass signoff using rebase: - -``` -git rebase --signoff origin/develop -``` - -## Private sign off - -If you would like to provide your legal name privately to the Matrix.org -Foundation (instead of in a public commit or comment), you can do so by emailing -your legal name and a link to the pull request to dco@matrix.org. It helps to -include "sign off" or similar in the subject line. You will then be instructed -further. - -Once private sign off is complete, doing so for future contributions will not -be required. - # Review expectations See https://github.com/element-hq/element-meta/wiki/Review-process diff --git a/Dockerfile b/Dockerfile index 93d7c676d9..cd1766347a 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,3 +1,5 @@ +# syntax=docker.io/docker/dockerfile:1.14-labs + # Builder FROM --platform=$BUILDPLATFORM node:22-bullseye AS builder @@ -8,7 +10,7 @@ ARG JS_SDK_BRANCH="master" WORKDIR /src -COPY . /src +COPY --exclude=docker . /src RUN /src/scripts/docker-link-repos.sh RUN yarn --network-timeout=200000 install RUN /src/scripts/docker-package.sh @@ -19,11 +21,15 @@ RUN cp /src/config.sample.json /src/webapp/config.json # App FROM nginx:alpine-slim +# Install jq and moreutils for sponge, both used by our entrypoints +RUN apk add jq moreutils + COPY --from=builder /src/webapp /app # Override default nginx config. Templates in `/etc/nginx/templates` are passed # through `envsubst` by the nginx docker image entry point. COPY /docker/nginx-templates/* /etc/nginx/templates/ +COPY /docker/docker-entrypoint.d/* /docker-entrypoint.d/ # Tell nginx to put its pidfile elsewhere, so it can run as non-root RUN sed -i -e 's,/var/run/nginx.pid,/tmp/nginx.pid,' /etc/nginx/nginx.conf @@ -40,3 +46,5 @@ USER nginx # HTTP listen port ENV ELEMENT_WEB_PORT=80 + +HEALTHCHECK --start-period=5s CMD wget -q --spider http://localhost:$ELEMENT_WEB_PORT/config.json diff --git a/README.md b/README.md index 5924f93498..00f8d6d89c 100644 --- a/README.md +++ b/README.md @@ -182,123 +182,11 @@ Dockerfile. # Development -Before attempting to develop on Element you **must** read the [developer guide -for `matrix-react-sdk`](https://github.com/matrix-org/matrix-react-sdk#developer-guide), which -also defines the design, architecture and style for Element too. +Please read through the following: -Read the [Choosing an issue](docs/choosing-an-issue.md) page for some guidance -about where to start. Before starting work on a feature, it's best to ensure -your plan aligns well with our vision for Element. Please chat with the team in -[#element-dev:matrix.org](https://matrix.to/#/#element-dev:matrix.org) before -you start so we can ensure it's something we'd be willing to merge. - -You should also familiarise yourself with the ["Here be Dragons" guide -](https://docs.google.com/document/d/12jYzvkidrp1h7liEuLIe6BMdU0NUjndUYI971O06ooM) -to the tame & not-so-tame dragons (gotchas) which exist in the codebase. - -The idea of Element is to be a relatively lightweight "skin" of customisations on -top of the underlying `matrix-react-sdk`. `matrix-react-sdk` provides both the -higher and lower level React components useful for building Matrix communication -apps using React. - -Please note that Element is intended to run correctly without access to the public -internet. So please don't depend on resources (JS libs, CSS, images, fonts) -hosted by external CDNs or servers but instead please package all dependencies -into Element itself. - -# Setting up a dev environment - -Much of the functionality in Element is actually in the `matrix-js-sdk` module. -It is possible to set these up in a way that makes it easy to track the `develop` branches -in git and to make local changes without having to manually rebuild each time. - -First clone and build `matrix-js-sdk`: - -```bash -git clone https://github.com/matrix-org/matrix-js-sdk.git -pushd matrix-js-sdk -yarn link -yarn install -popd -``` - -Clone the repo and switch to the `element-web` directory: - -```bash -git clone https://github.com/element-hq/element-web.git -cd element-web -``` - -Configure the app by copying `config.sample.json` to `config.json` and -modifying it. See the [configuration docs](docs/config.md) for details. - -Finally, build and start Element itself: - -```bash -yarn link matrix-js-sdk -yarn install -yarn start -``` - -Wait a few seconds for the initial build to finish; you should see something like: - -``` -[element-js] [webpack.Progress] 100% -[element-js] -[element-js] ℹ 「wdm」: 1840 modules -[element-js] ℹ 「wdm」: Compiled successfully. -``` - -Remember, the command will not terminate since it runs the web server -and rebuilds source files when they change. This development server also -disables caching, so do NOT use it in production. - -Open in your browser to see your newly built Element. - -**Note**: The build script uses inotify by default on Linux to monitor directories -for changes. If the inotify limits are too low your build will fail silently or with -`Error: EMFILE: too many open files`. To avoid these issues, we recommend a watch limit -of at least `128M` and instance limit around `512`. - -You may be interested in issues [#15750](https://github.com/element-hq/element-web/issues/15750) and -[#15774](https://github.com/element-hq/element-web/issues/15774) for further details. - -To set a new inotify watch and instance limit, execute: - -``` -sudo sysctl fs.inotify.max_user_watches=131072 -sudo sysctl fs.inotify.max_user_instances=512 -sudo sysctl -p -``` - -If you wish, you can make the new limits permanent, by executing: - -``` -echo fs.inotify.max_user_watches=131072 | sudo tee -a /etc/sysctl.conf -echo fs.inotify.max_user_instances=512 | sudo tee -a /etc/sysctl.conf -sudo sysctl -p -``` - ---- - -When you make changes to `matrix-js-sdk` they should be automatically picked up by webpack and built. - -If any of these steps error with, `file table overflow`, you are probably on a mac -which has a very low limit on max open files. Run `ulimit -Sn 1024` and try again. -You'll need to do this in each new terminal you open before building Element. - -## Running the tests - -There are a number of application-level tests in the `tests` directory; these -are designed to run with Jest and JSDOM. To run them - -``` -yarn test -``` - -### End-to-End tests - -See [matrix-react-sdk](https://github.com/matrix-org/matrix-react-sdk/#end-to-end-tests) for how to run the end-to-end tests. +1. [Developer guide](./developer_guide.md) +2. [Code style](./code_style.md) +3. [Contribution guide](./CONTRIBUTING.md) # Translations diff --git a/__mocks__/maplibre-gl.js b/__mocks__/maplibre-gl.js index cac71db330..475648e774 100644 --- a/__mocks__/maplibre-gl.js +++ b/__mocks__/maplibre-gl.js @@ -17,6 +17,7 @@ class MockMap extends EventEmitter { setCenter = jest.fn(); setStyle = jest.fn(); fitBounds = jest.fn(); + remove = jest.fn(); } const MockMapInstance = new MockMap(); diff --git a/babel.config.js b/babel.config.js index b63a90e5ff..58df067b79 100644 --- a/babel.config.js +++ b/babel.config.js @@ -31,5 +31,7 @@ module.exports = { "@babel/plugin-syntax-dynamic-import", "@babel/plugin-transform-runtime", + ["@babel/plugin-proposal-decorators", { version: "2023-11" }], // only needed by the js-sdk + "@babel/plugin-transform-class-static-block", // only needed by the js-sdk for decorators ], }; diff --git a/code_style.md b/code_style.md index 9aa6836442..9f0501ccd8 100644 --- a/code_style.md +++ b/code_style.md @@ -5,15 +5,6 @@ adjacent to. As of writing, these are: - element-desktop - element-web -- matrix-js-sdk - -Other projects might extend this code style for increased strictness. For example, matrix-events-sdk -has stricter code organization to reduce the maintenance burden. These projects will declare their code -style within their own repos. - -Note that some requirements will be layer-specific. Where the requirements don't make sense for the -project, they are used to the best of their ability, used in spirit, or ignored if not applicable, -in that order. ## Guiding principles @@ -234,17 +225,19 @@ Unless otherwise specified, the following applies to all code: Inheriting all the rules of TypeScript, the following additionally apply: -1. Types for lifecycle functions are not required (render, componentDidMount, and so on). -2. Class components must always have a `Props` interface declared immediately above them. It can be +1. Component source files are named with upper camel case (e.g. views/rooms/EventTile.js) +2. They are organised in a typically two-level hierarchy - first whether the component is a view or a structure, and then a broad functional grouping (e.g. 'rooms' here) +3. Types for lifecycle functions are not required (render, componentDidMount, and so on). +4. Class components must always have a `Props` interface declared immediately above them. It can be empty if the component accepts no props. -3. Class components should have an `State` interface declared immediately above them, but after `Props`. -4. Props and State should not be exported. Use `React.ComponentProps` +5. Class components should have an `State` interface declared immediately above them, but after `Props`. +6. Props and State should not be exported. Use `React.ComponentProps` instead. -5. One component per file, except when a component is a utility component specifically for the "primary" +7. One component per file, except when a component is a utility component specifically for the "primary" component. The utility component should not be exported. -6. Exported constants, enums, interfaces, functions, etc must be separate from files containing components +8. Exported constants, enums, interfaces, functions, etc must be separate from files containing components or stores. -7. Stores should use a singleton pattern with a static instance property: +9. Stores should use a singleton pattern with a static instance property: ```typescript class FooStore { @@ -261,44 +254,41 @@ Inheriting all the rules of TypeScript, the following additionally apply: } ``` -8. Stores must support using an alternative MatrixClient and dispatcher instance. -9. Utilities which require JSX must be split out from utilities which do not. This is to prevent import - cycles during runtime where components accidentally include more of the app than they intended. -10. Interdependence between stores should be kept to a minimum. Break functions and constants out to utilities +10. Stores must support using an alternative MatrixClient and dispatcher instance. +11. Utilities which require JSX must be split out from utilities which do not. This is to prevent import + cycles during runtime where components accidentally include more of the app than they intended. +12. Interdependence between stores should be kept to a minimum. Break functions and constants out to utilities if at all possible. -11. A component should only use CSS class names in line with the component name. +13. A component should only use CSS class names in line with the component name. 1. When knowingly using a class name from another component, document it with a [comment](#comments). -12. Curly braces within JSX should be padded with a space, however properties on those components should not. +14. Curly braces within JSX should be padded with a space, however properties on those components should not. See above code example. -13. Functions used as properties should either be defined on the class or stored in a variable. They should not +15. Functions used as properties should either be defined on the class or stored in a variable. They should not be inline unless mocking/short-circuiting the value. -14. Prefer hooks (functional components) over class components. Be consistent with the existing area if unsure +16. Prefer hooks (functional components) over class components. Be consistent with the existing area if unsure which should be used. 1. Unless the component is considered a "structure", in which case use classes. -15. Write more views than structures. Structures are chunks of functionality like MatrixChat while views are +17. Write more views than structures. Structures are chunks of functionality like MatrixChat while views are isolated components. -16. Components should serve a single, or near-single, purpose. -17. Prefer to derive information from component properties rather than establish state. -18. Do not use `React.Component::forceUpdate`. +18. Components should serve a single, or near-single, purpose. +19. Prefer to derive information from component properties rather than establish state. +20. Do not use `React.Component::forceUpdate`. ## Stylesheets (\*.pcss = PostCSS + Plugins) Note: We use PostCSS + some plugins to process our styles. It looks like SCSS, but actually it is not. -1. Class names must be prefixed with "mx\_". -2. Class names must denote the component which defines them, followed by any context. - The context is not further specified here in terms of meaning or syntax. - Use whatever is appropriate for your implementation use case. - Some examples: - 1. `mx_MyFoo` - 2. `mx_MyFoo_avatar` - 3. `mx_MyFoo_avatarUser` - 4. `mx_MyFoo_avatar--user` -3. Use the `$font` variables instead of manual values. -4. Keep indentation/nesting to a minimum. Maximum suggested nesting is 5 layers. -5. Use the whole class name instead of shortcuts: +1. The view's CSS file MUST have the same name as the component (e.g. `view/rooms/_MessageTile.css` for `MessageTile.tsx` component). +2. Per-view CSS is optional - it could choose to inherit all its styling from the context of the rest of the app, although this is unusual. +3. Class names must be prefixed with "mx\_". +4. Class names must strictly denote the component which defines them. + For example: `mx_MyFoo` for `MyFoo` component. +5. Class names for DOM elements within a view which aren't components are named by appending a lower camel case identifier to the view's class name - e.g. .mx_MyFoo_randomDiv is how you'd name the class of an arbitrary div within the MyFoo view. +6. Use the `$font` variables instead of manual values. +7. Keep indentation/nesting to a minimum. Maximum suggested nesting is 5 layers. +8. Use the whole class name instead of shortcuts: ```scss .mx_MyFoo { @@ -309,7 +299,7 @@ Note: We use PostCSS + some plugins to process our styles. It looks like SCSS, b } ``` -6. Break multiple selectors over multiple lines this way: +9. Break multiple selectors over multiple lines this way: ```scss .mx_MyFoo, @@ -319,9 +309,9 @@ Note: We use PostCSS + some plugins to process our styles. It looks like SCSS, b } ``` -7. Non-shared variables should use $lowerCamelCase. Shared variables use $dashed-naming. -8. Overrides to Z indexes, adjustments of dimensions/padding with pixels, and so on should all be - [documented](#comments) for what the values mean: +10. Non-shared variables should use $lowerCamelCase. Shared variables use $dashed-naming. +11. Overrides to Z indexes, adjustments of dimensions/padding with pixels, and so on should all be + [documented](#comments) for what the values mean: ```scss .mx_MyFoo { @@ -331,7 +321,9 @@ Note: We use PostCSS + some plugins to process our styles. It looks like SCSS, b } ``` -9. Avoid the use of `!important`. If `!important` is necessary, add a [comment](#comments) explaining why. +12. Avoid the use of `!important`. If `!important` is necessary, add a [comment](#comments) explaining why. +13. The CSS for a component can override the rules for child components. For instance, .mxRoomList .mx_RoomTile {} would be the selector to override styles of RoomTiles when viewed in the context of a RoomList view. Overrides must be scoped to the View's CSS class - i.e. don't just define .mx_RoomTile {} in RoomList.css - only RoomTile.css is allowed to define its own CSS. Instead, say .mx_RoomList .mx_RoomTile {} to scope the override only to the context of RoomList views. N.B. overrides should be relatively rare as in general CSS inheritance should be enough. +14. Components should render only within the bounding box of their outermost DOM element. Page-absolute positioning and negative CSS margins and similar are generally not cool and stop the component from being reused easily in different places. ## Tests diff --git a/debian/control b/debian/control index 158c3ada17..506ae1eb33 100755 --- a/debian/control +++ b/debian/control @@ -8,6 +8,6 @@ Package: element-web Architecture: all Recommends: httpd, element-io-archive-keyring Description: - A feature-rich client for Matrix.org + Element: the future of secure communication This package contains the web-based client that can be served through a web server. diff --git a/developer_guide.md b/developer_guide.md new file mode 100644 index 0000000000..fa4bb9a239 --- /dev/null +++ b/developer_guide.md @@ -0,0 +1,126 @@ +# Developer Guide + +## Development + +Read the [Choosing an issue](docs/choosing-an-issue.md) page for some guidance +about where to start. Before starting work on a feature, it's best to ensure +your plan aligns well with our vision for Element. Please chat with the team in +[#element-dev:matrix.org](https://matrix.to/#/#element-dev:matrix.org) before +you start so we can ensure it's something we'd be willing to merge. + +You should also familiarise yourself with the ["Here be Dragons" guide +](https://docs.google.com/document/d/12jYzvkidrp1h7liEuLIe6BMdU0NUjndUYI971O06ooM) +to the tame & not-so-tame dragons (gotchas) which exist in the codebase. + +Please note that Element is intended to run correctly without access to the public +internet. So please don't depend on resources (JS libs, CSS, images, fonts) +hosted by external CDNs or servers but instead please package all dependencies +into Element itself. + +## Setting up a dev environment + +Much of the functionality in Element is actually in the `matrix-js-sdk` module. +It is possible to set these up in a way that makes it easy to track the `develop` branches +in git and to make local changes without having to manually rebuild each time. + +First clone and build `matrix-js-sdk`: + +```bash +git clone https://github.com/matrix-org/matrix-js-sdk.git +pushd matrix-js-sdk +yarn link +yarn install +popd +``` + +Clone the repo and switch to the `element-web` directory: + +```bash +git clone https://github.com/element-hq/element-web.git +cd element-web +``` + +Configure the app by copying `config.sample.json` to `config.json` and +modifying it. See the [configuration docs](docs/config.md) for details. + +Finally, build and start Element itself: + +```bash +yarn link matrix-js-sdk +yarn install +yarn start +``` + +Wait a few seconds for the initial build to finish; you should see something like: + +``` +[element-js] [webpack.Progress] 100% +[element-js] +[element-js] ℹ 「wdm」: 1840 modules +[element-js] ℹ 「wdm」: Compiled successfully. +``` + +Remember, the command will not terminate since it runs the web server +and rebuilds source files when they change. This development server also +disables caching, so do NOT use it in production. + +Open in your browser to see your newly built Element. + +**Note**: The build script uses inotify by default on Linux to monitor directories +for changes. If the inotify limits are too low your build will fail silently or with +`Error: EMFILE: too many open files`. To avoid these issues, we recommend a watch limit +of at least `128M` and instance limit around `512`. + +You may be interested in issues [#15750](https://github.com/element-hq/element-web/issues/15750) and +[#15774](https://github.com/element-hq/element-web/issues/15774) for further details. + +To set a new inotify watch and instance limit, execute: + +``` +sudo sysctl fs.inotify.max_user_watches=131072 +sudo sysctl fs.inotify.max_user_instances=512 +sudo sysctl -p +``` + +If you wish, you can make the new limits permanent, by executing: + +``` +echo fs.inotify.max_user_watches=131072 | sudo tee -a /etc/sysctl.conf +echo fs.inotify.max_user_instances=512 | sudo tee -a /etc/sysctl.conf +sudo sysctl -p +``` + +--- + +When you make changes to `matrix-js-sdk` they should be automatically picked up by webpack and built. + +If any of these steps error with, `file table overflow`, you are probably on a mac +which has a very low limit on max open files. Run `ulimit -Sn 1024` and try again. +You'll need to do this in each new terminal you open before building Element. + +## Running the tests + +There are a number of application-level tests in the `tests` directory; these +are designed to run with Jest and JSDOM. To run them + +``` +yarn test +``` + +### End-to-End tests + +See [matrix-react-sdk](https://github.com/matrix-org/matrix-react-sdk/#end-to-end-tests) for how to run the end-to-end tests. + +## General github guidelines + +1. **Pull requests must only be filed against the `develop` branch.** +2. Try to keep your pull requests concise. Split them up if necessary. +3. Ensure that you provide a description that explains the fix/feature and its intent. + +## Adding new code + +New code should be committed as follows: + +- All new components: https://github.com/element-hq/element-web/tree/develop/src/components +- CSS: https://github.com/element-hq/element-web/tree/develop/res/css +- Theme specific CSS & resources: https://github.com/element-hq/element-web/tree/develop/res/themes diff --git a/docker/docker-entrypoint.d/18-load-element-modules.sh b/docker/docker-entrypoint.d/18-load-element-modules.sh new file mode 100755 index 0000000000..235c4edcf2 --- /dev/null +++ b/docker/docker-entrypoint.d/18-load-element-modules.sh @@ -0,0 +1,34 @@ +#!/bin/sh + +# Loads modules from `/modules` into config.json's `modules` field + +set -e + +entrypoint_log() { + if [ -z "${NGINX_ENTRYPOINT_QUIET_LOGS:-}" ]; then + echo "$@" + fi +} + +# Copy these config files as a base +mkdir -p /tmp/element-web-config +cp /app/config*.json /tmp/element-web-config/ + +# If there are modules to be loaded +if [ -d "/modules" ]; then + cd /modules + + for MODULE in * + do + # If the module has a package.json, use its main field as the entrypoint + ENTRYPOINT="index.js" + if [ -f "/modules/$MODULE/package.json" ]; then + ENTRYPOINT=$(jq -r '.main' "/modules/$MODULE/package.json") + fi + + entrypoint_log "Loading module $MODULE with entrypoint $ENTRYPOINT" + + # Append the module to the config + jq ".modules += [\"/modules/$MODULE/$ENTRYPOINT\"]" /tmp/element-web-config/config.json | sponge /tmp/element-web-config/config.json + done +fi diff --git a/docker/nginx-templates/default.conf.template b/docker/nginx-templates/default.conf.template index 06f33e08dd..b4690ce146 100644 --- a/docker/nginx-templates/default.conf.template +++ b/docker/nginx-templates/default.conf.template @@ -18,8 +18,12 @@ server { } # covers config.json and config.hostname.json requests as it is prefix. location /config { + root /tmp/element-web-config; add_header Cache-Control "no-cache"; } + location /modules { + alias /modules; + } # redirect server error pages to the static page /50x.html # error_page 500 502 503 504 /50x.html; diff --git a/docs/MVVM.md b/docs/MVVM.md new file mode 100644 index 0000000000..9dfb8e4776 --- /dev/null +++ b/docs/MVVM.md @@ -0,0 +1,67 @@ +# MVVM + +General description of the pattern can be found [here](https://en.wikipedia.org/wiki/Model%E2%80%93view%E2%80%93viewmodel). But the gist of it is that you divide your code into three sections: + +1. Model: This is where the business logic and data resides. +2. View Model: This code exists to provide the logic necessary for the UI. It directly uses the Model code. +3. View: This is the UI code itself and depends on the view model. + +If you do MVVM right, your view should be dumb i.e it gets data from the view model and merely displays it. + +### Practical guidelines for MVVM in element-web + +#### Model + +This is anywhere your data or business logic comes from. If your view model is accessing something simple exposed from `matrix-js-sdk`, then the sdk is your model. If you're using something more high level in element-web to get your data/logic (eg: `MemberListStore`), then that becomes your model. + +#### View Model + +1. View model is always a custom react hook named like `useFooViewModel()`. +2. The return type of your view model (known as view state) must be defined as a typescript interface: + ```ts + inteface FooViewState { + somethingUseful: string; + somethingElse: BarType; + update: () => Promise + ... + } + ``` +3. Any react state that your UI needs must be in the view model. + +#### View + +1. Views are simple react components (eg: `FooView`). +2. Views usually start by calling the view model hook, eg: + ```tsx + const FooView: React.FC = (props: IProps) => { + const vm = useFooViewModel(); + .... + return( +
+ {vm.somethingUseful} +
+ ); + } + ``` +3. Views are also allowed to accept the view model as a prop, eg: + ```tsx + const FooView: React.FC = ({ vm }: IProps) => { + .... + return( +
+ {vm.somethingUseful} +
+ ); + } + ``` +4. Multiple views can share the same view model if necessary. + +### Benefits + +1. MVVM forces a separation of concern i.e we will no longer have large react components that have a lot of state and rendering code mixed together. This improves code readability and makes it easier to introduce changes. +2. Introduces the possibility of code reuse. You can reuse an old view model with a new view or vice versa. +3. Adding to the point above, in future you could import element-web view models to your project and supply your own views thus creating something similar to the [hydrogen sdk](https://github.com/element-hq/hydrogen-web/blob/master/doc/SDK.md). + +### Example + +We started experimenting with MVVM in the redesigned memberlist, you can see the code [here](https://github.com/vector-im/element-web/blob/develop/src/components/views/rooms/MemberList/MemberListView.tsx). diff --git a/docs/config.md b/docs/config.md index 8ca4ba4eb8..9ae9f21254 100644 --- a/docs/config.md +++ b/docs/config.md @@ -155,7 +155,7 @@ complete re-branding/private labeling, a more personalised experience can be ach 3. `show_once`: Optional. If true then the notice will only be shown once per device. 18. `help_url`: The URL to point users to for help with the app, defaults to `https://element.io/help`. 19. `help_encryption_url`: The URL to point users to for help with encryption, defaults to `https://element.io/help#encryption`. -20. `force_verification`: If true, users must verify new logins (eg. with another device / their security key) +20. `force_verification`: If true, users must verify new logins (eg. with another device / their recovery key) ### `desktop_builds` and `mobile_builds` @@ -163,14 +163,14 @@ These two options describe the various availability for the application. When th such as trying to get the user to use an Android app or the desktop app for encrypted search, the config options will be looked at to see if the link should be to somewhere else. -Starting with `desktop_builds`, the following subproperties are available: +Starting with `desktop_builds`, the following sub-properties are available: 1. `available`: Required. When `true`, the desktop app can be downloaded from somewhere. 2. `logo`: Required. A URL to a logo (SVG), intended to be shown at 24x24 pixels. 3. `url`: Required. The download URL for the app. This is used as a hyperlink. 4. `url_macos`: Optional. Direct link to download macOS desktop app. -5. `url_win32`: Optional. Direct link to download Windows 32-bit desktop app. -6. `url_win64`: Optional. Direct link to download Windows 64-bit desktop app. +5. `url_win64`: Optional. Direct link to download Windows x86 64-bit desktop app. +6. `url_win64arm`: Optional. Direct link to download Windows ARM 64-bit desktop app. 7. `url_linux`: Optional. Direct link to download Linux desktop app. When `desktop_builds` is not specified at all, the app will assume desktop downloads are available from https://element.io @@ -592,3 +592,4 @@ The following are undocumented or intended for developer use only. 2. `sync_timeline_limit` 3. `dangerously_allow_unsafe_and_insecure_passwords` 4. `latex_maths_delims`: An optional setting to override the default delimiters used for maths parsing. See https://github.com/matrix-org/matrix-react-sdk/pull/5939 for details. Only used when `feature_latex_maths` is enabled. +5. `modules`: An optional list of modules to load. This is used for testing and development purposes only. diff --git a/docs/install.md b/docs/install.md index f6bd98611c..5f9e6ddd2e 100644 --- a/docs/install.md +++ b/docs/install.md @@ -66,6 +66,18 @@ on other runtimes may require root privileges. To resolve this, either run the image as root (`docker run --user 0`) or, better, change the port that nginx listens on via the `ELEMENT_WEB_PORT` environment variable. +[Element Web Modules](https://github.com/element-hq/element-modules/tree/main/packages/element-web-module-api) can be dynamically loaded +by being made available (e.g. via bind mount) in a directory within `/modules/`. +The default entrypoint will be index.js in that directory but can be overridden if a package.json file is found with a `main` directive. +These modules will be presented in a `/modules` subdirectory within the webroot, and automatically added to the config.json `modules` field. + +If you wish to use docker in read-only mode, +you should follow the [upstream instructions](https://hub.docker.com/_/nginx#:~:text=Running%20nginx%20in%20read%2Donly%20mode) +but additionally include the following directories: + +- /tmp/ +- /etc/nginx/conf.d/ + The behaviour of the docker image can be customised via the following environment variables: diff --git a/docs/labs.md b/docs/labs.md index cad9c5dd7c..7f69fca6e9 100644 --- a/docs/labs.md +++ b/docs/labs.md @@ -112,3 +112,7 @@ Unreliable in encrypted rooms. ## Knock rooms (`feature_ask_to_join`) [In Development] Enables knock feature for rooms. This allows users to ask to join a room. + +## New room list (`feature_new_room_list`) [In Development] + +Enable the new room list that is currently in development. diff --git a/docs/playwright.md b/docs/playwright.md index 2c26b7ab2b..e03d1f5f8d 100644 --- a/docs/playwright.md +++ b/docs/playwright.md @@ -66,11 +66,11 @@ as is typical for Playwright tests. Likewise, tests live in `playwright/e2e`. of Synapse/Dendrite. These servers are what Element-web runs against in the tests. Synapse can be launched with different configurations in order to test element -in different configurations. You can specify `synapseConfigOptions` as such: +in different configurations. You can specify `synapseConfig` as such: ```typescript test.use({ - synapseConfigOptions: { + synapseConfig: { // The config options to pass to the Synapse instance }, }); diff --git a/docs/release.md b/docs/release.md index b2c797b66b..26b507c737 100644 --- a/docs/release.md +++ b/docs/release.md @@ -8,11 +8,13 @@ #### develop -The develop branch holds the very latest and greatest code we have to offer, as such it may be less stable. It corresponds to the develop.element.io CD platform. +The develop branch holds the very latest and greatest code we have to offer, as such it may be less stable. +It is auto-deployed on every commit to element-web or matrix-js-sdk to develop.element.io via GitHub Actions `build_develop.yml`. #### staging The staging branch corresponds to the very latest release regardless of whether it is an RC or not. Deployed to staging.element.io manually. +It is auto-deployed on every release of element-web to staging.element.io via GitHub Actions `deploy.yml`. #### master @@ -126,7 +128,7 @@ flowchart TD subgraph Deploying D1[\Deploy staging.element.io/] - D2[\Check dockerhub/] + D2[\Check docker build/] D3[\Deploy app.element.io/] D4[\Check desktop package/] @@ -211,11 +213,11 @@ switched back to the version of the dependency from the master branch to not lea # Deploying We ship the SDKs to npm, this happens as part of the release process. -We ship Element Web to dockerhub, `*.element.io`, and packages.element.io. +We ship Element Web to dockerhub, ghcr.io, `*.element.io`, and packages.element.io. We ship Element Desktop to packages.element.io. -- [ ] Check that element-web has shipped to dockerhub -- [ ] Deploy staging.element.io. [See docs.](https://handbook.element.io/books/element-web-team/page/deploying-appstagingelementio) +- [ ] Check that element-web has shipped to dockerhub & ghcr.io +- [ ] Check that the staging [deployment](https://github.com/element-hq/element-web/actions/workflows/deploy.yml) has completed successfully - [ ] Test staging.element.io For final releases additionally do these steps: @@ -225,6 +227,9 @@ For final releases additionally do these steps: - [ ] Ensure Element Web package has shipped to packages.element.io - [ ] Ensure Element Desktop packages have shipped to packages.element.io +If you need to roll back a deployment to staging.element.io, +you can run the `deploy.yml` automation choosing an older tag which you wish to deploy. + # Housekeeping We have some manual housekeeping to do in order to prepare for the next release. diff --git a/jest.config.ts b/jest.config.ts index e620981ccc..5deccd269e 100644 --- a/jest.config.ts +++ b/jest.config.ts @@ -38,6 +38,8 @@ const config: Config = { "^!!raw-loader!.*": "jest-raw-loader", "recorderWorkletFactory": "/__mocks__/empty.js", "^fetch-mock$": "/node_modules/fetch-mock", + // Requires ESM which is incompatible with our current Jest setup + "^@element-hq/element-web-module-api$": "/__mocks__/empty.js", }, transformIgnorePatterns: ["/node_modules/(?!(mime|matrix-js-sdk)).+$"], collectCoverageFrom: [ diff --git a/knip.ts b/knip.ts index 17ad531332..0188e096e5 100644 --- a/knip.ts +++ b/knip.ts @@ -19,6 +19,7 @@ export default { ignore: [ // Keep for now "src/hooks/useLocalStorageState.ts", + "src/hooks/useTimeout.ts", "src/components/views/elements/InfoTooltip.tsx", "src/components/views/elements/StyledCheckbox.tsx", ], diff --git a/module_system/installer.ts b/module_system/installer.ts index 4e677b7d67..66df4e92b6 100644 --- a/module_system/installer.ts +++ b/module_system/installer.ts @@ -9,7 +9,7 @@ import * as fs from "fs"; import * as childProcess from "child_process"; import * as semver from "semver"; -import { BuildConfig } from "./BuildConfig"; +import { type BuildConfig } from "./BuildConfig"; // This expects to be run from ./scripts/install.ts @@ -23,10 +23,9 @@ const MODULES_TS_HEADER = ` * You are not a salmon. */ -import { RuntimeModule } from "@matrix-org/react-sdk-module-api/lib/RuntimeModule"; `; const MODULES_TS_DEFINITIONS = ` -export const INSTALLED_MODULES: RuntimeModule[] = []; +export const INSTALLED_MODULES = []; `; export function installer(config: BuildConfig): void { @@ -78,8 +77,8 @@ export function installer(config: BuildConfig): void { return; // hit the finally{} block before exiting } - // If we reach here, everything seems fine. Write modules.ts and log some output - // Note: we compile modules.ts in two parts for developer friendliness if they + // If we reach here, everything seems fine. Write modules.js and log some output + // Note: we compile modules.js in two parts for developer friendliness if they // happen to look at it. console.log("The following modules have been installed: ", installedModules); let modulesTsHeader = MODULES_TS_HEADER; @@ -193,5 +192,5 @@ function isModuleVersionCompatible(ourApiVersion: string, moduleApiVersion: stri } function writeModulesTs(content: string): void { - fs.writeFileSync("./src/modules.ts", content, "utf-8"); + fs.writeFileSync("./src/modules.js", content, "utf-8"); } diff --git a/package.json b/package.json index 7563cc5f49..6cf1d60072 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "element-web", - "version": "1.11.89", - "description": "A feature-rich client for Matrix.org", + "version": "1.11.95", + "description": "Element: the future of secure communication", "author": "New Vector Ltd.", "repository": { "type": "git", @@ -62,36 +62,38 @@ "test": "jest", "test:playwright": "playwright test", "test:playwright:open": "yarn test:playwright --ui", - "test:playwright:screenshots": "yarn test:playwright:screenshots:build && yarn test:playwright:screenshots:run", - "test:playwright:screenshots:build": "docker build playwright -t element-web-playwright", - "test:playwright:screenshots:run": "docker run --rm --network host -e BASE_URL -e CI -v $(pwd):/work/ -v $(node -e 'console.log(require(`path`).dirname(require.resolve(`matrix-js-sdk/package.json`)))'):/work/node_modules/matrix-js-sdk -v /var/run/docker.sock:/var/run/docker.sock -v /tmp/:/tmp/ -it element-web-playwright --grep @screenshot --project=Chrome", + "test:playwright:screenshots": "playwright-screenshots --project=Chrome", "coverage": "yarn test --coverage", "analyse:webpack-bundles": "webpack-bundle-analyzer webpack-stats.json webapp", "update:jitsi": "curl -s https://meet.element.io/libs/external_api.min.js > ./res/jitsi_external_api.min.js" }, "resolutions": { + "@playwright/test": "1.50.1", "@types/react": "18.3.18", "@types/react-dom": "18.3.5", "oidc-client-ts": "3.1.0", "jwt-decode": "4.0.0", - "caniuse-lite": "1.0.30001690", + "caniuse-lite": "1.0.30001701", + "testcontainers": "10.20.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", "@fontsource/inconsolata": "^5", "@fontsource/inter": "^5", "@formatjs/intl-segmenter": "^11.5.7", - "@matrix-org/analytics-events": "^0.29.0", - "@matrix-org/emojibase-bindings": "^1.3.3", + "@matrix-org/analytics-events": "^0.29.2", + "@matrix-org/emojibase-bindings": "^1.3.4", "@matrix-org/react-sdk-module-api": "^2.4.0", "@matrix-org/spec": "^1.7.0", - "@sentry/browser": "^8.0.0", + "@sentry/browser": "^9.0.0", "@types/png-chunks-extract": "^1.0.2", - "@vector-im/compound-design-tokens": "^2.1.0", - "@vector-im/compound-web": "^7.5.0", - "@vector-im/matrix-wysiwyg": "2.38.0", + "@types/react-virtualized": "^9.21.30", + "@vector-im/compound-design-tokens": "^4.0.0", + "@vector-im/compound-web": "^7.7.2", + "@vector-im/matrix-wysiwyg": "2.38.2", "@zxcvbn-ts/core": "^3.0.4", "@zxcvbn-ts/language-common": "^3.0.4", "@zxcvbn-ts/language-en": "^3.0.2", @@ -136,13 +138,14 @@ "png-chunks-extract": "^1.0.0", "posthog-js": "1.157.2", "qrcode": "1.5.4", - "re-resizable": "6.10.3", + "re-resizable": "6.11.2", "react": "^18.3.1", "react-beautiful-dnd": "^13.1.0", "react-blurhash": "^0.3.0", "react-dom": "^18.3.1", "react-focus-lock": "^2.5.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", @@ -150,20 +153,19 @@ "temporal-polyfill": "^0.2.5", "ua-parser-js": "^1.0.2", "uuid": "^11.0.0", - "what-input": "^5.2.10", - "@types/react-virtualized": "^9.21.30", - "react-virtualized": "^9.22.5" + "what-input": "^5.2.10" }, "devDependencies": { "@action-validator/cli": "^0.6.0", "@action-validator/core": "^0.6.0", - "@axe-core/playwright": "^4.8.1", "@babel/core": "^7.12.10", "@babel/eslint-parser": "^7.12.10", "@babel/eslint-plugin": "^7.12.10", + "@babel/plugin-proposal-decorators": "^7.25.9", "@babel/plugin-proposal-export-default-from": "^7.12.1", "@babel/plugin-syntax-dynamic-import": "^7.8.3", "@babel/plugin-transform-class-properties": "^7.12.1", + "@babel/plugin-transform-class-static-block": "^7.26.0", "@babel/plugin-transform-logical-assignment-operators": "^7.20.7", "@babel/plugin-transform-nullish-coalescing-operator": "^7.12.1", "@babel/plugin-transform-numeric-separator": "^7.12.7", @@ -175,15 +177,15 @@ "@babel/preset-typescript": "^7.12.7", "@babel/runtime": "^7.12.5", "@casualbot/jest-sonar-reporter": "2.2.7", + "@element-hq/element-web-playwright-common": "^1.1.5", "@peculiar/webcrypto": "^1.4.3", - "@playwright/test": "^1.40.1", + "@playwright/test": "^1.50.1", "@principalstudio/html-webpack-inject-preload": "^1.2.7", - "@sentry/webpack-plugin": "^2.7.1", - "@stylistic/eslint-plugin": "^2.9.0", + "@sentry/webpack-plugin": "^3.0.0", + "@stylistic/eslint-plugin": "^3.0.0", "@svgr/webpack": "^8.0.0", "@tenbin/jest": "^0.5.0", "@tenbin/playwright": "^0.5.0", - "@testcontainers/postgresql": "^10.16.0", "@testing-library/dom": "^10.4.0", "@testing-library/jest-dom": "^6.4.8", "@testing-library/react": "^16.0.0", @@ -219,12 +221,12 @@ "@typescript-eslint/eslint-plugin": "^8.19.0", "@typescript-eslint/parser": "^8.19.0", "babel-jest": "^29.0.0", - "babel-loader": "^9.0.0", + "babel-loader": "^10.0.0", "babel-plugin-jsx-remove-data-test-id": "^3.0.0", "blob-polyfill": "^9.0.0", "chokidar": "^4.0.0", "concurrently": "^9.0.0", - "copy-webpack-plugin": "^12.0.0", + "copy-webpack-plugin": "^13.0.0", "core-js": "^3.38.1", "cronstrue": "^2.41.0", "css-loader": "^7.0.0", @@ -232,13 +234,14 @@ "dotenv": "^16.0.2", "eslint": "8.57.1", "eslint-config-google": "^0.14.0", - "eslint-config-prettier": "^9.0.0", + "eslint-config-prettier": "^10.0.0", "eslint-plugin-deprecate": "0.8.5", "eslint-plugin-import": "^2.25.4", "eslint-plugin-jest": "^28.0.0", "eslint-plugin-jsx-a11y": "^6.5.1", "eslint-plugin-matrix-org": "^2.0.2", "eslint-plugin-react": "^7.28.0", + "eslint-plugin-react-compiler": "^19.0.0-beta-df7b47d-20241124", "eslint-plugin-react-hooks": "^5.0.0", "eslint-plugin-unicorn": "^56.0.0", "express": "^4.18.2", @@ -257,13 +260,12 @@ "jsqr": "^1.4.0", "knip": "^5.36.2", "lint-staged": "^15.0.2", - "mailhog": "^4.16.0", "matrix-web-i18n": "^3.2.1", "mini-css-extract-plugin": "2.9.2", "minimist": "^1.2.6", "modernizr": "^3.12.0", "node-fetch": "^2.6.7", - "playwright-core": "^1.45.1", + "playwright-core": "^1.51.0", "postcss": "8.4.46", "postcss-easings": "^4.0.0", "postcss-hexrgba": "2.1.0", @@ -274,27 +276,27 @@ "postcss-preset-env": "^10.0.0", "postcss-scss": "^4.0.4", "postcss-simple-vars": "^7.0.1", - "prettier": "3.4.2", + "prettier": "3.5.2", "process": "^0.11.10", "raw-loader": "^4.0.2", "rimraf": "^6.0.0", "semver": "^7.5.2", "source-map-loader": "^5.0.0", - "strip-ansi": "^7.1.0", - "stylelint": "^16.1.0", - "stylelint-config-standard": "^36.0.0", + "stylelint": "^16.13.0", + "stylelint-config-standard": "^37.0.0", "stylelint-scss": "^6.0.0", "stylelint-value-no-unknown-custom-properties": "^6.0.1", "terser-webpack-plugin": "^5.3.9", - "testcontainers": "^10.16.0", + "testcontainers": "^10.20.0", "ts-node": "^10.9.1", - "typescript": "5.7.2", + "typescript": "5.8.2", "util": "^0.12.5", "web-streams-polyfill": "^4.0.0", "webpack": "^5.89.0", "webpack-bundle-analyzer": "^4.8.0", "webpack-cli": "^6.0.0", "webpack-dev-server": "^5.0.0", + "webpack-retry-chunk-load-plugin": "^3.1.1", "webpack-version-file-plugin": "^0.5.0", "yaml": "^2.3.3" }, diff --git a/playwright.config.ts b/playwright.config.ts index 21522711b0..fde751aa96 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -9,19 +9,25 @@ Please see LICENSE files in the repository root for full details. import { defineConfig, devices } from "@playwright/test"; import { splitTests } from "@tenbin/playwright"; +import { type WorkerOptions } from "./playwright/services"; + const baseURL = process.env["BASE_URL"] ?? "http://localhost:8080"; -export default defineConfig({ +const chromeProject = { + ...devices["Desktop Chrome"], + channel: "chromium", + permissions: ["clipboard-write", "clipboard-read", "microphone"], + launchOptions: { + args: ["--use-fake-ui-for-media-stream", "--use-fake-device-for-media-stream", "--mute-audio"], + }, +}; + +export default defineConfig({ projects: [ { name: "Chrome", use: { - ...devices["Desktop Chrome"], - channel: "chromium", - permissions: ["clipboard-write", "clipboard-read", "microphone"], - launchOptions: { - args: ["--use-fake-ui-for-media-stream", "--use-fake-device-for-media-stream", "--mute-audio"], - }, + ...chromeProject, }, }, { @@ -49,6 +55,22 @@ export default defineConfig({ }, ignoreSnapshots: true, }, + { + name: "Dendrite", + use: { + ...chromeProject, + homeserverType: "dendrite", + }, + ignoreSnapshots: true, + }, + { + name: "Pinecone", + use: { + ...chromeProject, + homeserverType: "pinecone", + }, + ignoreSnapshots: true, + }, ], use: { viewport: { width: 1280, height: 720 }, @@ -62,6 +84,7 @@ export default defineConfig({ url: `${baseURL}/config.json`, reuseExistingServer: true, timeout: (process.env.CI ? 30 : 120) * 1000, + stdout: "pipe", }, testDir: "playwright/e2e", outputDir: "playwright/test-results", diff --git a/playwright/@types/playwright-core.d.ts b/playwright/@types/playwright-core.d.ts deleted file mode 100644 index 244f3c91d4..0000000000 --- a/playwright/@types/playwright-core.d.ts +++ /dev/null @@ -1,12 +0,0 @@ -/* -Copyright 2024 New Vector Ltd. -Copyright 2024 The Matrix.org Foundation C.I.C. - -SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial -Please see LICENSE files in the repository root for full details. -*/ - -declare module "playwright-core/lib/utils" { - // This type is not public in playwright-core utils - export function sanitizeForFilePath(filePath: string): string; -} diff --git a/playwright/Dockerfile b/playwright/Dockerfile deleted file mode 100644 index 7e918e04f7..0000000000 --- a/playwright/Dockerfile +++ /dev/null @@ -1,9 +0,0 @@ -FROM mcr.microsoft.com/playwright:v1.49.1-noble - -WORKDIR /work - -# fonts-dejavu is needed for the same RTL rendering as on CI -RUN apt-get update && apt-get -y install docker.io fonts-dejavu - -COPY docker-entrypoint.sh /opt/docker-entrypoint.sh -ENTRYPOINT ["bash", "/opt/docker-entrypoint.sh"] diff --git a/playwright/docker-entrypoint.sh b/playwright/docker-entrypoint.sh deleted file mode 100644 index 241528a29a..0000000000 --- a/playwright/docker-entrypoint.sh +++ /dev/null @@ -1,5 +0,0 @@ -#!/bin/bash - -set -e - -npx playwright test --update-snapshots --reporter line $@ diff --git a/playwright/e2e/accessibility/keyboard-navigation.spec.ts b/playwright/e2e/accessibility/keyboard-navigation.spec.ts index 6f4fc9be5f..e22664c898 100644 --- a/playwright/e2e/accessibility/keyboard-navigation.spec.ts +++ b/playwright/e2e/accessibility/keyboard-navigation.spec.ts @@ -123,7 +123,7 @@ test.describe("Landmark navigation tests", () => { await expect(page.getByText("Bob joined the room")).toBeVisible(); // Close the room - page.goto("/#/home"); + await page.goto("/#/home"); // Pressing Control+F6 will first focus the space button await page.keyboard.press("ControlOrMeta+F6"); diff --git a/playwright/e2e/app-loading/guest-registration.spec.ts b/playwright/e2e/app-loading/guest-registration.spec.ts index 1c38177ec7..960b6a6692 100644 --- a/playwright/e2e/app-loading/guest-registration.spec.ts +++ b/playwright/e2e/app-loading/guest-registration.spec.ts @@ -13,7 +13,7 @@ Please see LICENSE files in the repository root for full details. import { expect, test } from "../../element-web-test"; test.use({ - synapseConfigOptions: { + synapseConfig: { allow_guest_access: true, }, }); diff --git a/playwright/e2e/audio-player/audio-player.spec.ts b/playwright/e2e/audio-player/audio-player.spec.ts index a6d920dcb8..a8cb15a5da 100644 --- a/playwright/e2e/audio-player/audio-player.spec.ts +++ b/playwright/e2e/audio-player/audio-player.spec.ts @@ -11,7 +11,15 @@ import type { Locator, Page } from "@playwright/test"; import { test, expect } from "../../element-web-test"; import { SettingLevel } from "../../../src/settings/SettingLevel"; import { Layout } from "../../../src/settings/enums/Layout"; -import { ElementAppPage } from "../../pages/ElementAppPage"; +import { type ElementAppPage } from "../../pages/ElementAppPage"; + +// Find and click "Reply" button +const clickButtonReply = async (tile: Locator) => { + await expect(async () => { + await tile.hover(); + await tile.getByRole("button", { name: "Reply", exact: true }).click(); + }).toPass(); +}; test.describe("Audio player", { tag: ["@no-firefox", "@no-webkit"] }, () => { test.use({ @@ -222,8 +230,7 @@ test.describe("Audio player", { tag: ["@no-firefox", "@no-webkit"] }, () => { // Find and click "Reply" button on MessageActionBar const tile = page.locator(".mx_EventTile_last"); - await tile.hover(); - await tile.getByRole("button", { name: "Reply", exact: true }).click(); + await clickButtonReply(tile); // Reply to the player with another audio file await uploadFile(page, "playwright/sample-files/1sec.ogg"); @@ -251,18 +258,12 @@ test.describe("Audio player", { tag: ["@no-firefox", "@no-webkit"] }, () => { const tile = page.locator(".mx_EventTile_last"); - // Find and click "Reply" button - const clickButtonReply = async () => { - await tile.hover(); - await tile.getByRole("button", { name: "Reply", exact: true }).click(); - }; - await uploadFile(page, "playwright/sample-files/upload-first.ogg"); // Assert that the audio player is rendered await expect(page.locator(".mx_EventTile_last .mx_AudioPlayer_container")).toBeVisible(); - await clickButtonReply(); + await clickButtonReply(tile); // Reply to the player with another audio file await uploadFile(page, "playwright/sample-files/upload-second.ogg"); @@ -270,7 +271,7 @@ test.describe("Audio player", { tag: ["@no-firefox", "@no-webkit"] }, () => { // Assert that the audio player is rendered await expect(page.locator(".mx_EventTile_last .mx_AudioPlayer_container")).toBeVisible(); - await clickButtonReply(); + await clickButtonReply(tile); // Reply to the player with yet another audio file to create a reply chain await uploadFile(page, "playwright/sample-files/upload-third.ogg"); diff --git a/playwright/e2e/chat-export/html-export.spec.ts b/playwright/e2e/chat-export/html-export.spec.ts index 33ca6728c6..760d3cc5f1 100644 --- a/playwright/e2e/chat-export/html-export.spec.ts +++ b/playwright/e2e/chat-export/html-export.spec.ts @@ -95,7 +95,7 @@ test.describe("HTML Export", () => { async ({ page, app, room }) => { // Set a fixed time rather than masking off the line with the time in it: we don't need to worry // about the width changing and we can actually test this line looks correct. - page.clock.setSystemTime(new Date("2024-01-01T00:00:00Z")); + await page.clock.setSystemTime(new Date("2024-01-01T00:00:00Z")); // Send a bunch of messages to populate the room for (let i = 1; i < 10; i++) { diff --git a/playwright/e2e/composer/RTE.spec.ts b/playwright/e2e/composer/RTE.spec.ts index 3b750000c5..e88dd827fc 100644 --- a/playwright/e2e/composer/RTE.spec.ts +++ b/playwright/e2e/composer/RTE.spec.ts @@ -165,7 +165,7 @@ test.describe("Composer", () => { // Type another await page.locator("div[contenteditable=true]").pressSequentially("my message 1"); // Send message - page.locator("div[contenteditable=true]").press("Enter"); + await page.locator("div[contenteditable=true]").press("Enter"); // It was sent await expect(page.locator(".mx_EventTile_last .mx_EventTile_body").getByText("my message 1")).toBeVisible(); }); diff --git a/playwright/e2e/create-room/create-room.spec.ts b/playwright/e2e/create-room/create-room.spec.ts index 4a4fee1620..087a89e68d 100644 --- a/playwright/e2e/create-room/create-room.spec.ts +++ b/playwright/e2e/create-room/create-room.spec.ts @@ -27,7 +27,7 @@ test.describe("Create Room", () => { // Submit await dialog.getByRole("button", { name: "Create room" }).click(); - await expect(page).toHaveURL(/\/#\/room\/#test-room-1:localhost/); + await expect(page).toHaveURL(new RegExp(`/#/room/#test-room-1:${user.homeServer}`)); const header = page.locator(".mx_RoomHeader"); await expect(header).toContainText(name); }); diff --git a/playwright/e2e/crypto/backups-mas.spec.ts b/playwright/e2e/crypto/backups-mas.spec.ts index b51f737255..6519a484e9 100644 --- a/playwright/e2e/crypto/backups-mas.spec.ts +++ b/playwright/e2e/crypto/backups-mas.spec.ts @@ -11,27 +11,31 @@ import { registerAccountMas } from "../oidc"; import { isDendrite } from "../../plugins/homeserver/dendrite"; import { TestClientServerAPI } from "../csAPI"; import { masHomeserver } from "../../plugins/homeserver/synapse/masHomeserver.ts"; +import { checkDeviceIsConnectedKeyBackup } from "./utils"; // These tests register an account with MAS because then we go through the "normal" registration flow -// and crypto gets set up. Using the 'user' fixture create a a user an synthesizes an existing login, +// and crypto gets set up. Using the 'user' fixture create a user and synthesizes an existing login, // which is faster but leaves us without crypto set up. test.use(masHomeserver); test.describe("Encryption state after registration", () => { test.skip(isDendrite, "does not yet support MAS"); - test("Key backup is enabled by default", async ({ page, mailhogClient, app }, testInfo) => { + 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, mailhogClient, `alice_${testInfo.testId}`, "alice@email.com", "Pa$sW0rD!"); + await registerAccountMas(page, mailpitClient, `alice_${testInfo.testId}`, "alice@email.com", "Pa$sW0rD!"); - await app.settings.openUserSettings("Security & Privacy"); - await expect(page.getByText("This session is backing up your keys.")).toBeVisible(); + // Wait for the ui to load + await expect(page.locator(".mx_MatrixChat")).toBeVisible(); + + // Recovery is not set up yet + await checkDeviceIsConnectedKeyBackup(app, "1", true, false); }); - test("user is prompted to set up recovery", async ({ page, mailhogClient, app }, testInfo) => { + 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, mailhogClient, `alice_${testInfo.testId}`, "alice@email.com", "Pa$sW0rD!"); + await registerAccountMas(page, mailpitClient, `alice_${testInfo.testId}`, "alice@email.com", "Pa$sW0rD!"); await page.getByRole("button", { name: "Add room" }).click(); await page.getByRole("menuitem", { name: "New room" }).click(); @@ -47,7 +51,7 @@ test.describe("Key backup reset from elsewhere", () => { test("Key backup is disabled when reset from elsewhere", async ({ page, - mailhogClient, + mailpitClient, request, homeserver, }, testInfo) => { @@ -60,7 +64,7 @@ test.describe("Key backup reset from elsewhere", () => { await page.goto("/#/login"); await page.getByRole("button", { name: "Continue" }).click(); - await registerAccountMas(page, mailhogClient, testUsername, "alice@email.com", testPassword); + await registerAccountMas(page, mailpitClient, testUsername, "alice@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 index a49495edfa..e0cf1cc60b 100644 --- a/playwright/e2e/crypto/backups.spec.ts +++ b/playwright/e2e/crypto/backups.spec.ts @@ -9,6 +9,8 @@ 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( @@ -19,6 +21,7 @@ async function expectBackupVersionToBe(page: Page, version: string) { } test.describe("Backups", () => { + test.skip(isDendrite, "Dendrite lacks support for MSC3967 so requires additional auth here"); test.use({ displayName: "Hanako", }); @@ -33,19 +36,7 @@ test.describe("Backups", () => { await expect(securityTab.getByRole("heading", { name: "Secure Backup" })).toBeVisible(); await securityTab.getByRole("button", { name: "Set up", exact: true }).click(); - const currentDialogLocator = page.locator(".mx_Dialog"); - - // It's the first time and secure storage is not set up, so it will create one - await expect(currentDialogLocator.getByRole("heading", { name: "Set up Secure Backup" })).toBeVisible(); - await currentDialogLocator.getByRole("button", { name: "Continue", exact: true }).click(); - await expect(currentDialogLocator.getByRole("heading", { name: "Save your Security Key" })).toBeVisible(); - await currentDialogLocator.getByRole("button", { name: "Copy", exact: true }).click(); - // copy the recovery key to use it later - const securityKey = await app.getClipboard(); - await currentDialogLocator.getByRole("button", { name: "Continue", exact: true }).click(); - - await expect(currentDialogLocator.getByRole("heading", { name: "Secure Backup successful" })).toBeVisible(); - await currentDialogLocator.getByRole("button", { name: "Done", exact: true }).click(); + const securityKey = await completeCreateSecretStorageDialog(page); // Open the settings again await app.settings.openUserSettings("Security & Privacy"); @@ -60,14 +51,15 @@ test.describe("Backups", () => { 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: "Security Key" })).toBeVisible(); - await currentDialogLocator.getByLabel("Security Key").fill(securityKey); + 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 @@ -98,8 +90,8 @@ test.describe("Backups", () => { // Try to create another await securityTab.getByRole("button", { name: "Set up", exact: true }).click(); - await expect(currentDialogLocator.getByRole("heading", { name: "Security Key" })).toBeVisible(); - // But cancel the security key dialog, to simulate not having the secret storage passphrase + 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(); diff --git a/playwright/e2e/crypto/complete-security.spec.ts b/playwright/e2e/crypto/complete-security.spec.ts index da6974459c..d4c303fae4 100644 --- a/playwright/e2e/crypto/complete-security.spec.ts +++ b/playwright/e2e/crypto/complete-security.spec.ts @@ -8,8 +8,10 @@ Please see LICENSE files in the repository root for full details. import { test, expect } from "../../element-web-test"; import { logIntoElement } from "./utils"; +import { isDendrite } from "../../plugins/homeserver/dendrite"; test.describe("Complete security", () => { + test.skip(isDendrite, "Dendrite lacks support for MSC3967 so requires additional auth here"); test.use({ displayName: "Jeff", }); diff --git a/playwright/e2e/crypto/crypto.spec.ts b/playwright/e2e/crypto/crypto.spec.ts index 2b6844574e..2d294ff7c2 100644 --- a/playwright/e2e/crypto/crypto.spec.ts +++ b/playwright/e2e/crypto/crypto.spec.ts @@ -8,9 +8,17 @@ 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, copyAndContinue, createSharedRoomWithUser, enableKeyBackup, verify } from "./utils"; -import { Bot } from "../../pages/bot"; -import { ElementAppPage } from "../../pages/ElementAppPage"; +import { + autoJoin, + completeCreateSecretStorageDialog, + copyAndContinue, + createSharedRoomWithUser, + enableKeyBackup, + verify, +} from "./utils"; +import { type Bot } from "../../pages/bot"; +import { type ElementAppPage } from "../../pages/ElementAppPage"; +import { isDendrite } from "../../plugins/homeserver/dendrite"; const checkDMRoom = async (page: Page) => { const body = page.locator(".mx_RoomView_body"); @@ -20,7 +28,7 @@ const checkDMRoom = async (page: Page) => { }; const startDMWithBob = async (page: Page, bob: Bot) => { - await page.locator(".mx_RoomList").getByRole("button", { name: "Start chat" }).click(); + await page.locator(".mx_LegacyRoomList").getByRole("button", { name: "Start chat" }).click(); await page.getByTestId("invite-dialog-input").fill(bob.credentials.userId); await page.locator(".mx_InviteDialog_tile_nameStack_name").getByText("Bob").click(); await expect( @@ -67,6 +75,7 @@ const bobJoin = async (page: Page, bob: Bot) => { }; test.describe("Cryptography", function () { + test.skip(isDendrite, "Dendrite lacks support for MSC3967 so requires additional auth here"); test.use({ displayName: "Alice", botCreateOpts: { @@ -109,18 +118,7 @@ test.describe("Cryptography", function () { await app.settings.openUserSettings("Security & Privacy"); await page.getByRole("button", { name: "Set up Secure Backup" }).click(); - const dialog = page.locator(".mx_Dialog"); - // Recovery key is selected by default - await dialog.getByRole("button", { name: "Continue" }).click(); - await copyAndContinue(page); - - // If the device is unverified, there should be a "Setting up keys" step; however, it - // can be quite quick, and playwright can miss it, so we can't test for it. - - // Either way, we end up at a success dialog: - await expect(dialog.getByText("Secure Backup successful")).toBeVisible(); - await dialog.getByRole("button", { name: "Done" }).click(); - await expect(dialog.getByText("Secure Backup successful")).not.toBeVisible(); + await completeCreateSecretStorageDialog(page); // Verify that the SSSS keys are in the account data stored in the server await verifyKey(app, "master"); @@ -188,7 +186,7 @@ test.describe("Cryptography", function () { await page.getByRole("button", { name: "Clear cross-signing keys" }).click(); // Enter the 4S key - await page.getByPlaceholder("Security Key").fill(secretStorageKey); + await page.getByPlaceholder("Recovery Key").fill(secretStorageKey); await page.getByRole("button", { name: "Continue" }).click(); // Enter the password diff --git a/playwright/e2e/crypto/decryption-failure-messages.spec.ts b/playwright/e2e/crypto/decryption-failure-messages.spec.ts index e1952bfec6..529251b223 100644 --- a/playwright/e2e/crypto/decryption-failure-messages.spec.ts +++ b/playwright/e2e/crypto/decryption-failure-messages.spec.ts @@ -28,6 +28,8 @@ test.describe("Cryptography", function () { }); test.describe("decryption failure messages", () => { + test.skip(isDendrite, "Dendrite lacks support for MSC3967 so requires additional auth here"); + test("should handle device-relative historical messages", async ({ homeserver, page, diff --git a/playwright/e2e/crypto/dehydration.spec.ts b/playwright/e2e/crypto/dehydration.spec.ts index 6d7b6c0c7e..89ee854c91 100644 --- a/playwright/e2e/crypto/dehydration.spec.ts +++ b/playwright/e2e/crypto/dehydration.spec.ts @@ -6,45 +6,28 @@ 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 { Locator, type Page } from "@playwright/test"; - import { test, expect } from "../../element-web-test"; -import { viewRoomSummaryByName } from "../right-panel/utils"; import { isDendrite } from "../../plugins/homeserver/dendrite"; +import { completeCreateSecretStorageDialog, createBot, logIntoElement } from "./utils.ts"; +import { type Client } from "../../pages/client.ts"; +import { type ElementAppPage } from "../../pages/ElementAppPage.ts"; -const ROOM_NAME = "Test room"; const NAME = "Alice"; -function getMemberTileByName(page: Page, name: string): Locator { - return page.locator(`.mx_MemberTileView, [title="${name}"]`); -} - test.use({ displayName: NAME, - synapseConfigOptions: { + synapseConfig: { experimental_features: { msc2697_enabled: false, msc3814_enabled: true, }, }, - config: async ({ config, context }, use) => { - const wellKnown = { - ...config.default_server_config, - "org.matrix.msc3814": true, - }; - - await context.route("https://localhost/.well-known/matrix/client", async (route) => { - await route.fulfill({ json: wellKnown }); - }); - - await use(config); - }, }); test.describe("Dehydration", () => { test.skip(isDendrite, "does not yet support dehydration v2"); - test("Create dehydrated device", async ({ page, user, app }, workerInfo) => { + test("'Set up secure backup' creates dehydrated device", async ({ page, user, app }, workerInfo) => { // Create a backup (which will create SSSS, and dehydrated device) const securityTab = await app.settings.openUserSettings("Security & Privacy"); @@ -53,47 +36,146 @@ test.describe("Dehydration", () => { await expect(securityTab.getByText("Offline device enabled")).not.toBeVisible(); await securityTab.getByRole("button", { name: "Set up", exact: true }).click(); - const currentDialogLocator = page.locator(".mx_Dialog"); + await completeCreateSecretStorageDialog(page); - // It's the first time and secure storage is not set up, so it will create one - await expect(currentDialogLocator.getByRole("heading", { name: "Set up Secure Backup" })).toBeVisible(); - await currentDialogLocator.getByRole("button", { name: "Continue", exact: true }).click(); - await expect(currentDialogLocator.getByRole("heading", { name: "Save your Security Key" })).toBeVisible(); - await currentDialogLocator.getByRole("button", { name: "Copy", exact: true }).click(); - await currentDialogLocator.getByRole("button", { name: "Continue", exact: true }).click(); - - await expect(currentDialogLocator.getByRole("heading", { name: "Secure Backup successful" })).toBeVisible(); - await currentDialogLocator.getByRole("button", { name: "Done", exact: true }).click(); - - // Open the settings again - await app.settings.openUserSettings("Security & Privacy"); - - // The Security tab should indicate that there is a dehydrated device present - await expect(securityTab.getByText("Offline device enabled")).toBeVisible(); - - await app.settings.closeDialog(); + await expectDehydratedDeviceEnabled(app); // the dehydrated device gets created with the name "Dehydrated // device". We want to make sure that it is not visible as a normal // device. const sessionsTab = await app.settings.openUserSettings("Sessions"); await expect(sessionsTab.getByText("Dehydrated device")).not.toBeVisible(); + }); + + test("'Set up recovery' creates dehydrated device", async ({ app, credentials, page }) => { + await logIntoElement(page, credentials); + + const settingsDialogLocator = await app.settings.openUserSettings("Encryption"); + await settingsDialogLocator.getByRole("button", { name: "Set up recovery" }).click(); + + // First it displays an informative panel about the recovery key + await expect(settingsDialogLocator.getByRole("heading", { name: "Set up recovery" })).toBeVisible(); + await settingsDialogLocator.getByRole("button", { name: "Continue" }).click(); + + // Next, it displays the new recovery key. We click on the copy button. + await expect(settingsDialogLocator.getByText("Save your recovery key somewhere safe")).toBeVisible(); + await settingsDialogLocator.getByRole("button", { name: "Copy" }).click(); + const recoveryKey = await app.getClipboard(); + await settingsDialogLocator.getByRole("button", { name: "Continue" }).click(); + + await expect( + settingsDialogLocator.getByText("Enter your recovery key to confirm", { exact: true }), + ).toBeVisible(); + await settingsDialogLocator.getByRole("textbox").fill(recoveryKey); + await settingsDialogLocator.getByRole("button", { name: "Finish set up" }).click(); await app.settings.closeDialog(); - // now check that the user info right-panel shows the dehydrated device - // as a feature rather than as a normal device - await app.client.createRoom({ name: ROOM_NAME }); + await expectDehydratedDeviceEnabled(app); + }); - await viewRoomSummaryByName(page, app, ROOM_NAME); + test("Reset recovery key during login re-creates dehydrated device", async ({ + page, + homeserver, + app, + credentials, + }) => { + // Set up cross-signing and recovery + const { botClient } = await createBot(page, homeserver, credentials); + // ... and dehydration + await botClient.evaluate(async (client) => await client.getCrypto().startDehydration()); - await page.locator(".mx_RightPanel").getByRole("menuitem", { name: "People" }).click(); - await expect(page.locator(".mx_MemberListView")).toBeVisible(); + const initialDehydratedDeviceIds = await getDehydratedDeviceIds(botClient); + expect(initialDehydratedDeviceIds.length).toBe(1); - await getMemberTileByName(page, NAME).click(); - await page.locator(".mx_UserInfo_devices .mx_UserInfo_expand").click(); + await botClient.evaluate(async (client) => client.stopClient()); - await expect(page.locator(".mx_UserInfo_devices").getByText("Offline device enabled")).toBeVisible(); - await expect(page.locator(".mx_UserInfo_devices").getByText("Dehydrated device")).not.toBeVisible(); + // Log in our client + await logIntoElement(page, credentials); + + // Oh no, we forgot our recovery key + await page.locator(".mx_AuthPage").getByRole("button", { name: "Reset all" }).click(); + await page.locator(".mx_AuthPage").getByRole("button", { name: "Proceed with reset" }).click(); + + await completeCreateSecretStorageDialog(page, { accountPassword: credentials.password }); + + // 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]); + }); + + test("'Reset cryptographic identity' removes dehydrated device", async ({ page, homeserver, app, credentials }) => { + await logIntoElement(page, credentials); + + // Create a dehydrated device by setting up recovery (see "'Set up + // recovery' creates dehydrated device" test above) + const settingsDialogLocator = await app.settings.openUserSettings("Encryption"); + await settingsDialogLocator.getByRole("button", { name: "Set up recovery" }).click(); + + // First it displays an informative panel about the recovery key + await expect(settingsDialogLocator.getByRole("heading", { name: "Set up recovery" })).toBeVisible(); + await settingsDialogLocator.getByRole("button", { name: "Continue" }).click(); + + // Next, it displays the new recovery key. We click on the copy button. + await expect(settingsDialogLocator.getByText("Save your recovery key somewhere safe")).toBeVisible(); + await settingsDialogLocator.getByRole("button", { name: "Copy" }).click(); + const recoveryKey = await app.getClipboard(); + await settingsDialogLocator.getByRole("button", { name: "Continue" }).click(); + + await expect( + settingsDialogLocator.getByText("Enter your recovery key to confirm", { exact: true }), + ).toBeVisible(); + await settingsDialogLocator.getByRole("textbox").fill(recoveryKey); + await settingsDialogLocator.getByRole("button", { name: "Finish set up" }).click(); + + await expectDehydratedDeviceEnabled(app); + + // After recovery is set up, we reset our cryptographic identity, which + // should drop the dehydrated device. + await settingsDialogLocator.getByRole("button", { name: "Reset cryptographic identity" }).click(); + await settingsDialogLocator.getByRole("button", { name: "Continue" }).click(); + + await expectDehydratedDeviceDisabled(app); }); }); + +async function getDehydratedDeviceIds(client: Client): Promise { + return await client.evaluate(async (client) => { + const userId = client.getUserId(); + const devices = await client.getCrypto().getUserDeviceInfo([userId]); + return Array.from( + devices + .get(userId) + .values() + .filter((d) => d.dehydrated) + .map((d) => d.deviceId), + ); + }); +} + +/** Wait for our user to have a dehydrated device */ +async function expectDehydratedDeviceEnabled(app: ElementAppPage): Promise { + // It might be nice to do this via the UI, but currently this info is not exposed via the UI. + // + // Note we might have to wait for the device list to be refreshed, so we wrap in `expect.poll`. + await expect + .poll(async () => { + const dehydratedDeviceIds = await getDehydratedDeviceIds(app.client); + return dehydratedDeviceIds.length; + }) + .toEqual(1); +} + +/** Wait for our user to not have a dehydrated device */ +async function expectDehydratedDeviceDisabled(app: ElementAppPage): Promise { + // It might be nice to do this via the UI, but currently this info is not exposed via the UI. + // + // Note we might have to wait for the device list to be refreshed, so we wrap in `expect.poll`. + await expect + .poll(async () => { + const dehydratedDeviceIds = await getDehydratedDeviceIds(app.client); + return dehydratedDeviceIds.length; + }) + .toEqual(0); +} diff --git a/playwright/e2e/crypto/device-verification.spec.ts b/playwright/e2e/crypto/device-verification.spec.ts index a028bfb70c..9be79452c4 100644 --- a/playwright/e2e/crypto/device-verification.spec.ts +++ b/playwright/e2e/crypto/device-verification.spec.ts @@ -15,11 +15,13 @@ import { awaitVerifier, checkDeviceIsConnectedKeyBackup, checkDeviceIsCrossSigned, + createBot, doTwoWaySasVerification, logIntoElement, waitForVerificationRequest, } from "./utils"; -import { Bot } from "../../pages/bot"; +import { type Bot } from "../../pages/bot"; +import { Toasts } from "../../pages/toasts.ts"; test.describe("Device verification", { tag: "@no-webkit" }, () => { let aliceBotClient: Bot; @@ -28,29 +30,9 @@ test.describe("Device verification", { tag: "@no-webkit" }, () => { let expectedBackupVersion: string; test.beforeEach(async ({ page, homeserver, credentials }) => { - // Visit the login page of the app, to load the matrix sdk - await page.goto("/#/login"); - - // wait for the page to load - await page.waitForSelector(".mx_AuthPage", { timeout: 30000 }); - - // Create a new device for alice - aliceBotClient = new Bot(page, homeserver, { - bootstrapCrossSigning: true, - bootstrapSecretStorage: true, - }); - aliceBotClient.setCredentials(credentials); - - // Backup is prepared in the background. Poll until it is ready. - const botClientHandle = await aliceBotClient.prepareClient(); - await expect - .poll(async () => { - expectedBackupVersion = await botClientHandle.evaluate((cli) => - cli.getCrypto()!.getActiveSessionBackupVersion(), - ); - return expectedBackupVersion; - }) - .not.toBe(null); + const res = await createBot(page, homeserver, credentials, true); + aliceBotClient = res.botClient; + expectedBackupVersion = res.expectedBackupVersion; }); // Click the "Verify with another device" button, and have the bot client auto-accept it. @@ -87,8 +69,53 @@ test.describe("Device verification", { tag: "@no-webkit" }, () => { // Check that the current device is connected to key backup // For now we don't check that the backup key is in cache because it's a bit flaky, - // as we need to wait for the secret gossiping to happen and the settings dialog doesn't refresh automatically. - await checkDeviceIsConnectedKeyBackup(page, expectedBackupVersion, false); + // as we need to wait for the secret gossiping to happen. + await checkDeviceIsConnectedKeyBackup(app, expectedBackupVersion, false); + }); + + // Regression test for https://github.com/element-hq/element-web/issues/29110 + test("No toast after verification, even if the secrets take a while to arrive", async ({ page, credentials }) => { + // Before we log in, the bot creates an encrypted room, so that we can test the toast behaviour that only happens + // when we are in an encrypted room. + await aliceBotClient.createRoom({ + initial_state: [ + { + type: "m.room.encryption", + state_key: "", + content: { algorithm: "m.megolm.v1.aes-sha2" }, + }, + ], + }); + + // In order to simulate a real environment more accurately, we need to slow down the arrival of the + // `m.secret.send` to-device messages. That's slightly tricky to do directly, so instead we delay the *outgoing* + // `m.secret.request` messages. + await page.route("**/_matrix/client/v3/sendToDevice/m.secret.request/**", async (route) => { + await route.fulfill({ json: {} }); + await new Promise((f) => setTimeout(f, 1000)); + await route.fetch(); + }); + + await logIntoElement(page, credentials); + + // Launch the verification request between alice and the bot + const verificationRequest = await initiateAliceVerificationRequest(page); + + // Handle emoji SAS verification + const infoDialog = page.locator(".mx_InfoDialog"); + // the bot chooses to do an emoji verification + const verifier = await verificationRequest.evaluateHandle((request) => request.startVerification("m.sas.v1")); + + // Handle emoji request and check that emojis are matching + await doTwoWaySasVerification(page, verifier); + + await infoDialog.getByRole("button", { name: "They match" }).click(); + await infoDialog.getByRole("button", { name: "Got it" }).click(); + + // There should be no toast (other than the notifications one) + const toasts = new Toasts(page); + await toasts.rejectToast("Notifications"); + await toasts.assertNoToasts(); }); test("Verify device with QR code during login", async ({ page, app, credentials, homeserver }) => { @@ -131,16 +158,14 @@ test.describe("Device verification", { tag: "@no-webkit" }, () => { await checkDeviceIsCrossSigned(app); // Check that the current device is connected to key backup - // For now we don't check that the backup key is in cache because it's a bit flaky, - // as we need to wait for the secret gossiping to happen and the settings dialog doesn't refresh automatically. - await checkDeviceIsConnectedKeyBackup(page, expectedBackupVersion, false); + await checkDeviceIsConnectedKeyBackup(app, expectedBackupVersion, true); }); test("Verify device with Security Phrase during login", async ({ page, app, credentials, homeserver }) => { await logIntoElement(page, credentials); // Select the security phrase - await page.locator(".mx_AuthPage").getByRole("button", { name: "Verify with Security Key or Phrase" }).click(); + await page.locator(".mx_AuthPage").getByRole("button", { name: "Verify with Recovery Key or Phrase" }).click(); // Fill the passphrase const dialog = page.locator(".mx_Dialog"); @@ -154,18 +179,18 @@ 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(page, expectedBackupVersion, true); + await checkDeviceIsConnectedKeyBackup(app, expectedBackupVersion, true); }); - test("Verify device with Security Key during login", async ({ page, app, credentials, homeserver }) => { + test("Verify device with Recovery Key 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 Security Key or Phrase" }).click(); + await page.locator(".mx_AuthPage").getByRole("button", { name: "Verify with Recovery Key or Phrase" }).click(); - // Fill the security key + // Fill the recovery key const dialog = page.locator(".mx_Dialog"); - await dialog.getByRole("button", { name: "use your Security Key" }).click(); + 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(); @@ -177,7 +202,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(page, expectedBackupVersion, true); + await checkDeviceIsConnectedKeyBackup(app, expectedBackupVersion, true); }); test("Handle incoming verification request with SAS", async ({ page, credentials, homeserver, toasts }) => { @@ -212,16 +237,17 @@ test.describe("Device verification", { tag: "@no-webkit" }, () => { /* on the bot side, wait for the verifier to exist ... */ const verifier = await awaitVerifier(botVerificationRequest); // ... confirm ... - botVerificationRequest.evaluate((verificationRequest) => verificationRequest.verifier.verify()); + void botVerificationRequest.evaluate((verificationRequest) => verificationRequest.verifier.verify()); // ... and then check the emoji match await doTwoWaySasVerification(page, verifier); /* And we're all done! */ const infoDialog = page.locator(".mx_InfoDialog"); await infoDialog.getByRole("button", { name: "They match" }).click(); - await expect( - infoDialog.getByText(`You've successfully verified (${aliceBotClient.credentials.deviceId})!`), - ).toBeVisible(); + // We don't assert the full string as the device name is unset on Synapse but set to the user ID on Dendrite + await expect(infoDialog.getByText(`You've successfully verified`)).toContainText( + `(${aliceBotClient.credentials.deviceId})`, + ); await infoDialog.getByRole("button", { name: "Got it" }).click(); }); }); diff --git a/playwright/e2e/crypto/event-shields.spec.ts b/playwright/e2e/crypto/event-shields.spec.ts index 3811c2819e..7ac0df28ec 100644 --- a/playwright/e2e/crypto/event-shields.spec.ts +++ b/playwright/e2e/crypto/event-shields.spec.ts @@ -6,7 +6,7 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com Please see LICENSE files in the repository root for full details. */ -import { Locator } from "@playwright/test"; +import { type Locator } from "@playwright/test"; import { expect, test } from "../../element-web-test"; import { @@ -17,9 +17,10 @@ import { logIntoElement, logOutOfElement, verify, + waitForDevices, } from "./utils"; import { bootstrapCrossSigningForClient } from "../../pages/client.ts"; -import { ElementAppPage } from "../../pages/ElementAppPage.ts"; +import { type ElementAppPage } from "../../pages/ElementAppPage.ts"; test.describe("Cryptography", function () { test.use({ @@ -66,6 +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(); + await bob.sendEvent(testRoomId, null, "m.room.encrypted", { algorithm: "m.megolm.v1.aes-sha2", ciphertext: "the bird is in the hand", @@ -141,25 +145,8 @@ test.describe("Cryptography", function () { // bob deletes his second device await bobSecondDevice.evaluate((cli) => cli.logout(true)); - // wait for the logout to propagate. Workaround for https://github.com/vector-im/element-web/issues/26263 by repeatedly closing and reopening Bob's user info. - async function awaitOneDevice(iterations = 1) { - const rightPanel = page.locator(".mx_RightPanel"); - await rightPanel.getByTestId("base-card-back-button").click(); - await rightPanel.getByText("Bob").click(); - const sessionCountText = await rightPanel - .locator(".mx_UserInfo_devices") - .getByText(" session", { exact: false }) - .textContent(); - // cf https://github.com/vector-im/element-web/issues/26279: Element-R uses the wrong text here - if (sessionCountText != "1 session" && sessionCountText != "1 verified session") { - if (iterations >= 10) { - throw new Error(`Bob still has ${sessionCountText} after 10 iterations`); - } - await awaitOneDevice(iterations + 1); - } - } - - await awaitOneDevice(); + // wait for the logout to propagate. + await waitForDevices(app, bob.credentials.userId, 1); // close and reopen the room, to get the shield to update. await app.viewRoomByName("Bob"); @@ -282,11 +269,7 @@ test.describe("Cryptography", function () { // Workaround for https://github.com/element-hq/element-web/issues/28640: // make sure that Alice has seen Bob's identity before she goes offline. We do this by opening // his user info. - await app.toggleRoomInfoPanel(); - const rightPanel = page.locator(".mx_RightPanel"); - await rightPanel.getByRole("menuitem", { name: "People" }).click(); - await rightPanel.getByRole("button", { name: bob.credentials!.userId }).click(); - await expect(rightPanel.locator(".mx_UserInfo_devices")).toContainText("1 session"); + await waitForDevices(app, bob.credentials.userId, 1); // Our app is blocked from syncing while Bob sends his messages. await app.client.network.goOffline(); diff --git a/playwright/e2e/crypto/invisible-crypto.spec.ts b/playwright/e2e/crypto/invisible-crypto.spec.ts index f1272ec710..198abdac6d 100644 --- a/playwright/e2e/crypto/invisible-crypto.spec.ts +++ b/playwright/e2e/crypto/invisible-crypto.spec.ts @@ -11,6 +11,7 @@ import { bootstrapCrossSigningForClient } from "../../pages/client.ts"; /** Tests for the "invisible crypto" behaviour -- i.e., when the "exclude insecure devices" setting is enabled */ test.describe("Invisible cryptography", () => { + test.slow(); test.use({ displayName: "Alice", botCreateOpts: { displayName: "Bob" }, diff --git a/playwright/e2e/crypto/logout.spec.ts b/playwright/e2e/crypto/logout.spec.ts index 2bafe0ece8..faaf1e6a1e 100644 --- a/playwright/e2e/crypto/logout.spec.ts +++ b/playwright/e2e/crypto/logout.spec.ts @@ -8,8 +8,10 @@ Please see LICENSE files in the repository root for full details. import { test, expect } from "../../element-web-test"; import { createRoom, enableKeyBackup, logIntoElement, sendMessageInCurrentRoom } from "./utils"; +import { isDendrite } from "../../plugins/homeserver/dendrite"; test.describe("Logout tests", () => { + test.skip(isDendrite, "Dendrite lacks support for MSC3967 so requires additional auth here"); test.beforeEach(async ({ page, homeserver, credentials }) => { await logIntoElement(page, credentials); }); diff --git a/playwright/e2e/crypto/toasts.spec.ts b/playwright/e2e/crypto/toasts.spec.ts new file mode 100644 index 0000000000..905a8fb1ed --- /dev/null +++ b/playwright/e2e/crypto/toasts.spec.ts @@ -0,0 +1,53 @@ +/* + * 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 GeneratedSecretStorageKey } from "matrix-js-sdk/src/crypto-api"; + +import { test, expect } from "../../element-web-test"; +import { createBot, deleteCachedSecrets, logIntoElement } from "./utils"; + +test.describe("Key storage out of sync toast", () => { + let recoveryKey: GeneratedSecretStorageKey; + + test.beforeEach(async ({ page, homeserver, credentials }) => { + const res = await createBot(page, homeserver, credentials); + recoveryKey = res.recoveryKey; + + await logIntoElement(page, credentials, recoveryKey.encodedPrivateKey); + + await deleteCachedSecrets(page); + + // We won't be prompted for crypto setup unless we have an e2e room, so make one + await page.getByRole("button", { name: "Add room" }).click(); + await page.getByRole("menuitem", { name: "New room" }).click(); + await page.getByRole("textbox", { name: "Name" }).fill("Test room"); + await page.getByRole("button", { name: "Create room" }).click(); + }); + + test("should prompt for recovery key if 'enter recovery key' pressed", { tag: "@screenshot" }, async ({ page }) => { + // Need to wait for 2 to appear since playwright only evaluates 'first()' initially, so the waiting won't work + await expect(page.getByRole("alert")).toHaveCount(2); + await expect(page.getByRole("alert").first()).toMatchScreenshot("key-storage-out-of-sync-toast.png"); + + await page.getByRole("button", { name: "Enter recovery key" }).click(); + + await page.getByRole("textbox", { name: "Recovery Key" }).fill(recoveryKey.encodedPrivateKey); + await page.getByRole("button", { name: "Continue" }).click(); + + await expect(page.getByRole("button", { name: "Enter recovery key" })).not.toBeVisible(); + }); + + test("should open settings to reset flow if 'forgot recovery key' pressed", async ({ page, app, credentials }) => { + await expect(page.getByRole("button", { name: "Enter recovery key" })).toBeVisible(); + + await page.getByRole("button", { name: "Forgot recovery key?" }).click(); + + await expect( + page.getByRole("heading", { name: "Forgot your recovery key? You’ll need to reset your identity." }), + ).toBeVisible(); + }); +}); diff --git a/playwright/e2e/crypto/user-verification.spec.ts b/playwright/e2e/crypto/user-verification.spec.ts index 7d428ac060..ebe86c0a6e 100644 --- a/playwright/e2e/crypto/user-verification.spec.ts +++ b/playwright/e2e/crypto/user-verification.spec.ts @@ -8,10 +8,9 @@ Please see LICENSE files in the repository root for full details. import { type Preset, type Visibility } from "matrix-js-sdk/src/matrix"; -import type { Page } from "@playwright/test"; import { test, expect } from "../../element-web-test"; -import { doTwoWaySasVerification, awaitVerifier } from "./utils"; -import { Client } from "../../pages/client"; +import { doTwoWaySasVerification, awaitVerifier, waitForDevices } from "./utils"; +import { type Client } from "../../pages/client"; test.describe("User verification", () => { // note that there are other tests that check user verification works in `crypto.spec.ts`. @@ -33,13 +32,17 @@ test.describe("User verification", () => { }); test("can receive a verification request when there is no existing DM", async ({ + app, page, bot: bob, user: aliceCredentials, toasts, room: { roomId: dmRoomId }, }) => { - await waitForDeviceKeys(page); + await waitForDevices(app, bob.credentials.userId, 1); + await expect(page.getByRole("button", { name: "Avatar" })).toBeVisible(); + const avatar = page.getByRole("button", { name: "Avatar" }); + await avatar.click(); // once Alice has joined, Bob starts the verification const bobVerificationRequest = await bob.evaluateHandle( @@ -74,7 +77,7 @@ test.describe("User verification", () => { /* on the bot side, wait for the verifier to exist ... */ const botVerifier = await awaitVerifier(bobVerificationRequest); // ... confirm ... - botVerifier.evaluate((verifier) => verifier.verify()); + void botVerifier.evaluate((verifier) => verifier.verify()); // ... and then check the emoji match await doTwoWaySasVerification(page, botVerifier); @@ -84,13 +87,17 @@ test.describe("User verification", () => { }); test("can abort emoji verification when emoji mismatch", async ({ + app, page, bot: bob, user: aliceCredentials, toasts, room: { roomId: dmRoomId }, }) => { - await waitForDeviceKeys(page); + await waitForDevices(app, bob.credentials.userId, 1); + await expect(page.getByRole("button", { name: "Avatar" })).toBeVisible(); + const avatar = page.getByRole("button", { name: "Avatar" }); + await avatar.click(); // once Alice has joined, Bob starts the verification const bobVerificationRequest = await bob.evaluateHandle( @@ -154,15 +161,3 @@ async function createDMRoom(client: Client, userId: string): Promise { ], }); } - -/** - * Wait until we get the other user's device keys. - * In newer rust-crypto versions, the verification request will be ignored if we - * don't have the sender's device keys. - */ -async function waitForDeviceKeys(page: Page): Promise { - await expect(page.getByRole("button", { name: "Avatar" })).toBeVisible(); - const avatar = await page.getByRole("button", { name: "Avatar" }); - await avatar.click(); - await expect(page.getByText("1 session")).toBeVisible(); -} diff --git a/playwright/e2e/crypto/utils.ts b/playwright/e2e/crypto/utils.ts index 48da798f1a..a317a2a215 100644 --- a/playwright/e2e/crypto/utils.ts +++ b/playwright/e2e/crypto/utils.ts @@ -6,22 +6,66 @@ 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 { expect, JSHandle, type Page } from "@playwright/test"; +import { expect, type JSHandle, type Page } from "@playwright/test"; import type { ICreateRoomOpts, MatrixClient } from "matrix-js-sdk/src/matrix"; import type { CryptoEvent, EmojiMapping, + GeneratedSecretStorageKey, ShowSasCallbacks, VerificationRequest, Verifier, VerifierEvent, } from "matrix-js-sdk/src/crypto-api"; -import { Credentials, HomeserverInstance } from "../../plugins/homeserver"; -import { Client } from "../../pages/client"; -import { ElementAppPage } from "../../pages/ElementAppPage"; +import { type Credentials, type HomeserverInstance } from "../../plugins/homeserver"; +import { type Client } from "../../pages/client"; +import { type ElementAppPage } from "../../pages/ElementAppPage"; import { Bot } from "../../pages/bot"; +/** + * Create a bot client using the supplied credentials, and wait for the key backup to be ready. + * @param page - the playwright `page` fixture + * @param homeserver - the homeserver to use + * @param credentials - the credentials to use for the bot client + * @param usePassphrase - whether to use a passphrase when creating the recovery key + */ +export async function createBot( + page: Page, + homeserver: HomeserverInstance, + credentials: Credentials, + usePassphrase = false, +): Promise<{ botClient: Bot; recoveryKey: GeneratedSecretStorageKey; expectedBackupVersion: string }> { + // Visit the login page of the app, to load the matrix sdk + await page.goto("/#/login"); + + // wait for the page to load + await page.waitForSelector(".mx_AuthPage", { timeout: 30000 }); + + // Create a new bot client + const botClient = new Bot(page, homeserver, { + bootstrapCrossSigning: true, + bootstrapSecretStorage: true, + usePassphrase, + }); + botClient.setCredentials(credentials); + // Backup is prepared in the background. Poll until it is ready. + const botClientHandle = await botClient.prepareClient(); + let expectedBackupVersion: string; + await expect + .poll(async () => { + expectedBackupVersion = await botClientHandle.evaluate((cli) => + cli.getCrypto()!.getActiveSessionBackupVersion(), + ); + return expectedBackupVersion; + }) + .not.toBe(null); + + const recoveryKey = await botClient.getRecoveryKey(); + + return { botClient, recoveryKey, expectedBackupVersion }; +} + /** * wait for the given client to receive an incoming verification request, and automatically accept it * @@ -59,7 +103,7 @@ export function handleSasVerification(verifier: JSHandle): Promise((resolve) => { const onShowSas = (event: ShowSasCallbacks) => { verifier.off("show_sas" as VerifierEvent, onShowSas); - event.confirm(); + void event.confirm(); resolve(event.sas.emoji); }; @@ -98,14 +142,16 @@ export async function checkDeviceIsCrossSigned(app: ElementAppPage): Promise { // Sanity check the given backup version: if it's null, something went wrong earlier in the test. if (!expectedBackupVersion) { @@ -114,23 +160,48 @@ export async function checkDeviceIsConnectedKeyBackup( ); } - await page.getByRole("button", { name: "User menu" }).click(); - await page.locator(".mx_UserMenu_contextMenu").getByRole("menuitem", { name: "Security & Privacy" }).click(); - await expect(page.locator(".mx_Dialog").getByRole("button", { name: "Restore from Backup" })).toBeVisible(); + const backupData = await app.client.evaluate(async (client: MatrixClient) => { + const crypto = client.getCrypto(); + if (!crypto) return; - // expand the advanced section to see the active version in the reports - await page.locator(".mx_SecureBackupPanel_advanced").locator("..").click(); + const backupInfo = await crypto.getKeyBackupInfo(); + const backupKeyIn4S = Boolean(await client.isKeyBackupKeyStored()); + const backupPrivateKeyFromCache = await crypto.getSessionBackupPrivateKey(); + const hasBackupPrivateKeyFromCache = Boolean(backupPrivateKeyFromCache); + const backupPrivateKeyWellFormed = backupPrivateKeyFromCache instanceof Uint8Array; + const activeBackupVersion = await crypto.getActiveSessionBackupVersion(); - if (checkBackupKeyInCache) { - const cacheDecryptionKeyStatusElement = page.locator(".mx_SecureBackupPanel_statusList tr:nth-child(2) td"); - await expect(cacheDecryptionKeyStatusElement).toHaveText("cached locally, well formed"); + return { + backupInfo, + hasBackupPrivateKeyFromCache, + backupPrivateKeyWellFormed, + backupKeyIn4S, + activeBackupVersion, + }; + }); + + if (!backupData) { + throw new Error("Crypto module is not available"); } - await expect(page.locator(".mx_SecureBackupPanel_statusList tr:nth-child(5) td")).toHaveText( - expectedBackupVersion + " (Algorithm: m.megolm_backup.v1.curve25519-aes-sha2)", - ); + const { backupInfo, backupKeyIn4S, hasBackupPrivateKeyFromCache, backupPrivateKeyWellFormed, activeBackupVersion } = + backupData; - await expect(page.locator(".mx_SecureBackupPanel_statusList tr:nth-child(6) td")).toHaveText(expectedBackupVersion); + // We have a key backup + expect(backupInfo).toBeDefined(); + // The key backup version is as expected + expect(backupInfo.version).toBe(expectedBackupVersion); + // The active backup version is as expected + expect(activeBackupVersion).toBe(expectedBackupVersion); + // The backup key is stored in 4S + if (checkBackupKeyIn4S) expect(backupKeyIn4S).toBe(true); + + if (checkBackupPrivateKeyInCache) { + // The backup key is available locally + expect(hasBackupPrivateKeyFromCache).toBe(true); + // The backup key is well-formed + expect(backupPrivateKeyWellFormed).toBe(true); + } } /** @@ -147,8 +218,13 @@ export async function logIntoElement(page: Page, credentials: Credentials, secur // if a securityKey was given, verify the new device if (securityKey !== undefined) { - await page.locator(".mx_AuthPage").getByRole("button", { name: "Verify with Security Key" }).click(); - // Fill in the security key + await page.locator(".mx_AuthPage").getByRole("button", { name: "Verify with Recovery Key" }).click(); + + const useSecurityKey = page.locator(".mx_Dialog").getByRole("button", { name: "use your Recovery Key" }); + if (await useSecurityKey.isVisible()) { + 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.getByRole("button", { name: "Done" }).click(); @@ -175,18 +251,19 @@ export async function logOutOfElement(page: Page, discardKeys: boolean = false) } /** - * Open the security settings, and verify the current session using the security key. + * Open the encryption settings, and verify the current session using the recovery key. * * @param app - `ElementAppPage` wrapper for the playwright `Page`. - * @param securityKey - The security key (i.e., 4S key), set up during a previous session. + * @param securityKey - The recovery key (i.e., 4S key), set up during a previous session. */ export async function verifySession(app: ElementAppPage, securityKey: string) { - const settings = await app.settings.openUserSettings("Security & Privacy"); - await settings.getByRole("button", { name: "Verify this session" }).click(); - await app.page.getByRole("button", { name: "Verify with Security Key" }).click(); + 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.getByRole("button", { name: "Continue", disabled: false }).click(); await app.page.getByRole("button", { name: "Done" }).click(); + await app.settings.closeDialog(); } /** @@ -216,28 +293,61 @@ export async function doTwoWaySasVerification(page: Page, verifier: JSHandle { await app.settings.openUserSettings("Security & Privacy"); await app.page.getByRole("button", { name: "Set up Secure Backup" }).click(); - const dialog = app.page.locator(".mx_Dialog"); - // Recovery key is selected by default - await dialog.getByRole("button", { name: "Continue" }).click({ timeout: 60000 }); - // copy the text ourselves - const securityKey = await dialog.locator(".mx_CreateSecretStorageDialog_recoveryKey code").textContent(); - await copyAndContinue(app.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(); - - return securityKey; + return await completeCreateSecretStorageDialog(app.page); } /** - * Click on copy and continue buttons to dismiss the security key dialog + * Go through the "Set up Secure Backup" dialog (aka the `CreateSecretStorageDialog`). + * + * Assumes the dialog is already open for some reason (see also {@link enableKeyBackup}). + * + * @param page - The playwright `Page` fixture. + * @param opts - Options object + * @param opts.accountPassword - The user's account password. If we are also resetting cross-signing, then we will need + * to upload the public cross-signing keys, which will cause the app to prompt for the password. + * + * @returns the new recovery key. + */ +export async function completeCreateSecretStorageDialog( + page: Page, + opts?: { accountPassword?: string }, +): Promise { + const currentDialogLocator = page.locator(".mx_Dialog"); + + await expect(currentDialogLocator.getByRole("heading", { name: "Set up Secure Backup" })).toBeVisible(); + // "Generate a Recovery Key" is selected by default + await currentDialogLocator.getByRole("button", { name: "Continue", exact: true }).click(); + await expect(currentDialogLocator.getByRole("heading", { name: "Save your Recovery Key" })).toBeVisible(); + await currentDialogLocator.getByRole("button", { name: "Copy", exact: true }).click(); + // copy the recovery key to use it later + const recoveryKey = await page.evaluate(() => navigator.clipboard.readText()); + await currentDialogLocator.getByRole("button", { name: "Continue", exact: true }).click(); + + // If the device is unverified, there should be a "Setting up keys" step. + // If this is not the first time we are setting up cross-signing, the app will prompt for our password; otherwise + // the step is quite quick, and playwright can miss it, so we can't test for it. + if (opts && Object.hasOwn(opts, "accountPassword")) { + await expect(currentDialogLocator.getByRole("heading", { name: "Setting up keys" })).toBeVisible(); + await page.getByPlaceholder("Password").fill(opts!.accountPassword); + await currentDialogLocator.getByRole("button", { name: "Continue" }).click(); + } + + // Either way, we end up at a success dialog: + await expect(currentDialogLocator.getByRole("heading", { name: "Secure Backup successful" })).toBeVisible(); + await currentDialogLocator.getByRole("button", { name: "Done", exact: true }).click(); + await expect(currentDialogLocator.getByText("Secure Backup successful")).not.toBeVisible(); + + return recoveryKey; +} + +/** + * Click on copy and continue buttons to dismiss the recovery key dialog */ export async function copyAndContinue(page: Page) { await page.getByRole("button", { name: "Copy" }).click(); @@ -313,7 +423,7 @@ export async function autoJoin(client: Client) { await client.evaluate((cli) => { cli.on(window.matrixcs.RoomMemberEvent.Membership, (event, member) => { if (member.membership === "invite" && member.userId === cli.getUserId()) { - cli.joinRoom(member.roomId); + void cli.joinRoom(member.roomId); } }); }); @@ -372,3 +482,53 @@ export async function createSecondBotDevice(page: Page, homeserver: HomeserverIn await bobSecondDevice.prepareClient(); return bobSecondDevice; } + +/** + * Remove the cached secrets from the indexedDB + * This is a workaround to simulate the case where the secrets are not cached. + */ +export async function deleteCachedSecrets(page: Page) { + await page.evaluate(async () => { + const removeCachedSecrets = new Promise((resolve) => { + const request = window.indexedDB.open("matrix-js-sdk::matrix-sdk-crypto"); + request.onsuccess = (event: Event & { target: { result: IDBDatabase } }) => { + const db = event.target.result; + const request = db.transaction("core", "readwrite").objectStore("core").delete("private_identity"); + request.onsuccess = () => { + db.close(); + resolve(undefined); + }; + }; + }); + await removeCachedSecrets; + }); + await page.reload(); +} + +/** + * Wait until the given user has a given number of devices. + * This function will check the device keys ten times and if + * the expected number of devices were not found by then, an + * error is thrown. + */ +export async function waitForDevices( + app: ElementAppPage, + userId: string, + expectedNumberOfDevices: number, +): Promise { + const result = await app.client.evaluate( + async (cli, { userId, expectedNumberOfDevices }) => { + for (let i = 0; i < 10; ++i) { + const userDeviceMap = await cli.getCrypto()?.getUserDeviceInfo([userId], true); + const deviceMap = userDeviceMap?.get(userId); + if (deviceMap.size === expectedNumberOfDevices) return true; + await new Promise((r) => setTimeout(r, 500)); + } + return false; + }, + { userId, expectedNumberOfDevices }, + ); + if (!result) { + throw new Error(`User ${userId} did not have ${expectedNumberOfDevices} devices within ten iterations!`); + } +} diff --git a/playwright/e2e/csAPI.ts b/playwright/e2e/csAPI.ts index f171ded5e3..c622ac99ce 100644 --- a/playwright/e2e/csAPI.ts +++ b/playwright/e2e/csAPI.ts @@ -5,11 +5,11 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only Please see LICENSE files in the repository root for full details. */ -import { APIRequestContext } from "playwright-core"; -import { KeyBackupInfo } from "matrix-js-sdk/src/crypto-api"; +import { type APIRequestContext } from "@playwright/test"; +import { type KeyBackupInfo } from "matrix-js-sdk/src/crypto-api"; +import { ClientServerApi } from "@element-hq/element-web-playwright-common/lib/utils/api.js"; -import { HomeserverInstance } from "../plugins/homeserver"; -import { ClientServerApi } from "../testcontainers/utils.ts"; +import { type HomeserverInstance } from "../plugins/homeserver"; /** * A small subset of the Client-Server API used to manipulate the state of the diff --git a/playwright/e2e/editing/editing.spec.ts b/playwright/e2e/editing/editing.spec.ts index 83ae6ba2d9..6f8e68bbc3 100644 --- a/playwright/e2e/editing/editing.spec.ts +++ b/playwright/e2e/editing/editing.spec.ts @@ -6,12 +6,13 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com Please see LICENSE files in the repository root for full details. */ -import { Locator, Page } from "@playwright/test"; +import { type Locator, type Page } from "@playwright/test"; import type { EventType, IContent, ISendEventResponse, MsgType, Visibility } from "matrix-js-sdk/src/matrix"; import { expect, test } from "../../element-web-test"; -import { ElementAppPage } from "../../pages/ElementAppPage"; +import { type ElementAppPage } from "../../pages/ElementAppPage"; import { SettingLevel } from "../../../src/settings/SettingLevel"; +import { isDendrite } from "../../plugins/homeserver/dendrite"; async function sendEvent(app: ElementAppPage, roomId: string): Promise { return app.client.sendEvent(roomId, null, "m.room.message" as EventType, { @@ -31,6 +32,8 @@ function mkPadding(n: number): IContent { } test.describe("Editing", () => { + test.skip(isDendrite, "due to a Dendrite bug https://github.com/element-hq/dendrite/issues/3488"); + // Edit "Message" const editLastMessage = async (page: Page, edit: string) => { const eventTile = page.locator(".mx_RoomView_MessageList .mx_EventTile_last"); @@ -264,7 +267,6 @@ test.describe("Editing", () => { app, room, axe, - checkA11y, }) => { axe.disableRules("color-contrast"); // XXX: We have some known contrast issues here @@ -279,7 +281,7 @@ test.describe("Editing", () => { const line = tile.locator(".mx_EventTile_line"); await line.hover(); await line.getByRole("button", { name: "Edit" }).click(); - await checkA11y(); + await expect(axe).toHaveNoViolations(); const editComposer = page.getByRole("textbox", { name: "Edit message" }); await editComposer.pressSequentially("Foo"); await editComposer.press("Backspace"); @@ -287,7 +289,7 @@ test.describe("Editing", () => { await editComposer.press("Backspace"); await editComposer.press("Enter"); await app.getComposerField().hover(); // XXX: move the hover to get rid of the "Edit" tooltip - await checkA11y(); + await expect(axe).toHaveNoViolations(); } await expect( page.locator(".mx_RoomView_body .mx_EventTile[data-scroll-tokens]", { hasText: "Message" }), @@ -302,7 +304,6 @@ test.describe("Editing", () => { user, app, axe, - checkA11y, bot: bob, }) => { // This tests the behaviour when a message has been edited some time after it has been sent, and we diff --git a/playwright/e2e/forgot-password/forgot-password.spec.ts b/playwright/e2e/forgot-password/forgot-password.spec.ts index af4e6def7e..d075afda73 100644 --- a/playwright/e2e/forgot-password/forgot-password.spec.ts +++ b/playwright/e2e/forgot-password/forgot-password.spec.ts @@ -6,22 +6,21 @@ 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 { expect, test as base } from "../../element-web-test"; +import { type CredentialsWithDisplayName, expect, test as base } from "../../element-web-test"; import { selectHomeserver } from "../utils"; import { emailHomeserver } from "../../plugins/homeserver/synapse/emailHomeserver.ts"; import { isDendrite } from "../../plugins/homeserver/dendrite"; -import { Credentials } from "../../plugins/homeserver"; const email = "user@nowhere.dummy"; -const test = base.extend<{ credentials: Pick }>({ +const test = base.extend({ // eslint-disable-next-line no-empty-pattern credentials: async ({}, use, testInfo) => { await use({ username: `user_${testInfo.testId}`, // this has to be password-like enough to please zxcvbn. Needless to say it's just from pwgen. password: "oETo7MPf0o", - }); + } as CredentialsWithDisplayName); }, }); diff --git a/playwright/e2e/integration-manager/kick.spec.ts b/playwright/e2e/integration-manager/kick.spec.ts index b12215fe33..b1aa9eff52 100644 --- a/playwright/e2e/integration-manager/kick.spec.ts +++ b/playwright/e2e/integration-manager/kick.spec.ts @@ -69,29 +69,13 @@ async function sendActionFromIntegrationManager( await iframe.getByRole("button", { name: "Press to send action" }).click(); } -async function clickUntilGone(page: Page, selector: string, attempt = 0) { - if (attempt === 11) { - throw new Error("clickUntilGone attempt count exceeded"); - } - - await page.locator(selector).last().click(); - - const count = await page.locator(selector).count(); - if (count > 0) { - return clickUntilGone(page, selector, ++attempt); - } -} - async function expectKickedMessage(page: Page, shouldExist: boolean) { - // Expand any event summaries, we can't use a click multiple here because clicking one might de-render others - // This is quite horrible but seems the most stable way of clicking 0-N buttons, - // one at a time with a full re-evaluation after each click - await clickUntilGone(page, ".mx_GenericEventListSummary_toggle[aria-expanded=false]"); - - // Check for the event message (or lack thereof) - await expect(page.getByText(`${USER_DISPLAY_NAME} removed ${BOT_DISPLAY_NAME}: ${KICK_REASON}`)).toBeVisible({ - visible: shouldExist, - }); + await expect(async () => { + await page.locator(".mx_GenericEventListSummary_toggle[aria-expanded=false]").last().click(); + await expect(page.getByText(`${USER_DISPLAY_NAME} removed ${BOT_DISPLAY_NAME}: ${KICK_REASON}`)).toBeVisible({ + visible: shouldExist, + }); + }).toPass(); } test.describe("Integration Manager: Kick", () => { diff --git a/playwright/e2e/invite/invite-dialog.spec.ts b/playwright/e2e/invite/invite-dialog.spec.ts index 73238f8c3d..8d64e6e047 100644 --- a/playwright/e2e/invite/invite-dialog.spec.ts +++ b/playwright/e2e/invite/invite-dialog.spec.ts @@ -77,7 +77,7 @@ test.describe("Invite dialog", function () { "should support inviting a user to Direct Messages", { tag: "@screenshot" }, async ({ page, app, user, bot }) => { - await page.locator(".mx_RoomList").getByRole("button", { name: "Start chat" }).click(); + await page.locator(".mx_LegacyRoomList").getByRole("button", { name: "Start chat" }).click(); const other = page.locator(".mx_InviteDialog_other"); // Assert that the header is rendered diff --git a/playwright/e2e/knock/create-knock-room.spec.ts b/playwright/e2e/knock/create-knock-room.spec.ts index 1c729ff610..e21b30a3c2 100644 --- a/playwright/e2e/knock/create-knock-room.spec.ts +++ b/playwright/e2e/knock/create-knock-room.spec.ts @@ -9,8 +9,10 @@ Please see LICENSE files in the repository root for full details. import { test, expect } from "../../element-web-test"; import { waitForRoom } from "../utils"; import { Filter } from "../../pages/Spotlight"; +import { isDendrite } from "../../plugins/homeserver/dendrite"; test.describe("Create Knock Room", () => { + test.skip(isDendrite, "Dendrite does not have support for knocking"); test.use({ displayName: "Alice", labsFlags: ["feature_ask_to_join"], @@ -79,6 +81,7 @@ test.describe("Create Knock Room", () => { const spotlightDialog = await app.openSpotlight(); await spotlightDialog.filter(Filter.PublicRooms); + await spotlightDialog.search("Cyber"); await expect(spotlightDialog.results.nth(0)).toContainText("Cybersecurity"); }); }); diff --git a/playwright/e2e/knock/knock-into-room.spec.ts b/playwright/e2e/knock/knock-into-room.spec.ts index a87c33415b..be6619697d 100644 --- a/playwright/e2e/knock/knock-into-room.spec.ts +++ b/playwright/e2e/knock/knock-into-room.spec.ts @@ -13,8 +13,10 @@ import { type Visibility } from "matrix-js-sdk/src/matrix"; import { test, expect } from "../../element-web-test"; import { waitForRoom } from "../utils"; import { Filter } from "../../pages/Spotlight"; +import { isDendrite } from "../../plugins/homeserver/dendrite"; test.describe("Knock Into Room", () => { + test.skip(isDendrite, "Dendrite does not have support for knocking"); test.use({ displayName: "Alice", labsFlags: ["feature_ask_to_join"], @@ -282,6 +284,7 @@ test.describe("Knock Into Room", () => { const spotlightDialog = await app.openSpotlight(); await spotlightDialog.filter(Filter.PublicRooms); + await spotlightDialog.search("Cyber"); await expect(spotlightDialog.results.nth(0)).toContainText("Cybersecurity"); await spotlightDialog.results.nth(0).click(); diff --git a/playwright/e2e/knock/manage-knocks.spec.ts b/playwright/e2e/knock/manage-knocks.spec.ts index fb7e275194..3f4c9616ca 100644 --- a/playwright/e2e/knock/manage-knocks.spec.ts +++ b/playwright/e2e/knock/manage-knocks.spec.ts @@ -10,8 +10,10 @@ Please see LICENSE files in the repository root for full details. import { test, expect } from "../../element-web-test"; import { waitForRoom } from "../utils"; +import { isDendrite } from "../../plugins/homeserver/dendrite"; test.describe("Manage Knocks", () => { + test.skip(isDendrite, "Dendrite does not have support for knocking"); test.use({ displayName: "Alice", labsFlags: ["feature_ask_to_join"], @@ -50,7 +52,7 @@ test.describe("Manage Knocks", () => { }); test("should deny knock using bar", async ({ page, app, bot, room }) => { - bot.knockRoom(room.roomId); + await bot.knockRoom(room.roomId); const roomKnocksBar = page.locator(".mx_RoomKnocksBar"); await expect(roomKnocksBar.getByRole("heading", { name: "Asking to join" })).toBeVisible(); diff --git a/playwright/e2e/lazy-loading/lazy-loading.spec.ts b/playwright/e2e/lazy-loading/lazy-loading.spec.ts index ace6fdb738..7c31c288fa 100644 --- a/playwright/e2e/lazy-loading/lazy-loading.spec.ts +++ b/playwright/e2e/lazy-loading/lazy-loading.spec.ts @@ -10,8 +10,12 @@ import { Bot } from "../../pages/bot"; import type { Locator, Page } from "@playwright/test"; import type { ElementAppPage } from "../../pages/ElementAppPage"; import { test, expect } from "../../element-web-test"; +import { type Credentials } from "../../plugins/homeserver"; +import { isDendrite } from "../../plugins/homeserver/dendrite"; test.describe("Lazy Loading", () => { + test.skip(isDendrite, "due to a Dendrite bug https://github.com/element-hq/dendrite/issues/3488"); + const charlies: Bot[] = []; test.use({ @@ -35,12 +39,18 @@ test.describe("Lazy Loading", () => { }); const name = "Lazy Loading Test"; - const alias = "#lltest:localhost"; const charlyMsg1 = "hi bob!"; const charlyMsg2 = "how's it going??"; let roomId: string; - async function setupRoomWithBobAliceAndCharlies(page: Page, app: ElementAppPage, bob: Bot, charlies: Bot[]) { + async function setupRoomWithBobAliceAndCharlies( + page: Page, + app: ElementAppPage, + user: Credentials, + bob: Bot, + charlies: Bot[], + ) { + const alias = `#lltest:${user.homeServer}`; const visibility = await page.evaluate(() => (window as any).matrixcs.Visibility.Public); roomId = await bob.createRoom({ name, @@ -95,7 +105,13 @@ test.describe("Lazy Loading", () => { } } - async function joinCharliesWhileAliceIsOffline(page: Page, app: ElementAppPage, charlies: Bot[]) { + async function joinCharliesWhileAliceIsOffline( + page: Page, + app: ElementAppPage, + user: Credentials, + charlies: Bot[], + ) { + const alias = `#lltest:${user.homeServer}`; await app.client.network.goOffline(); for (const charly of charlies) { await charly.joinRoom(alias); @@ -107,19 +123,19 @@ test.describe("Lazy Loading", () => { await app.client.waitForNextSync(); } - test("should handle lazy loading properly even when offline", async ({ page, app, bot }) => { + test("should handle lazy loading properly even when offline", async ({ page, app, bot, user }) => { test.slow(); const charly1to5 = charlies.slice(0, 5); const charly6to10 = charlies.slice(5); // Set up room with alice, bob & charlies 1-5 - await setupRoomWithBobAliceAndCharlies(page, app, bot, charly1to5); + await setupRoomWithBobAliceAndCharlies(page, app, user, bot, charly1to5); // Alice should see 2 messages from every charly with the correct display name await checkPaginatedDisplayNames(app, charly1to5); await openMemberlist(app); await checkMemberList(page, charly1to5); - await joinCharliesWhileAliceIsOffline(page, app, charly6to10); + await joinCharliesWhileAliceIsOffline(page, app, user, charly6to10); await checkMemberList(page, charly6to10); for (const charly of charlies) { 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 new file mode 100644 index 0000000000..59f5a2fcab --- /dev/null +++ b/playwright/e2e/left-panel/room-list-panel/room-list-filter-sort.spec.ts @@ -0,0 +1,88 @@ +/* + * 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 { expect, test } from "../../../element-web-test"; +import type { Page } from "@playwright/test"; + +test.describe("Room list filters and sort", () => { + test.use({ + displayName: "Alice", + botCreateOpts: { + displayName: "BotBob", + autoAcceptInvites: true, + }, + labsFlags: ["feature_new_room_list"], + }); + + /** + * Get the room list + * @param page + */ + function getRoomList(page: Page) { + return page.getByTestId("room-list"); + } + + function getPrimaryFilters(page: Page) { + return page.getByRole("listbox", { name: "Room list filters" }); + } + + test.beforeEach(async ({ page, app, bot, user }) => { + // The notification toast is displayed above the search section + await app.closeNotificationToast(); + + await app.client.createRoom({ name: "empty room" }); + + const unReadDmId = await bot.createRoom({ + name: "unread dm", + invite: [user.userId], + is_direct: true, + }); + await bot.sendMessage(unReadDmId, "I am a robot. Beep."); + + const unReadRoomId = await app.client.createRoom({ name: "unread room" }); + await app.client.inviteUser(unReadRoomId, bot.credentials.userId); + await bot.joinRoom(unReadRoomId); + await bot.sendMessage(unReadRoomId, "I am a robot. Beep."); + + const favouriteId = await app.client.createRoom({ name: "favourite room" }); + await app.client.evaluate(async (client, favouriteId) => { + await client.setRoomTag(favouriteId, "m.favourite", { order: 0.5 }); + }, favouriteId); + }); + + test("should filter the list (with primary filters)", { tag: "@screenshot" }, async ({ page, app, user }) => { + const roomList = getRoomList(page); + const primaryFilters = getPrimaryFilters(page); + + const allFilters = await primaryFilters.locator("option").all(); + for (const filter of allFilters) { + expect(await filter.getAttribute("aria-selected")).toBe("false"); + } + await expect(primaryFilters).toMatchScreenshot("unselected-primary-filters.png"); + + await primaryFilters.getByRole("option", { name: "Unread" }).click(); + // only one room should be visible + await expect(roomList.getByRole("gridcell", { name: "unread dm" })).toBeVisible(); + await expect(roomList.getByRole("gridcell", { name: "unread room" })).toBeVisible(); + expect(await roomList.locator("role=gridcell").count()).toBe(2); + await expect(primaryFilters).toMatchScreenshot("unread-primary-filters.png"); + + await primaryFilters.getByRole("option", { name: "Favourite" }).click(); + await expect(roomList.getByRole("gridcell", { name: "favourite room" })).toBeVisible(); + expect(await roomList.locator("role=gridcell").count()).toBe(1); + + await primaryFilters.getByRole("option", { name: "People" }).click(); + await expect(roomList.getByRole("gridcell", { name: "unread dm" })).toBeVisible(); + expect(await roomList.locator("role=gridcell").count()).toBe(1); + + 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); + }); +}); diff --git a/playwright/e2e/left-panel/room-list-panel/room-list-header.spec.ts b/playwright/e2e/left-panel/room-list-panel/room-list-header.spec.ts new file mode 100644 index 0000000000..daa8d3869f --- /dev/null +++ b/playwright/e2e/left-panel/room-list-panel/room-list-header.spec.ts @@ -0,0 +1,87 @@ +/* + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial + * Please see LICENSE files in the repository root for full details. + */ + +import { test, expect } from "../../../element-web-test"; +import type { Page } from "@playwright/test"; + +test.describe("Header section of the room list", () => { + test.use({ + labsFlags: ["feature_new_room_list"], + }); + + /** + * Get the header section of the room list + * @param page + */ + function getHeaderSection(page: Page) { + return page.getByTestId("room-list-header"); + } + + test.beforeEach(async ({ page, app, user }) => { + // The notification toast is displayed above the search section + await app.closeNotificationToast(); + }); + + test("should render the header section", { tag: "@screenshot" }, async ({ page, app, user }) => { + const roomListHeader = getHeaderSection(page); + await expect(roomListHeader).toMatchScreenshot("room-list-header.png"); + + const composeMenu = roomListHeader.getByRole("button", { name: "Add" }); + await composeMenu.click(); + + await expect(page.getByRole("menu")).toMatchScreenshot("room-list-header-compose-menu.png"); + + // New message should open the direct messages dialog + await page.getByRole("menuitem", { name: "New message" }).click(); + await expect(page.getByRole("heading", { name: "Direct Messages" })).toBeVisible(); + await app.closeDialog(); + + // New room should open the room creation dialog + await composeMenu.click(); + await page.getByRole("menuitem", { name: "New room" }).click(); + await expect(page.getByRole("heading", { name: "Create a private room" })).toBeVisible(); + await app.closeDialog(); + }); + + test("should render the header section for a space", { tag: "@screenshot" }, async ({ page, app, user }) => { + await app.client.createSpace({ name: "MySpace" }); + await page.getByRole("button", { name: "MySpace" }).click(); + + const roomListHeader = getHeaderSection(page); + await expect(roomListHeader).toMatchScreenshot("room-list-space-header.png"); + + await expect(roomListHeader.getByRole("heading", { name: "MySpace" })).toBeVisible(); + await expect(roomListHeader.getByRole("button", { name: "Add" })).toBeVisible(); + + const spaceMenu = roomListHeader.getByRole("button", { name: "Open space menu" }); + await spaceMenu.click(); + + await expect(page.getByRole("menu")).toMatchScreenshot("room-list-header-space-menu.png"); + + // It should open the space home + await page.getByRole("menuitem", { name: "Space home" }).click(); + await expect(page.getByRole("main").getByRole("heading", { name: "MySpace" })).toBeVisible(); + + // It should open the invite dialog + await spaceMenu.click(); + await page.getByRole("menuitem", { name: "Invite" }).click(); + await expect(page.getByRole("heading", { name: "Invite to MySpace" })).toBeVisible(); + await app.closeDialog(); + + // It should open the space preferences + await spaceMenu.click(); + await page.getByRole("menuitem", { name: "Preferences" }).click(); + await expect(page.getByRole("heading", { name: "Preferences" })).toBeVisible(); + await app.closeDialog(); + + // It should open the space settings + await spaceMenu.click(); + await page.getByRole("menuitem", { name: "Space Settings" }).click(); + await expect(page.getByRole("heading", { name: "Settings" })).toBeVisible(); + await app.closeDialog(); + }); +}); 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 new file mode 100644 index 0000000000..2ffbc608c2 --- /dev/null +++ b/playwright/e2e/left-panel/room-list-panel/room-list-panel.spec.ts @@ -0,0 +1,41 @@ +/* + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial + * Please see LICENSE files in the repository root for full details. + */ + +import { type Page } from "@playwright/test"; + +import { test, expect } from "../../../element-web-test"; + +test.describe("Room list panel", () => { + test.use({ + labsFlags: ["feature_new_room_list"], + }); + + /** + * Get the room list view + * @param page + */ + function getRoomListView(page: Page) { + return page.getByTestId("room-list-panel"); + } + + test.beforeEach(async ({ page, app, user }) => { + // The notification toast is displayed above the search section + await app.closeNotificationToast(); + + // Populate the room list + for (let i = 0; i < 20; i++) { + await app.client.createRoom({ name: `room${i}` }); + } + }); + + test("should render the room list panel", { tag: "@screenshot" }, async ({ page, app, user }) => { + const roomListView = getRoomListView(page); + // Wait for the last room to be visible + await expect(roomListView.getByRole("gridcell", { name: "Open room room19" })).toBeVisible(); + await expect(roomListView).toMatchScreenshot("room-list-panel.png"); + }); +}); diff --git a/playwright/e2e/left-panel/room-list-panel/room-list-search.spec.ts b/playwright/e2e/left-panel/room-list-panel/room-list-search.spec.ts new file mode 100644 index 0000000000..028503f622 --- /dev/null +++ b/playwright/e2e/left-panel/room-list-panel/room-list-search.spec.ts @@ -0,0 +1,53 @@ +/* + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial + * Please see LICENSE files in the repository root for full details. + */ + +import { type Page } from "@playwright/test"; + +import { test, expect } from "../../../element-web-test"; + +test.describe("Search section of the room list", () => { + test.use({ + labsFlags: ["feature_new_room_list"], + }); + + /** + * Get the search section of the room list + * @param page + */ + function getSearchSection(page: Page) { + return page.getByRole("search"); + } + + test.beforeEach(async ({ page, app, user }) => { + // The notification toast is displayed above the search section + await app.closeNotificationToast(); + }); + + test("should render the search section", { tag: "@screenshot" }, async ({ page, app, user }) => { + const searchSection = getSearchSection(page); + // exact=false to ignore the shortcut which is related to the OS + await expect(searchSection.getByRole("button", { name: "Search", exact: false })).toBeVisible(); + await expect(searchSection).toMatchScreenshot("search-section.png"); + }); + + test("should open the spotlight when the search button is clicked", async ({ page, app, user }) => { + const searchSection = getSearchSection(page); + await searchSection.getByRole("button", { name: "Search", exact: false }).click(); + // The spotlight should be displayed + await expect(page.getByRole("dialog", { name: "Search Dialog" })).toBeVisible(); + }); + + test("should open the room directory when the search button is clicked", async ({ page, app, user }) => { + const searchSection = getSearchSection(page); + await searchSection.getByRole("button", { name: "Explore rooms" }).click(); + const dialog = page.getByRole("dialog", { name: "Search Dialog" }); + // The room directory should be displayed + await expect(dialog).toBeVisible(); + // The public room filter should be displayed + await expect(dialog.getByText("Public rooms")).toBeVisible(); + }); +}); 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 new file mode 100644 index 0000000000..493ed0d1ab --- /dev/null +++ b/playwright/e2e/left-panel/room-list-panel/room-list.spec.ts @@ -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 { type Page } from "@playwright/test"; + +import { test, expect } from "../../../element-web-test"; + +test.describe("Room list", () => { + test.use({ + displayName: "Alice", + labsFlags: ["feature_new_room_list"], + }); + + /** + * Get the room list + * @param page + */ + function getRoomList(page: Page) { + return page.getByTestId("room-list"); + } + + test.beforeEach(async ({ page, app, user }) => { + // The notification toast is displayed above the search section + await app.closeNotificationToast(); + for (let i = 0; i < 30; i++) { + await app.client.createRoom({ name: `room${i}` }); + } + }); + + test("should render the room list", { tag: "@screenshot" }, async ({ page, app, user }) => { + const roomListView = getRoomList(page); + await expect(roomListView.getByRole("gridcell", { name: "Open room room29" })).toBeVisible(); + await expect(roomListView).toMatchScreenshot("room-list.png"); + + await roomListView.hover(); + // Scroll to the end of the room list + await page.mouse.wheel(0, 1000); + await expect(roomListView.getByRole("gridcell", { name: "Open room room0" })).toBeVisible(); + await expect(roomListView).toMatchScreenshot("room-list-scrolled.png"); + }); + + test("should open the room when it is clicked", async ({ page, app, user }) => { + const roomListView = getRoomList(page); + await roomListView.getByRole("gridcell", { name: "Open room room29" }).click(); + await expect(page.getByRole("heading", { name: "room29", level: 1 })).toBeVisible(); + }); + + test("should open the more options menu", { tag: "@screenshot" }, async ({ page, app, user }) => { + const roomListView = getRoomList(page); + const roomItem = roomListView.getByRole("gridcell", { name: "Open room room29" }); + await roomItem.hover(); + + await expect(roomItem).toMatchScreenshot("room-list-item-hover.png"); + const roomItemMenu = roomItem.getByRole("button", { name: "More Options" }); + await roomItemMenu.click(); + await expect(page).toMatchScreenshot("room-list-item-open-more-options.png"); + + // It should make the room favourited + await page.getByRole("menuitemcheckbox", { name: "Favourited" }).click(); + + // Check that the room is favourited + await roomItem.hover(); + await roomItemMenu.click(); + await expect(page.getByRole("menuitemcheckbox", { name: "Favourited" })).toBeChecked(); + // It should show the invite dialog + await page.getByRole("menuitem", { name: "invite" }).click(); + await expect(page.getByRole("heading", { name: "Invite to room29" })).toBeVisible(); + await app.closeDialog(); + + // It should leave the room + await roomItem.hover(); + await roomItemMenu.click(); + await page.getByRole("menuitem", { name: "leave room" }).click(); + await expect(roomItem).not.toBeVisible(); + }); +}); diff --git a/playwright/e2e/location/location.spec.ts b/playwright/e2e/location/location.spec.ts index 6f4db3ae72..52afd5e173 100644 --- a/playwright/e2e/location/location.spec.ts +++ b/playwright/e2e/location/location.spec.ts @@ -6,7 +6,7 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com Please see LICENSE files in the repository root for full details. */ -import { Locator, Page } from "@playwright/test"; +import { type Locator, type Page } from "@playwright/test"; import { test, expect } from "../../element-web-test"; diff --git a/playwright/e2e/login/login-consent.spec.ts b/playwright/e2e/login/login-consent.spec.ts index 33e1e21d7e..28948dc3b9 100644 --- a/playwright/e2e/login/login-consent.spec.ts +++ b/playwright/e2e/login/login-consent.spec.ts @@ -6,12 +6,13 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com Please see LICENSE files in the repository root for full details. */ -import { Page } from "playwright-core"; +import { type Page } from "@playwright/test"; import { expect, test } from "../../element-web-test"; import { selectHomeserver } from "../utils"; -import { Credentials, HomeserverInstance } from "../../plugins/homeserver"; +import { type Credentials, type HomeserverInstance } from "../../plugins/homeserver"; import { consentHomeserver } from "../../plugins/homeserver/synapse/consentHomeserver.ts"; +import { isDendrite } from "../../plugins/homeserver/dendrite"; // This test requires fixed credentials for the device signing keys below to work const username = "user1234"; @@ -113,11 +114,13 @@ test.use({ test.describe("Login", () => { test.describe("Password login", () => { + test.skip(isDendrite, "Dendrite lacks support for MSC3967 so requires additional auth here"); + test("Loads the welcome page by default; then logs in with an existing account and lands on the home screen", async ({ credentials, page, homeserver, - checkA11y, + axe, }) => { await page.goto("/"); @@ -146,7 +149,7 @@ test.describe("Login", () => { await expect(page.getByRole("textbox", { name: "Username" })).toBeVisible(); // Disabled because flaky - see https://github.com/vector-im/element-web/issues/24688 // cy.percySnapshot("Login"); - await checkA11y(); + await expect(axe).toHaveNoViolations(); await page.getByRole("textbox", { name: "Username" }).fill(credentials.username); await page.getByPlaceholder("Password").fill(credentials.password); diff --git a/playwright/e2e/login/utils.ts b/playwright/e2e/login/utils.ts index e7121159f0..d74300908a 100644 --- a/playwright/e2e/login/utils.ts +++ b/playwright/e2e/login/utils.ts @@ -6,9 +6,9 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com Please see LICENSE files in the repository root for full details. */ -import { Page, expect, TestInfo } from "@playwright/test"; +import { type Page, expect, type TestInfo } from "@playwright/test"; -import { Credentials, HomeserverInstance } from "../../plugins/homeserver"; +import { type Credentials, type HomeserverInstance } from "../../plugins/homeserver"; /** Visit the login page, choose to log in with "OAuth test", register a new account, and redirect back to Element */ @@ -93,7 +93,7 @@ export async function interceptRequestsWithSoftLogout(page: Page, user: Credenti // do something to make the active /sync return: create a new room await page.evaluate(() => { // don't wait for this to complete: it probably won't, because of the broken sync - window.mxMatrixClientPeg.get().createRoom({}); + void window.mxMatrixClientPeg.get().createRoom({}); }); await promise; diff --git a/playwright/e2e/messages/messages.spec.ts b/playwright/e2e/messages/messages.spec.ts index 03c93d2620..f430d6b18b 100644 --- a/playwright/e2e/messages/messages.spec.ts +++ b/playwright/e2e/messages/messages.spec.ts @@ -8,7 +8,7 @@ Please see LICENSE files in the repository root for full details. /* See readme.md for tips on writing these tests. */ -import { Locator, Page } from "playwright-core"; +import { type Locator, type Page } from "@playwright/test"; import { test, expect } from "../../element-web-test"; @@ -58,6 +58,16 @@ async function editMessage(page: Page, message: Locator, newMsg: string): Promis await editComposer.press("Enter"); } +const screenshotOptions = (page?: Page) => ({ + mask: page ? [page.locator(".mx_MessageTimestamp")] : undefined, + // Hide the jump to bottom button in the timeline to avoid flakiness + css: ` + .mx_JumpToBottomButton { + display: none !important; + } + `, +}); + test.describe("Message rendering", () => { [ { direction: "ltr", displayName: "Quentin" }, @@ -79,9 +89,10 @@ test.describe("Message rendering", () => { await page.goto(`#/room/${room.roomId}`); const msgTile = await sendMessage(page, "Hello, world!"); - await expect(msgTile).toMatchScreenshot(`basic-message-ltr-${direction}displayname.png`, { - mask: [page.locator(".mx_MessageTimestamp")], - }); + await expect(msgTile).toMatchScreenshot( + `basic-message-ltr-${direction}displayname.png`, + screenshotOptions(page), + ); }, ); @@ -89,14 +100,17 @@ test.describe("Message rendering", () => { await page.goto(`#/room/${room.roomId}`); const msgTile = await sendMessage(page, "/me lays an egg"); - await expect(msgTile).toMatchScreenshot(`emote-ltr-${direction}displayname.png`); + await expect(msgTile).toMatchScreenshot(`emote-ltr-${direction}displayname.png`, screenshotOptions()); }); test("should render an LTR rich text emote", async ({ page, user, app, room }) => { await page.goto(`#/room/${room.roomId}`); const msgTile = await sendMessage(page, "/me lays a *free range* egg"); - await expect(msgTile).toMatchScreenshot(`emote-rich-ltr-${direction}displayname.png`); + await expect(msgTile).toMatchScreenshot( + `emote-rich-ltr-${direction}displayname.png`, + screenshotOptions(), + ); }); test("should render an edited LTR message", async ({ page, user, app, room }) => { @@ -106,9 +120,10 @@ test.describe("Message rendering", () => { await editMessage(page, msgTile, "Hello, universe!"); - await expect(msgTile).toMatchScreenshot(`edited-message-ltr-${direction}displayname.png`, { - mask: [page.locator(".mx_MessageTimestamp")], - }); + await expect(msgTile).toMatchScreenshot( + `edited-message-ltr-${direction}displayname.png`, + screenshotOptions(page), + ); }); test("should render a reply of a LTR message", async ({ page, user, app, room }) => { @@ -122,32 +137,37 @@ test.describe("Message rendering", () => { ]); await replyMessage(page, msgTile, "response to multiline message"); - await expect(msgTile).toMatchScreenshot(`reply-message-ltr-${direction}displayname.png`, { - mask: [page.locator(".mx_MessageTimestamp")], - }); + await expect(msgTile).toMatchScreenshot( + `reply-message-ltr-${direction}displayname.png`, + screenshotOptions(page), + ); }); test("should render a basic RTL text message", async ({ page, user, app, room }) => { await page.goto(`#/room/${room.roomId}`); const msgTile = await sendMessage(page, "مرحبا بالعالم!"); - await expect(msgTile).toMatchScreenshot(`basic-message-rtl-${direction}displayname.png`, { - mask: [page.locator(".mx_MessageTimestamp")], - }); + await expect(msgTile).toMatchScreenshot( + `basic-message-rtl-${direction}displayname.png`, + screenshotOptions(page), + ); }); test("should render an RTL emote", async ({ page, user, app, room }) => { await page.goto(`#/room/${room.roomId}`); const msgTile = await sendMessage(page, "/me يضع بيضة"); - await expect(msgTile).toMatchScreenshot(`emote-rtl-${direction}displayname.png`); + await expect(msgTile).toMatchScreenshot(`emote-rtl-${direction}displayname.png`, screenshotOptions()); }); test("should render a richtext RTL emote", async ({ page, user, app, room }) => { await page.goto(`#/room/${room.roomId}`); const msgTile = await sendMessage(page, "/me أضع بيضة *حرة النطاق*"); - await expect(msgTile).toMatchScreenshot(`emote-rich-rtl-${direction}displayname.png`); + await expect(msgTile).toMatchScreenshot( + `emote-rich-rtl-${direction}displayname.png`, + screenshotOptions(), + ); }); test("should render an edited RTL message", async ({ page, user, app, room }) => { @@ -157,9 +177,10 @@ test.describe("Message rendering", () => { await editMessage(page, msgTile, "مرحبا بالكون!"); - await expect(msgTile).toMatchScreenshot(`edited-message-rtl-${direction}displayname.png`, { - mask: [page.locator(".mx_MessageTimestamp")], - }); + await expect(msgTile).toMatchScreenshot( + `edited-message-rtl-${direction}displayname.png`, + screenshotOptions(page), + ); }); test("should render a reply of a RTL message", async ({ page, user, app, room }) => { @@ -173,9 +194,10 @@ test.describe("Message rendering", () => { ]); await replyMessage(page, msgTile, "مرحبا بالعالم!"); - await expect(msgTile).toMatchScreenshot(`reply-message-trl-${direction}displayname.png`, { - mask: [page.locator(".mx_MessageTimestamp")], - }); + await expect(msgTile).toMatchScreenshot( + `reply-message-trl-${direction}displayname.png`, + screenshotOptions(page), + ); }); }); }); diff --git a/playwright/e2e/modules/loader.spec.ts b/playwright/e2e/modules/loader.spec.ts new file mode 100644 index 0000000000..e21b5c2d92 --- /dev/null +++ b/playwright/e2e/modules/loader.spec.ts @@ -0,0 +1,35 @@ +/* +Copyright 2025 New Vector Ltd. + +SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial +Please see LICENSE files in the repository root for full details. +*/ + +import { test, expect } from "../../element-web-test"; + +test.describe("Module loading", () => { + test.use({ + displayName: "Manny", + }); + + test.describe("Example Module", () => { + test.use({ + config: { + modules: ["/modules/example-module.js"], + }, + page: async ({ page }, use) => { + await page.route("/modules/example-module.js", async (route) => { + await route.fulfill({ path: "playwright/sample-files/example-module.js" }); + }); + await use(page); + }, + }); + + 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!"); + }); + }); +}); diff --git a/playwright/e2e/oidc/index.ts b/playwright/e2e/oidc/index.ts index bfd49b496a..1989e8764f 100644 --- a/playwright/e2e/oidc/index.ts +++ b/playwright/e2e/oidc/index.ts @@ -6,14 +6,14 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com Please see LICENSE files in the repository root for full details. */ -import { API, Messages } from "mailhog"; -import { Page } from "@playwright/test"; +import { type MailpitClient } from "@element-hq/element-web-playwright-common/lib/testcontainers"; +import { type Page } from "@playwright/test"; import { expect } from "../../element-web-test"; export async function registerAccountMas( page: Page, - mailhog: API, + mailpit: MailpitClient, username: string, email: string, password: string, @@ -27,13 +27,13 @@ export async function registerAccountMas( await page.getByRole("textbox", { name: "Confirm Password" }).fill(password); await page.getByRole("button", { name: "Continue" }).click(); - let messages: Messages; + let code: string; await expect(async () => { - messages = await mailhog.messages(); - expect(messages.items).toHaveLength(1); + const messages = await mailpit.listMessages(); + expect(messages.messages[0].To[0].Address).toEqual(email); + const text = await mailpit.renderMessageText(messages.messages[0].ID); + [, code] = text.match(/Your verification code to confirm this email address is: (\d{6})/); }).toPass(); - expect(messages.items[0].to).toEqual(`${username} <${email}>`); - const [, code] = messages.items[0].text.match(/Your verification code to confirm this email address is: (\d{6})/); await page.getByRole("textbox", { name: "6-digit code" }).fill(code); 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 a50730ce74..8c9128c39b 100644 --- a/playwright/e2e/oidc/oidc-native.spec.ts +++ b/playwright/e2e/oidc/oidc-native.spec.ts @@ -9,19 +9,17 @@ Please see LICENSE files in the repository root for full details. import { test, expect } from "../../element-web-test.ts"; import { registerAccountMas } from "."; import { ElementAppPage } from "../../pages/ElementAppPage.ts"; -import { isDendrite } from "../../plugins/homeserver/dendrite"; import { masHomeserver } from "../../plugins/homeserver/synapse/masHomeserver.ts"; test.use(masHomeserver); test.describe("OIDC Native", { tag: ["@no-firefox", "@no-webkit"] }, () => { - test.skip(isDendrite, "does not yet support MAS"); test.slow(); // trace recording takes a while here test("can register the oauth2 client and an account", async ({ context, page, homeserver, - mailhogClient, + mailpitClient, mas, }, testInfo) => { await page.clock.install(); @@ -35,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, mailhogClient, userId, "alice@email.com", "Pa$sW0rD!"); + await registerAccountMas(page, mailpitClient, userId, "alice@email.com", "Pa$sW0rD!"); // Eventually, we should end up at the home screen. await expect(page).toHaveURL(/\/#\/home$/, { timeout: 10000 }); diff --git a/playwright/e2e/one-to-one-chat/one-to-one-chat.spec.ts b/playwright/e2e/one-to-one-chat/one-to-one-chat.spec.ts index 8a4401f5f2..6ad69deba9 100644 --- a/playwright/e2e/one-to-one-chat/one-to-one-chat.spec.ts +++ b/playwright/e2e/one-to-one-chat/one-to-one-chat.spec.ts @@ -8,13 +8,16 @@ Please see LICENSE files in the repository root for full details. */ import { test as base, expect } from "../../element-web-test"; -import { Credentials } from "../../plugins/homeserver"; +import { type Credentials } from "../../plugins/homeserver"; +import { isDendrite } from "../../plugins/homeserver/dendrite"; const test = base.extend<{ user2?: Credentials; }>({}); test.describe("1:1 chat room", () => { + test.skip(isDendrite, "due to a Dendrite bug https://github.com/element-hq/dendrite/issues/3492"); + test.use({ displayName: "Jeff", user2: async ({ homeserver }, use, testInfo) => { diff --git a/playwright/e2e/permalinks/permalinks.spec.ts b/playwright/e2e/permalinks/permalinks.spec.ts index 9b448455ec..e7657b1394 100644 --- a/playwright/e2e/permalinks/permalinks.spec.ts +++ b/playwright/e2e/permalinks/permalinks.spec.ts @@ -31,7 +31,7 @@ test.describe("permalinks", () => { await charlotte.prepareClient(); // We don't use a bot for danielle as we want a stable MXID. - const danielleId = "@danielle:localhost"; + const danielleId = `@danielle:${user.homeServer}`; const room1Id = await app.client.createRoom({ name: room1Name }); const room2Id = await app.client.createRoom({ name: room2Name }); diff --git a/playwright/e2e/pinned-messages/index.ts b/playwright/e2e/pinned-messages/index.ts index bb65f31627..198ab24118 100644 --- a/playwright/e2e/pinned-messages/index.ts +++ b/playwright/e2e/pinned-messages/index.ts @@ -6,12 +6,12 @@ * Please see LICENSE files in the repository root for full details. */ -import { Page } from "@playwright/test"; +import { type Page } from "@playwright/test"; import { test as base, expect } from "../../element-web-test"; -import { Client } from "../../pages/client"; -import { ElementAppPage } from "../../pages/ElementAppPage"; -import { Bot } from "../../pages/bot"; +import { type Client } from "../../pages/client"; +import { type ElementAppPage } from "../../pages/ElementAppPage"; +import { type Bot } from "../../pages/bot"; type RoomRef = { name: string; roomId: string }; diff --git a/playwright/e2e/pinned-messages/pinned-messages.spec.ts b/playwright/e2e/pinned-messages/pinned-messages.spec.ts index bb72c02610..de954fb8d4 100644 --- a/playwright/e2e/pinned-messages/pinned-messages.spec.ts +++ b/playwright/e2e/pinned-messages/pinned-messages.spec.ts @@ -35,10 +35,10 @@ test.describe("Pinned messages", () => { mask: [tile.locator(".mx_MessageTimestamp")], // Hide the jump to bottom button in the timeline to avoid flakiness css: ` - .mx_JumpToBottomButton { - display: none !important; - } - `, + .mx_JumpToBottomButton { + display: none !important; + } + `, }); }, ); diff --git a/playwright/e2e/polls/pollHistory.spec.ts b/playwright/e2e/polls/pollHistory.spec.ts index a4d6a8ae0e..4f2adff40d 100644 --- a/playwright/e2e/polls/pollHistory.spec.ts +++ b/playwright/e2e/polls/pollHistory.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 type { Bot } from "../../pages/bot"; import type { Client } from "../../pages/client"; -import { ElementAppPage } from "../../pages/ElementAppPage"; +import { type ElementAppPage } from "../../pages/ElementAppPage"; test.describe("Poll history", () => { type CreatePollOptions = { @@ -134,7 +134,7 @@ test.describe("Poll history", () => { await expect(dialog.getByText(pollParams2.title)).toBeAttached(); await expect(dialog.getByText(pollParams1.title)).toBeAttached(); - dialog.getByText("Active polls").click(); + await dialog.getByText("Active polls").click(); // no more active polls await expect(page.getByText("There are no active polls in this room")).toBeAttached(); diff --git a/playwright/e2e/polls/polls.spec.ts b/playwright/e2e/polls/polls.spec.ts index 727c453a31..fc49906b47 100644 --- a/playwright/e2e/polls/polls.spec.ts +++ b/playwright/e2e/polls/polls.spec.ts @@ -11,8 +11,11 @@ import { Bot } from "../../pages/bot"; import { SettingLevel } from "../../../src/settings/SettingLevel"; import { Layout } from "../../../src/settings/enums/Layout"; import type { Locator, Page } from "@playwright/test"; +import { isDendrite } from "../../plugins/homeserver/dendrite"; test.describe("Polls", () => { + test.skip(isDendrite, "due to a Dendrite bug https://github.com/element-hq/dendrite/issues/3492"); + type CreatePollOptions = { title: string; options: string[]; diff --git a/playwright/e2e/read-receipts/editing-messages-in-threads.spec.ts b/playwright/e2e/read-receipts/editing-messages-in-threads.spec.ts index da27d39a2c..4fa204bace 100644 --- a/playwright/e2e/read-receipts/editing-messages-in-threads.spec.ts +++ b/playwright/e2e/read-receipts/editing-messages-in-threads.spec.ts @@ -9,8 +9,11 @@ Please see LICENSE files in the repository root for full details. /* See readme.md for tips on writing these tests. */ import { test } from "."; +import { isDendrite } from "../../plugins/homeserver/dendrite"; test.describe("Read receipts", { tag: "@mergequeue" }, () => { + test.skip(isDendrite, "due to Dendrite bug https://github.com/element-hq/dendrite/issues/2970"); + test.describe("editing messages", () => { test.describe("in threads", () => { test("An edit of a threaded message makes the room unread", async ({ diff --git a/playwright/e2e/read-receipts/editing-messages-main-timeline.spec.ts b/playwright/e2e/read-receipts/editing-messages-main-timeline.spec.ts index bc4eff711b..6c9596a5b2 100644 --- a/playwright/e2e/read-receipts/editing-messages-main-timeline.spec.ts +++ b/playwright/e2e/read-receipts/editing-messages-main-timeline.spec.ts @@ -9,8 +9,11 @@ Please see LICENSE files in the repository root for full details. /* See readme.md for tips on writing these tests. */ import { test } from "."; +import { isDendrite } from "../../plugins/homeserver/dendrite"; test.describe("Read receipts", { tag: "@mergequeue" }, () => { + test.skip(isDendrite, "due to Dendrite bug https://github.com/element-hq/dendrite/issues/2970"); + test.describe("editing messages", () => { test.describe("in the main timeline", () => { test("Editing a message leaves a room read", async ({ roomAlpha: room1, roomBeta: room2, util, msg }) => { diff --git a/playwright/e2e/read-receipts/editing-messages-thread-roots.spec.ts b/playwright/e2e/read-receipts/editing-messages-thread-roots.spec.ts index a750fd9ba7..9cd158430a 100644 --- a/playwright/e2e/read-receipts/editing-messages-thread-roots.spec.ts +++ b/playwright/e2e/read-receipts/editing-messages-thread-roots.spec.ts @@ -9,8 +9,11 @@ Please see LICENSE files in the repository root for full details. /* See readme.md for tips on writing these tests. */ import { test } from "."; +import { isDendrite } from "../../plugins/homeserver/dendrite"; test.describe("Read receipts", { tag: "@mergequeue" }, () => { + test.skip(isDendrite, "due to Dendrite bug https://github.com/element-hq/dendrite/issues/2970"); + test.describe("editing messages", () => { test.describe("thread roots", () => { test("An edit of a thread root leaves the room read", async ({ diff --git a/playwright/e2e/read-receipts/high-level.spec.ts b/playwright/e2e/read-receipts/high-level.spec.ts index 627b2d348d..a723928c57 100644 --- a/playwright/e2e/read-receipts/high-level.spec.ts +++ b/playwright/e2e/read-receipts/high-level.spec.ts @@ -9,8 +9,10 @@ Please see LICENSE files in the repository root for full details. /* See readme.md for tips on writing these tests. */ import { customEvent, many, test } from "."; +import { isDendrite } from "../../plugins/homeserver/dendrite"; test.describe("Read receipts", { tag: "@mergequeue" }, () => { + test.skip(isDendrite, "due to Dendrite bug https://github.com/element-hq/dendrite/issues/2970"); test.slow(); test.describe("Ignored events", () => { diff --git a/playwright/e2e/read-receipts/index.ts b/playwright/e2e/read-receipts/index.ts index eab261042e..067d3d16d2 100644 --- a/playwright/e2e/read-receipts/index.ts +++ b/playwright/e2e/read-receipts/index.ts @@ -9,9 +9,9 @@ Please see LICENSE files in the repository root for full details. import type { JSHandle, Page } from "@playwright/test"; import type { MatrixEvent, Room, IndexedDBStore, ReceiptType } from "matrix-js-sdk/src/matrix"; import { test as base, expect } from "../../element-web-test"; -import { Bot } from "../../pages/bot"; -import { Client } from "../../pages/client"; -import { ElementAppPage } from "../../pages/ElementAppPage"; +import { type Bot } from "../../pages/bot"; +import { type Client } from "../../pages/client"; +import { type ElementAppPage } from "../../pages/ElementAppPage"; type RoomRef = { name: string; roomId: string }; @@ -526,9 +526,10 @@ class Helpers { await expect(threadPanel).toBeVisible(); await threadPanel.evaluate(($panel) => { const $button = $panel.querySelector('[data-testid="base-card-back-button"]'); + const title = $panel.querySelector(".mx_BaseCard_header_title")?.textContent; // If the Threads back button is present then click it - the // threads button can open either threads list or thread panel - if ($button) { + if ($button && title !== "Threads") { $button.click(); } }); diff --git a/playwright/e2e/read-receipts/new-messages-in-threads.spec.ts b/playwright/e2e/read-receipts/new-messages-in-threads.spec.ts index 8deef2d2f5..2f3c153f20 100644 --- a/playwright/e2e/read-receipts/new-messages-in-threads.spec.ts +++ b/playwright/e2e/read-receipts/new-messages-in-threads.spec.ts @@ -9,8 +9,11 @@ Please see LICENSE files in the repository root for full details. /* See readme.md for tips on writing these tests. */ import { many, test } from "."; +import { isDendrite } from "../../plugins/homeserver/dendrite"; test.describe("Read receipts", { tag: "@mergequeue" }, () => { + test.skip(isDendrite, "due to Dendrite bug https://github.com/element-hq/dendrite/issues/2970"); + test.describe("new messages", () => { test.describe("in threads", () => { test("Receiving a message makes a room unread", async ({ diff --git a/playwright/e2e/read-receipts/new-messages-main-timeline.spec.ts b/playwright/e2e/read-receipts/new-messages-main-timeline.spec.ts index 4f94e7b09f..16c8132378 100644 --- a/playwright/e2e/read-receipts/new-messages-main-timeline.spec.ts +++ b/playwright/e2e/read-receipts/new-messages-main-timeline.spec.ts @@ -9,8 +9,11 @@ Please see LICENSE files in the repository root for full details. /* See readme.md for tips on writing these tests. */ import { many, test } from "."; +import { isDendrite } from "../../plugins/homeserver/dendrite"; test.describe("Read receipts", { tag: "@mergequeue" }, () => { + test.skip(isDendrite, "due to Dendrite bug https://github.com/element-hq/dendrite/issues/2970"); + test.describe("new messages", () => { test.describe("in the main timeline", () => { test("Receiving a message makes a room unread", async ({ diff --git a/playwright/e2e/read-receipts/new-messages-thread-roots.spec.ts b/playwright/e2e/read-receipts/new-messages-thread-roots.spec.ts index 0e101d311a..fbb70776a8 100644 --- a/playwright/e2e/read-receipts/new-messages-thread-roots.spec.ts +++ b/playwright/e2e/read-receipts/new-messages-thread-roots.spec.ts @@ -9,8 +9,11 @@ Please see LICENSE files in the repository root for full details. /* See readme.md for tips on writing these tests. */ import { many, test } from "."; +import { isDendrite } from "../../plugins/homeserver/dendrite"; test.describe("Read receipts", { tag: "@mergequeue" }, () => { + test.skip(isDendrite, "due to Dendrite bug https://github.com/element-hq/dendrite/issues/2970"); + test.describe("new messages", () => { test.describe("thread roots", () => { test("Reading a thread root does not mark the thread as read", async ({ @@ -54,8 +57,8 @@ test.describe("Read receipts", { tag: "@mergequeue" }, () => { await util.openThread("ThreadRoot"); // Then the thread root is marked as read in the main timeline, - // 30 remaining messages are unread - 7 messages are displayed under the thread root - await util.assertUnread(room2, 30 - 7); + // 30 remaining messages are unread - 6 messages are displayed under the thread root + await util.assertUnread(room2, 30 - 6); }); test("Creating a new thread based on a reply makes the room unread", async ({ diff --git a/playwright/e2e/read-receipts/reactions-in-threads.spec.ts b/playwright/e2e/read-receipts/reactions-in-threads.spec.ts index eb733d3a12..b2cd2e554a 100644 --- a/playwright/e2e/read-receipts/reactions-in-threads.spec.ts +++ b/playwright/e2e/read-receipts/reactions-in-threads.spec.ts @@ -9,8 +9,11 @@ Please see LICENSE files in the repository root for full details. /* See readme.md for tips on writing these tests. */ import { test, expect } from "."; +import { isDendrite } from "../../plugins/homeserver/dendrite"; test.describe("Read receipts", { tag: "@mergequeue" }, () => { + test.skip(isDendrite, "due to Dendrite bug https://github.com/element-hq/dendrite/issues/2970"); + test.describe("reactions", () => { test.describe("in threads", () => { test("A reaction to a threaded message does not make the room unread", async ({ @@ -70,11 +73,7 @@ test.describe("Read receipts", { tag: "@mergequeue" }, () => { // Given a thread exists and I have marked it as read await util.goTo(room1); await util.assertRead(room2); - await util.receiveMessages(room2, [ - "Msg1", - msg.threadedOff("Msg1", "Reply1"), - msg.reactionTo("Reply1", "🪿"), - ]); + await util.receiveMessages(room2, ["Msg1", msg.threadedOff("Msg1", "Reply1")]); await util.assertUnread(room2, 1); await util.markAsRead(room2); await util.assertRead(room2); diff --git a/playwright/e2e/read-receipts/reactions-main-timeline.spec.ts b/playwright/e2e/read-receipts/reactions-main-timeline.spec.ts index d8c1647383..77ed8cd582 100644 --- a/playwright/e2e/read-receipts/reactions-main-timeline.spec.ts +++ b/playwright/e2e/read-receipts/reactions-main-timeline.spec.ts @@ -9,8 +9,11 @@ Please see LICENSE files in the repository root for full details. /* See readme.md for tips on writing these tests. */ import { test } from "."; +import { isDendrite } from "../../plugins/homeserver/dendrite"; test.describe("Read receipts", { tag: "@mergequeue" }, () => { + test.skip(isDendrite, "due to Dendrite bug https://github.com/element-hq/dendrite/issues/2970"); + test.describe("reactions", () => { test.describe("in the main timeline", () => { test("Receiving a reaction to a message does not make a room unread", async ({ diff --git a/playwright/e2e/read-receipts/reactions-thread-roots.spec.ts b/playwright/e2e/read-receipts/reactions-thread-roots.spec.ts index d83d55e5dc..a6d21cb34e 100644 --- a/playwright/e2e/read-receipts/reactions-thread-roots.spec.ts +++ b/playwright/e2e/read-receipts/reactions-thread-roots.spec.ts @@ -9,8 +9,10 @@ Please see LICENSE files in the repository root for full details. /* See readme.md for tips on writing these tests. */ import { test } from "."; +import { isDendrite } from "../../plugins/homeserver/dendrite"; test.describe("Read receipts", { tag: "@mergequeue" }, () => { + test.skip(isDendrite, "due to Dendrite bug https://github.com/element-hq/dendrite/issues/2970"); test.describe("reactions", () => { test.describe("thread roots", () => { test("A reaction to a thread root does not make the room unread", async ({ diff --git a/playwright/e2e/read-receipts/read-receipts.spec.ts b/playwright/e2e/read-receipts/read-receipts.spec.ts index 5d42513b56..8ebce22b52 100644 --- a/playwright/e2e/read-receipts/read-receipts.spec.ts +++ b/playwright/e2e/read-receipts/read-receipts.spec.ts @@ -9,11 +9,13 @@ Please see LICENSE files in the repository root for full details. import type { JSHandle } from "@playwright/test"; import type { MatrixEvent, ISendEventResponse, ReceiptType } from "matrix-js-sdk/src/matrix"; import { expect } from "../../element-web-test"; -import { ElementAppPage } from "../../pages/ElementAppPage"; -import { Bot } from "../../pages/bot"; +import { type ElementAppPage } from "../../pages/ElementAppPage"; +import { type Bot } from "../../pages/bot"; import { test } from "."; +import { isDendrite } from "../../plugins/homeserver/dendrite"; test.describe("Read receipts", { tag: "@mergequeue" }, () => { + test.skip(isDendrite, "due to Dendrite bug https://github.com/element-hq/dendrite/issues/2970"); test.use({ displayName: "Mae", botCreateOpts: { displayName: "Other User" }, @@ -100,12 +102,7 @@ test.describe("Read receipts", { tag: "@mergequeue" }, () => { await page.goto(`/#/room/${selectedRoomId}`); }); - // Disabled due to flakiness: https://github.com/element-hq/element-web/issues/26895 - test.skip("With sync accumulator, considers main thread and unthreaded receipts #24629", async ({ - page, - app, - bot, - }) => { + test("With sync accumulator, considers main thread and unthreaded receipts #24629", async ({ page, app, bot }) => { // Details are in https://github.com/vector-im/element-web/issues/24629 // This proves we've fixed one of the "stuck unreads" issues. diff --git a/playwright/e2e/read-receipts/redactions-in-threads.spec.ts b/playwright/e2e/read-receipts/redactions-in-threads.spec.ts index dc229d0b1b..4e8b6bef5a 100644 --- a/playwright/e2e/read-receipts/redactions-in-threads.spec.ts +++ b/playwright/e2e/read-receipts/redactions-in-threads.spec.ts @@ -9,8 +9,11 @@ Please see LICENSE files in the repository root for full details. /* See readme.md for tips on writing these tests. */ import { test } from "."; +import { isDendrite } from "../../plugins/homeserver/dendrite"; test.describe("Read receipts", { tag: "@mergequeue" }, () => { + test.skip(isDendrite, "due to Dendrite bug https://github.com/element-hq/dendrite/issues/2970"); + test.describe("redactions", () => { test.describe("in threads", () => { test("Redacting the threaded message pointed to by my receipt leaves the room read", async ({ diff --git a/playwright/e2e/read-receipts/redactions-main-timeline.spec.ts b/playwright/e2e/read-receipts/redactions-main-timeline.spec.ts index 356d03938f..203cbb997f 100644 --- a/playwright/e2e/read-receipts/redactions-main-timeline.spec.ts +++ b/playwright/e2e/read-receipts/redactions-main-timeline.spec.ts @@ -9,8 +9,11 @@ Please see LICENSE files in the repository root for full details. /* See readme.md for tips on writing these tests. */ import { test } from "."; +import { isDendrite } from "../../plugins/homeserver/dendrite"; test.describe("Read receipts", { tag: "@mergequeue" }, () => { + test.skip(isDendrite, "due to Dendrite bug https://github.com/element-hq/dendrite/issues/2970"); + test.describe("redactions", () => { test.describe("in the main timeline", () => { test("Redacting the message pointed to by my receipt leaves the room read", async ({ diff --git a/playwright/e2e/read-receipts/redactions-thread-roots.spec.ts b/playwright/e2e/read-receipts/redactions-thread-roots.spec.ts index d875b0cecb..108e61df34 100644 --- a/playwright/e2e/read-receipts/redactions-thread-roots.spec.ts +++ b/playwright/e2e/read-receipts/redactions-thread-roots.spec.ts @@ -9,8 +9,11 @@ Please see LICENSE files in the repository root for full details. /* See readme.md for tips on writing these tests. */ import { test } from "."; +import { isDendrite } from "../../plugins/homeserver/dendrite"; test.describe("Read receipts", { tag: "@mergequeue" }, () => { + test.skip(isDendrite, "due to Dendrite bug https://github.com/element-hq/dendrite/issues/2970"); + test.describe("redactions", () => { test.describe("thread roots", () => { test("Redacting a thread root after it was read leaves the room read", async ({ diff --git a/playwright/e2e/register/email.spec.ts b/playwright/e2e/register/email.spec.ts index 74c8ba7962..bf8a2157f5 100644 --- a/playwright/e2e/register/email.spec.ts +++ b/playwright/e2e/register/email.spec.ts @@ -32,9 +32,9 @@ test.describe("Email Registration", async () => { }); test( - "registers an account and lands on the use case selection screen", + "registers an account and lands on the home page", { tag: "@screenshot" }, - async ({ page, mailhogClient, request, checkA11y }) => { + async ({ page, mailpitClient, request, axe }) => { await expect(page.getByRole("textbox", { name: "Username" })).toBeVisible(); // Hide the server text as it contains the randomly allocated Homeserver port const screenshotOptions = { mask: [page.locator(".mx_ServerPicker_server")] }; @@ -47,17 +47,18 @@ test.describe("Email Registration", async () => { await expect(page.getByText("Check your email to continue")).toBeVisible(); await expect(page).toMatchScreenshot("registration_check_your_email.png", screenshotOptions); - await checkA11y(); + await expect(axe).toHaveNoViolations(); await expect(page.getByText("An error was encountered when sending the email")).not.toBeVisible(); - const messages = await mailhogClient.messages(); - expect(messages.items).toHaveLength(1); - expect(messages.items[0].to).toEqual("alice@email.com"); - const [emailLink] = messages.items[0].text.match(/http.+/); + const messages = await mailpitClient.listMessages(); + expect(messages.messages).toHaveLength(1); + expect(messages.messages[0].To[0].Address).toEqual("alice@email.com"); + const text = await mailpitClient.renderMessageText(messages.messages[0].ID); + const [emailLink] = text.match(/http.+/); await request.get(emailLink); // "Click" the link in the email - await expect(page.locator(".mx_UseCaseSelection_skip")).toBeVisible(); + await expect(page.getByText("Welcome alice")).toBeVisible(); }, ); }); diff --git a/playwright/e2e/register/register.spec.ts b/playwright/e2e/register/register.spec.ts index 90854de33a..c481b6ac43 100644 --- a/playwright/e2e/register/register.spec.ts +++ b/playwright/e2e/register/register.spec.ts @@ -8,6 +8,7 @@ Please see LICENSE files in the repository root for full details. import { test, expect } from "../../element-web-test"; import { consentHomeserver } from "../../plugins/homeserver/synapse/consentHomeserver.ts"; +import { isDendrite } from "../../plugins/homeserver/dendrite"; test.use(consentHomeserver); test.use({ @@ -23,6 +24,8 @@ test.use({ }); test.describe("Registration", () => { + test.skip(isDendrite, "Dendrite lacks support for MSC3967 so requires additional auth here"); + test.beforeEach(async ({ page }) => { await page.goto("/#/register"); }); @@ -30,12 +33,12 @@ test.describe("Registration", () => { test( "registers an account and lands on the home screen", { tag: "@screenshot" }, - async ({ homeserver, page, checkA11y, crypto }) => { + async ({ homeserver, page, axe, crypto }) => { await page.getByRole("button", { name: "Edit", exact: true }).click(); await expect(page.getByRole("button", { name: "Continue", exact: true })).toBeVisible(); await expect(page.locator(".mx_Dialog")).toMatchScreenshot("server-picker.png"); - await checkA11y(); + await expect(axe).toHaveNoViolations(); await page.getByRole("textbox", { name: "Other homeserver" }).fill(homeserver.baseUrl); await page.getByRole("button", { name: "Continue", exact: true }).click(); @@ -49,7 +52,7 @@ test.describe("Registration", () => { includeDialogBackground: true, }; await expect(page).toMatchScreenshot("registration.png", screenshotOptions); - await checkA11y(); + await expect(axe).toHaveNoViolations(); await page.getByRole("textbox", { name: "Username", exact: true }).fill("alice"); await page.getByPlaceholder("Password", { exact: true }).fill("totally a great password"); @@ -59,24 +62,18 @@ test.describe("Registration", () => { const dialog = page.getByRole("dialog"); await expect(dialog).toBeVisible(); await expect(page).toMatchScreenshot("email-prompt.png", screenshotOptions); - await checkA11y(); + await expect(axe).toHaveNoViolations(); await dialog.getByRole("button", { name: "Continue", exact: true }).click(); await expect(page.locator(".mx_InteractiveAuthEntryComponents_termsPolicy")).toBeVisible(); await expect(page).toMatchScreenshot("terms-prompt.png", screenshotOptions); - await checkA11y(); + await expect(axe).toHaveNoViolations(); const termsPolicy = page.locator(".mx_InteractiveAuthEntryComponents_termsPolicy"); await termsPolicy.getByRole("checkbox").click(); // Click the checkbox before terms of service anchor link await expect(termsPolicy.getByLabel("Privacy Policy")).toBeVisible(); await page.getByRole("button", { name: "Accept", exact: true }).click(); - - await expect(page.locator(".mx_UseCaseSelection_skip")).toBeVisible(); - await expect(page).toMatchScreenshot("use-case-selection.png", screenshotOptions); - await checkA11y(); - await page.getByRole("button", { name: "Skip", exact: true }).click(); - await expect(page).toHaveURL(/\/#\/home$/); /* diff --git a/playwright/e2e/release-announcement/index.ts b/playwright/e2e/release-announcement/index.ts index e20dfb85b4..3b6c2dd38a 100644 --- a/playwright/e2e/release-announcement/index.ts +++ b/playwright/e2e/release-announcement/index.ts @@ -6,7 +6,7 @@ * Please see LICENSE files in the repository root for full details. */ -import { Page } from "@playwright/test"; +import { type Page } from "@playwright/test"; import { test as base, expect } from "../../element-web-test"; diff --git a/playwright/e2e/right-panel/file-panel.spec.ts b/playwright/e2e/right-panel/file-panel.spec.ts index 579ba05bb7..d69b7d4731 100644 --- a/playwright/e2e/right-panel/file-panel.spec.ts +++ b/playwright/e2e/right-panel/file-panel.spec.ts @@ -6,10 +6,11 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com Please see LICENSE files in the repository root for full details. */ -import { Download, type Page } from "@playwright/test"; +import { type Download, type Page } from "@playwright/test"; import { test, expect } from "../../element-web-test"; import { viewRoomSummaryByName } from "./utils"; +import { isDendrite } from "../../plugins/homeserver/dendrite"; const ROOM_NAME = "Test room"; const NAME = "Alice"; @@ -181,6 +182,8 @@ test.describe("FilePanel", () => { }); test.describe("download", () => { + test.skip(isDendrite, "due to a Dendrite sending Content-Disposition inline"); + test("should download an image via the link on the panel", async ({ page, context }) => { // Upload an image file await uploadFile(page, "playwright/sample-files/riot.png"); diff --git a/playwright/e2e/right-panel/memberlist.spec.ts b/playwright/e2e/right-panel/memberlist.spec.ts index 1275e243b6..cd22626575 100644 --- a/playwright/e2e/right-panel/memberlist.spec.ts +++ b/playwright/e2e/right-panel/memberlist.spec.ts @@ -12,7 +12,7 @@ const ROOM_NAME = "Test room"; const NAME = "Alice"; test.use({ - synapseConfigOptions: { + synapseConfig: { presence: { enabled: false, include_offline_users_on_sync: false, @@ -42,7 +42,7 @@ test.describe("Memberlist", () => { await app.viewRoomByName(ROOM_NAME); const memberlist = await app.toggleMemberlistPanel(); await expect(memberlist.locator(".mx_MemberTileView")).toHaveCount(4); - await expect(memberlist.getByText("(Invited)")).toHaveCount(1); + await expect(memberlist.getByText("Invited")).toHaveCount(1); await expect(page.locator(".mx_MemberListView")).toMatchScreenshot("with-four-members.png"); }); }); diff --git a/playwright/e2e/right-panel/right-panel.spec.ts b/playwright/e2e/right-panel/right-panel.spec.ts index 0bdd0a283a..cc03963801 100644 --- a/playwright/e2e/right-panel/right-panel.spec.ts +++ b/playwright/e2e/right-panel/right-panel.spec.ts @@ -6,7 +6,7 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com Please see LICENSE files in the repository root for full details. */ -import { Locator, type Page } from "@playwright/test"; +import { type Locator, type Page } from "@playwright/test"; import { test, expect } from "../../element-web-test"; import { checkRoomSummaryCard, viewRoomSummaryByName } from "./utils"; @@ -38,29 +38,42 @@ test.describe("RightPanel", () => { }); test.describe("in rooms", () => { - test("should handle long room address and long room name", { tag: "@screenshot" }, async ({ page, app }) => { - await app.client.createRoom({ name: ROOM_NAME_LONG }); - await viewRoomSummaryByName(page, app, ROOM_NAME_LONG); + test( + "should handle long room address and long room name", + { tag: "@screenshot" }, + async ({ page, app, user }) => { + await app.client.createRoom({ name: ROOM_NAME_LONG }); + await viewRoomSummaryByName(page, app, ROOM_NAME_LONG); - await app.settings.openRoomSettings(); + await app.settings.openRoomSettings(); - // Set a local room address - const localAddresses = page.locator(".mx_SettingsFieldset", { hasText: "Local Addresses" }); - await localAddresses.getByRole("textbox").fill(ROOM_ADDRESS_LONG); - await expect(page.getByText("This address is available to use")).toBeVisible(); - await localAddresses.getByRole("button", { name: "Add" }).click(); - await expect(localAddresses.getByText(`#${ROOM_ADDRESS_LONG}:localhost`)).toHaveClass( - "mx_EditableItem_item", - ); + // Set a local room address + const localAddresses = page.locator(".mx_SettingsFieldset", { hasText: "Local Addresses" }); + await localAddresses.getByRole("textbox").fill(ROOM_ADDRESS_LONG); + await expect(page.getByText("This address is available to use")).toBeVisible(); + await localAddresses.getByRole("button", { name: "Add" }).click(); + await expect(localAddresses.getByText(`#${ROOM_ADDRESS_LONG}:${user.homeServer}`)).toHaveClass( + "mx_EditableItem_item", + ); - await app.closeDialog(); + await app.closeDialog(); - // Close and reopen the right panel to render the room address - await app.toggleRoomInfoPanel(); - await expect(page.locator(".mx_RightPanel")).not.toBeVisible(); - await app.toggleRoomInfoPanel(); + // Close and reopen the right panel to render the room address + await app.toggleRoomInfoPanel(); + await expect(page.locator(".mx_RightPanel")).not.toBeVisible(); + await app.toggleRoomInfoPanel(); - await expect(page.locator(".mx_RightPanel")).toMatchScreenshot("with-name-and-address.png"); + await expect(page.locator(".mx_RightPanel")).toMatchScreenshot("with-name-and-address.png"); + }, + ); + + test("should have padding under leave room", { tag: "@screenshot" }, async ({ page, app }) => { + await viewRoomSummaryByName(page, app, ROOM_NAME); + + const leaveButton = await page.getByRole("menuitem", { name: "Leave Room" }); + await leaveButton.scrollIntoViewIfNeeded(); + + await expect(page.locator(".mx_RightPanel")).toMatchScreenshot("with-leave-room.png"); }); test("should handle clicking add widgets", async ({ page, app }) => { diff --git a/playwright/e2e/right-panel/utils.ts b/playwright/e2e/right-panel/utils.ts index 0f57178f50..f4745b0158 100644 --- a/playwright/e2e/right-panel/utils.ts +++ b/playwright/e2e/right-panel/utils.ts @@ -8,7 +8,7 @@ Please see LICENSE files in the repository root for full details. import { type Page, expect } from "@playwright/test"; -import { ElementAppPage } from "../../pages/ElementAppPage"; +import { type ElementAppPage } from "../../pages/ElementAppPage"; export async function viewRoomSummaryByName(page: Page, app: ElementAppPage, name: string): Promise { await app.viewRoomByName(name); diff --git a/playwright/e2e/room-directory/room-directory.spec.ts b/playwright/e2e/room-directory/room-directory.spec.ts index a38cc7d395..8f90ef4b7e 100644 --- a/playwright/e2e/room-directory/room-directory.spec.ts +++ b/playwright/e2e/room-directory/room-directory.spec.ts @@ -10,6 +10,7 @@ import type { Preset, Visibility } from "matrix-js-sdk/src/matrix"; import { test, expect } from "../../element-web-test"; test.describe("Room Directory", () => { + test.skip(({ homeserverType }) => homeserverType === "pinecone", "Pinecone's /publicRooms API takes forever"); test.use({ displayName: "Ray", botCreateOpts: { displayName: "Paul" }, @@ -32,14 +33,14 @@ test.describe("Room Directory", () => { await localAddresses.getByRole("textbox").fill("gaming"); await expect(page.getByText("This address is available to use")).toBeVisible(); await localAddresses.getByRole("button", { name: "Add" }).click(); - await expect(localAddresses.getByText("#gaming:localhost")).toHaveClass("mx_EditableItem_item"); + await expect(localAddresses.getByText(`#gaming:${user.homeServer}`)).toHaveClass("mx_EditableItem_item"); // Publish into the public rooms directory const publishedAddresses = page.locator(".mx_SettingsFieldset", { hasText: "Published Addresses" }); - await expect(publishedAddresses.locator("#canonicalAlias")).toHaveValue("#gaming:localhost"); + await expect(publishedAddresses.locator("#canonicalAlias")).toHaveValue(`#gaming:${user.homeServer}`); const checkbox = publishedAddresses .locator(".mx_SettingsFlag", { - hasText: "Publish this room to the public in localhost's room directory?", + hasText: `Publish this room to the public in ${user.homeServer}'s room directory?`, }) .getByRole("switch"); await checkbox.check(); @@ -87,7 +88,7 @@ test.describe("Room Directory", () => { .getByRole("button", { name: "Join" }) .click(); - await expect(page).toHaveURL("/#/room/#test1234:localhost"); + await expect(page).toHaveURL(`/#/room/#test1234:${user.homeServer}`); }, ); }); diff --git a/playwright/e2e/room/room-header.spec.ts b/playwright/e2e/room/room-header.spec.ts index 7fe0cb3d47..78e37cd4d2 100644 --- a/playwright/e2e/room/room-header.spec.ts +++ b/playwright/e2e/room/room-header.spec.ts @@ -6,10 +6,11 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com Please see LICENSE files in the repository root for full details. */ -import { Page } from "@playwright/test"; +import { type Page } from "@playwright/test"; +import { type Visibility } from "matrix-js-sdk/src/matrix"; import { test, expect } from "../../element-web-test"; -import { ElementAppPage } from "../../pages/ElementAppPage"; +import { type ElementAppPage } from "../../pages/ElementAppPage"; test.describe("Room Header", () => { test.use({ @@ -85,6 +86,15 @@ test.describe("Room Header", () => { await expect(header).toMatchScreenshot("room-header-long-name.png"); }, ); + + test("should render room header icon correctly", { tag: "@screenshot" }, async ({ page, app, user }) => { + await app.client.createRoom({ name: "Test Room", visibility: "public" as Visibility }); + await app.viewRoomByName("Test Room"); + + const header = page.locator(".mx_RoomHeader"); + + await expect(header).toMatchScreenshot("room-header-with-icon.png"); + }); }); test.describe("with a video room", () => { @@ -111,6 +121,10 @@ test.describe("Room Header", () => { async ({ page, app, user }) => { await createVideoRoom(page, app); + // Dismiss a toast that is otherwise in the way (it's the other + // side but there's no need to have it in the screenshot) + await page.getByRole("button", { name: "Later" }).click(); + const header = page.locator(".mx_RoomHeader"); // There's two room info button - the header itself and the i button diff --git a/playwright/e2e/room_options/marked_unread.spec.ts b/playwright/e2e/room_options/marked_unread.spec.ts index b314152e68..2817bbc921 100644 --- a/playwright/e2e/room_options/marked_unread.spec.ts +++ b/playwright/e2e/room_options/marked_unread.spec.ts @@ -7,10 +7,13 @@ Please see LICENSE files in the repository root for full details. */ import { test, expect } from "../../element-web-test"; +import { isDendrite } from "../../plugins/homeserver/dendrite"; const TEST_ROOM_NAME = "The mark unread test room"; test.describe("Mark as Unread", () => { + test.skip(isDendrite, "due to Dendrite bug https://github.com/element-hq/dendrite/issues/2970"); + test.use({ displayName: "Tom", botCreateOpts: { @@ -48,6 +51,6 @@ test.describe("Mark as Unread", () => { await roomTile.getByRole("button", { name: "Room options" }).click(); await page.getByRole("menuitem", { name: "Mark as unread" }).click(); - expect(page.getByLabel(TEST_ROOM_NAME + " Unread messages.")).toBeVisible(); + await expect(page.getByLabel(TEST_ROOM_NAME + " Unread messages.")).toBeVisible(); }); }); diff --git a/playwright/e2e/settings/account-user-settings-tab.spec.ts b/playwright/e2e/settings/account-user-settings-tab.spec.ts index f037417447..df011bdc4e 100644 --- a/playwright/e2e/settings/account-user-settings-tab.spec.ts +++ b/playwright/e2e/settings/account-user-settings-tab.spec.ts @@ -34,14 +34,14 @@ test.describe("Account user settings tab", () => { await expect(profile.getByRole("textbox", { name: "Display Name" })).toHaveValue(USER_NAME); // Assert that a userId is rendered - expect(uut.getByLabel("Username")).toHaveText(user.userId); + await expect(uut.getByLabel("Username")).toHaveText(user.userId); // Wait until spinners disappear await expect(uut.getByTestId("accountSection").locator(".mx_Spinner")).not.toBeVisible(); await expect(uut.getByTestId("discoverySection").locator(".mx_Spinner")).not.toBeVisible(); const accountSection = uut.getByTestId("accountSection"); - accountSection.scrollIntoViewIfNeeded(); + await accountSection.scrollIntoViewIfNeeded(); // Assert that input areas for changing a password exists await expect(accountSection.getByLabel("Current password")).toBeVisible(); await expect(accountSection.getByLabel("New Password")).toBeVisible(); diff --git a/playwright/e2e/settings/appearance-user-settings-tab/index.ts b/playwright/e2e/settings/appearance-user-settings-tab/index.ts index be609edf9f..29e51fb0dd 100644 --- a/playwright/e2e/settings/appearance-user-settings-tab/index.ts +++ b/playwright/e2e/settings/appearance-user-settings-tab/index.ts @@ -6,9 +6,9 @@ * Please see LICENSE files in the repository root for full details. */ -import { Locator, Page } from "@playwright/test"; +import { type Locator, type Page } from "@playwright/test"; -import { ElementAppPage } from "../../../pages/ElementAppPage"; +import { type ElementAppPage } from "../../../pages/ElementAppPage"; import { test as base, expect } from "../../../element-web-test"; import { SettingLevel } from "../../../../src/settings/SettingLevel"; import { Layout } from "../../../../src/settings/enums/Layout"; diff --git a/playwright/e2e/settings/encryption-user-tab/advanced.spec.ts b/playwright/e2e/settings/encryption-user-tab/advanced.spec.ts new file mode 100644 index 0000000000..2b459c2e11 --- /dev/null +++ b/playwright/e2e/settings/encryption-user-tab/advanced.spec.ts @@ -0,0 +1,73 @@ +/* + * 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 "./index"; +import { checkDeviceIsCrossSigned } from "../../crypto/utils"; +import { bootstrapCrossSigningForClient } from "../../../pages/client"; + +test.describe("Advanced section in Encryption tab", () => { + test.beforeEach(async ({ page, app, homeserver, credentials, util }) => { + const clientHandle = await app.client.prepareClient(); + // Reset cross signing in order to have a verified session + await bootstrapCrossSigningForClient(clientHandle, credentials, true); + }); + + test("should show the encryption details", { tag: "@screenshot" }, async ({ page, app, util }) => { + await util.openEncryptionTab(); + const section = util.getEncryptionDetailsSection(); + + const deviceId = await page.evaluate(() => window.mxMatrixClientPeg.get().getDeviceId()); + await expect(section.getByText(deviceId)).toBeVisible(); + + await expect(section).toMatchScreenshot("encryption-details.png", { + mask: [section.getByTestId("deviceId"), section.getByTestId("sessionKey")], + }); + }); + + test("should show the import room keys dialog", async ({ page, app, util }) => { + await util.openEncryptionTab(); + const section = util.getEncryptionDetailsSection(); + + await section.getByRole("button", { name: "Import keys" }).click(); + await expect(page.getByRole("heading", { name: "Import room keys" })).toBeVisible(); + }); + + test("should show the export room keys dialog", async ({ page, app, util }) => { + await util.openEncryptionTab(); + const section = util.getEncryptionDetailsSection(); + + await section.getByRole("button", { name: "Export keys" }).click(); + await expect(page.getByRole("heading", { name: "Export room keys" })).toBeVisible(); + }); + + test( + "should reset the cryptographic identity", + { tag: "@screenshot" }, + async ({ page, app, credentials, util }) => { + const tab = await util.openEncryptionTab(); + const section = util.getEncryptionDetailsSection(); + + await section.getByRole("button", { name: "Reset cryptographic identity" }).click(); + await expect(util.getEncryptionTabContent()).toMatchScreenshot("reset-cryptographic-identity.png"); + await tab.getByRole("button", { name: "Continue" }).click(); + + // Fill password dialog and validate + const dialog = page.locator(".mx_InteractiveAuthDialog"); + await dialog.getByRole("textbox", { name: "Password" }).fill(credentials.password); + await dialog.getByRole("button", { name: "Continue" }).click(); + + await expect(section.getByRole("button", { name: "Reset cryptographic identity" })).toBeVisible(); + + // After resetting the identity, the user should set up a new recovery key + await expect( + util.getEncryptionRecoverySection().getByRole("button", { name: "Set up recovery" }), + ).toBeVisible(); + + await checkDeviceIsCrossSigned(app); + }, + ); +}); diff --git a/playwright/e2e/settings/encryption-user-tab/encryption-tab.spec.ts b/playwright/e2e/settings/encryption-user-tab/encryption-tab.spec.ts new file mode 100644 index 0000000000..427c801ef4 --- /dev/null +++ b/playwright/e2e/settings/encryption-user-tab/encryption-tab.spec.ts @@ -0,0 +1,144 @@ +/* + * 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 GeneratedSecretStorageKey } from "matrix-js-sdk/src/crypto-api"; + +import { test, expect } from "."; +import { + checkDeviceIsConnectedKeyBackup, + checkDeviceIsCrossSigned, + createBot, + deleteCachedSecrets, + verifySession, +} from "../../crypto/utils"; + +test.describe("Encryption tab", () => { + test.use({ displayName: "Alice" }); + + 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( + "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(); + + await util.verifyDevice(recoveryKey); + + 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 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 }) => { + 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(); + const dialog = util.getEncryptionTabContent(); + await dialog.getByRole("button", { name: "Forgot recovery key?" }).click(); + + // The user is prompted to reset their identity + await expect(dialog.getByText("Forgot your recovery key? You’ll need to reset your identity.")).toBeVisible(); + }); + + test("should warn before turning off key storage", { tag: "@screenshot" }, async ({ page, app, util }) => { + await verifySession(app, recoveryKey.encodedPrivateKey); + await util.openEncryptionTab(); + + await page.getByRole("checkbox", { name: "Allow key storage" }).click(); + + await expect( + page.getByRole("heading", { name: "Are you sure you want to turn off key storage and delete it?" }), + ).toBeVisible(); + + await expect(util.getEncryptionTabContent()).toMatchScreenshot("delete-key-storage-confirm.png"); + + 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({})); + } + }); +}); diff --git a/playwright/e2e/settings/encryption-user-tab/index.ts b/playwright/e2e/settings/encryption-user-tab/index.ts new file mode 100644 index 0000000000..a7351fd2b4 --- /dev/null +++ b/playwright/e2e/settings/encryption-user-tab/index.ts @@ -0,0 +1,110 @@ +/* + * Copyright 2024 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial + * Please see LICENSE files in the repository root for full details. + */ + +import { type Page } from "@playwright/test"; +import { type GeneratedSecretStorageKey } from "matrix-js-sdk/src/crypto-api"; + +import { type ElementAppPage } from "../../../pages/ElementAppPage"; +import { test as base, expect } from "../../../element-web-test"; +export { expect }; + +/** + * Set up for the encryption tab test + */ +export const test = base.extend<{ + util: Helpers; +}>({ + displayName: "Alice", + + util: async ({ page, app, bot }, use) => { + await use(new Helpers(page, app)); + }, +}); + +class Helpers { + constructor( + private page: Page, + private app: ElementAppPage, + ) {} + + /** + * Open the encryption tab + */ + openEncryptionTab() { + return this.app.settings.openUserSettings("Encryption"); + } + + /** + * Go through the device verification flow using the recovery key. + */ + async verifyDevice(recoveryKey: GeneratedSecretStorageKey) { + // Select the security phrase + await this.page.getByRole("button", { name: "Verify with Recovery Key" }).click(); + await this.enterRecoveryKey(recoveryKey); + await this.page.getByRole("button", { name: "Done" }).click(); + } + + /** + * Fill the recovery key in the dialog + * @param recoveryKey + */ + async enterRecoveryKey(recoveryKey: GeneratedSecretStorageKey) { + // Fill the recovery key + const dialog = this.page.locator(".mx_Dialog"); + await dialog.getByRole("textbox").fill(recoveryKey.encodedPrivateKey); + await dialog.getByRole("button", { name: "Continue" }).click(); + } + + /** + * Get the encryption tab content + */ + getEncryptionTabContent() { + return this.page.getByTestId("encryptionTab"); + } + + /** + * Get the recovery section + */ + getEncryptionRecoverySection() { + return this.page.getByTestId("recoveryPanel"); + } + + /** + * Get the encryption details section + */ + getEncryptionDetailsSection() { + return this.page.getByTestId("encryptionDetails"); + } + + /** + * Set the default key id of the secret storage to `null` + */ + async removeSecretStorageDefaultKeyId() { + const client = await this.app.client.prepareClient(); + await client.evaluate(async (client) => { + await client.secretStorage.setDefaultKeyId(null); + }); + } + + /** + * Get the recovery key from the clipboard and fill in the input field + * Then click on the finish button + * @param title - The title of the dialog + * @param confirmButtonLabel - The label of the confirm button + * @param screenshot + */ + async confirmRecoveryKey(title: string, confirmButtonLabel: string, screenshot: `${string}.png`) { + const dialog = this.getEncryptionTabContent(); + await expect(dialog.getByText(title, { exact: true })).toBeVisible(); + await expect(dialog).toMatchScreenshot(screenshot); + + const clipboardContent = await this.app.getClipboard(); + await dialog.getByRole("textbox").fill(clipboardContent); + await dialog.getByRole("button", { name: confirmButtonLabel }).click(); + await expect(this.getEncryptionRecoverySection()).toMatchScreenshot("default-recovery.png"); + } +} diff --git a/playwright/e2e/settings/encryption-user-tab/recovery.spec.ts b/playwright/e2e/settings/encryption-user-tab/recovery.spec.ts new file mode 100644 index 0000000000..8895e4a7ee --- /dev/null +++ b/playwright/e2e/settings/encryption-user-tab/recovery.spec.ts @@ -0,0 +1,91 @@ +/* + * Copyright 2024 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial + * Please see LICENSE files in the repository root for full details. + */ + +import { test, expect } from "."; +import { checkDeviceIsConnectedKeyBackup, createBot, verifySession } from "../../crypto/utils"; +import type { GeneratedSecretStorageKey } from "matrix-js-sdk/src/crypto-api"; + +test.describe("Recovery section in Encryption tab", () => { + test.use({ + displayName: "Alice", + }); + + let recoveryKey: GeneratedSecretStorageKey; + test.beforeEach(async ({ page, homeserver, credentials }) => { + // The bot bootstraps cross-signing, creates a key backup and sets up a recovery key + const res = await createBot(page, homeserver, credentials); + recoveryKey = res.recoveryKey; + }); + + test( + "should change the recovery key", + { tag: ["@screenshot", "@no-webkit"] }, + async ({ page, app, homeserver, credentials, util, context }) => { + await verifySession(app, recoveryKey.encodedPrivateKey); + const dialog = await util.openEncryptionTab(); + + // The user can only change the recovery key + const changeButton = dialog.getByRole("button", { name: "Change recovery key" }); + await expect(changeButton).toBeVisible(); + await expect(util.getEncryptionRecoverySection()).toMatchScreenshot("default-recovery.png"); + await changeButton.click(); + + // Display the new recovery key and click on the copy button + await expect(dialog.getByText("Change recovery key?")).toBeVisible(); + await expect(util.getEncryptionTabContent()).toMatchScreenshot("change-key-1-encryption-tab.png", { + mask: [dialog.getByTestId("recoveryKey")], + }); + await dialog.getByRole("button", { name: "Copy" }).click(); + await dialog.getByRole("button", { name: "Continue" }).click(); + + // Confirm the recovery key + await util.confirmRecoveryKey( + "Enter your new recovery key", + "Confirm new recovery key", + "change-key-2-encryption-tab.png", + ); + }, + ); + + test("should setup the recovery key", { tag: ["@screenshot", "@no-webkit"] }, async ({ page, app, util }) => { + await verifySession(app, recoveryKey.encodedPrivateKey); + await util.removeSecretStorageDefaultKeyId(); + + // The key backup is deleted and the user needs to set it up + const dialog = await util.openEncryptionTab(); + const setupButton = dialog.getByRole("button", { name: "Set up recovery" }); + await expect(setupButton).toBeVisible(); + await expect(util.getEncryptionRecoverySection()).toMatchScreenshot("set-up-recovery.png"); + await setupButton.click(); + + // Display an informative panel about the recovery key + await expect(dialog.getByRole("heading", { name: "Set up recovery" })).toBeVisible(); + await expect(util.getEncryptionTabContent()).toMatchScreenshot("set-up-key-1-encryption-tab.png"); + await dialog.getByRole("button", { name: "Continue" }).click(); + + // Display the new recovery key and click on the copy button + await expect(dialog.getByText("Save your recovery key somewhere safe")).toBeVisible(); + await expect(util.getEncryptionTabContent()).toMatchScreenshot("set-up-key-2-encryption-tab.png", { + mask: [dialog.getByTestId("recoveryKey")], + }); + await dialog.getByRole("button", { name: "Copy" }).click(); + await dialog.getByRole("button", { name: "Continue" }).click(); + + // Confirm the recovery key + await util.confirmRecoveryKey( + "Enter your recovery key to confirm", + "Finish set up", + "set-up-key-3-encryption-tab.png", + ); + + // The recovery key is now set up and the user can change it + await expect(dialog.getByRole("button", { name: "Change recovery key" })).toBeVisible(); + + // Check that the current device is connected to key backup and the backup version is the expected one + await checkDeviceIsConnectedKeyBackup(app, "1", true); + }); +}); diff --git a/playwright/e2e/settings/general-room-settings-tab.spec.ts b/playwright/e2e/settings/general-room-settings-tab.spec.ts index 5e29f802c2..376412914a 100644 --- a/playwright/e2e/settings/general-room-settings-tab.spec.ts +++ b/playwright/e2e/settings/general-room-settings-tab.spec.ts @@ -36,7 +36,7 @@ test.describe("General room settings tab", () => { await expect(settings.getByText("Show more")).toBeVisible(); }); - test("long address should not cause dialog to overflow", { tag: "@no-webkit" }, async ({ page, app }) => { + test("long address should not cause dialog to overflow", { tag: "@no-webkit" }, async ({ page, app, user }) => { const settings = await app.settings.openRoomSettings("General"); // 1. Set the room-address to be a really long string const longString = "abcasdhjasjhdaj1jh1asdhasjdhajsdhjavhjksd".repeat(4); @@ -45,7 +45,7 @@ test.describe("General room settings tab", () => { await settings.locator("#roomAliases").getByText("Add", { exact: true }).click(); // 2. wait for the new setting to apply ... - await expect(settings.locator("#canonicalAlias")).toHaveValue(`#${longString}:localhost`); + await expect(settings.locator("#canonicalAlias")).toHaveValue(`#${longString}:${user.homeServer}`); // 3. Check if the dialog overflows const dialogBoundingBox = await page.locator(".mx_Dialog").boundingBox(); diff --git a/playwright/e2e/settings/preferences-user-settings-tab.spec.ts b/playwright/e2e/settings/preferences-user-settings-tab.spec.ts index 4b6e3e299d..5c7c9efffb 100644 --- a/playwright/e2e/settings/preferences-user-settings-tab.spec.ts +++ b/playwright/e2e/settings/preferences-user-settings-tab.spec.ts @@ -24,7 +24,7 @@ test.describe("Preferences user settings tab", () => { }); test("should be rendered properly", { tag: "@screenshot" }, async ({ app, page, user }) => { - page.setViewportSize({ width: 1024, height: 3300 }); + await page.setViewportSize({ width: 1024, height: 3300 }); const tab = await app.settings.openUserSettings("Preferences"); // Assert that the top heading is rendered await expect(tab.getByRole("heading", { name: "Preferences" })).toBeVisible(); @@ -61,7 +61,7 @@ test.describe("Preferences user settings tab", () => { // Click the button to display the dropdown menu await timezoneInput.getByRole("button", { name: "Set timezone" }).click(); // Select a different value - timezoneInput.getByRole("option", { name: /Africa\/Abidjan/ }).click(); + await timezoneInput.getByRole("option", { name: /Africa\/Abidjan/ }).click(); // Check the new value await expect(timezoneValue.getByText("Africa/Abidjan")).toBeVisible(); }); diff --git a/playwright/e2e/settings/quick-settings-menu.spec.ts b/playwright/e2e/settings/quick-settings-menu.spec.ts new file mode 100644 index 0000000000..e58d523c21 --- /dev/null +++ b/playwright/e2e/settings/quick-settings-menu.spec.ts @@ -0,0 +1,18 @@ +/* +Copyright 2025 New Vector Ltd. + +SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial +Please see LICENSE files in the repository root for full details. +*/ + +import { test, expect } from "../../element-web-test"; + +test.describe("Quick settings menu", () => { + test("should be rendered properly", { tag: "@screenshot" }, async ({ app, page, user }) => { + await page.getByRole("button", { name: "Quick settings" }).click(); + // Assert that the top heading is renderedc + const settings = page.getByTestId("quick-settings-menu"); + await expect(settings).toBeVisible(); + await expect(settings).toMatchScreenshot("quick-settings.png"); + }); +}); 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 1193afe135..7cc4001c58 100644 --- a/playwright/e2e/settings/roles-permissions-room-settings-tab.spec.ts +++ b/playwright/e2e/settings/roles-permissions-room-settings-tab.spec.ts @@ -6,7 +6,7 @@ * Please see LICENSE files in the repository root for full details. */ -import { Locator } from "@playwright/test"; +import { type Locator } from "@playwright/test"; import { test, expect } from "../../element-web-test"; diff --git a/playwright/e2e/settings/security-user-settings-tab.spec.ts b/playwright/e2e/settings/security-user-settings-tab.spec.ts index b723d1398f..25bf1a9dbe 100644 --- a/playwright/e2e/settings/security-user-settings-tab.spec.ts +++ b/playwright/e2e/settings/security-user-settings-tab.spec.ts @@ -25,18 +25,14 @@ test.describe("Security user settings tab", () => { }, }); - test.beforeEach(async ({ page, user }) => { + test.beforeEach(async ({ page, app, user }) => { // Dismiss "Notification" toast - await page - .locator(".mx_Toast_toast", { hasText: "Notifications" }) - .getByRole("button", { name: "Dismiss" }) - .click(); - + await app.closeNotificationToast(); await page.locator(".mx_Toast_buttons").getByRole("button", { name: "Yes" }).click(); // Allow analytics }); test.describe("AnalyticsLearnMoreDialog", () => { - test("should be rendered properly", { tag: "@screenshot" }, async ({ app, page }) => { + test("should be rendered properly", { tag: "@screenshot" }, async ({ app, page, user }) => { const tab = await app.settings.openUserSettings("Security"); await tab.getByRole("button", { name: "Learn more" }).click(); await expect(page.locator(".mx_AnalyticsLearnMoreDialog_wrapper .mx_Dialog")).toMatchScreenshot( @@ -45,16 +41,57 @@ test.describe("Security user settings tab", () => { }); }); - test("should contain section to set ID server", async ({ app }) => { + test("should be able to set an ID server", async ({ app, context, user, page }) => { const tab = await app.settings.openUserSettings("Security"); - const setIdServer = tab.locator(".mx_SetIdServer"); + await context.route("https://identity.example.org/_matrix/identity/v2", async (route) => { + await route.fulfill({ + status: 200, + json: {}, + }); + }); + await context.route("https://identity.example.org/_matrix/identity/v2/account/register", async (route) => { + await route.fulfill({ + status: 200, + json: { + token: "AToken", + }, + }); + }); + await context.route("https://identity.example.org/_matrix/identity/v2/account", async (route) => { + await route.fulfill({ + status: 200, + json: { + user_id: user.userId, + }, + }); + }); + await context.route("https://identity.example.org/_matrix/identity/v2/terms", async (route) => { + await route.fulfill({ + status: 200, + json: { + policies: {}, + }, + }); + }); + const setIdServer = tab.locator(".mx_IdentityServerPicker"); await setIdServer.scrollIntoViewIfNeeded(); - // Assert that an input area for identity server exists - await expect(setIdServer.getByRole("textbox", { name: "Enter a new identity server" })).toBeVisible(); + + const textElement = setIdServer.getByRole("textbox", { name: "Enter a new identity server" }); + await textElement.click(); + await textElement.fill("https://identity.example.org"); + await setIdServer.getByRole("button", { name: "Change" }).click(); + + await expect(setIdServer.getByText("Checking server")).toBeVisible(); + // Accept terms + await page.getByTestId("dialog-primary-button").click(); + // Check identity has changed. + await expect(setIdServer.getByText("Your identity server has been changed")).toBeVisible(); + // Ensure section title is updated. + await expect(tab.getByText(`Identity server (identity.example.org)`, { exact: true })).toBeVisible(); }); - test("should enable show integrations as enabled", async ({ app, page }) => { + test("should show integrations as enabled", async ({ app, page, user }) => { const tab = await app.settings.openUserSettings("Security"); const setIntegrationManager = tab.locator(".mx_SetIntegrationManager"); @@ -65,7 +102,9 @@ test.describe("Security user settings tab", () => { }), ).toBeVisible(); // Make sure integration manager's toggle switch is enabled - await expect(setIntegrationManager.locator(".mx_ToggleSwitch_enabled")).toBeVisible(); + const toggleswitch = setIntegrationManager.getByLabel("Enable the integration manager"); + await expect(toggleswitch).toBeVisible(); + await expect(toggleswitch).toBeChecked(); await expect(setIntegrationManager.locator(".mx_SetIntegrationManager_heading_manager")).toHaveText( "Manage integrations(scalar.vector.im)", ); diff --git a/playwright/e2e/sliding-sync/sliding-sync.spec.ts b/playwright/e2e/sliding-sync/sliding-sync.spec.ts index 1ab7909a47..bf992a0edd 100644 --- a/playwright/e2e/sliding-sync/sliding-sync.spec.ts +++ b/playwright/e2e/sliding-sync/sliding-sync.spec.ts @@ -6,8 +6,8 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com Please see LICENSE files in the repository root for full details. */ -import { Page, Request } from "@playwright/test"; -import { GenericContainer, StartedTestContainer, Wait } from "testcontainers"; +import { type Page, type Request } from "@playwright/test"; +import { GenericContainer, type StartedTestContainer, Wait } from "testcontainers"; import { test as base, expect } from "../../element-web-test"; import type { ElementAppPage } from "../../pages/ElementAppPage"; @@ -275,7 +275,7 @@ test.describe("Sliding Sync", () => { // now rescind the invite await bot.evaluate( async (client, { roomRescind, clientUserId }) => { - client.kick(roomRescind, clientUserId); + await client.kick(roomRescind, clientUserId); }, { roomRescind, clientUserId }, ); @@ -294,7 +294,7 @@ test.describe("Sliding Sync", () => { is_direct: true, }); await app.client.evaluate(async (client, roomId) => { - client.setRoomTag(roomId, "m.favourite", { order: 0.5 }); + await client.setRoomTag(roomId, "m.favourite", { order: 0.5 }); }, roomId); await expect(page.getByRole("group", { name: "Favourites" }).getByText("Favourite DM")).toBeVisible(); await expect(page.getByRole("group", { name: "People" }).getByText("Favourite DM")).not.toBeAttached(); diff --git a/playwright/e2e/spaces/spaces.spec.ts b/playwright/e2e/spaces/spaces.spec.ts index 48bcc13c53..dce7515ef0 100644 --- a/playwright/e2e/spaces/spaces.spec.ts +++ b/playwright/e2e/spaces/spaces.spec.ts @@ -9,7 +9,8 @@ Please see LICENSE files in the repository root for full details. import type { Locator, Page } from "@playwright/test"; import { test, expect } from "../../element-web-test"; import type { Preset, ICreateRoomOpts } from "matrix-js-sdk/src/matrix"; -import { ElementAppPage } from "../../pages/ElementAppPage"; +import { type ElementAppPage } from "../../pages/ElementAppPage"; +import { isDendrite } from "../../plugins/homeserver/dendrite"; async function openSpaceCreateMenu(page: Page): Promise { await page.getByRole("button", { name: "Create a space" }).click(); @@ -50,6 +51,7 @@ function spaceChildInitialState(roomId: string): ICreateRoomOpts["initial_state" } test.describe("Spaces", () => { + test.skip(isDendrite, "due to a Dendrite bug https://github.com/element-hq/dendrite/issues/3488"); test.use({ displayName: "Sue", botCreateOpts: { displayName: "BotBob" }, @@ -82,7 +84,7 @@ test.describe("Spaces", () => { // Copy matrix.to link await page.getByRole("button", { name: "Share invite link" }).click(); - expect(await app.getClipboardText()).toEqual("https://matrix.to/#/#lets-have-a-riot:localhost"); + expect(await app.getClipboard()).toEqual(`https://matrix.to/#/#lets-have-a-riot:${user.homeServer}`); // Go to space home await page.getByRole("button", { name: "Go to my first room" }).click(); @@ -169,13 +171,13 @@ test.describe("Spaces", () => { room_alias_name: "space", }); - const menu = await openSpaceContextMenu(page, app, "#space:localhost"); + const menu = await openSpaceContextMenu(page, app, `#space:${user.homeServer}`); await menu.getByRole("menuitem", { name: "Invite" }).click(); const shareDialog = page.locator(".mx_SpacePublicShare"); // Copy link first await shareDialog.getByRole("button", { name: "Share invite link" }).click(); - expect(await app.getClipboardText()).toEqual("https://matrix.to/#/#space:localhost"); + expect(await app.getClipboard()).toEqual(`https://matrix.to/#/#space:${user.homeServer}`); // Start Matrix invite flow await shareDialog.getByRole("button", { name: "Invite people" }).click(); @@ -225,7 +227,7 @@ test.describe("Spaces", () => { test( "should render subspaces in the space panel only when expanded", { tag: "@screenshot" }, - async ({ page, app, user, axe, checkA11y }) => { + async ({ page, app, user, axe }) => { axe.disableRules([ // Disable this check as it triggers on nested roving tab index elements which are in practice fine "nested-interactive", @@ -247,7 +249,7 @@ test.describe("Spaces", () => { await expect(spaceTree.getByRole("button", { name: "Root Space" })).toBeVisible(); await expect(spaceTree.getByRole("button", { name: "Child Space" })).not.toBeVisible(); - await checkA11y(); + await expect(axe).toHaveNoViolations(); await expect(page.locator(".mx_SpacePanel")).toMatchScreenshot("space-panel-collapsed.png"); // This finds the expand button with the class name "mx_SpaceButton_toggleCollapse". Note there is another @@ -259,7 +261,7 @@ test.describe("Spaces", () => { await expect(item).toBeVisible(); await expect(item.locator(".mx_SpaceItem", { hasText: "Child Space" })).toBeVisible(); - await checkA11y(); + await expect(axe).toHaveNoViolations(); await expect(page.locator(".mx_SpacePanel")).toMatchScreenshot("space-panel-expanded.png"); }, ); diff --git a/playwright/e2e/spaces/threads-activity-centre/index.ts b/playwright/e2e/spaces/threads-activity-centre/index.ts index d3d3cb352b..a050175c83 100644 --- a/playwright/e2e/spaces/threads-activity-centre/index.ts +++ b/playwright/e2e/spaces/threads-activity-centre/index.ts @@ -6,14 +6,14 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com Please see LICENSE files in the repository root for full details. */ -import { JSHandle, Locator, Page } from "@playwright/test"; +import { type JSHandle, type Locator, type Page } from "@playwright/test"; import type { MatrixEvent, IContent, Room } from "matrix-js-sdk/src/matrix"; import { test as base, expect } from "../../../element-web-test"; -import { Bot } from "../../../pages/bot"; -import { Client } from "../../../pages/client"; -import { ElementAppPage } from "../../../pages/ElementAppPage"; -import { Credentials } from "../../../plugins/homeserver"; +import { type Bot } from "../../../pages/bot"; +import { type Client } from "../../../pages/client"; +import { type ElementAppPage } from "../../../pages/ElementAppPage"; +import { type Credentials } from "../../../plugins/homeserver"; type RoomRef = { name: string; roomId: string }; @@ -38,11 +38,13 @@ export const test = base.extend<{ room1Name: "Room 1", room1: async ({ room1Name: name, app, user, bot }, use) => { const roomId = await app.client.createRoom({ name, invite: [bot.credentials.userId] }); + await bot.awaitRoomMembership(roomId); await use({ name, roomId }); }, room2Name: "Room 2", room2: async ({ room2Name: name, app, user, bot }, use) => { const roomId = await app.client.createRoom({ name, invite: [bot.credentials.userId] }); + await bot.awaitRoomMembership(roomId); await use({ name, roomId }); }, msg: async ({ page, app, util }, use) => { diff --git a/playwright/e2e/spaces/threads-activity-centre/threadsActivityCentre.spec.ts b/playwright/e2e/spaces/threads-activity-centre/threadsActivityCentre.spec.ts index c45222d035..683577dce4 100644 --- a/playwright/e2e/spaces/threads-activity-centre/threadsActivityCentre.spec.ts +++ b/playwright/e2e/spaces/threads-activity-centre/threadsActivityCentre.spec.ts @@ -8,8 +8,14 @@ import { expect, test } from "."; import { CommandOrControl } from "../../utils"; +import { isDendrite } from "../../../plugins/homeserver/dendrite"; test.describe("Threads Activity Centre", { tag: "@no-firefox" }, () => { + test.skip( + isDendrite, + "due to Dendrite lacking full threads support https://github.com/element-hq/dendrite/issues/3283", + ); + test.use({ displayName: "Alice", botCreateOpts: { displayName: "Other User" }, diff --git a/playwright/e2e/spotlight/spotlight.spec.ts b/playwright/e2e/spotlight/spotlight.spec.ts index 5ee80d6ea7..7a5f7d4ea8 100644 --- a/playwright/e2e/spotlight/spotlight.spec.ts +++ b/playwright/e2e/spotlight/spotlight.spec.ts @@ -12,6 +12,7 @@ import { Filter } from "../../pages/Spotlight"; import { Bot } from "../../pages/bot"; import type { Locator, Page } from "@playwright/test"; import type { ElementAppPage } from "../../pages/ElementAppPage"; +import { isDendrite } from "../../plugins/homeserver/dendrite"; function roomHeaderName(page: Page): Locator { return page.locator(".mx_RoomHeader_heading"); @@ -89,6 +90,7 @@ const test = base.extend<{ }); test.describe("Spotlight", () => { + test.skip(isDendrite, "due to a Dendrite bug https://github.com/element-hq/dendrite/issues/3488"); test.use({ displayName: "Jim", }); diff --git a/playwright/e2e/threads/threads.spec.ts b/playwright/e2e/threads/threads.spec.ts index edcc0578d8..89cfe418ba 100644 --- a/playwright/e2e/threads/threads.spec.ts +++ b/playwright/e2e/threads/threads.spec.ts @@ -8,8 +8,10 @@ Please see LICENSE files in the repository root for full details. import { SettingLevel } from "../../../src/settings/SettingLevel"; import { Layout } from "../../../src/settings/enums/Layout"; import { test, expect } from "../../element-web-test"; +import { isDendrite } from "../../plugins/homeserver/dendrite"; test.describe("Threads", () => { + test.skip(isDendrite, "due to a Dendrite bug https://github.com/element-hq/dendrite/issues/3489"); test.use({ displayName: "Tom", botCreateOpts: { @@ -24,8 +26,7 @@ test.describe("Threads", () => { }); }); - // Flaky: https://github.com/vector-im/element-web/issues/26452 - test.skip("should be usable for a conversation", { tag: "@screenshot" }, async ({ page, app, bot }) => { + test("should be usable for a conversation", { tag: "@screenshot" }, async ({ page, app, bot }) => { const roomId = await app.client.createRoom({}); await app.client.inviteUser(roomId, bot.credentials.userId); await bot.joinRoom(roomId); @@ -76,7 +77,7 @@ test.describe("Threads", () => { mask: mask, }); await app.settings.setValue("layout", null, SettingLevel.DEVICE, Layout.Bubble); - await expect(page.locator(".mx_ThreadView .mx_EventTile[data-layout='bubble']")).toBeVisible(); + await expect(page.locator(".mx_ThreadView .mx_EventTile[data-layout='bubble']")).toHaveCount(2); await expect(page.locator(".mx_ThreadView")).toMatchScreenshot("Initial_ThreadView_on_bubble_layout.png", { mask: mask, @@ -136,8 +137,8 @@ test.describe("Threads", () => { await page.getByRole("gridcell", { name: "👋" }).click(); locator = page.locator(".mx_ThreadView"); - // Make sure the CSS style for spacing is applied to mx_ReactionsRow on group/modern layout - await expect(locator.locator(".mx_EventTile[data-layout=group] .mx_ReactionsRow")).toHaveCSS( + // Make sure the CSS style for spacing is applied to mx_EventTile_footer on group/modern layout + await expect(locator.locator(".mx_EventTile[data-layout=group] .mx_EventTile_footer")).toHaveCSS( "margin-inline-start", ThreadViewGroupSpacingStart, ); @@ -164,7 +165,7 @@ test.describe("Threads", () => { locator = page.locator( ".mx_ThreadView .mx_GenericEventListSummary[data-layout=bubble] .mx_EventTile_info.mx_EventTile_last", ); - expect(locator.locator(".mx_EventTile_line .mx_EventTile_content")) + await expect(locator.locator(".mx_EventTile_line .mx_EventTile_content")) // 76px: ThreadViewGroupSpacingStart + 14px + 6px // 14px: avatar width // See: _EventTile.pcss @@ -202,12 +203,14 @@ test.describe("Threads", () => { await locator.click(); // Wait until the response is redacted - await expect( - page.locator(".mx_ThreadView").locator(".mx_EventTile_last .mx_EventTile_receiptSent"), - ).toBeVisible(); + // XXX: one would expect this redaction to be shown in the thread the message was in, but due to redactions + // stripping the thread_id, it is instead shown in the main timeline + await expect(page.locator(".mx_MainSplit_timeline").locator(".mx_EventTile_last")).toContainText( + "Message deleted", + ); // Take snapshots in group layout and bubble layout (IRC layout is not available on ThreadView) - await expect(page.locator(".mx_ThreadView .mx_EventTile[data-layout='group']")).toBeVisible(); + await expect(page.locator(".mx_ThreadView .mx_EventTile[data-layout='group']")).toHaveCount(2); await expect(page.locator(".mx_ThreadView")).toMatchScreenshot( "ThreadView_with_redacted_messages_on_group_layout.png", { @@ -215,7 +218,7 @@ test.describe("Threads", () => { }, ); await app.settings.setValue("layout", null, SettingLevel.DEVICE, Layout.Bubble); - await expect(page.locator(".mx_ThreadView .mx_EventTile[data-layout='bubble']")).toBeVisible(); + await expect(page.locator(".mx_ThreadView .mx_EventTile[data-layout='bubble']")).toHaveCount(2); await expect(page.locator(".mx_ThreadView")).toMatchScreenshot( "ThreadView_with_redacted_messages_on_bubble_layout.png", { @@ -233,8 +236,8 @@ test.describe("Threads", () => { // User closes right panel after clicking back to thread list locator = page.locator(".mx_ThreadPanel"); - locator.getByRole("button", { name: "Threads" }).click(); - locator.getByRole("button", { name: "Close" }).click(); + await locator.getByRole("button", { name: "Threads" }).click(); + await locator.getByRole("button", { name: "Close" }).click(); // Bot responds to thread await bot.sendMessage(roomId, "How are things?", threadId); @@ -243,9 +246,8 @@ test.describe("Threads", () => { await expect(locator.locator(".mx_ThreadSummary_sender").getByText("BotBob")).toBeAttached(); await expect(locator.locator(".mx_ThreadSummary_content").getByText("How are things?")).toBeAttached(); - locator = page.getByRole("button", { name: "Threads" }); - await expect(locator).toHaveAttribute("data-indicator", "default"); // User asserts thread list unread indicator - // await expect(locator).toHaveClass(/mx_LegacyRoomHeader_button--unread/); + locator = page.getByRole("banner").getByRole("button", { name: "Threads" }); + await expect(locator).toHaveAttribute("data-indicator", "success"); // User asserts thread list unread indicator await locator.click(); // User opens thread list // User asserts thread with correct root & latest events & unread dot @@ -273,20 +275,18 @@ test.describe("Threads", () => { await expect(locator.getByText("Great!")).toBeAttached(); await locator.locator(".mx_EventTile_line").hover(); await locator.locator(".mx_EventTile_line").getByRole("button", { name: "Edit" }).click(); - await locator.getByRole("textbox").fill(" How about yourself?{enter}"); + await locator.getByRole("textbox").pressSequentially(" How about yourself?"); // fill would overwrite the original text await locator.getByRole("textbox").press("Enter"); locator = page.locator(".mx_RoomView_body .mx_ThreadSummary"); await expect(locator.locator(".mx_ThreadSummary_sender").getByText("Tom")).toBeAttached(); - await expect( - locator.locator(".mx_ThreadSummary_content").getByText("Great! How about yourself?"), - ).toBeAttached(); + await expect(locator.locator(".mx_ThreadSummary_content")).toHaveText("Great! How about yourself?"); // User closes right panel await page.locator(".mx_ThreadPanel").getByRole("button", { name: "Close" }).click(); // Bot responds to thread and saves the id of their message to @eventId - const { event_id: eventId } = await bot.sendMessage(roomId, threadId, "I'm very good thanks"); + const { event_id: eventId } = await bot.sendMessage(roomId, "I'm very good thanks", threadId); // User asserts locator = page.locator(".mx_RoomView_body .mx_ThreadSummary"); @@ -344,7 +344,7 @@ test.describe("Threads", () => { await expect(page.locator(".mx_ThreadView_timelinePanelWrapper")).toHaveCount(1); - (await app.openMessageComposerOptions(true)).getByRole("menuitem", { name: "Voice Message" }).click(); + await (await app.openMessageComposerOptions(true)).getByRole("menuitem", { name: "Voice Message" }).click(); await page.waitForTimeout(3000); await app.getComposer(true).getByRole("button", { name: "Send voice message" }).click(); await expect(page.locator(".mx_ThreadView .mx_MVoiceMessageBody")).toHaveCount(1); diff --git a/playwright/e2e/timeline/timeline.spec.ts b/playwright/e2e/timeline/timeline.spec.ts index fc8205709c..7b13d1ccb1 100644 --- a/playwright/e2e/timeline/timeline.spec.ts +++ b/playwright/e2e/timeline/timeline.spec.ts @@ -13,8 +13,8 @@ import type { ISendEventResponse, EventType, MsgType } from "matrix-js-sdk/src/m import { test, expect } from "../../element-web-test"; import { SettingLevel } from "../../../src/settings/SettingLevel"; import { Layout } from "../../../src/settings/enums/Layout"; -import { Client } from "../../pages/client"; -import { ElementAppPage } from "../../pages/ElementAppPage"; +import { type Client } from "../../pages/client"; +import { type ElementAppPage } from "../../pages/ElementAppPage"; import { Bot } from "../../pages/bot"; // The avatar size used in the timeline @@ -277,7 +277,7 @@ test.describe("Timeline", () => { test( "should add inline start margin to an event line on IRC layout", { tag: "@screenshot" }, - async ({ page, app, room, axe, checkA11y }) => { + async ({ page, app, room, axe }) => { axe.disableRules("color-contrast"); await page.goto(`/#/room/${room.roomId}`); @@ -318,7 +318,7 @@ test.describe("Timeline", () => { `, }, ); - await checkA11y(); + await expect(axe).toHaveNoViolations(); }, ); }); @@ -590,10 +590,6 @@ test.describe("Timeline", () => { "should set inline start padding to a hidden event line", { tag: "@screenshot" }, async ({ page, app, room }) => { - test.skip( - true, - "Disabled due to screenshot test being flaky - https://github.com/element-hq/element-web/issues/26890", - ); await sendEvent(app.client, room.roomId); await page.goto(`/#/room/${room.roomId}`); await app.settings.setValue("showHiddenEventsInTimeline", null, SettingLevel.DEVICE, true); @@ -607,7 +603,12 @@ test.describe("Timeline", () => { await messageEdit(page); // Click timestamp to highlight hidden event line - await page.locator(".mx_RoomView_body .mx_EventTile_info .mx_MessageTimestamp").click(); + const timestamp = page.locator(".mx_RoomView_body .mx_EventTile_info a", { + has: page.locator(".mx_MessageTimestamp"), + }); + // wait for the remote echo otherwise we get an error modal due to a 404 on the /event/ API + await expect(timestamp).not.toHaveAttribute("href", /~!/); + await timestamp.locator(".mx_MessageTimestamp").click(); // should not add inline start padding to a hidden event line on IRC layout await app.settings.setValue("layout", null, SettingLevel.DEVICE, Layout.IRC); @@ -742,68 +743,64 @@ test.describe("Timeline", () => { ).toBeVisible(); }); - test( - "should render url previews", - { tag: "@screenshot" }, - async ({ page, app, room, axe, checkA11y, context }) => { - axe.disableRules("color-contrast"); + test("should render url previews", { tag: "@screenshot" }, async ({ page, app, room, axe, context }) => { + axe.disableRules("color-contrast"); - // Element Web uses a Service Worker to rewrite unauthenticated media requests to authenticated ones, but - // the page can't see this happening. We intercept the route at the BrowserContext to ensure we get it - // post-worker, but we can't waitForResponse on that, so the page context is still used there. Because - // the page doesn't see the rewrite, it waits for the unauthenticated route. This is only confusing until - // the js-sdk (and thus the app as a whole) switches to using authenticated endpoints by default, hopefully. - await context.route( - "**/_matrix/client/v1/media/thumbnail/matrix.org/2022-08-16_yaiSVSRIsNFfxDnV?*", - async (route) => { - await route.fulfill({ - path: "playwright/sample-files/riot.png", - }); - }, - ); - await page.route( - "**/_matrix/media/v3/preview_url?url=https%3A%2F%2Fcall.element.io%2F&ts=*", - async (route) => { - await route.fulfill({ - json: { - "og:title": "Element Call", - "og:description": null, - "og:image:width": 48, - "og:image:height": 48, - "og:image": "mxc://matrix.org/2022-08-16_yaiSVSRIsNFfxDnV", - "og:image:type": "image/png", - "matrix:image:size": 2121, - }, - }); - }, - ); + // Element Web uses a Service Worker to rewrite unauthenticated media requests to authenticated ones, but + // the page can't see this happening. We intercept the route at the BrowserContext to ensure we get it + // post-worker, but we can't waitForResponse on that, so the page context is still used there. Because + // the page doesn't see the rewrite, it waits for the unauthenticated route. This is only confusing until + // the js-sdk (and thus the app as a whole) switches to using authenticated endpoints by default, hopefully. + await context.route( + "**/_matrix/client/v1/media/thumbnail/matrix.org/2022-08-16_yaiSVSRIsNFfxDnV?*", + async (route) => { + await route.fulfill({ + path: "playwright/sample-files/riot.png", + }); + }, + ); + await page.route( + "**/_matrix/media/v3/preview_url?url=https%3A%2F%2Fcall.element.io%2F&ts=*", + async (route) => { + await route.fulfill({ + json: { + "og:title": "Element Call", + "og:description": null, + "og:image:width": 48, + "og:image:height": 48, + "og:image": "mxc://matrix.org/2022-08-16_yaiSVSRIsNFfxDnV", + "og:image:type": "image/png", + "matrix:image:size": 2121, + }, + }); + }, + ); - const requestPromises: Promise[] = [ - page.waitForResponse("**/_matrix/media/v3/preview_url?url=https%3A%2F%2Fcall.element.io%2F&ts=*"), - // see context.route above for why we listen for the unauthenticated endpoint - page.waitForResponse("**/_matrix/media/v3/thumbnail/matrix.org/2022-08-16_yaiSVSRIsNFfxDnV?*"), - ]; + const requestPromises: Promise[] = [ + page.waitForResponse("**/_matrix/media/v3/preview_url?url=https%3A%2F%2Fcall.element.io%2F&ts=*"), + // see context.route above for why we listen for the unauthenticated endpoint + page.waitForResponse("**/_matrix/media/v3/thumbnail/matrix.org/2022-08-16_yaiSVSRIsNFfxDnV?*"), + ]; - await app.client.sendMessage(room.roomId, "https://call.element.io/"); - await page.goto(`/#/room/${room.roomId}`); + await app.client.sendMessage(room.roomId, "https://call.element.io/"); + await page.goto(`/#/room/${room.roomId}`); - await expect(page.locator(".mx_LinkPreviewWidget").getByText("Element Call")).toBeVisible(); - await Promise.all(requestPromises); + await expect(page.locator(".mx_LinkPreviewWidget").getByText("Element Call")).toBeVisible(); + await Promise.all(requestPromises); - await checkA11y(); + await expect(axe).toHaveNoViolations(); - await app.timeline.scrollToBottom(); - await expect(page.locator(".mx_EventTile_last")).toMatchScreenshot("url-preview.png", { - // Exclude timestamp and read marker from snapshot - mask: [page.locator(".mx_MessageTimestamp")], - css: ` + await app.timeline.scrollToBottom(); + await expect(page.locator(".mx_EventTile_last")).toMatchScreenshot("url-preview.png", { + // Exclude timestamp and read marker from snapshot + mask: [page.locator(".mx_MessageTimestamp")], + css: ` .mx_TopUnreadMessagesBar, .mx_MessagePanel_myReadMarker { display: none !important; } `, - }); - }, - ); + }); + }); test.describe("on search results panel", () => { test( @@ -874,6 +871,40 @@ test.describe("Timeline", () => { ); }); }); + + test("should render a code block", { tag: "@screenshot" }, async ({ page, app, room }) => { + await page.goto(`/#/room/${room.roomId}`); + await app.settings.setValue("layout", null, SettingLevel.DEVICE, Layout.IRC); + + // Wait until configuration is finished + await expect( + page + .locator(".mx_GenericEventListSummary_summary") + .getByText(`${OLD_NAME} created and configured the room.`), + ).toBeVisible(); + + // Send a code block + const composer = app.getComposerField(); + await composer.fill("```\nconsole.log('Hello, world!');\n```"); + await composer.press("Enter"); + + const tile = page.locator(".mx_EventTile"); + await expect(tile).toBeVisible(); + await expect(tile).toMatchScreenshot("code-block.png", { mask: [page.locator(".mx_MessageTimestamp")] }); + + // Edit a code block and assert the edited code block has been correctly rendered + await tile.hover(); + await page.getByRole("toolbar", { name: "Message Actions" }).getByRole("button", { name: "Edit" }).click(); + await page + .getByRole("textbox", { name: "Edit message" }) + .fill("```\nconsole.log('Edited: Hello, world!');\n```"); + await page.getByRole("textbox", { name: "Edit message" }).press("Enter"); + + const newTile = page.locator(".mx_EventTile"); + await expect(newTile).toMatchScreenshot("edited-code-block.png", { + mask: [page.locator(".mx_MessageTimestamp")], + }); + }); }); test.describe("message sending", { tag: ["@no-firefox", "@no-webkit"] }, () => { @@ -1194,6 +1225,7 @@ test.describe("Timeline", () => { }); await sendImage(app.client, room.roomId, NEW_AVATAR); + await app.timeline.scrollToBottom(); await expect(page.locator(".mx_MImageBody").first()).toBeVisible(); // Exclude timestamp and read marker from snapshot diff --git a/playwright/e2e/user-onboarding/user-onboarding-new.spec.ts b/playwright/e2e/user-onboarding/user-onboarding-new.spec.ts deleted file mode 100644 index 3c7ef1f171..0000000000 --- a/playwright/e2e/user-onboarding/user-onboarding-new.spec.ts +++ /dev/null @@ -1,79 +0,0 @@ -/* -Copyright 2024 New Vector Ltd. -Copyright 2022 The Matrix.org Foundation C.I.C. - -SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial -Please see LICENSE files in the repository root for full details. -*/ - -import { test, expect } from "../../element-web-test"; - -test.describe("User Onboarding (new user)", () => { - test.use({ - displayName: "Jane Doe", - }); - - // This first beforeEach happens before the `user` fixture runs - test.beforeEach(async ({ page }) => { - await page.addInitScript(() => { - window.localStorage.setItem("mx_registration_time", "1656633601"); - }); - }); - - test.beforeEach(async ({ page, user }) => { - await expect(page.locator(".mx_UserOnboardingPage")).toBeVisible(); - await expect(page.getByRole("button", { name: "Welcome" })).toBeVisible(); - await expect(page.locator(".mx_UserOnboardingList")).toBeVisible(); - }); - - test("page is shown and preference exists", { tag: "@screenshot" }, async ({ page, app }) => { - await expect(page.locator(".mx_UserOnboardingPage")).toMatchScreenshot( - "User-Onboarding-new-user-page-is-shown-and-preference-exists-1.png", - ); - await app.settings.openUserSettings("Preferences"); - await expect(page.getByText("Show shortcut to welcome checklist above the room list")).toBeVisible(); - }); - - test("app download dialog", { tag: "@screenshot" }, async ({ page }) => { - await page.getByRole("button", { name: "Download apps" }).click(); - await expect( - page.getByRole("dialog").getByRole("heading", { level: 1, name: "Download Element" }), - ).toBeVisible(); - await expect(page.locator(".mx_Dialog")).toMatchScreenshot( - "User-Onboarding-new-user-app-download-dialog-1.png", - { - // Set a constant bg behind the modal to ensure screenshot stability - css: ` - .mx_AppDownloadDialog_wrapper { - background: black; - } - `, - }, - ); - }); - - test("using find friends action should increase progress", async ({ page, homeserver }) => { - const bot = await homeserver.registerUser("botbob", "password", "BotBob"); - - const oldProgress = parseFloat(await page.getByRole("progressbar").getAttribute("value")); - await page.getByRole("button", { name: "Find friends" }).click(); - await page.locator(".mx_InviteDialog_editor").getByRole("textbox").fill(bot.userId); - await page.getByRole("button", { name: "Go" }).click(); - await expect(page.locator(".mx_InviteDialog_buttonAndSpinner")).not.toBeVisible(); - - const message = "Hi!"; - const composer = page.getByRole("textbox", { name: "Send a message…" }); - await composer.fill(`${message}`); - await composer.press("Enter"); - await expect(page.locator(".mx_MTextBody.mx_EventTile_content", { hasText: message })).toBeVisible(); - - await page.goto("/#/home"); - await expect(page.locator(".mx_UserOnboardingPage")).toBeVisible(); - await expect(page.getByRole("button", { name: "Welcome" })).toBeVisible(); - await expect(page.locator(".mx_UserOnboardingList")).toBeVisible(); - - await page.waitForTimeout(500); // await progress bar animation - const progress = parseFloat(await page.getByRole("progressbar").getAttribute("value")); - expect(progress).toBeGreaterThan(oldProgress); - }); -}); diff --git a/playwright/e2e/user-onboarding/user-onboarding-old.spec.ts b/playwright/e2e/user-onboarding/user-onboarding-old.spec.ts deleted file mode 100644 index 8931672b52..0000000000 --- a/playwright/e2e/user-onboarding/user-onboarding-old.spec.ts +++ /dev/null @@ -1,28 +0,0 @@ -/* -Copyright 2024 New Vector Ltd. -Copyright 2022 The Matrix.org Foundation C.I.C. - -SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial -Please see LICENSE files in the repository root for full details. -*/ - -import { test, expect } from "../../element-web-test"; - -test.describe("User Onboarding (old user)", () => { - test.use({ - displayName: "Jane Doe", - }); - - test.beforeEach(async ({ page }) => { - await page.addInitScript(() => { - window.localStorage.setItem("mx_registration_time", "2"); - }); - }); - - test("page and preference are hidden", async ({ page, user, app }) => { - await expect(page.locator(".mx_UserOnboardingPage")).not.toBeVisible(); - await expect(page.locator(".mx_UserOnboardingButton")).not.toBeVisible(); - await app.settings.openUserSettings("Preferences"); - await expect(page.getByText("Show shortcut to welcome checklist above the room list")).not.toBeVisible(); - }); -}); diff --git a/playwright/e2e/user-view/user-view.spec.ts b/playwright/e2e/user-view/user-view.spec.ts index f3745e7859..de97133e6a 100644 --- a/playwright/e2e/user-view/user-view.spec.ts +++ b/playwright/e2e/user-view/user-view.spec.ts @@ -19,7 +19,6 @@ test.describe("UserView", () => { const rightPanel = page.locator("#mx_RightPanel"); await expect(rightPanel.getByRole("heading", { name: bot.credentials.displayName, exact: true })).toBeVisible(); - await expect(rightPanel.getByText("1 session")).toBeVisible(); await expect(rightPanel).toMatchScreenshot("user-info.png", { mask: [page.locator(".mx_UserInfo_profile_mxid")], css: ` diff --git a/playwright/e2e/utils.ts b/playwright/e2e/utils.ts index 49e7577bf6..49561ed1b4 100644 --- a/playwright/e2e/utils.ts +++ b/playwright/e2e/utils.ts @@ -12,7 +12,7 @@ import { uniqueId } from "lodash"; import { expect, type Page } from "@playwright/test"; import type { ClientEvent, MatrixEvent, Room } from "matrix-js-sdk/src/matrix"; -import { Client } from "../pages/client"; +import { type Client } from "../pages/client"; /** * Resolves when room state matches predicate. diff --git a/playwright/e2e/voip/pstn.spec.ts b/playwright/e2e/voip/pstn.spec.ts new file mode 100644 index 0000000000..9a35d9b9c3 --- /dev/null +++ b/playwright/e2e/voip/pstn.spec.ts @@ -0,0 +1,31 @@ +/* +Copyright 2025 New Vector Ltd. + +SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial +Please see LICENSE files in the repository root for full details. +*/ + +import { test, expect } from "../../element-web-test"; + +test.describe("PSTN", () => { + test.beforeEach(async ({ page }) => { + // Mock the third party protocols endpoint to look like the HS has PSTN support + await page.route("**/_matrix/client/v3/thirdparty/protocols", async (route) => { + await route.fulfill({ + status: 200, + json: { + "im.vector.protocol.pstn": {}, + }, + }); + }); + }); + + test("should render dialpad as expected", { tag: "@screenshot" }, async ({ page, user, toasts }) => { + await toasts.rejectToast("Notifications"); + await toasts.assertNoToasts(); + + await expect(page.locator(".mx_LeftPanel_filterContainer")).toMatchScreenshot("dialpad-trigger.png"); + await page.getByLabel("Open dial pad").click(); + await expect(page.locator(".mx_Dialog")).toMatchScreenshot("dialpad.png"); + }); +}); diff --git a/playwright/e2e/widgets/stickers.spec.ts b/playwright/e2e/widgets/stickers.spec.ts index 54de1b69e2..e7b86e411d 100644 --- a/playwright/e2e/widgets/stickers.spec.ts +++ b/playwright/e2e/widgets/stickers.spec.ts @@ -10,8 +10,8 @@ import * as fs from "node:fs"; import type { Page } from "@playwright/test"; import { test, expect } from "../../element-web-test"; -import { ElementAppPage } from "../../pages/ElementAppPage"; -import { Credentials } from "../../plugins/homeserver"; +import { type ElementAppPage } from "../../pages/ElementAppPage"; +import { type Credentials } from "../../plugins/homeserver"; import type { UserWidget } from "../../../src/utils/WidgetUtils-types.ts"; const STICKER_PICKER_WIDGET_ID = "fake-sticker-picker"; @@ -88,7 +88,7 @@ async function sendStickerFromPicker(page: Page) { await expect(page.locator(".mx_AppTileFullWidth#stickers")).not.toBeVisible(); } -async function expectTimelineSticker(page: Page, roomId: string, contentUri: string) { +async function expectTimelineSticker(page: Page, serverName: string, roomId: string, contentUri: string) { const contentId = contentUri.split("/").slice(-1)[0]; // Make sure it's in the right room await expect(page.locator(".mx_EventTile_sticker > a")).toHaveAttribute("href", new RegExp(`/${roomId}/`)); @@ -98,7 +98,7 @@ async function expectTimelineSticker(page: Page, roomId: string, contentUri: str // download URL. await expect(page.locator(`img[alt="${STICKER_NAME}"]`)).toHaveAttribute( "src", - new RegExp(`/localhost/${contentId}`), + new RegExp(`/${serverName}/${contentId}`), ); } @@ -150,13 +150,13 @@ test.describe("Stickers", { tag: ["@no-firefox", "@no-webkit"] }, () => { const { content_uri: contentUri } = await app.client.uploadContent(STICKER_IMAGE, { type: "image/png" }); const widgetHtml = getWidgetHtml(contentUri, "image/png"); stickerPickerUrl = webserver.start(widgetHtml); - setWidgetAccountData(app, user, stickerPickerUrl); + await setWidgetAccountData(app, user, stickerPickerUrl); await app.viewRoomByName(ROOM_NAME_1); await expect(page).toHaveURL(`/#/room/${room.roomId}`); await openStickerPicker(app); await sendStickerFromPicker(page); - await expectTimelineSticker(page, room.roomId, contentUri); + await expectTimelineSticker(page, user.homeServer, room.roomId, contentUri); // Ensure that when we switch to a different room that the sticker // goes to the right place @@ -164,7 +164,7 @@ test.describe("Stickers", { tag: ["@no-firefox", "@no-webkit"] }, () => { await expect(page).toHaveURL(`/#/room/${roomId2}`); await openStickerPicker(app); await sendStickerFromPicker(page); - await expectTimelineSticker(page, roomId2, contentUri); + await expectTimelineSticker(page, user.homeServer, roomId2, contentUri); }); test("should handle a sticker picker widget missing creatorUserId", async ({ @@ -177,13 +177,13 @@ test.describe("Stickers", { tag: ["@no-firefox", "@no-webkit"] }, () => { const { content_uri: contentUri } = await app.client.uploadContent(STICKER_IMAGE, { type: "image/png" }); const widgetHtml = getWidgetHtml(contentUri, "image/png"); stickerPickerUrl = webserver.start(widgetHtml); - setWidgetAccountData(app, user, stickerPickerUrl, false); + await setWidgetAccountData(app, user, stickerPickerUrl, false); await app.viewRoomByName(ROOM_NAME_1); await expect(page).toHaveURL(`/#/room/${room.roomId}`); await openStickerPicker(app); await sendStickerFromPicker(page); - await expectTimelineSticker(page, room.roomId, contentUri); + await expectTimelineSticker(page, user.homeServer, room.roomId, contentUri); }); test("should render invalid mimetype as a file", async ({ webserver, page, app, user, room }) => { @@ -192,7 +192,7 @@ test.describe("Stickers", { tag: ["@no-firefox", "@no-webkit"] }, () => { }); const widgetHtml = getWidgetHtml(contentUri, "application/octet-stream"); stickerPickerUrl = webserver.start(widgetHtml); - setWidgetAccountData(app, user, stickerPickerUrl); + await setWidgetAccountData(app, user, stickerPickerUrl); await app.viewRoomByName(ROOM_NAME_1); await expect(page).toHaveURL(`/#/room/${room.roomId}`); diff --git a/playwright/e2e/widgets/widget-pip-close.spec.ts b/playwright/e2e/widgets/widget-pip-close.spec.ts index ec3184ed6c..e8163b5ee2 100644 --- a/playwright/e2e/widgets/widget-pip-close.spec.ts +++ b/playwright/e2e/widgets/widget-pip-close.spec.ts @@ -11,7 +11,7 @@ Please see LICENSE files in the repository root for full details. import type { IWidget } from "matrix-widget-api/src/interfaces/IWidget"; import type { MatrixEvent, RoomStateEvent } from "matrix-js-sdk/src/matrix"; import { test, expect } from "../../element-web-test"; -import { Client } from "../../pages/client"; +import { type Client } from "../../pages/client"; const DEMO_WIDGET_ID = "demo-widget-id"; const DEMO_WIDGET_NAME = "Demo Widget"; diff --git a/playwright/element-web-test.ts b/playwright/element-web-test.ts index 12b86c1f69..8520533461 100644 --- a/playwright/element-web-test.ts +++ b/playwright/element-web-test.ts @@ -6,84 +6,43 @@ 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 { expect as baseExpect, Locator, Page, ExpectMatcherState, ElementHandle } from "@playwright/test"; -import { sanitizeForFilePath } from "playwright-core/lib/utils"; -import AxeBuilder from "@axe-core/playwright"; -import _ from "lodash"; -import { extname } from "node:path"; +import { + type ExpectMatcherState, + type MatcherReturnType, + type Page, + type Locator, + type PlaywrightTestArgs, + type Fixtures as _Fixtures, +} from "@playwright/test"; +import { + type TestFixtures as BaseTestFixtures, + expect as baseExpect, + type ToMatchScreenshotOptions, +} from "@element-hq/element-web-playwright-common"; import type { IConfigOptions } from "../src/IConfigOptions"; -import { Credentials } from "./plugins/homeserver"; +import { type Credentials } from "./plugins/homeserver"; import { ElementAppPage } from "./pages/ElementAppPage"; import { Crypto } from "./pages/crypto"; import { Toasts } from "./pages/toasts"; -import { Bot, CreateBotOpts } from "./pages/bot"; +import { Bot, type CreateBotOpts } from "./pages/bot"; import { Webserver } from "./plugins/webserver"; -import { test as base } from "./services.ts"; +import { type WorkerOptions, type Services, test as base } from "./services"; // Enable experimental service worker support // See https://playwright.dev/docs/service-workers-experimental#how-to-enable process.env["PW_EXPERIMENTAL_SERVICE_WORKER_NETWORK_EVENTS"] = "1"; -// This is deliberately quite a minimal config.json, so that we can test that the default settings actually work. -const CONFIG_JSON: Partial = { - // The default language is set here for test consistency - setting_defaults: { - language: "en-GB", - }, - - // the location tests want a map style url. - map_style_url: "https://api.maptiler.com/maps/streets/style.json?key=fU3vlMsMn4Jb6dnEIFsx", - - features: { - // We don't want to go through the feature announcement during the e2e test - feature_release_announcement: false, - }, -}; +declare module "@element-hq/element-web-playwright-common" { + // Improve the type for the config fixture based on the real type + export interface Config extends Omit {} +} export interface CredentialsWithDisplayName extends Credentials { displayName: string; } -export interface Fixtures { - axe: AxeBuilder; - checkA11y: () => Promise; - - /** - * The contents of the config.json to send when the client requests it. - */ - config: typeof CONFIG_JSON; - - /** - * The displayname to use for the user registered in {@link #credentials}. - * - * To set it, call `test.use({ displayName: "myDisplayName" })` in the test file or `describe` block. - * See {@link https://playwright.dev/docs/api/class-test#test-use}. - */ - displayName?: string; - - /** - * A test fixture which registers a test user on the {@link #homeserver} and supplies the details - * of the registered user. - */ - credentials: CredentialsWithDisplayName; - - /** - * The same as {@link https://playwright.dev/docs/api/class-fixtures#fixtures-page|`page`}, - * but adds an initScript which will populate localStorage with the user's details from - * {@link #credentials} and {@link #homeserver}. - * - * Similar to {@link #user}, but doesn't load the app. - */ - pageWithCredentials: Page; - - /** - * A (rather poorly-named) test fixture which registers a user per {@link #credentials}, stores - * the credentials into localStorage per {@link #homeserver}, and then loads the front page of the - * app. - */ - user: CredentialsWithDisplayName; - +export interface TestFixtures extends BaseTestFixtures { /** * The same as {@link https://playwright.dev/docs/api/class-fixtures#fixtures-page|`page`}, * but wraps the returned `Page` in a class of utilities for interacting with the Element-Web UI, @@ -97,12 +56,12 @@ export interface Fixtures { uut?: Locator; // Unit Under Test, useful place to refer a prepared locator botCreateOpts: CreateBotOpts; bot: Bot; - labsFlags: string[]; webserver: Webserver; - disablePresence: boolean; } -export const test = base.extend({ +type CombinedTestFixtures = PlaywrightTestArgs & TestFixtures; +export type Fixtures = _Fixtures; +export const test = base.extend({ context: async ({ context }, use, testInfo) => { // We skip tests instead of using grep-invert to still surface the counts in the html report test.skip( @@ -111,102 +70,12 @@ export const test = base.extend({ ); await use(context); }, - disablePresence: false, - config: {}, // We merge this atop the default CONFIG_JSON in the page fixture to make extending it easier - page: async ({ homeserver, context, page, config, labsFlags, disablePresence }, use) => { - await context.route(`http://localhost:8080/config.json*`, async (route) => { - const json = { - ...CONFIG_JSON, - ...config, - default_server_config: { - "m.homeserver": { - base_url: homeserver.baseUrl, - }, - ...config.default_server_config, - }, - }; - json["features"] = { - ...json["features"], - // Enable the lab features - ...labsFlags.reduce((obj, flag) => { - obj[flag] = true; - return obj; - }, {}), - }; - if (disablePresence) { - json["enable_presence_by_hs_url"] = { - [homeserver.baseUrl]: false, - }; - } - await route.fulfill({ json }); - }); - await use(page); + + axe: async ({ axe }, use) => { + // Exclude floating UI for now + await use(axe.exclude("[data-floating-ui-portal]")); }, - displayName: undefined, - credentials: async ({ context, homeserver, displayName: testDisplayName }, use, testInfo) => { - const names = ["Alice", "Bob", "Charlie", "Daniel", "Eve", "Frank", "Grace", "Hannah", "Isaac", "Judy"]; - const password = _.uniqueId("password_"); - const displayName = testDisplayName ?? _.sample(names)!; - - const credentials = await homeserver.registerUser(`user_${testInfo.testId}`, password, displayName); - console.log(`Registered test user @user:localhost with displayname ${displayName}`); - - await use({ - ...credentials, - displayName, - }); - }, - labsFlags: [], - - pageWithCredentials: async ({ page, homeserver, credentials }, use) => { - await page.addInitScript( - ({ baseUrl, credentials }) => { - // Seed the localStorage with the required credentials - window.localStorage.setItem("mx_hs_url", baseUrl); - window.localStorage.setItem("mx_user_id", credentials.userId); - window.localStorage.setItem("mx_access_token", credentials.accessToken); - window.localStorage.setItem("mx_device_id", credentials.deviceId); - window.localStorage.setItem("mx_is_guest", "false"); - window.localStorage.setItem("mx_has_pickle_key", "false"); - window.localStorage.setItem("mx_has_access_token", "true"); - - window.localStorage.setItem( - "mx_local_settings", - JSON.stringify({ - // Retain any other settings which may have already been set - ...JSON.parse(window.localStorage.getItem("mx_local_settings") || "{}"), - // Ensure the language is set to a consistent value - language: "en", - }), - ); - }, - { baseUrl: homeserver.baseUrl, credentials }, - ); - await use(page); - }, - - user: async ({ pageWithCredentials: page, credentials }, use) => { - await page.goto("/"); - await page.waitForSelector(".mx_MatrixChat", { timeout: 30000 }); - await use(credentials); - }, - - axe: async ({ page }, use) => { - await use(new AxeBuilder({ page }).exclude("[data-floating-ui-portal]")); - }, - checkA11y: async ({ axe }, use, testInfo) => - use(async () => { - const results = await axe.analyze(); - - await testInfo.attach("accessibility-scan-results", { - body: JSON.stringify(results, null, 2), - contentType: "application/json", - }); - - expect(results.violations).toEqual([]); - }), - app: async ({ page }, use) => { const app = new ElementAppPage(page); await use(app); @@ -234,35 +103,23 @@ export const test = base.extend({ }, }); -// Based on https://github.com/microsoft/playwright/blob/2b77ed4d7aafa85a600caa0b0d101b72c8437eeb/packages/playwright/src/util.ts#L206C8-L210C2 -function sanitizeFilePathBeforeExtension(filePath: string): string { - const ext = extname(filePath); - const base = filePath.substring(0, filePath.length - ext.length); - return sanitizeForFilePath(base) + ext; +interface ExtendedToMatchScreenshotOptions extends ToMatchScreenshotOptions { + includeDialogBackground?: boolean; + showTooltips?: boolean; + timeout?: number; } -export const expect = baseExpect.extend({ - async toMatchScreenshot( +type Expectations = { + toMatchScreenshot: ( this: ExpectMatcherState, receiver: Page | Locator, name: `${string}.png`, - options?: { - mask?: Array; - includeDialogBackground?: boolean; - showTooltips?: boolean; - timeout?: number; - css?: string; - }, - ) { - const testInfo = test.info(); - if (!testInfo) throw new Error(`toMatchScreenshot() must be called during the test`); - - if (!testInfo.tags.includes("@screenshot")) { - throw new Error("toMatchScreenshot() must be used in a test tagged with @screenshot"); - } - - const page = "page" in receiver ? receiver.page() : receiver; + options?: ExtendedToMatchScreenshotOptions, + ) => Promise; +}; +export const expect = baseExpect.extend({ + async toMatchScreenshot(receiver, name, options) { let css = ` .mx_MessagePanel_myReadMarker { display: none !important; @@ -312,21 +169,9 @@ export const expect = baseExpect.extend({ css += options.css; } - // We add a custom style tag before taking screenshots - const style = (await page.addStyleTag({ - content: css, - })) as ElementHandle; - - const screenshotName = sanitizeFilePathBeforeExtension(name); - await baseExpect(receiver).toHaveScreenshot(screenshotName, options); - - await style.evaluate((tag) => tag.remove()); - - testInfo.annotations.push({ - // `_` prefix hides it from the HTML reporter - type: "_screenshot", - // include a path relative to `playwright/snapshots/` - description: testInfo.snapshotPath(screenshotName).split("/playwright/snapshots/", 2)[1], + await baseExpect(receiver).toMatchScreenshot(name, { + ...options, + css, }); return { pass: true, message: () => "", name: "toMatchScreenshot" }; diff --git a/playwright/flaky-reporter.ts b/playwright/flaky-reporter.ts index ad92aca12e..f816d7651e 100644 --- a/playwright/flaky-reporter.ts +++ b/playwright/flaky-reporter.ts @@ -24,13 +24,40 @@ type PaginationLinks = { first?: string; }; +// We see quite a few test flakes which are caused by the app exploding +// so we have some magic strings we check the logs for to better track the flake with its cause +const SPECIAL_CASES = { + "ChunkLoadError": "ChunkLoadError", + "Unreachable code should not be executed": "Rust crypto panic", + "Out of bounds memory access": "Rust crypto memory error", +}; + class FlakyReporter implements Reporter { - private flakes = new Set(); + private flakes = new Map(); public onTestEnd(test: TestCase): void { - const title = `${test.location.file.split("playwright/e2e/")[1]}: ${test.title}`; + // Ignores flakes on Dendrite and Pinecone as they have their own flakes we do not track + if (["Dendrite", "Pinecone"].includes(test.parent.project()?.name)) return; + let failures = [`${test.location.file.split("playwright/e2e/")[1]}: ${test.title}`]; if (test.outcome() === "flaky") { - this.flakes.add(title); + const timedOutRuns = test.results.filter((result) => result.status === "timedOut"); + const pageLogs = timedOutRuns.flatMap((result) => + result.attachments.filter((attachment) => attachment.name.startsWith("page-")), + ); + // If a test failed due to a systemic fault then the test is not flaky, the app is, record it as such. + const specialCases = Object.keys(SPECIAL_CASES).filter((log) => + pageLogs.some((attachment) => attachment.name.startsWith("page-") && attachment.body.includes(log)), + ); + if (specialCases.length > 0) { + failures = specialCases.map((specialCase) => SPECIAL_CASES[specialCase]); + } + + for (const title of failures) { + if (!this.flakes.has(title)) { + this.flakes.set(title, []); + } + this.flakes.get(title).push(test); + } } } @@ -97,12 +124,14 @@ class FlakyReporter implements Reporter { if (!GITHUB_TOKEN) return; const issues = await this.getAllIssues(); - for (const flake of this.flakes) { + for (const [flake, results] of this.flakes) { const title = ISSUE_TITLE_PREFIX + "`" + flake + "`"; const existingIssue = issues.find((issue) => issue.title === title); const headers = { Authorization: `Bearer ${GITHUB_TOKEN}` }; const body = `${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}`; + const labels = [LABEL, ...results.map((test) => `${LABEL}-${test.parent.project()?.name}`)]; + if (existingIssue) { console.log(`Found issue ${existingIssue.number} for ${flake}, adding comment...`); // Ensure that the test is open @@ -111,6 +140,11 @@ class FlakyReporter implements Reporter { headers, body: JSON.stringify({ state: "open" }), }); + await fetch(`${existingIssue.url}/labels`, { + method: "POST", + headers, + body: JSON.stringify({ labels }), + }); await fetch(`${existingIssue.url}/comments`, { method: "POST", headers, @@ -124,7 +158,7 @@ class FlakyReporter implements Reporter { body: JSON.stringify({ title, body, - labels: [LABEL], + labels: [...labels], }), }); } diff --git a/playwright/pages/ElementAppPage.ts b/playwright/pages/ElementAppPage.ts index 98d0bf30fb..15b475a5d1 100644 --- a/playwright/pages/ElementAppPage.ts +++ b/playwright/pages/ElementAppPage.ts @@ -158,10 +158,6 @@ export class ElementAppPage { return button.click(); } - public async getClipboardText(): Promise { - return this.page.evaluate("navigator.clipboard.readText()"); - } - public async openSpotlight(): Promise { const spotlight = new Spotlight(this.page); await spotlight.open(); @@ -206,4 +202,15 @@ export class ElementAppPage { } return this.page.locator(`id=${labelledById ?? describedById}`); } + + /** + * Close the notification toast + */ + public closeNotificationToast(): Promise { + // Dismiss "Notification" toast + return this.page + .locator(".mx_Toast_toast", { hasText: "Notifications" }) + .getByRole("button", { name: "Dismiss" }) + .click(); + } } diff --git a/playwright/pages/bot.ts b/playwright/pages/bot.ts index 1d414c7bf6..05a8948a65 100644 --- a/playwright/pages/bot.ts +++ b/playwright/pages/bot.ts @@ -6,7 +6,7 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com Please see LICENSE files in the repository root for full details. */ -import { JSHandle, Page } from "@playwright/test"; +import { type JSHandle, type Page } from "@playwright/test"; import { uniqueId } from "lodash"; import { type MatrixClient } from "matrix-js-sdk/src/matrix"; @@ -41,6 +41,10 @@ export interface CreateBotOpts { * Whether to bootstrap the secret storage */ bootstrapSecretStorage?: boolean; + /** + * Whether to use a passphrase when creating the recovery key + */ + usePassphrase?: boolean; } const defaultCreateBotOptions = { @@ -48,6 +52,7 @@ const defaultCreateBotOptions = { autoAcceptInvites: true, startClient: true, bootstrapCrossSigning: true, + usePassphrase: false, } satisfies CreateBotOpts; type ExtendedMatrixClient = MatrixClient & { __playwright_recovery_key: GeneratedSecretStorageKey }; @@ -121,7 +126,7 @@ export class Bot extends Client { return logger as unknown as Logger; } - const logger = getLogger(`cypress bot ${credentials.userId}`); + const logger = getLogger(`bot ${credentials.userId}`); const keys = {}; @@ -171,7 +176,7 @@ export class Bot extends Client { if (opts.autoAcceptInvites) { cli.on(window.matrixcs.RoomMemberEvent.Membership, (event, member) => { if (member.membership === "invite" && member.userId === cli.getUserId()) { - cli.joinRoom(member.roomId); + void cli.joinRoom(member.roomId); } }); } @@ -192,7 +197,6 @@ export class Bot extends Client { await clientHandle.evaluate(async (cli) => { await cli.initRustCrypto({ useIndexedDB: false }); - cli.setGlobalErrorOnUnknownDevices(false); await cli.startClient(); }); @@ -207,8 +211,8 @@ export class Bot extends Client { } if (this.opts.bootstrapSecretStorage) { - await clientHandle.evaluate(async (cli) => { - const passphrase = "new passphrase"; + await clientHandle.evaluate(async (cli, usePassphrase) => { + const passphrase = usePassphrase ? "new passphrase" : undefined; const recoveryKey = await cli.getCrypto().createRecoveryKeyFromPassphrase(passphrase); Object.assign(cli, { __playwright_recovery_key: recoveryKey }); @@ -217,7 +221,7 @@ export class Bot extends Client { setupNewKeyBackup: true, createSecretStorageKey: () => Promise.resolve(recoveryKey), }); - }); + }, this.opts.usePassphrase); } return clientHandle; diff --git a/playwright/pages/client.ts b/playwright/pages/client.ts index 362915ce71..8296e9111e 100644 --- a/playwright/pages/client.ts +++ b/playwright/pages/client.ts @@ -6,8 +6,8 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com Please see LICENSE files in the repository root for full details. */ -import { JSHandle, Page } from "@playwright/test"; -import { PageFunctionOn } from "playwright-core/types/structs"; +import { type JSHandle, type Page } from "@playwright/test"; +import { type PageFunctionOn } from "playwright-core/types/structs"; import { Network } from "./network"; import type { @@ -15,7 +15,6 @@ import type { ICreateRoomOpts, ISendEventResponse, MatrixClient, - Room, MatrixEvent, ReceiptType, IRoomDirectoryOptions, @@ -26,9 +25,10 @@ import type { StateEvents, TimelineEvents, AccountDataEvents, + EmptyObject, } from "matrix-js-sdk/src/matrix"; import type { RoomMessageEventContent } from "matrix-js-sdk/src/types"; -import { Credentials } from "../plugins/homeserver"; +import { type Credentials } from "../plugins/homeserver"; export class Client { public network: Network; @@ -178,21 +178,12 @@ export class Client { */ public async createRoom(options: ICreateRoomOpts): Promise { const client = await this.prepareClient(); - return await client.evaluate(async (cli, options) => { + const roomId = await client.evaluate(async (cli, options) => { const { room_id: roomId } = await cli.createRoom(options); - if (!cli.getRoom(roomId)) { - await new Promise((resolve) => { - const onRoom = (room: Room) => { - if (room.roomId === roomId) { - cli.off(window.matrixcs.ClientEvent.Room, onRoom); - resolve(); - } - }; - cli.on(window.matrixcs.ClientEvent.Room, onRoom); - }); - } return roomId; }, options); + await this.awaitRoomMembership(roomId); + return roomId; } /** @@ -373,7 +364,7 @@ export class Client { event: JSHandle, receiptType?: ReceiptType, unthreaded?: boolean, - ): Promise<{}> { + ): Promise { const client = await this.prepareClient(); return client.evaluate( (client, { event, receiptType, unthreaded }) => { @@ -396,7 +387,7 @@ export class Client { * @return {Promise} Resolves: {} an empty object. * @return {module:http-api.MatrixError} Rejects: with an error response. */ - public async setDisplayName(name: string): Promise<{}> { + public async setDisplayName(name: string): Promise { const client = await this.prepareClient(); return client.evaluate(async (cli: MatrixClient, name) => cli.setDisplayName(name), name); } @@ -407,7 +398,7 @@ export class Client { * @return {Promise} Resolves: {} an empty object. * @return {module:http-api.MatrixError} Rejects: with an error response. */ - public async setAvatarUrl(url: string): Promise<{}> { + public async setAvatarUrl(url: string): Promise { const client = await this.prepareClient(); return client.evaluate(async (cli: MatrixClient, url) => cli.setAvatarUrl(url), url); } diff --git a/playwright/pages/crypto.ts b/playwright/pages/crypto.ts index c31e7fbedb..c882040987 100644 --- a/playwright/pages/crypto.ts +++ b/playwright/pages/crypto.ts @@ -6,9 +6,9 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com Please see LICENSE files in the repository root for full details. */ -import { APIRequestContext, Page, expect } from "@playwright/test"; +import { type APIRequestContext, type Page, expect } from "@playwright/test"; -import { HomeserverInstance } from "../plugins/homeserver"; +import { type HomeserverInstance } from "../plugins/homeserver"; export class Crypto { public constructor( diff --git a/playwright/pages/settings.ts b/playwright/pages/settings.ts index 7dbd183233..a08ca65f34 100644 --- a/playwright/pages/settings.ts +++ b/playwright/pages/settings.ts @@ -6,7 +6,7 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com Please see LICENSE files in the repository root for full details. */ -import { Locator, Page } from "@playwright/test"; +import { type Locator, type Page } from "@playwright/test"; import type { SettingLevel } from "../../src/settings/SettingLevel"; diff --git a/playwright/pages/toasts.ts b/playwright/pages/toasts.ts index 4fadfb9a9b..cfab354aaf 100644 --- a/playwright/pages/toasts.ts +++ b/playwright/pages/toasts.ts @@ -6,7 +6,7 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com Please see LICENSE files in the repository root for full details. */ -import { Page, expect, Locator } from "@playwright/test"; +import { type Page, expect, type Locator } from "@playwright/test"; export class Toasts { public constructor(private readonly page: Page) {} diff --git a/playwright/plugins/homeserver/dendrite/index.ts b/playwright/plugins/homeserver/dendrite/index.ts index 897cafc5a3..c139650405 100644 --- a/playwright/plugins/homeserver/dendrite/index.ts +++ b/playwright/plugins/homeserver/dendrite/index.ts @@ -6,36 +6,8 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com Please see LICENSE files in the repository root for full details. */ -import { Fixtures } from "@playwright/test"; +import { type WorkerOptions } from "../../../services.ts"; -import { DendriteContainer, PineconeContainer } from "../../../testcontainers/dendrite.ts"; -import { Services } from "../../../services.ts"; - -export const dendriteHomeserver: Fixtures<{}, Services> = { - _homeserver: [ - // eslint-disable-next-line no-empty-pattern - async ({}, use) => { - const container = - process.env["PLAYWRIGHT_HOMESERVER"] === "dendrite" ? new DendriteContainer() : new PineconeContainer(); - await use(container); - }, - { scope: "worker" }, - ], - homeserver: [ - async ({ logger, network, _homeserver: homeserver }, use) => { - const container = await homeserver - .withNetwork(network) - .withNetworkAliases("homeserver") - .withLogConsumer(logger.getConsumer("dendrite")) - .start(); - - await use(container); - await container.stop(); - }, - { scope: "worker" }, - ], +export const isDendrite = ({ homeserverType }: WorkerOptions): boolean => { + return homeserverType === "dendrite" || homeserverType === "pinecone"; }; - -export function isDendrite(): boolean { - return process.env["PLAYWRIGHT_HOMESERVER"] === "dendrite" || process.env["PLAYWRIGHT_HOMESERVER"] === "pinecone"; -} diff --git a/playwright/plugins/homeserver/index.ts b/playwright/plugins/homeserver/index.ts index 9e54e0aa91..0571cd9615 100644 --- a/playwright/plugins/homeserver/index.ts +++ b/playwright/plugins/homeserver/index.ts @@ -6,7 +6,7 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com Please see LICENSE files in the repository root for full details. */ -import { ClientServerApi } from "../../testcontainers/utils.ts"; +import { type ClientServerApi } from "@element-hq/element-web-playwright-common/lib/utils/api.js"; export interface HomeserverInstance { readonly baseUrl: string; @@ -46,3 +46,5 @@ export interface Credentials { displayName?: string; username: string; // the localpart of the userId } + +export type HomeserverType = "synapse" | "dendrite" | "pinecone"; diff --git a/playwright/plugins/homeserver/synapse/consentHomeserver.ts b/playwright/plugins/homeserver/synapse/consentHomeserver.ts index 4abf22b16e..9b3316bf57 100644 --- a/playwright/plugins/homeserver/synapse/consentHomeserver.ts +++ b/playwright/plugins/homeserver/synapse/consentHomeserver.ts @@ -6,32 +6,19 @@ 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 { Fixtures } from "@playwright/test"; +import { type SynapseContainer } from "@element-hq/element-web-playwright-common/lib/testcontainers"; -import { Services } from "../../../services.ts"; +import { type Fixtures } from "../../../element-web-test.ts"; -export const consentHomeserver: Fixtures<{}, Services> = { +export const consentHomeserver: Fixtures = { _homeserver: [ - async ({ _homeserver: container, mailhog }, use) => { - container + async ({ _homeserver: container, mailpit }, use) => { + (container as SynapseContainer) .withCopyDirectoriesToContainer([ { source: "playwright/plugins/homeserver/synapse/res", target: "/data/res" }, ]) + .withSmtpServer(mailpit) .withConfig({ - email: { - enable_notifs: false, - smtp_host: "mailhog", - smtp_port: 1025, - smtp_user: "username", - smtp_pass: "password", - require_transport_security: false, - notif_from: "Your Friendly %(app)s homeserver ", - app_name: "Matrix", - notif_template_html: "notif_mail.html", - notif_template_text: "notif_mail.txt", - notif_for_new_users: true, - client_base_url: "http://localhost/element", - }, user_consent: { template_dir: "/data/res/templates/privacy", version: "1.0", @@ -56,4 +43,9 @@ export const consentHomeserver: Fixtures<{}, Services> = { }, { scope: "worker" }, ], + + context: async ({ homeserverType, context }, use, testInfo) => { + testInfo.skip(homeserverType !== "synapse", "does not yet support MAS"); + await use(context); + }, }; diff --git a/playwright/plugins/homeserver/synapse/emailHomeserver.ts b/playwright/plugins/homeserver/synapse/emailHomeserver.ts index 0df3a67048..889d2a57d8 100644 --- a/playwright/plugins/homeserver/synapse/emailHomeserver.ts +++ b/playwright/plugins/homeserver/synapse/emailHomeserver.ts @@ -6,19 +6,17 @@ 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 { Fixtures } from "@playwright/test"; +import { type Fixtures } from "../../../element-web-test.ts"; -import { Services } from "../../../services.ts"; - -export const emailHomeserver: Fixtures<{}, Services> = { +export const emailHomeserver: Fixtures = { _homeserver: [ - async ({ _homeserver: container, mailhog }, use) => { + async ({ _homeserver: container, mailpit }, use) => { container.withConfig({ enable_registration_without_verification: undefined, disable_msisdn_registration: undefined, registrations_require_3pid: ["email"], email: { - smtp_host: "mailhog", + smtp_host: "mailpit", smtp_port: 1025, notif_from: "Your Friendly %(app)s homeserver ", app_name: "my_branded_matrix_server", @@ -28,4 +26,9 @@ export const emailHomeserver: Fixtures<{}, Services> = { }, { scope: "worker" }, ], + + context: async ({ homeserverType, context }, use, testInfo) => { + testInfo.skip(homeserverType !== "synapse", "does not yet support MAS"); + await use(context); + }, }; diff --git a/playwright/plugins/homeserver/synapse/legacyOAuthHomeserver.ts b/playwright/plugins/homeserver/synapse/legacyOAuthHomeserver.ts index 361a36a82e..b6ef7243fd 100644 --- a/playwright/plugins/homeserver/synapse/legacyOAuthHomeserver.ts +++ b/playwright/plugins/homeserver/synapse/legacyOAuthHomeserver.ts @@ -6,17 +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 { Fixtures, PlaywrightTestArgs } from "@playwright/test"; import { TestContainers } from "testcontainers"; -import { Services, TestFixtures } from "../../../services.ts"; import { OAuthServer } from "../../oauth_server"; +import { type Fixtures } from "../../../element-web-test.ts"; -export const legacyOAuthHomeserver: Fixtures< - TestFixtures & PlaywrightTestArgs, - Services, - TestFixtures & PlaywrightTestArgs -> = { +export const legacyOAuthHomeserver: Fixtures = { oAuthServer: [ // eslint-disable-next-line no-empty-pattern async ({}, use) => { @@ -26,7 +21,8 @@ export const legacyOAuthHomeserver: Fixtures< }, { scope: "worker" }, ], - context: async ({ context, oAuthServer }, use, testInfo) => { + context: async ({ homeserverType, context, oAuthServer }, use, testInfo) => { + testInfo.skip(homeserverType !== "synapse", "does not yet support OIDC"); oAuthServer.onTestStarted(testInfo); await use(context); }, diff --git a/playwright/plugins/homeserver/synapse/masHomeserver.ts b/playwright/plugins/homeserver/synapse/masHomeserver.ts index bf2dd13a1c..342737d80d 100644 --- a/playwright/plugins/homeserver/synapse/masHomeserver.ts +++ b/playwright/plugins/homeserver/synapse/masHomeserver.ts @@ -6,16 +6,13 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com Please see LICENSE files in the repository root for full details. */ -import { Fixtures, PlaywrightTestArgs } from "@playwright/test"; +import { MatrixAuthenticationServiceContainer } from "@element-hq/element-web-playwright-common/lib/testcontainers"; -import { Services } from "../../../services.ts"; -import { Fixtures as BaseFixtures } from "../../../element-web-test.ts"; -import { MatrixAuthenticationServiceContainer } from "../../../testcontainers/mas.ts"; +import { type Fixtures } from "../../../element-web-test.ts"; -type Fixture = PlaywrightTestArgs & BaseFixtures; -export const masHomeserver: Fixtures = { +export const masHomeserver: Fixtures = { mas: [ - async ({ _homeserver: homeserver, logger, network, postgres, mailhog }, use) => { + async ({ _homeserver: homeserver, logger, network, postgres, mailpit }, use) => { const config = { clients: [ { @@ -83,4 +80,9 @@ export const masHomeserver: Fixtures = { default_server_config: wellKnown, }); }, + + context: async ({ homeserverType, context }, use, testInfo) => { + testInfo.skip(homeserverType !== "synapse", "does not yet support MAS"); + await use(context); + }, }; diff --git a/playwright/plugins/homeserver/synapse/uiaLongSessionTimeoutHomeserver.ts b/playwright/plugins/homeserver/synapse/uiaLongSessionTimeoutHomeserver.ts index 1a0e41a71f..ee11a966fd 100644 --- a/playwright/plugins/homeserver/synapse/uiaLongSessionTimeoutHomeserver.ts +++ b/playwright/plugins/homeserver/synapse/uiaLongSessionTimeoutHomeserver.ts @@ -5,15 +5,13 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com Please see LICENSE files in the repository root for full details. */ -import { Fixtures } from "@playwright/test"; +import { type Fixtures } from "../../../element-web-test.ts"; -import { Services } from "../../../services.ts"; - -export const uiaLongSessionTimeoutHomeserver: Fixtures<{}, Services> = { - synapseConfigOptions: [ - async ({ synapseConfigOptions }, use) => { +export const uiaLongSessionTimeoutHomeserver: Fixtures = { + synapseConfig: [ + async ({ synapseConfig }, use) => { await use({ - ...synapseConfigOptions, + ...synapseConfig, ui_auth: { session_timeout: "300s", }, diff --git a/playwright/plugins/oauth_server/index.ts b/playwright/plugins/oauth_server/index.ts index df5ee0f461..446426a9c1 100644 --- a/playwright/plugins/oauth_server/index.ts +++ b/playwright/plugins/oauth_server/index.ts @@ -8,10 +8,9 @@ Please see LICENSE files in the repository root for full details. import http from "http"; import express from "express"; -import { AddressInfo } from "net"; -import { TestInfo } from "@playwright/test"; - -import { randB64Bytes } from "../utils/rand.ts"; +import { type AddressInfo } from "net"; +import { type TestInfo } from "@playwright/test"; +import { randB64Bytes } from "@element-hq/element-web-playwright-common/lib/utils/rand.js"; export class OAuthServer { private server?: http.Server; diff --git a/playwright/plugins/utils/object.ts b/playwright/plugins/utils/object.ts deleted file mode 100644 index bfb92fecec..0000000000 --- a/playwright/plugins/utils/object.ts +++ /dev/null @@ -1,16 +0,0 @@ -/* -Copyright 2024 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. -*/ - -/** - * Deep copy the given object. The object MUST NOT have circular references and - * MUST NOT have functions. - * @param obj - The object to deep copy. - * @returns A copy of the object without any references to the original. - */ -export function deepCopy(obj: T): T { - return JSON.parse(JSON.stringify(obj)); -} diff --git a/playwright/plugins/utils/port.ts b/playwright/plugins/utils/port.ts deleted file mode 100644 index b54e251f2f..0000000000 --- a/playwright/plugins/utils/port.ts +++ /dev/null @@ -1,19 +0,0 @@ -/* -Copyright 2024 New Vector Ltd. -Copyright 2022 The Matrix.org Foundation C.I.C. - -SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial -Please see LICENSE files in the repository root for full details. -*/ - -import * as net from "net"; - -export async function getFreePort(): Promise { - return new Promise((resolve) => { - const srv = net.createServer(); - srv.listen(0, () => { - const port = (srv.address()).port; - srv.close(() => resolve(port)); - }); - }); -} diff --git a/playwright/plugins/utils/rand.ts b/playwright/plugins/utils/rand.ts deleted file mode 100644 index 94f723f0a6..0000000000 --- a/playwright/plugins/utils/rand.ts +++ /dev/null @@ -1,13 +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 crypto from "node:crypto"; - -export function randB64Bytes(numBytes: number): string { - return crypto.randomBytes(numBytes).toString("base64").replace(/=*$/, ""); -} diff --git a/playwright/plugins/webserver/index.ts b/playwright/plugins/webserver/index.ts index 7645e9cff3..fe236116bc 100644 --- a/playwright/plugins/webserver/index.ts +++ b/playwright/plugins/webserver/index.ts @@ -6,8 +6,8 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com Please see LICENSE files in the repository root for full details. */ -import * as http from "http"; -import { AddressInfo } from "net"; +import * as http from "node:http"; +import { type AddressInfo } from "node:net"; export class Webserver { private server?: http.Server; diff --git a/playwright/sample-files/example-module.js b/playwright/sample-files/example-module.js new file mode 100644 index 0000000000..cb9b80a93b --- /dev/null +++ b/playwright/sample-files/example-module.js @@ -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. +*/ + +export default class ExampleModule { + static moduleApiVersion = "^0.1.0"; + constructor(api) { + this.api = api; + } + async load() { + alert("Testing module loading successful!"); + } +} diff --git a/playwright/services.ts b/playwright/services.ts index 5dae9b8a38..8ecf10e20e 100644 --- a/playwright/services.ts +++ b/playwright/services.ts @@ -5,142 +5,54 @@ 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 { test as base } from "@playwright/test"; -import mailhog from "mailhog"; -import { Network, StartedNetwork } from "testcontainers"; -import { PostgreSqlContainer, StartedPostgreSqlContainer } from "@testcontainers/postgresql"; +import { test as base } from "@element-hq/element-web-playwright-common"; +import { + type Services as BaseServices, + type WorkerOptions as BaseWorkerOptions, +} from "@element-hq/element-web-playwright-common/lib/fixtures"; +import { type HomeserverContainer } from "@element-hq/element-web-playwright-common/lib/testcontainers"; -import { SynapseConfigOptions, SynapseContainer } from "./testcontainers/synapse.ts"; -import { ContainerLogger } from "./testcontainers/utils.ts"; -import { StartedMatrixAuthenticationServiceContainer } from "./testcontainers/mas.ts"; -import { HomeserverContainer, StartedHomeserverContainer } from "./testcontainers/HomeserverContainer.ts"; -import { MailhogContainer, StartedMailhogContainer } from "./testcontainers/mailhog.ts"; -import { OAuthServer } from "./plugins/oauth_server"; - -export interface TestFixtures { - mailhogClient: mailhog.API; -} - -export interface Services { - logger: ContainerLogger; - - network: StartedNetwork; - postgres: StartedPostgreSqlContainer; - mailhog: StartedMailhogContainer; - - synapseConfigOptions: SynapseConfigOptions; - _homeserver: HomeserverContainer; - homeserver: StartedHomeserverContainer; - mas?: StartedMatrixAuthenticationServiceContainer; +import { type OAuthServer } from "./plugins/oauth_server"; +import { DendriteContainer, PineconeContainer } from "./testcontainers/dendrite"; +import { type HomeserverType } from "./plugins/homeserver"; +import { SynapseContainer } from "./testcontainers/synapse"; +export interface Services extends BaseServices { // Set in legacyOAuthHomeserver only oAuthServer?: OAuthServer; } -export const test = base.extend({ - logger: [ - // eslint-disable-next-line no-empty-pattern - async ({}, use) => { - const logger = new ContainerLogger(); - await use(logger); - }, - { scope: "worker" }, - ], - network: [ - // eslint-disable-next-line no-empty-pattern - async ({}, use) => { - const network = await new Network().start(); - await use(network); - await network.stop(); - }, - { scope: "worker" }, - ], - postgres: [ - async ({ logger, network }, use) => { - const container = await new PostgreSqlContainer() - .withNetwork(network) - .withNetworkAliases("postgres") - .withLogConsumer(logger.getConsumer("postgres")) - .withTmpFs({ - "/dev/shm/pgdata/data": "", - }) - .withEnvironment({ - PG_DATA: "/dev/shm/pgdata/data", - }) - .withCommand([ - "-c", - "shared_buffers=128MB", - "-c", - `fsync=off`, - "-c", - `synchronous_commit=off`, - "-c", - "full_page_writes=off", - ]) - .start(); - await use(container); - await container.stop(); - }, - { scope: "worker" }, - ], +export interface WorkerOptions extends BaseWorkerOptions { + homeserverType: HomeserverType; +} - mailhog: [ - async ({ logger, network }, use) => { - const container = await new MailhogContainer() - .withNetwork(network) - .withNetworkAliases("mailhog") - .withLogConsumer(logger.getConsumer("mailhog")) - .start(); - await use(container); - await container.stop(); - }, - { scope: "worker" }, - ], - mailhogClient: async ({ mailhog: container }, use) => { - await use(container.client); - await container.client.deleteAll(); - }, - - synapseConfigOptions: [{}, { option: true, scope: "worker" }], +export const test = base.extend<{}, Services & WorkerOptions>({ + homeserverType: ["synapse", { option: true, scope: "worker" }], _homeserver: [ - // eslint-disable-next-line no-empty-pattern - async ({}, use) => { - const container = new SynapseContainer(); - await use(container); - }, - { scope: "worker" }, - ], - homeserver: [ - async ({ logger, network, _homeserver: homeserver, synapseConfigOptions, mas }, use) => { - const container = await homeserver - .withNetwork(network) - .withNetworkAliases("homeserver") - .withLogConsumer(logger.getConsumer("synapse")) - .withConfig(synapseConfigOptions) - .withMatrixAuthenticationService(mas) - .start(); + async ({ homeserverType }, use) => { + let container: HomeserverContainer; + switch (homeserverType) { + case "synapse": + container = new SynapseContainer(); + break; + case "dendrite": + container = new DendriteContainer(); + break; + case "pinecone": + container = new PineconeContainer(); + break; + } await use(container); - await container.stop(); - }, - { scope: "worker" }, - ], - mas: [ - // eslint-disable-next-line no-empty-pattern - async ({}, use) => { - // we stub the mas fixture to allow `homeserver` to depend on it to ensure - // when it is specified by `masHomeserver` it is started before the homeserver - await use(undefined); }, { scope: "worker" }, ], - context: async ({ logger, context, request, homeserver, mailhogClient }, use, testInfo) => { - homeserver.setRequest(request); - await logger.testStarted(testInfo); + context: async ({ homeserverType, synapseConfig, context, _homeserver }, use, testInfo) => { + testInfo.skip( + !(_homeserver instanceof SynapseContainer) && Object.keys(synapseConfig).length > 0, + `Test specifies Synapse config options so is unsupported with ${homeserverType}`, + ); await use(context); - await logger.testFinished(testInfo); - await homeserver.onTestFinished(testInfo); - await mailhogClient.deleteAll(); }, }); diff --git a/playwright/snapshots/app-loading/feature-detection.spec.ts/unsupported-browser-CompatibilityView-linux.png b/playwright/snapshots/app-loading/feature-detection.spec.ts/unsupported-browser-CompatibilityView-linux.png index 6524a45a67..b0bb64273e 100644 Binary files a/playwright/snapshots/app-loading/feature-detection.spec.ts/unsupported-browser-CompatibilityView-linux.png and b/playwright/snapshots/app-loading/feature-detection.spec.ts/unsupported-browser-CompatibilityView-linux.png differ diff --git a/playwright/snapshots/audio-player/audio-player.spec.ts/Selected-EventTile-of-audio-player-with-a-reply-chain-bubble-layout-linux.png b/playwright/snapshots/audio-player/audio-player.spec.ts/Selected-EventTile-of-audio-player-with-a-reply-chain-bubble-layout-linux.png index ffe1b0be50..bc9d6c88c3 100644 Binary files a/playwright/snapshots/audio-player/audio-player.spec.ts/Selected-EventTile-of-audio-player-with-a-reply-chain-bubble-layout-linux.png and b/playwright/snapshots/audio-player/audio-player.spec.ts/Selected-EventTile-of-audio-player-with-a-reply-chain-bubble-layout-linux.png differ diff --git a/playwright/snapshots/crypto/toasts.spec.ts/key-storage-out-of-sync-toast-linux.png b/playwright/snapshots/crypto/toasts.spec.ts/key-storage-out-of-sync-toast-linux.png new file mode 100644 index 0000000000..8cf3cb7d69 Binary files /dev/null and b/playwright/snapshots/crypto/toasts.spec.ts/key-storage-out-of-sync-toast-linux.png differ diff --git a/playwright/snapshots/file-upload/image-upload.spec.ts/image-upload-preview-linux.png b/playwright/snapshots/file-upload/image-upload.spec.ts/image-upload-preview-linux.png index e3c37e79c9..be47bf9e44 100644 Binary files a/playwright/snapshots/file-upload/image-upload.spec.ts/image-upload-preview-linux.png and b/playwright/snapshots/file-upload/image-upload.spec.ts/image-upload-preview-linux.png differ diff --git a/playwright/snapshots/forgot-password/forgot-password.spec.ts/forgot-password-linux.png b/playwright/snapshots/forgot-password/forgot-password.spec.ts/forgot-password-linux.png index 377e1931be..8bc25d6f2f 100644 Binary files a/playwright/snapshots/forgot-password/forgot-password.spec.ts/forgot-password-linux.png and b/playwright/snapshots/forgot-password/forgot-password.spec.ts/forgot-password-linux.png differ diff --git a/playwright/snapshots/forgot-password/forgot-password.spec.ts/forgot-password-verify-email-linux.png b/playwright/snapshots/forgot-password/forgot-password.spec.ts/forgot-password-verify-email-linux.png index ec7b9a174d..5537ae9991 100644 Binary files a/playwright/snapshots/forgot-password/forgot-password.spec.ts/forgot-password-verify-email-linux.png and b/playwright/snapshots/forgot-password/forgot-password.spec.ts/forgot-password-verify-email-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 c66606efcd..95b10570a0 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 02cbff0e3f..8f2a488c00 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 fa3cf90430..9bc23433a5 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/unread-primary-filters-linux.png b/playwright/snapshots/left-panel/room-list-panel/room-list-filter-sort.spec.ts/unread-primary-filters-linux.png new file mode 100644 index 0000000000..8c056bc754 Binary files /dev/null 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 new file mode 100644 index 0000000000..06314b5213 Binary files /dev/null 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 new file mode 100644 index 0000000000..8382d3b184 Binary files /dev/null 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 new file mode 100644 index 0000000000..6c871b6b9c Binary files /dev/null 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 new file mode 100644 index 0000000000..d2934c2a76 Binary files /dev/null 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 new file mode 100644 index 0000000000..bdb7f8fa2a Binary files /dev/null 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 new file mode 100644 index 0000000000..cba5fa86d4 Binary files /dev/null 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-search.spec.ts/search-section-linux.png b/playwright/snapshots/left-panel/room-list-panel/room-list-search.spec.ts/search-section-linux.png new file mode 100644 index 0000000000..123cf37586 Binary files /dev/null and b/playwright/snapshots/left-panel/room-list-panel/room-list-search.spec.ts/search-section-linux.png differ diff --git a/playwright/snapshots/left-panel/room-list-panel/room-list.spec.ts/room-list-item-hover-linux.png b/playwright/snapshots/left-panel/room-list-panel/room-list.spec.ts/room-list-item-hover-linux.png new file mode 100644 index 0000000000..45d5588733 Binary files /dev/null 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-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 new file mode 100644 index 0000000000..0501bf1e4c Binary files /dev/null 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-linux.png b/playwright/snapshots/left-panel/room-list-panel/room-list.spec.ts/room-list-linux.png new file mode 100644 index 0000000000..5d88f45aa5 Binary files /dev/null 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 new file mode 100644 index 0000000000..157b0ce156 Binary files /dev/null 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/emote-rich-ltr-rtldisplayname-linux.png b/playwright/snapshots/messages/messages.spec.ts/emote-rich-ltr-rtldisplayname-linux.png index a4b7d0a992..0e65830b51 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/permalinks/permalinks.spec.ts/permalink-rendering-linux.png b/playwright/snapshots/permalinks/permalinks.spec.ts/permalink-rendering-linux.png index 62ffcead99..b386eaa564 100644 Binary files a/playwright/snapshots/permalinks/permalinks.spec.ts/permalink-rendering-linux.png and b/playwright/snapshots/permalinks/permalinks.spec.ts/permalink-rendering-linux.png differ diff --git a/playwright/snapshots/polls/polls.spec.ts/ThreadView-with-a-poll-on-bubble-layout-linux.png b/playwright/snapshots/polls/polls.spec.ts/ThreadView-with-a-poll-on-bubble-layout-linux.png index a842b686dd..0a4abba833 100644 Binary files a/playwright/snapshots/polls/polls.spec.ts/ThreadView-with-a-poll-on-bubble-layout-linux.png and b/playwright/snapshots/polls/polls.spec.ts/ThreadView-with-a-poll-on-bubble-layout-linux.png differ diff --git a/playwright/snapshots/polls/polls.spec.ts/ThreadView-with-a-poll-on-group-layout-linux.png b/playwright/snapshots/polls/polls.spec.ts/ThreadView-with-a-poll-on-group-layout-linux.png index d9d12951df..f00047fe84 100644 Binary files a/playwright/snapshots/polls/polls.spec.ts/ThreadView-with-a-poll-on-group-layout-linux.png and b/playwright/snapshots/polls/polls.spec.ts/ThreadView-with-a-poll-on-group-layout-linux.png differ diff --git a/playwright/snapshots/register/email.spec.ts/registration-check-your-email-linux.png b/playwright/snapshots/register/email.spec.ts/registration-check-your-email-linux.png index 4b724d7772..fc52c2cb65 100644 Binary files a/playwright/snapshots/register/email.spec.ts/registration-check-your-email-linux.png and b/playwright/snapshots/register/email.spec.ts/registration-check-your-email-linux.png differ diff --git a/playwright/snapshots/register/register.spec.ts/email-prompt-linux.png b/playwright/snapshots/register/register.spec.ts/email-prompt-linux.png index f78a2a0b16..dd458f3157 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 da924bae82..16b686e101 100644 Binary files a/playwright/snapshots/register/register.spec.ts/server-picker-linux.png and b/playwright/snapshots/register/register.spec.ts/server-picker-linux.png differ diff --git a/playwright/snapshots/register/register.spec.ts/use-case-selection-linux.png b/playwright/snapshots/register/register.spec.ts/use-case-selection-linux.png deleted file mode 100644 index 1dd98b51e1..0000000000 Binary files a/playwright/snapshots/register/register.spec.ts/use-case-selection-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 0566a21175..c1fced1938 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/memberlist.spec.ts/with-four-members-linux.png b/playwright/snapshots/right-panel/memberlist.spec.ts/with-four-members-linux.png index f0e14ee55c..a5db88aae6 100644 Binary files a/playwright/snapshots/right-panel/memberlist.spec.ts/with-four-members-linux.png and b/playwright/snapshots/right-panel/memberlist.spec.ts/with-four-members-linux.png differ diff --git a/playwright/snapshots/right-panel/right-panel.spec.ts/with-leave-room-linux.png b/playwright/snapshots/right-panel/right-panel.spec.ts/with-leave-room-linux.png new file mode 100644 index 0000000000..a2bc63aaf4 Binary files /dev/null and b/playwright/snapshots/right-panel/right-panel.spec.ts/with-leave-room-linux.png differ diff --git a/playwright/snapshots/room/room-header.spec.ts/room-header-with-icon-linux.png b/playwright/snapshots/room/room-header.spec.ts/room-header-with-icon-linux.png new file mode 100644 index 0000000000..471c26ccdb Binary files /dev/null and b/playwright/snapshots/room/room-header.spec.ts/room-header-with-icon-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 655d45bc4a..e0d8b80639 100644 Binary files a/playwright/snapshots/settings/account-user-settings-tab.spec.ts/account-linux.png and b/playwright/snapshots/settings/account-user-settings-tab.spec.ts/account-linux.png differ diff --git a/playwright/snapshots/settings/account-user-settings-tab.spec.ts/account-smallscreen-linux.png b/playwright/snapshots/settings/account-user-settings-tab.spec.ts/account-smallscreen-linux.png index e2bd16fb5a..e0e46682a3 100644 Binary files a/playwright/snapshots/settings/account-user-settings-tab.spec.ts/account-smallscreen-linux.png and b/playwright/snapshots/settings/account-user-settings-tab.spec.ts/account-smallscreen-linux.png differ diff --git a/playwright/snapshots/settings/appearance-user-settings-tab/appearance-user-settings-tab.spec.ts/appearance-tab-linux.png b/playwright/snapshots/settings/appearance-user-settings-tab/appearance-user-settings-tab.spec.ts/appearance-tab-linux.png index 75a4852f9b..d1c320bff1 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 41ffca6c93..cc3c32ee95 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 eaa68eae4a..6e0cc3e06f 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 036ff61851..8e29fd26b8 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-custom-theme-added-linux.png b/playwright/snapshots/settings/appearance-user-settings-tab/theme-choice-panel.spec.ts/theme-panel-custom-theme-added-linux.png index 147fcfa057..60678f3bb4 100644 Binary files a/playwright/snapshots/settings/appearance-user-settings-tab/theme-choice-panel.spec.ts/theme-panel-custom-theme-added-linux.png and b/playwright/snapshots/settings/appearance-user-settings-tab/theme-choice-panel.spec.ts/theme-panel-custom-theme-added-linux.png differ diff --git a/playwright/snapshots/settings/appearance-user-settings-tab/theme-choice-panel.spec.ts/theme-panel-custom-theme-linux.png b/playwright/snapshots/settings/appearance-user-settings-tab/theme-choice-panel.spec.ts/theme-panel-custom-theme-linux.png index 5475f9a537..cc2dc4a0a7 100644 Binary files a/playwright/snapshots/settings/appearance-user-settings-tab/theme-choice-panel.spec.ts/theme-panel-custom-theme-linux.png and b/playwright/snapshots/settings/appearance-user-settings-tab/theme-choice-panel.spec.ts/theme-panel-custom-theme-linux.png differ diff --git a/playwright/snapshots/settings/appearance-user-settings-tab/theme-choice-panel.spec.ts/theme-panel-custom-theme-removed-linux.png b/playwright/snapshots/settings/appearance-user-settings-tab/theme-choice-panel.spec.ts/theme-panel-custom-theme-removed-linux.png index 23b88c022c..068aa2901f 100644 Binary files a/playwright/snapshots/settings/appearance-user-settings-tab/theme-choice-panel.spec.ts/theme-panel-custom-theme-removed-linux.png and b/playwright/snapshots/settings/appearance-user-settings-tab/theme-choice-panel.spec.ts/theme-panel-custom-theme-removed-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 6378098d7a..eb249d0b24 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/appearance-user-settings-tab/theme-choice-panel.spec.ts/theme-panel-light-linux.png b/playwright/snapshots/settings/appearance-user-settings-tab/theme-choice-panel.spec.ts/theme-panel-light-linux.png index f2269a0532..3b221183fb 100644 Binary files a/playwright/snapshots/settings/appearance-user-settings-tab/theme-choice-panel.spec.ts/theme-panel-light-linux.png and b/playwright/snapshots/settings/appearance-user-settings-tab/theme-choice-panel.spec.ts/theme-panel-light-linux.png differ diff --git a/playwright/snapshots/settings/appearance-user-settings-tab/theme-choice-panel.spec.ts/theme-panel-match-system-enabled-linux.png b/playwright/snapshots/settings/appearance-user-settings-tab/theme-choice-panel.spec.ts/theme-panel-match-system-enabled-linux.png index 6b41f30acd..73f7f02d65 100644 Binary files a/playwright/snapshots/settings/appearance-user-settings-tab/theme-choice-panel.spec.ts/theme-panel-match-system-enabled-linux.png and b/playwright/snapshots/settings/appearance-user-settings-tab/theme-choice-panel.spec.ts/theme-panel-match-system-enabled-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 new file mode 100644 index 0000000000..6a95f36da7 Binary files /dev/null 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 new file mode 100644 index 0000000000..18213b5375 Binary files /dev/null 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 new file mode 100644 index 0000000000..3af3e2aedf Binary files /dev/null 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 new file mode 100644 index 0000000000..10ece913d4 Binary files /dev/null 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 new file mode 100644 index 0000000000..ba4ccfe18e Binary files /dev/null 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 new file mode 100644 index 0000000000..dcc1f25008 Binary files /dev/null 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 new file mode 100644 index 0000000000..8d6f2a74f4 Binary files /dev/null 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 new file mode 100644 index 0000000000..e8def33311 Binary files /dev/null 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 new file mode 100644 index 0000000000..9c23f7ea20 Binary files /dev/null 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 new file mode 100644 index 0000000000..a1f286ac73 Binary files /dev/null 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 new file mode 100644 index 0000000000..0d8aa341c0 Binary files /dev/null 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 new file mode 100644 index 0000000000..00a96ce522 Binary files /dev/null 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 new file mode 100644 index 0000000000..e7dcea9436 Binary files /dev/null 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 c08a36a808..833dc3a8c7 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 7d95205251..077c8ba462 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 new file mode 100644 index 0000000000..135c669fb0 Binary files /dev/null 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 720cc41548..748bdd5c21 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/spaces/spaces.spec.ts/invite-teammates-dialog-linux.png b/playwright/snapshots/spaces/spaces.spec.ts/invite-teammates-dialog-linux.png index 2244dc7cf9..b69496e5fe 100644 Binary files a/playwright/snapshots/spaces/spaces.spec.ts/invite-teammates-dialog-linux.png and b/playwright/snapshots/spaces/spaces.spec.ts/invite-teammates-dialog-linux.png differ diff --git a/playwright/snapshots/threads/threads.spec.ts/Initial-ThreadView-on-bubble-layout-linux.png b/playwright/snapshots/threads/threads.spec.ts/Initial-ThreadView-on-bubble-layout-linux.png new file mode 100644 index 0000000000..60d6dc9e18 Binary files /dev/null and b/playwright/snapshots/threads/threads.spec.ts/Initial-ThreadView-on-bubble-layout-linux.png differ diff --git a/playwright/snapshots/threads/threads.spec.ts/Initial-ThreadView-on-group-layout-linux.png b/playwright/snapshots/threads/threads.spec.ts/Initial-ThreadView-on-group-layout-linux.png new file mode 100644 index 0000000000..f217fe7094 Binary files /dev/null and b/playwright/snapshots/threads/threads.spec.ts/Initial-ThreadView-on-group-layout-linux.png differ diff --git a/playwright/snapshots/threads/threads.spec.ts/ThreadView-with-reaction-and-a-hidden-event-on-bubble-layout-linux.png b/playwright/snapshots/threads/threads.spec.ts/ThreadView-with-reaction-and-a-hidden-event-on-bubble-layout-linux.png new file mode 100644 index 0000000000..cff1b27bd3 Binary files /dev/null and b/playwright/snapshots/threads/threads.spec.ts/ThreadView-with-reaction-and-a-hidden-event-on-bubble-layout-linux.png differ diff --git a/playwright/snapshots/threads/threads.spec.ts/ThreadView-with-reaction-and-a-hidden-event-on-group-layout-linux.png b/playwright/snapshots/threads/threads.spec.ts/ThreadView-with-reaction-and-a-hidden-event-on-group-layout-linux.png new file mode 100644 index 0000000000..30fa37ab9e Binary files /dev/null and b/playwright/snapshots/threads/threads.spec.ts/ThreadView-with-reaction-and-a-hidden-event-on-group-layout-linux.png differ diff --git a/playwright/snapshots/threads/threads.spec.ts/ThreadView-with-redacted-messages-on-bubble-layout-linux.png b/playwright/snapshots/threads/threads.spec.ts/ThreadView-with-redacted-messages-on-bubble-layout-linux.png new file mode 100644 index 0000000000..c92780196d Binary files /dev/null and b/playwright/snapshots/threads/threads.spec.ts/ThreadView-with-redacted-messages-on-bubble-layout-linux.png differ diff --git a/playwright/snapshots/threads/threads.spec.ts/ThreadView-with-redacted-messages-on-group-layout-linux.png b/playwright/snapshots/threads/threads.spec.ts/ThreadView-with-redacted-messages-on-group-layout-linux.png new file mode 100644 index 0000000000..4bad759050 Binary files /dev/null and b/playwright/snapshots/threads/threads.spec.ts/ThreadView-with-redacted-messages-on-group-layout-linux.png differ diff --git a/playwright/snapshots/timeline/timeline.spec.ts/code-block-linux.png b/playwright/snapshots/timeline/timeline.spec.ts/code-block-linux.png new file mode 100644 index 0000000000..2f62d0dec6 Binary files /dev/null and b/playwright/snapshots/timeline/timeline.spec.ts/code-block-linux.png differ diff --git a/playwright/snapshots/timeline/timeline.spec.ts/collapsed-gels-and-messages-irc-layout-linux.png b/playwright/snapshots/timeline/timeline.spec.ts/collapsed-gels-and-messages-irc-layout-linux.png index 00b271004e..f8135426c2 100644 Binary files a/playwright/snapshots/timeline/timeline.spec.ts/collapsed-gels-and-messages-irc-layout-linux.png and b/playwright/snapshots/timeline/timeline.spec.ts/collapsed-gels-and-messages-irc-layout-linux.png differ diff --git a/playwright/snapshots/timeline/timeline.spec.ts/collapsed-gels-bubble-layout-linux.png b/playwright/snapshots/timeline/timeline.spec.ts/collapsed-gels-bubble-layout-linux.png index 8f11c831db..4d75b3b966 100644 Binary files a/playwright/snapshots/timeline/timeline.spec.ts/collapsed-gels-bubble-layout-linux.png and b/playwright/snapshots/timeline/timeline.spec.ts/collapsed-gels-bubble-layout-linux.png differ diff --git a/playwright/snapshots/timeline/timeline.spec.ts/configured-room-irc-layout-linux.png b/playwright/snapshots/timeline/timeline.spec.ts/configured-room-irc-layout-linux.png index 6365543947..a6e31b89cd 100644 Binary files a/playwright/snapshots/timeline/timeline.spec.ts/configured-room-irc-layout-linux.png and b/playwright/snapshots/timeline/timeline.spec.ts/configured-room-irc-layout-linux.png differ diff --git a/playwright/snapshots/timeline/timeline.spec.ts/edited-code-block-linux.png b/playwright/snapshots/timeline/timeline.spec.ts/edited-code-block-linux.png new file mode 100644 index 0000000000..46f5cf1a7a Binary files /dev/null and b/playwright/snapshots/timeline/timeline.spec.ts/edited-code-block-linux.png differ diff --git a/playwright/snapshots/timeline/timeline.spec.ts/event-line-inline-start-margin-irc-layout-linux.png b/playwright/snapshots/timeline/timeline.spec.ts/event-line-inline-start-margin-irc-layout-linux.png index d8a5ae4056..8d3d7b09ed 100644 Binary files a/playwright/snapshots/timeline/timeline.spec.ts/event-line-inline-start-margin-irc-layout-linux.png and b/playwright/snapshots/timeline/timeline.spec.ts/event-line-inline-start-margin-irc-layout-linux.png differ diff --git a/playwright/snapshots/timeline/timeline.spec.ts/event-tiles-bubble-layout-linux.png b/playwright/snapshots/timeline/timeline.spec.ts/event-tiles-bubble-layout-linux.png index 58c844a54d..785681b28c 100644 Binary files a/playwright/snapshots/timeline/timeline.spec.ts/event-tiles-bubble-layout-linux.png and b/playwright/snapshots/timeline/timeline.spec.ts/event-tiles-bubble-layout-linux.png differ diff --git a/playwright/snapshots/timeline/timeline.spec.ts/event-tiles-compact-modern-layout-linux.png b/playwright/snapshots/timeline/timeline.spec.ts/event-tiles-compact-modern-layout-linux.png index d8e6da9f8f..e5e312ac73 100644 Binary files a/playwright/snapshots/timeline/timeline.spec.ts/event-tiles-compact-modern-layout-linux.png and b/playwright/snapshots/timeline/timeline.spec.ts/event-tiles-compact-modern-layout-linux.png differ diff --git a/playwright/snapshots/timeline/timeline.spec.ts/event-tiles-irc-layout-linux.png b/playwright/snapshots/timeline/timeline.spec.ts/event-tiles-irc-layout-linux.png index e1a4e6ef06..f79934e621 100644 Binary files a/playwright/snapshots/timeline/timeline.spec.ts/event-tiles-irc-layout-linux.png and b/playwright/snapshots/timeline/timeline.spec.ts/event-tiles-irc-layout-linux.png differ diff --git a/playwright/snapshots/timeline/timeline.spec.ts/event-tiles-modern-layout-linux.png b/playwright/snapshots/timeline/timeline.spec.ts/event-tiles-modern-layout-linux.png index 032a8c1118..ff4fa7c1b9 100644 Binary files a/playwright/snapshots/timeline/timeline.spec.ts/event-tiles-modern-layout-linux.png and b/playwright/snapshots/timeline/timeline.spec.ts/event-tiles-modern-layout-linux.png differ diff --git a/playwright/snapshots/timeline/timeline.spec.ts/expanded-gels-and-messages-irc-layout-linux.png b/playwright/snapshots/timeline/timeline.spec.ts/expanded-gels-and-messages-irc-layout-linux.png index b31eae03f6..f6840e8daf 100644 Binary files a/playwright/snapshots/timeline/timeline.spec.ts/expanded-gels-and-messages-irc-layout-linux.png and b/playwright/snapshots/timeline/timeline.spec.ts/expanded-gels-and-messages-irc-layout-linux.png differ diff --git a/playwright/snapshots/timeline/timeline.spec.ts/expanded-gels-bubble-layout-linux.png b/playwright/snapshots/timeline/timeline.spec.ts/expanded-gels-bubble-layout-linux.png index 1c7265ca62..6154a0a268 100644 Binary files a/playwright/snapshots/timeline/timeline.spec.ts/expanded-gels-bubble-layout-linux.png and b/playwright/snapshots/timeline/timeline.spec.ts/expanded-gels-bubble-layout-linux.png differ diff --git a/playwright/snapshots/timeline/timeline.spec.ts/expanded-gels-emote-irc-layout-linux.png b/playwright/snapshots/timeline/timeline.spec.ts/expanded-gels-emote-irc-layout-linux.png index 33ef04df3c..06853769d7 100644 Binary files a/playwright/snapshots/timeline/timeline.spec.ts/expanded-gels-emote-irc-layout-linux.png and b/playwright/snapshots/timeline/timeline.spec.ts/expanded-gels-emote-irc-layout-linux.png differ diff --git a/playwright/snapshots/timeline/timeline.spec.ts/expanded-gels-irc-layout-linux.png b/playwright/snapshots/timeline/timeline.spec.ts/expanded-gels-irc-layout-linux.png index d8a5ae4056..8d3d7b09ed 100644 Binary files a/playwright/snapshots/timeline/timeline.spec.ts/expanded-gels-irc-layout-linux.png and b/playwright/snapshots/timeline/timeline.spec.ts/expanded-gels-irc-layout-linux.png differ diff --git a/playwright/snapshots/timeline/timeline.spec.ts/expanded-gels-modern-layout-linux.png b/playwright/snapshots/timeline/timeline.spec.ts/expanded-gels-modern-layout-linux.png index 608b17051d..f1a95a8275 100644 Binary files a/playwright/snapshots/timeline/timeline.spec.ts/expanded-gels-modern-layout-linux.png and b/playwright/snapshots/timeline/timeline.spec.ts/expanded-gels-modern-layout-linux.png differ diff --git a/playwright/snapshots/timeline/timeline.spec.ts/expanded-gels-redaction-placeholder-linux.png b/playwright/snapshots/timeline/timeline.spec.ts/expanded-gels-redaction-placeholder-linux.png index 06aa02cdf8..9d9dacd1bf 100644 Binary files a/playwright/snapshots/timeline/timeline.spec.ts/expanded-gels-redaction-placeholder-linux.png and b/playwright/snapshots/timeline/timeline.spec.ts/expanded-gels-redaction-placeholder-linux.png differ diff --git a/playwright/snapshots/timeline/timeline.spec.ts/hidden-event-line-padding-modern-layout-linux.png b/playwright/snapshots/timeline/timeline.spec.ts/hidden-event-line-padding-modern-layout-linux.png new file mode 100644 index 0000000000..e0523d6eec Binary files /dev/null and b/playwright/snapshots/timeline/timeline.spec.ts/hidden-event-line-padding-modern-layout-linux.png differ diff --git a/playwright/snapshots/timeline/timeline.spec.ts/hidden-event-line-zero-padding-irc-layout-linux.png b/playwright/snapshots/timeline/timeline.spec.ts/hidden-event-line-zero-padding-irc-layout-linux.png new file mode 100644 index 0000000000..5c6d7710f6 Binary files /dev/null and b/playwright/snapshots/timeline/timeline.spec.ts/hidden-event-line-zero-padding-irc-layout-linux.png differ diff --git a/playwright/snapshots/timeline/timeline.spec.ts/image-in-timeline-default-layout-linux.png b/playwright/snapshots/timeline/timeline.spec.ts/image-in-timeline-default-layout-linux.png index b8a24fb3a4..b1236c9ea0 100644 Binary files a/playwright/snapshots/timeline/timeline.spec.ts/image-in-timeline-default-layout-linux.png and b/playwright/snapshots/timeline/timeline.spec.ts/image-in-timeline-default-layout-linux.png differ diff --git a/playwright/snapshots/timeline/timeline.spec.ts/long-strings-with-reply-bubble-layout-linux.png b/playwright/snapshots/timeline/timeline.spec.ts/long-strings-with-reply-bubble-layout-linux.png index 879e647f5e..5d44a1f655 100644 Binary files a/playwright/snapshots/timeline/timeline.spec.ts/long-strings-with-reply-bubble-layout-linux.png and b/playwright/snapshots/timeline/timeline.spec.ts/long-strings-with-reply-bubble-layout-linux.png differ diff --git a/playwright/snapshots/timeline/timeline.spec.ts/long-strings-with-reply-irc-layout-linux.png b/playwright/snapshots/timeline/timeline.spec.ts/long-strings-with-reply-irc-layout-linux.png index b52a56a22e..baa75ffaba 100644 Binary files a/playwright/snapshots/timeline/timeline.spec.ts/long-strings-with-reply-irc-layout-linux.png and b/playwright/snapshots/timeline/timeline.spec.ts/long-strings-with-reply-irc-layout-linux.png differ diff --git a/playwright/snapshots/timeline/timeline.spec.ts/long-strings-with-reply-modern-layout-linux.png b/playwright/snapshots/timeline/timeline.spec.ts/long-strings-with-reply-modern-layout-linux.png index bbaba1dbea..4b54392a21 100644 Binary files a/playwright/snapshots/timeline/timeline.spec.ts/long-strings-with-reply-modern-layout-linux.png and b/playwright/snapshots/timeline/timeline.spec.ts/long-strings-with-reply-modern-layout-linux.png differ diff --git a/playwright/snapshots/user-menu/user-menu.spec.ts/user-menu-linux.png b/playwright/snapshots/user-menu/user-menu.spec.ts/user-menu-linux.png index e2c6718a93..73c9ce49ac 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-onboarding/user-onboarding-new.spec.ts/User-Onboarding-new-user-app-download-dialog-1-linux.png b/playwright/snapshots/user-onboarding/user-onboarding-new.spec.ts/User-Onboarding-new-user-app-download-dialog-1-linux.png deleted file mode 100644 index 024886d01e..0000000000 Binary files a/playwright/snapshots/user-onboarding/user-onboarding-new.spec.ts/User-Onboarding-new-user-app-download-dialog-1-linux.png and /dev/null differ diff --git a/playwright/snapshots/user-onboarding/user-onboarding-new.spec.ts/User-Onboarding-new-user-page-is-shown-and-preference-exists-1-linux.png b/playwright/snapshots/user-onboarding/user-onboarding-new.spec.ts/User-Onboarding-new-user-page-is-shown-and-preference-exists-1-linux.png deleted file mode 100644 index 1042d92e76..0000000000 Binary files a/playwright/snapshots/user-onboarding/user-onboarding-new.spec.ts/User-Onboarding-new-user-page-is-shown-and-preference-exists-1-linux.png and /dev/null 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 30206f1a25..dc832f31c9 100644 Binary files a/playwright/snapshots/user-view/user-view.spec.ts/user-info-linux.png and b/playwright/snapshots/user-view/user-view.spec.ts/user-info-linux.png differ diff --git a/playwright/snapshots/voip/pstn.spec.ts/dialpad-linux.png b/playwright/snapshots/voip/pstn.spec.ts/dialpad-linux.png new file mode 100644 index 0000000000..3be63e2f50 Binary files /dev/null and b/playwright/snapshots/voip/pstn.spec.ts/dialpad-linux.png differ diff --git a/playwright/snapshots/voip/pstn.spec.ts/dialpad-trigger-linux.png b/playwright/snapshots/voip/pstn.spec.ts/dialpad-trigger-linux.png new file mode 100644 index 0000000000..17bea8979d Binary files /dev/null and b/playwright/snapshots/voip/pstn.spec.ts/dialpad-trigger-linux.png differ diff --git a/playwright/testcontainers/HomeserverContainer.ts b/playwright/testcontainers/HomeserverContainer.ts deleted file mode 100644 index 259ecb7fe0..0000000000 --- a/playwright/testcontainers/HomeserverContainer.ts +++ /dev/null @@ -1,24 +0,0 @@ -/* -Copyright 2024 New Vector Ltd. - -SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial -Please see LICENSE files in the repository root for full details. -*/ - -import { AbstractStartedContainer, GenericContainer } from "testcontainers"; -import { APIRequestContext, TestInfo } from "@playwright/test"; - -import { HomeserverInstance } from "../plugins/homeserver"; -import { StartedMatrixAuthenticationServiceContainer } from "./mas.ts"; - -export interface HomeserverContainer extends GenericContainer { - withConfigField(key: string, value: any): this; - withConfig(config: Partial): this; - withMatrixAuthenticationService(mas?: StartedMatrixAuthenticationServiceContainer): this; - start(): Promise; -} - -export interface StartedHomeserverContainer extends AbstractStartedContainer, HomeserverInstance { - setRequest(request: APIRequestContext): void; - onTestFinished(testInfo: TestInfo): Promise; -} diff --git a/playwright/testcontainers/dendrite.ts b/playwright/testcontainers/dendrite.ts index c358ff1585..55938778cd 100644 --- a/playwright/testcontainers/dendrite.ts +++ b/playwright/testcontainers/dendrite.ts @@ -8,12 +8,13 @@ Please see LICENSE files in the repository root for full details. import { GenericContainer, Wait } from "testcontainers"; import * as YAML from "yaml"; import { set } from "lodash"; - -import { randB64Bytes } from "../plugins/utils/rand.ts"; -import { StartedSynapseContainer } from "./synapse.ts"; -import { deepCopy } from "../plugins/utils/object.ts"; -import { HomeserverContainer } from "./HomeserverContainer.ts"; -import { StartedMatrixAuthenticationServiceContainer } from "./mas.ts"; +import { randB64Bytes } from "@element-hq/element-web-playwright-common/lib/utils/rand.js"; +import { deepCopy } from "@element-hq/element-web-playwright-common/lib/utils/object.js"; +import { + StartedSynapseContainer, + type HomeserverContainer, + type StartedMatrixAuthenticationServiceContainer, +} from "@element-hq/element-web-playwright-common/lib/testcontainers"; const DEFAULT_CONFIG = { version: 2, @@ -223,7 +224,7 @@ export class DendriteContainer extends GenericContainer implements HomeserverCon .withWaitStrategy(Wait.forHttp("/_matrix/client/versions", 8008)); } - public withConfigField(key: string, value: any): this { + public withConfigField(key: string, value: unknown): this { set(this.config, key, value); return this; } @@ -236,8 +237,14 @@ export class DendriteContainer extends GenericContainer implements HomeserverCon return this; } + // Dendrite does not support SMTP at this time - https://github.com/element-hq/dendrite/issues/1298 + public withSmtpServer(): this { + return this; + } + + // Dendrite does not support MAS at this time public withMatrixAuthenticationService(mas?: StartedMatrixAuthenticationServiceContainer): this { - throw new Error("Dendrite does not support MAS."); + return this; } public override async start(): Promise { @@ -264,4 +271,10 @@ export class PineconeContainer extends DendriteContainer { } // Surprisingly, Dendrite implements the same register user Synapse Admin API, so we can just extend it -export class StartedDendriteContainer extends StartedSynapseContainer {} +export class StartedDendriteContainer extends StartedSynapseContainer { + protected async deletePublicRooms(): Promise { + // Dendrite does not support admin users managing the room directory + // https://github.com/element-hq/dendrite/blob/main/clientapi/routing/directory.go#L365 + return; + } +} diff --git a/playwright/testcontainers/mailhog.ts b/playwright/testcontainers/mailhog.ts deleted file mode 100644 index c3305607d8..0000000000 --- a/playwright/testcontainers/mailhog.ts +++ /dev/null @@ -1,30 +0,0 @@ -/* -Copyright 2024 New Vector Ltd. - -SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial -Please see LICENSE files in the repository root for full details. -*/ - -import { AbstractStartedContainer, GenericContainer, StartedTestContainer, Wait } from "testcontainers"; -import mailhog from "mailhog"; - -export class MailhogContainer extends GenericContainer { - constructor() { - super("mailhog/mailhog:latest"); - - this.withExposedPorts(8025).withWaitStrategy(Wait.forListeningPorts()); - } - - public override async start(): Promise { - return new StartedMailhogContainer(await super.start()); - } -} - -export class StartedMailhogContainer extends AbstractStartedContainer { - public readonly client: mailhog.API; - - constructor(container: StartedTestContainer) { - super(container); - this.client = mailhog({ host: container.getHost(), port: container.getMappedPort(8025) }); - } -} diff --git a/playwright/testcontainers/mas.ts b/playwright/testcontainers/mas.ts deleted file mode 100644 index 9b05b521ba..0000000000 --- a/playwright/testcontainers/mas.ts +++ /dev/null @@ -1,340 +0,0 @@ -/* -Copyright 2024 New Vector Ltd. - -SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial -Please see LICENSE files in the repository root for full details. -*/ - -import { AbstractStartedContainer, GenericContainer, StartedTestContainer, Wait, ExecResult } from "testcontainers"; -import { StartedPostgreSqlContainer } from "@testcontainers/postgresql"; -import * as YAML from "yaml"; - -import { getFreePort } from "../plugins/utils/port.ts"; -import { deepCopy } from "../plugins/utils/object.ts"; -import { Credentials } from "../plugins/homeserver"; - -const DEFAULT_CONFIG = { - http: { - listeners: [ - { - name: "web", - resources: [ - { name: "discovery" }, - { name: "human" }, - { name: "oauth" }, - { name: "compat" }, - { - name: "graphql", - playground: true, - }, - { - name: "assets", - path: "/usr/local/share/mas-cli/assets/", - }, - ], - binds: [ - { - address: "[::]:8080", - }, - ], - proxy_protocol: false, - }, - { - name: "internal", - resources: [ - { - name: "health", - }, - ], - binds: [ - { - address: "[::]:8081", - }, - ], - proxy_protocol: false, - }, - ], - trusted_proxies: ["192.128.0.0/16", "172.16.0.0/12", "10.0.0.0/10", "127.0.0.1/8", "fd00::/8", "::1/128"], - public_base: "", // Needs to be set - issuer: "", // Needs to be set - }, - database: { - host: "postgres", - port: 5432, - database: "postgres", - username: "postgres", - password: "p4S5w0rD", - max_connections: 10, - min_connections: 0, - connect_timeout: 30, - idle_timeout: 600, - max_lifetime: 1800, - }, - telemetry: { - tracing: { - exporter: "none", - propagators: [], - }, - metrics: { - exporter: "none", - }, - sentry: { - dsn: null, - }, - }, - templates: { - path: "/usr/local/share/mas-cli/templates/", - assets_manifest: "/usr/local/share/mas-cli/manifest.json", - translations_path: "/usr/local/share/mas-cli/translations/", - }, - email: { - from: '"Authentication Service" ', - reply_to: '"Authentication Service" ', - transport: "smtp", - mode: "plain", - hostname: "mailhog", - port: 1025, - username: "username", - password: "password", - }, - secrets: { - encryption: "984b18e207c55ad5fbb2a49b217481a722917ee87b2308d4cf314c83fed8e3b5", - keys: [ - { - kid: "YEAhzrKipJ", - key: "-----BEGIN RSA PRIVATE KEY-----\nMIIEowIBAAKCAQEAuIV+AW5vx52I4CuumgSxp6yvKfIAnRdALeZZCoFkIGxUli1B\nS79NJ3ls46oLh1pSD9RrhaMp6HTNoi4K3hnP9Q9v77pD7KwdFKG3UdG1zksIB0s/\n+/Ey/DmX4LPluwBBS7r/LkQ1jk745lENA++oiDqZf2D/uP8jCHlvaSNyVKTqi1ki\nOXPd4T4xBUjzuas9ze5jQVSYtfOidgnv1EzUipbIxgvH1jNt4raRlmP8mOq7xEnW\nR+cF5x6n/g17PdSEfrwO4kz6aKGZuMP5lVlDEEnMHKabFSQDBl7+Mpok6jXutbtA\nuiBnsKEahF9eoj4na4fpbRNPdIVyoaN5eGvm5wIDAQABAoIBAApyFCYEmHNWaa83\nCdVSOrRhRDE9r+c0r79pcNT1ajOjrk4qFa4yEC4R46YntCtfY5Hd1pBkIjU0l4d8\nz8Su9WTMEOwjQUEepS7L0NLi6kXZXYT8L40VpGs+32grBvBFHW0qEtQNrHJ36gMv\nx2rXoFTF7HaXiSJx3wvVxAbRqOE9tBXLsmNHaWaAdWQG5o77V9+zvMri3cAeEg2w\nVkKokb0dza7es7xG3tqS26k69SrwGeeuKo7qCHPH2cfyWmY5Yhv8iOoA59JzzbiK\nUdxyzCHskrPSpRKVkVVwmY3RBt282TmSRG7td7e5ESSj50P2e5BI5uu1Hp/dvU4F\nvYjV7kECgYEA6WqYoUpVsgQiqhvJwJIc/8gRm0mUy8TenI36z4Iim01Nt7fibWH7\nXnsFqLGjXtYNVWvBcCrUl9doEnRbJeG2eRGbGKYAWVrOeFvwM4fYvw9GoOiJdDj4\ncgWDe7eHbHE+UTqR7Nnr/UBfipoNWDh6X68HRBuXowh0Q6tOfxsrRFECgYEAyl/V\n4b8bFp3pKZZCb+KPSYsQf793cRmrBexPcLWcDPYbMZQADEZ/VLjbrNrpTOWxUWJT\nhr8MrWswnHO+l5AFu5CNO+QgV2dHLk+2w8qpdpFRPJCfXfo2D3wZ0c4cv3VCwv1V\n5y7f6XWVjDWZYV4wj6c3shxZJjZ+9Hbhf3/twbcCgYA6fuRRR3fCbRbi2qPtBrEN\nyO3gpMgNaQEA6vP4HPzfPrhDWmn8T5nXS61XYW03zxz4U1De81zj0K/cMBzHmZFJ\nNghQXQmpWwBzWVcREvJWr1Vb7erEnaJlsMwKrSvbGWYspSj82oAxr3hCG+lMOpsw\nb4S6pM+TpAK/EqdRY1WsgQKBgQCGoMaaTRXqL9bC0bEU2XVVCWxKb8c3uEmrwQ7/\n/fD4NmjUzI5TnDps1CVfkqoNe+hAKddDFqmKXHqUOfOaxDbsFje+lf5l5tDVoDYH\nfjTKKdYPIm7CiAeauYY7qpA5Vfq52Opixy4yEwUPp0CII67OggFtPaqY3zwJyWQt\n+57hdQKBgGCXM/KKt7ceUDcNJxSGjvu0zD9D5Sv2ihYlEBT/JLaTCCJdvzREevaJ\n1d+mpUAt0Lq6A8NWOMq8HPaxAik3rMQ0WtM5iG+XgsUqvTSb7NcshArDLuWGnW3m\nMC4rM0UBYAS4QweduUSH1imrwH/1Gu5+PxbiecceRMMggWpzu0Bq\n-----END RSA PRIVATE KEY-----\n", - }, - { - kid: "8J1AxrlNZT", - key: "-----BEGIN EC PRIVATE KEY-----\nMHcCAQEEIF1cjfIOEdy3BXJ72x6fKpEB8WP1ddZAUJAaqqr/6CpToAoGCCqGSM49\nAwEHoUQDQgAEfHdNuI1Yeh3/uOq2PlnW2vymloOVpwBYebbw4VVsna9xhnutIdQW\ndE8hkX8Yb0pIDasrDiwllVLzSvsWJAI0Kw==\n-----END EC PRIVATE KEY-----\n", - }, - { - kid: "3BW6un1EBi", - key: "-----BEGIN EC PRIVATE KEY-----\nMIGkAgEBBDA+3ZV17r8TsiMdw1cpbTSNbyEd5SMy3VS1Mk/kz6O2Ev/3QZut8GE2\nq3eGtLBoVQigBwYFK4EEACKhZANiAASs8Wxjk/uRimRKXnPr2/wDaXkN9wMDjYQK\nmZULb+0ZP1/cXmuXuri8hUGhQvIU8KWY9PkpV+LMPEdpE54mHPKSLjq5CDXoSZ/P\n9f7cdRaOZ000KQPZfIFR9ujJTtDN7Vs=\n-----END EC PRIVATE KEY-----\n", - }, - { - kid: "pkZ0pTKK0X", - key: "-----BEGIN EC PRIVATE KEY-----\nMHQCAQEEIHenfsXYPc5yzjZKUfvmydDR1YRwdsfZYvwHf/2wsYxooAcGBSuBBAAK\noUQDQgAEON1x7Vlu+nA0KvC5vYSOHhDUkfLYNZwYSLPFVT02h9E13yFFMIJegIBl\nAer+6PMZpPc8ycyeH9N+U9NAyliBhQ==\n-----END EC PRIVATE KEY-----\n", - }, - ], - }, - passwords: { - enabled: true, - schemes: [ - { - version: 1, - algorithm: "argon2id", - }, - ], - minimum_complexity: 0, - }, - policy: { - wasm_module: "/usr/local/share/mas-cli/policy.wasm", - client_registration_entrypoint: "client_registration/violation", - register_entrypoint: "register/violation", - authorization_grant_entrypoint: "authorization_grant/violation", - password_entrypoint: "password/violation", - email_entrypoint: "email/violation", - data: { - client_registration: { - // allow non-SSL and localhost URIs - allow_insecure_uris: true, - // EW doesn't have contacts at this time - allow_missing_contacts: true, - }, - }, - }, - upstream_oauth2: { - providers: [], - }, - branding: { - service_name: null, - policy_uri: null, - tos_uri: null, - imprint: null, - logo_uri: null, - }, - account: { - password_registration_enabled: true, - }, - experimental: { - access_token_ttl: 300, - compat_token_ttl: 300, - }, - rate_limiting: { - login: { - burst: 10, - per_second: 1, - }, - registration: { - burst: 10, - per_second: 1, - }, - }, -}; - -export class MatrixAuthenticationServiceContainer extends GenericContainer { - private config: typeof DEFAULT_CONFIG; - private readonly args = ["-c", "/config/config.yaml"]; - - constructor(db: StartedPostgreSqlContainer) { - // We rely on `mas-cli manage add-email` which isn't in a release yet - // https://github.com/element-hq/matrix-authentication-service/pull/3235 - super("ghcr.io/element-hq/matrix-authentication-service:sha-0b90c33"); - - this.config = deepCopy(DEFAULT_CONFIG); - this.config.database.username = db.getUsername(); - this.config.database.password = db.getPassword(); - - this.withExposedPorts(8080, 8081) - .withWaitStrategy(Wait.forHttp("/health", 8081)) - .withCommand(["server", ...this.args]); - } - - public withConfig(config: object): this { - this.config = { - ...this.config, - ...config, - }; - return this; - } - - public override async start(): Promise { - // MAS config issuer needs to know what URL it'll be accessed from, so we have to map the port manually - const port = await getFreePort(); - - this.config.http.public_base = `http://localhost:${port}/`; - this.config.http.issuer = `http://localhost:${port}/`; - - this.withExposedPorts({ - container: 8080, - host: port, - }).withCopyContentToContainer([ - { - target: "/config/config.yaml", - content: YAML.stringify(this.config), - }, - ]); - - return new StartedMatrixAuthenticationServiceContainer( - await super.start(), - `http://localhost:${port}`, - this.args, - ); - } -} - -export class StartedMatrixAuthenticationServiceContainer extends AbstractStartedContainer { - private adminTokenPromise?: Promise; - - constructor( - container: StartedTestContainer, - public readonly baseUrl: string, - private readonly args: string[], - ) { - super(container); - } - - public async getAdminToken(): Promise { - if (this.adminTokenPromise === undefined) { - this.adminTokenPromise = this.registerUserInternal( - "admin", - "totalyinsecureadminpassword", - undefined, - true, - ).then((res) => res.accessToken); - } - return this.adminTokenPromise; - } - - private async manage(cmd: string, ...args: string[]): Promise { - const result = await this.exec(["mas-cli", "manage", cmd, ...this.args, ...args]); - if (result.exitCode !== 0) { - throw new Error(`Failed mas-cli manage ${cmd}: ${result.output}`); - } - return result; - } - - private async manageRegisterUser( - username: string, - password: string, - displayName?: string, - admin = false, - ): Promise { - const args: string[] = []; - if (admin) args.push("-a"); - const result = await this.manage( - "register-user", - ...args, - "-y", - "-p", - password, - "-d", - displayName ?? "", - username, - ); - - const registerLines = result.output.trim().split("\n"); - const userId = registerLines - .find((line) => line.includes("Matrix ID: ")) - ?.split(": ") - .pop(); - - if (!userId) { - throw new Error(`Failed to register user: ${result.output}`); - } - - return userId; - } - - private async manageIssueCompatibilityToken( - username: string, - admin = false, - ): Promise<{ accessToken: string; deviceId: string }> { - const args: string[] = []; - if (admin) args.push("--yes-i-want-to-grant-synapse-admin-privileges"); - const result = await this.manage("issue-compatibility-token", ...args, username); - - const parts = result.output.trim().split(/\s+/); - const accessToken = parts.find((part) => part.startsWith("mct_")); - const deviceId = parts.find((part) => part.startsWith("compat_session.device="))?.split("=")[1]; - - if (!accessToken || !deviceId) { - throw new Error(`Failed to issue compatibility token: ${result.output}`); - } - - return { accessToken, deviceId }; - } - - private async registerUserInternal( - username: string, - password: string, - displayName?: string, - admin = false, - ): Promise { - const userId = await this.manageRegisterUser(username, password, displayName, admin); - const { deviceId, accessToken } = await this.manageIssueCompatibilityToken(username, admin); - - return { - userId, - accessToken, - deviceId, - homeServer: userId.slice(1).split(":").slice(1).join(":"), - displayName, - username, - password, - }; - } - - public async registerUser(username: string, password: string, displayName?: string): Promise { - return this.registerUserInternal(username, password, displayName, false); - } - - public async setThreepid(username: string, medium: string, address: string): Promise { - if (medium !== "email") { - throw new Error("Only email threepids are supported by MAS"); - } - - await this.manage("add-email", username, address); - } -} diff --git a/playwright/testcontainers/synapse.ts b/playwright/testcontainers/synapse.ts index 7ee83c3fe6..2e841604be 100644 --- a/playwright/testcontainers/synapse.ts +++ b/playwright/testcontainers/synapse.ts @@ -1,390 +1,20 @@ /* -Copyright 2024 New Vector Ltd. +Copyright 2024-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 { AbstractStartedContainer, GenericContainer, RestartOptions, StartedTestContainer, Wait } from "testcontainers"; -import { APIRequestContext, TestInfo } from "@playwright/test"; -import crypto from "node:crypto"; -import * as YAML from "yaml"; -import { set } from "lodash"; +import { SynapseContainer as BaseSynapseContainer } from "@element-hq/element-web-playwright-common/lib/testcontainers"; -import { getFreePort } from "../plugins/utils/port.ts"; -import { randB64Bytes } from "../plugins/utils/rand.ts"; -import { Credentials } from "../plugins/homeserver"; -import { deepCopy } from "../plugins/utils/object.ts"; -import { HomeserverContainer, StartedHomeserverContainer } from "./HomeserverContainer.ts"; -import { StartedMatrixAuthenticationServiceContainer } from "./mas.ts"; -import { Api, ClientServerApi, Verb } from "./utils.ts"; +const TAG = "develop@sha256:2ea87d45fc7ff3327c671b3b4447e6b2032d4f5ca07d62d8aef0d900e105c2f4"; -const TAG = "develop@sha256:b69222d98abe9625d46f5d3cb01683d5dc173ae339215297138392cfeec935d9"; - -const DEFAULT_CONFIG = { - server_name: "localhost", - public_baseurl: "", // set by start method - pid_file: "/homeserver.pid", - web_client: false, - soft_file_limit: 0, - // Needs to be configured to log to the console like a good docker process - log_config: "/data/log.config", - listeners: [ - { - // Listener is always port 8008 (configured in the container) - port: 8008, - tls: false, - bind_addresses: ["::"], - type: "http", - x_forwarded: true, - resources: [ - { - names: ["client"], - compress: false, - }, - ], - }, - ], - database: { - // An sqlite in-memory database is fast & automatically wipes each time - name: "sqlite3", - args: { - database: ":memory:", - }, - }, - rc_messages_per_second: 10000, - rc_message_burst_count: 10000, - rc_registration: { - per_second: 10000, - burst_count: 10000, - }, - rc_joins: { - local: { - per_second: 9999, - burst_count: 9999, - }, - remote: { - per_second: 9999, - burst_count: 9999, - }, - }, - rc_joins_per_room: { - per_second: 9999, - burst_count: 9999, - }, - rc_3pid_validation: { - per_second: 1000, - burst_count: 1000, - }, - rc_invites: { - per_room: { - per_second: 1000, - burst_count: 1000, - }, - per_user: { - per_second: 1000, - burst_count: 1000, - }, - }, - rc_login: { - address: { - per_second: 10000, - burst_count: 10000, - }, - account: { - per_second: 10000, - burst_count: 10000, - }, - failed_attempts: { - per_second: 10000, - burst_count: 10000, - }, - }, - media_store_path: "/tmp/media_store", - max_upload_size: "50M", - max_image_pixels: "32M", - dynamic_thumbnails: false, - enable_registration: true, - enable_registration_without_verification: true, - disable_msisdn_registration: false, - registrations_require_3pid: [], - enable_metrics: false, - report_stats: false, - // These placeholders will be replaced with values generated at start - registration_shared_secret: "secret", - macaroon_secret_key: "secret", - form_secret: "secret", - // Signing key must be here: it will be generated to this file - signing_key_path: "/data/localhost.signing.key", - trusted_key_servers: [], - password_config: { - enabled: true, - }, - ui_auth: {}, - background_updates: { - // Inhibit background updates as this Synapse isn't long-lived - min_batch_size: 100000, - sleep_duration_ms: 100000, - }, - enable_authenticated_media: true, - email: undefined, - user_consent: undefined, - server_notices: undefined, - allow_guest_access: false, - experimental_features: {}, - oidc_providers: [], - serve_server_wellknown: true, - presence: { - enabled: true, - include_offline_users_on_sync: true, - }, -}; - -export type SynapseConfigOptions = Partial; - -export class SynapseContainer extends GenericContainer implements HomeserverContainer { - private config: typeof DEFAULT_CONFIG; - private mas?: StartedMatrixAuthenticationServiceContainer; - - constructor() { +/** + * SynapseContainer which freezes the docker digest to stabilise tests, + * updated periodically by the `playwright-image-updates.yaml` workflow. + */ +export class SynapseContainer extends BaseSynapseContainer { + public constructor() { super(`ghcr.io/element-hq/synapse:${TAG}`); - - this.config = deepCopy(DEFAULT_CONFIG); - this.config.registration_shared_secret = randB64Bytes(16); - this.config.macaroon_secret_key = randB64Bytes(16); - this.config.form_secret = randB64Bytes(16); - - const signingKey = randB64Bytes(32); - this.withWaitStrategy(Wait.forHttp("/health", 8008)).withCopyContentToContainer([ - { target: this.config.signing_key_path, content: `ed25519 x ${signingKey}` }, - { - target: this.config.log_config, - content: YAML.stringify({ - version: 1, - formatters: { - precise: { - format: "%(asctime)s - %(name)s - %(lineno)d - %(levelname)s - %(request)s - %(message)s", - }, - }, - handlers: { - console: { - class: "logging.StreamHandler", - formatter: "precise", - }, - }, - loggers: { - "synapse.storage.SQL": { - level: "DEBUG", - }, - "twisted": { - handlers: ["console"], - propagate: false, - }, - }, - root: { - level: "DEBUG", - handlers: ["console"], - }, - disable_existing_loggers: false, - }), - }, - ]); - } - - public withConfigField(key: string, value: any): this { - set(this.config, key, value); - return this; - } - - public withConfig(config: Partial): this { - this.config = { - ...this.config, - ...config, - }; - return this; - } - - public withMatrixAuthenticationService(mas?: StartedMatrixAuthenticationServiceContainer): this { - this.mas = mas; - return this; - } - - public override async start(): Promise { - // Synapse config public_baseurl needs to know what URL it'll be accessed from, so we have to map the port manually - const port = await getFreePort(); - - this.withExposedPorts({ - container: 8008, - host: port, - }) - .withConfig({ - public_baseurl: `http://localhost:${port}`, - }) - .withCopyContentToContainer([ - { - target: "/data/homeserver.yaml", - content: YAML.stringify(this.config), - }, - ]); - - const container = await super.start(); - const baseUrl = `http://localhost:${port}`; - if (this.mas) { - return new StartedSynapseWithMasContainer( - container, - baseUrl, - this.config.registration_shared_secret, - this.mas, - ); - } - - return new StartedSynapseContainer(container, baseUrl, this.config.registration_shared_secret); - } -} - -export class StartedSynapseContainer extends AbstractStartedContainer implements StartedHomeserverContainer { - protected adminTokenPromise?: Promise; - protected _request?: APIRequestContext; - protected readonly adminApi: Api; - public readonly csApi: ClientServerApi; - - constructor( - container: StartedTestContainer, - public readonly baseUrl: string, - private readonly registrationSharedSecret: string, - ) { - super(container); - this.adminApi = new Api(`${this.baseUrl}/_synapse/admin`); - this.csApi = new ClientServerApi(this.baseUrl); - } - - public restart(options?: Partial): Promise { - this.adminTokenPromise = undefined; - return super.restart(options); - } - - public setRequest(request: APIRequestContext): void { - this._request = request; - this.csApi.setRequest(request); - this.adminApi.setRequest(request); - } - - public async onTestFinished(testInfo: TestInfo): Promise { - // Clean up the server to prevent rooms leaking between tests - await this.deletePublicRooms(); - } - - protected async deletePublicRooms(): Promise { - const token = await this.getAdminToken(); - // We hide the rooms from the room directory to save time between tests and for portability between homeservers - const { chunk: rooms } = await this.csApi.request<{ - chunk: { room_id: string }[]; - }>("GET", "/v3/publicRooms", token, {}); - await Promise.all( - rooms.map((room) => - this.csApi.request("PUT", `/v3/directory/list/room/${room.room_id}`, token, { visibility: "private" }), - ), - ); - } - - private async registerUserInternal( - username: string, - password: string, - displayName?: string, - admin = false, - ): Promise { - const path = "/v1/register"; - const { nonce } = await this.adminApi.request<{ nonce: string }>("GET", path, undefined, {}); - const mac = crypto - .createHmac("sha1", this.registrationSharedSecret) - .update(`${nonce}\0${username}\0${password}\0${admin ? "" : "not"}admin`) - .digest("hex"); - const data = await this.adminApi.request<{ - home_server: string; - access_token: string; - user_id: string; - device_id: string; - }>("POST", path, undefined, { - nonce, - username, - password, - mac, - admin, - displayname: displayName, - }); - - return { - homeServer: data.home_server, - accessToken: data.access_token, - userId: data.user_id, - deviceId: data.device_id, - password, - displayName, - username, - }; - } - - protected async getAdminToken(): Promise { - if (this.adminTokenPromise === undefined) { - this.adminTokenPromise = this.registerUserInternal( - "admin", - "totalyinsecureadminpassword", - undefined, - true, - ).then((res) => res.accessToken); - } - return this.adminTokenPromise; - } - - private async adminRequest(verb: "GET", path: string, data?: never): Promise; - private async adminRequest(verb: Verb, path: string, data?: object): Promise; - private async adminRequest(verb: Verb, path: string, data?: object): Promise { - const adminToken = await this.getAdminToken(); - return this.adminApi.request(verb, path, adminToken, data); - } - - public registerUser(username: string, password: string, displayName?: string): Promise { - return this.registerUserInternal(username, password, displayName, false); - } - - public async loginUser(userId: string, password: string): Promise { - return this.csApi.loginUser(userId, password); - } - - public async setThreepid(userId: string, medium: string, address: string): Promise { - await this.adminRequest("PUT", `/v2/users/${userId}`, { - threepids: [ - { - medium, - address, - }, - ], - }); - } -} - -export class StartedSynapseWithMasContainer extends StartedSynapseContainer { - constructor( - container: StartedTestContainer, - baseUrl: string, - registrationSharedSecret: string, - private readonly mas: StartedMatrixAuthenticationServiceContainer, - ) { - super(container, baseUrl, registrationSharedSecret); - } - - protected async getAdminToken(): Promise { - if (this.adminTokenPromise === undefined) { - this.adminTokenPromise = this.mas.getAdminToken(); - } - return this.adminTokenPromise; - } - - public registerUser(username: string, password: string, displayName?: string): Promise { - return this.mas.registerUser(username, password, displayName); - } - - public async setThreepid(userId: string, medium: string, address: string): Promise { - return this.mas.setThreepid(userId, medium, address); } } diff --git a/playwright/testcontainers/utils.ts b/playwright/testcontainers/utils.ts deleted file mode 100644 index f4fe7f6d31..0000000000 --- a/playwright/testcontainers/utils.ts +++ /dev/null @@ -1,111 +0,0 @@ -/* -Copyright 2024 New Vector Ltd. - -SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial -Please see LICENSE files in the repository root for full details. -*/ - -import { APIRequestContext, TestInfo } from "@playwright/test"; -import { Readable } from "stream"; -import stripAnsi from "strip-ansi"; - -import { Credentials } from "../plugins/homeserver"; - -export class ContainerLogger { - private logs: Record = {}; - - public getConsumer(container: string) { - this.logs[container] = ""; - return (stream: Readable) => { - stream.on("data", (chunk) => { - this.logs[container] += chunk.toString(); - }); - stream.on("err", (chunk) => { - this.logs[container] += "ERR " + chunk.toString(); - }); - }; - } - - public async testStarted(testInfo: TestInfo) { - for (const container in this.logs) { - this.logs[container] = ""; - } - } - - public async testFinished(testInfo: TestInfo) { - if (testInfo.status !== "passed") { - for (const container in this.logs) { - await testInfo.attach(container, { - body: stripAnsi(this.logs[container]), - contentType: "text/plain", - }); - } - } - } -} - -export type Verb = "GET" | "POST" | "PUT" | "DELETE"; - -export class Api { - private _request?: APIRequestContext; - - constructor(private readonly baseUrl: string) {} - - public setRequest(request: APIRequestContext): void { - this._request = request; - } - - public async request(verb: "GET", path: string, token?: string, data?: never): Promise; - public async request(verb: Verb, path: string, token?: string, data?: object): Promise; - public async request(verb: Verb, path: string, token?: string, data?: object): Promise { - const url = `${this.baseUrl}${path}`; - const res = await this._request.fetch(url, { - data, - method: verb, - headers: token - ? { - Authorization: `Bearer ${token}`, - } - : undefined, - }); - - if (!res.ok()) { - throw new Error( - `Request to ${url} failed with status ${res.status()}: ${JSON.stringify(await res.json())}`, - ); - } - - return res.json(); - } -} - -export class ClientServerApi extends Api { - constructor(baseUrl: string) { - super(`${baseUrl}/_matrix/client`); - } - - public async loginUser(userId: string, password: string): Promise { - const json = await this.request<{ - access_token: string; - user_id: string; - device_id: string; - home_server: string; - }>("POST", "/v3/login", undefined, { - type: "m.login.password", - identifier: { - type: "m.id.user", - user: userId, - }, - password: password, - }); - - return { - password, - accessToken: json.access_token, - userId: json.user_id, - deviceId: json.device_id, - homeServer: json.home_server, - username: userId.slice(1).split(":")[0], - }; - } -} diff --git a/res/css/_common.pcss b/res/css/_common.pcss index ac7c36daa5..75180013f6 100644 --- a/res/css/_common.pcss +++ b/res/css/_common.pcss @@ -589,16 +589,21 @@ legend { * in the app look the same by being AccessibleButtons, or possibly by having explict button classes. * We should go through and have one consistent set of styles for buttons throughout the app. * For now, I am duplicating the selectors here for mx_Dialog and mx_DialogButtons. - * - * Elements that should not be styled like a dialog button are mentioned in a :not() pseudo-class. - * For the widest browser support, we use multiple :not pseudo-classes instead of :not(.a, .b). */ .mx_Dialog - button:not(.mx_Dialog_nonDialogButton):not([class|="maplibregl"]):not(.mx_AccessibleButton):not( - .mx_UserProfileSettings button - ):not(.mx_ThemeChoicePanel_CustomTheme button):not(.mx_UnpinAllDialog button):not(.mx_ShareDialog button), + button:not( + .mx_EncryptionUserSettingsTab button, + .mx_UserProfileSettings button, + .mx_ShareDialog button, + .mx_UnpinAllDialog button, + .mx_ThemeChoicePanel_CustomTheme button, + .mx_Dialog_nonDialogButton, + .mx_AccessibleButton, + .mx_IdentityServerPicker button, + [class|="maplibregl"] + ), +.mx_Dialog_buttons button:not(.mx_Dialog_nonDialogButton, .mx_AccessibleButton), .mx_Dialog input[type="submit"], -.mx_Dialog_buttons button:not(.mx_Dialog_nonDialogButton):not(.mx_AccessibleButton), .mx_Dialog_buttons input[type="submit"] { @mixin mx_DialogButton; margin-left: 0px; @@ -614,30 +619,48 @@ legend { } .mx_Dialog - button:not(.mx_Dialog_nonDialogButton):not([class|="maplibregl"]):not(.mx_AccessibleButton):not( - .mx_UserProfileSettings button - ):not(.mx_ThemeChoicePanel_CustomTheme button):not(.mx_UnpinAllDialog button):not( - .mx_ShareDialog button + button:not( + .mx_Dialog_nonDialogButton, + [class|="maplibregl"], + .mx_AccessibleButton, + .mx_UserProfileSettings button, + .mx_ThemeChoicePanel_CustomTheme button, + .mx_UnpinAllDialog button, + .mx_ShareDialog button, + .mx_EncryptionUserSettingsTab button ):last-child { margin-right: 0px; } .mx_Dialog - button:not(.mx_Dialog_nonDialogButton):not([class|="maplibregl"]):not(.mx_AccessibleButton):not( - .mx_UserProfileSettings button - ):not(.mx_ThemeChoicePanel_CustomTheme button):not(.mx_UnpinAllDialog button):not(.mx_ShareDialog button):focus, + button:not( + .mx_Dialog_nonDialogButton, + [class|="maplibregl"], + .mx_AccessibleButton, + .mx_UserProfileSettings button, + .mx_ThemeChoicePanel_CustomTheme button, + .mx_UnpinAllDialog button, + .mx_ShareDialog button, + .mx_EncryptionUserSettingsTab button + ):focus, .mx_Dialog input[type="submit"]:focus, -.mx_Dialog_buttons button:not(.mx_Dialog_nonDialogButton):not(.mx_AccessibleButton):focus, +.mx_Dialog_buttons button:not(.mx_Dialog_nonDialogButton, .mx_AccessibleButton):focus, .mx_Dialog_buttons input[type="submit"]:focus { filter: brightness($focus-brightness); } -.mx_Dialog button.mx_Dialog_primary:not(.mx_Dialog_nonDialogButton):not([class|="maplibregl"]), +.mx_Dialog button.mx_Dialog_primary:not(.mx_Dialog_nonDialogButton, [class|="maplibregl"]), .mx_Dialog input[type="submit"].mx_Dialog_primary, .mx_Dialog_buttons - button.mx_Dialog_primary:not(.mx_Dialog_nonDialogButton):not(.mx_AccessibleButton):not( - .mx_UserProfileSettings button - ):not(.mx_ThemeChoicePanel_CustomTheme button):not(.mx_UnpinAllDialog button):not(.mx_ShareDialog button), + button:not( + .mx_Dialog_nonDialogButton, + .mx_AccessibleButton, + .mx_UserProfileSettings button, + .mx_ThemeChoicePanel_CustomTheme button, + .mx_UnpinAllDialog button, + .mx_ShareDialog button, + .mx_EncryptionUserSettingsTab button + ), .mx_Dialog_buttons input[type="submit"].mx_Dialog_primary { color: var(--cpd-color-text-on-solid-primary); background-color: var(--cpd-color-bg-action-primary-rest); @@ -645,30 +668,43 @@ legend { min-width: 156px; } -.mx_Dialog button.danger:not(.mx_Dialog_nonDialogButton):not([class|="maplibregl"]), +.mx_Dialog button.danger:not(.mx_Dialog_nonDialogButton, [class|="maplibregl"]), .mx_Dialog input[type="submit"].danger, .mx_Dialog_buttons - button.danger:not(.mx_Dialog_nonDialogButton):not(.mx_AccessibleButton):not(.mx_UserProfileSettings button):not( - .mx_ThemeChoicePanel_CustomTheme button - ):not(.mx_UnpinAllDialog button):not(.mx_ShareDialog button), + button.danger:not( + .mx_Dialog_nonDialogButton, + .mx_AccessibleButton, + .mx_UserProfileSettings button, + .mx_ThemeChoicePanel_CustomTheme button, + .mx_UnpinAllDialog button, + .mx_ShareDialog button, + .mx_EncryptionUserSettingsTab button + ), .mx_Dialog_buttons input[type="submit"].danger { background-color: var(--cpd-color-bg-critical-primary); border: solid 1px var(--cpd-color-bg-critical-primary); color: var(--cpd-color-text-on-solid-primary); } -.mx_Dialog button.warning:not(.mx_Dialog_nonDialogButton):not([class|="maplibregl"]), +.mx_Dialog button.warning:not(.mx_Dialog_nonDialogButton, [class|="maplibregl"]), .mx_Dialog input[type="submit"].warning { border: solid 1px var(--cpd-color-border-critical-subtle); color: var(--cpd-color-text-critical-primary); } .mx_Dialog - button:not(.mx_Dialog_nonDialogButton):not([class|="maplibregl"]):not(.mx_AccessibleButton):not( - .mx_UserProfileSettings button - ):not(.mx_ThemeChoicePanel_CustomTheme button):not(.mx_UnpinAllDialog button):not(.mx_ShareDialog button):disabled, + button:not( + .mx_Dialog_nonDialogButton, + [class|="maplibregl"], + .mx_AccessibleButton, + .mx_UserProfileSettings button, + .mx_ThemeChoicePanel_CustomTheme button, + .mx_UnpinAllDialog button, + .mx_ShareDialog button, + .mx_EncryptionUserSettingsTab button + ):disabled, .mx_Dialog input[type="submit"]:disabled, -.mx_Dialog_buttons button:not(.mx_Dialog_nonDialogButton):not(.mx_AccessibleButton):disabled, +.mx_Dialog_buttons button:not(.mx_Dialog_nonDialogButton, .mx_AccessibleButton):disabled, .mx_Dialog_buttons input[type="submit"]:disabled { background-color: $light-fg-color; border: solid 1px $light-fg-color; diff --git a/res/css/_components.pcss b/res/css/_components.pcss index b966d62ddd..1b4dc79296 100644 --- a/res/css/_components.pcss +++ b/res/css/_components.pcss @@ -48,6 +48,7 @@ @import "./components/views/settings/devices/_FilteredDeviceListHeader.pcss"; @import "./components/views/settings/devices/_SecurityRecommendations.pcss"; @import "./components/views/settings/devices/_SelectableDeviceTile.pcss"; +@import "./components/views/settings/encryption/_KeyStoragePanel.pcss"; @import "./components/views/settings/shared/_SettingsSubsection.pcss"; @import "./components/views/settings/shared/_SettingsSubsectionHeading.pcss"; @import "./components/views/spaces/_QuickThemeSwitcher.pcss"; @@ -126,7 +127,6 @@ @import "./views/context_menus/_RoomNotificationContextMenu.pcss"; @import "./views/dialogs/_AddExistingToSpaceDialog.pcss"; @import "./views/dialogs/_AnalyticsLearnMoreDialog.pcss"; -@import "./views/dialogs/_AppDownloadDialog.pcss"; @import "./views/dialogs/_BugReportDialog.pcss"; @import "./views/dialogs/_BulkRedactDialog.pcss"; @import "./views/dialogs/_ChangelogDialog.pcss"; @@ -135,6 +135,7 @@ @import "./views/dialogs/_ConfirmUserActionDialog.pcss"; @import "./views/dialogs/_CreateRoomDialog.pcss"; @import "./views/dialogs/_CreateSubspaceDialog.pcss"; +@import "./views/dialogs/_Crypto.pcss"; @import "./views/dialogs/_DeactivateAccountDialog.pcss"; @import "./views/dialogs/_DevtoolsDialog.pcss"; @import "./views/dialogs/_ExportDialog.pcss"; @@ -217,8 +218,6 @@ @import "./views/elements/_TagComposer.pcss"; @import "./views/elements/_TextWithTooltip.pcss"; @import "./views/elements/_ToggleSwitch.pcss"; -@import "./views/elements/_UseCaseSelection.pcss"; -@import "./views/elements/_UseCaseSelectionButton.pcss"; @import "./views/elements/_Validation.pcss"; @import "./views/emojipicker/_EmojiPicker.pcss"; @import "./views/location/_LocationPicker.pcss"; @@ -271,6 +270,13 @@ @import "./views/right_panel/_VerificationPanel.pcss"; @import "./views/right_panel/_WidgetCard.pcss"; @import "./views/room_settings/_AliasSettings.pcss"; +@import "./views/rooms/RoomListPanel/_RoomList.pcss"; +@import "./views/rooms/RoomListPanel/_RoomListHeaderView.pcss"; +@import "./views/rooms/RoomListPanel/_RoomListItemMenuView.pcss"; +@import "./views/rooms/RoomListPanel/_RoomListItemView.pcss"; +@import "./views/rooms/RoomListPanel/_RoomListPanel.pcss"; +@import "./views/rooms/RoomListPanel/_RoomListPrimaryFilters.pcss"; +@import "./views/rooms/RoomListPanel/_RoomListSearch.pcss"; @import "./views/rooms/_AppsDrawer.pcss"; @import "./views/rooms/_Autocomplete.pcss"; @import "./views/rooms/_AuxPanel.pcss"; @@ -286,7 +292,10 @@ @import "./views/rooms/_EventTile.pcss"; @import "./views/rooms/_HistoryTile.pcss"; @import "./views/rooms/_IRCLayout.pcss"; +@import "./views/rooms/_InvitedIconView.pcss"; @import "./views/rooms/_JumpToBottomButton.pcss"; +@import "./views/rooms/_LegacyRoomList.pcss"; +@import "./views/rooms/_LegacyRoomListHeader.pcss"; @import "./views/rooms/_LinkPreviewGroup.pcss"; @import "./views/rooms/_LinkPreviewWidget.pcss"; @import "./views/rooms/_LiveContentSummary.pcss"; @@ -310,8 +319,6 @@ @import "./views/rooms/_RoomHeader.pcss"; @import "./views/rooms/_RoomInfoLine.pcss"; @import "./views/rooms/_RoomKnocksBar.pcss"; -@import "./views/rooms/_RoomList.pcss"; -@import "./views/rooms/_RoomListHeader.pcss"; @import "./views/rooms/_RoomPreviewBar.pcss"; @import "./views/rooms/_RoomPreviewCard.pcss"; @import "./views/rooms/_RoomSearchAuxPanel.pcss"; @@ -347,13 +354,20 @@ @import "./views/settings/_PowerLevelSelector.pcss"; @import "./views/settings/_RoomProfileSettings.pcss"; @import "./views/settings/_SecureBackupPanel.pcss"; -@import "./views/settings/_SetIdServer.pcss"; @import "./views/settings/_SetIntegrationManager.pcss"; @import "./views/settings/_SettingsFieldset.pcss"; +@import "./views/settings/_SettingsHeader.pcss"; +@import "./views/settings/_SettingsSubheader.pcss"; @import "./views/settings/_SpellCheckLanguages.pcss"; @import "./views/settings/_ThemeChoicePanel.pcss"; @import "./views/settings/_UpdateCheckButton.pcss"; @import "./views/settings/_UserProfileSettings.pcss"; +@import "./views/settings/encryption/_AdvancedPanel.pcss"; +@import "./views/settings/encryption/_ChangeRecoveryKey.pcss"; +@import "./views/settings/encryption/_EncryptionCard.pcss"; +@import "./views/settings/encryption/_EncryptionCardEmphasisedContent.pcss"; +@import "./views/settings/encryption/_RecoveryPanelOutOfSync.pcss"; +@import "./views/settings/encryption/_ResetIdentityPanel.pcss"; @import "./views/settings/tabs/_SettingsBanner.pcss"; @import "./views/settings/tabs/_SettingsIndent.pcss"; @import "./views/settings/tabs/_SettingsSection.pcss"; @@ -379,11 +393,6 @@ @import "./views/toasts/_IncomingLegacyCallToast.pcss"; @import "./views/toasts/_NonUrgentEchoFailureToast.pcss"; @import "./views/typography/_Heading.pcss"; -@import "./views/user-onboarding/_UserOnboardingButton.pcss"; -@import "./views/user-onboarding/_UserOnboardingHeader.pcss"; -@import "./views/user-onboarding/_UserOnboardingList.pcss"; -@import "./views/user-onboarding/_UserOnboardingPage.pcss"; -@import "./views/user-onboarding/_UserOnboardingTask.pcss"; @import "./views/verification/_VerificationShowSas.pcss"; @import "./views/voip/LegacyCallView/_LegacyCallViewButtons.pcss"; @import "./views/voip/_CallDuration.pcss"; diff --git a/res/css/components/views/settings/encryption/_KeyStoragePanel.pcss b/res/css/components/views/settings/encryption/_KeyStoragePanel.pcss new file mode 100644 index 0000000000..34a79db9cd --- /dev/null +++ b/res/css/components/views/settings/encryption/_KeyStoragePanel.pcss @@ -0,0 +1,10 @@ +/* + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial + * Please see LICENSE files in the repository root for full details. + */ + +.mx_KeyStoragePanel_toggleRow { + flex-direction: row; +} diff --git a/res/css/components/views/utils/_Flex.pcss b/res/css/components/views/utils/_Flex.pcss index a7f3688466..9cfa6424f0 100644 --- a/res/css/components/views/utils/_Flex.pcss +++ b/res/css/components/views/utils/_Flex.pcss @@ -12,4 +12,5 @@ Please see LICENSE files in the repository root for full details. align-items: var(--mx-flex-align, unset); justify-content: var(--mx-flex-justify, unset); gap: var(--mx-flex-gap, unset); + flex-wrap: var(--mx-flex-wrap, unset); } diff --git a/res/css/structures/ErrorView.pcss b/res/css/structures/ErrorView.pcss index cf09ac02af..ddc510e188 100644 --- a/res/css/structures/ErrorView.pcss +++ b/res/css/structures/ErrorView.pcss @@ -10,8 +10,9 @@ Please see LICENSE files in the repository root for full details. --cpd-separator-inset: calc(50% - (var(--width) / 2)); --cpd-separator-spacing: var(--cpd-space-8x); - font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif, - "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol"; + font-family: + -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif, "Apple Color Emoji", + "Segoe UI Emoji", "Segoe UI Symbol"; text-align: center; color: var(--cpd-color-text-primary); width: 100%; diff --git a/res/css/structures/_LeftPanel.pcss b/res/css/structures/_LeftPanel.pcss index cf2845b173..c76fd5da02 100644 --- a/res/css/structures/_LeftPanel.pcss +++ b/res/css/structures/_LeftPanel.pcss @@ -113,7 +113,7 @@ Please see LICENSE files in the repository root for full details. display: flex; align-items: center; - & + .mx_RoomListHeader { + & + .mx_LegacyRoomListHeader { margin-top: 12px; } @@ -180,7 +180,7 @@ Please see LICENSE files in the repository root for full details. } } - .mx_RoomListHeader:first-child { + .mx_LegacyRoomListHeader:first-child { margin-top: 12px; } diff --git a/res/css/structures/_QuickSettingsButton.pcss b/res/css/structures/_QuickSettingsButton.pcss index 44e0ded064..52aa2377ac 100644 --- a/res/css/structures/_QuickSettingsButton.pcss +++ b/res/css/structures/_QuickSettingsButton.pcss @@ -104,6 +104,12 @@ Please see LICENSE files in the repository root for full details. } } +.mx_QuickSettingsButton_ContextMenuWrapper_new_room_list { + .mx_QuickThemeSwitcher { + margin-top: var(--cpd-space-2x); + } +} + .mx_QuickSettingsButton_icon { // TODO remove when all icons have fill=currentColor * { diff --git a/res/css/structures/_RoomView.pcss b/res/css/structures/_RoomView.pcss index 478bf548ca..b7ab171615 100644 --- a/res/css/structures/_RoomView.pcss +++ b/res/css/structures/_RoomView.pcss @@ -35,6 +35,7 @@ Please see LICENSE files in the repository root for full details. width: 100%; flex: 0 0 auto; margin-right: 2px; + padding-bottom: 1em; } } diff --git a/res/css/structures/_SpaceHierarchy.pcss b/res/css/structures/_SpaceHierarchy.pcss index 31dad9413f..02f39a0b72 100644 --- a/res/css/structures/_SpaceHierarchy.pcss +++ b/res/css/structures/_SpaceHierarchy.pcss @@ -77,7 +77,7 @@ Please see LICENSE files in the repository root for full details. height: 16px; width: 16px; left: 0; - background-image: url("@vector-im/compound-design-tokens/icons/error.svg"); + background-image: url("@vector-im/compound-design-tokens/icons/error-solid.svg"); background-size: cover; background-repeat: no-repeat; } diff --git a/res/css/structures/_SpacePanel.pcss b/res/css/structures/_SpacePanel.pcss index 31dab1ee8d..664d781829 100644 --- a/res/css/structures/_SpacePanel.pcss +++ b/res/css/structures/_SpacePanel.pcss @@ -365,7 +365,8 @@ Please see LICENSE files in the repository root for full details. Note the top fade is much smaller because the spaces start close to the top, so otherwise a large gradient suddenly appears when you scroll down. */ - mask-image: linear-gradient(to bottom, transparent, black 16px), + mask-image: + linear-gradient(to bottom, transparent, black 16px), linear-gradient( to top, transparent, diff --git a/res/css/structures/_SplashPage.pcss b/res/css/structures/_SplashPage.pcss index 6f976ba575..26eec65a93 100644 --- a/res/css/structures/_SplashPage.pcss +++ b/res/css/structures/_SplashPage.pcss @@ -16,7 +16,8 @@ Please see LICENSE files in the repository root for full details. position: absolute; z-index: -1; opacity: 0.6; - background-image: radial-gradient( + 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% diff --git a/res/css/structures/_UserMenu.pcss b/res/css/structures/_UserMenu.pcss index 50e93d2369..2f4099c893 100644 --- a/res/css/structures/_UserMenu.pcss +++ b/res/css/structures/_UserMenu.pcss @@ -37,27 +37,6 @@ Please see LICENSE files in the repository root for full details. line-height: $font-24px; margin-left: 10px; } - - .mx_UserMenu_dndBadge { - position: absolute; - bottom: -2px; - right: -7px; - width: 16px; - height: 16px; - border-radius: 50%; - - &::before { - content: ""; - width: 16px; - height: 16px; - position: absolute; - mask-position: center; - mask-size: contain; - mask-repeat: no-repeat; - background-color: $alert; - mask-image: url("$(res)/img/element-icons/roomlist/dnd.svg"); - } - } } .mx_IconizedContextMenu { @@ -158,14 +137,6 @@ Please see LICENSE files in the repository root for full details. mask-image: url("@vector-im/compound-design-tokens/icons/home-solid.svg"); } - .mx_UserMenu_iconDnd::before { - mask-image: url("$(res)/img/element-icons/roomlist/dnd.svg"); - } - - .mx_UserMenu_iconDndOff::before { - mask-image: url("$(res)/img/element-icons/roomlist/dnd-cross.svg"); - } - .mx_UserMenu_iconBell::before { mask-image: url("$(res)/img/element-icons/notifications.svg"); } diff --git a/res/css/views/context_menus/_MessageContextMenu.pcss b/res/css/views/context_menus/_MessageContextMenu.pcss index f365c4a293..9fc454f328 100644 --- a/res/css/views/context_menus/_MessageContextMenu.pcss +++ b/res/css/views/context_menus/_MessageContextMenu.pcss @@ -29,7 +29,7 @@ Please see LICENSE files in the repository root for full details. } .mx_MessageContextMenu_iconReport::before { - mask-image: url("@vector-im/compound-design-tokens/icons/error.svg"); + mask-image: url("@vector-im/compound-design-tokens/icons/error-solid.svg"); } .mx_MessageContextMenu_iconLink::before { diff --git a/res/css/views/dialogs/_AppDownloadDialog.pcss b/res/css/views/dialogs/_AppDownloadDialog.pcss deleted file mode 100644 index e0591ed7e9..0000000000 --- a/res/css/views/dialogs/_AppDownloadDialog.pcss +++ /dev/null @@ -1,77 +0,0 @@ -.mx_AppDownloadDialog { - display: flex; - flex-direction: column; - gap: $spacing-32; - color: $primary-content; - - &.mx_Dialog_fixedWidth { - width: 640px; - } - - .mx_AppDownloadDialog_desktop { - display: flex; - flex-direction: column; - align-items: center; - gap: $spacing-16; - } - - .mx_AppDownloadDialog_mobile { - display: flex; - flex-direction: row; - gap: $spacing-24; - - .mx_AppDownloadDialog_app { - display: flex; - flex-direction: column; - flex-grow: 1; - flex-basis: 50%; - align-items: center; - gap: $spacing-16; - - .mx_QRCode { - /* intentionally hardcoded color to ensure the QR code is readable in any situation */ - background: #ffffff; - - padding: $spacing-24; - border: 1px solid $quinary-content; - border-radius: 4px; - align-self: stretch; - display: flex; - align-items: center; - flex-direction: column; - - .mx_VerificationQRCode { - height: 144px; - width: 144px; - image-rendering: pixelated; - border-radius: 0; - } - } - - .mx_AppDownloadDialog_info { - font-size: $font-12px; - color: $tertiary-content; - } - - .mx_AppDownloadDialog_links { - display: flex; - flex-direction: row; - gap: $spacing-8; - - .mx_AccessibleButton { - svg { - height: 40px; - } - } - } - } - } - - .mx_AppDownloadDialog_legal { - p { - margin: 0; - font-size: $font-12px; - color: $tertiary-content; - } - } -} diff --git a/res/css/views/dialogs/_Crypto.pcss b/res/css/views/dialogs/_Crypto.pcss new file mode 100644 index 0000000000..12d46cf75b --- /dev/null +++ b/res/css/views/dialogs/_Crypto.pcss @@ -0,0 +1,18 @@ +/* + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial + * Please see LICENSE files in the repository root for full details. + */ + +.mx_Crypto { + table { + margin: var(--cpd-space-4x) 0; + text-align: left; + border-spacing: var(--cpd-space-2x) 0; + + thead { + font: var(--cpd-font-heading-sm-semibold); + } + } +} diff --git a/res/css/views/dialogs/security/_AccessSecretStorageDialog.pcss b/res/css/views/dialogs/security/_AccessSecretStorageDialog.pcss index da71b4462b..83b9fe96b4 100644 --- a/res/css/views/dialogs/security/_AccessSecretStorageDialog.pcss +++ b/res/css/views/dialogs/security/_AccessSecretStorageDialog.pcss @@ -21,7 +21,7 @@ Please see LICENSE files in the repository root for full details. &.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.svg"); + background-image: url("@vector-im/compound-design-tokens/icons/error-solid.svg"); background-size: 24px; background-color: transparent; } @@ -120,7 +120,7 @@ Please see LICENSE files in the repository root for full details. width: 16px; left: 0; top: 2px; /* alignment */ - background-image: url("@vector-im/compound-design-tokens/icons/error.svg"); + background-image: url("@vector-im/compound-design-tokens/icons/error-solid.svg"); background-size: contain; } diff --git a/res/css/views/elements/_InfoTooltip.pcss b/res/css/views/elements/_InfoTooltip.pcss index 5229b7d9f5..a214f0bf83 100644 --- a/res/css/views/elements/_InfoTooltip.pcss +++ b/res/css/views/elements/_InfoTooltip.pcss @@ -29,5 +29,5 @@ Please see LICENSE files in the repository root for full details. } .mx_InfoTooltip_icon_warning::before { - mask-image: url("@vector-im/compound-design-tokens/icons/error.svg"); + mask-image: url("@vector-im/compound-design-tokens/icons/error-solid.svg"); } diff --git a/res/css/views/elements/_Pill.pcss b/res/css/views/elements/_Pill.pcss index 055a524c5a..d692f812a4 100644 --- a/res/css/views/elements/_Pill.pcss +++ b/res/css/views/elements/_Pill.pcss @@ -26,7 +26,8 @@ Please see LICENSE files in the repository root for full details. } &.mx_UserPill_me, - &.mx_AtRoomPill { + &.mx_AtRoomPill, + &.mx_KeywordPill { background-color: var(--cpd-color-bg-critical-primary) !important; /* To override .markdown-body */ } @@ -45,7 +46,8 @@ Please see LICENSE files in the repository root for full details. } /* We don't want to indicate clickability */ - &.mx_AtRoomPill:hover { + &.mx_AtRoomPill:hover, + &.mx_KeywordPill:hover { background-color: var(--cpd-color-bg-critical-primary) !important; /* To override .markdown-body */ cursor: unset; } diff --git a/res/css/views/elements/_UseCaseSelection.pcss b/res/css/views/elements/_UseCaseSelection.pcss deleted file mode 100644 index ec577a66bd..0000000000 --- a/res/css/views/elements/_UseCaseSelection.pcss +++ /dev/null @@ -1,122 +0,0 @@ -/* -Copyright 2024 New Vector Ltd. -Copyright 2022 The Matrix.org Foundation C.I.C. - -SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial -Please see LICENSE files in the repository root for full details. -*/ - -.mx_UseCaseSelection { - display: grid; - grid-template-rows: 1fr 1fr max-content 2fr; - height: 100%; - grid-gap: $spacing-40; - - .mx_UseCaseSelection_title { - display: flex; - flex-direction: column; - justify-content: flex-end; - - h1 { - font-weight: var(--cpd-font-weight-semibold); - font-size: $font-32px; - text-align: center; - } - } - - .mx_UseCaseSelection_info { - display: flex; - flex-direction: column; - gap: $spacing-8; - align-self: flex-end; - - h2 { - margin: 0; - font-weight: 500; - font-size: $font-24px; - text-align: center; - } - - h3 { - margin: 0; - font-weight: 400; - font-size: $font-16px; - color: $secondary-content; - text-align: center; - } - } - - .mx_UseCaseSelection_options { - display: grid; - grid-template-columns: repeat(auto-fit, 232px); - gap: $spacing-32; - align-self: stretch; - justify-content: center; - } - - .mx_UseCaseSelection_skip { - display: flex; - flex-direction: column; - align-self: flex-start; - } -} - -.mx_UseCaseSelection_slideIn { - animation-delay: 800ms; - animation-duration: 300ms; - animation-timing-function: cubic-bezier(0, 0, 0.58, 1); - animation-name: mx_UseCaseSelection_slideInLong; - animation-fill-mode: backwards; - will-change: opacity; -} - -.mx_UseCaseSelection_slideInDelayed { - animation-delay: 1500ms; - animation-duration: 300ms; - animation-timing-function: cubic-bezier(0, 0, 0.58, 1); - animation-name: mx_UseCaseSelection_slideInShort; - animation-fill-mode: backwards; - will-change: transform, opacity; -} - -.mx_UseCaseSelection_selected { - .mx_UseCaseSelection_slideIn, - .mx_UseCaseSelection_slideInDelayed { - animation-delay: 800ms; - animation-duration: 300ms; - animation-fill-mode: forwards; - animation-name: mx_UseCaseSelection_fadeOut; - will-change: opacity; - } -} - -@keyframes mx_UseCaseSelection_slideInLong { - 0% { - transform: translate(0, 20px); - opacity: 0; - } - 100% { - transform: translate(0, 0); - opacity: 1; - } -} - -@keyframes mx_UseCaseSelection_slideInShort { - 0% { - transform: translate(0, 8px); - opacity: 0; - } - 100% { - transform: translate(0, 0); - opacity: 1; - } -} - -@keyframes mx_UseCaseSelection_fadeOut { - 0% { - opacity: 1; - } - 100% { - opacity: 0; - } -} diff --git a/res/css/views/elements/_UseCaseSelectionButton.pcss b/res/css/views/elements/_UseCaseSelectionButton.pcss deleted file mode 100644 index 9393b8a53c..0000000000 --- a/res/css/views/elements/_UseCaseSelectionButton.pcss +++ /dev/null @@ -1,98 +0,0 @@ -/* -Copyright 2024 New Vector Ltd. -Copyright 2022 The Matrix.org Foundation C.I.C. - -SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial -Please see LICENSE files in the repository root for full details. -*/ - -.mx_UseCaseSelectionButton { - display: flex; - flex-direction: column; - align-items: center; - padding: $spacing-24 $spacing-16; - background: $background; - border: 1px solid $quinary-content; - border-radius: 8px; - text-align: center; - position: relative; - transition-property: box-shadow, transform; - transition-duration: 300ms; - - .mx_UseCaseSelectionButton_icon { - /* workaround: design expects a layering of two colors */ - background: linear-gradient(0deg, rgba(172, 59, 168, 0.15), rgba(172, 59, 168, 0.15)), #ffffff; - border-radius: 14px; - padding: $spacing-8; - margin-bottom: $spacing-16; - - &::before { - content: ""; - display: block; - /* this has to remain the same color across all themes, - as its background has a fixed color as well */ - background: #1e1e1e; - mask-position: center; - mask-repeat: no-repeat; - mask-size: contain; - width: 22px; - height: 22px; - } - - &.mx_UseCaseSelectionButton_messaging::before { - mask-image: url("$(res)/img/element-icons/chat-bubble.svg"); - } - - &.mx_UseCaseSelectionButton_work::before { - mask-image: url("$(res)/img/element-icons/view-community.svg"); - } - - &.mx_UseCaseSelectionButton_community::before { - mask-image: url("@vector-im/compound-design-tokens/icons/public.svg"); - mask-size: 24px; - } - } - - &:hover, - &:focus { - box-shadow: 0 $spacing-4 $spacing-8 rgba(0, 0, 0, 0.08); - transform: translate(0, -$spacing-8); - } - - .mx_UseCaseSelectionButton_selectedIcon { - right: -12px; - top: -12px; - position: absolute; - border-radius: 24px; - background: $accent; - padding: 6px; - transition-property: opacity, transform; - transition-duration: 150ms; - opacity: 0; - transform: scale(0.6); - - &::before { - content: ""; - display: block; - background: $background; - mask-position: center; - mask-repeat: no-repeat; - mask-size: contain; - width: 12px; - height: 12px; - - mask-image: url("@vector-im/compound-design-tokens/icons/check.svg"); - } - } - - &.mx_UseCaseSelectionButton_selected { - border: 2px solid $accent; - padding: calc($spacing-24 - 1px) calc($spacing-16 - 1px); - box-shadow: 0 $spacing-4 $spacing-8 rgba(0, 0, 0, 0.08); - - .mx_UseCaseSelectionButton_selectedIcon { - opacity: 1; - transform: scale(1); - } - } -} diff --git a/res/css/views/messages/_DisambiguatedProfile.pcss b/res/css/views/messages/_DisambiguatedProfile.pcss index 3f10c07abe..4758bb5407 100644 --- a/res/css/views/messages/_DisambiguatedProfile.pcss +++ b/res/css/views/messages/_DisambiguatedProfile.pcss @@ -35,6 +35,8 @@ Please see LICENSE files in the repository root for full details. .mx_DisambiguatedProfile_mxid { margin-inline-start: 0; font: var(--cpd-font-body-sm-regular); + text-overflow: ellipsis; + overflow: hidden; } span:not(.mx_DisambiguatedProfile_mxid) { diff --git a/res/css/views/right_panel/_RoomSummaryCard.pcss b/res/css/views/right_panel/_RoomSummaryCard.pcss index fb1d6f7d9c..eb3b14579f 100644 --- a/res/css/views/right_panel/_RoomSummaryCard.pcss +++ b/res/css/views/right_panel/_RoomSummaryCard.pcss @@ -100,3 +100,7 @@ Please see LICENSE files in the repository root for full details. .mx_RoomSummaryCard_roomName { margin: $spacing-12 0 $spacing-4; } + +.mx_RoomSummaryCard_leave { + margin: 0 0 var(--cpd-space-8x); +} diff --git a/res/css/views/right_panel/_ThreadPanel.pcss b/res/css/views/right_panel/_ThreadPanel.pcss index b21eb17f03..2fe1a86908 100644 --- a/res/css/views/right_panel/_ThreadPanel.pcss +++ b/res/css/views/right_panel/_ThreadPanel.pcss @@ -15,41 +15,48 @@ Please see LICENSE files in the repository root for full details. flex: unset; } - .mx_BaseCard_header { - .mx_BaseCard_header_title { - .mx_AccessibleButton { - font-size: 12px; - color: $secondary-content; + .mx_ThreadPanelHeader { + height: 60px; + display: flex; + box-sizing: border-box; + padding: 16px; + align-items: center; + border-bottom: 1px solid var(--cpd-color-gray-400); + + .mx_AccessibleButton { + font-size: 12px; + color: $secondary-content; + } + + .mx_ThreadPanel_vertical_separator { + height: 28px; + margin-left: var(--cpd-space-3x); + margin-right: var(--cpd-space-2x); + border-left: 1px solid var(--cpd-color-gray-400); + } + + .mx_ThreadPanel_dropdown { + font: var(--cpd-font-body-sm-regular); + padding: 3px $spacing-4 3px $spacing-8; + border-radius: 4px; + line-height: 1.5; + user-select: none; + + &:hover, + &[aria-expanded="true"] { + background: $quinary-content; } - .mx_ThreadPanel_vertical_separator { - height: 16px; - margin-left: var(--cpd-space-3x); - margin-right: var(--cpd-space-1x); - border-left: 1px solid var(--cpd-color-gray-400); - } - - .mx_ThreadPanel_dropdown { - padding: 3px $spacing-4 3px $spacing-8; - border-radius: 4px; - line-height: 1.5; - user-select: none; - - &:hover, - &[aria-expanded="true"] { - background: $quinary-content; - } - - &::before { - content: ""; - width: 18px; - height: 18px; - background: currentColor; - mask-image: url("@vector-im/compound-design-tokens/icons/chevron-down.svg"); - mask-size: 100%; - mask-repeat: no-repeat; - float: right; - } + &::before { + margin-left: 2px; + content: ""; + width: 20px; + height: 20px; + background: currentColor; + mask-image: url("@vector-im/compound-design-tokens/icons/chevron-down.svg"); + mask-size: 100%; + mask-repeat: no-repeat; + float: right; } } } diff --git a/res/css/views/right_panel/_UserInfo.pcss b/res/css/views/right_panel/_UserInfo.pcss index 0af5585d1a..7fccd6e2d1 100644 --- a/res/css/views/right_panel/_UserInfo.pcss +++ b/res/css/views/right_panel/_UserInfo.pcss @@ -34,13 +34,9 @@ Please see LICENSE files in the repository root for full details. } .mx_UserInfo_container { - padding: var(--cpd-space-4x) 0; + padding: var(--cpd-space-2x) 0 var(--cpd-space-4x); margin: 0 var(--cpd-space-4x); - .mx_UserInfo_container_verifyButton { - margin-top: $spacing-8; - } - & + .mx_UserInfo_container { border-top: 1px solid $separator; } @@ -65,7 +61,7 @@ Please see LICENSE files in the repository root for full details. } .mx_UserInfo_avatar { - margin: $spacing-24 $spacing-32 0 $spacing-32; + margin: var(--cpd-space-12x) var(--cpd-space-4x) 0 var(--cpd-space-4x); .mx_UserInfo_avatar_transition { max-width: 120px; @@ -98,8 +94,18 @@ Please see LICENSE files in the repository root for full details. margin: 5px 0; } + .mx_UserInfo_header { + margin-bottom: var(--cpd-space-8x); + padding-bottom: 0; + } + .mx_UserInfo_profile { + display: flex; + flex-direction: column; + gap: var(--cpd-space-1x); + h1 { + margin: 0; font-size: $font-20px; line-height: $font-25px; @@ -119,8 +125,45 @@ Please see LICENSE files in the repository root for full details. } } + .mx_UserInfo_profile_name { + height: 30px; + } + + .mx_UserInfo_profile_mxid { + color: var(--cpd-color-text-secondary); + height: 28px; + } + .mx_UserInfo_profileStatus { - margin: var(--cpd-space-1x) 0; + height: 20px; + } + + .mx_UserInfo_timezone { + height: 20px; + margin: 0; + display: flex; + align-items: center; + } + + /** Overrides for the copy to clipboard button **/ + .mx_CopyableText { + align-items: center; + } + + .mx_CopyableText_copyButton { + width: 28px; + height: 28px; + display: flex; + justify-content: center; + align-items: center; + position: unset; + padding-left: var(--cpd-space-2x); + } + + .mx_CopyableText_copyButton::before { + width: 20px; + height: 20px; + background-color: var(--cpd-color-icon-secondary-alpha); } } @@ -133,6 +176,28 @@ Please see LICENSE files in the repository root for full details. opacity: 1; } + .mx_UserInfo_verification { + margin-top: var(--cpd-space-4x); + height: 36px; + + .mx_UserInfo_verified_badge { + min-width: 68px; + height: 20px; + + .mx_UserInfo_verified_icon { + flex-shrink: 0; + } + + .mx_UserInfo_verified_label { + margin: 0; + } + } + + .mx_UserInfo_verification_unavailable { + color: var(--cpd-color-text-secondary); + } + } + .mx_UserInfo_memberDetails { .mx_UserInfo_profileField { display: flex; @@ -179,45 +244,6 @@ Please see LICENSE files in the repository root for full details. flex: 1 1 0; } - .mx_UserInfo_devices { - .mx_UserInfo_device { - display: flex; - margin: $spacing-8 0; - - &.mx_UserInfo_device_verified { - .mx_UserInfo_device_trusted { - color: $accent; - } - } - &.mx_UserInfo_device_unverified { - .mx_UserInfo_device_trusted { - color: $alert; - } - } - - .mx_UserInfo_device_name { - flex: 1; - margin: 0 5px; - word-break: break-word; - } - } - - /* both for icon in expand button and device item */ - .mx_E2EIcon { - /* don't squeeze */ - flex: 0 0 auto; - margin: 0; - width: 12px; - height: 12px; - } - - .mx_UserInfo_expand { - column-gap: 5px; /* cf: mx_UserInfo_device_name */ - margin-bottom: 11px; - align-items: initial; /* Cancel the default property */ - } - } - &.mx_UserInfo_smallAvatar { .mx_UserInfo_avatar { .mx_UserInfo_avatar_transition { diff --git a/res/css/views/rooms/RoomListPanel/_RoomList.pcss b/res/css/views/rooms/RoomListPanel/_RoomList.pcss new file mode 100644 index 0000000000..2563c1b675 --- /dev/null +++ b/res/css/views/rooms/RoomListPanel/_RoomList.pcss @@ -0,0 +1,15 @@ +/* + * 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_RoomList { + height: 100%; + + .mx_RoomList_List { + /* Avoid when on hover, the background color to be on top of the right border */ + padding-right: 1px; + } +} diff --git a/res/css/views/rooms/RoomListPanel/_RoomListHeaderView.pcss b/res/css/views/rooms/RoomListPanel/_RoomListHeaderView.pcss new file mode 100644 index 0000000000..595f47f9c8 --- /dev/null +++ b/res/css/views/rooms/RoomListPanel/_RoomListHeaderView.pcss @@ -0,0 +1,39 @@ +/* + * 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_RoomListHeaderView { + flex: 0 0 60px; + padding: 0 var(--cpd-space-3x); + + .mx_RoomListHeaderView_title { + min-width: 0; + + h1 { + all: unset; + font: var(--cpd-font-heading-sm-semibold); + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + } + } + + button { + color: var(--cpd-color-icon-secondary); + } + + .mx_SpaceMenu_button { + svg { + transition: transform 0.1s linear; + } + } + + .mx_SpaceMenu_button[aria-expanded="true"] { + svg { + transform: rotate(180deg); + } + } +} diff --git a/res/css/views/rooms/RoomListPanel/_RoomListItemMenuView.pcss b/res/css/views/rooms/RoomListPanel/_RoomListItemMenuView.pcss new file mode 100644 index 0000000000..cabd9b2d20 --- /dev/null +++ b/res/css/views/rooms/RoomListPanel/_RoomListItemMenuView.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_RoomListItemMenuView { + svg { + fill: var(--cpd-color-icon-primary); + } +} diff --git a/res/css/views/rooms/RoomListPanel/_RoomListItemView.pcss b/res/css/views/rooms/RoomListPanel/_RoomListItemView.pcss new file mode 100644 index 0000000000..e53ba3dc79 --- /dev/null +++ b/res/css/views/rooms/RoomListPanel/_RoomListItemView.pcss @@ -0,0 +1,49 @@ +/* + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial + * Please see LICENSE files in the repository root for full details. + */ + +/** + * The RoomListItemView has the following structure: + * button----------------------------------------| + * | <-12px-> container--------------------------| + * | | room avatar <-12px-> content-----| + * | | | room_name | + * | | | ----------| <-- border + * |---------------------------------------------| + */ +.mx_RoomListItemView { + all: unset; + + &:hover { + background-color: var(--cpd-color-bg-action-secondary-hovered); + } + + .mx_RoomListItemView_container { + padding-left: var(--cpd-space-3x); + font: var(--cpd-font-body-md-regular); + height: 100%; + + .mx_RoomListItemView_content { + padding-right: var(--cpd-space-3x); + height: 100%; + flex: 1; + /* The border is only under the room name and the future hover menu */ + border-bottom: var(--cpd-border-width-0-5) solid var(--cpd-color-bg-subtle-secondary); + box-sizing: border-box; + min-width: 0; + + span { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + } + } +} + +.mx_RoomListItemView_menu_open { + background-color: var(--cpd-color-bg-action-secondary-hovered); +} diff --git a/res/css/views/rooms/RoomListPanel/_RoomListPanel.pcss b/res/css/views/rooms/RoomListPanel/_RoomListPanel.pcss new file mode 100644 index 0000000000..eb1f6e5fe5 --- /dev/null +++ b/res/css/views/rooms/RoomListPanel/_RoomListPanel.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_RoomListPanel { + background-color: var(--cpd-color-bg-canvas-default); + height: 100%; + border-right: 1px solid var(--cpd-color-bg-subtle-primary); +} diff --git a/res/css/views/rooms/RoomListPanel/_RoomListPrimaryFilters.pcss b/res/css/views/rooms/RoomListPanel/_RoomListPrimaryFilters.pcss new file mode 100644 index 0000000000..ac85782bbd --- /dev/null +++ b/res/css/views/rooms/RoomListPanel/_RoomListPrimaryFilters.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_RoomListPrimaryFilters { + margin: unset; + list-style-type: none; + padding: var(--cpd-space-2x) var(--cpd-space-3x); +} diff --git a/res/css/views/rooms/RoomListPanel/_RoomListSearch.pcss b/res/css/views/rooms/RoomListPanel/_RoomListSearch.pcss new file mode 100644 index 0000000000..8a97086df8 --- /dev/null +++ b/res/css/views/rooms/RoomListPanel/_RoomListSearch.pcss @@ -0,0 +1,39 @@ +/* + * 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_RoomListSearch { + /* From figma, this should be aligned with the room header */ + flex: 0 0 64px; + box-sizing: border-box; + border-bottom: var(--cpd-border-width-1) solid var(--cpd-color-bg-subtle-primary); + padding: 0 var(--cpd-space-3x); + + svg { + fill: var(--cpd-color-icon-secondary); + } + + .mx_RoomListSearch_search { + /* The search button should take all the remaining space */ + flex: 1; + font: var(--cpd-font-body-md-regular); + color: var(--cpd-color-text-secondary); + + span { + flex: 1; + + kbd { + font-family: inherit; + } + } + } + + .mx_RoomListSearch_button:hover { + svg { + fill: var(--cpd-color-icon-primary); + } + } +} diff --git a/res/css/views/rooms/_EventTile.pcss b/res/css/views/rooms/_EventTile.pcss index 5b86e3f753..54f91bd5f4 100644 --- a/res/css/views/rooms/_EventTile.pcss +++ b/res/css/views/rooms/_EventTile.pcss @@ -135,12 +135,6 @@ $left-gutter: 64px; } } - &.mx_EventTile_highlight, - &.mx_EventTile_highlight .markdown-body, - &.mx_EventTile_highlight .mx_EventTile_edited { - color: $alert; - } - &.mx_EventTile_bubbleContainer { display: grid; grid-template-columns: 1fr 100px; @@ -689,6 +683,7 @@ $left-gutter: 64px; line-height: inherit !important; background-color: inherit; color: inherit; /* inherit the colour from the dark or light theme by default (but not for code blocks) */ + flex: 1; pre, code { diff --git a/res/css/views/rooms/_InvitedIconView.pcss b/res/css/views/rooms/_InvitedIconView.pcss new file mode 100644 index 0000000000..504f4498af --- /dev/null +++ b/res/css/views/rooms/_InvitedIconView.pcss @@ -0,0 +1,10 @@ +/* +Copyright 2025 New Vector Ltd. + +SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial +Please see LICENSE files in the repository root for full details. +*/ + +.mx_InvitedIconView { + color: var(--cpd-color-icon-tertiary); +} diff --git a/res/css/views/rooms/_RoomList.pcss b/res/css/views/rooms/_LegacyRoomList.pcss similarity index 71% rename from res/css/views/rooms/_RoomList.pcss rename to res/css/views/rooms/_LegacyRoomList.pcss index 74e2e86ed1..acf162b7a2 100644 --- a/res/css/views/rooms/_RoomList.pcss +++ b/res/css/views/rooms/_LegacyRoomList.pcss @@ -6,31 +6,31 @@ 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_RoomList { +.mx_LegacyRoomList { padding-right: 7px; /* width of the scrollbar, to line things up */ } -.mx_RoomList_iconPlus::before { +.mx_LegacyRoomList_iconPlus::before { mask-image: url("$(res)/img/element-icons/roomlist/plus-circle.svg"); } -.mx_RoomList_iconNewRoom::before { +.mx_LegacyRoomList_iconNewRoom::before { mask-image: url("$(res)/img/element-icons/roomlist/hash-plus.svg"); } -.mx_RoomList_iconNewVideoRoom::before { +.mx_LegacyRoomList_iconNewVideoRoom::before { mask-image: url("$(res)/img/element-icons/roomlist/hash-video.svg"); } -.mx_RoomList_iconAddExistingRoom::before { +.mx_LegacyRoomList_iconAddExistingRoom::before { mask-image: url("$(res)/img/element-icons/roomlist/hash.svg"); } -.mx_RoomList_iconExplore::before { +.mx_LegacyRoomList_iconExplore::before { mask-image: url("$(res)/img/element-icons/roomlist/hash-search.svg"); } -.mx_RoomList_iconDialpad::before { +.mx_LegacyRoomList_iconDialpad::before { mask-image: url("$(res)/img/element-icons/roomlist/dialpad.svg"); } -.mx_RoomList_iconStartChat::before { +.mx_LegacyRoomList_iconStartChat::before { mask-image: url("@vector-im/compound-design-tokens/icons/user-add-solid.svg"); } -.mx_RoomList_iconInvite::before { +.mx_LegacyRoomList_iconInvite::before { mask-image: url("$(res)/img/element-icons/room/share.svg"); } diff --git a/res/css/views/rooms/_RoomListHeader.pcss b/res/css/views/rooms/_LegacyRoomListHeader.pcss similarity index 83% rename from res/css/views/rooms/_RoomListHeader.pcss rename to res/css/views/rooms/_LegacyRoomListHeader.pcss index 396aa4a61a..c04b56d94a 100644 --- a/res/css/views/rooms/_RoomListHeader.pcss +++ b/res/css/views/rooms/_LegacyRoomListHeader.pcss @@ -6,12 +6,12 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com Please see LICENSE files in the repository root for full details. */ -.mx_RoomListHeader { +.mx_LegacyRoomListHeader { display: flex; align-items: center; - .mx_RoomListHeader_contextLessTitle, - .mx_RoomListHeader_contextMenuButton { + .mx_LegacyRoomListHeader_contextLessTitle, + .mx_LegacyRoomListHeader_contextMenuButton { font: var(--cpd-font-heading-sm-semibold); font-weight: var(--cpd-font-weight-semibold); padding: 1px 24px 1px 4px; @@ -24,7 +24,7 @@ Please see LICENSE files in the repository root for full details. user-select: none; } - .mx_RoomListHeader_contextMenuButton { + .mx_LegacyRoomListHeader_contextMenuButton { border-radius: 6px; &:hover { @@ -54,7 +54,7 @@ Please see LICENSE files in the repository root for full details. } } - .mx_RoomListHeader_plusButton { + .mx_LegacyRoomListHeader_plusButton { width: 32px; height: 32px; border-radius: 8px; @@ -88,21 +88,21 @@ Please see LICENSE files in the repository root for full details. } } -.mx_RoomListHeader_iconInvite::before { +.mx_LegacyRoomListHeader_iconInvite::before { mask-image: url("$(res)/img/element-icons/room/invite.svg"); } -.mx_RoomListHeader_iconStartChat::before { +.mx_LegacyRoomListHeader_iconStartChat::before { mask-image: url("@vector-im/compound-design-tokens/icons/user-add-solid.svg"); } -.mx_RoomListHeader_iconNewRoom::before { +.mx_LegacyRoomListHeader_iconNewRoom::before { mask-image: url("$(res)/img/element-icons/roomlist/hash-plus.svg"); } -.mx_RoomListHeader_iconNewVideoRoom::before { +.mx_LegacyRoomListHeader_iconNewVideoRoom::before { mask-image: url("$(res)/img/element-icons/roomlist/hash-video.svg"); } -.mx_RoomListHeader_iconExplore::before { +.mx_LegacyRoomListHeader_iconExplore::before { mask-image: url("$(res)/img/element-icons/roomlist/hash-search.svg"); } -.mx_RoomListHeader_iconPlus::before { +.mx_LegacyRoomListHeader_iconPlus::before { mask-image: url("@vector-im/compound-design-tokens/icons/plus.svg"); } diff --git a/res/css/views/rooms/_MemberListHeaderView.pcss b/res/css/views/rooms/_MemberListHeaderView.pcss index 326cf84dd6..314bd74062 100644 --- a/res/css/views/rooms/_MemberListHeaderView.pcss +++ b/res/css/views/rooms/_MemberListHeaderView.pcss @@ -16,6 +16,7 @@ Please see LICENSE files in the repository root for full details. .mx_MemberListHeaderView_invite_small { margin-left: var(--cpd-space-3x); + margin-right: var(--cpd-space-4x); } .mx_MemberListHeaderView_invite_large { @@ -33,5 +34,7 @@ Please see LICENSE files in the repository root for full details. .mx_MemberListHeaderView_search { width: 240px; + flex-grow: 1; + margin-left: var(--cpd-space-4x); } } diff --git a/res/css/views/rooms/_MemberListView.pcss b/res/css/views/rooms/_MemberListView.pcss index e13b17b226..0aee9cb159 100644 --- a/res/css/views/rooms/_MemberListView.pcss +++ b/res/css/views/rooms/_MemberListView.pcss @@ -14,4 +14,10 @@ Please see LICENSE files in the repository root for full details. .mx_MemberListView_container { height: 100%; } + + .mx_MemberListView_separator { + margin: 0; + border: none; + border-top: 2px solid var(--cpd-color-bg-subtle-primary); + } } diff --git a/res/css/views/rooms/_MemberTileView.pcss b/res/css/views/rooms/_MemberTileView.pcss index 702edd8f9d..307625d042 100644 --- a/res/css/views/rooms/_MemberTileView.pcss +++ b/res/css/views/rooms/_MemberTileView.pcss @@ -27,13 +27,13 @@ Please see LICENSE files in the repository root for full details. .mx_MemberTileView_name { font: var(--cpd-font-body-md-medium); - font-size: 15px; min-width: 0; } - .mx_MemberTileView_user_label { + .mx_MemberTileView_userLabel { font: var(--cpd-font-body-sm-regular); - font-size: 13px; + color: var(--cpd-color-text-secondary); + margin-left: var(--cpd-space-4x); } .mx_MemberTileView_avatar { @@ -41,18 +41,4 @@ Please see LICENSE files in the repository root for full details. height: 32px; width: 32px; } - - .mx_E2EIconView { - display: flex; - justify-content: center; - align-items: center; - } - - .mx_E2EIconView_warning { - color: var(--cpd-color-icon-critical-primary); - } - - .mx_E2EIconView_verified { - color: var(--cpd-color-icon-success-primary); - } } diff --git a/res/css/views/rooms/_RoomHeader.pcss b/res/css/views/rooms/_RoomHeader.pcss index 32eb055f07..afde8f3464 100644 --- a/res/css/views/rooms/_RoomHeader.pcss +++ b/res/css/views/rooms/_RoomHeader.pcss @@ -59,6 +59,7 @@ Please see LICENSE files in the repository root for full details. .mx_RoomHeader_icon { flex-shrink: 0; + padding: var(--cpd-space-1x); } .mx_RoomHeader .mx_FacePile { @@ -71,6 +72,7 @@ Please see LICENSE files in the repository root for full details. padding: var(--cpd-space-1-5x); cursor: pointer; user-select: none; + font: var(--cpd-font-body-sm-medium); /* RoomAvatar doesn't pass classes down to avatar So set style here @@ -83,6 +85,12 @@ Please see LICENSE files in the repository root for full details. color: $primary-content; background: var(--cpd-color-bg-subtle-primary); } + + &.mx_FacePile_toggled { + background: var(--cpd-color-bg-success-subtle); + color: var(--cpd-color-text-action-accent); + font: var(--cpd-font-body-sm-semibold); + } } .mx_RoomHeader .mx_BaseAvatar { @@ -93,3 +101,7 @@ Please see LICENSE files in the repository root for full details. /* Workaround for https://github.com/element-hq/compound/issues/331 */ min-width: 240px; } + +.mx_RoomHeader .mx_RoomHeader_toggled { + color: var(--cpd-color-icon-accent-primary); +} diff --git a/res/css/views/rooms/_UserIdentityWarning.pcss b/res/css/views/rooms/_UserIdentityWarning.pcss index e5d14eb472..cf87e47a24 100644 --- a/res/css/views/rooms/_UserIdentityWarning.pcss +++ b/res/css/views/rooms/_UserIdentityWarning.pcss @@ -20,8 +20,14 @@ Please see LICENSE files in the repository root for full details. margin-left: var(--cpd-space-6x); flex-grow: 1; } + .mx_UserIdentityWarning_main.critical { + color: var(--cpd-color-text-critical-primary); + } } } +.mx_UserIdentityWarning.critical { + background: linear-gradient(180deg, var(--cpd-color-red-100) 0%, var(--cpd-color-theme-bg) 100%); +} .mx_MessageComposer.mx_MessageComposer--compact > .mx_UserIdentityWarning { margin-left: calc(-25px + var(--RoomView_MessageList-padding)); diff --git a/res/css/views/settings/_SetIdServer.pcss b/res/css/views/settings/_SetIdServer.pcss deleted file mode 100644 index 377292451f..0000000000 --- a/res/css/views/settings/_SetIdServer.pcss +++ /dev/null @@ -1,23 +0,0 @@ -/* -Copyright 2024 New Vector Ltd. -Copyright 2019-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_SetIdServer { - display: flex; - flex-direction: column; - align-items: flex-start; - gap: $spacing-8; - - .mx_Field { - width: 100%; - margin: 0; - } -} - -.mx_SetIdServer_tooltip { - max-width: var(--SettingsTab_tooltip-max-width); -} diff --git a/res/css/views/settings/_SetIntegrationManager.pcss b/res/css/views/settings/_SetIntegrationManager.pcss index a046ce0fff..f370d06e5e 100644 --- a/res/css/views/settings/_SetIntegrationManager.pcss +++ b/res/css/views/settings/_SetIntegrationManager.pcss @@ -7,19 +7,13 @@ Please see LICENSE files in the repository root for full details. */ .mx_SetIntegrationManager { - .mx_SettingsFlag { + .mx_SetIntegrationManager_heading_manager { + display: flex; align-items: center; - - .mx_SetIntegrationManager_heading_manager { - display: flex; - align-items: center; - flex-wrap: wrap; - column-gap: $spacing-4; - } - - .mx_ToggleSwitch { - align-self: flex-start; - min-width: var(--ToggleSwitch-min-width); /* avoid compression */ - } + flex-wrap: wrap; + column-gap: $spacing-4; + } + form { + margin-top: var(--cpd-space-3x); } } diff --git a/res/css/views/settings/_SettingsHeader.pcss b/res/css/views/settings/_SettingsHeader.pcss new file mode 100644 index 0000000000..a705deda6c --- /dev/null +++ b/res/css/views/settings/_SettingsHeader.pcss @@ -0,0 +1,19 @@ +/* + * Copyright 2024 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial + * Please see LICENSE files in the repository root for full details. + */ + +.mx_SettingsHeader { + display: flex; + align-items: center; + gap: var(--cpd-space-2x); + /* Override margin from common.pcss */ + margin: 0; + + > span { + font: var(--cpd-font-body-sm-medium); + color: var(--cpd-color-text-action-accent); + } +} diff --git a/res/css/views/settings/_SettingsSubheader.pcss b/res/css/views/settings/_SettingsSubheader.pcss new file mode 100644 index 0000000000..276421e5be --- /dev/null +++ b/res/css/views/settings/_SettingsSubheader.pcss @@ -0,0 +1,27 @@ +/* + * Copyright 2024 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial + * Please see LICENSE files in the repository root for full details. + */ + +.mx_SettingsSubheader { + display: flex; + flex-direction: column; + gap: var(--cpd-space-2x); + + > span { + display: flex; + align-items: center; + gap: var(--cpd-space-2x); + font: var(--cpd-font-body-sm-medium); + } + + .mx_SettingsSubheader_success { + color: var(--cpd-color-text-success-primary); + } + + .mx_SettingsSubheader_error { + color: var(--cpd-color-text-critical-primary); + } +} diff --git a/res/css/views/settings/encryption/_AdvancedPanel.pcss b/res/css/views/settings/encryption/_AdvancedPanel.pcss new file mode 100644 index 0000000000..fed8fca7ea --- /dev/null +++ b/res/css/views/settings/encryption/_AdvancedPanel.pcss @@ -0,0 +1,51 @@ +/* + * Copyright 2024 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial + * Please see LICENSE files in the repository root for full details. + */ + +.mx_EncryptionDetails, +.mx_OtherSettings { + display: flex; + flex-direction: column; + gap: var(--cpd-space-6x); + width: 100%; + align-items: start; + + .mx_EncryptionDetails_session_title, + .mx_OtherSettings_title { + font: var(--cpd-font-body-lg-semibold); + padding-bottom: var(--cpd-space-2x); + border-bottom: 1px solid var(--cpd-color-gray-400); + width: 100%; + margin: 0; + } +} + +.mx_EncryptionDetails { + .mx_EncryptionDetails_session { + display: flex; + flex-direction: column; + gap: var(--cpd-space-4x); + width: 100%; + + > div { + display: flex; + + > span { + width: 50%; + word-wrap: break-word; + } + } + + > div:nth-child(odd) { + background-color: var(--cpd-color-gray-200); + } + } + + .mx_EncryptionDetails_buttons { + display: flex; + gap: var(--cpd-space-4x); + } +} diff --git a/res/css/views/settings/encryption/_ChangeRecoveryKey.pcss b/res/css/views/settings/encryption/_ChangeRecoveryKey.pcss new file mode 100644 index 0000000000..ceacb22c27 --- /dev/null +++ b/res/css/views/settings/encryption/_ChangeRecoveryKey.pcss @@ -0,0 +1,72 @@ +/* + * Copyright 2024 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial + * Please see LICENSE files in the repository root for full details. + */ + +.mx_ChangeRecoveryKey { + .mx_InformationPanel_description { + text-align: center; + } + + .mx_ChangeRecoveryKey_Form { + display: flex; + flex-direction: column; + gap: var(--cpd-space-8x); + + .mx_ChangeRecoveryKey_footer { + display: flex; + flex-direction: column; + gap: var(--cpd-space-4x); + justify-content: center; + } + } + + .mx_KeyPanel { + display: grid; + grid-template: + "header button" auto + "content button" auto / 1fr; + + column-gap: var(--cpd-space-3x); + row-gap: var(--cpd-space-1x); + align-items: center; + + > span { + grid-area: header; + } + + > div { + grid-area: content; + display: flex; + flex-direction: column; + gap: var(--cpd-space-2x); + color: var(--cpd-color-text-secondary); + + .mx_KeyPanel_key { + font-family: Inconsolata, monospace; + /* + * From figma https://www.figma.com/design/qTWRfItpO3RdCjnTKPu4mL/Settings?node-id=375-77471&t=t7lozYrSI1AVZZ3U-4 + */ + height: 70px; + box-sizing: border-box; + border-radius: var(--cpd-space-2x); + padding: var(--cpd-space-3x) var(--cpd-space-4x); + background-color: var(--cpd-color-bg-subtle-secondary); + } + } + + > button { + margin: 0 var(--cpd-space-1x); + grid-area: button; + color: var(--cpd-color-icon-secondary-alpha); + } + } + + .mx_KeyForm { + display: flex; + flex-direction: column; + gap: var(--cpd-space-8x); + } +} diff --git a/res/css/views/settings/encryption/_EncryptionCard.pcss b/res/css/views/settings/encryption/_EncryptionCard.pcss new file mode 100644 index 0000000000..87118135e5 --- /dev/null +++ b/res/css/views/settings/encryption/_EncryptionCard.pcss @@ -0,0 +1,40 @@ +/* + * Copyright 2024 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial + * Please see LICENSE files in the repository root for full details. + */ + +.mx_EncryptionCard { + display: flex; + flex-direction: column; + gap: var(--cpd-space-8x); + 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); + border: 1px solid var(--cpd-color-gray-400); + + .mx_EncryptionCard_header { + display: flex; + flex-direction: column; + gap: var(--cpd-space-4x); + align-items: center; + + > h2 { + margin: 0; + } + + > span { + color: var(--cpd-color-text-secondary); + text-align: center; + } + } +} + +.mx_EncryptionCard_buttons { + display: flex; + flex-direction: column; + gap: var(--cpd-space-4x); + justify-content: center; +} diff --git a/res/css/views/settings/encryption/_EncryptionCardEmphasisedContent.pcss b/res/css/views/settings/encryption/_EncryptionCardEmphasisedContent.pcss new file mode 100644 index 0000000000..6b18fcff65 --- /dev/null +++ b/res/css/views/settings/encryption/_EncryptionCardEmphasisedContent.pcss @@ -0,0 +1,13 @@ +/* + * Copyright 2024 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial + * Please see LICENSE files in the repository root for full details. + */ + +.mx_EncryptionCard_emphasisedContent { + span { + font: var(--cpd-font-body-md-medium); + text-align: center; + } +} diff --git a/res/css/views/settings/encryption/_RecoveryPanelOutOfSync.pcss b/res/css/views/settings/encryption/_RecoveryPanelOutOfSync.pcss new file mode 100644 index 0000000000..fc6ba7d959 --- /dev/null +++ b/res/css/views/settings/encryption/_RecoveryPanelOutOfSync.pcss @@ -0,0 +1,11 @@ +/* + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial + * Please see LICENSE files in the repository root for full details. + */ + +.mx_RecoveryPanelOutOfSync { + display: flex; + gap: var(--cpd-space-2x); +} diff --git a/res/css/views/settings/encryption/_ResetIdentityPanel.pcss b/res/css/views/settings/encryption/_ResetIdentityPanel.pcss new file mode 100644 index 0000000000..8318d6d91c --- /dev/null +++ b/res/css/views/settings/encryption/_ResetIdentityPanel.pcss @@ -0,0 +1,11 @@ +/* + * Copyright 2024 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial + * Please see LICENSE files in the repository root for full details. + */ + +// Red text for the "Do not close this window" warning +.mx_ResetIdentityPanel_warning { + color: var(--cpd-color-text-critical-primary); +} diff --git a/res/css/views/settings/tabs/_SettingsSection.pcss b/res/css/views/settings/tabs/_SettingsSection.pcss index 1dd1166138..997343190d 100644 --- a/res/css/views/settings/tabs/_SettingsSection.pcss +++ b/res/css/views/settings/tabs/_SettingsSection.pcss @@ -15,6 +15,20 @@ Please see LICENSE files in the repository root for full details. a { color: $links; } + + &.mx_SettingsSection_newUi { + display: flex; + flex-direction: column; + gap: var(--cpd-space-6x); + align-items: start; + } + + .mx_SettingsSection_header { + display: flex; + flex-direction: column; + gap: var(--cpd-space-3x); + color: var(--cpd-color-text-secondary); + } } .mx_SettingsSection_subSections { diff --git a/res/css/views/settings/tabs/_SettingsTab.pcss b/res/css/views/settings/tabs/_SettingsTab.pcss index 6055c289fc..e0abf08e83 100644 --- a/res/css/views/settings/tabs/_SettingsTab.pcss +++ b/res/css/views/settings/tabs/_SettingsTab.pcss @@ -14,7 +14,7 @@ Please see LICENSE files in the repository root for full details. color: $links; } - form { + form:not(.mx_EncryptionUserSettingsTab form) { display: flex; flex-direction: column; gap: $spacing-8; diff --git a/res/css/views/user-onboarding/_UserOnboardingButton.pcss b/res/css/views/user-onboarding/_UserOnboardingButton.pcss deleted file mode 100644 index 75b1b1eb68..0000000000 --- a/res/css/views/user-onboarding/_UserOnboardingButton.pcss +++ /dev/null @@ -1,75 +0,0 @@ -/* -Copyright 2024 New Vector Ltd. -Copyright 2022 The Matrix.org Foundation C.I.C. - -SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial -Please see LICENSE files in the repository root for full details. -*/ - -.mx_UserOnboardingButton { - display: flex; - flex-direction: column; - align-content: stretch; - align-items: stretch; - border-radius: 8px; - margin: $spacing-8 $spacing-8 0; - padding: $spacing-12; - - &.mx_UserOnboardingButton_selected, - &:hover, - &:focus-within { - background-color: $panel-actions; - } - - .mx_UserOnboardingButton_content { - display: flex; - flex-direction: row; - gap: 5px; - align-items: center; - - .mx_Heading_h4 { - margin-right: auto; - font: var(--cpd-font-body-md-regular); - color: $primary-content; - } - - .mx_UserOnboardingButton_percentage { - font-size: $font-12px; - color: $secondary-content; - } - - .mx_UserOnboardingButton_close { - position: relative; - box-sizing: border-box; - width: 14px; - height: 14px; - border-radius: 7px; - border: 1px solid $secondary-content; - flex-shrink: 0; - - &::before { - background-color: $secondary-content; - content: ""; - mask-repeat: no-repeat; - mask-position: center; - mask-size: 12px; - width: inherit; - height: inherit; - position: absolute; - left: -1px; - top: -1px; - mask-image: url("@vector-im/compound-design-tokens/icons/close.svg"); - } - } - } - - .mx_ProgressBar { - width: auto; - margin-top: $spacing-8; - background: $background; - } - - &.mx_UserOnboardingButton_completed .mx_ProgressBar { - display: none; - } -} diff --git a/res/css/views/user-onboarding/_UserOnboardingHeader.pcss b/res/css/views/user-onboarding/_UserOnboardingHeader.pcss deleted file mode 100644 index 6402e8c859..0000000000 --- a/res/css/views/user-onboarding/_UserOnboardingHeader.pcss +++ /dev/null @@ -1,93 +0,0 @@ -/* -Copyright 2024 New Vector Ltd. -Copyright 2022 The Matrix.org Foundation C.I.C. - -SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial -Please see LICENSE files in the repository root for full details. -*/ - -.mx_UserOnboardingHeader { - display: flex; - flex-direction: row; - padding: $spacing-32; - border-radius: 16px; - background: $system; - gap: $spacing-64; - - animation-delay: 1500ms; - animation-duration: 300ms; - animation-timing-function: cubic-bezier(0, 0, 0.58, 1); - animation-name: mx_UserOnboardingHeader_slideIn; - animation-fill-mode: backwards; - will-change: opacity, transform; - - @media (max-width: 1280px) { - margin: $spacing-32; - } - - .mx_UserOnboardingHeader_dot { - color: $accent; - } - - .mx_UserOnboardingHeader_content { - display: flex; - flex-direction: column; - flex-basis: 50%; - flex-shrink: 1; - flex-grow: 1; - min-width: 0; - gap: $spacing-24; - margin-right: auto; - - p { - margin: 0; - } - - .mx_AccessibleButton { - margin-top: auto; - align-self: flex-start; - padding: $spacing-12 $spacing-24; - } - } - - .mx_UserOnboardingHeader_image { - flex-basis: 30%; - flex-shrink: 1; - flex-grow: 1; - align-self: center; - height: calc(100% + $spacing-64 + $spacing-64); - aspect-ratio: 4 / 3; - object-fit: contain; - min-width: 0; - min-height: 0; - margin-top: -$spacing-64; - margin-bottom: -$spacing-64; - - animation-delay: 1500ms; - animation-duration: 300ms; - animation-timing-function: cubic-bezier(0, 0, 0.58, 1); - animation-name: mx_UserOnboardingHeader_slideInLong; - animation-fill-mode: backwards; - will-change: opacity, transform; - } -} - -@keyframes mx_UserOnboardingHeader_slideIn { - 0% { - transform: translate(0, 8px); - opacity: 0; - } - 100% { - transform: translate(0, 0); - opacity: 1; - } -} - -@keyframes mx_UserOnboardingHeader_slideInLong { - 0% { - transform: translate(0, 32px); - } - 100% { - transform: translate(0, 0); - } -} diff --git a/res/css/views/user-onboarding/_UserOnboardingList.pcss b/res/css/views/user-onboarding/_UserOnboardingList.pcss deleted file mode 100644 index bd198de2fe..0000000000 --- a/res/css/views/user-onboarding/_UserOnboardingList.pcss +++ /dev/null @@ -1,67 +0,0 @@ -/* -Copyright 2024 New Vector Ltd. -Copyright 2022 The Matrix.org Foundation C.I.C. - -SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial -Please see LICENSE files in the repository root for full details. -*/ - -.mx_UserOnboardingList { - display: flex; - flex-direction: column; - margin: 0 $spacing-32; - - animation-duration: 300ms; - animation-timing-function: cubic-bezier(0, 0, 0.58, 1); - animation-name: mx_UserOnboardingList_slideIn; - animation-fill-mode: backwards; - will-change: opacity; - - .mx_UserOnboardingList_header { - display: flex; - flex-direction: row; - gap: 12px; - align-items: center; - - .mx_UserOnboardingList_hint { - color: $secondary-content; - } - } - - .mx_UserOnboardingList_progress { - display: flex; - flex-direction: column; - counter-reset: user-onboarding; - - .mx_ProgressBar { - width: auto; - margin-top: $spacing-16; - height: 16px; - - @mixin ProgressBarBorderRadius 16px; - } - } - - .mx_UserOnboardingList_list { - display: grid; - grid-template-columns: max-content 1fr max-content; - - appearance: none; - list-style: none; - margin: $spacing-32 0 0; - padding: 0; - - grid-gap: $spacing-24; - } -} - -@keyframes mx_UserOnboardingList_slideIn { - 0% { - transform: translate(0, 8px); - opacity: 0; - } - 100% { - transform: translate(0, 0); - opacity: 1; - } -} diff --git a/res/css/views/user-onboarding/_UserOnboardingPage.pcss b/res/css/views/user-onboarding/_UserOnboardingPage.pcss deleted file mode 100644 index 285a1b34d4..0000000000 --- a/res/css/views/user-onboarding/_UserOnboardingPage.pcss +++ /dev/null @@ -1,27 +0,0 @@ -/* -Copyright 2024 New Vector Ltd. -Copyright 2022 The Matrix.org Foundation C.I.C. - -SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial -Please see LICENSE files in the repository root for full details. -*/ - -.mx_UserOnboardingPage { - width: 100%; - height: 100%; - - align-self: stretch; - max-width: 1200px; - margin: 0 auto auto; - - display: flex; - flex-direction: column; - box-sizing: border-box; - - gap: $spacing-64; - padding: $spacing-64 100px; - - @media (max-width: 1280px) { - padding: $spacing-48 $spacing-32; - } -} diff --git a/res/css/views/user-onboarding/_UserOnboardingTask.pcss b/res/css/views/user-onboarding/_UserOnboardingTask.pcss deleted file mode 100644 index 756a9d3604..0000000000 --- a/res/css/views/user-onboarding/_UserOnboardingTask.pcss +++ /dev/null @@ -1,112 +0,0 @@ -/* -Copyright 2024 New Vector Ltd. -Copyright 2022 The Matrix.org Foundation C.I.C. - -SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial -Please see LICENSE files in the repository root for full details. -*/ - -.mx_UserOnboardingTask { - display: contents; - - .mx_UserOnboardingTask_number { - counter-increment: user-onboarding; - grid-column: 1; - color: $secondary-content; - width: 32px; - height: 32px; - text-align: center; - border: 2px solid $quinary-content; - border-radius: 32px; - line-height: 32px; - align-self: center; - position: relative; - - &::before { - content: counter(user-onboarding); - } - } - - .mx_UserOnboardingTask_content { - grid-column: 2; - display: flex; - flex-direction: column; - flex-grow: 1; - flex-shrink: 1; - - transition: all 500ms; - - .mx_UserOnboardingTask_title { - font: var(--cpd-font-body-md-medium); - } - - .mx_UserOnboardingTask_description { - font-size: $font-12px; - } - } - - .mx_UserOnboardingTask_action.mx_AccessibleButton { - grid-column: 3; - min-width: 180px; - - @media (max-width: 800px) { - grid-column: 2; - margin-top: -16px; - } - } - - &.mx_UserOnboardingTask_completed { - .mx_UserOnboardingTask_number { - &::before { - content: ""; - position: absolute; - inset: -2px; - background: var(--cpd-color-icon-accent-tertiary); - border-radius: 32px; - - animation-duration: 300ms; - animation-fill-mode: both; - animation-name: mx_UserOnboardingTask_spring; - will-change: opacity, transform; - } - - &::after { - background-color: var(--cpd-color-icon-on-solid-primary); - content: ""; - mask-repeat: no-repeat; - mask-position: center; - mask-size: 24px; - width: inherit; - height: inherit; - position: absolute; - left: 0; - top: 0; - mask-image: url("@vector-im/compound-design-tokens/icons/check.svg"); - - animation-duration: 300ms; - animation-fill-mode: both; - animation-name: mx_UserOnboardingTask_spring; - will-change: opacity, transform; - } - } - - .mx_UserOnboardingTask_content { - opacity: 0.6; - } - } -} - -@keyframes mx_UserOnboardingTask_spring { - 0% { - opacity: 0; - transform: scale(0.6); - } - 50% { - opacity: 1; - transform: scale(1.2); - } - 100% { - opacity: 1; - transform: scale(1); - } -} diff --git a/res/fonts/Twemoji_Mozilla/TwemojiMozilla-colr.woff2 b/res/fonts/Twemoji_Mozilla/TwemojiMozilla-colr.woff2 index c9ecd7a0da..5bfc425d66 100644 Binary files a/res/fonts/Twemoji_Mozilla/TwemojiMozilla-colr.woff2 and b/res/fonts/Twemoji_Mozilla/TwemojiMozilla-colr.woff2 differ diff --git a/res/img/badges/f-droid.svg b/res/img/badges/f-droid.svg deleted file mode 100644 index d97143c42b..0000000000 --- a/res/img/badges/f-droid.svg +++ /dev/null @@ -1 +0,0 @@ - diff --git a/res/img/badges/google-play.svg b/res/img/badges/google-play.svg deleted file mode 100644 index 973d9d3afc..0000000000 --- a/res/img/badges/google-play.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/res/img/badges/ios.svg b/res/img/badges/ios.svg deleted file mode 100644 index e723d1cc04..0000000000 --- a/res/img/badges/ios.svg +++ /dev/null @@ -1 +0,0 @@ - diff --git a/res/img/element-icons/roomlist/dnd-cross.svg b/res/img/element-icons/roomlist/dnd-cross.svg deleted file mode 100644 index 2091d59802..0000000000 --- a/res/img/element-icons/roomlist/dnd-cross.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/res/img/element-icons/roomlist/dnd.svg b/res/img/element-icons/roomlist/dnd.svg deleted file mode 100644 index 8c4a86e519..0000000000 --- a/res/img/element-icons/roomlist/dnd.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/res/img/user-onboarding/CommunityMessaging.png b/res/img/user-onboarding/CommunityMessaging.png deleted file mode 100644 index ec13eef8d6..0000000000 Binary files a/res/img/user-onboarding/CommunityMessaging.png and /dev/null differ diff --git a/res/img/user-onboarding/PersonalMessaging.png b/res/img/user-onboarding/PersonalMessaging.png deleted file mode 100644 index 8dce18ad90..0000000000 Binary files a/res/img/user-onboarding/PersonalMessaging.png and /dev/null differ diff --git a/res/img/user-onboarding/WorkMessaging.png b/res/img/user-onboarding/WorkMessaging.png deleted file mode 100644 index 7c3b813a84..0000000000 Binary files a/res/img/user-onboarding/WorkMessaging.png and /dev/null differ diff --git a/res/themes/dark/css/_dark.pcss b/res/themes/dark/css/_dark.pcss index 2d3ea2e4f4..c9c86717e6 100644 --- a/res/themes/dark/css/_dark.pcss +++ b/res/themes/dark/css/_dark.pcss @@ -309,11 +309,8 @@ body { /* Splash Page Gradient */ .mx_SplashPage::before { - background-image: radial-gradient( - 53.85% 66.75% at 87.55% 0%, - hsla(0deg, 0%, 11%, 0.15) 0%, - hsla(250deg, 100%, 88%, 0) 100% - ), + background-image: + radial-gradient(53.85% 66.75% at 87.55% 0%, hsla(0deg, 0%, 11%, 0.15) 0%, hsla(250deg, 100%, 88%, 0) 100%), radial-gradient(41.93% 41.93% at 0% 0%, hsla(0deg, 0%, 38%, 0.28) 0%, hsla(250deg, 100%, 88%, 0) 100%), radial-gradient(100% 100% at 0% 0%, hsla(250deg, 100%, 88%, 0.3) 0%, hsla(0deg, 100%, 86%, 0) 100%), radial-gradient(106.35% 96.26% at 100% 0%, hsla(25deg, 100%, 88%, 0.4) 0%, hsla(167deg, 76%, 82%, 0) 100%) !important; diff --git a/res/themes/legacy-light/css/_legacy-light.pcss b/res/themes/legacy-light/css/_legacy-light.pcss index 32ca7d3d1a..eea7197d9f 100644 --- a/res/themes/legacy-light/css/_legacy-light.pcss +++ b/res/themes/legacy-light/css/_legacy-light.pcss @@ -10,11 +10,13 @@ /* Noto Color Emoji contains digits, in fixed-width, therefore causing digits in flowed text to stand out. TODO: Consider putting all emoji fonts to the end rather than the front. */ -$font-family: "Nunito", var(--emoji-font-family), "Apple Color Emoji", "Segoe UI Emoji", "Arial", "Helvetica", - sans-serif, "Noto Color Emoji"; +$font-family: + "Nunito", var(--emoji-font-family), "Apple Color Emoji", "Segoe UI Emoji", "Arial", "Helvetica", sans-serif, + "Noto Color Emoji"; -$monospace-font-family: "Inconsolata", var(--emoji-font-family), "Apple Color Emoji", "Segoe UI Emoji", "Courier", - monospace, "Noto Color Emoji"; +$monospace-font-family: + "Inconsolata", var(--emoji-font-family), "Apple Color Emoji", "Segoe UI Emoji", "Courier", monospace, + "Noto Color Emoji"; /* unified palette */ /* try to use these colors when possible */ diff --git a/res/themes/light/css/_light.pcss b/res/themes/light/css/_light.pcss index 1a1705a9c1..a6bb29bac4 100644 --- a/res/themes/light/css/_light.pcss +++ b/res/themes/light/css/_light.pcss @@ -10,11 +10,13 @@ /* Noto Color Emoji contains digits, in fixed-width, therefore causing digits in flowed text to stand out. TODO: Consider putting all emoji fonts to the end rather than the front. */ -$font-family: "Inter", var(--emoji-font-family), "Apple Color Emoji", "Segoe UI Emoji", "Arial", "Helvetica", - sans-serif, "Noto Color Emoji"; +$font-family: + "Inter", var(--emoji-font-family), "Apple Color Emoji", "Segoe UI Emoji", "Arial", "Helvetica", sans-serif, + "Noto Color Emoji"; -$monospace-font-family: "Inconsolata", var(--emoji-font-family), "Apple Color Emoji", "Segoe UI Emoji", "Courier", - monospace, "Noto Color Emoji"; +$monospace-font-family: + "Inconsolata", var(--emoji-font-family), "Apple Color Emoji", "Segoe UI Emoji", "Courier", monospace, + "Noto Color Emoji"; /* Colors from Figma Compound https://www.figma.com/file/X4XTH9iS2KGJ2wFKDqkyed/Compound?node-id=559%3A120 */ /* ******************** */ diff --git a/scripts/fetchdep.sh b/scripts/fetchdep.sh index 666a74ff67..1c34beaf42 100755 --- a/scripts/fetchdep.sh +++ b/scripts/fetchdep.sh @@ -72,6 +72,13 @@ if [[ "$head" == *":"* ]]; then fi clone ${TRY_ORG} $defrepo ${TRY_BRANCH} +# For merge queue runs we need to extract the temporary branch name +# the ref_name will look like `gh-readonly-queue//pr--` +if [[ "$GITHUB_EVENT_NAME" == "merge_group" ]]; then + withoutPrefix=${GITHUB_REF_NAME#gh-readonly-queue/} + clone $deforg $defrepo ${withoutPrefix%%/pr-*} +fi + # Try the target branch of the push or PR. if [ -n "$GITHUB_BASE_REF" ]; then clone $deforg $defrepo $GITHUB_BASE_REF diff --git a/src/@types/common.ts b/src/@types/common.ts index cdf969a698..85e0b95043 100644 --- a/src/@types/common.ts +++ b/src/@types/common.ts @@ -6,7 +6,7 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com Please see LICENSE files in the repository root for full details. */ -import { JSXElementConstructor } from "react"; +import { type JSXElementConstructor } from "react"; export type { NonEmptyArray, XOR, Writeable } from "matrix-js-sdk/src/matrix"; diff --git a/src/@types/commonmark.ts b/src/@types/commonmark.ts index 2d3be1b243..dde24d0251 100644 --- a/src/@types/commonmark.ts +++ b/src/@types/commonmark.ts @@ -6,7 +6,7 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com Please see LICENSE files in the repository root for full details. */ -import * as commonmark from "commonmark"; +import type * as commonmark from "commonmark"; declare module "commonmark" { export type Attr = [key: string, value: string]; diff --git a/src/@types/diff-dom.d.ts b/src/@types/diff-dom.d.ts index 986a84dc0b..12587446d0 100644 --- a/src/@types/diff-dom.d.ts +++ b/src/@types/diff-dom.d.ts @@ -18,6 +18,7 @@ declare module "diff-dom" { newValue: HTMLElement | string; } + // eslint-disable-next-line @typescript-eslint/no-empty-object-type interface IOpts {} export class DiffDOM { diff --git a/src/@types/global.d.ts b/src/@types/global.d.ts index c76c43f829..3bbeda067b 100644 --- a/src/@types/global.d.ts +++ b/src/@types/global.d.ts @@ -10,41 +10,44 @@ Please see LICENSE files in the repository root for full details. import "matrix-js-sdk/src/@types/global"; // load matrix-js-sdk's type extensions first import "@types/modernizr"; +import type { ModuleLoader } from "@element-hq/element-web-module-api"; import type { logger } from "matrix-js-sdk/src/logger"; -import ContentMessages from "../ContentMessages"; -import { IMatrixClientPeg } from "../MatrixClientPeg"; -import ToastStore from "../stores/ToastStore"; -import DeviceListener from "../DeviceListener"; -import { RoomListStore } from "../stores/room-list/Interface"; -import { PlatformPeg } from "../PlatformPeg"; -import RoomListLayoutStore from "../stores/room-list/RoomListLayoutStore"; -import { IntegrationManagers } from "../integrations/IntegrationManagers"; -import { ModalManager } from "../Modal"; -import SettingsStore from "../settings/SettingsStore"; -import { Notifier } from "../Notifier"; -import RightPanelStore from "../stores/right-panel/RightPanelStore"; -import WidgetStore from "../stores/WidgetStore"; -import LegacyCallHandler from "../LegacyCallHandler"; -import UserActivity from "../UserActivity"; -import { ModalWidgetStore } from "../stores/ModalWidgetStore"; -import { WidgetLayoutStore } from "../stores/widgets/WidgetLayoutStore"; -import VoipUserMapper from "../VoipUserMapper"; -import { SpaceStoreClass } from "../stores/spaces/SpaceStore"; -import TypingStore from "../stores/TypingStore"; -import { EventIndexPeg } from "../indexing/EventIndexPeg"; -import { VoiceRecordingStore } from "../stores/VoiceRecordingStore"; -import PerformanceMonitor from "../performance"; -import UIStore from "../stores/UIStore"; -import { SetupEncryptionStore } from "../stores/SetupEncryptionStore"; -import { RoomScrollStateStore } from "../stores/RoomScrollStateStore"; -import { ConsoleLogger, IndexedDBLogStore } from "../rageshake/rageshake"; -import ActiveWidgetStore from "../stores/ActiveWidgetStore"; -import AutoRageshakeStore from "../stores/AutoRageshakeStore"; -import { IConfigOptions } from "../IConfigOptions"; -import { MatrixDispatcher } from "../dispatcher/dispatcher"; -import { DeepReadonly } from "./common"; -import MatrixChat from "../components/structures/MatrixChat"; -import { InitialCryptoSetupStore } from "../stores/InitialCryptoSetupStore"; +import type ContentMessages from "../ContentMessages"; +import { type IMatrixClientPeg } from "../MatrixClientPeg"; +import type ToastStore from "../stores/ToastStore"; +import type DeviceListener from "../DeviceListener"; +import { type RoomListStore } from "../stores/room-list/Interface"; +import { type PlatformPeg } from "../PlatformPeg"; +import type RoomListLayoutStore from "../stores/room-list/RoomListLayoutStore"; +import { type IntegrationManagers } from "../integrations/IntegrationManagers"; +import { type ModalManager } from "../Modal"; +import type SettingsStore from "../settings/SettingsStore"; +import { type Notifier } from "../Notifier"; +import type RightPanelStore from "../stores/right-panel/RightPanelStore"; +import type WidgetStore from "../stores/WidgetStore"; +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"; +import { type VoiceRecordingStore } from "../stores/VoiceRecordingStore"; +import type PerformanceMonitor from "../performance"; +import type UIStore from "../stores/UIStore"; +import { type SetupEncryptionStore } from "../stores/SetupEncryptionStore"; +import { type RoomScrollStateStore } from "../stores/RoomScrollStateStore"; +import { type ConsoleLogger, type IndexedDBLogStore } from "../rageshake/rageshake"; +import type ActiveWidgetStore from "../stores/ActiveWidgetStore"; +import type AutoRageshakeStore from "../stores/AutoRageshakeStore"; +import { type IConfigOptions } from "../IConfigOptions"; +import { type MatrixDispatcher } from "../dispatcher/dispatcher"; +import { type DeepReadonly } from "./common"; +import type MatrixChat from "../components/structures/MatrixChat"; +import { type InitialCryptoSetupStore } from "../stores/InitialCryptoSetupStore"; +import { type ModuleApiType } from "../modules/Api.ts"; +import type { RoomListStoreV3Class } from "../stores/room-list-v3/RoomListStoreV3.ts"; /* eslint-disable @typescript-eslint/naming-convention */ @@ -97,6 +100,7 @@ declare global { mxToastStore: ToastStore; mxDeviceListener: DeviceListener; mxRoomListStore: RoomListStore; + mxRoomListStoreV3: RoomListStoreV3Class; mxRoomListLayoutStore: RoomListLayoutStore; mxPlatformPeg: PlatformPeg; mxIntegrationManagers: typeof IntegrationManagers; @@ -122,6 +126,8 @@ declare global { mxRoomScrollStateStore?: RoomScrollStateStore; mxActiveWidgetStore?: ActiveWidgetStore; mxOnRecaptchaLoaded?: () => void; + mxModuleLoader: ModuleLoader; + mxModuleApi: ModuleApiType; // electron-only electron?: Electron; diff --git a/src/@types/matrix-js-sdk.d.ts b/src/@types/matrix-js-sdk.d.ts index 6ffa09dd4f..92b76c4c4d 100644 --- a/src/@types/matrix-js-sdk.d.ts +++ b/src/@types/matrix-js-sdk.d.ts @@ -11,6 +11,7 @@ import type { BLURHASH_FIELD } from "../utils/image-media"; import type { JitsiCallMemberEventType, JitsiCallMemberContent } from "../call-types"; import type { ILayoutStateEvent, WIDGET_LAYOUT_EVENT_TYPE } from "../stores/widgets/types"; 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"; @@ -35,7 +36,7 @@ declare module "matrix-js-sdk/src/types" { [JitsiCallMemberEventType]: JitsiCallMemberContent; // Unstable widgets state events - "im.vector.modular.widgets": IWidget | {}; + "im.vector.modular.widgets": IWidget | EmptyObject; [WIDGET_LAYOUT_EVENT_TYPE]: ILayoutStateEvent; // Element custom state events @@ -104,6 +105,6 @@ declare module "matrix-js-sdk/src/types" { // https://github.com/matrix-org/matrix-doc/pull/3246 waveform?: number[]; }; - "org.matrix.msc3245.voice"?: {}; + "org.matrix.msc3245.voice"?: EmptyObject; } } diff --git a/src/@types/react.d.ts b/src/@types/react.d.ts index 2573bc0ab9..d66c22e56f 100644 --- a/src/@types/react.d.ts +++ b/src/@types/react.d.ts @@ -6,11 +6,13 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com Please see LICENSE files in the repository root for full details. */ -import React, { PropsWithChildren } from "react"; +import { type PropsWithChildren } from "react"; + +import type React from "react"; declare module "react" { // Fix forwardRef types for Generic components - https://stackoverflow.com/a/58473012 - function forwardRef( + function forwardRef( render: (props: PropsWithChildren

, ref: React.ForwardedRef) => React.ReactElement | null, ): (props: P & React.RefAttributes) => React.ReactElement | null; diff --git a/src/AddThreepid.ts b/src/AddThreepid.ts index 757ea18180..7d2c4cefb5 100644 --- a/src/AddThreepid.ts +++ b/src/AddThreepid.ts @@ -9,21 +9,22 @@ Please see LICENSE files in the repository root for full details. */ import { - IAddThreePidOnlyBody, - IRequestMsisdnTokenResponse, - IRequestTokenResponse, - MatrixClient, + type IAddThreePidOnlyBody, + type IRequestMsisdnTokenResponse, + type IRequestTokenResponse, + type MatrixClient, MatrixError, HTTPError, - IThreepid, - UIAResponse, + type IThreepid, } from "matrix-js-sdk/src/matrix"; import Modal from "./Modal"; import { _t, UserFriendlyError } from "./languageHandler"; import IdentityAuthClient from "./IdentityAuthClient"; import { SSOAuthEntry } from "./components/views/auth/InteractiveAuthEntryComponents"; -import InteractiveAuthDialog, { InteractiveAuthDialogProps } from "./components/views/dialogs/InteractiveAuthDialog"; +import InteractiveAuthDialog, { + type InteractiveAuthDialogProps, +} from "./components/views/dialogs/InteractiveAuthDialog"; function getIdServerDomain(matrixClient: MatrixClient): string { const idBaseUrl = matrixClient.getIdentityServerUrl(true); @@ -179,9 +180,7 @@ export default class AddThreepid { * with a "message" property which contains a human-readable message detailing why * the request failed. */ - public async checkEmailLinkClicked(): Promise< - [success?: boolean, result?: UIAResponse | Error | null] - > { + public async checkEmailLinkClicked(): Promise<[success?: boolean, result?: IAddThreePidOnlyBody | Error | null]> { try { if (this.bind) { const authClient = new IdentityAuthClient(); @@ -249,6 +248,7 @@ export default class AddThreepid { * @param {{type: string, session?: string}} auth UI auth object * @return {Promise} Response from /3pid/add call (in current spec, an empty object) */ + // eslint-disable-next-line @typescript-eslint/no-empty-object-type private makeAddThreepidOnlyRequest = (auth?: IAddThreePidOnlyBody["auth"] | null): Promise<{}> => { return this.matrixClient.addThreePidOnly({ sid: this.sessionId!, @@ -267,7 +267,7 @@ export default class AddThreepid { */ public async haveMsisdnToken( msisdnToken: string, - ): Promise<[success?: boolean, result?: UIAResponse | Error | null]> { + ): Promise<[success?: boolean, result?: IAddThreePidOnlyBody | Error | null]> { const authClient = new IdentityAuthClient(); if (this.submitUrl) { diff --git a/src/AsyncWrapper.tsx b/src/AsyncWrapper.tsx index e1b80c9d5a..e3d9c2f524 100644 --- a/src/AsyncWrapper.tsx +++ b/src/AsyncWrapper.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, { ReactNode, Suspense } from "react"; +import React, { type ReactNode, Suspense } from "react"; import { _t } from "./languageHandler"; import BaseDialog from "./components/views/dialogs/BaseDialog"; diff --git a/src/Avatar.ts b/src/Avatar.ts index a6725710d1..abddfd87f1 100644 --- a/src/Avatar.ts +++ b/src/Avatar.ts @@ -6,7 +6,7 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com Please see LICENSE files in the repository root for full details. */ -import { RoomMember, User, Room, ResizeMethod } from "matrix-js-sdk/src/matrix"; +import { type RoomMember, type User, type Room, type ResizeMethod } from "matrix-js-sdk/src/matrix"; import { useIdColorHash } from "@vector-im/compound-web"; import DMRoomMap from "./utils/DMRoomMap"; diff --git a/src/BasePlatform.ts b/src/BasePlatform.ts index db4802d4bb..2093881cc5 100644 --- a/src/BasePlatform.ts +++ b/src/BasePlatform.ts @@ -10,25 +10,25 @@ Please see LICENSE files in the repository root for full details. */ import { - MatrixClient, - MatrixEvent, - Room, - SSOAction, + type MatrixClient, + type MatrixEvent, + type Room, + type SSOAction, encodeUnpaddedBase64, - OidcRegistrationClientMetadata, + type OidcRegistrationClientMetadata, } from "matrix-js-sdk/src/matrix"; import { logger } from "matrix-js-sdk/src/logger"; import dis from "./dispatcher/dispatcher"; -import BaseEventIndexManager from "./indexing/BaseEventIndexManager"; -import { ActionPayload } from "./dispatcher/payloads"; -import { CheckUpdatesPayload } from "./dispatcher/payloads/CheckUpdatesPayload"; +import type BaseEventIndexManager from "./indexing/BaseEventIndexManager"; +import { type ActionPayload } from "./dispatcher/payloads"; +import { type CheckUpdatesPayload } from "./dispatcher/payloads/CheckUpdatesPayload"; import { Action } from "./dispatcher/actions"; import { hideToast as hideUpdateToast } from "./toasts/UpdateToast"; import { MatrixClientPeg } from "./MatrixClientPeg"; import { idbLoad, idbSave, idbDelete } from "./utils/StorageAccess"; -import { ViewRoomPayload } from "./dispatcher/payloads/ViewRoomPayload"; -import { IConfigOptions } from "./IConfigOptions"; +import { type ViewRoomPayload } from "./dispatcher/payloads/ViewRoomPayload"; +import { type IConfigOptions } from "./IConfigOptions"; import SdkConfig from "./SdkConfig"; import { buildAndEncodePickleKey, encryptPickleKey } from "./utils/tokens/pickling"; import Favicon from "./favicon.ts"; diff --git a/src/BlurhashEncoder.ts b/src/BlurhashEncoder.ts index aef0746dbe..ab6969d0c8 100644 --- a/src/BlurhashEncoder.ts +++ b/src/BlurhashEncoder.ts @@ -6,7 +6,7 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com Please see LICENSE files in the repository root for full details. */ -import { Request, Response } from "./workers/blurhash.worker.ts"; +import { type Request, type Response } from "./workers/blurhash.worker.ts"; import { WorkerManager } from "./WorkerManager"; import blurhashWorkerFactory from "./workers/blurhashWorkerFactory"; diff --git a/src/ContentMessages.ts b/src/ContentMessages.ts index 060c8ab994..c5e34d7130 100644 --- a/src/ContentMessages.ts +++ b/src/ContentMessages.ts @@ -9,23 +9,23 @@ Please see LICENSE files in the repository root for full details. */ import { - MatrixClient, + type MatrixClient, MsgType, HTTPError, - IEventRelation, - ISendEventResponse, - MatrixEvent, - UploadOpts, - UploadProgress, + type IEventRelation, + type ISendEventResponse, + type MatrixEvent, + type UploadOpts, + type UploadProgress, THREAD_RELATION_TYPE, } from "matrix-js-sdk/src/matrix"; import { - ImageInfo, - AudioInfo, - VideoInfo, - EncryptedFile, - MediaEventContent, - MediaEventInfo, + type ImageInfo, + type AudioInfo, + type VideoInfo, + type EncryptedFile, + type MediaEventContent, + type MediaEventInfo, } from "matrix-js-sdk/src/types"; import encrypt from "matrix-encrypt-attachment"; import extractPngChunks from "png-chunks-extract"; @@ -38,11 +38,11 @@ import Modal from "./Modal"; import Spinner from "./components/views/elements/Spinner"; import { Action } from "./dispatcher/actions"; import { - UploadCanceledPayload, - UploadErrorPayload, - UploadFinishedPayload, - UploadProgressPayload, - UploadStartedPayload, + type UploadCanceledPayload, + type UploadErrorPayload, + type UploadFinishedPayload, + type UploadProgressPayload, + type UploadStartedPayload, } from "./dispatcher/payloads/UploadPayload"; import { RoomUpload } from "./models/RoomUpload"; import SettingsStore from "./settings/SettingsStore"; diff --git a/src/CreateCrossSigning.ts b/src/CreateCrossSigning.ts index 0c043d9d2b..db9bc3e3fe 100644 --- a/src/CreateCrossSigning.ts +++ b/src/CreateCrossSigning.ts @@ -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 { AuthDict, MatrixClient, MatrixError, UIAResponse } from "matrix-js-sdk/src/matrix"; +import { type AuthDict, type MatrixClient, MatrixError } from "matrix-js-sdk/src/matrix"; import { SSOAuthEntry } from "./components/views/auth/InteractiveAuthEntryComponents"; import Modal from "./Modal"; @@ -31,49 +31,50 @@ export async function createCrossSigning(cli: MatrixClient): Promise { throw new Error("No crypto API found!"); } - const doBootstrapUIAuth = async ( - makeRequest: (authData: AuthDict) => Promise>, - ): Promise => { - try { - await makeRequest({}); - } catch (error) { - if (!(error instanceof MatrixError) || !error.data || !error.data.flows) { - // Not a UIA response - throw error; - } - - const dialogAesthetics = { - [SSOAuthEntry.PHASE_PREAUTH]: { - title: _t("auth|uia|sso_title"), - body: _t("auth|uia|sso_preauth_body"), - continueText: _t("auth|sso"), - continueKind: "primary", - }, - [SSOAuthEntry.PHASE_POSTAUTH]: { - title: _t("encryption|confirm_encryption_setup_title"), - body: _t("encryption|confirm_encryption_setup_body"), - continueText: _t("action|confirm"), - continueKind: "primary", - }, - }; - - const { finished } = Modal.createDialog(InteractiveAuthDialog, { - title: _t("encryption|bootstrap_title"), - matrixClient: cli, - makeRequest, - aestheticsForStagePhases: { - [SSOAuthEntry.LOGIN_TYPE]: dialogAesthetics, - [SSOAuthEntry.UNSTABLE_LOGIN_TYPE]: dialogAesthetics, - }, - }); - const [confirmed] = await finished; - if (!confirmed) { - throw new Error("Cross-signing key upload auth canceled"); - } - } - }; - await cryptoApi.bootstrapCrossSigning({ - authUploadDeviceSigningKeys: doBootstrapUIAuth, + authUploadDeviceSigningKeys: (makeRequest) => uiAuthCallback(cli, makeRequest), }); } + +export async function uiAuthCallback( + matrixClient: MatrixClient, + makeRequest: (authData: AuthDict) => Promise, +): Promise { + try { + await makeRequest({}); + } catch (error) { + if (!(error instanceof MatrixError) || !error.data || !error.data.flows) { + // Not a UIA response + throw error; + } + + const dialogAesthetics = { + [SSOAuthEntry.PHASE_PREAUTH]: { + title: _t("auth|uia|sso_title"), + body: _t("auth|uia|sso_preauth_body"), + continueText: _t("auth|sso"), + continueKind: "primary", + }, + [SSOAuthEntry.PHASE_POSTAUTH]: { + title: _t("encryption|confirm_encryption_setup_title"), + body: _t("encryption|confirm_encryption_setup_body"), + continueText: _t("action|confirm"), + continueKind: "primary", + }, + }; + + const { finished } = Modal.createDialog(InteractiveAuthDialog, { + title: _t("encryption|bootstrap_title"), + matrixClient, + makeRequest, + aestheticsForStagePhases: { + [SSOAuthEntry.LOGIN_TYPE]: dialogAesthetics, + [SSOAuthEntry.UNSTABLE_LOGIN_TYPE]: dialogAesthetics, + }, + }); + const [confirmed] = await finished; + if (!confirmed) { + throw new Error("Cross-signing key upload auth canceled"); + } + } +} diff --git a/src/DateUtils.ts b/src/DateUtils.ts index 7c7b7dd7e6..e788ca09bf 100644 --- a/src/DateUtils.ts +++ b/src/DateUtils.ts @@ -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 { Optional } from "matrix-events-sdk"; +import { type Optional } from "matrix-events-sdk"; import { _t, getUserLanguage } from "./languageHandler"; import { getUserTimezone } from "./TimezoneHandler"; @@ -38,7 +38,7 @@ export function getMonthsArray(month: Intl.DateTimeFormatOptions["month"] = "sho // XXX: Ideally we could just specify `hour12: boolean` but it has issues on Chrome in the `en` locale // https://support.google.com/chrome/thread/29828561?hl=en -function getTwelveHourOptions(showTwelveHour: boolean): Intl.DateTimeFormatOptions { +export function getTwelveHourOptions(showTwelveHour: boolean): Intl.DateTimeFormatOptions { return { hourCycle: showTwelveHour ? "h12" : "h23", }; diff --git a/src/DecryptionFailureTracker.ts b/src/DecryptionFailureTracker.ts index 6a226ad3fe..8725a0db0d 100644 --- a/src/DecryptionFailureTracker.ts +++ b/src/DecryptionFailureTracker.ts @@ -7,8 +7,8 @@ Please see LICENSE files in the repository root for full details. */ import ScalableBloomFilter from "bloom-filters/dist/bloom/scalable-bloom-filter"; -import { HttpApiEvent, MatrixClient, MatrixEventEvent, MatrixEvent } from "matrix-js-sdk/src/matrix"; -import { Error as ErrorEvent } from "@matrix-org/analytics-events/types/typescript/Error"; +import { HttpApiEvent, type MatrixClient, MatrixEventEvent, type MatrixEvent } from "matrix-js-sdk/src/matrix"; +import { type Error as ErrorEvent } from "@matrix-org/analytics-events/types/typescript/Error"; import { DecryptionFailureCode, CryptoEvent } from "matrix-js-sdk/src/crypto-api"; import { PosthogAnalytics } from "./PosthogAnalytics"; diff --git a/src/DeviceListener.ts b/src/DeviceListener.ts index 28bb5f655e..751e71dd9f 100644 --- a/src/DeviceListener.ts +++ b/src/DeviceListener.ts @@ -7,17 +7,18 @@ Please see LICENSE files in the repository root for full details. */ import { - MatrixEvent, + type MatrixEvent, ClientEvent, EventType, - MatrixClient, + type MatrixClient, RoomStateEvent, - SyncState, + type SyncState, ClientStoppedError, } from "matrix-js-sdk/src/matrix"; -import { logger } from "matrix-js-sdk/src/logger"; -import { CryptoEvent, KeyBackupInfo } from "matrix-js-sdk/src/crypto-api"; -import { CryptoSessionStateChange } from "@matrix-org/analytics-events/types/typescript/CryptoSessionStateChange"; +import { logger as baseLogger, LogSpan } from "matrix-js-sdk/src/logger"; +import { CryptoEvent, type KeyBackupInfo } from "matrix-js-sdk/src/crypto-api"; +import { type CryptoSessionStateChange } from "@matrix-org/analytics-events/types/typescript/CryptoSessionStateChange"; +import { secureRandomString } from "matrix-js-sdk/src/randomstring"; import { PosthogAnalytics } from "./PosthogAnalytics"; import dis from "./dispatcher/dispatcher"; @@ -34,15 +35,13 @@ import { hideToast as hideUnverifiedSessionsToast, showToast as showUnverifiedSessionsToast, } from "./toasts/UnverifiedSessionToast"; -import { accessSecretStorage, isSecretStorageBeingAccessed } from "./SecurityManager"; -import { isSecureBackupRequired } from "./utils/WellKnownUtils"; -import { ActionPayload } from "./dispatcher/payloads"; +import { isSecretStorageBeingAccessed } from "./SecurityManager"; +import { type ActionPayload } from "./dispatcher/payloads"; import { Action } from "./dispatcher/actions"; -import { isLoggedIn } from "./utils/login"; import SdkConfig from "./SdkConfig"; import PlatformPeg from "./PlatformPeg"; import { recordClientInformation, removeClientInformation } from "./utils/device/clientInformation"; -import SettingsStore, { CallbackFn } from "./settings/SettingsStore"; +import SettingsStore, { type CallbackFn } from "./settings/SettingsStore"; import { UIFeature } from "./settings/UIFeature"; import { isBulkUnverifiedDeviceReminderSnoozed } from "./utils/device/snoozeBulkUnverifiedDeviceReminder"; import { getUserDeviceIds } from "./utils/crypto/deviceInfo"; @@ -50,6 +49,16 @@ import { asyncSomeParallel } from "./utils/arrays.ts"; const KEY_BACKUP_POLL_INTERVAL = 5 * 60 * 1000; +/** + * Unfortunately-named account data key used by Element X to indicate that the user + * has chosen to disable server side key backups. + * + * We need to set and honour this to prevent Element X from automatically turning key backup back on. + */ +export const BACKUP_DISABLED_ACCOUNT_DATA_KEY = "m.org.matrix.custom.backup_disabled"; + +const logger = baseLogger.getChild("DeviceListener:"); + export default class DeviceListener { private dispatcherRef?: string; // device IDs for which the user has dismissed the verify toast ('Later') @@ -91,6 +100,7 @@ export default class DeviceListener { this.client.on(ClientEvent.AccountData, this.onAccountData); this.client.on(ClientEvent.Sync, this.onSync); this.client.on(RoomStateEvent.Events, this.onRoomStateEvents); + this.client.on(ClientEvent.ToDeviceEvent, this.onToDeviceEvent); this.shouldRecordClientInformation = SettingsStore.getValue("deviceClientInformationOptIn"); // only configurable in config, so we don't need to watch the value this.enableBulkUnverifiedSessionsReminder = SettingsStore.getValue(UIFeature.BulkUnverifiedSessionsReminder); @@ -113,6 +123,7 @@ export default class DeviceListener { this.client.removeListener(ClientEvent.AccountData, this.onAccountData); this.client.removeListener(ClientEvent.Sync, this.onSync); this.client.removeListener(RoomStateEvent.Events, this.onRoomStateEvents); + this.client.removeListener(ClientEvent.ToDeviceEvent, this.onToDeviceEvent); } SettingsStore.unwatchSetting(this.deviceClientInformationSettingWatcherRef); dis.unregister(this.dispatcherRef); @@ -133,7 +144,7 @@ export default class DeviceListener { * @param {String[]} deviceIds List of device IDs to dismiss notifications for */ public async dismissUnverifiedSessions(deviceIds: Iterable): Promise { - logger.log("Dismissing unverified sessions: " + Array.from(deviceIds).join(",")); + logger.debug("Dismissing unverified sessions: " + Array.from(deviceIds).join(",")); for (const d of deviceIds) { this.dismissed.add(d); } @@ -220,6 +231,11 @@ export default class DeviceListener { this.updateClientInformation(); }; + private onToDeviceEvent = (event: MatrixEvent): void => { + // Receiving a 4S secret can mean we are in sync where we were not before. + if (event.getType() === EventType.SecretSend) this.recheck(); + }; + /** * Fetch the key backup information from the server. * @@ -268,25 +284,51 @@ export default class DeviceListener { private async doRecheck(): Promise { if (!this.running || !this.client) return; // we have been stopped + const logSpan = new LogSpan(logger, "check_" + secureRandomString(4)); + const cli = this.client; // cross-signing support was added to Matrix in MSC1756, which landed in spec v1.1 - if (!(await cli.isVersionSupported("v1.1"))) return; + if (!(await cli.isVersionSupported("v1.1"))) { + logSpan.debug("cross-signing not supported"); + return; + } const crypto = cli.getCrypto(); - if (!crypto) return; + if (!crypto) { + logSpan.debug("crypto not enabled"); + return; + } // don't recheck until the initial sync is complete: lots of account data events will fire // while the initial sync is processing and we don't need to recheck on each one of them // (we add a listener on sync to do once check after the initial sync is done) - if (!cli.isInitialSyncComplete()) return; + if (!cli.isInitialSyncComplete()) { + logSpan.debug("initial sync not yet complete"); + return; + } const crossSigningReady = await crypto.isCrossSigningReady(); const secretStorageReady = await crypto.isSecretStorageReady(); - const allSystemsReady = crossSigningReady && secretStorageReady; + const crossSigningStatus = await crypto.getCrossSigningStatus(); + const allCrossSigningSecretsCached = + crossSigningStatus.privateKeysCachedLocally.masterKey && + crossSigningStatus.privateKeysCachedLocally.selfSigningKey && + crossSigningStatus.privateKeysCachedLocally.userSigningKey; + + const defaultKeyId = await cli.secretStorage.getDefaultKeyId(); + + const isCurrentDeviceTrusted = + crossSigningReady && + Boolean( + (await crypto.getDeviceVerificationStatus(cli.getSafeUserId(), cli.deviceId!))?.crossSigningVerified, + ); + + const allSystemsReady = crossSigningReady && secretStorageReady && allCrossSigningSecretsCached; await this.reportCryptoSessionStateToAnalytics(cli); if (this.dismissedThisDeviceToast || allSystemsReady) { + logSpan.info("No toast needed"); hideSetupEncryptionToast(); this.checkKeyBackupStatus(); @@ -294,32 +336,46 @@ export default class DeviceListener { // make sure our keys are finished downloading await crypto.getUserDeviceInfo([cli.getSafeUserId()]); - // cross signing isn't enabled - nag to enable it - // There are 3 different toasts for: - if (!(await crypto.getCrossSigningKeyId()) && (await crypto.userHasCrossSigningKeys())) { - // Toast 1. Cross-signing on account but this device doesn't trust the master key (verify this session) + if (!crossSigningReady) { + // This account is legacy and doesn't have cross-signing set up at all. + // Prompt the user to set it up. + logSpan.info("Cross-signing not ready: showing SET_UP_ENCRYPTION toast"); + showSetupEncryptionToast(SetupKind.SET_UP_ENCRYPTION); + } else if (!isCurrentDeviceTrusted) { + // cross signing is ready but the current device is not trusted: prompt the user to verify + logSpan.info("Current device not verified: showing VERIFY_THIS_SESSION toast"); showSetupEncryptionToast(SetupKind.VERIFY_THIS_SESSION); - this.checkKeyBackupStatus(); - } else { - const backupInfo = await this.getKeyBackupInfo(); - if (backupInfo) { - // Toast 2: Key backup is enabled but recovery (4S) is not set up: prompt user to set up recovery. - // Since we now enable key backup at registration time, this will be the common case for - // new users. + } else if (!allCrossSigningSecretsCached) { + // cross signing ready & device trusted, but we are missing secrets from our local cache. + // prompt the user to enter their recovery key. + logSpan.info( + "Some secrets not cached: showing KEY_STORAGE_OUT_OF_SYNC toast", + crossSigningStatus.privateKeysCachedLocally, + ); + showSetupEncryptionToast(SetupKind.KEY_STORAGE_OUT_OF_SYNC); + } 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) { + logSpan.info("No default 4S key: showing SET_UP_RECOVERY toast"); showSetupEncryptionToast(SetupKind.SET_UP_RECOVERY); } else { - // Toast 3: No cross-signing or key backup on account (set up encryption) - await cli.waitForClientWellKnown(); - if (isSecureBackupRequired(cli) && isLoggedIn()) { - // If we're meant to set up, and Secure Backup is required, - // trigger the flow directly without a toast once logged in. - hideSetupEncryptionToast(); - accessSecretStorage(); - } else { - showSetupEncryptionToast(SetupKind.SET_UP_ENCRYPTION); - } + logSpan.info("No default 4S key but backup disabled: no toast needed"); } + } else { + // some other condition... yikes! Show the 'set up encryption' toast: this is what we previously did + // in 'other' situations. Possibly we should consider prompting for a full reset in this case? + logSpan.warn("Couldn't match encryption state to a known case: showing 'setup encryption' prompt", { + crossSigningReady, + secretStorageReady, + allCrossSigningSecretsCached, + isCurrentDeviceTrusted, + defaultKeyId, + }); + showSetupEncryptionToast(SetupKind.SET_UP_ENCRYPTION); } + } else { + logSpan.info("Not yet ready, but shouldShowSetupEncryptionToast==false"); } // This needs to be done after awaiting on getUserDeviceInfo() above, so @@ -334,12 +390,6 @@ export default class DeviceListener { // Unverified devices that have appeared since then const newUnverifiedDeviceIds = new Set(); - const isCurrentDeviceTrusted = - crossSigningReady && - Boolean( - (await crypto.getDeviceVerificationStatus(cli.getSafeUserId(), cli.deviceId!))?.crossSigningVerified, - ); - // as long as cross-signing isn't ready, // you can't see or dismiss any device toasts if (crossSigningReady) { @@ -358,9 +408,9 @@ export default class DeviceListener { } } - logger.debug("Old unverified sessions: " + Array.from(oldUnverifiedDeviceIds).join(",")); - logger.debug("New unverified sessions: " + Array.from(newUnverifiedDeviceIds).join(",")); - logger.debug("Currently showing toasts for: " + Array.from(this.displayingToastsForDeviceIds).join(",")); + logSpan.debug("Old unverified sessions: " + Array.from(oldUnverifiedDeviceIds).join(",")); + logSpan.debug("New unverified sessions: " + Array.from(newUnverifiedDeviceIds).join(",")); + logSpan.debug("Currently showing toasts for: " + Array.from(this.displayingToastsForDeviceIds).join(",")); const isBulkUnverifiedSessionsReminderSnoozed = isBulkUnverifiedDeviceReminderSnoozed(); @@ -385,7 +435,7 @@ export default class DeviceListener { // ...and hide any we don't need any more for (const deviceId of this.displayingToastsForDeviceIds) { if (!newUnverifiedDeviceIds.has(deviceId)) { - logger.debug("Hiding unverified session toast for " + deviceId); + logSpan.debug("Hiding unverified session toast for " + deviceId); hideUnverifiedSessionsToast(deviceId); } } diff --git a/src/Editing.ts b/src/Editing.ts index 3b8d2d393a..063533f7e5 100644 --- a/src/Editing.ts +++ b/src/Editing.ts @@ -6,7 +6,7 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com Please see LICENSE files in the repository root for full details. */ -import { TimelineRenderingType } from "./contexts/RoomContext"; +import { type TimelineRenderingType } from "./contexts/RoomContext"; export const editorRoomKey = (roomId: string, context: TimelineRenderingType): string => `mx_edit_room_${roomId}_${context}`; diff --git a/src/HtmlUtils.tsx b/src/HtmlUtils.tsx index d635b23221..0a6243a12d 100644 --- a/src/HtmlUtils.tsx +++ b/src/HtmlUtils.tsx @@ -9,13 +9,13 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com Please see LICENSE files in the repository root for full details. */ -import React, { LegacyRef, ReactNode } from "react"; -import sanitizeHtml, { IOptions } from "sanitize-html"; +import React, { type LegacyRef, type ReactNode } from "react"; +import sanitizeHtml, { type IOptions } from "sanitize-html"; import classNames from "classnames"; import katex from "katex"; import { decode } from "html-entities"; -import { IContent } from "matrix-js-sdk/src/matrix"; -import { Optional } from "matrix-events-sdk"; +import { type IContent } from "matrix-js-sdk/src/matrix"; +import { type Optional } from "matrix-events-sdk"; import escapeHtml from "escape-html"; import { getEmojiFromUnicode } from "@matrix-org/emojibase-bindings"; diff --git a/src/IConfigOptions.ts b/src/IConfigOptions.ts index bbb377e07b..67b7515eac 100644 --- a/src/IConfigOptions.ts +++ b/src/IConfigOptions.ts @@ -7,9 +7,9 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com Please see LICENSE files in the repository root for full details. */ -import { IClientWellKnown } from "matrix-js-sdk/src/matrix"; +import { type IClientWellKnown } from "matrix-js-sdk/src/matrix"; -import { ValidatedServerConfig } from "./utils/ValidatedServerConfig"; +import { type ValidatedServerConfig } from "./utils/ValidatedServerConfig"; // Convention decision: All config options are lower_snake_case // We use an isolated file for the interface so we can mess around with the eslint options. @@ -71,7 +71,7 @@ export interface IConfigOptions { url: string; // download url url_macos?: string; url_win64?: string; - url_win32?: string; + url_win64arm?: string; url_linux?: string; }; mobile_builds: { @@ -206,6 +206,8 @@ export interface IConfigOptions { policy_uri?: string; contacts?: string[]; }; + + modules?: string[]; } export interface ISsoRedirectOptions { diff --git a/src/IdentityAuthClient.tsx b/src/IdentityAuthClient.tsx index ce20bc92ed..76c151c282 100644 --- a/src/IdentityAuthClient.tsx +++ b/src/IdentityAuthClient.tsx @@ -7,7 +7,7 @@ Please see LICENSE files in the repository root for full details. */ import React from "react"; -import { SERVICE_TYPES, createClient, MatrixClient, MatrixError } from "matrix-js-sdk/src/matrix"; +import { SERVICE_TYPES, createClient, type MatrixClient, MatrixError } from "matrix-js-sdk/src/matrix"; import { logger } from "matrix-js-sdk/src/logger"; import { MatrixClientPeg } from "./MatrixClientPeg"; diff --git a/src/KeyBindingsDefaults.ts b/src/KeyBindingsDefaults.ts index 3a4412c5db..0ab6acf030 100644 --- a/src/KeyBindingsDefaults.ts +++ b/src/KeyBindingsDefaults.ts @@ -10,7 +10,7 @@ Please see LICENSE files in the repository root for full details. import { IS_MAC, Key } from "./Keyboard"; import SettingsStore from "./settings/SettingsStore"; import SdkConfig from "./SdkConfig"; -import { IKeyBindingsProvider, KeyBinding } from "./KeyBindingsManager"; +import { type IKeyBindingsProvider, type KeyBinding } from "./KeyBindingsManager"; import { CATEGORIES, CategoryName, KeyBindingAction } from "./accessibility/KeyboardShortcuts"; import { getKeyboardShortcuts } from "./accessibility/KeyboardShortcutUtils"; diff --git a/src/KeyBindingsManager.ts b/src/KeyBindingsManager.ts index 5c8bccbfd0..bef954f8fe 100644 --- a/src/KeyBindingsManager.ts +++ b/src/KeyBindingsManager.ts @@ -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 { KeyBindingAction } from "./accessibility/KeyboardShortcuts"; +import { type KeyBindingAction } from "./accessibility/KeyboardShortcuts"; import { defaultBindingsProvider } from "./KeyBindingsDefaults"; import { IS_MAC } from "./Keyboard"; diff --git a/src/Keyboard.ts b/src/Keyboard.ts index 59c6bf77cf..de7ab059c6 100644 --- a/src/Keyboard.ts +++ b/src/Keyboard.ts @@ -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 from "react"; +import type React from "react"; export const Key = { HOME: "Home", diff --git a/src/LegacyCallHandler.tsx b/src/LegacyCallHandler.tsx index c2df066fa2..4361d3d027 100644 --- a/src/LegacyCallHandler.tsx +++ b/src/LegacyCallHandler.tsx @@ -10,19 +10,18 @@ Please see LICENSE files in the repository root for full details. */ import React from "react"; -import { MatrixError, RuleId, TweakName, SyncState } from "matrix-js-sdk/src/matrix"; +import { MatrixError, RuleId, TweakName, SyncState, TypedEventEmitter } from "matrix-js-sdk/src/matrix"; import { - CallError, + type CallError, CallErrorCode, CallEvent, CallParty, CallState, CallType, FALLBACK_ICE_SERVER, - MatrixCall, + type MatrixCall, } from "matrix-js-sdk/src/webrtc/call"; import { logger } from "matrix-js-sdk/src/logger"; -import EventEmitter from "events"; import { PushProcessor } from "matrix-js-sdk/src/pushprocessor"; import { CallEventHandlerEvent } from "matrix-js-sdk/src/webrtc/callEventHandler"; @@ -49,9 +48,9 @@ import { Container, WidgetLayoutStore } from "./stores/widgets/WidgetLayoutStore import IncomingLegacyCallToast, { getIncomingLegacyCallToastKey } from "./toasts/IncomingLegacyCallToast"; import ToastStore from "./stores/ToastStore"; import Resend from "./Resend"; -import { ViewRoomPayload } from "./dispatcher/payloads/ViewRoomPayload"; +import { type ViewRoomPayload } from "./dispatcher/payloads/ViewRoomPayload"; import { InviteKind } from "./components/views/dialogs/InviteDialogTypes"; -import { OpenInviteDialogPayload } from "./dispatcher/payloads/OpenInviteDialogPayload"; +import { type OpenInviteDialogPayload } from "./dispatcher/payloads/OpenInviteDialogPayload"; import { findDMForUser } from "./utils/dm/findDMForUser"; import { getJoinedNonFunctionalMembers } from "./utils/room/getJoinedNonFunctionalMembers"; import { localNotificationsAreSilenced } from "./utils/notifications"; @@ -137,14 +136,23 @@ export enum LegacyCallHandlerEvent { CallChangeRoom = "call_change_room", SilencedCallsChanged = "silenced_calls_changed", CallState = "call_state", + ProtocolSupport = "protocol_support", } +type EventEmitterMap = { + [LegacyCallHandlerEvent.CallsChanged]: (calls: Map) => void; + [LegacyCallHandlerEvent.CallChangeRoom]: (call: MatrixCall) => void; + [LegacyCallHandlerEvent.SilencedCallsChanged]: (calls: Set) => void; + [LegacyCallHandlerEvent.CallState]: (mappedRoomId: string | null, status: CallState) => void; + [LegacyCallHandlerEvent.ProtocolSupport]: () => void; +}; + /** * LegacyCallHandler manages all currently active calls. It should be used for * placing, answering, rejecting and hanging up calls. It also handles ringing, * PSTN support and other things. */ -export default class LegacyCallHandler extends EventEmitter { +export default class LegacyCallHandler extends TypedEventEmitter { private calls = new Map(); // roomId -> call // Calls started as an attended transfer, ie. with the intention of transferring another // call with a different party to this one. @@ -271,15 +279,13 @@ export default class LegacyCallHandler extends EventEmitter { this.supportsPstnProtocol = null; } - dis.dispatch({ action: Action.PstnSupportUpdated }); - if (protocols[PROTOCOL_SIP_NATIVE] !== undefined && protocols[PROTOCOL_SIP_VIRTUAL] !== undefined) { this.supportsSipNativeVirtual = Boolean( protocols[PROTOCOL_SIP_NATIVE] && protocols[PROTOCOL_SIP_VIRTUAL], ); } - dis.dispatch({ action: Action.VirtualRoomSupportUpdated }); + this.emit(LegacyCallHandlerEvent.ProtocolSupport); } catch (e) { if (maxTries === 1) { logger.log("Failed to check for protocol support and no retries remain: assuming no support", e); @@ -296,8 +302,8 @@ export default class LegacyCallHandler extends EventEmitter { return !!SdkConfig.getObject("voip")?.get("obey_asserted_identity"); } - public getSupportsPstnProtocol(): boolean | null { - return this.supportsPstnProtocol; + public getSupportsPstnProtocol(): boolean { + return this.supportsPstnProtocol ?? false; } public getSupportsVirtualRooms(): boolean | null { @@ -568,6 +574,7 @@ export default class LegacyCallHandler extends EventEmitter { if (!mappedRoomId || !this.matchesCallForThisRoom(call)) return; this.setCallState(call, newState); + // XXX: this is used by the IPC into Electron to keep device awake dis.dispatch({ action: "call_state", room_id: mappedRoomId, diff --git a/src/Lifecycle.ts b/src/Lifecycle.ts index cdb7d39115..3ef7c56e82 100644 --- a/src/Lifecycle.ts +++ b/src/Lifecycle.ts @@ -9,13 +9,19 @@ 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 { ReactNode } from "react"; -import { createClient, MatrixClient, SSOAction, OidcTokenRefresher, decodeBase64 } from "matrix-js-sdk/src/matrix"; -import { AESEncryptedSecretStoragePayload } from "matrix-js-sdk/src/types"; -import { QueryDict } from "matrix-js-sdk/src/utils"; +import { type ReactNode } from "react"; +import { + createClient, + type MatrixClient, + SSOAction, + type OidcTokenRefresher, + decodeBase64, +} from "matrix-js-sdk/src/matrix"; +import { type AESEncryptedSecretStoragePayload } from "matrix-js-sdk/src/types"; +import { type QueryDict } from "matrix-js-sdk/src/utils"; import { logger } from "matrix-js-sdk/src/logger"; -import { IMatrixClientCreds, MatrixClientPeg, MatrixClientPegAssignOpts } from "./MatrixClientPeg"; +import { type IMatrixClientCreds, MatrixClientPeg, type MatrixClientPegAssignOpts } from "./MatrixClientPeg"; import { ModuleRunner } from "./modules/ModuleRunner"; import EventIndexPeg from "./indexing/EventIndexPeg"; import createMatrixClient from "./utils/createMatrixClient"; @@ -50,12 +56,12 @@ import { setSentryUser } from "./sentry"; import SdkConfig from "./SdkConfig"; import { DialogOpener } from "./utils/DialogOpener"; import { Action } from "./dispatcher/actions"; -import { OverwriteLoginPayload } from "./dispatcher/payloads/OverwriteLoginPayload"; +import { type OverwriteLoginPayload } from "./dispatcher/payloads/OverwriteLoginPayload"; import { SdkContextClass } from "./contexts/SDKContext"; import { messageForLoginError } from "./utils/ErrorUtils"; import { completeOidcLogin } from "./utils/oidc/authorize"; import { getOidcErrorMessage } from "./utils/oidc/error"; -import { OidcClientStore } from "./stores/oidc/OidcClientStore"; +import { type OidcClientStore } from "./stores/oidc/OidcClientStore"; import { getStoredOidcClientId, getStoredOidcIdTokenClaims, @@ -1049,9 +1055,9 @@ async function clearStorage(opts?: { deleteEverything?: boolean }): Promise, ScreenName> = { [Views.WELCOME]: "Welcome", [Views.LOGIN]: "Login", [Views.REGISTER]: "Register", - [Views.USE_CASE_SELECTION]: "UseCaseSelection", [Views.FORGOT_PASSWORD]: "ForgotPassword", [Views.COMPLETE_SECURITY]: "CompleteSecurity", [Views.E2E_SETUP]: "E2ESetup", diff --git a/src/Presence.ts b/src/Presence.ts index 8057a0f737..7388e2d165 100644 --- a/src/Presence.ts +++ b/src/Presence.ts @@ -14,7 +14,7 @@ import { SetPresence } from "matrix-js-sdk/src/matrix"; import { MatrixClientPeg } from "./MatrixClientPeg"; import dis from "./dispatcher/dispatcher"; import Timer from "./utils/Timer"; -import { ActionPayload } from "./dispatcher/payloads"; +import { type ActionPayload } from "./dispatcher/payloads"; // Time in ms after that a user is considered as unavailable/away const UNAVAILABLE_TIME_MS = 3 * 60 * 1000; // 3 mins diff --git a/src/Resend.ts b/src/Resend.ts index 109aea632f..e93082f397 100644 --- a/src/Resend.ts +++ b/src/Resend.ts @@ -6,7 +6,7 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com Please see LICENSE files in the repository root for full details. */ -import { MatrixEvent, EventStatus, Room, MatrixClient } from "matrix-js-sdk/src/matrix"; +import { type MatrixEvent, EventStatus, type Room, type MatrixClient } from "matrix-js-sdk/src/matrix"; import { logger } from "matrix-js-sdk/src/logger"; import dis from "./dispatcher/dispatcher"; diff --git a/src/RoomInvite.tsx b/src/RoomInvite.tsx index 91ea459230..a02530a1cf 100644 --- a/src/RoomInvite.tsx +++ b/src/RoomInvite.tsx @@ -6,11 +6,11 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com Please see LICENSE files in the repository root for full details. */ -import React, { ComponentProps } from "react"; -import { Room, MatrixEvent, MatrixClient, User, EventType } from "matrix-js-sdk/src/matrix"; +import React, { type ComponentProps } from "react"; +import { type Room, type MatrixEvent, type MatrixClient, type User, EventType } from "matrix-js-sdk/src/matrix"; import { logger } from "matrix-js-sdk/src/logger"; -import MultiInviter, { CompletionStates } from "./utils/MultiInviter"; +import MultiInviter, { type CompletionStates } from "./utils/MultiInviter"; import Modal from "./Modal"; import { _t } from "./languageHandler"; import InviteDialog from "./components/views/dialogs/InviteDialog"; @@ -18,7 +18,7 @@ import BaseAvatar from "./components/views/avatars/BaseAvatar"; import { mediaFromMxc } from "./customisations/Media"; import ErrorDialog from "./components/views/dialogs/ErrorDialog"; import { InviteKind } from "./components/views/dialogs/InviteDialogTypes"; -import { Member } from "./utils/direct-messages"; +import { type Member } from "./utils/direct-messages"; export interface IInviteResult { states: CompletionStates; diff --git a/src/Rooms.ts b/src/Rooms.ts index 347316c4c9..cf458fef84 100644 --- a/src/Rooms.ts +++ b/src/Rooms.ts @@ -6,7 +6,7 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com Please see LICENSE files in the repository root for full details. */ -import { Room, EventType, RoomMember, MatrixClient } from "matrix-js-sdk/src/matrix"; +import { type Room, EventType, type RoomMember, type MatrixClient } from "matrix-js-sdk/src/matrix"; import AliasCustomisations from "./customisations/Alias"; diff --git a/src/ScalarAuthClient.ts b/src/ScalarAuthClient.ts index 094bb6fe59..26ee94de40 100644 --- a/src/ScalarAuthClient.ts +++ b/src/ScalarAuthClient.ts @@ -7,13 +7,13 @@ Please see LICENSE files in the repository root for full details. */ import { logger } from "matrix-js-sdk/src/logger"; -import { SERVICE_TYPES, Room, IOpenIDToken } from "matrix-js-sdk/src/matrix"; +import { SERVICE_TYPES, type Room, type IOpenIDToken } from "matrix-js-sdk/src/matrix"; import SettingsStore from "./settings/SettingsStore"; -import { Service, startTermsFlow, TermsInteractionCallback, TermsNotSignedError } from "./Terms"; +import { Service, startTermsFlow, type TermsInteractionCallback, TermsNotSignedError } from "./Terms"; import { MatrixClientPeg } from "./MatrixClientPeg"; import SdkConfig from "./SdkConfig"; -import { WidgetType } from "./widgets/WidgetType"; +import { type WidgetType } from "./widgets/WidgetType"; import { parseUrl } from "./utils/UrlUtils"; // The version of the integration manager API we're intending to work with diff --git a/src/ScalarMessaging.ts b/src/ScalarMessaging.ts index 003d02f2dd..840717dead 100644 --- a/src/ScalarMessaging.ts +++ b/src/ScalarMessaging.ts @@ -282,7 +282,7 @@ Response: */ -import { IContent, MatrixEvent, IEvent, StateEvents } from "matrix-js-sdk/src/matrix"; +import { type IContent, type MatrixEvent, type IEvent, type StateEvents } from "matrix-js-sdk/src/matrix"; import { KnownMembership } from "matrix-js-sdk/src/types"; import { logger } from "matrix-js-sdk/src/logger"; diff --git a/src/SdkConfig.ts b/src/SdkConfig.ts index ee1c4eab8a..a41b890b19 100644 --- a/src/SdkConfig.ts +++ b/src/SdkConfig.ts @@ -7,13 +7,13 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com Please see LICENSE files in the repository root for full details. */ -import { Optional } from "matrix-events-sdk"; +import { type Optional } from "matrix-events-sdk"; import { mergeWith } from "lodash"; import { SnakedObject } from "./utils/SnakedObject"; -import { IConfigOptions, ISsoRedirectOptions } from "./IConfigOptions"; +import { type IConfigOptions, type ISsoRedirectOptions } from "./IConfigOptions"; import { isObject, objectClone } from "./utils/objects"; -import { DeepReadonly, Defaultize } from "./@types/common"; +import { type DeepReadonly, type Defaultize } from "./@types/common"; // see element-web config.md for docs, or the IConfigOptions interface for dev docs export const DEFAULTS: DeepReadonly = { @@ -59,7 +59,7 @@ export const DEFAULTS: DeepReadonly = { url: "https://element.io/download", url_macos: "https://packages.element.io/desktop/install/macos/Element.dmg", url_win64: "https://packages.element.io/desktop/install/win32/x64/Element%20Setup.exe", - url_win32: "https://packages.element.io/desktop/install/win32/ia32/Element%20Setup.exe", + url_win64arm: "https://packages.element.io/desktop/install/win32/arm64/Element%20Setup.exe", url_linux: "https://element.io/download#linux", }, mobile_builds: { diff --git a/src/Searching.ts b/src/Searching.ts index dea724fbdf..f9b2830e85 100644 --- a/src/Searching.ts +++ b/src/Searching.ts @@ -7,19 +7,19 @@ Please see LICENSE files in the repository root for full details. */ import { - IResultRoomEvents, - ISearchRequestBody, - ISearchResponse, - ISearchResult, - ISearchResults, + type IResultRoomEvents, + type ISearchRequestBody, + type ISearchResponse, + type ISearchResult, + type ISearchResults, SearchOrderBy, - IRoomEventFilter, + type IRoomEventFilter, EventType, - MatrixClient, - SearchResult, + type MatrixClient, + type SearchResult, } from "matrix-js-sdk/src/matrix"; -import { ISearchArgs } from "./indexing/BaseEventIndexManager"; +import { type ISearchArgs } from "./indexing/BaseEventIndexManager"; import EventIndexPeg from "./indexing/EventIndexPeg"; import { isNotUndefined } from "./Typeguards"; diff --git a/src/SecurityManager.ts b/src/SecurityManager.ts index 6af8dd5f18..b497c57a10 100644 --- a/src/SecurityManager.ts +++ b/src/SecurityManager.ts @@ -7,15 +7,17 @@ Please see LICENSE files in the repository root for full details. */ import { lazy } from "react"; -import { SecretStorage } from "matrix-js-sdk/src/matrix"; -import { deriveRecoveryKeyFromPassphrase, decodeRecoveryKey, CryptoCallbacks } from "matrix-js-sdk/src/crypto-api"; -import { logger } from "matrix-js-sdk/src/logger"; +import { type SecretStorage } from "matrix-js-sdk/src/matrix"; +import { deriveRecoveryKeyFromPassphrase, decodeRecoveryKey, type CryptoCallbacks } from "matrix-js-sdk/src/crypto-api"; +import { logger as rootLogger } from "matrix-js-sdk/src/logger"; import Modal from "./Modal"; import { MatrixClientPeg } from "./MatrixClientPeg"; import { _t } from "./languageHandler"; import { isSecureBackupRequired } from "./utils/WellKnownUtils"; -import AccessSecretStorageDialog, { KeyParams } from "./components/views/dialogs/security/AccessSecretStorageDialog"; +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"; @@ -29,6 +31,8 @@ let secretStorageKeys: Record = {}; let secretStorageKeyInfo: Record = {}; let secretStorageBeingAccessed = false; +const logger = rootLogger.getChild("SecurityManager:"); + /** * This can be used by other components to check if secret storage access is in * progress, so that we can e.g. avoid intermittently showing toasts during @@ -70,33 +74,34 @@ function makeInputToKey( }; } -async function getSecretStorageKey({ - keys: keyInfos, -}: { - keys: Record; -}): Promise<[string, Uint8Array]> { +async function getSecretStorageKey( + { + keys: keyInfos, + }: { + keys: Record; + }, + secretName: string, +): Promise<[string, Uint8Array]> { const cli = MatrixClientPeg.safeGet(); - let keyId = await cli.secretStorage.getDefaultKeyId(); - let keyInfo!: SecretStorage.SecretStorageKeyDescription; - if (keyId) { - // use the default SSSS key if set - keyInfo = keyInfos[keyId]; - if (!keyInfo) { - // if the default key is not available, pretend the default key - // isn't set - keyId = null; - } - } - if (!keyId) { - // if no default SSSS key is set, fall back to a heuristic of using the + const defaultKeyId = await cli.secretStorage.getDefaultKeyId(); + + let keyId: string; + // If the defaultKey is useful, use that + if (defaultKeyId && keyInfos[defaultKeyId]) { + keyId = defaultKeyId; + } else { + // Fall back to a heuristic of using the // only available key, if only one key is set - const keyInfoEntries = Object.entries(keyInfos); - if (keyInfoEntries.length > 1) { + const usefulKeys = Object.keys(keyInfos); + if (usefulKeys.length > 1) { throw new Error("Multiple storage key requests not implemented"); } - [keyId, keyInfo] = keyInfoEntries[0]; + keyId = usefulKeys[0]; } - logger.debug(`getSecretStorageKey: request for 4S keys [${Object.keys(keyInfos)}]: looking for key ${keyId}`); + const keyInfo = keyInfos[keyId]; + logger.debug( + `getSecretStorageKey: request for 4S keys [${Object.keys(keyInfos)}] for secret \`${secretName}\`: looking for key ${keyId}`, + ); // Check the in-memory cache if (secretStorageBeingAccessed && secretStorageKeys[keyId]) { @@ -106,12 +111,18 @@ async function getSecretStorageKey({ const keyFromCustomisations = ModuleRunner.instance.extensions.cryptoSetup.getSecretStorageKey(); if (keyFromCustomisations) { - logger.log("getSecretStorageKey: Using secret storage key from CryptoSetupExtension"); + logger.debug("getSecretStorageKey: Using secret storage key from CryptoSetupExtension"); cacheSecretStorageKey(keyId, keyInfo, keyFromCustomisations); return [keyId, keyFromCustomisations]; } - logger.debug("getSecretStorageKey: prompting user for key"); + // We only prompt the user for the default key + if (keyId !== defaultKeyId) { + logger.debug(`getSecretStorageKey: request for non-default key ${keyId}: not prompting user`); + throw new Error("Request for non-default 4S key"); + } + + logger.debug(`getSecretStorageKey: prompting user for key ${keyId}`); const inputToKey = makeInputToKey(keyInfo); const { finished } = Modal.createDialog( AccessSecretStorageDialog, @@ -139,7 +150,7 @@ async function getSecretStorageKey({ if (!keyParams) { throw new AccessCancelledError(); } - logger.debug("getSecretStorageKey: got key from user"); + logger.debug(`getSecretStorageKey: got key ${keyId} from user`); const key = await inputToKey(keyParams); // Save to cache to avoid future prompts in the current session @@ -154,6 +165,7 @@ function cacheSecretStorageKey( key: Uint8Array, ): void { if (secretStorageBeingAccessed) { + logger.debug(`Caching 4S key ${keyId}`); secretStorageKeys[keyId] = key; secretStorageKeyInfo[keyId] = keyInfo; } @@ -173,13 +185,13 @@ export const crossSigningCallbacks: CryptoCallbacks = { * @param func - The operation to be wrapped. */ export async function withSecretStorageKeyCache(func: () => Promise): Promise { - logger.debug("SecurityManager: enabling 4S key cache"); + logger.debug("enabling 4S key cache"); secretStorageBeingAccessed = true; try { return await func(); } finally { // Clear secret storage key cache now that work is complete - logger.debug("SecurityManager: disabling 4S key cache"); + logger.debug("disabling 4S key cache"); secretStorageBeingAccessed = false; secretStorageKeys = {}; secretStorageKeyInfo = {}; diff --git a/src/SendHistoryManager.ts b/src/SendHistoryManager.ts index 517e1b45e3..149dbe42d2 100644 --- a/src/SendHistoryManager.ts +++ b/src/SendHistoryManager.ts @@ -7,11 +7,11 @@ Please see LICENSE files in the repository root for full details. */ import { clamp } from "lodash"; -import { MatrixEvent } from "matrix-js-sdk/src/matrix"; +import { type MatrixEvent } from "matrix-js-sdk/src/matrix"; import { logger } from "matrix-js-sdk/src/logger"; -import { SerializedPart } from "./editor/parts"; -import EditorModel from "./editor/model"; +import { type SerializedPart } from "./editor/parts"; +import type EditorModel from "./editor/model"; interface IHistoryItem { parts: SerializedPart[]; diff --git a/src/SlashCommands.tsx b/src/SlashCommands.tsx index 08ecd0562b..4a4a80f723 100644 --- a/src/SlashCommands.tsx +++ b/src/SlashCommands.tsx @@ -10,9 +10,16 @@ Please see LICENSE files in the repository root for full details. */ import * as React from "react"; -import { ContentHelpers, Direction, EventType, IContent, MRoomTopicEventContent, User } from "matrix-js-sdk/src/matrix"; +import { + ContentHelpers, + Direction, + EventType, + type IContent, + type MRoomTopicEventContent, + type User, +} from "matrix-js-sdk/src/matrix"; import { logger } from "matrix-js-sdk/src/logger"; -import { KnownMembership, RoomMemberEventContent } from "matrix-js-sdk/src/types"; +import { KnownMembership, type RoomMemberEventContent } from "matrix-js-sdk/src/types"; import dis from "./dispatcher/dispatcher"; import { _t, _td, UserFriendlyError } from "./languageHandler"; @@ -29,7 +36,7 @@ import { WidgetType } from "./widgets/WidgetType"; import { Jitsi } from "./widgets/Jitsi"; import BugReportDialog from "./components/views/dialogs/BugReportDialog"; import { ensureDMExists } from "./createRoom"; -import { ViewUserPayload } from "./dispatcher/payloads/ViewUserPayload"; +import { type ViewUserPayload } from "./dispatcher/payloads/ViewUserPayload"; import { Action } from "./dispatcher/actions"; import SdkConfig from "./SdkConfig"; import SettingsStore from "./settings/SettingsStore"; @@ -44,7 +51,7 @@ import InfoDialog from "./components/views/dialogs/InfoDialog"; import SlashCommandHelpDialog from "./components/views/dialogs/SlashCommandHelpDialog"; import { shouldShowComponent } from "./customisations/helpers/UIComponents"; import { TimelineRenderingType } from "./contexts/RoomContext"; -import { ViewRoomPayload } from "./dispatcher/payloads/ViewRoomPayload"; +import { type ViewRoomPayload } from "./dispatcher/payloads/ViewRoomPayload"; import VoipUserMapper from "./VoipUserMapper"; import { htmlSerializeFromMdIfNeeded } from "./editor/serialize"; import { leaveRoomBehaviour } from "./utils/leave-behaviour"; diff --git a/src/SlidingSyncManager.ts b/src/SlidingSyncManager.ts index b9922e2290..adfca7c3c3 100644 --- a/src/SlidingSyncManager.ts +++ b/src/SlidingSyncManager.ts @@ -36,10 +36,10 @@ Please see LICENSE files in the repository root for full details. * list ops) */ -import { MatrixClient, EventType, AutoDiscovery, Method, timeoutSignal } from "matrix-js-sdk/src/matrix"; +import { type MatrixClient, EventType, AutoDiscovery, Method, timeoutSignal } from "matrix-js-sdk/src/matrix"; import { - MSC3575Filter, - MSC3575List, + type MSC3575Filter, + type MSC3575List, MSC3575_STATE_KEY_LAZY, MSC3575_STATE_KEY_ME, MSC3575_WILDCARD, diff --git a/src/Terms.ts b/src/Terms.ts index 02b67545da..002ad0464f 100644 --- a/src/Terms.ts +++ b/src/Terms.ts @@ -7,11 +7,18 @@ Please see LICENSE files in the repository root for full details. */ import classNames from "classnames"; -import { SERVICE_TYPES, MatrixClient } from "matrix-js-sdk/src/matrix"; +import { + type SERVICE_TYPES, + type MatrixClient, + type Terms, + type Policy, + type InternationalisedPolicy, +} from "matrix-js-sdk/src/matrix"; import { logger } from "matrix-js-sdk/src/logger"; import Modal from "./Modal"; import TermsDialog from "./components/views/dialogs/TermsDialog"; +import { pickBestLanguage } from "./languageHandler.tsx"; export class TermsNotSignedError extends Error {} @@ -32,23 +39,8 @@ export class Service { ) {} } -export interface LocalisedPolicy { - name: string; - url: string; -} - -export interface Policy { - // @ts-ignore: No great way to express indexed types together with other keys - version: string; - [lang: string]: LocalisedPolicy; -} - -export type Policies = { - [policy: string]: Policy; -}; - export type ServicePolicyPair = { - policies: Policies; + policies: Terms["policies"]; service: Service; }; @@ -58,6 +50,11 @@ export type TermsInteractionCallback = ( extraClassNames?: string, ) => Promise; +export function pickBestPolicyLanguage(policy: Policy): InternationalisedPolicy | undefined { + const termsLang = pickBestLanguage(Object.keys(policy).filter((k) => k !== "version")); + return policy[termsLang]; +} + /** * Start a flow where the user is presented with terms & conditions for some services * @@ -96,7 +93,7 @@ export async function startTermsFlow( * } */ - const terms: { policies: Policies }[] = await Promise.all(termsPromises); + const terms: Terms[] = await Promise.all(termsPromises); const policiesAndServicePairs = terms.map((t, i) => { return { service: services[i], policies: t.policies }; }); @@ -113,11 +110,11 @@ export async function startTermsFlow( // things they've not agreed to yet. const unagreedPoliciesAndServicePairs: ServicePolicyPair[] = []; for (const { service, policies } of policiesAndServicePairs) { - const unagreedPolicies: Policies = {}; + const unagreedPolicies: Terms["policies"] = {}; for (const [policyName, policy] of Object.entries(policies)) { let policyAgreed = false; for (const lang of Object.keys(policy)) { - if (lang === "version") continue; + if (lang === "version" || typeof policy[lang] === "string") continue; if (agreedUrlSet.has(policy[lang].url)) { policyAgreed = true; break; @@ -154,7 +151,7 @@ export async function startTermsFlow( const urlsForService = Array.from(agreedUrlSet).filter((url) => { for (const policy of Object.values(policiesAndService.policies)) { for (const lang of Object.keys(policy)) { - if (lang === "version") continue; + if (lang === "version" || typeof policy[lang] === "string") continue; if (policy[lang].url === url) return true; } } diff --git a/src/TextForEvent.tsx b/src/TextForEvent.tsx index bdb7e8cbe0..b63e5b2a00 100644 --- a/src/TextForEvent.tsx +++ b/src/TextForEvent.tsx @@ -8,8 +8,8 @@ Please see LICENSE files in the repository root for full details. import React from "react"; import { - MatrixEvent, - MatrixClient, + type MatrixEvent, + type MatrixClient, GuestAccess, HistoryVisibility, JoinRule, @@ -17,11 +17,12 @@ import { MsgType, M_POLL_START, M_POLL_END, + ContentHelpers, } from "matrix-js-sdk/src/matrix"; import { KnownMembership } from "matrix-js-sdk/src/types"; import { logger } from "matrix-js-sdk/src/logger"; import { removeDirectionOverrideChars } from "matrix-js-sdk/src/utils"; -import { PollStartEvent } from "matrix-js-sdk/src/extensible_events_v1/PollStartEvent"; +import { type PollStartEvent } from "matrix-js-sdk/src/extensible_events_v1/PollStartEvent"; import { _t } from "./languageHandler"; import * as Roles from "./Roles"; @@ -190,7 +191,10 @@ function textForMemberEvent( case KnownMembership.Leave: if (ev.getSender() === ev.getStateKey()) { if (prevContent.membership === KnownMembership.Invite) { - return () => _t("timeline|m.room.member|reject_invite", { targetName }); + return () => + reason + ? _t("timeline|m.room.member|reject_invite_reason", { targetName, reason }) + : _t("timeline|m.room.member|reject_invite", { targetName }); } else { return () => reason @@ -227,11 +231,16 @@ function textForMemberEvent( function textForTopicEvent(ev: MatrixEvent): (() => string) | null { const senderDisplayName = ev.sender && ev.sender.name ? ev.sender.name : ev.getSender(); + const topic = ContentHelpers.parseTopicContent(ev.getContent()).text; return () => - _t("timeline|m.room.topic", { - senderDisplayName, - topic: ev.getContent().topic, - }); + topic + ? _t("timeline|m.room.topic|changed", { + senderDisplayName, + topic, + }) + : _t("timeline|m.room.topic|removed", { + senderDisplayName, + }); } function textForRoomAvatarEvent(ev: MatrixEvent): (() => string) | null { diff --git a/src/Unread.ts b/src/Unread.ts index e79a9cb75f..2c8fa0cff3 100644 --- a/src/Unread.ts +++ b/src/Unread.ts @@ -6,7 +6,7 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com Please see LICENSE files in the repository root for full details. */ -import { M_BEACON, Room, Thread, MatrixEvent, EventType, MatrixClient } from "matrix-js-sdk/src/matrix"; +import { M_BEACON, type Room, Thread, type MatrixEvent, EventType, type MatrixClient } from "matrix-js-sdk/src/matrix"; import { logger } from "matrix-js-sdk/src/logger"; import shouldHideEvent from "./shouldHideEvent"; diff --git a/src/Views.ts b/src/Views.ts index 90480b2669..6c0df53a66 100644 --- a/src/Views.ts +++ b/src/Views.ts @@ -33,9 +33,6 @@ enum Views { // flow to setup SSSS / cross-signing on this account E2E_SETUP, - // screen that allows users to select which use case they’ll use matrix for - USE_CASE_SELECTION, - // we are logged in with an active matrix client. The logged_in state also // includes guests users as they too are logged in at the client level. LOGGED_IN, diff --git a/src/VoipUserMapper.ts b/src/VoipUserMapper.ts index a0290a3eb1..c2a7810d96 100644 --- a/src/VoipUserMapper.ts +++ b/src/VoipUserMapper.ts @@ -6,7 +6,7 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com Please see LICENSE files in the repository root for full details. */ -import { Room, EventType } from "matrix-js-sdk/src/matrix"; +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"; diff --git a/src/WhoIsTyping.ts b/src/WhoIsTyping.ts index c209253206..000cfa29e5 100644 --- a/src/WhoIsTyping.ts +++ b/src/WhoIsTyping.ts @@ -6,7 +6,7 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com Please see LICENSE files in the repository root for full details. */ -import { Room, RoomMember } from "matrix-js-sdk/src/matrix"; +import { type Room, type RoomMember } from "matrix-js-sdk/src/matrix"; import { _t } from "./languageHandler"; diff --git a/src/WorkerManager.ts b/src/WorkerManager.ts index 089463dc91..a8a95ca727 100644 --- a/src/WorkerManager.ts +++ b/src/WorkerManager.ts @@ -6,11 +6,11 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com Please see LICENSE files in the repository root for full details. */ -import { defer, IDeferred } from "matrix-js-sdk/src/utils"; +import { defer, type IDeferred } from "matrix-js-sdk/src/utils"; -import { WorkerPayload } from "./workers/worker"; +import { type WorkerPayload } from "./workers/worker"; -export class WorkerManager { +export class WorkerManager { private readonly worker: Worker; private seq = 0; private pendingDeferredMap = new Map>(); diff --git a/src/accessibility/KeyboardShortcutUtils.ts b/src/accessibility/KeyboardShortcutUtils.ts index 4eafb80f36..a261bf361b 100644 --- a/src/accessibility/KeyboardShortcutUtils.ts +++ b/src/accessibility/KeyboardShortcutUtils.ts @@ -6,7 +6,7 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com Please see LICENSE files in the repository root for full details. */ -import { KeyCombo } from "../KeyBindingsManager"; +import { type KeyCombo } from "../KeyBindingsManager"; import { IS_MAC, Key } from "../Keyboard"; import { _t, _td } from "../languageHandler"; import PlatformPeg from "../PlatformPeg"; @@ -14,10 +14,10 @@ import SettingsStore from "../settings/SettingsStore"; import { DESKTOP_SHORTCUTS, DIGITS, - IKeyboardShortcuts, + type IKeyboardShortcuts, KeyBindingAction, KEYBOARD_SHORTCUTS, - KeyboardShortcutSetting, + type KeyboardShortcutSetting, MAC_ONLY_SHORTCUTS, } from "./KeyboardShortcuts"; diff --git a/src/accessibility/KeyboardShortcuts.ts b/src/accessibility/KeyboardShortcuts.ts index 1e8febb0bb..da0097f4b2 100644 --- a/src/accessibility/KeyboardShortcuts.ts +++ b/src/accessibility/KeyboardShortcuts.ts @@ -8,10 +8,10 @@ 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 { _td, TranslationKey } from "../languageHandler"; +import { _td, type TranslationKey } from "../languageHandler"; import { IS_MAC, IS_ELECTRON, Key } from "../Keyboard"; -import { IBaseSetting } from "../settings/Settings"; -import { KeyCombo } from "../KeyBindingsManager"; +import { type IBaseSetting } from "../settings/Settings"; +import { type KeyCombo } from "../KeyBindingsManager"; export enum KeyBindingAction { /** Send a message */ @@ -520,9 +520,8 @@ export const KEYBOARD_SHORTCUTS: IKeyboardShortcuts = { }, [KeyBindingAction.GoToHome]: { default: { - ctrlOrCmdKey: true, - altKey: !IS_MAC, - shiftKey: IS_MAC, + ctrlKey: true, + altKey: true, key: Key.H, }, displayName: _td("keyboard|go_home_view"), @@ -585,7 +584,7 @@ export const KEYBOARD_SHORTCUTS: IKeyboardShortcuts = { }, [KeyBindingAction.ToggleHiddenEventVisibility]: { default: { - ctrlOrCmdKey: true, + ctrlKey: true, shiftKey: true, key: Key.H, }, diff --git a/src/accessibility/RovingTabIndex.tsx b/src/accessibility/RovingTabIndex.tsx index dada99b3e7..45f3367aab 100644 --- a/src/accessibility/RovingTabIndex.tsx +++ b/src/accessibility/RovingTabIndex.tsx @@ -13,16 +13,16 @@ import React, { useMemo, useRef, useReducer, - Reducer, - Dispatch, - RefObject, - ReactNode, - RefCallback, + type Reducer, + type Dispatch, + type RefObject, + type ReactNode, + type RefCallback, } from "react"; import { getKeyBindingsManager } from "../KeyBindingsManager"; import { KeyBindingAction } from "./KeyboardShortcuts"; -import { FocusHandler } from "./roving/types"; +import { type FocusHandler } from "./roving/types"; /** * Module to simplify implementing the Roving TabIndex accessibility technique @@ -392,6 +392,7 @@ export const useRovingTabIndex = ( }); }, []); // eslint-disable-line react-hooks/exhaustive-deps + // eslint-disable-next-line react-compiler/react-compiler const isActive = context.state.activeNode === nodeRef.current; return [onFocus, isActive, ref, nodeRef]; }; diff --git a/src/accessibility/context_menu/ContextMenuButton.tsx b/src/accessibility/context_menu/ContextMenuButton.tsx index e326e087b9..8096203d0e 100644 --- a/src/accessibility/context_menu/ContextMenuButton.tsx +++ b/src/accessibility/context_menu/ContextMenuButton.tsx @@ -8,9 +8,9 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com Please see LICENSE files in the repository root for full details. */ -import React, { forwardRef, Ref } from "react"; +import React, { forwardRef, type Ref } from "react"; -import AccessibleButton, { ButtonProps } from "../../components/views/elements/AccessibleButton"; +import AccessibleButton, { type ButtonProps } from "../../components/views/elements/AccessibleButton"; type Props = ButtonProps & { label?: string; diff --git a/src/accessibility/context_menu/ContextMenuTooltipButton.tsx b/src/accessibility/context_menu/ContextMenuTooltipButton.tsx index 7a4230ddf7..f58fbea171 100644 --- a/src/accessibility/context_menu/ContextMenuTooltipButton.tsx +++ b/src/accessibility/context_menu/ContextMenuTooltipButton.tsx @@ -8,9 +8,9 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com Please see LICENSE files in the repository root for full details. */ -import React, { forwardRef, Ref } from "react"; +import React, { forwardRef, type Ref } from "react"; -import AccessibleButton, { ButtonProps } from "../../components/views/elements/AccessibleButton"; +import AccessibleButton, { type ButtonProps } from "../../components/views/elements/AccessibleButton"; type Props = ButtonProps & { // whether the context menu is currently open diff --git a/src/accessibility/roving/RovingAccessibleButton.tsx b/src/accessibility/roving/RovingAccessibleButton.tsx index 70e569e335..8bcf71dd4d 100644 --- a/src/accessibility/roving/RovingAccessibleButton.tsx +++ b/src/accessibility/roving/RovingAccessibleButton.tsx @@ -6,9 +6,9 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com Please see LICENSE files in the repository root for full details. */ -import React, { RefObject } from "react"; +import React, { type RefObject } from "react"; -import AccessibleButton, { ButtonProps } from "../../components/views/elements/AccessibleButton"; +import AccessibleButton, { type ButtonProps } from "../../components/views/elements/AccessibleButton"; import { useRovingTabIndex } from "../RovingTabIndex"; type Props = Omit, "tabIndex"> & { diff --git a/src/accessibility/roving/RovingTabIndexWrapper.tsx b/src/accessibility/roving/RovingTabIndexWrapper.tsx index a47c1cb3ef..aa9b1ad8c1 100644 --- a/src/accessibility/roving/RovingTabIndexWrapper.tsx +++ b/src/accessibility/roving/RovingTabIndexWrapper.tsx @@ -6,10 +6,11 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com Please see LICENSE files in the repository root for full details. */ -import React, { ReactElement, RefCallback } from "react"; +import { type ReactElement, type RefCallback } from "react"; +import type React from "react"; import { useRovingTabIndex } from "../RovingTabIndex"; -import { FocusHandler, Ref } from "./types"; +import { type FocusHandler, type Ref } from "./types"; interface IProps { inputRef?: Ref; diff --git a/src/accessibility/roving/types.ts b/src/accessibility/roving/types.ts index 3cf8cdf6c0..e3fa694f11 100644 --- a/src/accessibility/roving/types.ts +++ b/src/accessibility/roving/types.ts @@ -6,7 +6,7 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com Please see LICENSE files in the repository root for full details. */ -import { RefObject } from "react"; +import { type RefObject } from "react"; export type Ref = RefObject; diff --git a/src/actions/MatrixActionCreators.ts b/src/actions/MatrixActionCreators.ts index ccda4853f6..9dc0127bff 100644 --- a/src/actions/MatrixActionCreators.ts +++ b/src/actions/MatrixActionCreators.ts @@ -8,18 +8,18 @@ Please see LICENSE files in the repository root for full details. import { ClientEvent, - MatrixClient, - MatrixEvent, + type MatrixClient, + type MatrixEvent, MatrixEventEvent, - Room, + type Room, RoomEvent, - IRoomTimelineData, - RoomState, + type IRoomTimelineData, + type RoomState, RoomStateEvent, } from "matrix-js-sdk/src/matrix"; import dis from "../dispatcher/dispatcher"; -import { ActionPayload } from "../dispatcher/payloads"; +import { type ActionPayload } from "../dispatcher/payloads"; /** * Create a MatrixActions.sync action that represents a MatrixClient `sync` event, diff --git a/src/actions/RoomListActions.ts b/src/actions/RoomListActions.ts index 7418ec2cdd..255518a7dc 100644 --- a/src/actions/RoomListActions.ts +++ b/src/actions/RoomListActions.ts @@ -7,17 +7,17 @@ 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 { MatrixClient, Room } from "matrix-js-sdk/src/matrix"; +import { type MatrixClient, type Room } from "matrix-js-sdk/src/matrix"; import { logger } from "matrix-js-sdk/src/logger"; import { asyncAction } from "./actionCreators"; import Modal from "../Modal"; import * as Rooms from "../Rooms"; import { _t } from "../languageHandler"; -import { AsyncActionPayload } from "../dispatcher/payloads"; +import { type AsyncActionPayload } from "../dispatcher/payloads"; import RoomListStore from "../stores/room-list/RoomListStore"; import { SortAlgorithm } from "../stores/room-list/algorithms/models"; -import { DefaultTagID, TagID } from "../stores/room-list/models"; +import { DefaultTagID, type TagID } from "../stores/room-list/models"; import ErrorDialog from "../components/views/dialogs/ErrorDialog"; export default class RoomListActions { diff --git a/src/actions/actionCreators.ts b/src/actions/actionCreators.ts index f3a097d06f..ed8eb6b265 100644 --- a/src/actions/actionCreators.ts +++ b/src/actions/actionCreators.ts @@ -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 { AsyncActionFn, AsyncActionPayload } from "../dispatcher/payloads"; +import { type AsyncActionFn, AsyncActionPayload } from "../dispatcher/payloads"; /** * Create an action thunk that will dispatch actions indicating the current diff --git a/src/async-components/structures/ErrorView.tsx b/src/async-components/structures/ErrorView.tsx index cee6c6345b..57cf048403 100644 --- a/src/async-components/structures/ErrorView.tsx +++ b/src/async-components/structures/ErrorView.tsx @@ -5,7 +5,7 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com Please see LICENSE files in the repository root for full details. */ -import React, { ReactNode } from "react"; +import React, { type ReactNode } from "react"; import { Text, Heading, Button, Separator } from "@vector-im/compound-web"; import PopOutIcon from "@vector-im/compound-design-tokens/assets/web/icons/pop-out"; @@ -80,9 +80,9 @@ const MobileAppLinks: React.FC<{ const DesktopAppLinks: React.FC<{ macOsUrl?: string; win64Url?: string; - win32Url?: string; + win64ArmUrl?: string; linuxUrl?: string; -}> = ({ macOsUrl, win64Url, win32Url, linuxUrl }) => { +}> = ({ macOsUrl, win64Url, win64ArmUrl, linuxUrl }) => { return ( {macOsUrl && ( @@ -92,12 +92,12 @@ const DesktopAppLinks: React.FC<{ )} {win64Url && ( )} - {win32Url && ( - )} {linuxUrl && ( @@ -127,7 +127,7 @@ export const UnsupportedBrowserView: React.FC<{ config.desktop_builds?.available && (config.desktop_builds?.url_macos || config.desktop_builds?.url_win64 || - config.desktop_builds?.url_win32 || + config.desktop_builds?.url_win64arm || config.desktop_builds?.url_linux); const hasMobileBuilds = Boolean( config.mobile_builds?.ios || config.mobile_builds?.android || config.mobile_builds?.fdroid, @@ -157,7 +157,7 @@ export const UnsupportedBrowserView: React.FC<{ diff --git a/src/async-components/views/dialogs/eventindex/ManageEventIndexDialog.tsx b/src/async-components/views/dialogs/eventindex/ManageEventIndexDialog.tsx index a740523549..d0680fb4cd 100644 --- a/src/async-components/views/dialogs/eventindex/ManageEventIndexDialog.tsx +++ b/src/async-components/views/dialogs/eventindex/ManageEventIndexDialog.tsx @@ -6,8 +6,8 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com Please see LICENSE files in the repository root for full details. */ -import React, { ChangeEvent } from "react"; -import { Room } from "matrix-js-sdk/src/matrix"; +import React, { type ChangeEvent } from "react"; +import { type Room } from "matrix-js-sdk/src/matrix"; import { _t } from "../../../../languageHandler"; import SdkConfig from "../../../../SdkConfig"; @@ -19,7 +19,7 @@ import { SettingLevel } from "../../../../settings/SettingLevel"; import Field from "../../../../components/views/elements/Field"; import BaseDialog from "../../../../components/views/dialogs/BaseDialog"; import DialogButtons from "../../../../components/views/elements/DialogButtons"; -import { IIndexStats } from "../../../../indexing/BaseEventIndexManager"; +import { type IIndexStats } from "../../../../indexing/BaseEventIndexManager"; interface IProps { onFinished(): void; diff --git a/src/async-components/views/dialogs/security/CreateSecretStorageDialog.tsx b/src/async-components/views/dialogs/security/CreateSecretStorageDialog.tsx index 235f73fc8e..2bcf577d9f 100644 --- a/src/async-components/views/dialogs/security/CreateSecretStorageDialog.tsx +++ b/src/async-components/views/dialogs/security/CreateSecretStorageDialog.tsx @@ -10,8 +10,8 @@ Please see LICENSE files in the repository root for full details. import React, { createRef } from "react"; import FileSaver from "file-saver"; import { logger } from "matrix-js-sdk/src/logger"; -import { AuthDict, CrossSigningKeys, MatrixError, UIAFlow, UIAResponse } from "matrix-js-sdk/src/matrix"; -import { GeneratedSecretStorageKey } from "matrix-js-sdk/src/crypto-api"; +import { type AuthDict } from "matrix-js-sdk/src/matrix"; +import { type GeneratedSecretStorageKey } from "matrix-js-sdk/src/crypto-api"; import classNames from "classnames"; import CheckmarkIcon from "@vector-im/compound-design-tokens/assets/web/icons/check"; @@ -31,13 +31,13 @@ import { SecureBackupSetupMethod, } from "../../../../utils/WellKnownUtils"; import { ModuleRunner } from "../../../../modules/ModuleRunner"; -import Field from "../../../../components/views/elements/Field"; +import type Field from "../../../../components/views/elements/Field"; import BaseDialog from "../../../../components/views/dialogs/BaseDialog"; import Spinner from "../../../../components/views/elements/Spinner"; import InteractiveAuthDialog from "../../../../components/views/dialogs/InteractiveAuthDialog"; -import { IValidationResult } from "../../../../components/views/elements/Validation"; +import { type IValidationResult } from "../../../../components/views/elements/Validation"; import PassphraseConfirmField from "../../../../components/views/auth/PassphraseConfirmField"; -import { initialiseDehydration } from "../../../../utils/device/dehydration"; +import { initialiseDehydrationIfEnabled } from "../../../../utils/device/dehydration"; // I made a mistake while converting this and it has to be fixed! enum Phase { @@ -55,8 +55,6 @@ enum Phase { const PASSWORD_MIN_SCORE = 4; // So secure, many characters, much complex, wow, etc, etc. interface IProps { - hasCancel?: boolean; - accountPassword?: string; forceReset?: boolean; resetCrossSigning?: boolean; onFinished(ok?: boolean): void; @@ -71,11 +69,6 @@ interface IState { downloaded: boolean; setPassphrase: boolean; - // does the server offer a UI auth flow with just m.login.password - // for /keys/device_signing/upload? - canUploadKeysWithPasswordOnly: boolean | null; - accountPassword: string; - accountPasswordCorrect: boolean | null; canSkip: boolean; passPhraseKeySelected: string; error?: boolean; @@ -90,7 +83,6 @@ interface IState { */ export default class CreateSecretStorageDialog extends React.PureComponent { public static defaultProps: Partial = { - hasCancel: true, forceReset: false, resetCrossSigning: false, }; @@ -111,16 +103,6 @@ export default class CreateSecretStorageDialog extends React.PureComponent { - try { - await MatrixClientPeg.safeGet().uploadDeviceSigningKeys(undefined, {} as CrossSigningKeys); - // We should never get here: the server should always require - // UI auth to upload device signing keys. If we do, we upload - // no keys which would be a no-op. - logger.log("uploadDeviceSigningKeys unexpectedly succeeded without UI auth!"); - } catch (error) { - if (!(error instanceof MatrixError) || !error.data || !error.data.flows) { - logger.log("uploadDeviceSigningKeys advertised no flows!"); - return; - } - const canUploadKeysWithPasswordOnly = error.data.flows.some((f: UIAFlow) => { - return f.stages.length === 1 && f.stages[0] === "m.login.password"; - }); - this.setState({ - canUploadKeysWithPasswordOnly, - }); - } - } - private onKeyPassphraseChange = (e: React.ChangeEvent): void => { this.setState({ passPhraseKeySelected: e.target.value, @@ -225,47 +177,34 @@ export default class CreateSecretStorageDialog extends React.PureComponent Promise>, - ): Promise => { - if (this.state.canUploadKeysWithPasswordOnly && this.state.accountPassword) { - await makeRequest({ - type: "m.login.password", - identifier: { - type: "m.id.user", - user: MatrixClientPeg.safeGet().getSafeUserId(), - }, - password: this.state.accountPassword, - }); - } else { - const dialogAesthetics = { - [SSOAuthEntry.PHASE_PREAUTH]: { - title: _t("auth|uia|sso_title"), - body: _t("auth|uia|sso_preauth_body"), - continueText: _t("auth|sso"), - continueKind: "primary", - }, - [SSOAuthEntry.PHASE_POSTAUTH]: { - title: _t("encryption|confirm_encryption_setup_title"), - body: _t("encryption|confirm_encryption_setup_body"), - continueText: _t("action|confirm"), - continueKind: "primary", - }, - }; + private doBootstrapUIAuth = async (makeRequest: (authData: AuthDict) => Promise): Promise => { + const dialogAesthetics = { + [SSOAuthEntry.PHASE_PREAUTH]: { + title: _t("auth|uia|sso_title"), + body: _t("auth|uia|sso_preauth_body"), + continueText: _t("auth|sso"), + continueKind: "primary", + }, + [SSOAuthEntry.PHASE_POSTAUTH]: { + title: _t("encryption|confirm_encryption_setup_title"), + body: _t("encryption|confirm_encryption_setup_body"), + continueText: _t("action|confirm"), + continueKind: "primary", + }, + }; - const { finished } = Modal.createDialog(InteractiveAuthDialog, { - title: _t("encryption|bootstrap_title"), - matrixClient: MatrixClientPeg.safeGet(), - makeRequest, - aestheticsForStagePhases: { - [SSOAuthEntry.LOGIN_TYPE]: dialogAesthetics, - [SSOAuthEntry.UNSTABLE_LOGIN_TYPE]: dialogAesthetics, - }, - }); - const [confirmed] = await finished; - if (!confirmed) { - throw new Error("Cross-signing key upload auth canceled"); - } + const { finished } = Modal.createDialog(InteractiveAuthDialog, { + title: _t("encryption|bootstrap_title"), + matrixClient: MatrixClientPeg.safeGet(), + makeRequest, + aestheticsForStagePhases: { + [SSOAuthEntry.LOGIN_TYPE]: dialogAesthetics, + [SSOAuthEntry.UNSTABLE_LOGIN_TYPE]: dialogAesthetics, + }, + }); + const [confirmed] = await finished; + if (!confirmed) { + throw new Error("Cross-signing key upload auth canceled"); } }; @@ -332,7 +271,7 @@ export default class CreateSecretStorageDialog extends React.PureComponent
{content}
diff --git a/src/async-components/views/dialogs/security/ExportE2eKeysDialog.tsx b/src/async-components/views/dialogs/security/ExportE2eKeysDialog.tsx index 18dd507b5a..c2a2510173 100644 --- a/src/async-components/views/dialogs/security/ExportE2eKeysDialog.tsx +++ b/src/async-components/views/dialogs/security/ExportE2eKeysDialog.tsx @@ -8,17 +8,17 @@ Please see LICENSE files in the repository root for full details. */ import FileSaver from "file-saver"; -import React, { ChangeEvent } from "react"; -import { MatrixClient } from "matrix-js-sdk/src/matrix"; +import React, { type ChangeEvent } from "react"; +import { type MatrixClient } from "matrix-js-sdk/src/matrix"; import { logger } from "matrix-js-sdk/src/logger"; import { _t, _td } from "../../../../languageHandler"; import * as MegolmExportEncryption from "../../../../utils/MegolmExportEncryption"; import BaseDialog from "../../../../components/views/dialogs/BaseDialog"; -import { KeysStartingWith } from "../../../../@types/common"; +import { type KeysStartingWith } from "../../../../@types/common"; import PassphraseField from "../../../../components/views/auth/PassphraseField"; import PassphraseConfirmField from "../../../../components/views/auth/PassphraseConfirmField"; -import Field from "../../../../components/views/elements/Field"; +import type Field from "../../../../components/views/elements/Field"; enum Phase { Edit = "edit", diff --git a/src/async-components/views/dialogs/security/ImportE2eKeysDialog.tsx b/src/async-components/views/dialogs/security/ImportE2eKeysDialog.tsx index 16d50640f5..fff841d8a6 100644 --- a/src/async-components/views/dialogs/security/ImportE2eKeysDialog.tsx +++ b/src/async-components/views/dialogs/security/ImportE2eKeysDialog.tsx @@ -8,7 +8,7 @@ Please see LICENSE files in the repository root for full details. */ import React, { createRef } from "react"; -import { MatrixClient } from "matrix-js-sdk/src/matrix"; +import { type MatrixClient } from "matrix-js-sdk/src/matrix"; import { logger } from "matrix-js-sdk/src/logger"; import * as MegolmExportEncryption from "../../../../utils/MegolmExportEncryption"; diff --git a/src/async-components/views/dialogs/security/NewRecoveryMethodDialog.tsx b/src/async-components/views/dialogs/security/NewRecoveryMethodDialog.tsx index bec708e664..9b9683b798 100644 --- a/src/async-components/views/dialogs/security/NewRecoveryMethodDialog.tsx +++ b/src/async-components/views/dialogs/security/NewRecoveryMethodDialog.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, { JSX, useEffect, useState } from "react"; +import React, { type JSX, useEffect, useState } from "react"; import dis from "../../../../dispatcher/dispatcher"; import { _t } from "../../../../languageHandler"; diff --git a/src/audio/ManagedPlayback.ts b/src/audio/ManagedPlayback.ts index 0f0b420d1e..43cea38f5f 100644 --- a/src/audio/ManagedPlayback.ts +++ b/src/audio/ManagedPlayback.ts @@ -7,7 +7,7 @@ Please see LICENSE files in the repository root for full details. */ import { Playback } from "./Playback"; -import { PlaybackManager } from "./PlaybackManager"; +import { type PlaybackManager } from "./PlaybackManager"; import { DEFAULT_WAVEFORM } from "./consts"; /** diff --git a/src/audio/Playback.ts b/src/audio/Playback.ts index af777a8c01..58b8a23c22 100644 --- a/src/audio/Playback.ts +++ b/src/audio/Playback.ts @@ -13,7 +13,7 @@ import { defer } from "matrix-js-sdk/src/utils"; import { UPDATE_EVENT } from "../stores/AsyncStore"; import { arrayFastResample } from "../utils/arrays"; -import { IDestroyable } from "../utils/IDestroyable"; +import { type IDestroyable } from "../utils/IDestroyable"; import { PlaybackClock } from "./PlaybackClock"; import { createAudioContext, decodeOgg } from "./compat"; import { clamp } from "../utils/numbers"; diff --git a/src/audio/PlaybackClock.ts b/src/audio/PlaybackClock.ts index ceb06987c8..4099483c8e 100644 --- a/src/audio/PlaybackClock.ts +++ b/src/audio/PlaybackClock.ts @@ -7,9 +7,9 @@ Please see LICENSE files in the repository root for full details. */ import { SimpleObservable } from "matrix-widget-api"; -import { MatrixEvent } from "matrix-js-sdk/src/matrix"; +import { type MatrixEvent } from "matrix-js-sdk/src/matrix"; -import { IDestroyable } from "../utils/IDestroyable"; +import { type IDestroyable } from "../utils/IDestroyable"; /** * Tracks accurate human-perceptible time for an audio clip, as informed diff --git a/src/audio/PlaybackManager.ts b/src/audio/PlaybackManager.ts index 80d28f1196..85677d68aa 100644 --- a/src/audio/PlaybackManager.ts +++ b/src/audio/PlaybackManager.ts @@ -6,7 +6,7 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com Please see LICENSE files in the repository root for full details. */ -import { Playback, PlaybackState } from "./Playback"; +import { type Playback, PlaybackState } from "./Playback"; import { ManagedPlayback } from "./ManagedPlayback"; import { DEFAULT_WAVEFORM } from "./consts"; diff --git a/src/audio/PlaybackQueue.ts b/src/audio/PlaybackQueue.ts index f84f6add3c..fbca9d7872 100644 --- a/src/audio/PlaybackQueue.ts +++ b/src/audio/PlaybackQueue.ts @@ -6,10 +6,10 @@ 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 { MatrixEvent, Room, EventType } from "matrix-js-sdk/src/matrix"; +import { type MatrixEvent, type Room, EventType } from "matrix-js-sdk/src/matrix"; import { logger } from "matrix-js-sdk/src/logger"; -import { Playback, PlaybackState } from "./Playback"; +import { type Playback, PlaybackState } from "./Playback"; import { UPDATE_EVENT } from "../stores/AsyncStore"; import { MatrixClientPeg } from "../MatrixClientPeg"; import { arrayFastClone } from "../utils/arrays"; diff --git a/src/audio/RecorderWorklet.ts b/src/audio/RecorderWorklet.ts index acf23f4dcd..ec4a143c4e 100644 --- a/src/audio/RecorderWorklet.ts +++ b/src/audio/RecorderWorklet.ts @@ -6,7 +6,7 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com Please see LICENSE files in the repository root for full details. */ -import { IAmplitudePayload, ITimingPayload, PayloadEvent, WORKLET_NAME } from "./consts"; +import { type IAmplitudePayload, type ITimingPayload, PayloadEvent, WORKLET_NAME } from "./consts"; import { percentageOf } from "../utils/numbers"; // from AudioWorkletGlobalScope: https://developer.mozilla.org/en-US/docs/Web/API/AudioWorkletGlobalScope diff --git a/src/audio/VoiceMessageRecording.ts b/src/audio/VoiceMessageRecording.ts index 2dac4ac9c3..1b15896e97 100644 --- a/src/audio/VoiceMessageRecording.ts +++ b/src/audio/VoiceMessageRecording.ts @@ -6,16 +6,16 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com Please see LICENSE files in the repository root for full details. */ -import { MatrixClient } from "matrix-js-sdk/src/matrix"; -import { EncryptedFile } from "matrix-js-sdk/src/types"; -import { SimpleObservable } from "matrix-widget-api"; +import { type MatrixClient } from "matrix-js-sdk/src/matrix"; +import { type EncryptedFile } from "matrix-js-sdk/src/types"; +import { type SimpleObservable } from "matrix-widget-api"; import { uploadFile } from "../ContentMessages"; import { concat } from "../utils/arrays"; -import { IDestroyable } from "../utils/IDestroyable"; +import { type IDestroyable } from "../utils/IDestroyable"; import { Singleflight } from "../utils/Singleflight"; import { Playback } from "./Playback"; -import { IRecordingUpdate, RecordingState, VoiceRecording } from "./VoiceRecording"; +import { type IRecordingUpdate, RecordingState, VoiceRecording } from "./VoiceRecording"; export interface IUpload { mxc?: string; // for unencrypted uploads diff --git a/src/audio/VoiceRecording.ts b/src/audio/VoiceRecording.ts index 0a48c27e06..bde86f9dd7 100644 --- a/src/audio/VoiceRecording.ts +++ b/src/audio/VoiceRecording.ts @@ -13,7 +13,7 @@ import EventEmitter from "events"; import { logger } from "matrix-js-sdk/src/logger"; import MediaDeviceHandler from "../MediaDeviceHandler"; -import { IDestroyable } from "../utils/IDestroyable"; +import { type IDestroyable } from "../utils/IDestroyable"; import { Singleflight } from "../utils/Singleflight"; import { PayloadEvent, WORKLET_NAME } from "./consts"; import { UPDATE_EVENT } from "../stores/AsyncStore"; diff --git a/src/autocomplete/AutocompleteProvider.tsx b/src/autocomplete/AutocompleteProvider.tsx index 0161a64dce..94614f0ae7 100644 --- a/src/autocomplete/AutocompleteProvider.tsx +++ b/src/autocomplete/AutocompleteProvider.tsx @@ -7,8 +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 from "react"; - +import type React from "react"; import { TimelineRenderingType } from "../contexts/RoomContext"; import type { ICompletion, ISelectionRange } from "./Autocompleter"; diff --git a/src/autocomplete/Autocompleter.ts b/src/autocomplete/Autocompleter.ts index 7ac11e9539..b3ab0ade46 100644 --- a/src/autocomplete/Autocompleter.ts +++ b/src/autocomplete/Autocompleter.ts @@ -6,8 +6,8 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com Please see LICENSE files in the repository root for full details. */ -import { ReactElement } from "react"; -import { Room } from "matrix-js-sdk/src/matrix"; +import { type ReactElement } from "react"; +import { type Room } from "matrix-js-sdk/src/matrix"; import CommandProvider from "./CommandProvider"; import RoomProvider from "./RoomProvider"; @@ -15,7 +15,8 @@ import UserProvider from "./UserProvider"; import EmojiProvider from "./EmojiProvider"; import NotifProvider from "./NotifProvider"; import { timeout } from "../utils/promise"; -import AutocompleteProvider, { ICommand } from "./AutocompleteProvider"; +import { type ICommand } from "./AutocompleteProvider"; +import type AutocompleteProvider from "./AutocompleteProvider"; import SpaceProvider from "./SpaceProvider"; import { TimelineRenderingType } from "../contexts/RoomContext"; import { filterBoolean } from "../utils/arrays"; diff --git a/src/autocomplete/CommandProvider.tsx b/src/autocomplete/CommandProvider.tsx index d2b9f18f15..76d53bb865 100644 --- a/src/autocomplete/CommandProvider.tsx +++ b/src/autocomplete/CommandProvider.tsx @@ -10,15 +10,15 @@ Please see LICENSE files in the repository root for full details. */ import React from "react"; -import { Room } from "matrix-js-sdk/src/matrix"; +import { type Room } from "matrix-js-sdk/src/matrix"; import { _t } from "../languageHandler"; import AutocompleteProvider from "./AutocompleteProvider"; import QueryMatcher from "./QueryMatcher"; import { TextualCompletion } from "./Components"; -import { ICompletion, ISelectionRange } from "./Autocompleter"; -import { Command, Commands, CommandMap } from "../SlashCommands"; -import { TimelineRenderingType } from "../contexts/RoomContext"; +import { type ICompletion, type ISelectionRange } from "./Autocompleter"; +import { type Command, Commands, CommandMap } from "../SlashCommands"; +import { type TimelineRenderingType } from "../contexts/RoomContext"; import { MatrixClientPeg } from "../MatrixClientPeg"; const COMMAND_RE = /(^\/\w*)(?: .*)?/g; diff --git a/src/autocomplete/EmojiProvider.tsx b/src/autocomplete/EmojiProvider.tsx index f976fd37db..2d31095acd 100644 --- a/src/autocomplete/EmojiProvider.tsx +++ b/src/autocomplete/EmojiProvider.tsx @@ -11,18 +11,18 @@ Please see LICENSE files in the repository root for full details. */ import React from "react"; -import { uniq, sortBy, uniqBy, ListIteratee } from "lodash"; +import { uniq, sortBy, uniqBy, type ListIteratee } from "lodash"; import EMOTICON_REGEX from "emojibase-regex/emoticon"; -import { Room } from "matrix-js-sdk/src/matrix"; -import { EMOJI, Emoji, getEmojiFromUnicode } from "@matrix-org/emojibase-bindings"; +import { type Room } from "matrix-js-sdk/src/matrix"; +import { EMOJI, type Emoji, getEmojiFromUnicode } from "@matrix-org/emojibase-bindings"; import { _t } from "../languageHandler"; import AutocompleteProvider from "./AutocompleteProvider"; import QueryMatcher from "./QueryMatcher"; import { PillCompletion } from "./Components"; -import { ICompletion, ISelectionRange } from "./Autocompleter"; +import { type ICompletion, type ISelectionRange } from "./Autocompleter"; import SettingsStore from "../settings/SettingsStore"; -import { TimelineRenderingType } from "../contexts/RoomContext"; +import { type TimelineRenderingType } from "../contexts/RoomContext"; import * as recent from "../emojipicker/recent"; import { filterBoolean } from "../utils/arrays"; diff --git a/src/autocomplete/NotifProvider.tsx b/src/autocomplete/NotifProvider.tsx index 4919045578..2a6e070efa 100644 --- a/src/autocomplete/NotifProvider.tsx +++ b/src/autocomplete/NotifProvider.tsx @@ -6,15 +6,15 @@ Please see LICENSE files in the repository root for full details. */ import React from "react"; -import { Room } from "matrix-js-sdk/src/matrix"; +import { type Room } from "matrix-js-sdk/src/matrix"; import AutocompleteProvider from "./AutocompleteProvider"; import { _t } from "../languageHandler"; import { MatrixClientPeg } from "../MatrixClientPeg"; import { PillCompletion } from "./Components"; -import { ICompletion, ISelectionRange } from "./Autocompleter"; +import { type ICompletion, type ISelectionRange } from "./Autocompleter"; import RoomAvatar from "../components/views/avatars/RoomAvatar"; -import { TimelineRenderingType } from "../contexts/RoomContext"; +import { type TimelineRenderingType } from "../contexts/RoomContext"; const AT_ROOM_REGEX = /@\S*/g; diff --git a/src/autocomplete/QueryMatcher.ts b/src/autocomplete/QueryMatcher.ts index 985ca3a516..0012beacfa 100644 --- a/src/autocomplete/QueryMatcher.ts +++ b/src/autocomplete/QueryMatcher.ts @@ -11,10 +11,10 @@ Please see LICENSE files in the repository root for full details. import { at, uniq } from "lodash"; import { removeHiddenChars } from "matrix-js-sdk/src/utils"; -import { TimelineRenderingType } from "../contexts/RoomContext"; -import { Leaves } from "../@types/common"; +import { type TimelineRenderingType } from "../contexts/RoomContext"; +import { type Leaves } from "../@types/common"; -interface IOptions { +interface IOptions { keys: Array>; funcs?: Array<(o: T) => string | string[]>; shouldMatchWordsOnly?: boolean; @@ -37,7 +37,7 @@ interface IOptions { * @param {function[]} options.funcs List of functions that when called with the * object as an arg will return a string to use as an index */ -export default class QueryMatcher { +export default class QueryMatcher { private _options: IOptions; private _items = new Map(); diff --git a/src/autocomplete/RoomProvider.tsx b/src/autocomplete/RoomProvider.tsx index 84a4a8a023..5c06856b08 100644 --- a/src/autocomplete/RoomProvider.tsx +++ b/src/autocomplete/RoomProvider.tsx @@ -10,7 +10,7 @@ Please see LICENSE files in the repository root for full details. import React from "react"; import { sortBy, uniqBy } from "lodash"; -import { Room } from "matrix-js-sdk/src/matrix"; +import { type Room } from "matrix-js-sdk/src/matrix"; import { _t } from "../languageHandler"; import AutocompleteProvider from "./AutocompleteProvider"; @@ -18,9 +18,9 @@ import { MatrixClientPeg } from "../MatrixClientPeg"; import QueryMatcher from "./QueryMatcher"; import { PillCompletion } from "./Components"; import { makeRoomPermalink } from "../utils/permalinks/Permalinks"; -import { ICompletion, ISelectionRange } from "./Autocompleter"; +import { type ICompletion, type ISelectionRange } from "./Autocompleter"; import RoomAvatar from "../components/views/avatars/RoomAvatar"; -import { TimelineRenderingType } from "../contexts/RoomContext"; +import { type TimelineRenderingType } from "../contexts/RoomContext"; import SettingsStore from "../settings/SettingsStore"; const ROOM_REGEX = /\B#\S*/g; diff --git a/src/autocomplete/SpaceProvider.tsx b/src/autocomplete/SpaceProvider.tsx index 273d9ace00..b031f37c86 100644 --- a/src/autocomplete/SpaceProvider.tsx +++ b/src/autocomplete/SpaceProvider.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 { Room } from "matrix-js-sdk/src/matrix"; +import { type Room } from "matrix-js-sdk/src/matrix"; import React from "react"; import { _t } from "../languageHandler"; diff --git a/src/autocomplete/UserProvider.tsx b/src/autocomplete/UserProvider.tsx index 673bee7bf2..9cd5564795 100644 --- a/src/autocomplete/UserProvider.tsx +++ b/src/autocomplete/UserProvider.tsx @@ -12,13 +12,13 @@ Please see LICENSE files in the repository root for full details. import React from "react"; import { sortBy } from "lodash"; import { - MatrixEvent, - Room, + type MatrixEvent, + type Room, RoomEvent, - RoomMember, - RoomState, + type RoomMember, + type RoomState, RoomStateEvent, - IRoomTimelineData, + type IRoomTimelineData, } from "matrix-js-sdk/src/matrix"; import { KnownMembership } from "matrix-js-sdk/src/types"; @@ -28,9 +28,9 @@ import { PillCompletion } from "./Components"; import AutocompleteProvider from "./AutocompleteProvider"; import { _t } from "../languageHandler"; import { makeUserPermalink } from "../utils/permalinks/Permalinks"; -import { ICompletion, ISelectionRange } from "./Autocompleter"; +import { type ICompletion, type ISelectionRange } from "./Autocompleter"; import MemberAvatar from "../components/views/avatars/MemberAvatar"; -import { TimelineRenderingType } from "../contexts/RoomContext"; +import { type TimelineRenderingType } from "../contexts/RoomContext"; import UserIdentifierCustomisations from "../customisations/UserIdentifier"; const USER_REGEX = /\B@\S*/g; diff --git a/src/boundThreepids.ts b/src/boundThreepids.ts index b7c749a020..2d8de4817e 100644 --- a/src/boundThreepids.ts +++ b/src/boundThreepids.ts @@ -6,7 +6,7 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com Please see LICENSE files in the repository root for full details. */ -import { IThreepid, ThreepidMedium, MatrixClient, MatrixError } from "matrix-js-sdk/src/matrix"; +import { type IThreepid, type ThreepidMedium, type MatrixClient, MatrixError } from "matrix-js-sdk/src/matrix"; import IdentityAuthClient from "./IdentityAuthClient"; diff --git a/src/components/structures/AutoHideScrollbar.tsx b/src/components/structures/AutoHideScrollbar.tsx index 764a712d44..7e52e9b192 100644 --- a/src/components/structures/AutoHideScrollbar.tsx +++ b/src/components/structures/AutoHideScrollbar.tsx @@ -8,10 +8,10 @@ Please see LICENSE files in the repository root for full details. */ import classNames from "classnames"; -import React, { HTMLAttributes, ReactHTML, ReactNode, WheelEvent } from "react"; +import React, { type HTMLAttributes, type ReactHTML, type ReactNode, type WheelEvent } from "react"; type DynamicHtmlElementProps = - JSX.IntrinsicElements[T] extends HTMLAttributes<{}> ? DynamicElementProps : DynamicElementProps<"div">; + JSX.IntrinsicElements[T] extends HTMLAttributes ? DynamicElementProps : DynamicElementProps<"div">; type DynamicElementProps = Partial>; export type IProps = Omit, "onScroll"> & { diff --git a/src/components/structures/AutocompleteInput.tsx b/src/components/structures/AutocompleteInput.tsx index b25e93bc75..9767bd5761 100644 --- a/src/components/structures/AutocompleteInput.tsx +++ b/src/components/structures/AutocompleteInput.tsx @@ -6,13 +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, { useState, ReactNode, ChangeEvent, KeyboardEvent, useRef, ReactElement } from "react"; +import React, { + useState, + type ReactNode, + type ChangeEvent, + type KeyboardEvent, + useRef, + type ReactElement, +} from "react"; import classNames from "classnames"; import { SearchIcon, CloseIcon } from "@vector-im/compound-design-tokens/assets/web/icons"; -import Autocompleter from "../../autocomplete/AutocompleteProvider"; +import type Autocompleter from "../../autocomplete/AutocompleteProvider"; import { Key } from "../../Keyboard"; -import { ICompletion } from "../../autocomplete/Autocompleter"; +import { type ICompletion } from "../../autocomplete/Autocompleter"; import AccessibleButton from "../../components/views/elements/AccessibleButton"; import useFocus from "../../hooks/useFocus"; @@ -142,6 +149,7 @@ export const AutocompleteInput: React.FC = ({ {isFocused && suggestions.length ? (
diff --git a/src/components/structures/BackdropPanel.tsx b/src/components/structures/BackdropPanel.tsx index 64b2ab0fce..f3a44521fa 100644 --- a/src/components/structures/BackdropPanel.tsx +++ b/src/components/structures/BackdropPanel.tsx @@ -5,7 +5,7 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com Please see LICENSE files in the repository root for full details. */ -import React, { CSSProperties } from "react"; +import React, { type CSSProperties } from "react"; interface IProps { backgroundImage?: string; diff --git a/src/components/structures/ContextMenu.tsx b/src/components/structures/ContextMenu.tsx index 3d0c169267..5a6ca20e35 100644 --- a/src/components/structures/ContextMenu.tsx +++ b/src/components/structures/ContextMenu.tsx @@ -8,12 +8,12 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com Please see LICENSE files in the repository root for full details. */ -import React, { CSSProperties, RefObject, SyntheticEvent, useRef, useState } from "react"; +import React, { type CSSProperties, type RefObject, type SyntheticEvent, useRef, useState } from "react"; import ReactDOM from "react-dom"; import classNames from "classnames"; import FocusLock from "react-focus-lock"; -import { Writeable } from "../../@types/common"; +import { type Writeable } from "../../@types/common"; import UIStore from "../../stores/UIStore"; import { checkInputableElement, RovingTabIndexProvider } from "../../accessibility/RovingTabIndex"; import { KeyBindingAction } from "../../accessibility/KeyboardShortcuts"; @@ -607,6 +607,7 @@ export const useContextMenu = (inputRef?: RefObject setIsOpen(false); }; + // eslint-disable-next-line react-compiler/react-compiler return [button.current ? isOpen : false, button, open, close, setIsOpen]; }; diff --git a/src/components/structures/EmbeddedPage.tsx b/src/components/structures/EmbeddedPage.tsx index 2833f79626..4b89102d6f 100644 --- a/src/components/structures/EmbeddedPage.tsx +++ b/src/components/structures/EmbeddedPage.tsx @@ -12,12 +12,12 @@ import sanitizeHtml from "sanitize-html"; import classnames from "classnames"; import { logger } from "matrix-js-sdk/src/logger"; -import { _t, TranslationKey } from "../../languageHandler"; +import { _t, type TranslationKey } from "../../languageHandler"; import dis from "../../dispatcher/dispatcher"; import { MatrixClientPeg } from "../../MatrixClientPeg"; import MatrixClientContext from "../../contexts/MatrixClientContext"; import AutoHideScrollbar from "./AutoHideScrollbar"; -import { ActionPayload } from "../../dispatcher/payloads"; +import { type ActionPayload } from "../../dispatcher/payloads"; interface IProps { // URL to request embedded page content from diff --git a/src/components/structures/ErrorMessage.tsx b/src/components/structures/ErrorMessage.tsx index ce4788c0fa..c721e67fc5 100644 --- a/src/components/structures/ErrorMessage.tsx +++ b/src/components/structures/ErrorMessage.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, { ReactNode } from "react"; +import React, { type ReactNode } from "react"; import { WarningIcon } from "@vector-im/compound-design-tokens/assets/web/icons"; interface ErrorMessageProps { diff --git a/src/components/structures/FilePanel.tsx b/src/components/structures/FilePanel.tsx index 32e5bbc519..5930490c34 100644 --- a/src/components/structures/FilePanel.tsx +++ b/src/components/structures/FilePanel.tsx @@ -10,14 +10,14 @@ Please see LICENSE files in the repository root for full details. import React, { createRef } from "react"; import { Filter, - EventTimelineSet, - IRoomTimelineData, - Direction, - MatrixEvent, + type EventTimelineSet, + type IRoomTimelineData, + type Direction, + type MatrixEvent, MatrixEventEvent, - Room, + type Room, RoomEvent, - TimelineWindow, + type TimelineWindow, } from "matrix-js-sdk/src/matrix"; import { logger } from "matrix-js-sdk/src/logger"; import FilesIcon from "@vector-im/compound-design-tokens/assets/web/icons/files"; @@ -27,7 +27,7 @@ import EventIndexPeg from "../../indexing/EventIndexPeg"; import { _t } from "../../languageHandler"; import SearchWarning, { WarningKind } from "../views/elements/SearchWarning"; import BaseCard from "../views/right_panel/BaseCard"; -import ResizeNotifier from "../../utils/ResizeNotifier"; +import type ResizeNotifier from "../../utils/ResizeNotifier"; import TimelinePanel from "./TimelinePanel"; import Spinner from "../views/elements/Spinner"; import { Layout } from "../../settings/enums/Layout"; @@ -286,9 +286,7 @@ class FilePanel extends React.Component { ref={this.card} header={_t("right_panel|files_button")} > - {this.card.current && ( - - )} + = { diff --git a/src/components/structures/HomePage.tsx b/src/components/structures/HomePage.tsx index f8dcc7b70d..01c0042752 100644 --- a/src/components/structures/HomePage.tsx +++ b/src/components/structures/HomePage.tsx @@ -17,7 +17,7 @@ import dis from "../../dispatcher/dispatcher"; import { Action } from "../../dispatcher/actions"; import BaseAvatar from "../views/avatars/BaseAvatar"; import { OwnProfileStore } from "../../stores/OwnProfileStore"; -import AccessibleButton, { ButtonEvent } from "../views/elements/AccessibleButton"; +import AccessibleButton, { type ButtonEvent } from "../views/elements/AccessibleButton"; import { UPDATE_EVENT } from "../../stores/AsyncStore"; import { useEventEmitter } from "../../hooks/useEventEmitter"; import MatrixClientContext, { useMatrixClientContext } from "../../contexts/MatrixClientContext"; @@ -27,7 +27,7 @@ import EmbeddedPage from "./EmbeddedPage"; const onClickSendDm = (ev: ButtonEvent): void => { PosthogTrackers.trackInteraction("WebHomeCreateChatButton", ev); - dis.dispatch({ action: "view_create_chat" }); + dis.dispatch({ action: Action.CreateChat }); }; const onClickExplore = (ev: ButtonEvent): void => { @@ -37,7 +37,7 @@ const onClickExplore = (ev: ButtonEvent): void => { const onClickNewRoom = (ev: ButtonEvent): void => { PosthogTrackers.trackInteraction("WebHomeCreateRoomButton", ev); - dis.dispatch({ action: "view_create_room" }); + dis.dispatch({ action: Action.CreateRoom }); }; interface IProps { diff --git a/src/components/structures/IndicatorScrollbar.tsx b/src/components/structures/IndicatorScrollbar.tsx index 1d0a90a14e..5244074bf8 100644 --- a/src/components/structures/IndicatorScrollbar.tsx +++ b/src/components/structures/IndicatorScrollbar.tsx @@ -7,7 +7,7 @@ Please see LICENSE files in the repository root for full details. import React, { createRef } from "react"; -import AutoHideScrollbar, { IProps as AutoHideScrollbarProps } from "./AutoHideScrollbar"; +import AutoHideScrollbar, { type IProps as AutoHideScrollbarProps } from "./AutoHideScrollbar"; import UIStore, { UI_EVENTS } from "../../stores/UIStore"; export type IProps = Omit, "onWheel" | "element"> & { diff --git a/src/components/structures/InteractiveAuth.tsx b/src/components/structures/InteractiveAuth.tsx index 08aa4eeafa..d1c670c27c 100644 --- a/src/components/structures/InteractiveAuth.tsx +++ b/src/components/structures/InteractiveAuth.tsx @@ -9,31 +9,28 @@ Please see LICENSE files in the repository root for full details. import React, { createRef } from "react"; import { AuthType, - IAuthData, - AuthDict, - IInputs, + type IAuthData, + type AuthDict, + type IInputs, InteractiveAuth, - IStageStatus, + type IStageStatus, } from "matrix-js-sdk/src/interactive-auth"; -import { MatrixClient } from "matrix-js-sdk/src/matrix"; +import { type MatrixClient } from "matrix-js-sdk/src/matrix"; import { logger } from "matrix-js-sdk/src/logger"; import getEntryComponentForLoginType, { - ContinueKind, - CustomAuthType, - IStageComponent, + type ContinueKind, + type CustomAuthType, + type IStageComponent, } from "../views/auth/InteractiveAuthEntryComponents"; import Spinner from "../views/elements/Spinner"; export const ERROR_USER_CANCELLED = new Error("User cancelled auth session"); -type InteractiveAuthCallbackSuccess = ( - success: true, - response: T, - extra?: { emailSid?: string; clientSecret?: string }, -) => Promise; -type InteractiveAuthCallbackFailure = (success: false, response: IAuthData | Error) => Promise; -export type InteractiveAuthCallback = InteractiveAuthCallbackSuccess & InteractiveAuthCallbackFailure; +export type InteractiveAuthCallback = { + (success: true, response: T, extra?: { emailSid?: string; clientSecret?: string }): Promise; + (success: false, response: IAuthData | Error): Promise; +}; export interface InteractiveAuthProps { // matrix client to use for UI auth requests @@ -49,10 +46,6 @@ export interface InteractiveAuthProps { emailSid?: string; // If true, poll to see if the auth flow has been completed out-of-band poll?: boolean; - // If true, components will be told that the 'Continue' button - // is managed by some other party and should not be managed by - // the component itself. - continueIsManaged?: boolean; // continueText and continueKind are passed straight through to the AuthEntryComponent. continueText?: string; continueKind?: ContinueKind; @@ -288,7 +281,6 @@ export default class InteractiveAuthComponent extends React.Component { private listContainerRef = createRef(); - private roomListRef = createRef(); + private roomListRef = createRef(); private focusedElement: Element | null = null; private isDoingStickyHeaders = false; @@ -66,6 +68,7 @@ export default class LeftPanel extends React.Component { this.state = { activeSpace: SpaceStore.instance.activeSpace, showBreadcrumbs: LeftPanel.breadcrumbsMode, + supportsPstnProtocol: LegacyCallHandler.instance.getSupportsPstnProtocol(), }; } @@ -77,6 +80,7 @@ export default class LeftPanel extends React.Component { BreadcrumbsStore.instance.on(UPDATE_EVENT, this.onBreadcrumbsUpdate); RoomListStore.instance.on(LISTS_UPDATE_EVENT, this.onBreadcrumbsUpdate); SpaceStore.instance.on(UPDATE_SELECTED_SPACE, this.updateActiveSpace); + LegacyCallHandler.instance.on(LegacyCallHandlerEvent.ProtocolSupport, this.updateProtocolSupport); if (this.listContainerRef.current) { UIStore.instance.trackElementDimensions("ListContainer", this.listContainerRef.current); @@ -91,6 +95,7 @@ export default class LeftPanel extends React.Component { BreadcrumbsStore.instance.off(UPDATE_EVENT, this.onBreadcrumbsUpdate); RoomListStore.instance.off(LISTS_UPDATE_EVENT, this.onBreadcrumbsUpdate); SpaceStore.instance.off(UPDATE_SELECTED_SPACE, this.updateActiveSpace); + LegacyCallHandler.instance.off(LegacyCallHandlerEvent.ProtocolSupport, this.updateProtocolSupport); UIStore.instance.stopTrackingElementDimensions("ListContainer"); UIStore.instance.removeListener("ListContainer", this.refreshStickyHeaders); this.listContainerRef.current?.removeEventListener("scroll", this.onScroll); @@ -102,6 +107,10 @@ export default class LeftPanel extends React.Component { } } + private updateProtocolSupport = (): void => { + this.setState({ supportsPstnProtocol: LegacyCallHandler.instance.getSupportsPstnProtocol() }); + }; + private updateActiveSpace = (activeSpace: SpaceKey): void => { this.setState({ activeSpace }); }; @@ -331,9 +340,8 @@ export default class LeftPanel extends React.Component { private renderSearchDialExplore(): React.ReactNode { let dialPadButton: JSX.Element | undefined; - // If we have dialer support, show a button to bring up the dial pad - // to start a new call - if (LegacyCallHandler.instance.getSupportsPstnProtocol()) { + // If we have dialer support, show a button to bring up the dial pad to start a new call + if (this.state.supportsPstnProtocol) { dialPadButton = ( { } public render(): React.ReactNode { + const containerClasses = classNames({ + mx_LeftPanel: true, + 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 ( +
+
+ +
+
+ ); + } + const roomList = ( - { /> ); - const containerClasses = classNames({ - mx_LeftPanel: true, - mx_LeftPanel_minimized: this.props.isMinimized, - }); - - const roomListClasses = classNames("mx_LeftPanel_actualRoomListContainer", "mx_AutoHideScrollbar"); - return (
{shouldShowComponent(UIComponent.FilterContainer) && this.renderSearchDialExplore()} {this.renderBreadcrumbs()} - {!this.props.isMinimized && } - + {!this.props.isMinimized && }