Merge branch 'element-hq:master' into master

This commit is contained in:
2025-06-22 08:37:03 +02:00
committed by GitHub
691 changed files with 22611 additions and 15850 deletions
+9
View File
@@ -30,6 +30,10 @@ module.exports = {
["window.innerHeight", "window.innerWidth", "window.visualViewport"], ["window.innerHeight", "window.innerWidth", "window.visualViewport"],
"Use UIStore to access window dimensions instead.", "Use UIStore to access window dimensions instead.",
), ),
...buildRestrictedPropertiesOptions(
["React.forwardRef", "*.forwardRef", "forwardRef"],
"Use ref props instead.",
),
...buildRestrictedPropertiesOptions( ...buildRestrictedPropertiesOptions(
["*.mxcUrlToHttp", "*.getHttpUriForMxc"], ["*.mxcUrlToHttp", "*.getHttpUriForMxc"],
"Use Media helper instead to centralise access for customisation.", "Use Media helper instead to centralise access for customisation.",
@@ -55,6 +59,11 @@ module.exports = {
"error", "error",
{ {
paths: [ paths: [
{
name: "react",
importNames: ["forwardRef"],
message: "Use ref props instead.",
},
{ {
name: "@testing-library/react", name: "@testing-library/react",
message: "Please use jest-matrix-react instead", message: "Please use jest-matrix-react instead",
+3 -3
View File
@@ -43,9 +43,9 @@ jobs:
run: run:
shell: bash shell: bash
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
- uses: actions/setup-node@v4 - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with: with:
# Disable cache on Windows as it is slower than not caching # Disable cache on Windows as it is slower than not caching
# https://github.com/actions/setup-node/issues/975 # https://github.com/actions/setup-node/issues/975
@@ -77,7 +77,7 @@ jobs:
yarn build yarn build
- name: Upload Artifact - name: Upload Artifact
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with: with:
name: webapp-${{ matrix.image }} name: webapp-${{ matrix.image }}
path: webapp path: webapp
+2 -2
View File
@@ -14,7 +14,7 @@ jobs:
R2_URL: ${{ vars.CF_R2_S3_API }} R2_URL: ${{ vars.CF_R2_S3_API }}
VERSION: ${{ github.ref_name }} VERSION: ${{ github.ref_name }}
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
- name: Download package - name: Download package
run: | run: |
@@ -62,7 +62,7 @@ jobs:
dpkg-gencontrol -v"$VERSION" -ldebian/tmp/DEBIAN/changelog dpkg-gencontrol -v"$VERSION" -ldebian/tmp/DEBIAN/changelog
dpkg-deb -Zxz --root-owner-group --build debian/tmp element-web.deb dpkg-deb -Zxz --root-owner-group --build debian/tmp element-web.deb
- uses: actions/upload-artifact@v4 - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with: with:
name: element-web.deb name: element-web.deb
path: element-web.deb path: element-web.deb
+3 -3
View File
@@ -26,9 +26,9 @@ jobs:
R2_URL: ${{ vars.CF_R2_S3_API }} R2_URL: ${{ vars.CF_R2_S3_API }}
R2_PUBLIC_URL: "https://element-web-develop.element.io" R2_PUBLIC_URL: "https://element-web-develop.element.io"
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
- uses: actions/setup-node@v4 - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with: with:
cache: "yarn" cache: "yarn"
node-version: "lts/*" node-version: "lts/*"
@@ -53,7 +53,7 @@ jobs:
- run: mv dist/element-*.tar.gz dist/develop.tar.gz - run: mv dist/element-*.tar.gz dist/develop.tar.gz
- uses: actions/upload-artifact@v4 - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with: with:
name: webapp name: webapp
path: dist/develop.tar.gz path: dist/develop.tar.gz
+1 -1
View File
@@ -34,7 +34,7 @@ jobs:
env: env:
SITE: ${{ inputs.site || 'staging.element.io' }} SITE: ${{ inputs.site || 'staging.element.io' }}
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
- name: Load GPG key - name: Load GPG key
run: | run: |
+3 -3
View File
@@ -20,12 +20,12 @@ jobs:
env: env:
TEST_TAG: vectorim/element-web:test TEST_TAG: vectorim/element-web:test
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
with: with:
fetch-depth: 0 # needed for docker-package to be able to calculate the version fetch-depth: 0 # needed for docker-package to be able to calculate the version
- name: Install Cosign - name: Install Cosign
uses: sigstore/cosign-installer@d7d6bc7722e3daa8354c50bcb52f4837da5e9b6a # v3 uses: sigstore/cosign-installer@3454372f43399081ed03b604cb2d021dabca52bb # v3
if: github.event_name != 'pull_request' if: github.event_name != 'pull_request'
- name: Set up QEMU - name: Set up QEMU
@@ -132,7 +132,7 @@ jobs:
cosign sign --yes ${images} cosign sign --yes ${images}
- name: Update repo description - name: Update repo description
uses: peter-evans/dockerhub-description@e98e4d1628a5f3be2be7c231e50981aee98723ae # v4 uses: peter-evans/dockerhub-description@432a30c9e07499fd01da9f8a49f0faf9e0ca5b77 # v4
if: github.event_name != 'pull_request' if: github.event_name != 'pull_request'
continue-on-error: true continue-on-error: true
with: with:
+7 -7
View File
@@ -17,23 +17,23 @@ jobs:
runs-on: ubuntu-24.04 runs-on: ubuntu-24.04
steps: steps:
- name: Fetch element-desktop - name: Fetch element-desktop
uses: actions/checkout@v4 uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
with: with:
repository: element-hq/element-desktop repository: element-hq/element-desktop
path: element-desktop path: element-desktop
- name: Fetch element-web - name: Fetch element-web
uses: actions/checkout@v4 uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
with: with:
path: element-web path: element-web
- name: Fetch matrix-js-sdk - name: Fetch matrix-js-sdk
uses: actions/checkout@v4 uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
with: with:
repository: matrix-org/matrix-js-sdk repository: matrix-org/matrix-js-sdk
path: matrix-js-sdk path: matrix-js-sdk
- uses: actions/setup-node@v4 - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with: with:
cache: "yarn" cache: "yarn"
cache-dependency-path: element-web/yarn.lock cache-dependency-path: element-web/yarn.lock
@@ -47,7 +47,7 @@ jobs:
echo "- [Automations](automations.md)" >> docs/SUMMARY.md echo "- [Automations](automations.md)" >> docs/SUMMARY.md
- name: Setup mdBook - name: Setup mdBook
uses: peaceiris/actions-mdbook@v2 uses: peaceiris/actions-mdbook@ee69d230fe19748b7abf22df32acaa93833fad08 # v2
with: with:
mdbook-version: "0.4.10" mdbook-version: "0.4.10"
@@ -88,7 +88,7 @@ jobs:
run: mdbook build run: mdbook build
- name: Upload artifact - name: Upload artifact
uses: actions/upload-pages-artifact@v3 uses: actions/upload-pages-artifact@56afc609e74202658d3ffba0e8f6dda462b719fa # v3
with: with:
path: ./book path: ./book
@@ -104,4 +104,4 @@ jobs:
steps: steps:
- name: Deploy to GitHub Pages - name: Deploy to GitHub Pages
id: deployment id: deployment
uses: actions/deploy-pages@v4 uses: actions/deploy-pages@d6db90164ac5ed86f2b6aed7e0febac5b3c0c03e # v4
@@ -25,7 +25,7 @@ jobs:
actions: read actions: read
steps: steps:
- name: Download HTML report - name: Download HTML report
uses: actions/download-artifact@v4 uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
with: with:
github-token: ${{ secrets.GITHUB_TOKEN }} github-token: ${{ secrets.GITHUB_TOKEN }}
run-id: ${{ github.event.workflow_run.id }} run-id: ${{ github.event.workflow_run.id }}
@@ -33,7 +33,7 @@ jobs:
path: playwright-report path: playwright-report
- name: 📤 Deploy to Netlify - name: 📤 Deploy to Netlify
uses: matrix-org/netlify-pr-preview@v3 uses: matrix-org/netlify-pr-preview@9805cd123fc9a7e421e35340a05e1ebc5dee46b5 # v3
with: with:
path: playwright-report path: playwright-report
owner: ${{ github.event.workflow_run.head_repository.owner.login }} owner: ${{ github.event.workflow_run.head_repository.owner.login }}
+27 -18
View File
@@ -50,11 +50,11 @@ jobs:
runners-matrix: ${{ steps.runner-vars.outputs.matrix }} runners-matrix: ${{ steps.runner-vars.outputs.matrix }}
steps: steps:
- name: Checkout code - name: Checkout code
uses: actions/checkout@v4 uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
with: with:
repository: element-hq/element-web repository: element-hq/element-web
- uses: actions/setup-node@v4 - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with: with:
cache: "yarn" cache: "yarn"
node-version: "lts/*" node-version: "lts/*"
@@ -81,7 +81,7 @@ jobs:
yarn build yarn build
- name: Upload Artifact - name: Upload Artifact
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with: with:
name: webapp name: webapp
path: webapp path: webapp
@@ -89,7 +89,7 @@ jobs:
- name: Calculate runner variables - name: Calculate runner variables
id: runner-vars id: runner-vars
uses: actions/github-script@v7 uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7
with: with:
script: | script: |
const numRunners = parseInt(process.env.NUM_RUNNERS, 10); const numRunners = parseInt(process.env.NUM_RUNNERS, 10);
@@ -129,18 +129,18 @@ jobs:
- runAllTests: false - runAllTests: false
project: Pinecone project: Pinecone
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
with: with:
persist-credentials: false persist-credentials: false
repository: element-hq/element-web repository: element-hq/element-web
- name: 📥 Download artifact - name: 📥 Download artifact
uses: actions/download-artifact@v4 uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
with: with:
name: webapp name: webapp
path: webapp path: webapp
- uses: actions/setup-node@v4 - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with: with:
cache: "yarn" cache: "yarn"
cache-dependency-path: yarn.lock cache-dependency-path: yarn.lock
@@ -154,12 +154,11 @@ jobs:
run: echo "version=$(yarn list --pattern @playwright/test --depth=0 --json --non-interactive --no-progress | jq -r '.data.trees[].name')" >> $GITHUB_OUTPUT run: echo "version=$(yarn list --pattern @playwright/test --depth=0 --json --non-interactive --no-progress | jq -r '.data.trees[].name')" >> $GITHUB_OUTPUT
- name: Cache playwright binaries - name: Cache playwright binaries
uses: actions/cache@v4 uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4
id: playwright-cache id: playwright-cache
with: with:
path: | path: ~/.cache/ms-playwright
~/.cache/ms-playwright key: ${{ runner.os }}-${{ runner.arch }}-playwright-${{ steps.playwright.outputs.version }}
key: ${{ runner.os }}-playwright-${{ steps.playwright.outputs.version }}
- name: Install Playwright browsers - name: Install Playwright browsers
if: steps.playwright-cache.outputs.cache-hit != 'true' if: steps.playwright-cache.outputs.cache-hit != 'true'
@@ -180,25 +179,35 @@ jobs:
- name: Upload blob report to GitHub Actions Artifacts - name: Upload blob report to GitHub Actions Artifacts
if: always() if: always()
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with: with:
name: all-blob-reports-${{ matrix.project }}-${{ matrix.runner }} name: all-blob-reports-${{ matrix.project }}-${{ matrix.runner }}
path: blob-report path: blob-report
retention-days: 1 retention-days: 1
downstream-modules:
name: Downstream Playwright tests [element-modules]
needs: build
if: inputs.skip != true && github.event_name == 'merge_group'
uses: element-hq/element-modules/.github/workflows/reusable-playwright-tests.yml@main
with:
webapp-artifact: webapp
complete: complete:
name: end-to-end-tests name: end-to-end-tests
needs: playwright needs:
- playwright
- downstream-modules
if: always() if: always()
runs-on: ubuntu-24.04 runs-on: ubuntu-24.04
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
if: inputs.skip != true if: inputs.skip != true
with: with:
persist-credentials: false persist-credentials: false
repository: element-hq/element-web repository: element-hq/element-web
- uses: actions/setup-node@v4 - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
if: inputs.skip != true if: inputs.skip != true
with: with:
cache: "yarn" cache: "yarn"
@@ -210,7 +219,7 @@ jobs:
- name: Download blob reports from GitHub Actions Artifacts - name: Download blob reports from GitHub Actions Artifacts
if: inputs.skip != true if: inputs.skip != true
uses: actions/download-artifact@v4 uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
with: with:
pattern: all-blob-reports-* pattern: all-blob-reports-*
path: all-blob-reports path: all-blob-reports
@@ -226,11 +235,11 @@ jobs:
# Upload the HTML report even if one of our reporters fails, this can happen when stale screenshots are detected # Upload the HTML report even if one of our reporters fails, this can happen when stale screenshots are detected
- name: Upload HTML report - name: Upload HTML report
if: always() && inputs.skip != true if: always() && inputs.skip != true
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with: with:
name: html-report name: html-report
path: playwright-report path: playwright-report
retention-days: 14 retention-days: 14
- if: needs.playwright.result != 'skipped' && needs.playwright.result != 'success' - if: contains(needs.*.result, 'failure') || contains(needs.*.result, 'cancelled')
run: exit 1 run: exit 1
+2 -2
View File
@@ -10,7 +10,7 @@ jobs:
name: Tidy closed issues name: Tidy closed issues
runs-on: ubuntu-24.04 runs-on: ubuntu-24.04
steps: steps:
- uses: actions/github-script@v7 - uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7
id: main id: main
with: with:
# PAT needed as the GITHUB_TOKEN won't be able to see cross-references from other orgs (matrix-org) # PAT needed as the GITHUB_TOKEN won't be able to see cross-references from other orgs (matrix-org)
@@ -142,7 +142,7 @@ jobs:
}); });
} }
} }
- uses: actions/github-script@v7 - uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7
name: Close duplicate as Not Planned name: Close duplicate as Not Planned
if: steps.main.outputs.closeAsNotPlanned if: steps.main.outputs.closeAsNotPlanned
with: with:
+2 -2
View File
@@ -28,7 +28,7 @@ jobs:
Exercise caution. Use test accounts. Exercise caution. Use test accounts.
- name: 📥 Download artifact - name: 📥 Download artifact
uses: actions/download-artifact@v4 uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
with: with:
github-token: ${{ secrets.GITHUB_TOKEN }} github-token: ${{ secrets.GITHUB_TOKEN }}
run-id: ${{ github.event.workflow_run.id }} run-id: ${{ github.event.workflow_run.id }}
@@ -36,7 +36,7 @@ jobs:
path: webapp path: webapp
- name: 📤 Deploy to Netlify - name: 📤 Deploy to Netlify
uses: matrix-org/netlify-pr-preview@v3 uses: matrix-org/netlify-pr-preview@9805cd123fc9a7e421e35340a05e1ebc5dee46b5 # v3
with: with:
path: webapp path: webapp
owner: ${{ github.event.workflow_run.head_repository.owner.login }} owner: ${{ github.event.workflow_run.head_repository.owner.login }}
+1 -1
View File
@@ -16,7 +16,7 @@ jobs:
URL: "https://github.com/pulls?q=is%3Apr+is%3Aopen+repo%3Amatrix-org%2Fmatrix-js-sdk+repo%3Amatrix-org%2Fmatrix-react-sdk+repo%3Aelement-hq%2Felement-web+repo%3Aelement-hq%2Felement-desktop+review-requested%3A%40me+sort%3Aupdated-desc+" URL: "https://github.com/pulls?q=is%3Apr+is%3Aopen+repo%3Amatrix-org%2Fmatrix-js-sdk+repo%3Amatrix-org%2Fmatrix-react-sdk+repo%3Aelement-hq%2Felement-web+repo%3Aelement-hq%2Felement-desktop+review-requested%3A%40me+sort%3Aupdated-desc+"
RELEASE_BLOCKERS_URL: "https://github.com/pulls?q=is%3Aopen+repo%3Amatrix-org%2Fmatrix-js-sdk+repo%3Amatrix-org%2Fmatrix-react-sdk+repo%3Aelement-hq%2Felement-web+repo%3Aelement-hq%2Felement-desktop+sort%3Aupdated-desc+label%3AX-Release-Blocker+" RELEASE_BLOCKERS_URL: "https://github.com/pulls?q=is%3Aopen+repo%3Amatrix-org%2Fmatrix-js-sdk+repo%3Amatrix-org%2Fmatrix-react-sdk+repo%3Aelement-hq%2Felement-web+repo%3Aelement-hq%2Felement-desktop+sort%3Aupdated-desc+label%3AX-Release-Blocker+"
steps: steps:
- uses: actions/github-script@v7 - uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7
env: env:
HS_URL: ${{ secrets.BETABOT_HS_URL }} HS_URL: ${{ secrets.BETABOT_HS_URL }}
ROOM_ID: ${{ secrets.ROOM_ID }} ROOM_ID: ${{ secrets.ROOM_ID }}
@@ -10,7 +10,7 @@ jobs:
permissions: permissions:
pull-requests: write pull-requests: write
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
- name: Update synapse image - name: Update synapse image
run: | run: |
@@ -8,7 +8,7 @@ jobs:
name: Check PR base branch name: Check PR base branch
runs-on: ubuntu-24.04 runs-on: ubuntu-24.04
steps: steps:
- uses: actions/github-script@v7 - uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7
with: with:
script: | script: |
const baseBranch = context.payload.pull_request.base.ref; const baseBranch = context.payload.pull_request.base.ref;
+6 -6
View File
@@ -41,7 +41,7 @@ jobs:
REPOS: matrix-js-sdk element-web element-desktop REPOS: matrix-js-sdk element-web element-desktop
steps: steps:
- name: Checkout Element Desktop - name: Checkout Element Desktop
uses: actions/checkout@v4 uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
if: inputs.element-desktop if: inputs.element-desktop
with: with:
repository: element-hq/element-desktop repository: element-hq/element-desktop
@@ -51,7 +51,7 @@ jobs:
fetch-tags: true fetch-tags: true
token: ${{ secrets.ELEMENT_BOT_TOKEN }} token: ${{ secrets.ELEMENT_BOT_TOKEN }}
- name: Checkout Element Web - name: Checkout Element Web
uses: actions/checkout@v4 uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
if: inputs.element-web if: inputs.element-web
with: with:
repository: element-hq/element-web repository: element-hq/element-web
@@ -61,7 +61,7 @@ jobs:
fetch-tags: true fetch-tags: true
token: ${{ secrets.ELEMENT_BOT_TOKEN }} token: ${{ secrets.ELEMENT_BOT_TOKEN }}
- name: Checkout Matrix JS SDK - name: Checkout Matrix JS SDK
uses: actions/checkout@v4 uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
if: inputs.matrix-js-sdk if: inputs.matrix-js-sdk
with: with:
repository: matrix-org/matrix-js-sdk repository: matrix-org/matrix-js-sdk
@@ -100,7 +100,7 @@ jobs:
repo: matrix-org/matrix-js-sdk repo: matrix-org/matrix-js-sdk
repo-token: ${{ secrets.ELEMENT_BOT_TOKEN }} repo-token: ${{ secrets.ELEMENT_BOT_TOKEN }}
wait-interval: 10 wait-interval: 10
check-name: draft check-name: "draft / draft"
allowed-conclusions: success allowed-conclusions: success
- name: Wait for element-web draft - name: Wait for element-web draft
@@ -111,7 +111,7 @@ jobs:
repo: element-hq/element-web repo: element-hq/element-web
repo-token: ${{ secrets.ELEMENT_BOT_TOKEN }} repo-token: ${{ secrets.ELEMENT_BOT_TOKEN }}
wait-interval: 10 wait-interval: 10
check-name: draft check-name: "draft / draft"
allowed-conclusions: success allowed-conclusions: success
- name: Wait for element-desktop draft - name: Wait for element-desktop draft
@@ -122,5 +122,5 @@ jobs:
repo: element-hq/element-desktop repo: element-hq/element-desktop
repo-token: ${{ secrets.ELEMENT_BOT_TOKEN }} repo-token: ${{ secrets.ELEMENT_BOT_TOKEN }}
wait-interval: 10 wait-interval: 10
check-name: draft check-name: "draft / draft"
allowed-conclusions: success allowed-conclusions: success
+11 -11
View File
@@ -23,9 +23,9 @@ jobs:
name: "Typescript Syntax Check" name: "Typescript Syntax Check"
runs-on: ubuntu-24.04 runs-on: ubuntu-24.04
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
- uses: actions/setup-node@v4 - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with: with:
cache: "yarn" cache: "yarn"
node-version: "lts/*" node-version: "lts/*"
@@ -57,7 +57,7 @@ jobs:
name: "Rethemendex Check" name: "Rethemendex Check"
runs-on: ubuntu-24.04 runs-on: ubuntu-24.04
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
- run: ./res/css/rethemendex.sh - run: ./res/css/rethemendex.sh
@@ -67,9 +67,9 @@ jobs:
name: "ESLint" name: "ESLint"
runs-on: ubuntu-24.04 runs-on: ubuntu-24.04
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
- uses: actions/setup-node@v4 - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with: with:
cache: "yarn" cache: "yarn"
node-version: "lts/*" node-version: "lts/*"
@@ -85,9 +85,9 @@ jobs:
name: "Style Lint" name: "Style Lint"
runs-on: ubuntu-24.04 runs-on: ubuntu-24.04
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
- uses: actions/setup-node@v4 - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with: with:
cache: "yarn" cache: "yarn"
node-version: "lts/*" node-version: "lts/*"
@@ -103,9 +103,9 @@ jobs:
name: "Workflow Lint" name: "Workflow Lint"
runs-on: ubuntu-24.04 runs-on: ubuntu-24.04
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
- uses: actions/setup-node@v4 - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with: with:
cache: "yarn" cache: "yarn"
node-version: "lts/*" node-version: "lts/*"
@@ -121,9 +121,9 @@ jobs:
name: "Analyse Dead Code" name: "Analyse Dead Code"
runs-on: ubuntu-24.04 runs-on: ubuntu-24.04
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
- uses: actions/setup-node@v4 - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with: with:
cache: "yarn" cache: "yarn"
node-version: "lts/*" node-version: "lts/*"
+5 -5
View File
@@ -39,12 +39,12 @@ jobs:
runner: [1, 2] runner: [1, 2]
steps: steps:
- name: Checkout code - name: Checkout code
uses: actions/checkout@v4 uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
with: with:
repository: ${{ inputs.matrix-js-sdk-sha && 'element-hq/element-web' || github.repository }} repository: ${{ inputs.matrix-js-sdk-sha && 'element-hq/element-web' || github.repository }}
- name: Yarn cache - name: Yarn cache
uses: actions/setup-node@v4 uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with: with:
node-version: "lts/*" node-version: "lts/*"
cache: "yarn" cache: "yarn"
@@ -55,7 +55,7 @@ jobs:
JS_SDK_GITHUB_BASE_REF: ${{ inputs.matrix-js-sdk-sha }} JS_SDK_GITHUB_BASE_REF: ${{ inputs.matrix-js-sdk-sha }}
- name: Jest Cache - name: Jest Cache
uses: actions/cache@v4 uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4
with: with:
path: /tmp/jest_cache path: /tmp/jest_cache
key: ${{ hashFiles('**/yarn.lock') }} key: ${{ hashFiles('**/yarn.lock') }}
@@ -84,7 +84,7 @@ jobs:
- name: Upload Artifact - name: Upload Artifact
if: env.ENABLE_COVERAGE == 'true' if: env.ENABLE_COVERAGE == 'true'
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with: with:
name: coverage-${{ matrix.runner }} name: coverage-${{ matrix.runner }}
path: | path: |
@@ -104,7 +104,7 @@ jobs:
- name: Skip SonarCloud in merge queue - name: Skip SonarCloud in merge queue
if: github.event_name == 'merge_group' || inputs.disable_coverage == 'true' if: github.event_name == 'merge_group' || inputs.disable_coverage == 'true'
uses: guibranco/github-status-action-v2@fe98467f9071758c7fc214af9dbac7f301bd23d4 uses: guibranco/github-status-action-v2@5f2b01ce1394109f70954ae6b69ef41cf7928e63
with: with:
authToken: ${{ secrets.GITHUB_TOKEN }} authToken: ${{ secrets.GITHUB_TOKEN }}
state: success state: success
+2 -1
View File
@@ -11,7 +11,8 @@ jobs:
runs-on: ubuntu-24.04 runs-on: ubuntu-24.04
if: | if: |
contains(github.event.issue.assignees.*.login, 't3chguy') || contains(github.event.issue.assignees.*.login, 't3chguy') ||
contains(github.event.issue.assignees.*.login, 'andybalaam') || contains(github.event.issue.assignees.*.login, 'florianduros') ||
contains(github.event.issue.assignees.*.login, 'dbkr') ||
contains(github.event.issue.assignees.*.login, 'MidhunSureshR') contains(github.event.issue.assignees.*.login, 'MidhunSureshR')
steps: steps:
- uses: actions/add-to-project@main - uses: actions/add-to-project@main
+8 -8
View File
@@ -27,7 +27,7 @@ jobs:
contains(github.event.issue.labels.*.name, 'A-Rich-Text-Editor') || contains(github.event.issue.labels.*.name, 'A-Rich-Text-Editor') ||
contains(github.event.issue.labels.*.name, 'A-Element-Call') contains(github.event.issue.labels.*.name, 'A-Element-Call')
steps: steps:
- uses: actions/github-script@v7 - uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7
with: with:
script: | script: |
github.rest.issues.addLabels({ github.rest.issues.addLabels({
@@ -44,7 +44,7 @@ jobs:
contains(github.event.issue.labels.*.name, 'good first issue') || contains(github.event.issue.labels.*.name, 'good first issue') ||
contains(github.event.issue.labels.*.name, 'Hacktoberfest') contains(github.event.issue.labels.*.name, 'Hacktoberfest')
steps: steps:
- uses: actions/github-script@v7 - uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7
with: with:
script: | script: |
github.rest.issues.addLabels({ github.rest.issues.addLabels({
@@ -61,7 +61,7 @@ jobs:
contains(github.event.issue.labels.*.name, 'X-Needs-Info') contains(github.event.issue.labels.*.name, 'X-Needs-Info')
steps: steps:
- id: add_to_project - id: add_to_project
uses: actions/add-to-project@v1.0.2 uses: actions/add-to-project@244f685bbc3b7adfa8466e08b698b5577571133e # v1.0.2
with: with:
project-url: ${{ env.PROJECT_URL }} project-url: ${{ env.PROJECT_URL }}
github-token: ${{ secrets.ELEMENT_BOT_TOKEN }} github-token: ${{ secrets.ELEMENT_BOT_TOKEN }}
@@ -84,7 +84,7 @@ jobs:
contains(github.event.issue.labels.*.name, 'Z-Flaky-Test') contains(github.event.issue.labels.*.name, 'Z-Flaky-Test')
steps: steps:
- id: add_to_project - id: add_to_project
uses: actions/add-to-project@v1.0.2 uses: actions/add-to-project@244f685bbc3b7adfa8466e08b698b5577571133e # v1.0.2
with: with:
project-url: ${{ env.PROJECT_URL }} project-url: ${{ env.PROJECT_URL }}
github-token: ${{ secrets.ELEMENT_BOT_TOKEN }} github-token: ${{ secrets.ELEMENT_BOT_TOKEN }}
@@ -150,15 +150,15 @@ jobs:
project-url: https://github.com/orgs/element-hq/projects/41 project-url: https://github.com/orgs/element-hq/projects/41
github-token: ${{ secrets.ELEMENT_BOT_TOKEN }} github-token: ${{ secrets.ELEMENT_BOT_TOKEN }}
verticals_feature: crypto:
name: Add labelled issues to Verticals Feature project name: Add labelled issues to Crypto project
runs-on: ubuntu-24.04 runs-on: ubuntu-24.04
if: > if: >
contains(github.event.issue.labels.*.name, 'Team: Verticals Feature') contains(github.event.issue.labels.*.name, 'Team: Crypto')
steps: steps:
- uses: actions/add-to-project@main - uses: actions/add-to-project@main
with: with:
project-url: https://github.com/orgs/element-hq/projects/57 project-url: https://github.com/orgs/element-hq/projects/76
github-token: ${{ secrets.ELEMENT_BOT_TOKEN }} github-token: ${{ secrets.ELEMENT_BOT_TOKEN }}
tech_debt: tech_debt:
+1 -1
View File
@@ -12,7 +12,7 @@ jobs:
issues: write issues: write
pull-requests: write pull-requests: write
steps: steps:
- uses: actions/stale@v9 - uses: actions/stale@5bef64f19d7facfb25b37b414482c7164d639639 # v9
with: with:
operations-per-run: 100 operations-per-run: 100
# Flaky test issue closing # Flaky test issue closing
+1 -1
View File
@@ -62,7 +62,7 @@ jobs:
contains(github.event.issue.labels.*.name, 'A-Element-Call')) && contains(github.event.issue.labels.*.name, 'A-Element-Call')) &&
contains(github.event.issue.labels.*.name, 'Z-Labs') contains(github.event.issue.labels.*.name, 'Z-Labs')
steps: steps:
- uses: actions/github-script@v7 - uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7
with: with:
script: | script: |
github.rest.issues.removeLabel({ github.rest.issues.removeLabel({
+2 -2
View File
@@ -9,9 +9,9 @@ jobs:
update: update:
runs-on: ubuntu-24.04 runs-on: ubuntu-24.04
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
- uses: actions/setup-node@v4 - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with: with:
cache: "yarn" cache: "yarn"
node-version: "lts/*" node-version: "lts/*"
+1 -1
View File
@@ -22,7 +22,7 @@ jobs:
runs-on: ubuntu-24.04 runs-on: ubuntu-24.04
environment: Matrix environment: Matrix
steps: steps:
- uses: actions/github-script@v7 - uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7
env: env:
HS_URL: ${{ secrets.BETABOT_HS_URL }} HS_URL: ${{ secrets.BETABOT_HS_URL }}
LOBBY_ROOM_ID: ${{ secrets.ROOM_ID }} LOBBY_ROOM_ID: ${{ secrets.ROOM_ID }}
-1
View File
@@ -25,7 +25,6 @@ electron/pub
.env .env
/coverage /coverage
# Auto-generated file # Auto-generated file
/src/modules.ts
/src/modules.js /src/modules.js
/build_config.yaml /build_config.yaml
/book /book
+145
View File
@@ -1,3 +1,148 @@
Changes in [1.11.104](https://github.com/element-hq/element-web/releases/tag/v1.11.104) (2025-06-17)
====================================================================================================
## ✨ Features
* Update the mobile\_guide page to the new design. ([#30006](https://github.com/element-hq/element-web/pull/30006)). Contributed by @pixlwave.
* Provide a devtool for manually verifying other devices ([#30094](https://github.com/element-hq/element-web/pull/30094)). Contributed by @andybalaam.
* Implement MSC4155: Invite filtering ([#29603](https://github.com/element-hq/element-web/pull/29603)). Contributed by @Half-Shot.
* Add low priority avatar decoration to room tile ([#30065](https://github.com/element-hq/element-web/pull/30065)). Contributed by @MidhunSureshR.
* Add ability to prevent window content being captured by other apps (Desktop) ([#30098](https://github.com/element-hq/element-web/pull/30098)). Contributed by @t3chguy.
* New room list: move message preview in user settings ([#30023](https://github.com/element-hq/element-web/pull/30023)). Contributed by @florianduros.
* New room list: change room options icon ([#30029](https://github.com/element-hq/element-web/pull/30029)). Contributed by @florianduros.
* RoomListStore: Sort low priority rooms to the bottom of the list ([#30070](https://github.com/element-hq/element-web/pull/30070)). Contributed by @MidhunSureshR.
* Add low priority filter pill to the room list UI ([#30060](https://github.com/element-hq/element-web/pull/30060)). Contributed by @MidhunSureshR.
* New room list: remove color gradient in space panel ([#29721](https://github.com/element-hq/element-web/pull/29721)). Contributed by @florianduros.
* /share?msg=foo endpoint using forward message dialog ([#29874](https://github.com/element-hq/element-web/pull/29874)). Contributed by @ara4n.
## 🐛 Bug Fixes
* Do not send empty auth when setting up cross-signing keys ([#29914](https://github.com/element-hq/element-web/pull/29914)). Contributed by @gnieto.
* Settings: flip local video feed by default ([#29501](https://github.com/element-hq/element-web/pull/29501)). Contributed by @jbtrystram.
* AccessSecretStorageDialog: various fixes ([#30093](https://github.com/element-hq/element-web/pull/30093)). Contributed by @richvdh.
* AccessSecretStorageDialog: fix inability to enter recovery key ([#30090](https://github.com/element-hq/element-web/pull/30090)). Contributed by @richvdh.
* Fix failure to upload thumbnail causing image to send as file ([#30086](https://github.com/element-hq/element-web/pull/30086)). Contributed by @t3chguy.
* Low priority menu item should be a toggle ([#30071](https://github.com/element-hq/element-web/pull/30071)). Contributed by @MidhunSureshR.
* Add sanity checks to prevent users from ignoring themselves ([#30079](https://github.com/element-hq/element-web/pull/30079)). Contributed by @MidhunSureshR.
* Fix issue with duplicate images ([#30073](https://github.com/element-hq/element-web/pull/30073)). Contributed by @fatlewis.
* Handle errors returned from Seshat ([#30083](https://github.com/element-hq/element-web/pull/30083)). Contributed by @richvdh.
Changes in [1.11.103](https://github.com/element-hq/element-web/releases/tag/v1.11.103) (2025-06-10)
====================================================================================================
## 🐛 Bug Fixes
+ Check the sender of an event matches owner of session, preventing sender spoofing by homeserver owners.
[13c1d20](https://github.com/matrix-org/matrix-rust-sdk/commit/13c1d2048286bbabf5e7bc6b015aafee98f04d55) (High, [GHSA-x958-rvg6-956w](https://github.com/matrix-org/matrix-rust-sdk/security/advisories/GHSA-x958-rvg6-956w)).
Changes in [1.11.102](https://github.com/element-hq/element-web/releases/tag/v1.11.102) (2025-06-03)
====================================================================================================
## ✨ Features
* EW: Modernize the recovery key input modal ([#29819](https://github.com/element-hq/element-web/pull/29819)). Contributed by @uhoreg.
* New room list: move secondary filters into primary filters ([#29972](https://github.com/element-hq/element-web/pull/29972)). Contributed by @florianduros.
* Prompt the user when key storage is unexpectedly off ([#29912](https://github.com/element-hq/element-web/pull/29912)). Contributed by @andybalaam.
* New room list: move sort menu in room list header ([#29983](https://github.com/element-hq/element-web/pull/29983)). Contributed by @florianduros.
* New room list: rework spacing of room list item ([#29965](https://github.com/element-hq/element-web/pull/29965)). Contributed by @florianduros.
* RLS: Remove forgotten room from skiplist ([#29933](https://github.com/element-hq/element-web/pull/29933)). Contributed by @MidhunSureshR.
* Add room list sorting ([#29951](https://github.com/element-hq/element-web/pull/29951)). Contributed by @dbkr.
* Don't use the minimised width(68px) on the new room list ([#29778](https://github.com/element-hq/element-web/pull/29778)). Contributed by @langleyd.
## 🐛 Bug Fixes
* [Backport staging] Close call options popup menu when option has been selected ([#30054](https://github.com/element-hq/element-web/pull/30054)). Contributed by @RiotRobot.
* RoomListStoreV3: Only add new rooms that pass `VisibilityProvider` check ([#29974](https://github.com/element-hq/element-web/pull/29974)). Contributed by @MidhunSureshR.
* Re-order primary filters ([#29957](https://github.com/element-hq/element-web/pull/29957)). Contributed by @dbkr.
* Fix leaky CSS adding `!` to all H1 elements ([#29964](https://github.com/element-hq/element-web/pull/29964)). Contributed by @t3chguy.
* Fix extensions panel style ([#29273](https://github.com/element-hq/element-web/pull/29273)). Contributed by @langleyd.
* Fix state events being hidden from widgets in read\_events actions ([#29954](https://github.com/element-hq/element-web/pull/29954)). Contributed by @robintown.
* Remove old filter test ([#29963](https://github.com/element-hq/element-web/pull/29963)). Contributed by @dbkr.
Changes in [1.11.101](https://github.com/element-hq/element-web/releases/tag/v1.11.101) (2025-05-20)
====================================================================================================
## ✨ Features
* New room list: add keyboard navigation support ([#29805](https://github.com/element-hq/element-web/pull/29805)). Contributed by @florianduros.
* Use the JoinRuleSettings component for the guest link access prompt. ([#28614](https://github.com/element-hq/element-web/pull/28614)). Contributed by @toger5.
* Add loading state to the new room list view ([#29725](https://github.com/element-hq/element-web/pull/29725)). Contributed by @langleyd.
* Make OIDC identity reset consistent with EX ([#29854](https://github.com/element-hq/element-web/pull/29854)). Contributed by @andybalaam.
* Support error code for email / phone adding unsupported (MSC4178) ([#29855](https://github.com/element-hq/element-web/pull/29855)). Contributed by @dbkr.
* Update identity reset UI (Make consistent with EX) ([#29701](https://github.com/element-hq/element-web/pull/29701)). Contributed by @andybalaam.
* Add secondary filters to the new room list ([#29818](https://github.com/element-hq/element-web/pull/29818)). Contributed by @dbkr.
* Fix battery drain from Web Audio ([#29203](https://github.com/element-hq/element-web/pull/29203)). Contributed by @mbachry.
## 🐛 Bug Fixes
* Fix go home shortcut on macos and change toggle action events shortcut ([#29929](https://github.com/element-hq/element-web/pull/29929)). Contributed by @florianduros.
* New room list: fix outdated message preview when space or filter change ([#29925](https://github.com/element-hq/element-web/pull/29925)). Contributed by @florianduros.
* Stop migrating to MSC4278 if the config exists. ([#29924](https://github.com/element-hq/element-web/pull/29924)). Contributed by @Half-Shot.
* Ensure consistent download file name on download from ImageView ([#29913](https://github.com/element-hq/element-web/pull/29913)). Contributed by @t3chguy.
* Add error toast when service worker registration fails ([#29895](https://github.com/element-hq/element-web/pull/29895)). Contributed by @t3chguy.
* New Room List: Prevent old tombstoned rooms from appearing in the list ([#29881](https://github.com/element-hq/element-web/pull/29881)). Contributed by @MidhunSureshR.
* Remove lag in search field ([#29885](https://github.com/element-hq/element-web/pull/29885)). Contributed by @florianduros.
* Respect UIFeature.Voip ([#29873](https://github.com/element-hq/element-web/pull/29873)). Contributed by @langleyd.
* Allow jumping to message search from spotlight ([#29850](https://github.com/element-hq/element-web/pull/29850)). Contributed by @t3chguy.
Changes in [1.11.100](https://github.com/element-hq/element-web/releases/tag/v1.11.100) (2025-05-06)
====================================================================================================
## ✨ Features
* Move rich topics out of labs / stabilise MSC3765 ([#29817](https://github.com/element-hq/element-web/pull/29817)). Contributed by @Johennes.
* Spell out that Element Web does \*not\* work on mobile. ([#29211](https://github.com/element-hq/element-web/pull/29211)). Contributed by @ara4n.
* Add message preview support to the new room list ([#29784](https://github.com/element-hq/element-web/pull/29784)). Contributed by @dbkr.
* Global configuration flag for media previews ([#29582](https://github.com/element-hq/element-web/pull/29582)). Contributed by @Half-Shot.
* New room list: add partial keyboard shortcuts support ([#29783](https://github.com/element-hq/element-web/pull/29783)). Contributed by @florianduros.
* MVVM RoomSummaryCard Topic ([#29710](https://github.com/element-hq/element-web/pull/29710)). Contributed by @MarcWadai.
* Warn on self change from settings > roles ([#28926](https://github.com/element-hq/element-web/pull/28926)). Contributed by @MarcWadai.
* New room list: new visual for invitation ([#29773](https://github.com/element-hq/element-web/pull/29773)). Contributed by @florianduros.
## 🐛 Bug Fixes
* Fix incorrect display of the user info display name ([#29826](https://github.com/element-hq/element-web/pull/29826)). Contributed by @langleyd.
* RoomListStore: Remove invite rooms on decline ([#29804](https://github.com/element-hq/element-web/pull/29804)). Contributed by @MidhunSureshR.
* Fix the buttons not being displayed with long preview text ([#29811](https://github.com/element-hq/element-web/pull/29811)). Contributed by @dbkr.
* New room list: fix missing/incorrect notification decoration ([#29796](https://github.com/element-hq/element-web/pull/29796)). Contributed by @florianduros.
* New Room List: Prevent potential scroll jump/flicker when switching spaces ([#29781](https://github.com/element-hq/element-web/pull/29781)). Contributed by @MidhunSureshR.
* New room list: fix incorrect decoration ([#29770](https://github.com/element-hq/element-web/pull/29770)). Contributed by @florianduros.
Changes in [1.11.99](https://github.com/element-hq/element-web/releases/tag/v1.11.99) (2025-04-23)
==================================================================================================
No changes, just bumping the version to accommodate a new Element Desktop release
Changes in [1.11.98](https://github.com/element-hq/element-web/releases/tag/v1.11.98) (2025-04-22)
==================================================================================================
## ✨ Features
* print better errors in the search view instead of a blocking modal ([#29724](https://github.com/element-hq/element-web/pull/29724)). Contributed by @Jujure.
* New room list: video room and video call decoration ([#29693](https://github.com/element-hq/element-web/pull/29693)). Contributed by @florianduros.
* Remove Secure Backup, Cross-signing and Cryptography sections in `Security & Privacy` user settings ([#29088](https://github.com/element-hq/element-web/pull/29088)). Contributed by @florianduros.
* Allow reporting a room when rejecting an invite. ([#29570](https://github.com/element-hq/element-web/pull/29570)). Contributed by @Half-Shot.
* RoomListViewModel: Reset primary and secondary filters on space change ([#29672](https://github.com/element-hq/element-web/pull/29672)). Contributed by @MidhunSureshR.
* RoomListStore: Support specific sorting requirements for muted rooms ([#29665](https://github.com/element-hq/element-web/pull/29665)). Contributed by @MidhunSureshR.
* New room list: add notification options menu ([#29639](https://github.com/element-hq/element-web/pull/29639)). Contributed by @florianduros.
* Room List: Scroll to top of the list when active room is not in the list ([#29650](https://github.com/element-hq/element-web/pull/29650)). Contributed by @MidhunSureshR.
## 🐛 Bug Fixes
* Fix unwanted form submit behaviour in memberlist ([#29747](https://github.com/element-hq/element-web/pull/29747)). Contributed by @MidhunSureshR.
* New room list: fix public room icon visibility when filter change ([#29737](https://github.com/element-hq/element-web/pull/29737)). Contributed by @florianduros.
* Fix custom theme support for short hex \& rgba hex strings ([#29726](https://github.com/element-hq/element-web/pull/29726)). Contributed by @t3chguy.
* New room list: minor visual fixes ([#29723](https://github.com/element-hq/element-web/pull/29723)). Contributed by @florianduros.
* Fix getOidcCallbackUrl for Element Desktop ([#29711](https://github.com/element-hq/element-web/pull/29711)). Contributed by @t3chguy.
* Fix some webp images improperly marked as animated ([#29713](https://github.com/element-hq/element-web/pull/29713)). Contributed by @Petersmit27.
* Revert deletion of hydrateSession ([#29703](https://github.com/element-hq/element-web/pull/29703)). Contributed by @Jujure.
* Fix converttoroom \& converttodm not working ([#29705](https://github.com/element-hq/element-web/pull/29705)). Contributed by @t3chguy.
* Ensure forceCloseAllModals also closes priority/static modals ([#29706](https://github.com/element-hq/element-web/pull/29706)). Contributed by @t3chguy.
* Continue button is disabled when uploading a recovery key file ([#29695](https://github.com/element-hq/element-web/pull/29695)). Contributed by @Giwayume.
* Catch errors after syncing recovery ([#29691](https://github.com/element-hq/element-web/pull/29691)). Contributed by @andybalaam.
* New room list: fix multiple visual issues ([#29673](https://github.com/element-hq/element-web/pull/29673)). Contributed by @florianduros.
* New Room List: Fix mentions filter matching rooms with any highlight ([#29668](https://github.com/element-hq/element-web/pull/29668)). Contributed by @MidhunSureshR.
* Fix truncated emoji label during emoji SAS ([#29643](https://github.com/element-hq/element-web/pull/29643)). Contributed by @florianduros.
* Remove duplicate jitsi link ([#29642](https://github.com/element-hq/element-web/pull/29642)). Contributed by @dbkr.
Changes in [1.11.97](https://github.com/element-hq/element-web/releases/tag/v1.11.97) (2025-04-08) Changes in [1.11.97](https://github.com/element-hq/element-web/releases/tag/v1.11.97) (2025-04-08)
================================================================================================== ==================================================================================================
## ✨ Features ## ✨ Features
+3 -3
View File
@@ -1,7 +1,7 @@
# syntax=docker.io/docker/dockerfile:1.14-labs # syntax=docker.io/docker/dockerfile:1.16-labs@sha256:bb5e2b225985193779991f3256d1901a0b3e6a0b284c7bffa0972064f4a6d458
# Builder # Builder
FROM --platform=$BUILDPLATFORM node:22-bullseye AS builder FROM --platform=$BUILDPLATFORM node:22-bullseye@sha256:f16d8e8af67bb6361231e932b8b3e7afa040cbfed181719a450b02c3821b26c1 AS builder
# Support custom branch of the js-sdk. This also helps us build images of element-web develop. # Support custom branch of the js-sdk. This also helps us build images of element-web develop.
ARG USE_CUSTOM_SDKS=false ARG USE_CUSTOM_SDKS=false
@@ -19,7 +19,7 @@ RUN /src/scripts/docker-package.sh
RUN cp /src/config.sample.json /src/webapp/config.json RUN cp /src/config.sample.json /src/webapp/config.json
# App # App
FROM nginxinc/nginx-unprivileged:alpine-slim FROM nginxinc/nginx-unprivileged:alpine-slim@sha256:2acffd86b1bdefb8fa6b48b6e9aadf75430e8ab9c43c54c515ea7df77897f987
# Need root user to install packages & manipulate the usr directory # Need root user to install packages & manipulate the usr directory
USER root USER root
+1 -1
View File
@@ -126,7 +126,7 @@ guide](https://classic.yarnpkg.com/en/docs/install) if you do not have it alread
1. Install the prerequisites: `yarn install`. 1. Install the prerequisites: `yarn install`.
- If you're using the `develop` branch, then it is recommended to set up a - If you're using the `develop` branch, then it is recommended to set up a
proper development environment (see [Setting up a dev proper development environment (see [Setting up a dev
environment](#setting-up-a-dev-environment) below). Alternatively, you environment](./developer_guide.md#setting-up-a-dev-environment) below). Alternatively, you
can use <https://develop.element.io> - the continuous integration release of can use <https://develop.element.io> - the continuous integration release of
the develop branch. the develop branch.
1. Configure the app by copying `config.sample.json` to `config.json` and 1. Configure the app by copying `config.sample.json` to `config.json` and
-13
View File
@@ -1,13 +0,0 @@
{
"name": "Element",
"description": "A glossy Matrix collaboration client for the web.",
"repository": {
"url": "https://github.com/element-hq/element-web",
"license": "AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial"
},
"bugs": {
"list": "https://github.com/element-hq/element-web/issues",
"report": "https://github.com/element-hq/element-web/issues/new/choose"
},
"keywords": ["chat", "riot", "matrix"]
}
-1
View File
@@ -46,7 +46,6 @@
- [Skinning](skinning.md) - [Skinning](skinning.md)
- [Cider editor](ciderEditor.md) - [Cider editor](ciderEditor.md)
- [Iconography](icons.md) - [Iconography](icons.md)
- [Jitsi](jitsi.md)
- [Local echo](local-echo-dev.md) - [Local echo](local-echo-dev.md)
- [Media](media-handling.md) - [Media](media-handling.md)
- [Room List Store](room-list-store.md) - [Room List Store](room-list-store.md)
-4
View File
@@ -101,10 +101,6 @@ Under the hood this stops Element Web from adding the `perParticipantE2EE` flag
This is useful while we experiment with encryption and to make calling compatible with platforms that don't use encryption yet. This is useful while we experiment with encryption and to make calling compatible with platforms that don't use encryption yet.
## Rich text in room topics (`feature_html_topic`) [In Development]
Enables rendering of MD / HTML in room topics.
## Enable the notifications panel in the room header (`feature_notifications`) ## Enable the notifications panel in the room header (`feature_notifications`)
Unreliable in encrypted rooms. Unreliable in encrypted rooms.
+34 -32
View File
@@ -1,6 +1,6 @@
{ {
"name": "element-web", "name": "element-web",
"version": "1.11.97", "version": "1.11.104",
"description": "Element: the future of secure communication", "description": "Element: the future of secure communication",
"author": "New Vector Ltd.", "author": "New Vector Ltd.",
"repository": { "repository": {
@@ -22,8 +22,7 @@
"LICENSE", "LICENSE",
"README.md", "README.md",
"AUTHORS.rst", "AUTHORS.rst",
"package.json", "package.json"
"contribute.json"
], ],
"style": "bundle.css", "style": "bundle.css",
"matrix_i18n_extra_translation_funcs": [ "matrix_i18n_extra_translation_funcs": [
@@ -69,19 +68,20 @@
"postinstall": "patch-package" "postinstall": "patch-package"
}, },
"resolutions": { "resolutions": {
"@playwright/test": "1.51.1", "**/pretty-format/react-is": "19.1.0",
"@types/react": "18.3.18", "@playwright/test": "1.52.0",
"@types/react-dom": "18.3.5", "@types/react": "19.1.6",
"oidc-client-ts": "3.2.0", "@types/react-dom": "19.1.5",
"oidc-client-ts": "3.2.1",
"jwt-decode": "4.0.0", "jwt-decode": "4.0.0",
"caniuse-lite": "1.0.30001704", "caniuse-lite": "1.0.30001720",
"testcontainers": "10.21.0", "testcontainers": "^11.0.0",
"wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0", "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0",
"wrap-ansi": "npm:wrap-ansi@^7.0.0" "wrap-ansi": "npm:wrap-ansi@^7.0.0"
}, },
"dependencies": { "dependencies": {
"@babel/runtime": "^7.12.5", "@babel/runtime": "^7.12.5",
"@element-hq/element-web-module-api": "^0.1.1", "@element-hq/element-web-module-api": "1.0.0",
"@fontsource/inconsolata": "^5", "@fontsource/inconsolata": "^5",
"@fontsource/inter": "^5", "@fontsource/inter": "^5",
"@formatjs/intl-segmenter": "^11.5.7", "@formatjs/intl-segmenter": "^11.5.7",
@@ -93,8 +93,8 @@
"@types/png-chunks-extract": "^1.0.2", "@types/png-chunks-extract": "^1.0.2",
"@types/react-virtualized": "^9.21.30", "@types/react-virtualized": "^9.21.30",
"@vector-im/compound-design-tokens": "^4.0.0", "@vector-im/compound-design-tokens": "^4.0.0",
"@vector-im/compound-web": "^7.9.0", "@vector-im/compound-web": "^7.11.0",
"@vector-im/matrix-wysiwyg": "2.38.2", "@vector-im/matrix-wysiwyg": "2.38.3",
"@zxcvbn-ts/core": "^3.0.4", "@zxcvbn-ts/core": "^3.0.4",
"@zxcvbn-ts/language-common": "^3.0.4", "@zxcvbn-ts/language-common": "^3.0.4",
"@zxcvbn-ts/language-en": "^3.0.2", "@zxcvbn-ts/language-en": "^3.0.2",
@@ -123,14 +123,14 @@
"jsrsasign": "^11.0.0", "jsrsasign": "^11.0.0",
"jszip": "^3.7.0", "jszip": "^3.7.0",
"katex": "^0.16.0", "katex": "^0.16.0",
"linkify-react": "4.2.0", "linkify-react": "4.3.1",
"linkify-string": "4.2.0", "linkify-string": "4.3.1",
"linkifyjs": "4.2.0", "linkifyjs": "4.3.1",
"lodash": "^4.17.21", "lodash": "^4.17.21",
"maplibre-gl": "^5.0.0", "maplibre-gl": "^5.0.0",
"matrix-encrypt-attachment": "^1.0.3", "matrix-encrypt-attachment": "^1.0.3",
"matrix-events-sdk": "0.0.1", "matrix-events-sdk": "0.0.1",
"matrix-js-sdk": "37.3.0", "matrix-js-sdk": "37.9.0",
"matrix-widget-api": "^1.10.0", "matrix-widget-api": "^1.10.0",
"memoize-one": "^6.0.0", "memoize-one": "^6.0.0",
"mime": "^4.0.4", "mime": "^4.0.4",
@@ -138,22 +138,22 @@
"opus-recorder": "^8.0.3", "opus-recorder": "^8.0.3",
"pako": "^2.0.3", "pako": "^2.0.3",
"png-chunks-extract": "^1.0.0", "png-chunks-extract": "^1.0.0",
"posthog-js": "1.157.2", "posthog-js": "1.248.1",
"qrcode": "1.5.4", "qrcode": "1.5.4",
"re-resizable": "6.11.2", "re-resizable": "6.11.2",
"react": "^18.3.1", "react": "^19.0.0",
"react-beautiful-dnd": "^13.1.0", "react-beautiful-dnd": "^13.1.0",
"react-blurhash": "^0.3.0", "react-blurhash": "^0.3.0",
"react-dom": "^18.3.1", "react-dom": "^19.0.0",
"react-focus-lock": "^2.5.1", "react-focus-lock": "^2.5.1",
"react-string-replace": "^1.1.1", "react-string-replace": "^1.1.1",
"react-transition-group": "^4.4.1", "react-transition-group": "^4.4.1",
"react-virtualized": "^9.22.5", "react-virtualized": "^9.22.5",
"rfc4648": "^1.4.0", "rfc4648": "^1.4.0",
"sanitize-filename": "^1.6.3", "sanitize-filename": "^1.6.3",
"sanitize-html": "2.14.0", "sanitize-html": "2.17.0",
"tar-js": "^0.3.0", "tar-js": "^0.3.0",
"temporal-polyfill": "^0.2.5", "temporal-polyfill": "^0.3.0",
"ua-parser-js": "^1.0.2", "ua-parser-js": "^1.0.2",
"uuid": "^11.0.0", "uuid": "^11.0.0",
"what-input": "^5.2.10" "what-input": "^5.2.10"
@@ -180,13 +180,14 @@
"@babel/preset-typescript": "^7.12.7", "@babel/preset-typescript": "^7.12.7",
"@babel/runtime": "^7.12.5", "@babel/runtime": "^7.12.5",
"@casualbot/jest-sonar-reporter": "2.2.7", "@casualbot/jest-sonar-reporter": "2.2.7",
"@element-hq/element-call-embedded": "0.9.0", "@element-hq/element-call-embedded": "0.12.0",
"@element-hq/element-web-playwright-common": "^1.1.5", "@element-hq/element-web-playwright-common": "^1.1.5",
"@peculiar/webcrypto": "^1.4.3", "@peculiar/webcrypto": "^1.4.3",
"@playwright/test": "^1.50.1", "@playwright/test": "^1.50.1",
"@principalstudio/html-webpack-inject-preload": "^1.2.7", "@principalstudio/html-webpack-inject-preload": "^1.2.7",
"@rrweb/types": "^2.0.0-alpha.18",
"@sentry/webpack-plugin": "^3.0.0", "@sentry/webpack-plugin": "^3.0.0",
"@stylistic/eslint-plugin": "^3.0.0", "@stylistic/eslint-plugin": "^4.0.0",
"@svgr/webpack": "^8.0.0", "@svgr/webpack": "^8.0.0",
"@testing-library/dom": "^10.4.0", "@testing-library/dom": "^10.4.0",
"@testing-library/jest-dom": "^6.4.8", "@testing-library/jest-dom": "^6.4.8",
@@ -211,11 +212,11 @@
"@types/node-fetch": "^2.6.2", "@types/node-fetch": "^2.6.2",
"@types/pako": "^2.0.0", "@types/pako": "^2.0.0",
"@types/qrcode": "^1.3.5", "@types/qrcode": "^1.3.5",
"@types/react": "18.3.18", "@types/react": "19.1.6",
"@types/react-beautiful-dnd": "^13.0.0", "@types/react-beautiful-dnd": "^13.0.0",
"@types/react-dom": "18.3.5", "@types/react-dom": "19.1.5",
"@types/react-transition-group": "^4.4.0", "@types/react-transition-group": "^4.4.0",
"@types/sanitize-html": "2.13.0", "@types/sanitize-html": "2.16.0",
"@types/semver": "^7.5.8", "@types/semver": "^7.5.8",
"@types/tar-js": "^0.3.5", "@types/tar-js": "^0.3.5",
"@types/ua-parser-js": "^0.7.36", "@types/ua-parser-js": "^0.7.36",
@@ -246,7 +247,7 @@
"eslint-plugin-react-compiler": "^19.0.0-beta-df7b47d-20241124", "eslint-plugin-react-compiler": "^19.0.0-beta-df7b47d-20241124",
"eslint-plugin-react-hooks": "^5.0.0", "eslint-plugin-react-hooks": "^5.0.0",
"eslint-plugin-unicorn": "^56.0.0", "eslint-plugin-unicorn": "^56.0.0",
"express": "^4.18.2", "express": "^5.0.0",
"fake-indexeddb": "^6.0.0", "fake-indexeddb": "^6.0.0",
"fetch-mock": "9.11.0", "fetch-mock": "9.11.0",
"fetch-mock-jest": "^1.5.1", "fetch-mock-jest": "^1.5.1",
@@ -261,7 +262,7 @@
"jest-raw-loader": "^1.0.1", "jest-raw-loader": "^1.0.1",
"jsqr": "^1.4.0", "jsqr": "^1.4.0",
"knip": "^5.36.2", "knip": "^5.36.2",
"lint-staged": "^15.0.2", "lint-staged": "^16.0.0",
"matrix-web-i18n": "^3.2.1", "matrix-web-i18n": "^3.2.1",
"mini-css-extract-plugin": "2.9.2", "mini-css-extract-plugin": "2.9.2",
"minimist": "^1.2.6", "minimist": "^1.2.6",
@@ -286,13 +287,13 @@
"semver": "^7.5.2", "semver": "^7.5.2",
"source-map-loader": "^5.0.0", "source-map-loader": "^5.0.0",
"stylelint": "^16.13.0", "stylelint": "^16.13.0",
"stylelint-config-standard": "^37.0.0", "stylelint-config-standard": "^38.0.0",
"stylelint-scss": "^6.0.0", "stylelint-scss": "^6.0.0",
"stylelint-value-no-unknown-custom-properties": "^6.0.1", "stylelint-value-no-unknown-custom-properties": "^6.0.1",
"terser-webpack-plugin": "^5.3.9", "terser-webpack-plugin": "^5.3.9",
"testcontainers": "^10.20.0", "testcontainers": "^11.0.0",
"ts-node": "^10.9.1", "ts-node": "^10.9.1",
"typescript": "5.8.2", "typescript": "5.8.3",
"util": "^0.12.5", "util": "^0.12.5",
"web-streams-polyfill": "^4.0.0", "web-streams-polyfill": "^4.0.0",
"webpack": "^5.89.0", "webpack": "^5.89.0",
@@ -310,5 +311,6 @@
}, },
"engines": { "engines": {
"node": ">=20.0.0" "node": ">=20.0.0"
} },
"packageManager": "yarn@1.22.22+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e"
} }
-76
View File
@@ -1,76 +0,0 @@
diff --git a/node_modules/@types/react/index.d.ts b/node_modules/@types/react/index.d.ts
index 6ea73ef..cb51757 100644
--- a/node_modules/@types/react/index.d.ts
+++ b/node_modules/@types/react/index.d.ts
@@ -151,7 +151,7 @@ declare namespace React {
/**
* The current value of the ref.
*/
- readonly current: T | null;
+ current: T;
}
interface DO_NOT_USE_OR_YOU_WILL_BE_FIRED_CALLBACK_REF_RETURN_VALUES {
@@ -186,7 +186,7 @@ declare namespace React {
* @see {@link RefObject}
*/
- type Ref<T> = RefCallback<T> | RefObject<T> | null;
+ type Ref<T> = RefCallback<T> | RefObject<T | null> | null;
/**
* A legacy implementation of refs where you can pass a string to a ref prop.
*
@@ -300,7 +300,7 @@ declare namespace React {
*
* @see {@link https://react.dev/learn/referencing-values-with-refs#refs-and-the-dom React Docs}
*/
- ref?: LegacyRef<T> | undefined;
+ ref?: LegacyRef<T | null> | undefined;
}
/**
@@ -1234,7 +1234,7 @@ declare namespace React {
*
* @see {@link ForwardRefRenderFunction}
*/
- type ForwardedRef<T> = ((instance: T | null) => void) | MutableRefObject<T | null> | null;
+ type ForwardedRef<T> = ((instance: T | null) => void) | RefObject<T | null> | null;
/**
* The type of the function passed to {@link forwardRef}. This is considered different
@@ -1565,7 +1565,7 @@ declare namespace React {
[propertyName: string]: any;
}
- function createRef<T>(): RefObject<T>;
+ function createRef<T>(): RefObject<T | null>;
/**
* The type of the component returned from {@link forwardRef}.
@@ -1989,7 +1989,7 @@ declare namespace React {
* @version 16.8.0
* @see {@link https://react.dev/reference/react/useRef}
*/
- function useRef<T>(initialValue: T): MutableRefObject<T>;
+ function useRef<T>(initialValue: T): RefObject<T>;
// convenience overload for refs given as a ref prop as they typically start with a null value
/**
* `useRef` returns a mutable ref object whose `.current` property is initialized to the passed argument
@@ -2004,7 +2004,7 @@ declare namespace React {
* @version 16.8.0
* @see {@link https://react.dev/reference/react/useRef}
*/
- function useRef<T>(initialValue: T | null): RefObject<T>;
+ function useRef<T>(initialValue: T | null): RefObject<T | null>;
// convenience overload for potentially undefined initialValue / call with 0 arguments
// has a default to stop it from defaulting to {} instead
/**
@@ -2017,7 +2017,7 @@ declare namespace React {
* @version 16.8.0
* @see {@link https://react.dev/reference/react/useRef}
*/
- function useRef<T = undefined>(initialValue?: undefined): MutableRefObject<T | undefined>;
+ function useRef<T>(initialValue: T | undefined): RefObject<T | undefined>;
/**
* The signature is identical to `useEffect`, but it fires synchronously after all DOM mutations.
* Use this to read layout from the DOM and synchronously re-render. Updates scheduled inside
+31
View File
@@ -0,0 +1,31 @@
diff --git a/node_modules/@types/react/index.d.ts b/node_modules/@types/react/index.d.ts
index d3318dc..c2b2c77 100644
--- a/node_modules/@types/react/index.d.ts
+++ b/node_modules/@types/react/index.d.ts
@@ -134,7 +134,7 @@ declare namespace React {
props: P,
) => ReactNode | Promise<ReactNode>)
// constructor signature must match React.Component
- | (new(props: P) => Component<any, any>);
+ | (new(props: P, context?: any) => Component<any, any>);
/**
* Created by {@link createRef}, or {@link useRef} when passed `null`.
@@ -945,7 +945,7 @@ declare namespace React {
context: unknown;
// Keep in sync with constructor signature of JSXElementConstructor and ComponentClass.
- constructor(props: P);
+ constructor(props: P, context?: unknown);
// We MUST keep setState() as a unified signature because it allows proper checking of the method return type.
// See: https://github.com/DefinitelyTyped/DefinitelyTyped/issues/18365#issuecomment-351013257
@@ -1117,7 +1117,7 @@ declare namespace React {
*/
interface ComponentClass<P = {}, S = ComponentState> extends StaticLifecycle<P, S> {
// constructor signature must match React.Component
- new(props: P): Component<P, S>;
+ new(props: P, context?: any): Component<P, S>;
/**
* Ignored by React.
* @deprecated Only kept in types for backwards compatibility. Will be removed in a future major release.
+22
View File
@@ -0,0 +1,22 @@
diff --git a/node_modules/react-blurhash/dist/index.d.ts b/node_modules/react-blurhash/dist/index.d.ts
index 3adbd0a..32e8c13 100644
--- a/node_modules/react-blurhash/dist/index.d.ts
+++ b/node_modules/react-blurhash/dist/index.d.ts
@@ -19,7 +19,7 @@ declare class Blurhash extends React.PureComponent<Props$1> {
resolutionY: number;
};
componentDidUpdate(): void;
- render(): JSX.Element;
+ render(): React.JSX.Element;
}
declare type Props = React.CanvasHTMLAttributes<HTMLCanvasElement> & {
@@ -37,7 +37,7 @@ declare class BlurhashCanvas extends React.PureComponent<Props> {
componentDidUpdate(): void;
handleRef: (canvas: HTMLCanvasElement) => void;
draw: () => void;
- render(): JSX.Element;
+ render(): React.JSX.Element;
}
export { Blurhash, BlurhashCanvas };
+15 -3
View File
@@ -23,7 +23,13 @@ test.describe("Encryption state after registration", () => {
test("Key backup is enabled by default", async ({ page, mailpitClient, app }, testInfo) => { test("Key backup is enabled by default", async ({ page, mailpitClient, app }, testInfo) => {
await page.goto("/#/login"); await page.goto("/#/login");
await page.getByRole("button", { name: "Continue" }).click(); await page.getByRole("button", { name: "Continue" }).click();
await registerAccountMas(page, mailpitClient, `alice_${testInfo.testId}`, "alice@email.com", "Pa$sW0rD!"); await registerAccountMas(
page,
mailpitClient,
`alice_${testInfo.testId}`,
`alice_${testInfo.testId}@email.com`,
"Pa$sW0rD!",
);
// Wait for the ui to load // Wait for the ui to load
await expect(page.locator(".mx_MatrixChat")).toBeVisible(); await expect(page.locator(".mx_MatrixChat")).toBeVisible();
@@ -35,7 +41,13 @@ test.describe("Encryption state after registration", () => {
test("user is prompted to set up recovery", async ({ page, mailpitClient, app }, testInfo) => { test("user is prompted to set up recovery", async ({ page, mailpitClient, app }, testInfo) => {
await page.goto("/#/login"); await page.goto("/#/login");
await page.getByRole("button", { name: "Continue" }).click(); await page.getByRole("button", { name: "Continue" }).click();
await registerAccountMas(page, mailpitClient, `alice_${testInfo.testId}`, "alice@email.com", "Pa$sW0rD!"); await registerAccountMas(
page,
mailpitClient,
`alice_${testInfo.testId}`,
`alice_${testInfo.testId}@email.com`,
"Pa$sW0rD!",
);
await page.getByRole("button", { name: "Add room" }).click(); await page.getByRole("button", { name: "Add room" }).click();
await page.getByRole("menuitem", { name: "New room" }).click(); await page.getByRole("menuitem", { name: "New room" }).click();
@@ -64,7 +76,7 @@ test.describe("Key backup reset from elsewhere", () => {
await page.goto("/#/login"); await page.goto("/#/login");
await page.getByRole("button", { name: "Continue" }).click(); await page.getByRole("button", { name: "Continue" }).click();
await registerAccountMas(page, mailpitClient, testUsername, "alice@email.com", testPassword); await registerAccountMas(page, mailpitClient, testUsername, `${testUsername}@email.com`, testPassword);
await page.getByRole("button", { name: "Add room" }).click(); await page.getByRole("button", { name: "Add room" }).click();
await page.getByRole("menuitem", { name: "New room" }).click(); await page.getByRole("menuitem", { name: "New room" }).click();
-108
View File
@@ -1,108 +0,0 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2023 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import { type Page } from "@playwright/test";
import { test, expect } from "../../element-web-test";
import { isDendrite } from "../../plugins/homeserver/dendrite";
import { completeCreateSecretStorageDialog } from "./utils.ts";
async function expectBackupVersionToBe(page: Page, version: string) {
await expect(page.locator(".mx_SecureBackupPanel_statusList tr:nth-child(5) td")).toHaveText(
version + " (Algorithm: m.megolm_backup.v1.curve25519-aes-sha2)",
);
await expect(page.locator(".mx_SecureBackupPanel_statusList tr:nth-child(6) td")).toHaveText(version);
}
test.describe("Backups", () => {
test.skip(isDendrite, "Dendrite lacks support for MSC3967 so requires additional auth here");
test.use({
displayName: "Hanako",
});
test(
"Create, delete and recreate a keys backup",
{ tag: "@no-webkit" },
async ({ page, user, app }, workerInfo) => {
// Create a backup
const securityTab = await app.settings.openUserSettings("Security & Privacy");
await expect(securityTab.getByRole("heading", { name: "Secure Backup" })).toBeVisible();
await securityTab.getByRole("button", { name: "Set up", exact: true }).click();
const securityKey = await completeCreateSecretStorageDialog(page);
// Open the settings again
await app.settings.openUserSettings("Security & Privacy");
await expect(securityTab.getByRole("heading", { name: "Secure Backup" })).toBeVisible();
// expand the advanced section to see the active version in the reports
await page
.locator(".mx_Dialog .mx_SettingsSubsection_content details .mx_SecureBackupPanel_advanced")
.locator("..")
.click();
await expectBackupVersionToBe(page, "1");
await securityTab.getByRole("button", { name: "Delete Backup", exact: true }).click();
const currentDialogLocator = page.locator(".mx_Dialog");
await expect(currentDialogLocator.getByRole("heading", { name: "Delete Backup" })).toBeVisible();
// Delete it
await currentDialogLocator.getByTestId("dialog-primary-button").click(); // Click "Delete Backup"
// Create another
await securityTab.getByRole("button", { name: "Set up", exact: true }).click();
await expect(currentDialogLocator.getByRole("heading", { name: "Recovery Key" })).toBeVisible();
await currentDialogLocator.getByLabel("Recovery Key").fill(securityKey);
await currentDialogLocator.getByRole("button", { name: "Continue", exact: true }).click();
// Should be successful
await expect(currentDialogLocator.getByRole("heading", { name: "Success!" })).toBeVisible();
await currentDialogLocator.getByRole("button", { name: "OK", exact: true }).click();
// Open the settings again
await app.settings.openUserSettings("Security & Privacy");
await expect(securityTab.getByRole("heading", { name: "Secure Backup" })).toBeVisible();
// expand the advanced section to see the active version in the reports
await page
.locator(".mx_Dialog .mx_SettingsSubsection_content details .mx_SecureBackupPanel_advanced")
.locator("..")
.click();
await expectBackupVersionToBe(page, "2");
// ==
// Ensure that if you don't have the secret storage passphrase the backup won't be created
// ==
// First delete version 2
await securityTab.getByRole("button", { name: "Delete Backup", exact: true }).click();
await expect(currentDialogLocator.getByRole("heading", { name: "Delete Backup" })).toBeVisible();
// Click "Delete Backup"
await currentDialogLocator.getByTestId("dialog-primary-button").click();
// Try to create another
await securityTab.getByRole("button", { name: "Set up", exact: true }).click();
await expect(currentDialogLocator.getByRole("heading", { name: "Recovery Key" })).toBeVisible();
// But cancel the recovery key dialog, to simulate not having the secret storage passphrase
await currentDialogLocator.getByTestId("dialog-cancel-button").click();
await expect(currentDialogLocator.getByRole("heading", { name: "Starting backup…" })).toBeVisible();
// check that it failed
await expect(currentDialogLocator.getByText("Unable to create key backup")).toBeVisible();
// cancel
await currentDialogLocator.getByTestId("dialog-cancel-button").click();
// go back to the settings to check that no backup was created (the setup button should still be there)
await app.settings.openUserSettings("Security & Privacy");
await expect(securityTab.getByRole("button", { name: "Set up", exact: true })).toBeVisible();
},
);
});
+38 -94
View File
@@ -8,14 +8,7 @@ Please see LICENSE files in the repository root for full details.
import type { Page } from "@playwright/test"; import type { Page } from "@playwright/test";
import { expect, test } from "../../element-web-test"; import { expect, test } from "../../element-web-test";
import { import { autoJoin, createSharedRoomWithUser, enableKeyBackup, verify } from "./utils";
autoJoin,
completeCreateSecretStorageDialog,
copyAndContinue,
createSharedRoomWithUser,
enableKeyBackup,
verify,
} from "./utils";
import { type Bot } from "../../pages/bot"; import { type Bot } from "../../pages/bot";
import { type ElementAppPage } from "../../pages/ElementAppPage"; import { type ElementAppPage } from "../../pages/ElementAppPage";
import { isDendrite } from "../../plugins/homeserver/dendrite"; import { isDendrite } from "../../plugins/homeserver/dendrite";
@@ -84,86 +77,43 @@ test.describe("Cryptography", function () {
}, },
}); });
for (const isDeviceVerified of [true, false]) { /**
test.describe(`setting up secure key backup should work isDeviceVerified=${isDeviceVerified}`, () => { * Verify that the `m.cross_signing.${keyType}` key is available on the account data on the server
/** * @param keyType
* Verify that the `m.cross_signing.${keyType}` key is available on the account data on the server */
* @param keyType async function verifyKey(app: ElementAppPage, keyType: "master" | "self_signing" | "user_signing") {
*/ const accountData: { encrypted: Record<string, Record<string, string>> } = await app.client.evaluate(
async function verifyKey(app: ElementAppPage, keyType: "master" | "self_signing" | "user_signing") { (cli, keyType) => cli.getAccountDataFromServer(`m.cross_signing.${keyType}`),
const accountData: { encrypted: Record<string, Record<string, string>> } = await app.client.evaluate( keyType,
(cli, keyType) => cli.getAccountDataFromServer(`m.cross_signing.${keyType}`), );
keyType,
);
expect(accountData.encrypted).toBeDefined();
const keys = Object.keys(accountData.encrypted);
const key = accountData.encrypted[keys[0]];
expect(key.ciphertext).toBeDefined();
expect(key.iv).toBeDefined();
expect(key.mac).toBeDefined();
}
test("by recovery code", async ({ page, app, user: aliceCredentials }) => { expect(accountData.encrypted).toBeDefined();
// Verified the device const keys = Object.keys(accountData.encrypted);
if (isDeviceVerified) { const key = accountData.encrypted[keys[0]];
await app.client.bootstrapCrossSigning(aliceCredentials); expect(key.ciphertext).toBeDefined();
} expect(key.iv).toBeDefined();
expect(key.mac).toBeDefined();
await page.route("**/_matrix/client/v3/keys/signatures/upload", async (route) => {
// We delay this API otherwise the `Setting up keys` may happen too quickly and cause flakiness
await new Promise((resolve) => setTimeout(resolve, 500));
await route.continue();
});
await app.settings.openUserSettings("Security & Privacy");
await page.getByRole("button", { name: "Set up Secure Backup" }).click();
await completeCreateSecretStorageDialog(page);
// Verify that the SSSS keys are in the account data stored in the server
await verifyKey(app, "master");
await verifyKey(app, "self_signing");
await verifyKey(app, "user_signing");
});
test("by passphrase", async ({ page, app, user: aliceCredentials }) => {
// Verified the device
if (isDeviceVerified) {
await app.client.bootstrapCrossSigning(aliceCredentials);
}
await app.settings.openUserSettings("Security & Privacy");
await page.getByRole("button", { name: "Set up Secure Backup" }).click();
const dialog = page.locator(".mx_Dialog");
// Select passphrase option
await dialog.getByText("Enter a Security Phrase").click();
await dialog.getByRole("button", { name: "Continue" }).click();
// Fill passphrase input
await dialog.locator("input").fill("new passphrase for setting up a secure key backup");
await dialog.locator(".mx_Dialog_primary:not([disabled])", { hasText: "Continue" }).click();
// Confirm passphrase
await dialog.locator("input").fill("new passphrase for setting up a secure key backup");
await dialog.locator(".mx_Dialog_primary:not([disabled])", { hasText: "Continue" }).click();
await copyAndContinue(page);
await expect(dialog.getByText("Secure Backup successful")).toBeVisible();
await dialog.getByRole("button", { name: "Done" }).click();
await expect(dialog.getByText("Secure Backup successful")).not.toBeVisible();
// Verify that the SSSS keys are in the account data stored in the server
await verifyKey(app, "master");
await verifyKey(app, "self_signing");
await verifyKey(app, "user_signing");
});
});
} }
test("Setting up key backup by recovery key", async ({ page, app, user: aliceCredentials }) => {
await app.client.bootstrapCrossSigning(aliceCredentials);
await enableKeyBackup(app);
// Wait for the cross signing keys to be uploaded
// Waiting for "Change the recovery key" button ensure that all the secrets are uploaded and cached locally
const encryptionTab = await app.settings.openUserSettings("Encryption");
await expect(encryptionTab.getByRole("button", { name: "Change recovery key" })).toBeVisible();
// Verify that the SSSS keys are in the account data stored in the server
await verifyKey(app, "master");
await verifyKey(app, "self_signing");
await verifyKey(app, "user_signing");
});
test("Can reset cross-signing keys", async ({ page, app, user: aliceCredentials }) => { test("Can reset cross-signing keys", async ({ page, app, user: aliceCredentials }) => {
await app.client.bootstrapCrossSigning(aliceCredentials); await app.client.bootstrapCrossSigning(aliceCredentials);
const secretStorageKey = await enableKeyBackup(app); await enableKeyBackup(app);
// Fetch the current cross-signing keys // Fetch the current cross-signing keys
async function fetchMasterKey() { async function fetchMasterKey() {
@@ -177,18 +127,15 @@ test.describe("Cryptography", function () {
return k; return k;
}); });
} }
const masterKey1 = await fetchMasterKey(); const masterKey1 = await fetchMasterKey();
// Find the "reset cross signing" button, and click it // Find "the Reset cryptographic identity" button
await app.settings.openUserSettings("Security & Privacy"); const encryptionTab = await app.settings.openUserSettings("Encryption");
await page.locator("div.mx_CrossSigningPanel_buttonRow").getByRole("button", { name: "Reset" }).click(); await encryptionTab.getByRole("button", { name: "Reset cryptographic identity" }).click();
// Confirm // Confirm
await page.getByRole("button", { name: "Clear cross-signing keys" }).click(); await encryptionTab.getByRole("button", { name: "Continue" }).click();
// Enter the 4S key
await page.getByPlaceholder("Recovery Key").fill(secretStorageKey);
await page.getByRole("button", { name: "Continue" }).click();
// Enter the password // Enter the password
await page.getByPlaceholder("Password").fill(aliceCredentials.password); await page.getByPlaceholder("Password").fill(aliceCredentials.password);
@@ -198,9 +145,6 @@ test.describe("Cryptography", function () {
const masterKey2 = await fetchMasterKey(); const masterKey2 = await fetchMasterKey();
expect(masterKey1).not.toEqual(masterKey2); expect(masterKey1).not.toEqual(masterKey2);
}).toPass(); }).toPass();
// The dialog should have gone away
await expect(page.locator(".mx_Dialog")).toHaveCount(1);
}); });
test( test(
+28 -12
View File
@@ -8,7 +8,7 @@ Please see LICENSE files in the repository root for full details.
import { test, expect } from "../../element-web-test"; import { test, expect } from "../../element-web-test";
import { isDendrite } from "../../plugins/homeserver/dendrite"; import { isDendrite } from "../../plugins/homeserver/dendrite";
import { completeCreateSecretStorageDialog, createBot, logIntoElement } from "./utils.ts"; import { createBot, logIntoElement } from "./utils.ts";
import { type Client } from "../../pages/client.ts"; import { type Client } from "../../pages/client.ts";
import { type ElementAppPage } from "../../pages/ElementAppPage.ts"; import { type ElementAppPage } from "../../pages/ElementAppPage.ts";
@@ -28,21 +28,27 @@ test.describe("Dehydration", () => {
test.skip(isDendrite, "does not yet support dehydration v2"); test.skip(isDendrite, "does not yet support dehydration v2");
test("Verify device and reset creates dehydrated device", async ({ page, user, credentials, app }, workerInfo) => { test("Verify device and reset creates dehydrated device", async ({ page, user, credentials, app }, workerInfo) => {
// Verify the device by resetting the key (which will create SSSS, and dehydrated device) // Verify the device by resetting the identity key, and then set up recovery (which will create SSSS, and dehydrated device)
const securityTab = await app.settings.openUserSettings("Security & Privacy"); const securityTab = await app.settings.openUserSettings("Security & Privacy");
await expect(securityTab.getByText("Offline device enabled")).not.toBeVisible(); await expect(securityTab.getByText("Offline device enabled")).not.toBeVisible();
await app.closeDialog(); await app.closeDialog();
// Verify the device by resetting the key // Reset the identity key
const settings = await app.settings.openUserSettings("Encryption"); const settings = await app.settings.openUserSettings("Encryption");
await settings.getByRole("button", { name: "Verify this device" }).click(); await settings.getByRole("button", { name: "Verify this device" }).click();
await page.getByRole("button", { name: "Proceed with reset" }).click(); await page.getByRole("button", { name: "Proceed with reset" }).click();
await page.getByRole("button", { name: "Continue" }).click(); await page.getByRole("button", { name: "Continue" }).click();
await page.getByRole("button", { name: "Copy" }).click();
// Set up recovery
await page.getByRole("button", { name: "Set up recovery" }).click();
await page.getByRole("button", { name: "Continue" }).click(); await page.getByRole("button", { name: "Continue" }).click();
await page.getByRole("button", { name: "Done" }).click(); const recoveryKey = await page.getByTestId("recoveryKey").innerText();
await page.getByRole("button", { name: "Continue" }).click();
await page.getByRole("textbox").fill(recoveryKey);
await page.getByRole("button", { name: "Finish set up" }).click();
await page.getByRole("button", { name: "Close" }).click();
await expectDehydratedDeviceEnabled(app); await expectDehydratedDeviceEnabled(app);
@@ -80,7 +86,7 @@ test.describe("Dehydration", () => {
await expectDehydratedDeviceEnabled(app); await expectDehydratedDeviceEnabled(app);
}); });
test("Reset recovery key during login re-creates dehydrated device", async ({ test("Reset identity during login and set up recovery re-creates dehydrated device", async ({
page, page,
homeserver, homeserver,
app, app,
@@ -99,16 +105,26 @@ test.describe("Dehydration", () => {
// Log in our client // Log in our client
await logIntoElement(page, credentials); await logIntoElement(page, credentials);
// Oh no, we forgot our recovery key // Oh no, we forgot our recovery key - reset our identity
await page.locator(".mx_AuthPage").getByRole("button", { name: "Reset all" }).click(); await page.locator(".mx_AuthPage").getByRole("button", { name: "Reset all" }).click();
await page.locator(".mx_AuthPage").getByRole("button", { name: "Proceed with reset" }).click(); await expect(
page.getByRole("heading", { name: "Are you sure you want to reset your identity?" }),
).toBeVisible();
await page.getByRole("button", { name: "Continue", exact: true }).click();
await page.getByPlaceholder("Password").fill(credentials.password);
await page.getByRole("button", { name: "Continue" }).click();
await completeCreateSecretStorageDialog(page, { accountPassword: credentials.password }); // And set up recovery
const settings = await app.settings.openUserSettings("Encryption");
await settings.getByRole("button", { name: "Set up recovery" }).click();
await settings.getByRole("button", { name: "Continue" }).click();
const recoveryKey = await settings.getByTestId("recoveryKey").innerText();
await settings.getByRole("button", { name: "Continue" }).click();
await settings.getByRole("textbox").fill(recoveryKey);
await settings.getByRole("button", { name: "Finish set up" }).click();
// There should be a brand new dehydrated device // There should be a brand new dehydrated device
const dehydratedDeviceIds = await getDehydratedDeviceIds(app.client); await expectDehydratedDeviceEnabled(app);
expect(dehydratedDeviceIds.length).toBe(1);
expect(dehydratedDeviceIds[0]).not.toEqual(initialDehydratedDeviceIds[0]);
}); });
test("'Reset cryptographic identity' removes dehydrated device", async ({ page, homeserver, app, credentials }) => { test("'Reset cryptographic identity' removes dehydrated device", async ({ page, homeserver, app, credentials }) => {
@@ -22,6 +22,7 @@ import {
} from "./utils"; } from "./utils";
import { type Bot } from "../../pages/bot"; import { type Bot } from "../../pages/bot";
import { Toasts } from "../../pages/toasts.ts"; import { Toasts } from "../../pages/toasts.ts";
import type { ElementAppPage } from "../../pages/ElementAppPage.ts";
test.describe("Device verification", { tag: "@no-webkit" }, () => { test.describe("Device verification", { tag: "@no-webkit" }, () => {
let aliceBotClient: Bot; let aliceBotClient: Bot;
@@ -163,39 +164,44 @@ test.describe("Device verification", { tag: "@no-webkit" }, () => {
test("Verify device with Security Phrase during login", async ({ page, app, credentials, homeserver }) => { test("Verify device with Security Phrase during login", async ({ page, app, credentials, homeserver }) => {
await logIntoElement(page, credentials); await logIntoElement(page, credentials);
await enterRecoveryKeyAndCheckVerified(page, app, "new passphrase");
// Select the security phrase
await page.locator(".mx_AuthPage").getByRole("button", { name: "Verify with Recovery Key or Phrase" }).click();
// Fill the passphrase
const dialog = page.locator(".mx_Dialog");
await dialog.locator("input").fill("new passphrase");
await dialog.locator(".mx_Dialog_primary:not([disabled])", { hasText: "Continue" }).click();
await page.locator(".mx_AuthPage").getByRole("button", { name: "Done" }).click();
// Check that our device is now cross-signed
await checkDeviceIsCrossSigned(app);
// Check that the current device is connected to key backup
// The backup decryption key should be in cache also, as we got it directly from the 4S
await checkDeviceIsConnectedKeyBackup(app, expectedBackupVersion, true);
}); });
test("Verify device with Recovery Key during login", async ({ page, app, credentials, homeserver }) => { test("Verify device with Recovery Key during login", async ({ page, app, credentials, homeserver }) => {
const recoveryKey = (await aliceBotClient.getRecoveryKey()).encodedPrivateKey;
await logIntoElement(page, credentials);
await enterRecoveryKeyAndCheckVerified(page, app, recoveryKey);
});
test("Verify device with Recovery Key from settings", async ({ page, app, credentials }) => {
const recoveryKey = (await aliceBotClient.getRecoveryKey()).encodedPrivateKey;
await logIntoElement(page, credentials); await logIntoElement(page, credentials);
// Select the security phrase /* Dismiss "Verify this device" */
await page.locator(".mx_AuthPage").getByRole("button", { name: "Verify with Recovery Key or Phrase" }).click(); const authPage = page.locator(".mx_AuthPage");
await authPage.getByRole("button", { name: "Skip verification for now" }).click();
await authPage.getByRole("button", { name: "I'll verify later" }).click();
await page.waitForSelector(".mx_MatrixChat");
// Fill the recovery key const settings = await app.settings.openUserSettings("Encryption");
await settings.getByRole("button", { name: "Verify this device" }).click();
await enterRecoveryKeyAndCheckVerified(page, app, recoveryKey);
});
/** Helper for the three tests above which verify by recovery key */
async function enterRecoveryKeyAndCheckVerified(page: Page, app: ElementAppPage, recoveryKey: string) {
await page.getByRole("button", { name: "Verify with Recovery Key or Phrase" }).click();
// Enter the recovery key
const dialog = page.locator(".mx_Dialog"); const dialog = page.locator(".mx_Dialog");
await dialog.getByRole("button", { name: "use your Recovery Key" }).click(); // We use `pressSequentially` here to make sure that the FocusLock isn't causing us any problems
const aliceRecoveryKey = await aliceBotClient.getRecoveryKey(); // (cf https://github.com/element-hq/element-web/issues/30089)
await dialog.locator("#mx_securityKey").fill(aliceRecoveryKey.encodedPrivateKey); await dialog.locator("textarea").pressSequentially(recoveryKey);
await dialog.locator(".mx_Dialog_primary:not([disabled])", { hasText: "Continue" }).click(); await dialog.getByRole("button", { name: "Continue", disabled: false }).click();
await page.locator(".mx_AuthPage").getByRole("button", { name: "Done" }).click(); await page.getByRole("button", { name: "Done" }).click();
// Check that our device is now cross-signed // Check that our device is now cross-signed
await checkDeviceIsCrossSigned(app); await checkDeviceIsCrossSigned(app);
@@ -203,7 +209,7 @@ test.describe("Device verification", { tag: "@no-webkit" }, () => {
// Check that the current device is connected to key backup // 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 // The backup decryption key should be in cache also, as we got it directly from the 4S
await checkDeviceIsConnectedKeyBackup(app, expectedBackupVersion, true); await checkDeviceIsConnectedKeyBackup(app, expectedBackupVersion, true);
}); }
test("Handle incoming verification request with SAS", async ({ page, credentials, homeserver, toasts }) => { test("Handle incoming verification request with SAS", async ({ page, credentials, homeserver, toasts }) => {
await logIntoElement(page, credentials); await logIntoElement(page, credentials);
+3 -2
View File
@@ -67,8 +67,9 @@ test.describe("Cryptography", function () {
// Bob has a second, not cross-signed, device // Bob has a second, not cross-signed, device
const bobSecondDevice = await createSecondBotDevice(page, homeserver, bob); 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 // Dismiss the toasts nagging us, otherwise they get in the way of clicking the room list
await page.getByRole("button", { name: "Not now" }).click(); await page.getByRole("button", { name: "Dismiss" }).click();
await page.getByRole("button", { name: "Yes, dismiss" }).click();
await bob.sendEvent(testRoomId, null, "m.room.encrypted", { await bob.sendEvent(testRoomId, null, "m.room.encrypted", {
algorithm: "m.megolm.v1.aes-sha2", algorithm: "m.megolm.v1.aes-sha2",
+113 -1
View File
@@ -8,7 +8,8 @@
import { type GeneratedSecretStorageKey } from "matrix-js-sdk/src/crypto-api"; import { type GeneratedSecretStorageKey } from "matrix-js-sdk/src/crypto-api";
import { test, expect } from "../../element-web-test"; import { test, expect } from "../../element-web-test";
import { createBot, deleteCachedSecrets, logIntoElement } from "./utils"; import { createBot, deleteCachedSecrets, disableKeyBackup, logIntoElement } from "./utils";
import { type Bot } from "../../pages/bot";
test.describe("Key storage out of sync toast", () => { test.describe("Key storage out of sync toast", () => {
let recoveryKey: GeneratedSecretStorageKey; let recoveryKey: GeneratedSecretStorageKey;
@@ -53,3 +54,114 @@ test.describe("Key storage out of sync toast", () => {
).toBeVisible(); ).toBeVisible();
}); });
}); });
test.describe("'Turn on key storage' toast", () => {
let botClient: Bot | undefined;
test.beforeEach(async ({ page, homeserver, credentials, toasts }) => {
// Set up all crypto stuff. Key storage defaults to on.
const res = await createBot(page, homeserver, credentials);
const recoveryKey = res.recoveryKey;
botClient = res.botClient;
await logIntoElement(page, credentials, recoveryKey.encodedPrivateKey);
// We won't be prompted for crypto setup unless we have an e2e room, so make one
await page.getByRole("button", { name: "Add room" }).click();
await page.getByRole("menuitem", { name: "New room" }).click();
await page.getByRole("textbox", { name: "Name" }).fill("Test room");
await page.getByRole("button", { name: "Create room" }).click();
await toasts.rejectToast("Notifications");
});
test("should not show toast if key storage is on", async ({ page, toasts }) => {
// Given the default situation after signing in
// Then no toast is shown (because key storage is on)
await toasts.assertNoToasts();
// When we reload
await page.reload();
// Give the toasts time to appear
await new Promise((resolve) => setTimeout(resolve, 2000));
// Then still no toast is shown
await toasts.assertNoToasts();
});
test("should not show toast if key storage is off because we turned it off", async ({ app, page, toasts }) => {
// Given the backup is disabled because we disabled it
await disableKeyBackup(app);
// Then no toast is shown
await toasts.assertNoToasts();
// When we reload
await page.reload();
// Give the toasts time to appear
await new Promise((resolve) => setTimeout(resolve, 2000));
// Then still no toast is shown
await toasts.assertNoToasts();
});
test("should show toast if key storage is off but account data is missing", async ({ app, page, toasts }) => {
// Given the backup is disabled but we didn't set account data saying that is expected
await disableKeyBackup(app);
await botClient.setAccountData("m.org.matrix.custom.backup_disabled", { disabled: false });
// Wait for the account data setting to stick
await new Promise((resolve) => setTimeout(resolve, 2000));
// When we enter the app
await page.reload();
// Then the toast is displayed
let toast = await toasts.getToast("Turn on key storage");
// And when we click "Continue"
await toast.getByRole("button", { name: "Continue" }).click();
// Then we see the Encryption settings dialog with an option to turn on key storage
await expect(page.getByRole("checkbox", { name: "Allow key storage" })).toBeVisible();
// And when we close that
await page.getByRole("button", { name: "Close dialog" }).click();
// Then we see the toast again
toast = await toasts.getToast("Turn on key storage");
// And when we click "Dismiss"
await toast.getByRole("button", { name: "Dismiss" }).click();
// Then we see the "are you sure?" dialog
await expect(
page.getByRole("heading", { name: "Are you sure you want to keep key storage turned off?" }),
).toBeVisible();
// And when we close it by clicking away
await page.getByTestId("dialog-background").click({ force: true, position: { x: 10, y: 10 } });
// Then we see the toast again
toast = await toasts.getToast("Turn on key storage");
// And when we click Dismiss and then "Go to Settings"
await toast.getByRole("button", { name: "Dismiss" }).click();
await page.getByRole("button", { name: "Go to Settings" }).click();
// Then we see Encryption settings again
await expect(page.getByRole("checkbox", { name: "Allow key storage" })).toBeVisible();
// And when we close that, see the toast, click Dismiss, and Yes, Dismiss
await page.getByRole("button", { name: "Close dialog" }).click();
toast = await toasts.getToast("Turn on key storage");
await toast.getByRole("button", { name: "Dismiss" }).click();
await page.getByRole("button", { name: "Yes, dismiss" }).click();
// Then the toast is gone
await toasts.assertNoToasts();
});
});
+22 -3
View File
@@ -228,8 +228,8 @@ export async function logIntoElement(page: Page, credentials: Credentials, secur
await useSecurityKey.click(); await useSecurityKey.click();
} }
// Fill in the recovery key // Fill in the recovery key
await page.locator(".mx_Dialog").locator('input[type="password"]').fill(securityKey); await page.locator(".mx_Dialog").locator("textarea").fill(securityKey);
await page.locator(".mx_Dialog_primary:not([disabled])", { hasText: "Continue" }).click(); await page.getByRole("button", { name: "Continue", disabled: false }).click();
await page.getByRole("button", { name: "Done" }).click(); await page.getByRole("button", { name: "Done" }).click();
} }
} }
@@ -263,7 +263,7 @@ export async function verifySession(app: ElementAppPage, securityKey: string) {
const settings = await app.settings.openUserSettings("Encryption"); const settings = await app.settings.openUserSettings("Encryption");
await settings.getByRole("button", { name: "Verify this device" }).click(); await settings.getByRole("button", { name: "Verify this device" }).click();
await app.page.getByRole("button", { name: "Verify with Recovery Key" }).click(); await app.page.getByRole("button", { name: "Verify with Recovery Key" }).click();
await app.page.locator(".mx_Dialog").locator('input[type="password"]').fill(securityKey); await app.page.locator(".mx_Dialog").locator("textarea").fill(securityKey);
await app.page.getByRole("button", { name: "Continue", disabled: false }).click(); await app.page.getByRole("button", { name: "Continue", disabled: false }).click();
await app.page.getByRole("button", { name: "Done" }).click(); await app.page.getByRole("button", { name: "Done" }).click();
await app.settings.closeDialog(); await app.settings.closeDialog();
@@ -316,6 +316,25 @@ export async function enableKeyBackup(app: ElementAppPage): Promise<string> {
return recoveryKey; return recoveryKey;
} }
/**
* Open the encryption settings and disable key storage (and recovery)
* Assumes that the current device has been verified
*/
export async function disableKeyBackup(app: ElementAppPage): Promise<void> {
const encryptionTab = await app.settings.openUserSettings("Encryption");
const keyStorageToggle = encryptionTab.getByRole("checkbox", { name: "Allow key storage" });
if (await keyStorageToggle.isChecked()) {
await encryptionTab.getByRole("checkbox", { name: "Allow key storage" }).click();
await encryptionTab.getByRole("button", { name: "Delete key storage" }).click();
await encryptionTab.getByRole("checkbox", { name: "Allow key storage" }).isVisible();
// Wait for the update to account data to stick
await new Promise((resolve) => setTimeout(resolve, 2000));
}
await app.settings.closeDialog();
}
/** /**
* Go through the "Set up Secure Backup" dialog (aka the `CreateSecretStorageDialog`). * Go through the "Set up Secure Backup" dialog (aka the `CreateSecretStorageDialog`).
* *
@@ -5,8 +5,11 @@
* Please see LICENSE files in the repository root for full details. * Please see LICENSE files in the repository root for full details.
*/ */
import { type Visibility } from "matrix-js-sdk/src/matrix";
import { type Locator, type Page } from "@playwright/test";
import { expect, test } from "../../../element-web-test"; import { expect, test } from "../../../element-web-test";
import type { Page } from "@playwright/test"; import { SettingLevel } from "../../../../src/settings/SettingLevel";
test.describe("Room list filters and sort", () => { test.describe("Room list filters and sort", () => {
test.use({ test.use({
@@ -18,24 +21,134 @@ test.describe("Room list filters and sort", () => {
labsFlags: ["feature_new_room_list"], labsFlags: ["feature_new_room_list"],
}); });
function getPrimaryFilters(page: Page) { function getPrimaryFilters(page: Page): Locator {
return page.getByRole("listbox", { name: "Room list filters" }); return page.getByRole("listbox", { name: "Room list filters" });
} }
function getRoomOptionsMenu(page: Page): Locator {
return page.getByRole("button", { name: "Room Options" });
}
/**
* Get the room list
* @param page
*/
function getRoomList(page: Page) {
return page.getByTestId("room-list");
}
test.beforeEach(async ({ page, app, bot, user }) => { test.beforeEach(async ({ page, app, bot, user }) => {
// The notification toast is displayed above the search section // The notification toast is displayed above the search section
await app.closeNotificationToast(); await app.closeNotificationToast();
}); });
test.describe("Room list", () => { test("Tombstoned rooms are not shown even when they receive updates", async ({ page, app, bot }) => {
/** // This bug shows up with this setting turned on
* Get the room list await app.settings.setValue("Spaces.allRoomsInHome", null, SettingLevel.DEVICE, true);
* @param page
*/
function getRoomList(page: Page) {
return page.getByTestId("room-list");
}
/*
We will first create a room named 'Old Room' and will invite the bot user to this room.
We will also send a simple message in this room.
*/
const oldRoomId = await app.client.createRoom({ name: "Old Room" });
await app.client.inviteUser(oldRoomId, bot.credentials.userId);
await bot.joinRoom(oldRoomId);
const response = await app.client.sendMessage(oldRoomId, "Hello!");
/*
At this point, we haven't done anything interesting.
So we expect 'Old Room' to show up in the room list.
*/
const roomListView = getRoomList(page);
const oldRoomTile = roomListView.getByRole("gridcell", { name: "Open room Old Room" });
await expect(oldRoomTile).toBeVisible();
/*
Now let's tombstone 'Old Room'.
First we create a new room ('New Room') with the predecessor set to the old room..
*/
const newRoomId = await bot.createRoom({
name: "New Room",
creation_content: {
predecessor: {
event_id: response.event_id,
room_id: oldRoomId,
},
},
visibility: "public" as Visibility,
});
/*
Now we can send the tombstone event itself to the 'Old Room'.
*/
await app.client.sendStateEvent(oldRoomId, "m.room.tombstone", {
body: "This room has been replaced",
replacement_room: newRoomId,
});
// Let's join the replaced room.
await app.client.joinRoom(newRoomId);
// We expect 'Old Room' to be hidden from the room list.
await expect(oldRoomTile).not.toBeVisible();
/*
Let's say some user in the 'Old Room' changes their display name.
This will send events to the all the rooms including 'Old Room'.
Nevertheless, the replaced room should not be shown in the room list.
*/
await bot.setDisplayName("MyNewName");
await expect(oldRoomTile).not.toBeVisible();
});
test.describe("Scroll behaviour", () => {
test("should scroll to the top of list when filter is applied and active room is not in filtered list", async ({
page,
app,
}) => {
const createFavouriteRoom = async (name: string) => {
const id = await app.client.createRoom({
name,
});
await app.client.evaluate(async (client, favouriteId) => {
await client.setRoomTag(favouriteId, "m.favourite", { order: 0.5 });
}, id);
};
// Create 5 favourite rooms
let i = 0;
for (; i < 5; i++) {
await createFavouriteRoom(`room${i}-fav`);
}
// Create a non-favourite room
await app.client.createRoom({ name: `room-non-fav` });
// Create rest of the favourite rooms
for (; i < 20; i++) {
await createFavouriteRoom(`room${i}-fav`);
}
// Open the non-favourite room
const roomListView = getRoomList(page);
const tile = roomListView.getByRole("gridcell", { name: "Open room room-non-fav" });
await tile.scrollIntoViewIfNeeded();
await tile.click();
// Enable Favourite filter
const primaryFilters = getPrimaryFilters(page);
await primaryFilters.getByRole("option", { name: "Favourite" }).click();
await expect(tile).not.toBeVisible();
// Ensure the room list is not scrolled
const isScrolledDown = await page
.getByRole("grid", { name: "Room list" })
.evaluate((e) => e.scrollTop !== 0);
expect(isScrolledDown).toStrictEqual(false);
});
});
test.describe("Room list", () => {
let unReadDmId: string | undefined; let unReadDmId: string | undefined;
let unReadRoomId: string | undefined; let unReadRoomId: string | undefined;
@@ -59,6 +172,38 @@ test.describe("Room list filters and sort", () => {
await app.client.evaluate(async (client, favouriteId) => { await app.client.evaluate(async (client, favouriteId) => {
await client.setRoomTag(favouriteId, "m.favourite", { order: 0.5 }); await client.setRoomTag(favouriteId, "m.favourite", { order: 0.5 });
}, favouriteId); }, favouriteId);
const lowPrioId = await app.client.createRoom({ name: "Low prio room" });
await app.client.evaluate(async (client, id) => {
await client.setRoomTag(id, "m.lowpriority", { order: 0.5 });
}, lowPrioId);
await bot.createRoom({
name: "invited room",
invite: [user.userId],
is_direct: true,
});
const mentionRoomId = await app.client.createRoom({ name: "room with mention" });
await app.client.inviteUser(mentionRoomId, bot.credentials.userId);
await bot.joinRoom(mentionRoomId);
const clientBot = await bot.prepareClient();
await clientBot.evaluate(
async (client, { mentionRoomId, userId }) => {
await client.sendMessage(mentionRoomId, {
// @ts-ignore ignore usage of MsgType.text
"msgtype": "m.text",
"body": "User",
"format": "org.matrix.custom.html",
"formatted_body": `<a href="https://matrix.to/#/${userId}">User</a>`,
"m.mentions": {
user_ids: [userId],
},
});
},
{ mentionRoomId, userId: user.userId },
);
}); });
test("should filter the list (with primary filters)", { tag: "@screenshot" }, async ({ page, app, user }) => { test("should filter the list (with primary filters)", { tag: "@screenshot" }, async ({ page, app, user }) => {
@@ -75,7 +220,7 @@ test.describe("Room list filters and sort", () => {
// only one room should be visible // only one room should be visible
await expect(roomList.getByRole("gridcell", { name: "unread dm" })).toBeVisible(); await expect(roomList.getByRole("gridcell", { name: "unread dm" })).toBeVisible();
await expect(roomList.getByRole("gridcell", { name: "unread room" })).toBeVisible(); await expect(roomList.getByRole("gridcell", { name: "unread room" })).toBeVisible();
expect(await roomList.locator("role=gridcell").count()).toBe(2); expect(await roomList.locator("role=gridcell").count()).toBe(4);
await expect(primaryFilters).toMatchScreenshot("unread-primary-filters.png"); await expect(primaryFilters).toMatchScreenshot("unread-primary-filters.png");
await primaryFilters.getByRole("option", { name: "Favourite" }).click(); await primaryFilters.getByRole("option", { name: "Favourite" }).click();
@@ -84,37 +229,71 @@ test.describe("Room list filters and sort", () => {
await primaryFilters.getByRole("option", { name: "People" }).click(); await primaryFilters.getByRole("option", { name: "People" }).click();
await expect(roomList.getByRole("gridcell", { name: "unread dm" })).toBeVisible(); await expect(roomList.getByRole("gridcell", { name: "unread dm" })).toBeVisible();
expect(await roomList.locator("role=gridcell").count()).toBe(1); await expect(roomList.getByRole("gridcell", { name: "invited room" })).toBeVisible();
expect(await roomList.locator("role=gridcell").count()).toBe(2);
await primaryFilters.getByRole("option", { name: "Rooms" }).click(); await primaryFilters.getByRole("option", { name: "Rooms" }).click();
await expect(roomList.getByRole("gridcell", { name: "unread room" })).toBeVisible(); await expect(roomList.getByRole("gridcell", { name: "unread room" })).toBeVisible();
await expect(roomList.getByRole("gridcell", { name: "favourite room" })).toBeVisible(); await expect(roomList.getByRole("gridcell", { name: "favourite room" })).toBeVisible();
await expect(roomList.getByRole("gridcell", { name: "empty room" })).toBeVisible(); await expect(roomList.getByRole("gridcell", { name: "empty room" })).toBeVisible();
expect(await roomList.locator("role=gridcell").count()).toBe(3); await expect(roomList.getByRole("gridcell", { name: "room with mention" })).toBeVisible();
await expect(roomList.getByRole("gridcell", { name: "Low prio room" })).toBeVisible();
expect(await roomList.locator("role=gridcell").count()).toBe(5);
await primaryFilters.getByRole("option", { name: "Mentions" }).click();
await expect(roomList.getByRole("gridcell", { name: "room with mention" })).toBeVisible();
expect(await roomList.locator("role=gridcell").count()).toBe(1);
await primaryFilters.getByRole("option", { name: "Invites" }).click();
await expect(roomList.getByRole("gridcell", { name: "invited room" })).toBeVisible();
expect(await roomList.locator("role=gridcell").count()).toBe(1);
}); });
test("unread filter should only match unread rooms that have a count", async ({ page, app, bot }) => { test(
"unread filter should only match unread rooms that have a count",
{ tag: "@screenshot" },
async ({ page, app, bot }) => {
const roomListView = getRoomList(page);
// Let's configure unread dm room so that we only get notification for mentions and keywords
await app.viewRoomById(unReadDmId);
await app.settings.openRoomSettings("Notifications");
await page.getByText("@mentions & keywords").click();
await app.settings.closeDialog();
// Let's open a room other than unread room or unread dm
await roomListView.getByRole("gridcell", { name: "Open room favourite room" }).click();
// Let's make the bot send a new message in both rooms
await bot.sendMessage(unReadDmId, "Hello!");
await bot.sendMessage(unReadRoomId, "Hello!");
// Let's activate the unread filter now
await page.getByRole("option", { name: "Unread" }).click();
// Unread filter should only show unread room and not unread dm!
const unreadDm = roomListView.getByRole("gridcell", { name: "Open room unread room" });
await expect(unreadDm).toBeVisible();
await expect(unreadDm).toMatchScreenshot("unread-dm.png");
await expect(roomListView.getByRole("gridcell", { name: "Open room unread dm" })).not.toBeVisible();
},
);
test("should sort the room list alphabetically", async ({ page }) => {
const roomListView = getRoomList(page); const roomListView = getRoomList(page);
// Let's configure unread dm room so that we only get notification for mentions and keywords await getRoomOptionsMenu(page).click();
await app.viewRoomById(unReadDmId); await page.getByRole("menuitemradio", { name: "A-Z" }).click();
await app.settings.openRoomSettings("Notifications");
await page.getByText("@mentions & keywords").click();
await app.settings.closeDialog();
// Let's open a room other than unread room or unread dm await expect(roomListView.getByRole("gridcell").first()).toHaveText(/empty room/);
await roomListView.getByRole("gridcell", { name: "Open room favourite room" }).click(); });
test("should move room to the top on message when sorting by activity", async ({ page, bot }) => {
const roomListView = getRoomList(page);
// Let's make the bot send a new message in both rooms
await bot.sendMessage(unReadDmId, "Hello!"); await bot.sendMessage(unReadDmId, "Hello!");
await bot.sendMessage(unReadRoomId, "Hello!");
// Let's activate the unread filter now await expect(roomListView.getByRole("gridcell").first()).toHaveText(/unread dm/);
await page.getByRole("option", { name: "Unread" }).click();
// Unread filter should only show unread room and not unread dm!
await expect(roomListView.getByRole("gridcell", { name: "Open room unread room" })).toBeVisible();
await expect(roomListView.getByRole("gridcell", { name: "Open room unread dm" })).not.toBeVisible();
}); });
}); });
@@ -137,15 +316,25 @@ test.describe("Room list filters and sort", () => {
}, },
); );
test("should render the placeholder for unread filter", { tag: "@screenshot" }, async ({ page, app, user }) => { [
const primaryFilters = getPrimaryFilters(page); { filter: "Unreads", action: "Show all chats" },
await primaryFilters.getByRole("option", { name: "Unread" }).click(); { filter: "Mentions", action: "See all activity" },
{ filter: "Invites", action: "See all activity" },
].forEach(({ filter, action }) => {
test(
`should render the placeholder for ${filter} filter`,
{ tag: "@screenshot" },
async ({ page, app, user }) => {
const primaryFilters = getPrimaryFilters(page);
await primaryFilters.getByRole("option", { name: filter }).click();
const emptyRoomList = getEmptyRoomList(page); const emptyRoomList = getEmptyRoomList(page);
await expect(emptyRoomList).toMatchScreenshot("unread-empty-room-list.png"); await expect(emptyRoomList).toMatchScreenshot(`${filter}-empty-room-list.png`);
await emptyRoomList.getByRole("button", { name: "show all chats" }).click(); await emptyRoomList.getByRole("button", { name: action }).click();
await expect(primaryFilters.getByRole("option", { name: "Unread" })).not.toBeChecked(); await expect(primaryFilters.getByRole("option", { name: filter })).not.toBeChecked();
},
);
}); });
["People", "Rooms", "Favourite"].forEach((filter) => { ["People", "Rooms", "Favourite"].forEach((filter) => {
@@ -30,6 +30,9 @@ test.describe("Room list panel", () => {
for (let i = 0; i < 20; i++) { for (let i = 0; i < 20; i++) {
await app.client.createRoom({ name: `room${i}` }); await app.client.createRoom({ name: `room${i}` });
} }
// focus the user menu to avoid to have hover decoration
await page.getByRole("button", { name: "User menu" }).focus();
}); });
test("should render the room list panel", { tag: "@screenshot" }, async ({ page, app, user }) => { test("should render the room list panel", { tag: "@screenshot" }, async ({ page, app, user }) => {
@@ -38,4 +41,10 @@ test.describe("Room list panel", () => {
await expect(roomListView.getByRole("gridcell", { name: "Open room room19" })).toBeVisible(); await expect(roomListView.getByRole("gridcell", { name: "Open room room19" })).toBeVisible();
await expect(roomListView).toMatchScreenshot("room-list-panel.png"); await expect(roomListView).toMatchScreenshot("room-list-panel.png");
}); });
test("should respond to small screen sizes", { tag: "@screenshot" }, async ({ page }) => {
await page.setViewportSize({ width: 575, height: 600 });
const roomListPanel = page.getByTestId("room-list-panel");
await expect(roomListPanel).toMatchScreenshot("room-list-panel-smallscreen.png");
});
}); });
@@ -7,7 +7,7 @@
import { type Page } from "@playwright/test"; import { type Page } from "@playwright/test";
import { test, expect } from "../../../element-web-test"; import { expect, test } from "../../../element-web-test";
test.describe("Room list", () => { test.describe("Room list", () => {
test.use({ test.use({
@@ -29,6 +29,9 @@ test.describe("Room list", () => {
test.beforeEach(async ({ page, app, user }) => { test.beforeEach(async ({ page, app, user }) => {
// The notification toast is displayed above the search section // The notification toast is displayed above the search section
await app.closeNotificationToast(); await app.closeNotificationToast();
// focus the user menu to avoid to have hover decoration
await page.getByRole("button", { name: "User menu" }).focus();
}); });
test.describe("Room list", () => { test.describe("Room list", () => {
@@ -43,7 +46,8 @@ test.describe("Room list", () => {
await expect(roomListView.getByRole("gridcell", { name: "Open room room29" })).toBeVisible(); await expect(roomListView.getByRole("gridcell", { name: "Open room room29" })).toBeVisible();
await expect(roomListView).toMatchScreenshot("room-list.png"); await expect(roomListView).toMatchScreenshot("room-list.png");
await roomListView.hover(); // Put focus on the room list
await roomListView.getByRole("gridcell", { name: "Open room room29" }).click();
// Scroll to the end of the room list // Scroll to the end of the room list
await page.mouse.wheel(0, 1000); await page.mouse.wheel(0, 1000);
await expect(roomListView.getByRole("gridcell", { name: "Open room room0" })).toBeVisible(); await expect(roomListView.getByRole("gridcell", { name: "Open room room0" })).toBeVisible();
@@ -85,9 +89,49 @@ test.describe("Room list", () => {
await expect(roomItem).not.toBeVisible(); await expect(roomItem).not.toBeVisible();
}); });
test("should open the notification options menu", { tag: "@screenshot" }, async ({ page, app, user }) => {
const roomListView = getRoomList(page);
const roomItem = roomListView.getByRole("gridcell", { name: "Open room room29" });
await roomItem.hover();
await expect(roomItem).toMatchScreenshot("room-list-item-hover.png");
let roomItemMenu = roomItem.getByRole("button", { name: "Notification options" });
await roomItemMenu.click();
// Default settings should be selected
await expect(page.getByRole("menuitem", { name: "Match default settings" })).toHaveAttribute(
"aria-selected",
"true",
);
await expect(page).toMatchScreenshot("room-list-item-open-notification-options.png");
// It should make the room muted
await page.getByRole("menuitem", { name: "Mute room" }).click();
// Put focus on the room list
await roomListView.getByRole("gridcell", { name: "Open room room28" }).click();
// Scroll to the end of the room list
await page.mouse.wheel(0, 1000);
// The room decoration should have the muted icon
await expect(roomItem.getByTestId("notification-decoration")).toBeVisible();
await roomItem.hover();
// On hover, the room should show the muted icon
await expect(roomItem).toMatchScreenshot("room-list-item-hover-silent.png");
roomItemMenu = roomItem.getByRole("button", { name: "Notification options" });
await roomItemMenu.click();
// The Mute room option should be selected
await expect(page.getByRole("menuitem", { name: "Mute room" })).toHaveAttribute("aria-selected", "true");
await expect(page).toMatchScreenshot("room-list-item-open-notification-options-selection.png");
});
test("should scroll to the current room", async ({ page, app, user }) => { test("should scroll to the current room", async ({ page, app, user }) => {
const roomListView = getRoomList(page); const roomListView = getRoomList(page);
await roomListView.hover(); // Put focus on the room list
await roomListView.getByRole("gridcell", { name: "Open room room29" }).click();
// Scroll to the end of the room list // Scroll to the end of the room list
await page.mouse.wheel(0, 1000); await page.mouse.wheel(0, 1000);
@@ -100,6 +144,154 @@ test.describe("Room list", () => {
await filters.getByRole("option", { name: "People" }).click(); await filters.getByRole("option", { name: "People" }).click();
await expect(roomListView.getByRole("gridcell", { name: "Open room room0" })).toBeVisible(); await expect(roomListView.getByRole("gridcell", { name: "Open room room0" })).toBeVisible();
}); });
test.describe("Shortcuts", () => {
test("should select the next room", async ({ page, app, user }) => {
const roomListView = getRoomList(page);
await roomListView.getByRole("gridcell", { name: "Open room room29" }).click();
await page.keyboard.press("Alt+ArrowDown");
await expect(page.getByRole("heading", { name: "room28", level: 1 })).toBeVisible();
});
test("should select the previous room", async ({ page, app, user }) => {
const roomListView = getRoomList(page);
await roomListView.getByRole("gridcell", { name: "Open room room28" }).click();
await page.keyboard.press("Alt+ArrowUp");
await expect(page.getByRole("heading", { name: "room29", level: 1 })).toBeVisible();
});
test("should select the last room", async ({ page, app, user }) => {
const roomListView = getRoomList(page);
await roomListView.getByRole("gridcell", { name: "Open room room29" }).click();
await page.keyboard.press("Alt+ArrowUp");
await expect(page.getByRole("heading", { name: "room0", level: 1 })).toBeVisible();
});
test("should select the next unread room", async ({ page, app, user, bot }) => {
const roomListView = getRoomList(page);
const roomId = await app.client.createRoom({ name: "1 notification" });
await app.client.inviteUser(roomId, bot.credentials.userId);
await bot.joinRoom(roomId);
await bot.sendMessage(roomId, "I am a robot. Beep.");
await roomListView.getByRole("gridcell", { name: "Open room room20" }).click();
await page.keyboard.press("Alt+Shift+ArrowDown");
await expect(page.getByRole("heading", { name: "1 notification", level: 1 })).toBeVisible();
});
});
test.describe("Keyboard navigation", () => {
test("should navigate to the room list", async ({ page, app, user }) => {
const roomListView = getRoomList(page);
const room29 = roomListView.getByRole("gridcell", { name: "Open room room29" });
const room28 = roomListView.getByRole("gridcell", { name: "Open room room28" });
// open the room
await room29.click();
// put focus back on the room list item
await room29.click();
await expect(room29).toBeFocused();
await page.keyboard.press("ArrowDown");
await expect(room28).toBeFocused();
await expect(room29).not.toBeFocused();
await page.keyboard.press("ArrowUp");
await expect(room29).toBeFocused();
await expect(room28).not.toBeFocused();
});
test("should navigate to the notification menu", async ({ page, app, user }) => {
const roomListView = getRoomList(page);
const room29 = roomListView.getByRole("gridcell", { name: "Open room room29" });
const moreButton = room29.getByRole("button", { name: "More options" });
const notificationButton = room29.getByRole("button", { name: "Notification options" });
await room29.click();
// put focus back on the room list item
await room29.click();
await page.keyboard.press("Tab");
await expect(moreButton).toBeFocused();
await page.keyboard.press("Tab");
await expect(notificationButton).toBeFocused();
// Open the menu
await notificationButton.click();
// Wait for the menu to be open
await expect(page.getByRole("menuitem", { name: "Match default settings" })).toHaveAttribute(
"aria-selected",
"true",
);
// Close the menu
await page.keyboard.press("Escape");
// Focus should be back on the room list item
await expect(room29).toBeFocused();
});
});
});
test.describe("Avatar decoration", () => {
test.use({ labsFlags: ["feature_video_rooms", "feature_new_room_list"] });
test("should be a public room", { tag: "@screenshot" }, async ({ page, app, user }) => {
// @ts-ignore Visibility enum is not accessible
await app.client.createRoom({ name: "public room", visibility: "public" });
// focus the user menu to avoid to have hover decoration
await page.getByRole("button", { name: "User menu" }).focus();
const roomListView = getRoomList(page);
const publicRoom = roomListView.getByRole("gridcell", { name: "public room" });
await expect(publicRoom).toBeVisible();
await expect(publicRoom).toMatchScreenshot("room-list-item-public.png");
});
test("should be a low priority room", { tag: "@screenshot" }, async ({ page, app, user }) => {
// @ts-ignore Visibility enum is not accessible
await app.client.createRoom({ name: "low priority room", visibility: "public" });
const roomListView = getRoomList(page);
const publicRoom = roomListView.getByRole("gridcell", { name: "low priority room" });
// Make room low priority
await publicRoom.hover();
const roomItemMenu = publicRoom.getByRole("button", { name: "More Options" });
await roomItemMenu.click();
await page.getByRole("menuitemcheckbox", { name: "Low priority" }).click();
// Should have low priority decoration
await expect(publicRoom.locator(".mx_RoomAvatarView_icon")).toHaveAccessibleName(
"This is a low priority room",
);
// focus the user menu to avoid to have hover decoration
await page.getByRole("button", { name: "User menu" }).focus();
await expect(publicRoom).toMatchScreenshot("room-list-item-low-priority.png");
});
test("should be a video room", { tag: "@screenshot" }, async ({ page, app, user }) => {
await page.getByTestId("room-list-panel").getByRole("button", { name: "Add" }).click();
await page.getByRole("menuitem", { name: "New video room" }).click();
await page.getByRole("textbox", { name: "Name" }).fill("video room");
await page.getByRole("button", { name: "Create video room" }).click();
const roomListView = getRoomList(page);
const videoRoom = roomListView.getByRole("gridcell", { name: "video room" });
// focus the user menu to avoid to have hover decoration
await page.getByRole("button", { name: "User menu" }).focus();
await expect(videoRoom).toBeVisible();
await expect(videoRoom).toMatchScreenshot("room-list-item-video.png");
});
}); });
test.describe("Notification decoration", () => { test.describe("Notification decoration", () => {
@@ -162,6 +354,27 @@ test.describe("Room list", () => {
await expect(room).toMatchScreenshot("room-list-item-mention.png"); await expect(room).toMatchScreenshot("room-list-item-mention.png");
}); });
test("should render a message preview", { tag: "@screenshot" }, async ({ page, app, user, bot }) => {
await app.settings.openUserSettings("Preferences");
await page.getByRole("switch", { name: "Show message previews" }).click();
await app.closeDialog();
const roomListView = getRoomList(page);
const roomId = await app.client.createRoom({ name: "activity" });
// focus the user menu to avoid to have hover decoration
await page.getByRole("button", { name: "User menu" }).focus();
await app.client.inviteUser(roomId, bot.credentials.userId);
await bot.joinRoom(roomId);
await bot.sendMessage(roomId, "I am a robot. Beep.");
const room = roomListView.getByRole("gridcell", { name: "activity" });
await expect(room.getByText("I am a robot. Beep.")).toBeVisible();
await expect(room).toMatchScreenshot("room-list-item-message-preview.png");
});
test("should render an activity decoration", { tag: "@screenshot" }, async ({ page, app, user, bot }) => { test("should render an activity decoration", { tag: "@screenshot" }, async ({ page, app, user, bot }) => {
const roomListView = getRoomList(page); const roomListView = getRoomList(page);
@@ -201,8 +414,8 @@ test.describe("Room list", () => {
await room.getByRole("button", { name: "More Options" }).click(); await room.getByRole("button", { name: "More Options" }).click();
await page.getByRole("menuitem", { name: "mark as unread" }).click(); await page.getByRole("menuitem", { name: "mark as unread" }).click();
// Remove hover on the room list item // focus the user menu to avoid to have hover decoration
await roomListView.hover(); await page.getByRole("button", { name: "User menu" }).focus();
await expect(room).toMatchScreenshot("room-list-item-mark-as-unread.png"); await expect(room).toMatchScreenshot("room-list-item-mark-as-unread.png");
}); });
@@ -288,6 +288,43 @@ test.describe("Login", () => {
await expect(h1).toBeVisible(); await expect(h1).toBeVisible();
}); });
}); });
test("Can reset identity to become verified", async ({ page, homeserver, request, credentials }) => {
// Log in
const res = await request.post(`${homeserver.baseUrl}/_matrix/client/v3/keys/device_signing/upload`, {
headers: { Authorization: `Bearer ${credentials.accessToken}` },
data: DEVICE_SIGNING_KEYS_BODY,
});
if (!res.ok()) {
console.log(`Uploading dummy keys failed with HTTP status ${res.status}`, await res.json());
throw new Error("Uploading dummy keys failed");
}
await page.goto("/");
await login(page, homeserver, credentials);
await expect(page.getByRole("heading", { name: "Verify this device", level: 1 })).toBeVisible();
// Start the reset process
await page.getByRole("button", { name: "Proceed with reset" }).click();
// First try cancelling and restarting
await page.getByRole("button", { name: "Cancel" }).click();
await page.getByRole("button", { name: "Proceed with reset" }).click();
// Then click outside the dialog and restart
await page.getByRole("link", { name: "Powered by Matrix" }).click({ force: true });
await page.getByRole("button", { name: "Proceed with reset" }).click();
// Finally we actually continue
await page.getByRole("button", { name: "Continue" }).click();
await page.getByPlaceholder("Password").fill(credentials.password);
await page.getByRole("button", { name: "Continue" }).click();
// We end up at the Home screen
await expect(page).toHaveURL(/\/#\/home$/, { timeout: 10000 });
await expect(page.getByRole("heading", { name: "Welcome Dave", exact: true })).toBeVisible();
});
}); });
}); });
+27 -6
View File
@@ -15,6 +15,7 @@ test.describe("Module loading", () => {
test.describe("Example Module", () => { test.describe("Example Module", () => {
test.use({ test.use({
config: { config: {
brand: "TestBrand",
modules: ["/modules/example-module.js"], modules: ["/modules/example-module.js"],
}, },
page: async ({ page }, use) => { page: async ({ page }, use) => {
@@ -25,11 +26,31 @@ test.describe("Module loading", () => {
}, },
}); });
test("should show alert", async ({ page }) => { const testCases = [
const dialogPromise = page.waitForEvent("dialog"); ["en", "TestBrand module loading successful!"],
await page.goto("/"); ["de", "TestBrand-Module erfolgreich geladen!"],
const dialog = await dialogPromise; ];
expect(dialog.message()).toBe("Testing module loading successful!");
}); for (const [lang, message] of testCases) {
test.describe(`language-${lang}`, () => {
test.use({
config: async ({ config }, use) => {
await use({
...config,
setting_defaults: {
language: lang,
},
});
},
});
test("should show alert", async ({ page }) => {
const dialogPromise = page.waitForEvent("dialog");
await page.goto("/");
const dialog = await dialogPromise;
expect(dialog.message()).toBe(message);
});
});
}
}); });
}); });
+2
View File
@@ -37,6 +37,8 @@ export async function registerAccountMas(
await page.getByRole("textbox", { name: "6-digit code" }).fill(code); await page.getByRole("textbox", { name: "6-digit code" }).fill(code);
await page.getByRole("button", { name: "Continue" }).click(); await page.getByRole("button", { name: "Continue" }).click();
await page.getByRole("textbox", { name: "Display Name" }).fill(username);
await page.getByRole("button", { name: "Continue" }).click();
await expect(page.getByText("Allow access to your account?")).toBeVisible(); await expect(page.getByText("Allow access to your account?")).toBeVisible();
await page.getByRole("button", { name: "Continue" }).click(); await page.getByRole("button", { name: "Continue" }).click();
} }
+3 -4
View File
@@ -33,7 +33,7 @@ test.describe("OIDC Native", { tag: ["@no-firefox", "@no-webkit"] }, () => {
await page.getByRole("button", { name: "Continue" }).click(); await page.getByRole("button", { name: "Continue" }).click();
const userId = `alice_${testInfo.testId}`; const userId = `alice_${testInfo.testId}`;
await registerAccountMas(page, mailpitClient, userId, "alice@email.com", "Pa$sW0rD!"); await registerAccountMas(page, mailpitClient, userId, `${userId}@email.com`, "Pa$sW0rD!");
// Eventually, we should end up at the home screen. // Eventually, we should end up at the home screen.
await expect(page).toHaveURL(/\/#\/home$/, { timeout: 10000 }); await expect(page).toHaveURL(/\/#\/home$/, { timeout: 10000 });
@@ -55,7 +55,7 @@ test.describe("OIDC Native", { tag: ["@no-firefox", "@no-webkit"] }, () => {
const newPage = await newPagePromise; const newPage = await newPagePromise;
await newPage.getByText("Devices").click(); await newPage.getByText("Devices").click();
await newPage.getByText(deviceId).click(); await newPage.getByText(deviceId).click();
await expect(newPage.getByText("Element")).toBeVisible(); await expect(newPage.getByText("Element", { exact: true })).toBeVisible();
await expect(newPage.getByText("http://localhost:8080/")).toBeVisible(); await expect(newPage.getByText("http://localhost:8080/")).toBeVisible();
await expect(newPage).toHaveURL(/\/oauth2_session/); await expect(newPage).toHaveURL(/\/oauth2_session/);
await newPage.close(); await newPage.close();
@@ -83,12 +83,11 @@ test.describe("OIDC Native", { tag: ["@no-firefox", "@no-webkit"] }, () => {
await page.getByRole("button", { name: "Continue" }).click(); await page.getByRole("button", { name: "Continue" }).click();
const userId = `alice_${testInfo.testId}`; const userId = `alice_${testInfo.testId}`;
await registerAccountMas(page, mailpitClient, userId, "alice@email.com", "Pa$sW0rD!"); await registerAccountMas(page, mailpitClient, userId, `${userId}@email.com`, "Pa$sW0rD!");
await expect(page.getByText("Welcome")).toBeVisible(); await expect(page.getByText("Welcome")).toBeVisible();
await page.goto("about:blank"); await page.goto("about:blank");
// @ts-expect-error
const result = await mas.manage("kill-sessions", userId); const result = await mas.manage("kill-sessions", userId);
expect(result.output).toContain("Ended 1 active OAuth 2.0 session"); expect(result.output).toContain("Ended 1 active OAuth 2.0 session");
@@ -43,6 +43,7 @@ test.describe("Pills", () => {
// go back to the message room and try to click on the pill text, as a user would // go back to the message room and try to click on the pill text, as a user would
await app.viewRoomByName(messageRoom); await app.viewRoomByName(messageRoom);
await expect(page).toHaveURL(new RegExp(`/#/room/${messageRoomId}`));
const pillText = page.locator(".mx_EventTile_body .mx_Pill .mx_Pill_text"); const pillText = page.locator(".mx_EventTile_body .mx_Pill .mx_Pill_text");
await expect(pillText).toHaveCSS("pointer-events", "none"); await expect(pillText).toHaveCSS("pointer-events", "none");
await pillText.click({ force: true }); // force is to ensure we bypass pointer-events await pillText.click({ force: true }); // force is to ensure we bypass pointer-events
@@ -15,20 +15,34 @@ test.describe("Release announcement", () => {
feature_release_announcement: true, feature_release_announcement: true,
}, },
}, },
labsFlags: ["threadsActivityCentre"], room: async ({ app, user }, use) => {
const roomId = await app.client.createRoom({
name: "Test room",
});
await app.viewRoomById(roomId);
await use({ roomId });
},
}); });
test("should display the release announcement process", { tag: "@screenshot" }, async ({ page, app, util }) => { test(
// The TAC release announcement should be displayed "should display the pinned messages release announcement",
await util.assertReleaseAnnouncementIsVisible("Threads Activity Centre"); { tag: "@screenshot" },
// Hide the release announcement async ({ page, app, room, util }) => {
await util.markReleaseAnnouncementAsRead("Threads Activity Centre"); await app.toggleRoomInfoPanel();
await util.assertReleaseAnnouncementIsNotVisible("Threads Activity Centre");
await page.reload(); const name = "All new pinned messages";
// Wait for EW to load
await expect(page.getByRole("navigation", { name: "Spaces" })).toBeVisible(); // The release announcement should be displayed
// Check that once the release announcement has been marked as viewed, it does not appear again await util.assertReleaseAnnouncementIsVisible(name);
await util.assertReleaseAnnouncementIsNotVisible("Threads Activity Centre"); // Hide the release announcement
}); await util.markReleaseAnnouncementAsRead(name);
await util.assertReleaseAnnouncementIsNotVisible(name);
await page.reload();
await app.toggleRoomInfoPanel();
await expect(page.getByRole("menuitem", { name: "Pinned messages" })).toBeVisible();
// Check that once the release announcement has been marked as viewed, it does not appear again
await util.assertReleaseAnnouncementIsNotVisible(name);
},
);
}); });
+59 -5
View File
@@ -11,6 +11,7 @@ import { type Locator, type Page } from "@playwright/test";
import { test, expect } from "../../element-web-test"; import { test, expect } from "../../element-web-test";
import { checkRoomSummaryCard, viewRoomSummaryByName } from "./utils"; import { checkRoomSummaryCard, viewRoomSummaryByName } from "./utils";
import { isDendrite } from "../../plugins/homeserver/dendrite"; import { isDendrite } from "../../plugins/homeserver/dendrite";
import { Bot } from "../../pages/bot";
const ROOM_NAME = "Test room"; const ROOM_NAME = "Test room";
const ROOM_NAME_LONG = const ROOM_NAME_LONG =
@@ -21,20 +22,23 @@ const ROOM_NAME_LONG =
"officia deserunt mollit anim id est laborum."; "officia deserunt mollit anim id est laborum.";
const SPACE_NAME = "Test space"; const SPACE_NAME = "Test space";
const NAME = "Alice"; const NAME = "Alice";
const LONG_NAME = "Bob long long long long long long long long long long long long long long long name";
const ROOM_ADDRESS_LONG = const ROOM_ADDRESS_LONG =
"loremIpsumDolorSitAmetConsecteturAdipisicingElitSedDoEiusmodTemporIncididuntUtLaboreEtDoloreMagnaAliqua"; "loremIpsumDolorSitAmetConsecteturAdipisicingElitSedDoEiusmodTemporIncididuntUtLaboreEtDoloreMagnaAliqua";
function getMemberTileByName(page: Page, name: string): Locator { function getMemberTileByName(page: Page, name: string): Locator {
return page.locator(`.mx_MemberTileView, [title="${name}"]`); return page.locator(".mx_MemberListView .mx_MemberTileView_name").filter({ hasText: name });
} }
test.describe("RightPanel", () => { test.describe("RightPanel", () => {
let testRoomId: string;
test.use({ test.use({
displayName: NAME, displayName: NAME,
}); });
test.beforeEach(async ({ app, user }) => { test.beforeEach(async ({ app, user }) => {
await app.client.createRoom({ name: ROOM_NAME }); testRoomId = await app.client.createRoom({ name: ROOM_NAME });
await app.client.createSpace({ name: SPACE_NAME }); await app.client.createSpace({ name: SPACE_NAME });
}); });
@@ -77,10 +81,12 @@ test.describe("RightPanel", () => {
await expect(page.locator(".mx_RightPanel")).toMatchScreenshot("with-leave-room.png"); await expect(page.locator(".mx_RightPanel")).toMatchScreenshot("with-leave-room.png");
}); });
test("should handle clicking add widgets", async ({ page, app }) => { test("should handle clicking add widgets", { tag: "@screenshot" }, async ({ page, app }) => {
await viewRoomSummaryByName(page, app, ROOM_NAME); await viewRoomSummaryByName(page, app, ROOM_NAME);
await page.getByRole("menuitem", { name: "Extensions" }).click(); await page.getByRole("menuitem", { name: "Extensions" }).click();
await expect(page.getByTestId("right-panel")).toMatchScreenshot("with-extensions.png");
await page.getByRole("button", { name: "Add extensions" }).click(); await page.getByRole("button", { name: "Add extensions" }).click();
await expect(page.locator(".mx_IntegrationManager")).toBeVisible(); await expect(page.locator(".mx_IntegrationManager")).toBeVisible();
}); });
@@ -134,15 +140,63 @@ test.describe("RightPanel", () => {
await page.getByLabel("Room info").nth(1).click(); await page.getByLabel("Room info").nth(1).click();
await checkRoomSummaryCard(page, ROOM_NAME); await checkRoomSummaryCard(page, ROOM_NAME);
}); });
test(
"should handle viewing long room member name",
{ tag: "@screenshot" },
async ({ page, homeserver, app }) => {
const bobLongName = new Bot(page, homeserver, { displayName: LONG_NAME });
await bobLongName.prepareClient();
await app.client.inviteUser(testRoomId, bobLongName.credentials.userId);
await bobLongName.joinRoom(testRoomId);
await viewRoomSummaryByName(page, app, ROOM_NAME);
await page.locator(".mx_RightPanel").getByRole("menuitem", { name: "People" }).click();
await expect(page.locator(".mx_MemberListView")).toBeVisible();
await getMemberTileByName(page, LONG_NAME).click();
await expect(page.locator(".mx_UserInfo")).toBeVisible();
await expect(page.locator(".mx_UserInfo_profile").getByText(LONG_NAME)).toBeVisible();
await expect(page.locator(".mx_UserInfo")).toMatchScreenshot("with-long-name.png", {
mask: [page.locator(".mx_UserInfo_profile_mxid")],
css: `
/* Use monospace font for consistent mask width */
.mx_UserInfo_profile_mxid {
font-family: Inconsolata !important;
}
`,
});
},
);
test.describe("room reporting", () => { test.describe("room reporting", () => {
test.skip(isDendrite, "Dendrite does not implement room reporting"); test.skip(isDendrite, "Dendrite does not implement room reporting");
test("should handle reporting a room", async ({ page, app }) => { test("should handle reporting a room", { tag: "@screenshot" }, async ({ page, app }) => {
await viewRoomSummaryByName(page, app, ROOM_NAME); await viewRoomSummaryByName(page, app, ROOM_NAME);
await page.getByRole("menuitem", { name: "Report room" }).click(); await page.getByRole("menuitem", { name: "Report room" }).click();
const dialog = await page.getByRole("dialog", { name: "Report Room" }); const dialog = await page.getByRole("dialog", { name: "Report Room" });
await dialog.getByLabel("reason").fill("This room should be reported"); await dialog.getByLabel("reason").fill("This room should be reported");
await expect(dialog).toMatchScreenshot("room-report-dialog.png");
await dialog.getByRole("button", { name: "Send report" }).click(); await dialog.getByRole("button", { name: "Send report" }).click();
await expect(page.getByText("Your report was sent.")).toBeVisible();
// Dialog should have gone
await expect(page.locator(".mx_Dialog")).toHaveCount(0);
});
test("should handle reporting a room and leaving the room", async ({ page, app }) => {
await viewRoomSummaryByName(page, app, ROOM_NAME);
await page.getByRole("menuitem", { name: "Report room" }).click();
const dialog = await page.getByRole("dialog", { name: "Report room" });
await dialog.getByRole("switch", { name: "Leave room" }).click();
await dialog.getByLabel("reason").fill("This room should be reported");
await dialog.getByRole("button", { name: "Send report" }).click();
await page.getByRole("dialog", { name: "Leave room" }).getByRole("button", { name: "Leave" }).click();
// Dialog should have gone
await expect(page.locator(".mx_Dialog")).toHaveCount(0);
}); });
}); });
}); });
+74
View File
@@ -0,0 +1,74 @@
/*
Copyright 2025 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import { test, expect } from "../../element-web-test";
test.describe("Invites", () => {
test.use({
displayName: "Alice",
botCreateOpts: {
displayName: "Bob",
},
});
test("should render an invite view", { tag: "@screenshot" }, async ({ page, homeserver, user, bot, app }) => {
const roomId = await bot.createRoom({ is_direct: true });
await bot.inviteUser(roomId, user.userId);
await app.viewRoomByName("Bob");
await expect(page.locator(".mx_RoomView")).toMatchScreenshot("Invites_room_view.png", {
// Hide the mxid, which is not stable.
css: `
.mx_RoomPreviewBar_inviter_mxid {
display: none !important;
}
`,
});
});
test("should be able to decline an invite", async ({ page, homeserver, user, bot, app }) => {
const roomId = await bot.createRoom({ is_direct: true });
await bot.inviteUser(roomId, user.userId);
await app.viewRoomByName("Bob");
await page.getByRole("button", { name: "Decline", exact: true }).click();
await expect(page.getByRole("heading", { name: "Welcome Alice", exact: true })).toBeVisible();
await expect(
page.getByRole("tree", { name: "Rooms" }).getByRole("treeitem", { name: "Bob", exact: true }),
).not.toBeVisible();
});
test(
"should be able to decline an invite, report the room and ignore the user",
{ tag: "@screenshot" },
async ({ page, homeserver, user, bot, app }) => {
const roomId = await bot.createRoom({ is_direct: true });
await bot.inviteUser(roomId, user.userId);
await app.viewRoomByName("Bob");
await page.getByRole("button", { name: "Decline and block" }).click();
await page.getByLabel("Ignore user").click();
await page.getByLabel("Report room").click();
await page.getByLabel("Reason").fill("Do not want the room");
const roomReported = page.waitForRequest(
(req) =>
req.url().endsWith(`/_matrix/client/v3/rooms/${encodeURIComponent(roomId)}/report`) &&
req.method() === "POST",
);
await expect(page.getByRole("dialog", { name: "Decline invitation" })).toMatchScreenshot(
"Invites_reject_dialog.png",
);
await page.getByRole("button", { name: "Decline invite" }).click();
// Check room was reported.
await roomReported;
// Check user is ignored.
await app.settings.openUserSettings("Security & Privacy");
const ignoredUsersList = page.getByRole("list", { name: "Ignored users" });
await ignoredUsersList.scrollIntoViewIfNeeded();
await expect(ignoredUsersList.getByRole("listitem", { name: bot.credentials.userId })).toBeVisible();
},
);
});
@@ -19,126 +19,162 @@ import {
test.describe("Encryption tab", () => { test.describe("Encryption tab", () => {
test.use({ displayName: "Alice" }); test.use({ displayName: "Alice" });
let recoveryKey: GeneratedSecretStorageKey; test.describe("when encryption is set up", () => {
let expectedBackupVersion: string; let recoveryKey: GeneratedSecretStorageKey;
let expectedBackupVersion: string;
test.beforeEach(async ({ page, homeserver, credentials }) => { test.beforeEach(async ({ page, homeserver, credentials }) => {
// The bot bootstraps cross-signing, creates a key backup and sets up a recovery key // The bot bootstraps cross-signing, creates a key backup and sets up a recovery key
const res = await createBot(page, homeserver, credentials); const res = await createBot(page, homeserver, credentials);
recoveryKey = res.recoveryKey; recoveryKey = res.recoveryKey;
expectedBackupVersion = res.expectedBackupVersion; expectedBackupVersion = res.expectedBackupVersion;
}); });
test( test(
"should show a 'Verify this device' button if the device is unverified", "should show a 'Verify this device' button if the device is unverified",
{ tag: "@screenshot" }, { tag: "@screenshot" },
async ({ page, app, util }) => { async ({ page, app, util }) => {
const dialog = await util.openEncryptionTab(); const dialog = await util.openEncryptionTab();
const content = util.getEncryptionTabContent(); 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 // 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" }); const verifyButton = dialog.getByRole("button", { name: "Verify this device" });
await expect(verifyButton).toBeVisible(); await expect(verifyButton).toBeVisible();
await expect(content).toMatchScreenshot("verify-device-encryption-tab.png"); await expect(content).toMatchScreenshot("verify-device-encryption-tab.png");
await verifyButton.click(); await verifyButton.click();
await util.verifyDevice(recoveryKey); await util.verifyDevice(recoveryKey);
await expect(content).toMatchScreenshot("default-tab.png", { await expect(content).toMatchScreenshot("default-tab.png", {
mask: [content.getByTestId("deviceId"), content.getByTestId("sessionKey")], mask: [content.getByTestId("deviceId"), content.getByTestId("sessionKey")],
}); });
// Check that our device is now cross-signed // Check that our device is now cross-signed
await checkDeviceIsCrossSigned(app); await checkDeviceIsCrossSigned(app);
// Check that the current device is connected to key backup // 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 // The backup decryption key should be in cache also, as we got it directly from the 4S
await checkDeviceIsConnectedKeyBackup(app, expectedBackupVersion, true); 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. // 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. // 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. // We simulate this case by deleting the cached secrets in the indexedDB.
test( test(
"should prompt to enter the recovery key when the secrets are not cached locally", "should prompt to enter the recovery key when the secrets are not cached locally",
{ tag: "@screenshot" }, { tag: "@screenshot" },
async ({ page, app, util }) => { 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); await verifySession(app, recoveryKey.encodedPrivateKey);
// We need to delete the cached secrets // We need to delete the cached secrets
await deleteCachedSecrets(page); 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(); await util.openEncryptionTab();
// We ask the user to enter the recovery key
const dialog = util.getEncryptionTabContent(); const dialog = util.getEncryptionTabContent();
const enterKeyButton = dialog.getByRole("button", { name: "Enter recovery key" }); await dialog.getByRole("button", { name: "Forgot recovery key?" }).click();
await expect(enterKeyButton).toBeVisible();
await expect(dialog).toMatchScreenshot("out-of-sync-recovery.png");
await enterKeyButton.click();
// Fill the recovery key // The user is prompted to reset their identity
await util.enterRecoveryKey(recoveryKey); await expect(
await expect(dialog).toMatchScreenshot("default-tab.png", { dialog.getByText("Forgot your recovery key? You’ll need to reset your identity."),
mask: [dialog.getByTestId("deviceId"), dialog.getByTestId("sessionKey")], ).toBeVisible();
}); });
// Check that our device is now cross-signed test("should warn before turning off key storage", { tag: "@screenshot" }, async ({ page, app, util }) => {
await checkDeviceIsCrossSigned(app); await verifySession(app, recoveryKey.encodedPrivateKey);
await util.openEncryptionTab();
// Check that the current device is connected to key backup await page.getByRole("checkbox", { name: "Allow key storage" }).click();
// 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 ({ await expect(
page, page.getByRole("heading", { name: "Are you sure you want to turn off key storage and delete it?" }),
app, ).toBeVisible();
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 expect(util.getEncryptionTabContent()).toMatchScreenshot("delete-key-storage-confirm.png");
await util.openEncryptionTab();
const dialog = util.getEncryptionTabContent();
await dialog.getByRole("button", { name: "Forgot recovery key?" }).click();
// The user is prompted to reset their identity const deleteRequestPromises = [
await expect(dialog.getByText("Forgot your recovery key? You’ll need to reset your identity.")).toBeVisible(); page.waitForRequest((req) => req.url().endsWith("/account_data/m.cross_signing.master")),
page.waitForRequest((req) => req.url().endsWith("/account_data/m.cross_signing.self_signing")),
page.waitForRequest((req) => req.url().endsWith("/account_data/m.cross_signing.user_signing")),
page.waitForRequest((req) => req.url().endsWith("/account_data/m.megolm_backup.v1")),
page.waitForRequest((req) => req.url().endsWith("/account_data/m.secret_storage.default_key")),
page.waitForRequest((req) => req.url().includes("/account_data/m.secret_storage.key.")),
];
await page.getByRole("button", { name: "Delete key storage" }).click();
await expect(page.getByRole("checkbox", { name: "Allow key storage" })).not.toBeChecked();
for (const prom of deleteRequestPromises) {
const request = await prom;
expect(request.method()).toBe("PUT");
expect(request.postData()).toBe(JSON.stringify({}));
}
});
}); });
test("should warn before turning off key storage", { tag: "@screenshot" }, async ({ page, app, util }) => { test.describe("when encryption is not set up", () => {
await verifySession(app, recoveryKey.encodedPrivateKey); test("'Verify this device' allows us to become verified", async ({
await util.openEncryptionTab(); page,
user,
credentials,
app,
}, workerInfo) => {
const settings = await app.settings.openUserSettings("Encryption");
await page.getByRole("checkbox", { name: "Allow key storage" }).click(); // Initially, our device is not verified
await expect(settings.getByRole("heading", { name: "Device not verified" })).toBeVisible();
await expect( // We will reset our identity
page.getByRole("heading", { name: "Are you sure you want to turn off key storage and delete it?" }), await settings.getByRole("button", { name: "Verify this device" }).click();
).toBeVisible(); await page.getByRole("button", { name: "Proceed with reset" }).click();
await expect(util.getEncryptionTabContent()).toMatchScreenshot("delete-key-storage-confirm.png"); // First try cancelling and restarting
await page.getByRole("button", { name: "Cancel" }).click();
await page.getByRole("button", { name: "Proceed with reset" }).click();
const deleteRequestPromises = [ // Then click outside the dialog and restart
page.waitForRequest((req) => req.url().endsWith("/account_data/m.cross_signing.master")), await page.locator("li").filter({ hasText: "Encryption" }).click({ force: true });
page.waitForRequest((req) => req.url().endsWith("/account_data/m.cross_signing.self_signing")), await page.getByRole("button", { name: "Proceed with reset" }).click();
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(); // Finally we actually continue
await page.getByRole("button", { name: "Continue" }).click();
await expect(page.getByRole("checkbox", { name: "Allow key storage" })).not.toBeChecked(); // Now we are verified, so we see the Key storage toggle
await expect(settings.getByRole("heading", { name: "Key storage" })).toBeVisible();
for (const prom of deleteRequestPromises) { });
const request = await prom;
expect(request.method()).toBe("PUT");
expect(request.postData()).toBe(JSON.stringify({}));
}
}); });
}); });
@@ -21,10 +21,12 @@ test.describe("Preferences user settings tab", () => {
const locator = await app.settings.openUserSettings("Preferences"); const locator = await app.settings.openUserSettings("Preferences");
await use(locator); await use(locator);
}, },
// display message preview settings
labsFlags: ["feature_new_room_list"],
}); });
test("should be rendered properly", { tag: "@screenshot" }, async ({ app, page, user }) => { test("should be rendered properly", { tag: "@screenshot" }, async ({ app, page, user }) => {
await page.setViewportSize({ width: 1024, height: 3300 }); await page.setViewportSize({ width: 1024, height: 4000 });
const tab = await app.settings.openUserSettings("Preferences"); const tab = await app.settings.openUserSettings("Preferences");
// Assert that the top heading is rendered // Assert that the top heading is rendered
await expect(tab.getByRole("heading", { name: "Preferences" })).toBeVisible(); await expect(tab.getByRole("heading", { name: "Preferences" })).toBeVisible();
@@ -37,6 +37,15 @@ test.describe("Roles & Permissions room settings tab", () => {
// Change the role of Alice to Moderator (50) // Change the role of Alice to Moderator (50)
await combobox.selectOption("Moderator"); await combobox.selectOption("Moderator");
await expect(combobox).toHaveValue("50"); await expect(combobox).toHaveValue("50");
// Should display a modal to warn that we are demoting the only admin user
const modal = await page.locator(".mx_Dialog", {
hasText: "Warning",
});
await expect(modal).toBeVisible();
// Click on the continue button in the modal
await modal.getByRole("button", { name: "Continue" }).click();
const respPromise = page.waitForRequest("**/state/**"); const respPromise = page.waitForRequest("**/state/**");
await applyButton.click(); await applyButton.click();
await respPromise; await respPromise;
@@ -0,0 +1,34 @@
/*
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
import { test, expect } from "../../element-web-test";
test.describe("share from URL", () => {
test.use({
displayName: "Alice",
room: async ({ app }, use) => {
const roomId = await app.client.createRoom({ name: "A test room" });
await use({ roomId });
},
});
test("should share message when users navigates to share URL", async ({ page, user, room, app }) => {
await page.goto("/#/share?msg=Hello+world");
// The forward message dialog doesn't update as new infomation arrives via sync, which means sometimes
// this is just says, "Empty room". For the same reason, we can't reliably write a test for loading the
// app straight away with a /#/share url as the room doesn't appear until the client syncs.]
// Ideally we should fix the forward dialog to update and eliminate races, until then, there is only one
// room so we click the first button.
await page.getByRole("listitem" /*, { name: "A test room" }*/).getByRole("button", { name: "Send" }).click();
await page.keyboard.press("Escape");
await app.viewRoomByName("A test room");
const lastMessage = page.locator(".mx_RoomView_MessageList .mx_EventTile_last");
await expect(lastMessage).toBeVisible();
const lastMessageText = await lastMessage.locator(".mx_EventTile_body").innerText();
await expect(lastMessageText).toBe("Hello world");
});
});
@@ -255,8 +255,8 @@ test.describe("Sliding Sync", () => {
// Select the room to reject // Select the room to reject
await page.getByRole("treeitem", { name: "Room to Reject" }).click(); await page.getByRole("treeitem", { name: "Room to Reject" }).click();
// Reject the invite // Decline the invite
await page.locator(".mx_RoomView").getByRole("button", { name: "Reject", exact: true }).click(); await page.locator(".mx_RoomView").getByRole("button", { name: "Decline", exact: true }).click();
await expect( await expect(
page.getByRole("group", { name: "Invites" }).locator(".mx_RoomSublist_tiles").getByRole("treeitem"), page.getByRole("group", { name: "Invites" }).locator(".mx_RoomSublist_tiles").getByRole("treeitem"),
@@ -19,7 +19,6 @@ test.describe("Threads Activity Centre", { tag: "@no-firefox" }, () => {
test.use({ test.use({
displayName: "Alice", displayName: "Alice",
botCreateOpts: { displayName: "Other User" }, botCreateOpts: { displayName: "Other User" },
labsFlags: ["threadsActivityCentre"],
}); });
test( test(
@@ -0,0 +1,139 @@
/*
Copyright 2025 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import * as fs from "node:fs";
import { type EventType, type MsgType, type RoomJoinRulesEventContent } from "matrix-js-sdk/src/types";
import { test, expect } from "../../element-web-test";
const MEDIA_FILE = fs.readFileSync("playwright/sample-files/riot.png");
test.describe("Media preview settings", () => {
test.use({
displayName: "Alan",
botCreateOpts: {
displayName: "Bob",
},
room: async ({ app, page, homeserver, bot, user }, use) => {
const mxc = (await bot.uploadContent(MEDIA_FILE, { name: "image.png", type: "image/png" })).content_uri;
const roomId = await bot.createRoom({
name: "Test room",
invite: [user.userId],
initial_state: [{ type: "m.room.avatar", content: { url: mxc }, state_key: "" }],
});
await bot.sendEvent(roomId, null, "m.room.message" as EventType, {
msgtype: "m.image" as MsgType,
body: "image.png",
url: mxc,
});
await use({ roomId });
},
});
test("should be able to hide avatars of inviters", { tag: "@screenshot" }, async ({ page, app, room, user }) => {
let settings = await app.settings.openUserSettings("Preferences");
await settings.getByLabel("Hide avatars of room and inviter").click();
await app.closeDialog();
await app.viewRoomById(room.roomId);
await expect(
page.getByRole("complementary").filter({ hasText: "Do you want to join Test room" }),
).toMatchScreenshot("invite-no-avatar.png", {
// Hide the mxid, which is not stable.
css: `
.mx_RoomPreviewBar_inviter_mxid {
display: none !important;
}
`,
});
await expect(
page.getByRole("tree", { name: "Rooms" }).getByRole("treeitem", { name: "Test room" }),
).toMatchScreenshot("invite-room-tree-no-avatar.png");
// And then go back to being visible
settings = await app.settings.openUserSettings("Preferences");
await settings.getByLabel("Hide avatars of room and inviter").click();
await app.closeDialog();
await page.goto("#/home");
await app.viewRoomById(room.roomId);
await expect(
page.getByRole("complementary").filter({ hasText: "Do you want to join Test room" }),
).toMatchScreenshot("invite-with-avatar.png", {
// Hide the mxid, which is not stable.
css: `
.mx_RoomPreviewBar_inviter_mxid {
display: none !important;
}
`,
});
await expect(
page.getByRole("tree", { name: "Rooms" }).getByRole("treeitem", { name: "Test room" }),
).toMatchScreenshot("invite-room-tree-with-avatar.png");
});
test("should be able to hide media in rooms globally", async ({ page, app, room, user }) => {
const settings = await app.settings.openUserSettings("Preferences");
await settings.getByLabel("Show media in timeline").getByRole("radio", { name: "Always hide" }).click();
await app.closeDialog();
await app.viewRoomById(room.roomId);
await page.getByRole("button", { name: "Accept" }).click();
await expect(page.getByText("Show image")).toBeVisible();
});
test("should be able to hide media in non-private rooms globally", async ({ page, app, room, user, bot }) => {
await bot.sendStateEvent(room.roomId, "m.room.join_rules", {
join_rule: "public",
});
const settings = await app.settings.openUserSettings("Preferences");
await settings.getByLabel("Show media in timeline").getByLabel("In private rooms").click();
await app.closeDialog();
await app.viewRoomById(room.roomId);
await page.getByRole("button", { name: "Accept" }).click();
await expect(page.getByText("Show image")).toBeVisible();
for (const joinRule of ["invite", "knock", "restricted"] as RoomJoinRulesEventContent["join_rule"][]) {
await bot.sendStateEvent(room.roomId, "m.room.join_rules", {
join_rule: joinRule,
} satisfies RoomJoinRulesEventContent);
await expect(page.getByText("Show image")).not.toBeVisible();
}
});
test("should be able to show media in rooms globally", async ({ page, app, room, user }) => {
const settings = await app.settings.openUserSettings("Preferences");
await settings.getByLabel("Show media in timeline").getByRole("radio", { name: "Always show" }).click();
await app.closeDialog();
await app.viewRoomById(room.roomId);
await page.getByRole("button", { name: "Accept" }).click();
await expect(page.getByText("Show image")).not.toBeVisible();
});
test("should be able to hide media in an individual room", async ({ page, app, room, user }) => {
const settings = await app.settings.openUserSettings("Preferences");
await settings.getByLabel("Show media in timeline").getByRole("radio", { name: "Always show" }).click();
await app.closeDialog();
await app.viewRoomById(room.roomId);
await page.getByRole("button", { name: "Accept" }).click();
const roomSettings = await app.settings.openRoomSettings("General");
await roomSettings.getByLabel("Show media in timeline").getByRole("radio", { name: "Always hide" }).click();
await app.closeDialog();
await expect(page.getByText("Show image")).toBeVisible();
});
test("should be able to show media in an individual room", async ({ page, app, room, user }) => {
const settings = await app.settings.openUserSettings("Preferences");
await settings.getByLabel("Show media in timeline").getByRole("radio", { name: "Always hide" }).click();
await app.closeDialog();
await app.viewRoomById(room.roomId);
await page.getByRole("button", { name: "Accept" }).click();
const roomSettings = await app.settings.openRoomSettings("General");
await roomSettings.getByLabel("Show media in timeline").getByRole("radio", { name: "Always show" }).click();
await app.closeDialog();
await expect(page.getByText("Show image")).not.toBeVisible();
});
});
+10 -2
View File
@@ -6,11 +6,19 @@ Please see LICENSE files in the repository root for full details.
*/ */
export default class ExampleModule { export default class ExampleModule {
static moduleApiVersion = "^0.1.0"; static moduleApiVersion = "^1.0.0";
constructor(api) { constructor(api) {
this.api = api; this.api = api;
this.api.i18n.register({
key: {
en: "%(brand)s module loading successful!",
de: "%(brand)s-Module erfolgreich geladen!",
},
});
} }
async load() { async load() {
alert("Testing module loading successful!"); const brand = this.api.config.get("brand");
alert(this.api.i18n.translate("key", { brand }));
} }
} }
Binary file not shown.

Before

Width:  |  Height:  |  Size: 56 KiB

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 52 KiB

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 59 KiB

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 50 KiB

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 35 KiB

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.2 KiB

After

Width:  |  Height:  |  Size: 9.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.5 KiB

After

Width:  |  Height:  |  Size: 7.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.6 KiB

After

Width:  |  Height:  |  Size: 7.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.9 KiB

After

Width:  |  Height:  |  Size: 7.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.7 KiB

After

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 KiB

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 9.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.1 KiB

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 31 KiB

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.3 KiB

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.3 KiB

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.9 KiB

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.2 KiB

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.2 KiB

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.3 KiB

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 80 KiB

After

Width:  |  Height:  |  Size: 80 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.3 KiB

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

Some files were not shown because too many files have changed in this diff Show More