diff --git a/.eslintrc.js b/.eslintrc.js
index 4bec4e83203..3eefd222062 100644
--- a/.eslintrc.js
+++ b/.eslintrc.js
@@ -34,6 +34,14 @@ module.exports = {
["*.mxcUrlToHttp", "*.getHttpUriForMxc"],
"Use Media helper instead to centralise access for customisation.",
),
+ ...buildRestrictedPropertiesOptions(["window.setImmediate"], "Use setTimeout instead."),
+ ],
+ "no-restricted-globals": [
+ "error",
+ {
+ name: "setImmediate",
+ message: "Use setTimeout instead.",
+ },
],
"import/no-duplicates": ["error"],
@@ -70,6 +78,11 @@ module.exports = {
name: "matrix-react-sdk/",
message: "Please use matrix-react-sdk/src/index instead",
},
+ {
+ name: "emojibase-regex",
+ message:
+ "This regex doesn't actually test for emoji. See the docs at https://emojibase.dev/docs/regex/ and prefer our own EMOJI_REGEX from HtmlUtils.",
+ },
],
patterns: [
{
@@ -107,13 +120,9 @@ module.exports = {
"!matrix-js-sdk/src/extensible_events_v1/InvalidEventError",
"!matrix-js-sdk/src/crypto",
"!matrix-js-sdk/src/crypto/aes",
- "!matrix-js-sdk/src/crypto/olmlib",
- "!matrix-js-sdk/src/crypto/crypto",
"!matrix-js-sdk/src/crypto/keybackup",
- "!matrix-js-sdk/src/crypto/RoomList",
"!matrix-js-sdk/src/crypto/deviceinfo",
"!matrix-js-sdk/src/crypto/key_passphrase",
- "!matrix-js-sdk/src/crypto/CrossSigning",
"!matrix-js-sdk/src/crypto/recoverykey",
"!matrix-js-sdk/src/crypto/dehydration",
"!matrix-js-sdk/src/oidc",
@@ -136,6 +145,11 @@ module.exports = {
],
message: "Please use matrix-js-sdk/src/matrix instead",
},
+ {
+ group: ["emojibase-regex/emoji*"],
+ message:
+ "This regex doesn't actually test for emoji. See the docs at https://emojibase.dev/docs/regex/ and prefer our own EMOJI_REGEX from HtmlUtils.",
+ },
],
},
],
diff --git a/.github/workflows/end-to-end-tests.yaml b/.github/workflows/end-to-end-tests.yaml
index b663948254b..97d9692a38b 100644
--- a/.github/workflows/end-to-end-tests.yaml
+++ b/.github/workflows/end-to-end-tests.yaml
@@ -56,6 +56,7 @@ jobs:
- uses: actions/setup-node@v4
with:
cache: "yarn"
+ node-version: "lts/*"
- name: Fetch layered build
id: layered_build
@@ -103,7 +104,7 @@ jobs:
fail-fast: false
matrix:
# Run multiple instances in parallel to speed up the tests
- runner: [1, 2, 3, 4, 5, 6, 7, 8]
+ runner: [1, 2, 3, 4, 5, 6]
steps:
- uses: actions/checkout@v4
with:
@@ -121,6 +122,7 @@ jobs:
with:
cache: "yarn"
cache-dependency-path: matrix-react-sdk/yarn.lock
+ node-version: "lts/*"
- name: Install dependencies
working-directory: matrix-react-sdk
@@ -145,10 +147,8 @@ jobs:
run: yarn playwright install --with-deps
- name: Run Playwright tests
- uses: coactions/setup-xvfb@6b00cf1889f4e1d5a48635647013c0508128ee1a
- with:
- run: yarn playwright test --shard ${{ matrix.runner }}/${{ strategy.job-total }}
- working-directory: matrix-react-sdk
+ run: yarn playwright test --shard ${{ matrix.runner }}/${{ strategy.job-total }}
+ working-directory: matrix-react-sdk
- name: Upload blob report to GitHub Actions Artifacts
if: always()
@@ -174,6 +174,7 @@ jobs:
if: inputs.skip != true
with:
cache: "yarn"
+ node-version: "lts/*"
- name: Install dependencies
if: inputs.skip != true
@@ -189,13 +190,14 @@ jobs:
- name: Merge into HTML Report
if: inputs.skip != true
- run: yarn playwright merge-reports --reporter=html,./playwright/flaky-reporter.ts ./all-blob-reports
+ run: yarn playwright merge-reports --reporter=html,./playwright/flaky-reporter.ts,./playwright/stale-screenshot-reporter.ts ./all-blob-reports
env:
# Only pass creds to the flaky-reporter on main branch runs
- GITHUB_TOKEN: ${{ github.event.workflow_run.head_branch == 'develop' && secrets.ELEMENT_BOT_TOKEN || '' }}
+ GITHUB_TOKEN: ${{ github.ref_name == 'develop' && secrets.ELEMENT_BOT_TOKEN || '' }}
+ # Upload the HTML report even if one of our reporters fails, this can happen when stale screenshots are detected
- name: Upload HTML report
- if: inputs.skip != true
+ if: always() && inputs.skip != true
uses: actions/upload-artifact@v4
with:
name: html-report
diff --git a/.github/workflows/playwright-image-updates.yaml b/.github/workflows/playwright-image-updates.yaml
index 15bea28e0f9..a160b77bcf8 100644
--- a/.github/workflows/playwright-image-updates.yaml
+++ b/.github/workflows/playwright-image-updates.yaml
@@ -7,7 +7,7 @@ jobs:
update:
runs-on: ubuntu-latest
steps:
- - uses: actions/checkout@v3
+ - uses: actions/checkout@v4
- name: Update matrixdotorg/synapse image
run: |
@@ -20,7 +20,7 @@ jobs:
- name: Create Pull Request
id: cpr
- uses: peter-evans/create-pull-request@153407881ec5c347639a548ade7d8ad1d6740e38 # v5
+ uses: peter-evans/create-pull-request@c5a7806660adbe173f04e3e038b0ccdcd758773c # v6
with:
token: ${{ secrets.ELEMENT_BOT_TOKEN }}
branch: actions/playwright-image-updates
diff --git a/.github/workflows/pull_request_base_branch.yaml b/.github/workflows/pull_request_base_branch.yaml
new file mode 100644
index 00000000000..49d7bcef7c7
--- /dev/null
+++ b/.github/workflows/pull_request_base_branch.yaml
@@ -0,0 +1,16 @@
+name: Pull Request Base Branch
+on:
+ pull_request:
+ types: [opened, edited, synchronize]
+jobs:
+ check_base_branch:
+ name: Check PR base branch
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/github-script@v7
+ with:
+ script: |
+ const baseBranch = context.payload.pull_request.base.ref;
+ if (!['develop', 'staging'].includes(baseBranch) && !baseBranch.startsWith('feat/')) {
+ core.setFailed(`Invalid base branch: ${baseBranch}`);
+ }
diff --git a/.github/workflows/static_analysis.yaml b/.github/workflows/static_analysis.yaml
index 6e225467afa..94ed2c7488d 100644
--- a/.github/workflows/static_analysis.yaml
+++ b/.github/workflows/static_analysis.yaml
@@ -25,6 +25,7 @@ jobs:
- uses: actions/setup-node@v4
with:
cache: "yarn"
+ node-version: "lts/*"
- name: Install Deps
run: "./scripts/ci/install-deps.sh"
@@ -83,6 +84,7 @@ jobs:
- uses: actions/setup-node@v4
with:
cache: "yarn"
+ node-version: "lts/*"
# Does not need branch matching as only analyses this layer
- name: Install Deps
@@ -100,6 +102,7 @@ jobs:
- uses: actions/setup-node@v4
with:
cache: "yarn"
+ node-version: "lts/*"
# Does not need branch matching as only analyses this layer
- name: Install Deps
@@ -117,6 +120,7 @@ jobs:
- uses: actions/setup-node@v4
with:
cache: "yarn"
+ node-version: "lts/*"
# Does not need branch matching as only analyses this layer
- name: Install Deps
@@ -134,6 +138,7 @@ jobs:
- uses: actions/setup-node@v4
with:
cache: "yarn"
+ node-version: "lts/*"
- name: Install Deps
run: "scripts/ci/layered.sh"
diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml
index 3815c4fb4cc..e8418a95199 100644
--- a/.github/workflows/tests.yml
+++ b/.github/workflows/tests.yml
@@ -44,6 +44,7 @@ jobs:
- name: Yarn cache
uses: actions/setup-node@v4
with:
+ node-version: "lts/*"
cache: "yarn"
- name: Install Deps
@@ -115,6 +116,7 @@ jobs:
- uses: actions/setup-node@v4
with:
cache: "yarn"
+ node-version: "lts/*"
- name: Run tests
run: "./scripts/ci/app-tests.sh"
diff --git a/CHANGELOG.md b/CHANGELOG.md
index ea499883aac..280d6dd562d 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,3 +1,88 @@
+Changes in [3.104.0](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v3.104.0) (2024-07-30)
+=======================================================================================================
+## ✨ Features
+
+* Add release announcement for the new room header ([#12802](https://github.com/matrix-org/matrix-react-sdk/pull/12802)). Contributed by @MidhunSureshR.
+* Default the room header to on ([#12803](https://github.com/matrix-org/matrix-react-sdk/pull/12803)). Contributed by @MidhunSureshR.
+* Update Thread Panel to match latest designs ([#12797](https://github.com/matrix-org/matrix-react-sdk/pull/12797)). Contributed by @t3chguy.
+* Close any open modals on logout ([#12777](https://github.com/matrix-org/matrix-react-sdk/pull/12777)). Contributed by @dbkr.
+* Iterate design of right panel empty state ([#12796](https://github.com/matrix-org/matrix-react-sdk/pull/12796)). Contributed by @t3chguy.
+* Update styling of UserInfo right panel card ([#12788](https://github.com/matrix-org/matrix-react-sdk/pull/12788)). Contributed by @t3chguy.
+* Accessibility: Add Landmark navigation ([#12190](https://github.com/matrix-org/matrix-react-sdk/pull/12190)). Contributed by @akirk.
+* Let Element Call widget receive m.room.create ([#12710](https://github.com/matrix-org/matrix-react-sdk/pull/12710)). Contributed by @AndrewFerr.
+* Let Element Call widget set session memberships ([#12713](https://github.com/matrix-org/matrix-react-sdk/pull/12713)). Contributed by @AndrewFerr.
+* Update right panel base card styling to match Compound ([#12768](https://github.com/matrix-org/matrix-react-sdk/pull/12768)). Contributed by @t3chguy.
+* Align `widget_build_url_ignore_dm` with call behaviour switch between 1:1 and Widget ([#12760](https://github.com/matrix-org/matrix-react-sdk/pull/12760)). Contributed by @t3chguy.
+* Move integrations switch ([#12733](https://github.com/matrix-org/matrix-react-sdk/pull/12733)). Contributed by @dbkr.
+* Element-R: Report events with withheld keys separately to Posthog. ([#12755](https://github.com/matrix-org/matrix-react-sdk/pull/12755)). Contributed by @richvdh.
+
+## 🐛 Bug Fixes
+
+* Test for lack of WebAssembly support ([#12792](https://github.com/matrix-org/matrix-react-sdk/pull/12792)). Contributed by @dbkr.
+* Fix stray 'account' heading ([#12791](https://github.com/matrix-org/matrix-react-sdk/pull/12791)). Contributed by @dbkr.
+* Add test for the unsupported browser screen ([#12787](https://github.com/matrix-org/matrix-react-sdk/pull/12787)). Contributed by @dbkr.
+* Fix HTML export test ([#12778](https://github.com/matrix-org/matrix-react-sdk/pull/12778)). Contributed by @dbkr.
+* Fix HTML export missing a bunch of Compound variables ([#12774](https://github.com/matrix-org/matrix-react-sdk/pull/12774)). Contributed by @t3chguy.
+* Fix inability to change accent colour consistently in custom theming ([#12772](https://github.com/matrix-org/matrix-react-sdk/pull/12772)). Contributed by @t3chguy.
+* Fix edge case of landing on 3pid email link with registration disabled ([#12771](https://github.com/matrix-org/matrix-react-sdk/pull/12771)). Contributed by @t3chguy.
+
+
+Changes in [3.103.0](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v3.103.0) (2024-07-16)
+=======================================================================================================
+## ✨ Features
+
+* Add tabs to the right panel ([#12672](https://github.com/matrix-org/matrix-react-sdk/pull/12672)). Contributed by @MidhunSureshR.
+* Promote new room header from labs to Beta ([#12739](https://github.com/matrix-org/matrix-react-sdk/pull/12739)). Contributed by @t3chguy.
+* Redesign room search interface ([#12677](https://github.com/matrix-org/matrix-react-sdk/pull/12677)). Contributed by @t3chguy.
+* Move language settings to 'preferences' ([#12723](https://github.com/matrix-org/matrix-react-sdk/pull/12723)). Contributed by @dbkr.
+* New layout selector ui in user settings ([#12676](https://github.com/matrix-org/matrix-react-sdk/pull/12676)). Contributed by @florianduros.
+* Prevent Element appearing in system media controls ([#10995](https://github.com/matrix-org/matrix-react-sdk/pull/10995)). Contributed by @SuperKenVery.
+* Move the account management button ([#12663](https://github.com/matrix-org/matrix-react-sdk/pull/12663)). Contributed by @dbkr.
+* Disable profile controls if the HS doesn't allow them to be set ([#12652](https://github.com/matrix-org/matrix-react-sdk/pull/12652)). Contributed by @dbkr.
+* New theme ui in user settings ([#12576](https://github.com/matrix-org/matrix-react-sdk/pull/12576)). Contributed by @florianduros.
+* Adjust room header hover transition from 300ms to 200ms ([#12703](https://github.com/matrix-org/matrix-react-sdk/pull/12703)). Contributed by @t3chguy.
+* Split out email \& phone number settings to separate components \& move discovery to privacy tab ([#12670](https://github.com/matrix-org/matrix-react-sdk/pull/12670)). Contributed by @dbkr.
+
+## 🐛 Bug Fixes
+
+* Fix incoming call toast crash due to audio refactor ([#12737](https://github.com/matrix-org/matrix-react-sdk/pull/12737)). Contributed by @t3chguy.
+* Improve new room header accessibility ([#12725](https://github.com/matrix-org/matrix-react-sdk/pull/12725)). Contributed by @t3chguy.
+* Fix closing all modals ([#12728](https://github.com/matrix-org/matrix-react-sdk/pull/12728)). Contributed by @dbkr.
+* Fix close button on forgot password flow ([#12732](https://github.com/matrix-org/matrix-react-sdk/pull/12732)). Contributed by @dbkr.
+* Don't consider textual characters to be emoji ([#12582](https://github.com/matrix-org/matrix-react-sdk/pull/12582)). Contributed by @robintown.
+* Clear autocomplete input on selection accept ([#12709](https://github.com/matrix-org/matrix-react-sdk/pull/12709)). Contributed by @dbkr.
+* Fix `Match system theme` toggle ([#12719](https://github.com/matrix-org/matrix-react-sdk/pull/12719)). Contributed by @florianduros.
+
+
+Changes in [3.102.0](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v3.102.0) (2024-07-08)
+=======================================================================================================
+## ✨ Features
+
+* Switch to Rust crypto stack for all logins ([#12630](https://github.com/matrix-org/matrix-react-sdk/pull/12630)). Contributed by @richvdh.
+* Hide voip buttons in group rooms in environments with widgets disabled ([#12664](https://github.com/matrix-org/matrix-react-sdk/pull/12664)). Contributed by @t3chguy.
+* Minor tweaks to UserSettings dialog ([#12651](https://github.com/matrix-org/matrix-react-sdk/pull/12651)). Contributed by @florianduros.
+* Hide voice call button when redundant ([#12639](https://github.com/matrix-org/matrix-react-sdk/pull/12639)). Contributed by @t3chguy.
+* Improve accessibility of the room summary card ([#12586](https://github.com/matrix-org/matrix-react-sdk/pull/12586)). Contributed by @t3chguy.
+* Show tooltips on narrow tabbed views ([#12624](https://github.com/matrix-org/matrix-react-sdk/pull/12624)). Contributed by @dbkr.
+* Update gfm.css to github-markdown-css ([#12613](https://github.com/matrix-org/matrix-react-sdk/pull/12613)). Contributed by @t3chguy.
+* Cache e2eStatus to avoid concerning unencrypted flicker when changing rooms ([#12606](https://github.com/matrix-org/matrix-react-sdk/pull/12606)). Contributed by @t3chguy.
+* Tweak copy for user verification toast ([#12605](https://github.com/matrix-org/matrix-react-sdk/pull/12605)). Contributed by @t3chguy.
+* Support s tags for strikethrough for Matrix v1.10 ([#12604](https://github.com/matrix-org/matrix-react-sdk/pull/12604)). Contributed by @t3chguy.
+
+## 🐛 Bug Fixes
+
+* Remove redundant copy in deactive uia modal ([#12668](https://github.com/matrix-org/matrix-react-sdk/pull/12668)). Contributed by @t3chguy.
+* Fix high contrast theme in settings ([#12649](https://github.com/matrix-org/matrix-react-sdk/pull/12649)). Contributed by @florianduros.
+* Fix background on live location sharing footer ([#12629](https://github.com/matrix-org/matrix-react-sdk/pull/12629)). Contributed by @t3chguy.
+* Remove outdated iframe sandbox attribute ([#12633](https://github.com/matrix-org/matrix-react-sdk/pull/12633)). Contributed by @t3chguy.
+* Remove stray setState which caused encryption state shields to flicker ([#12632](https://github.com/matrix-org/matrix-react-sdk/pull/12632)). Contributed by @t3chguy.
+* Fix stray background colour on markdown body ([#12628](https://github.com/matrix-org/matrix-react-sdk/pull/12628)). Contributed by @t3chguy.
+* Fix widgets not being cleaned up correctly. ([#12616](https://github.com/matrix-org/matrix-react-sdk/pull/12616)). Contributed by @toger5.
+* Add in-progress view to display name EditInPlace ([#12609](https://github.com/matrix-org/matrix-react-sdk/pull/12609)). Contributed by @dbkr.
+* Fix config override of other settings levels ([#12593](https://github.com/matrix-org/matrix-react-sdk/pull/12593)). Contributed by @langleyd.
+* Don't show 'saved' on display name save error ([#12600](https://github.com/matrix-org/matrix-react-sdk/pull/12600)). Contributed by @dbkr.
+
+
Changes in [3.101.0](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v3.101.0) (2024-06-18)
=======================================================================================================
## ✨ Features
diff --git a/babel.config.js b/babel.config.js
index 1c4adcb7f70..a9e4b5c137b 100644
--- a/babel.config.js
+++ b/babel.config.js
@@ -17,9 +17,9 @@ module.exports = {
],
plugins: [
"@babel/plugin-proposal-export-default-from",
- "@babel/plugin-proposal-numeric-separator",
- "@babel/plugin-proposal-class-properties",
- "@babel/plugin-proposal-object-rest-spread",
+ "@babel/plugin-transform-numeric-separator",
+ "@babel/plugin-transform-class-properties",
+ "@babel/plugin-transform-object-rest-spread",
"@babel/plugin-syntax-dynamic-import",
"@babel/plugin-transform-runtime",
],
diff --git a/jest.config.ts b/jest.config.ts
index 182c28f68ae..7293e5b3be9 100644
--- a/jest.config.ts
+++ b/jest.config.ts
@@ -22,7 +22,7 @@ const config: Config = {
testEnvironment: "jsdom",
testMatch: ["/test/**/*-test.[jt]s?(x)"],
globalSetup: "/test/globalSetup.ts",
- setupFiles: ["jest-canvas-mock"],
+ setupFiles: ["jest-canvas-mock", "web-streams-polyfill/polyfill"],
setupFilesAfterEnv: ["/test/setupTests.ts"],
moduleNameMapper: {
"\\.(gif|png|ttf|woff2)$": "/__mocks__/imageMock.js",
diff --git a/package.json b/package.json
index 6e21f438d1d..62ce8627bb2 100644
--- a/package.json
+++ b/package.json
@@ -1,7 +1,7 @@
{
"name": "matrix-react-sdk",
"version": "0.0.0",
- "version-matrix": "3.101.0",
+ "version-matrix": "3.104.0",
"description": "SDK for matrix.org using React for Tchap",
"author": "DINUM",
"repository": {
@@ -24,6 +24,9 @@
"package.json",
".stylelintrc.js"
],
+ "engines": {
+ "node": ">=20.0.0"
+ },
"main": "./lib/index.ts",
"matrix_src_main": "./src/index.ts",
"matrix_lib_main": "./lib/index.ts",
@@ -56,29 +59,30 @@
"test:playwright:open": "yarn test:playwright --ui",
"test:playwright:screenshots": "yarn test:playwright:screenshots:build && yarn test:playwright:screenshots:run",
"test:playwright:screenshots:build": "docker build playwright -t matrix-react-sdk-playwright",
- "test:playwright:screenshots:run": "docker run --rm --network host -v $(pwd)/../:/work/ -v /var/run/docker.sock:/var/run/docker.sock -v /tmp/:/tmp/ -it matrix-react-sdk-playwright",
+ "test:playwright:screenshots:run": "docker run --rm --network host -e BASE_URL -v $(pwd)/../:/work/ -v /var/run/docker.sock:/var/run/docker.sock -v /tmp/:/tmp/ -it matrix-react-sdk-playwright",
"coverage": "yarn test --coverage",
"lint:workflows": "find .github/workflows -type f \\( -iname '*.yaml' -o -iname '*.yml' \\) | xargs -I {} sh -c 'echo \"Linting {}\"; action-validator \"{}\"'"
},
"resolutions": {
"@types/react-dom": "17.0.25",
"@types/react": "17.0.80",
- "@types/seedrandom": "3.0.4",
+ "@types/seedrandom": "3.0.8",
"oidc-client-ts": "3.0.1",
- "jwt-decode": "4.0.0"
+ "jwt-decode": "4.0.0",
+ "@floating-ui/react": "0.26.11",
+ "@radix-ui/react-id": "1.1.0"
},
"dependencies": {
"@babel/runtime": "^7.12.5",
- "@matrix-org/analytics-events": "^0.21.0",
+ "@matrix-org/analytics-events": "^0.24.0",
"@matrix-org/emojibase-bindings": "^1.1.2",
- "@matrix-org/matrix-wysiwyg": "2.17.0",
- "@matrix-org/olm": "3.2.15",
+ "@matrix-org/matrix-wysiwyg": "2.37.4",
"@matrix-org/react-sdk-module-api": "^2.4.0",
"@matrix-org/spec": "^1.7.0",
- "@sentry/browser": "^7.0.0",
+ "@sentry/browser": "^8.0.0",
"@testing-library/react-hooks": "^8.0.1",
- "@vector-im/compound-design-tokens": "^1.2.0",
- "@vector-im/compound-web": "^4.4.1",
+ "@vector-im/compound-design-tokens": "^1.6.1",
+ "@vector-im/compound-web": "^5.4.0",
"@zxcvbn-ts/core": "^3.0.4",
"@zxcvbn-ts/language-common": "^3.0.4",
"@zxcvbn-ts/language-en": "^3.0.2",
@@ -88,19 +92,19 @@
"classnames": "^2.2.6",
"commonmark": "^0.31.0",
"counterpart": "^0.18.6",
+ "css-tree": "^2.3.1",
"diff-dom": "^5.0.0",
"diff-match-patch": "^1.0.5",
- "emojibase-regex": "15.3.0",
+ "emojibase-regex": "15.3.2",
"escape-html": "^1.0.3",
"file-saver": "^2.0.5",
- "filesize": "10.1.2",
- "gfm.css": "^1.1.2",
+ "filesize": "10.1.4",
+ "github-markdown-css": "^5.5.1",
"glob-to-regexp": "^0.4.1",
- "graphemer": "^1.4.0",
"highlight.js": "^11.3.1",
"html-entities": "^2.0.0",
"is-ip": "^3.1.0",
- "js-xxhash": "^3.0.1",
+ "js-xxhash": "^4.0.0",
"jszip": "^3.7.0",
"katex": "^0.16.0",
"linkify-element": "4.1.3",
@@ -111,7 +115,7 @@
"maplibre-gl": "^2.0.0",
"matrix-encrypt-attachment": "^1.0.3",
"matrix-events-sdk": "0.0.1",
- "matrix-js-sdk": "33.1.0",
+ "matrix-js-sdk": "34.2.0",
"matrix-widget-api": "^1.5.0",
"memoize-one": "^6.0.0",
"minimist": "^1.2.5",
@@ -119,8 +123,7 @@
"opus-recorder": "^8.0.3",
"pako": "^2.0.3",
"png-chunks-extract": "^1.0.0",
- "posthog-js": "1.135.2",
- "proposal-temporal": "^0.9.0",
+ "posthog-js": "1.145.0",
"qrcode": "1.5.3",
"re-resizable": "^6.9.0",
"react": "17.0.2",
@@ -133,8 +136,9 @@
"sanitize-filename": "^1.6.3",
"sanitize-html": "2.13.0",
"tar-js": "^0.3.0",
+ "temporal-polyfill": "^0.2.5",
"ua-parser-js": "^1.0.2",
- "uuid": "^9.0.0",
+ "uuid": "^10.0.0",
"what-input": "^5.2.10"
},
"devDependencies": {
@@ -146,10 +150,10 @@
"@babel/eslint-parser": "^7.12.10",
"@babel/eslint-plugin": "^7.12.10",
"@babel/parser": "^7.12.11",
- "@babel/plugin-proposal-class-properties": "^7.12.1",
"@babel/plugin-proposal-export-default-from": "^7.12.1",
- "@babel/plugin-proposal-numeric-separator": "^7.12.7",
- "@babel/plugin-proposal-object-rest-spread": "^7.12.1",
+ "@babel/plugin-transform-class-properties": "^7.12.1",
+ "@babel/plugin-transform-numeric-separator": "^7.12.7",
+ "@babel/plugin-transform-object-rest-spread": "^7.12.1",
"@babel/plugin-transform-runtime": "^7.12.10",
"@babel/preset-env": "^7.12.11",
"@babel/preset-react": "^7.12.10",
@@ -165,6 +169,7 @@
"@types/commonmark": "^0.27.4",
"@types/content-type": "^1.1.5",
"@types/counterpart": "^0.18.1",
+ "@types/css-tree": "^2.3.8",
"@types/diff-match-patch": "^1.0.32",
"@types/escape-html": "^1.0.1",
"@types/express": "^4.17.21",
@@ -185,10 +190,10 @@
"@types/react-transition-group": "^4.4.0",
"@types/sanitize-html": "2.11.0",
"@types/sdp-transform": "^2.4.6",
- "@types/seedrandom": "3.0.4",
+ "@types/seedrandom": "3.0.8",
"@types/tar-js": "^0.3.2",
"@types/ua-parser-js": "^0.7.36",
- "@types/uuid": "^9.0.2",
+ "@types/uuid": "^10.0.0",
"@typescript-eslint/eslint-plugin": "^7.0.0",
"@typescript-eslint/parser": "^7.0.0",
"axe-core": "4.9.1",
@@ -204,11 +209,12 @@
"eslint-plugin-matrix-org": "1.2.1",
"eslint-plugin-react": "^7.28.0",
"eslint-plugin-react-hooks": "^4.3.0",
- "eslint-plugin-unicorn": "^53.0.0",
+ "eslint-plugin-unicorn": "^54.0.0",
"express": "^4.18.2",
- "fake-indexeddb": "^5.0.2",
+ "fake-indexeddb": "^6.0.0",
"fetch-mock-jest": "^1.5.1",
"fs-extra": "^11.0.0",
+ "glob": "^11.0.0",
"jest": "^29.6.2",
"jest-canvas-mock": "^2.5.2",
"jest-environment-jsdom": "^29.6.2",
@@ -219,15 +225,17 @@
"matrix-web-i18n": "^3.2.1",
"mocha-junit-reporter": "^2.2.0",
"node-fetch": "2",
+ "playwright-core": "^1.45.1",
"postcss-scss": "^4.0.4",
- "prettier": "3.2.5",
+ "prettier": "3.3.2",
"raw-loader": "^4.0.2",
- "rimraf": "^5.0.0",
+ "rimraf": "^6.0.0",
"stylelint": "^16.1.0",
"stylelint-config-standard": "^36.0.0",
"stylelint-scss": "^6.0.0",
"ts-node": "^10.9.1",
- "typescript": "5.4.5"
+ "typescript": "5.5.3",
+ "web-streams-polyfill": "^4.0.0"
},
"peerDependencies": {
"postcss": "^8.4.19",
diff --git a/playwright.config.ts b/playwright.config.ts
index 96a8dd95ec5..ba491ff82a6 100644
--- a/playwright.config.ts
+++ b/playwright.config.ts
@@ -16,11 +16,9 @@ limitations under the License.
import { defineConfig } from "@playwright/test";
-import { TestOptions } from "./playwright/element-web-test";
-
const baseURL = process.env["BASE_URL"] ?? "http://localhost:8080";
-export default defineConfig({
+export default defineConfig({
use: {
viewport: { width: 1280, height: 720 },
ignoreHTTPSErrors: true,
@@ -39,19 +37,10 @@ export default defineConfig({
},
testDir: "playwright/e2e",
outputDir: "playwright/test-results",
- workers: 1,
+ workers: process.env.CI ? "50%" : 1,
retries: process.env.CI ? 2 : 0,
reporter: process.env.CI ? [["blob"], ["github"]] : [["html", { outputFolder: "playwright/html-report" }]],
- projects: [
- {
- name: "Legacy Crypto",
- use: { cryptoBackend: "legacy" },
- },
- {
- name: "Rust Crypto",
- use: { cryptoBackend: "rust" },
- },
- ],
snapshotDir: "playwright/snapshots",
snapshotPathTemplate: "{snapshotDir}/{testFilePath}/{arg}-{platform}{ext}",
+ forbidOnly: !!process.env.CI,
});
diff --git a/res/css/views/rooms/_SpaceScopeHeader.pcss b/playwright/@types/playwright-core.d.ts
similarity index 63%
rename from res/css/views/rooms/_SpaceScopeHeader.pcss
rename to playwright/@types/playwright-core.d.ts
index 4a94793ba72..0ef2ca0ece1 100644
--- a/res/css/views/rooms/_SpaceScopeHeader.pcss
+++ b/playwright/@types/playwright-core.d.ts
@@ -1,5 +1,5 @@
/*
-Copyright 2023 The Matrix.org Foundation C.I.C.
+Copyright 2024 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
@@ -14,16 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
-.mx_SpaceScopeHeader {
- text-align: center;
-
- display: -webkit-box;
- -webkit-box-orient: vertical;
- -webkit-line-clamp: 1;
- overflow: hidden;
-
- .mx_BaseAvatar {
- margin-right: var(--cpd-space-2x);
- vertical-align: middle;
- }
+declare module "playwright-core/lib/utils" {
+ // This type is not public in playwright-core utils
+ export function sanitizeForFilePath(filePath: string): string;
}
diff --git a/playwright/Dockerfile b/playwright/Dockerfile
index 7179e08ab0b..c5a9cdbb815 100644
--- a/playwright/Dockerfile
+++ b/playwright/Dockerfile
@@ -1,4 +1,4 @@
-FROM mcr.microsoft.com/playwright:v1.44.1-jammy
+FROM mcr.microsoft.com/playwright:v1.45.1-jammy
WORKDIR /work/matrix-react-sdk
VOLUME ["/work/element-web/node_modules"]
diff --git a/playwright/docker-entrypoint.sh b/playwright/docker-entrypoint.sh
index 7bc5d2c9d84..1e2ba553a72 100644
--- a/playwright/docker-entrypoint.sh
+++ b/playwright/docker-entrypoint.sh
@@ -5,4 +5,4 @@ set -e
yarn link
yarn --cwd ../element-web install
yarn --cwd ../element-web link matrix-react-sdk
-npx playwright test --update-snapshots --reporter line --project='Legacy Crypto' $@
+npx playwright test --update-snapshots --reporter line $@
diff --git a/playwright/e2e/accessibility/keyboard-navigation.spec.ts b/playwright/e2e/accessibility/keyboard-navigation.spec.ts
new file mode 100644
index 00000000000..b4b74f51877
--- /dev/null
+++ b/playwright/e2e/accessibility/keyboard-navigation.spec.ts
@@ -0,0 +1,166 @@
+/*
+Copyright 2024 The Matrix.org Foundation C.I.C.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+import { test, expect } from "../../element-web-test";
+import { Bot } from "../../pages/bot";
+
+test.describe("Landmark navigation tests", () => {
+ test.use({
+ displayName: "Alice",
+ });
+
+ test("without any rooms", async ({ page, homeserver, app, user }) => {
+ /**
+ * Without any rooms, there is no tile in the roomlist to be focused.
+ * So the next landmark in the list should be focused instead.
+ */
+
+ // Pressing Control+F6 will first focus the space button
+ await page.keyboard.press("ControlOrMeta+F6");
+ await expect(page.locator(".mx_SpaceButton_active")).toBeFocused();
+
+ // Pressing Control+F6 again will focus room search
+ await page.keyboard.press("ControlOrMeta+F6");
+ await expect(page.locator(".mx_RoomSearch")).toBeFocused();
+
+ // Pressing Control+F6 again will focus the message composer
+ await page.keyboard.press("ControlOrMeta+F6");
+ await expect(page.locator(".mx_HomePage")).toBeFocused();
+
+ // Pressing Control+F6 again will bring focus back to the space button
+ await page.keyboard.press("ControlOrMeta+F6");
+ await expect(page.locator(".mx_SpaceButton_active")).toBeFocused();
+
+ // Now go back in the same order
+ await page.keyboard.press("ControlOrMeta+Shift+F6");
+ await expect(page.locator(".mx_HomePage")).toBeFocused();
+
+ await page.keyboard.press("ControlOrMeta+Shift+F6");
+ await expect(page.locator(".mx_RoomSearch")).toBeFocused();
+
+ await page.keyboard.press("ControlOrMeta+Shift+F6");
+ await expect(page.locator(".mx_SpaceButton_active")).toBeFocused();
+ });
+
+ test("with an open room", async ({ page, homeserver, app, user }) => {
+ const bob = new Bot(page, homeserver, { displayName: "Bob" });
+ await bob.prepareClient();
+
+ // create dm with bob
+ await app.client.evaluate(
+ async (cli, { bob }) => {
+ const bobRoom = await cli.createRoom({ is_direct: true });
+ await cli.invite(bobRoom.room_id, bob);
+ },
+ {
+ bob: bob.credentials.userId,
+ },
+ );
+
+ await app.viewRoomByName("Bob");
+ // confirm the room was loaded
+ await expect(page.getByText("Bob joined the room")).toBeVisible();
+
+ // Pressing Control+F6 will first focus the space button
+ await page.keyboard.press("ControlOrMeta+F6");
+ await expect(page.locator(".mx_SpaceButton_active")).toBeFocused();
+
+ // Pressing Control+F6 again will focus room search
+ await page.keyboard.press("ControlOrMeta+F6");
+ await expect(page.locator(".mx_RoomSearch")).toBeFocused();
+
+ // Pressing Control+F6 again will focus the room tile in the room list
+ await page.keyboard.press("ControlOrMeta+F6");
+ await expect(page.locator(".mx_RoomTile_selected")).toBeFocused();
+
+ // Pressing Control+F6 again will focus the message composer
+ await page.keyboard.press("ControlOrMeta+F6");
+ await expect(page.locator(".mx_BasicMessageComposer_input")).toBeFocused();
+
+ // Pressing Control+F6 again will bring focus back to the space button
+ await page.keyboard.press("ControlOrMeta+F6");
+ await expect(page.locator(".mx_SpaceButton_active")).toBeFocused();
+
+ // Now go back in the same order
+ await page.keyboard.press("ControlOrMeta+Shift+F6");
+ await expect(page.locator(".mx_BasicMessageComposer_input")).toBeFocused();
+
+ await page.keyboard.press("ControlOrMeta+Shift+F6");
+ await expect(page.locator(".mx_RoomTile_selected")).toBeFocused();
+
+ await page.keyboard.press("ControlOrMeta+Shift+F6");
+ await expect(page.locator(".mx_RoomSearch")).toBeFocused();
+
+ await page.keyboard.press("ControlOrMeta+Shift+F6");
+ await expect(page.locator(".mx_SpaceButton_active")).toBeFocused();
+ });
+
+ test("without an open room", async ({ page, homeserver, app, user }) => {
+ const bob = new Bot(page, homeserver, { displayName: "Bob" });
+ await bob.prepareClient();
+
+ // create a dm with bob
+ await app.client.evaluate(
+ async (cli, { bob }) => {
+ const bobRoom = await cli.createRoom({ is_direct: true });
+ await cli.invite(bobRoom.room_id, bob);
+ },
+ {
+ bob: bob.credentials.userId,
+ },
+ );
+
+ await app.viewRoomByName("Bob");
+ // confirm the room was loaded
+ await expect(page.getByText("Bob joined the room")).toBeVisible();
+
+ // Close the room
+ page.goto("/#/home");
+
+ // Pressing Control+F6 will first focus the space button
+ await page.keyboard.press("ControlOrMeta+F6");
+ await expect(page.locator(".mx_SpaceButton_active")).toBeFocused();
+
+ // Pressing Control+F6 again will focus room search
+ await page.keyboard.press("ControlOrMeta+F6");
+ await expect(page.locator(".mx_RoomSearch")).toBeFocused();
+
+ // Pressing Control+F6 again will focus the room tile in the room list
+ await page.keyboard.press("ControlOrMeta+F6");
+ await expect(page.locator(".mx_RoomTile")).toBeFocused();
+
+ // Pressing Control+F6 again will focus the home section
+ await page.keyboard.press("ControlOrMeta+F6");
+ await expect(page.locator(".mx_HomePage")).toBeFocused();
+
+ // Pressing Control+F6 will bring focus back to the space button
+ await page.keyboard.press("ControlOrMeta+F6");
+ await expect(page.locator(".mx_SpaceButton_active")).toBeFocused();
+
+ // Now go back in same order
+ await page.keyboard.press("ControlOrMeta+Shift+F6");
+ await expect(page.locator(".mx_HomePage")).toBeFocused();
+
+ await page.keyboard.press("ControlOrMeta+Shift+F6");
+ await expect(page.locator(".mx_RoomTile")).toBeFocused();
+
+ await page.keyboard.press("ControlOrMeta+Shift+F6");
+ await expect(page.locator(".mx_RoomSearch")).toBeFocused();
+
+ await page.keyboard.press("ControlOrMeta+Shift+F6");
+ await expect(page.locator(".mx_SpaceButton_active")).toBeFocused();
+ });
+});
diff --git a/playwright/e2e/app-loading/feature-detection.spec.ts b/playwright/e2e/app-loading/feature-detection.spec.ts
new file mode 100644
index 00000000000..2acde32c37f
--- /dev/null
+++ b/playwright/e2e/app-loading/feature-detection.spec.ts
@@ -0,0 +1,42 @@
+/*
+Copyright 2024 The Matrix.org Foundation C.I.C.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+import { test, expect } from "../../element-web-test";
+
+test(`shows error page if browser lacks Intl support`, async ({ page }) => {
+ await page.addInitScript({ content: `delete window.Intl;` });
+ await page.goto("/");
+
+ // Lack of Intl support causes the app bundle to fail to load, so we get the iframed
+ // static error page and need to explicitly look in the iframe becuse Playwright doesn't
+ // recurse into iframes when looking for elements
+ const header = await page.frameLocator("iframe").getByText("Unsupported browser");
+ await expect(header).toBeVisible();
+
+ await expect(page).toMatchScreenshot("unsupported-browser.png");
+});
+
+test(`shows error page if browser lacks WebAssembly support`, async ({ page }) => {
+ await page.addInitScript({ content: `delete window.WebAssembly;` });
+ await page.goto("/");
+
+ // Lack of WebAssembly support doesn't cause the bundle to fail loading, so we get
+ // CompatibilityView, ie. no iframes.
+ const header = await page.getByText("Unsupported browser");
+ await expect(header).toBeVisible();
+
+ await expect(page).toMatchScreenshot("unsupported-browser-CompatibilityView.png");
+});
diff --git a/playwright/e2e/app-loading/guest-registration.spec.ts b/playwright/e2e/app-loading/guest-registration.spec.ts
new file mode 100644
index 00000000000..17ec369359f
--- /dev/null
+++ b/playwright/e2e/app-loading/guest-registration.spec.ts
@@ -0,0 +1,45 @@
+/*
+Copyright 2024 The Matrix.org Foundation C.I.C.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+/*
+ * Tests for application startup with guest registration enabled on the server.
+ */
+
+import { expect, test } from "../../element-web-test";
+
+test.use({
+ startHomeserverOpts: "guest-enabled",
+ config: async ({ homeserver }, use) => {
+ await use({
+ default_server_config: {
+ "m.homeserver": { base_url: homeserver.config.baseUrl },
+ },
+ });
+ },
+});
+
+test("Shows the welcome page by default", async ({ page }) => {
+ await page.goto("/");
+ await expect(page.getByRole("heading", { name: "Welcome to Element!" })).toBeVisible();
+ await expect(page.getByRole("link", { name: "Sign in" })).toBeVisible();
+});
+
+test("Room link correctly loads a room view", async ({ page }) => {
+ await page.goto("/#/room/!room:id");
+ await page.waitForSelector(".mx_MatrixChat", { timeout: 30000 });
+ await expect(page).toHaveURL(/\/#\/room\/!room:id$/);
+ await expect(page.getByRole("heading", { name: "Join the conversation with an account" })).toBeVisible();
+});
diff --git a/playwright/e2e/app-loading/stored-credentials.spec.ts b/playwright/e2e/app-loading/stored-credentials.spec.ts
new file mode 100644
index 00000000000..f720a545597
--- /dev/null
+++ b/playwright/e2e/app-loading/stored-credentials.spec.ts
@@ -0,0 +1,68 @@
+/*
+Copyright 2024 The Matrix.org Foundation C.I.C.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+import { expect, test } from "../../element-web-test";
+import { ElementAppPage } from "../../pages/ElementAppPage";
+
+/*
+ * Tests for application startup with credentials stored in localstorage.
+ */
+
+test.use({ displayName: "Boris" });
+
+test("Shows the homepage by default", async ({ pageWithCredentials: page }) => {
+ await page.goto("/");
+ await page.waitForSelector(".mx_MatrixChat", { timeout: 30000 });
+
+ await expect(page).toHaveURL(/\/#\/home/);
+ await expect(page.getByRole("heading", { name: "Welcome Boris", exact: true })).toBeVisible();
+});
+
+test("Shows the last known page on reload", async ({ pageWithCredentials: page }) => {
+ await page.goto("/");
+ await page.waitForSelector(".mx_MatrixChat", { timeout: 30000 });
+
+ const app = new ElementAppPage(page);
+ await app.client.createRoom({ name: "Test Room" });
+ await app.viewRoomByName("Test Room");
+
+ // Navigate away
+ await page.goto("about:blank");
+
+ // And back again
+ await page.goto("/");
+ await page.waitForSelector(".mx_MatrixChat", { timeout: 30000 });
+
+ // Check that the room reloaded
+ await expect(page).toHaveURL(/\/#\/room\//);
+ await expect(page.locator(".mx_RoomHeader")).toContainText("Test Room");
+});
+
+test("Room link correctly loads a room view", async ({ pageWithCredentials: page }) => {
+ await page.goto("/#/room/!room:id");
+ await page.waitForSelector(".mx_MatrixChat", { timeout: 30000 });
+
+ await expect(page).toHaveURL(/\/#\/room\/!room:id$/);
+ await expect(page.getByRole("button", { name: "Join the discussion" })).toBeVisible();
+});
+
+test("Login link redirects to home page", async ({ pageWithCredentials: page }) => {
+ await page.goto("/#/login");
+ await page.waitForSelector(".mx_MatrixChat", { timeout: 30000 });
+
+ await expect(page).toHaveURL(/\/#\/home/);
+ await expect(page.getByRole("heading", { name: "Welcome Boris", exact: true })).toBeVisible();
+});
diff --git a/playwright/e2e/audio-player/audio-player.spec.ts b/playwright/e2e/audio-player/audio-player.spec.ts
index 4581801db5b..e60d2732994 100644
--- a/playwright/e2e/audio-player/audio-player.spec.ts
+++ b/playwright/e2e/audio-player/audio-player.spec.ts
@@ -160,7 +160,7 @@ test.describe("Audio player", () => {
// Enable high contrast manually
const settings = await app.settings.openUserSettings("Appearance");
- await settings.getByTestId("mx_ThemeChoicePanel").getByText("Use high contrast").click();
+ await settings.getByRole("radio", { name: "High contrast" }).click();
await app.closeDialog();
diff --git a/playwright/e2e/chat-export/html-export.spec.ts b/playwright/e2e/chat-export/html-export.spec.ts
new file mode 100644
index 00000000000..b142fcec4e3
--- /dev/null
+++ b/playwright/e2e/chat-export/html-export.spec.ts
@@ -0,0 +1,133 @@
+/*
+Copyright 2024 The Matrix.org Foundation C.I.C.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+import os from "node:os";
+import path from "node:path";
+import * as fsp from "node:fs/promises";
+import * as fs from "node:fs";
+import JSZip from "jszip";
+
+import { test, expect } from "../../element-web-test";
+
+// Based on https://github.com/Stuk/jszip/issues/466#issuecomment-2097061912
+async function extractZipFileToPath(file: string, outputPath: string): Promise {
+ if (!fs.existsSync(outputPath)) {
+ fs.mkdirSync(outputPath, { recursive: true });
+ }
+
+ const data = await fsp.readFile(file);
+ const zip = await JSZip.loadAsync(data, { createFolders: true });
+
+ await new Promise((resolve, reject) => {
+ let entryCount = 0;
+ let errorOut = false;
+
+ zip.forEach(() => {
+ entryCount++;
+ }); // there is no other way to count the number of entries within the zip file.
+
+ zip.forEach((relativePath, zipEntry) => {
+ if (errorOut) {
+ return;
+ }
+
+ const outputEntryPath = path.join(outputPath, relativePath);
+ if (zipEntry.dir) {
+ if (!fs.existsSync(outputEntryPath)) {
+ fs.mkdirSync(outputEntryPath, { recursive: true });
+ }
+
+ entryCount--;
+
+ if (entryCount === 0) {
+ resolve();
+ }
+ } else {
+ void zipEntry
+ .async("blob")
+ .then(async (content) => Buffer.from(await content.arrayBuffer()))
+ .then((buffer) => {
+ const stream = fs.createWriteStream(outputEntryPath);
+ stream.write(buffer, (error) => {
+ if (error) {
+ reject(error);
+ errorOut = true;
+ }
+ });
+ stream.on("finish", () => {
+ entryCount--;
+
+ if (entryCount === 0) {
+ resolve();
+ }
+ });
+ stream.end(); // extremely important on Windows. On Mac / Linux, not so much since those platforms allow multiple apps to read from the same file. Windows doesn't allow that.
+ })
+ .catch((e) => {
+ errorOut = true;
+ reject(e);
+ });
+ }
+ });
+ });
+
+ return zip;
+}
+
+test.describe("HTML Export", () => {
+ test.use({
+ displayName: "Alice",
+ room: async ({ app, user }, use) => {
+ const roomId = await app.client.createRoom({ name: "Important Room" });
+ await app.viewRoomByName("Important Room");
+ await use({ roomId });
+ },
+ });
+
+ test("should export html successfully and match screenshot", async ({ page, app, room }) => {
+ // Send a bunch of messages to populate the room
+ for (let i = 1; i < 10; i++) {
+ await app.client.sendMessage(room.roomId, { body: `Testing ${i}`, msgtype: "m.text" });
+ }
+
+ // Wait for all the messages to be displayed
+ await expect(
+ page.locator(".mx_EventTile_last .mx_MTextBody .mx_EventTile_body").getByText("Testing 9"),
+ ).toBeVisible();
+
+ await app.toggleRoomInfoPanel();
+ await page.getByRole("menuitem", { name: "Export Chat" }).click();
+
+ const downloadPromise = page.waitForEvent("download");
+ await page.getByRole("button", { name: "Export", exact: true }).click();
+ const download = await downloadPromise;
+
+ const dirPath = path.join(os.tmpdir(), "html-export-test");
+ const zipPath = `${dirPath}.zip`;
+ await download.saveAs(zipPath);
+
+ const zip = await extractZipFileToPath(zipPath, dirPath);
+ await page.goto(`file://${dirPath}/${Object.keys(zip.files)[0]}/messages.html`);
+ await expect(page).toMatchScreenshot("html-export.png", {
+ mask: [
+ page.getByText("This is the start of export", { exact: false }),
+ // We need to mask the whole thing because the width of the time part changes
+ page.locator(".mx_TimelineSeparator"),
+ page.locator(".mx_MessageTimestamp"),
+ ],
+ });
+ });
+});
diff --git a/playwright/e2e/composer/CIDER.spec.ts b/playwright/e2e/composer/CIDER.spec.ts
new file mode 100644
index 00000000000..779babdaf24
--- /dev/null
+++ b/playwright/e2e/composer/CIDER.spec.ts
@@ -0,0 +1,106 @@
+/*
+Copyright 2022 - 2023 The Matrix.org Foundation C.I.C.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+import { test, expect } from "../../element-web-test";
+import { SettingLevel } from "../../../src/settings/SettingLevel";
+
+const CtrlOrMeta = process.platform === "darwin" ? "Meta" : "Control";
+
+test.describe("Composer", () => {
+ test.use({
+ displayName: "Janet",
+ });
+
+ test.use({
+ room: async ({ app, user }, use) => {
+ const roomId = await app.client.createRoom({ name: "Composing Room" });
+ await app.viewRoomByName("Composing Room");
+ await use({ roomId });
+ },
+ });
+
+ test.beforeEach(async ({ room }) => {}); // trigger room fixture
+
+ test.describe("CIDER", () => {
+ test("sends a message when you click send or press Enter", async ({ page }) => {
+ const composer = page.getByRole("textbox", { name: "Send a message…" });
+
+ // Type a message
+ await composer.pressSequentially("my message 0");
+ // It has not been sent yet
+ await expect(page.locator(".mx_EventTile_body", { hasText: "my message 0" })).not.toBeVisible();
+
+ // Click send
+ await page.getByRole("button", { name: "Send message" }).click();
+ // It has been sent
+ await expect(
+ page.locator(".mx_EventTile_last .mx_EventTile_body", { hasText: "my message 0" }),
+ ).toBeVisible();
+
+ // Type another and press Enter afterward
+ await composer.pressSequentially("my message 1");
+ await composer.press("Enter");
+ // It was sent
+ await expect(
+ page.locator(".mx_EventTile_last .mx_EventTile_body", { hasText: "my message 1" }),
+ ).toBeVisible();
+ });
+
+ test("can write formatted text", async ({ page }) => {
+ const composer = page.getByRole("textbox", { name: "Send a message…" });
+
+ await composer.pressSequentially("my bold");
+ await composer.press(`${CtrlOrMeta}+KeyB`);
+ await composer.pressSequentially(" message");
+ await page.getByRole("button", { name: "Send message" }).click();
+ // Note: both "bold" and "message" are bold, which is probably surprising
+ await expect(page.locator(".mx_EventTile_body strong", { hasText: "bold message" })).toBeVisible();
+ });
+
+ test("should allow user to input emoji via graphical picker", async ({ page, app }) => {
+ await app.getComposer(false).getByRole("button", { name: "Emoji" }).click();
+
+ await page.getByTestId("mx_EmojiPicker").locator(".mx_EmojiPicker_item", { hasText: "😇" }).click();
+
+ await page.locator(".mx_ContextualMenu_background").click(); // Close emoji picker
+ await page.getByRole("textbox", { name: "Send a message…" }).press("Enter"); // Send message
+
+ await expect(page.locator(".mx_EventTile_body", { hasText: "😇" })).toBeVisible();
+ });
+
+ test.describe("when Control+Enter is required to send", () => {
+ test.beforeEach(async ({ app }) => {
+ await app.settings.setValue("MessageComposerInput.ctrlEnterToSend", null, SettingLevel.ACCOUNT, true);
+ });
+
+ test("only sends when you press Control+Enter", async ({ page }) => {
+ const composer = page.getByRole("textbox", { name: "Send a message…" });
+ // Type a message and press Enter
+ await composer.pressSequentially("my message 3");
+ await composer.press("Enter");
+ // It has not been sent yet
+ await expect(page.locator(".mx_EventTile_body", { hasText: "my message 3" })).not.toBeVisible();
+
+ // Press Control+Enter
+ await composer.press(`${CtrlOrMeta}+Enter`);
+ // It was sent
+ await expect(
+ page.locator(".mx_EventTile_last .mx_EventTile_body", { hasText: "my message 3" }),
+ ).toBeVisible();
+ });
+ });
+ });
+});
diff --git a/playwright/e2e/composer/composer.spec.ts b/playwright/e2e/composer/RTE.spec.ts
similarity index 80%
rename from playwright/e2e/composer/composer.spec.ts
rename to playwright/e2e/composer/RTE.spec.ts
index e7be457f83e..53599d5320b 100644
--- a/playwright/e2e/composer/composer.spec.ts
+++ b/playwright/e2e/composer/RTE.spec.ts
@@ -34,76 +34,6 @@ test.describe("Composer", () => {
test.beforeEach(async ({ room }) => {}); // trigger room fixture
- test.describe("CIDER", () => {
- test("sends a message when you click send or press Enter", async ({ page }) => {
- const composer = page.getByRole("textbox", { name: "Send a message…" });
-
- // Type a message
- await composer.pressSequentially("my message 0");
- // It has not been sent yet
- await expect(page.locator(".mx_EventTile_body", { hasText: "my message 0" })).not.toBeVisible();
-
- // Click send
- await page.getByRole("button", { name: "Send message" }).click();
- // It has been sent
- await expect(
- page.locator(".mx_EventTile_last .mx_EventTile_body", { hasText: "my message 0" }),
- ).toBeVisible();
-
- // Type another and press Enter afterward
- await composer.pressSequentially("my message 1");
- await composer.press("Enter");
- // It was sent
- await expect(
- page.locator(".mx_EventTile_last .mx_EventTile_body", { hasText: "my message 1" }),
- ).toBeVisible();
- });
-
- test("can write formatted text", async ({ page }) => {
- const composer = page.getByRole("textbox", { name: "Send a message…" });
-
- await composer.pressSequentially("my bold");
- await composer.press(`${CtrlOrMeta}+KeyB`);
- await composer.pressSequentially(" message");
- await page.getByRole("button", { name: "Send message" }).click();
- // Note: both "bold" and "message" are bold, which is probably surprising
- await expect(page.locator(".mx_EventTile_body strong", { hasText: "bold message" })).toBeVisible();
- });
-
- test("should allow user to input emoji via graphical picker", async ({ page, app }) => {
- await app.getComposer(false).getByRole("button", { name: "Emoji" }).click();
-
- await page.getByTestId("mx_EmojiPicker").locator(".mx_EmojiPicker_item", { hasText: "😇" }).click();
-
- await page.locator(".mx_ContextualMenu_background").click(); // Close emoji picker
- await page.getByRole("textbox", { name: "Send a message…" }).press("Enter"); // Send message
-
- await expect(page.locator(".mx_EventTile_body", { hasText: "😇" })).toBeVisible();
- });
-
- test.describe("when Control+Enter is required to send", () => {
- test.beforeEach(async ({ app }) => {
- await app.settings.setValue("MessageComposerInput.ctrlEnterToSend", null, SettingLevel.ACCOUNT, true);
- });
-
- test("only sends when you press Control+Enter", async ({ page }) => {
- const composer = page.getByRole("textbox", { name: "Send a message…" });
- // Type a message and press Enter
- await composer.pressSequentially("my message 3");
- await composer.press("Enter");
- // It has not been sent yet
- await expect(page.locator(".mx_EventTile_body", { hasText: "my message 3" })).not.toBeVisible();
-
- // Press Control+Enter
- await composer.press(`${CtrlOrMeta}+Enter`);
- // It was sent
- await expect(
- page.locator(".mx_EventTile_last .mx_EventTile_body", { hasText: "my message 3" }),
- ).toBeVisible();
- });
- });
- });
-
test.describe("Rich text editor", () => {
test.use({
labsFlags: ["feature_wysiwyg_composer"],
diff --git a/playwright/e2e/create-room/create-room.spec.ts b/playwright/e2e/create-room/create-room.spec.ts
index 0e5882e23c5..651439302d5 100644
--- a/playwright/e2e/create-room/create-room.spec.ts
+++ b/playwright/e2e/create-room/create-room.spec.ts
@@ -36,7 +36,7 @@ test.describe("Create Room", () => {
await dialog.getByRole("button", { name: "Create room" }).click();
await expect(page).toHaveURL(/\/#\/room\/#test-room-1:localhost/);
- const header = page.locator(".mx_LegacyRoomHeader");
+ const header = page.locator(".mx_RoomHeader");
await expect(header).toContainText(name);
await expect(header).toContainText(topic);
});
diff --git a/playwright/e2e/crypto/crypto.spec.ts b/playwright/e2e/crypto/crypto.spec.ts
index 326aeaff8e7..3f4621f90fa 100644
--- a/playwright/e2e/crypto/crypto.spec.ts
+++ b/playwright/e2e/crypto/crypto.spec.ts
@@ -15,29 +15,10 @@ limitations under the License.
*/
import type { Page } from "@playwright/test";
-import type { EmittedEvents, Preset } from "matrix-js-sdk/src/matrix";
import { expect, test } from "../../element-web-test";
-import {
- copyAndContinue,
- createRoom,
- createSharedRoomWithUser,
- doTwoWaySasVerification,
- enableKeyBackup,
- logIntoElement,
- logOutOfElement,
- sendMessageInCurrentRoom,
- verifySession,
- waitForVerificationRequest,
-} from "./utils";
+import { autoJoin, copyAndContinue, createSharedRoomWithUser, enableKeyBackup, verify } from "./utils";
import { Bot } from "../../pages/bot";
import { ElementAppPage } from "../../pages/ElementAppPage";
-import { Client } from "../../pages/client";
-import { isDendrite } from "../../plugins/homeserver/dendrite";
-
-const openRoomInfo = async (page: Page) => {
- await page.getByRole("button", { name: "Room info" }).click();
- return page.locator(".mx_RightPanel");
-};
const checkDMRoom = async (page: Page) => {
const body = page.locator(".mx_RoomView_body");
@@ -88,38 +69,6 @@ const bobJoin = async (page: Page, bob: Bot) => {
return roomId;
};
-/** configure the given MatrixClient to auto-accept any invites */
-async function autoJoin(client: Client) {
- await client.evaluate((cli) => {
- cli.on(window.matrixcs.RoomMemberEvent.Membership, (event, member) => {
- if (member.membership === "invite" && member.userId === cli.getUserId()) {
- cli.joinRoom(member.roomId);
- }
- });
- });
-}
-
-const verify = async (page: Page, bob: Bot) => {
- const bobsVerificationRequestPromise = waitForVerificationRequest(bob);
-
- const roomInfo = await openRoomInfo(page);
- await roomInfo.getByRole("menuitem", { name: "People" }).click();
- await roomInfo.getByText("Bob").click();
- await roomInfo.getByRole("button", { name: "Verify" }).click();
- await roomInfo.getByRole("button", { name: "Start Verification" }).click();
-
- // this requires creating a DM, so can take a while. Give it a longer timeout.
- await roomInfo.getByRole("button", { name: "Verify by emoji" }).click({ timeout: 30000 });
-
- const request = await bobsVerificationRequestPromise;
- // the bot user races with the Element user to hit the "verify by emoji" button
- const verifier = await request.evaluateHandle((request) => request.startVerification("m.sas.v1"));
- await doTwoWaySasVerification(page, verifier);
- await roomInfo.getByRole("button", { name: "They match" }).click();
- await expect(roomInfo.getByText("You've successfully verified Bob!")).toBeVisible();
- await roomInfo.getByRole("button", { name: "Got it" }).click();
-};
-
test.describe("Cryptography", function () {
test.use({
displayName: "Alice",
@@ -248,6 +197,10 @@ test.describe("Cryptography", function () {
await page.getByPlaceholder("Security Key").fill(secretStorageKey);
await page.getByRole("button", { name: "Continue" }).click();
+ // Enter the password
+ await page.getByPlaceholder("Password").fill(aliceCredentials.password);
+ await page.getByRole("button", { name: "Continue" }).click();
+
await expect(async () => {
const masterKey2 = await fetchMasterKey();
expect(masterKey1).not.toEqual(masterKey2);
@@ -271,11 +224,11 @@ test.describe("Cryptography", function () {
await checkDMRoom(page);
const bobRoomId = await bobJoin(page, bob);
await testMessages(page, bob, bobRoomId);
- await verify(page, bob);
+ await verify(app, bob);
// Assert that verified icon is rendered
- await page.getByRole("button", { name: "Room members" }).click();
- await page.getByRole("button", { name: "Room information" }).click();
+ await page.getByTestId("base-card-back-button").click();
+ await page.locator(".mx_RightPanelTabs").getByText("Info").click();
await expect(page.locator('.mx_RoomSummaryCard_badges [data-kind="success"]')).toContainText("Encrypted");
// Take a snapshot of RoomSummaryCard with a verified E2EE icon
@@ -293,525 +246,6 @@ test.describe("Cryptography", function () {
// we need to have a room with the other user present, so we can open the verification panel
await createSharedRoomWithUser(app, bob.credentials.userId);
- await verify(page, bob);
- });
-
- test.describe("event shields", () => {
- let testRoomId: string;
-
- test.beforeEach(async ({ page, bot: bob, user: aliceCredentials, app }) => {
- await app.client.bootstrapCrossSigning(aliceCredentials);
- await autoJoin(bob);
-
- // create an encrypted room
- testRoomId = await createSharedRoomWithUser(app, bob.credentials.userId, {
- name: "TestRoom",
- initial_state: [
- {
- type: "m.room.encryption",
- state_key: "",
- content: {
- algorithm: "m.megolm.v1.aes-sha2",
- },
- },
- ],
- });
- });
-
- test("should show the correct shield on e2e events", async ({
- page,
- app,
- bot: bob,
- homeserver,
- cryptoBackend,
- }) => {
- // Bob has a second, not cross-signed, device
- const bobSecondDevice = new Bot(page, homeserver, {
- bootstrapSecretStorage: false,
- bootstrapCrossSigning: false,
- });
- bobSecondDevice.setCredentials(
- await homeserver.loginUser(bob.credentials.userId, bob.credentials.password),
- );
- await bobSecondDevice.prepareClient();
-
- await bob.sendEvent(testRoomId, null, "m.room.encrypted", {
- algorithm: "m.megolm.v1.aes-sha2",
- ciphertext: "the bird is in the hand",
- });
-
- const last = page.locator(".mx_EventTile_last");
- await expect(last).toContainText("Unable to decrypt message");
- const lastE2eIcon = last.locator(".mx_EventTile_e2eIcon");
- await expect(lastE2eIcon).toHaveClass(/mx_EventTile_e2eIcon_decryption_failure/);
- await lastE2eIcon.focus();
- await expect(page.getByRole("tooltip")).toContainText("This message could not be decrypted");
-
- /* Should show a red padlock for an unencrypted message in an e2e room */
- await bob.evaluate(
- (cli, testRoomId) =>
- cli.http.authedRequest(
- window.matrixcs.Method.Put,
- `/rooms/${encodeURIComponent(testRoomId)}/send/m.room.message/test_txn_1`,
- undefined,
- {
- msgtype: "m.text",
- body: "test unencrypted",
- },
- ),
- testRoomId,
- );
-
- await expect(last).toContainText("test unencrypted");
- await expect(lastE2eIcon).toHaveClass(/mx_EventTile_e2eIcon_warning/);
- await lastE2eIcon.focus();
- await expect(page.getByRole("tooltip")).toContainText("Not encrypted");
-
- /* Should show no padlock for an unverified user */
- // bob sends a valid event
- await bob.sendMessage(testRoomId, "test encrypted 1");
-
- // the message should appear, decrypted, with no warning, but also no "verified"
- const lastTile = page.locator(".mx_EventTile_last");
- const lastTileE2eIcon = lastTile.locator(".mx_EventTile_e2eIcon");
- await expect(lastTile).toContainText("test encrypted 1");
- // no e2e icon
- await expect(lastTileE2eIcon).not.toBeVisible();
-
- /* Now verify Bob */
- await verify(page, bob);
-
- /* Existing message should be updated when user is verified. */
- await expect(last).toContainText("test encrypted 1");
- // still no e2e icon
- await expect(last.locator(".mx_EventTile_e2eIcon")).not.toBeVisible();
-
- /* should show no padlock, and be verified, for a message from a verified device */
- await bob.sendMessage(testRoomId, "test encrypted 2");
-
- await expect(lastTile).toContainText("test encrypted 2");
- // no e2e icon
- await expect(lastTileE2eIcon).not.toBeVisible();
-
- /* should show red padlock for a message from an unverified device */
- await bobSecondDevice.sendMessage(testRoomId, "test encrypted from unverified");
- await expect(lastTile).toContainText("test encrypted from unverified");
- await expect(lastTileE2eIcon).toHaveClass(/mx_EventTile_e2eIcon_warning/);
- await lastTileE2eIcon.focus();
- await expect(page.getByRole("tooltip")).toContainText("Encrypted by a device not verified by its owner.");
-
- /* Should show a grey padlock for a message from an unknown device */
- // bob deletes his second device
- await bobSecondDevice.evaluate((cli) => cli.logout(true));
-
- // wait for the logout to propagate. Workaround for https://github.com/vector-im/element-web/issues/26263 by repeatedly closing and reopening Bob's user info.
- async function awaitOneDevice(iterations = 1) {
- const rightPanel = page.locator(".mx_RightPanel");
- await rightPanel.getByRole("button", { name: "Room members" }).click();
- await rightPanel.getByText("Bob").click();
- const sessionCountText = await rightPanel
- .locator(".mx_UserInfo_devices")
- .getByText(" session", { exact: false })
- .textContent();
- // cf https://github.com/vector-im/element-web/issues/26279: Element-R uses the wrong text here
- if (sessionCountText != "1 session" && sessionCountText != "1 verified session") {
- if (iterations >= 10) {
- throw new Error(`Bob still has ${sessionCountText} after 10 iterations`);
- }
- await awaitOneDevice(iterations + 1);
- }
- }
-
- await awaitOneDevice();
-
- // close and reopen the room, to get the shield to update.
- await app.viewRoomByName("Bob");
- await app.viewRoomByName("TestRoom");
-
- // some debate over whether this should have a red or a grey shield. Legacy crypto shows a grey shield,
- // Rust crypto a red one.
- await expect(last).toContainText("test encrypted from unverified");
- if (cryptoBackend === "rust") {
- await expect(lastE2eIcon).toHaveClass(/mx_EventTile_e2eIcon_warning/);
- } else {
- await expect(lastE2eIcon).toHaveClass(/mx_EventTile_e2eIcon_normal/);
- }
- await lastE2eIcon.focus();
- await expect(page.getByRole("tooltip")).toContainText("Encrypted by an unknown or deleted device.");
- });
-
- test("Should show a grey padlock for a key restored from backup", async ({
- page,
- app,
- bot: bob,
- homeserver,
- user: aliceCredentials,
- }) => {
- const securityKey = await enableKeyBackup(app);
-
- // bob sends a valid event
- await bob.sendMessage(testRoomId, "test encrypted 1");
-
- const lastTile = page.locator(".mx_EventTile_last");
- const lastTileE2eIcon = lastTile.locator(".mx_EventTile_e2eIcon");
- await expect(lastTile).toContainText("test encrypted 1");
- // no e2e icon
- await expect(lastTileE2eIcon).not.toBeVisible();
-
- // Workaround for https://github.com/element-hq/element-web/issues/27267. It can take up to 10 seconds for
- // the key to be backed up.
- await page.waitForTimeout(10000);
-
- /* log out, and back in */
- await logOutOfElement(page);
- await logIntoElement(page, homeserver, aliceCredentials, securityKey);
-
- /* go back to the test room and find Bob's message again */
- await app.viewRoomById(testRoomId);
- await expect(lastTile).toContainText("test encrypted 1");
- // The gray shield would be a mx_EventTile_e2eIcon_normal. The red shield would be a mx_EventTile_e2eIcon_warning.
- // No shield would have no div mx_EventTile_e2eIcon at all.
- await expect(lastTileE2eIcon).toHaveClass(/mx_EventTile_e2eIcon_normal/);
- await lastTileE2eIcon.hover();
- // The key is coming from backup, so it is not anymore possible to establish if the claimed device
- // creator of this key is authentic. The tooltip should be "The authenticity of this encrypted message can't be guaranteed on this device."
- // It is not "Encrypted by an unknown or deleted device." even if the claimed device is actually deleted.
- await expect(page.getByRole("tooltip")).toContainText(
- "The authenticity of this encrypted message can't be guaranteed on this device.",
- );
- });
-
- test("should show the correct shield on edited e2e events", async ({ page, app, bot: bob, homeserver }) => {
- // bob has a second, not cross-signed, device
- const bobSecondDevice = new Bot(page, homeserver, {
- bootstrapSecretStorage: false,
- bootstrapCrossSigning: false,
- });
- bobSecondDevice.setCredentials(
- await homeserver.loginUser(bob.credentials.userId, bob.credentials.password),
- );
- await bobSecondDevice.prepareClient();
-
- // verify Bob
- await verify(page, bob);
-
- // bob sends a valid event
- const testEvent = await bob.sendMessage(testRoomId, "Hoo!");
-
- // the message should appear, decrypted, with no warning
- await expect(
- page.locator(".mx_EventTile", { hasText: "Hoo!" }).locator(".mx_EventTile_e2eIcon_warning"),
- ).not.toBeVisible();
-
- // bob sends an edit to the first message with his unverified device
- await bobSecondDevice.sendMessage(testRoomId, {
- "m.new_content": {
- msgtype: "m.text",
- body: "Haa!",
- },
- "m.relates_to": {
- rel_type: "m.replace",
- event_id: testEvent.event_id,
- },
- });
-
- // the edit should have a warning
- await expect(
- page.locator(".mx_EventTile", { hasText: "Haa!" }).locator(".mx_EventTile_e2eIcon_warning"),
- ).toBeVisible();
-
- // a second edit from the verified device should be ok
- await bob.sendMessage(testRoomId, {
- "m.new_content": {
- msgtype: "m.text",
- body: "Hee!",
- },
- "m.relates_to": {
- rel_type: "m.replace",
- event_id: testEvent.event_id,
- },
- });
-
- await expect(
- page.locator(".mx_EventTile", { hasText: "Hee!" }).locator(".mx_EventTile_e2eIcon_warning"),
- ).not.toBeVisible();
- });
- });
-
- test.describe("decryption failure messages", () => {
- test("should handle device-relative historical messages", async ({
- homeserver,
- page,
- app,
- credentials,
- user,
- cryptoBackend,
- }) => {
- test.skip(cryptoBackend === "legacy", "Not implemented for legacy crypto");
- test.setTimeout(60000);
-
- // Start with a logged-in session, without key backup, and send a message.
- await createRoom(page, "Test room", true);
- await sendMessageInCurrentRoom(page, "test test");
-
- // Log out, discarding the key for the sent message.
- await logOutOfElement(page, true);
-
- // Log in again, and see how the message looks.
- await logIntoElement(page, homeserver, credentials);
- await app.viewRoomByName("Test room");
- const lastTile = page.locator(".mx_EventTile").last();
- await expect(lastTile).toContainText("Historical messages are not available on this device");
- await expect(lastTile.locator(".mx_EventTile_e2eIcon_decryption_failure")).toBeVisible();
-
- // Now, we set up key backup, and then send another message.
- const secretStorageKey = await enableKeyBackup(app);
- await app.viewRoomByName("Test room");
- await sendMessageInCurrentRoom(page, "test2 test2");
-
- // Workaround for https://github.com/element-hq/element-web/issues/27267. It can take up to 10 seconds for
- // the key to be backed up.
- await page.waitForTimeout(10000);
-
- // Finally, log out again, and back in, skipping verification for now, and see what we see.
- await logOutOfElement(page);
- await logIntoElement(page, homeserver, credentials);
- await page.locator(".mx_AuthPage").getByRole("button", { name: "Skip verification for now" }).click();
- await page.locator(".mx_AuthPage").getByRole("button", { name: "I'll verify later" }).click();
- await app.viewRoomByName("Test room");
-
- // There should be two historical events in the timeline
- const tiles = await page.locator(".mx_EventTile").all();
- expect(tiles.length).toBeGreaterThanOrEqual(2);
- // look at the last two tiles only
- for (const tile of tiles.slice(-2)) {
- await expect(tile).toContainText("You need to verify this device for access to historical messages");
- await expect(tile.locator(".mx_EventTile_e2eIcon_decryption_failure")).toBeVisible();
- }
-
- // Now verify our device (setting up key backup), and check what happens
- await verifySession(app, secretStorageKey);
- const tilesAfterVerify = (await page.locator(".mx_EventTile").all()).slice(-2);
-
- // The first message still cannot be decrypted, because it was never backed up. It's now a regular UTD though.
- await expect(tilesAfterVerify[0]).toContainText("Unable to decrypt message");
- await expect(tilesAfterVerify[0].locator(".mx_EventTile_e2eIcon_decryption_failure")).toBeVisible();
-
- // The second message should now be decrypted, with a grey shield
- await expect(tilesAfterVerify[1]).toContainText("test2 test2");
- await expect(tilesAfterVerify[1].locator(".mx_EventTile_e2eIcon_normal")).toBeVisible();
- });
-
- test.describe("non-joined historical messages", () => {
- test.skip(isDendrite, "does not yet support membership on events");
-
- test("should display undecryptable non-joined historical messages with a different message", async ({
- homeserver,
- page,
- app,
- credentials: aliceCredentials,
- user: alice,
- cryptoBackend,
- bot: bob,
- }) => {
- test.skip(cryptoBackend === "legacy", "Not implemented for legacy crypto");
-
- // Bob creates an encrypted room and sends a message to it. He then invites Alice
- const roomId = await bob.evaluate(
- async (client, { alice }) => {
- const encryptionStatePromise = new Promise((resolve) => {
- client.on("RoomState.events" as EmittedEvents, (event, _state, _lastStateEvent) => {
- if (event.getType() === "m.room.encryption") {
- resolve();
- }
- });
- });
-
- const { room_id: roomId } = await client.createRoom({
- initial_state: [
- {
- type: "m.room.encryption",
- content: {
- algorithm: "m.megolm.v1.aes-sha2",
- },
- },
- ],
- name: "Test room",
- preset: "private_chat" as Preset,
- });
-
- // wait for m.room.encryption event, so that when we send a
- // message, it will be encrypted
- await encryptionStatePromise;
-
- await client.sendTextMessage(roomId, "This should be undecryptable");
-
- await client.invite(roomId, alice.userId);
-
- return roomId;
- },
- { alice },
- );
-
- // Alice accepts the invite
- await expect(
- page.getByRole("group", { name: "Invites" }).locator(".mx_RoomSublist_tiles").getByRole("treeitem"),
- ).toHaveCount(1);
- await page.getByRole("treeitem", { name: "Test room" }).click();
- await page.locator(".mx_RoomView").getByRole("button", { name: "Accept" }).click();
-
- // Bob sends an encrypted event and an undecryptable event
- await bob.evaluate(
- async (client, { roomId }) => {
- await client.sendTextMessage(roomId, "This should be decryptable");
- await client.sendEvent(
- roomId,
- "m.room.encrypted" as any,
- {
- algorithm: "m.megolm.v1.aes-sha2",
- ciphertext: "this+message+will+be+undecryptable",
- device_id: client.getDeviceId()!,
- sender_key: (await client.getCrypto()!.getOwnDeviceKeys()).ed25519,
- session_id: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
- } as any,
- );
- },
- { roomId },
- );
-
- // We wait for the event tiles that we expect from the messages that
- // Bob sent, in sequence.
- await expect(
- page.locator(`.mx_EventTile`).getByText("You don't have access to this message"),
- ).toBeVisible();
- await expect(page.locator(`.mx_EventTile`).getByText("This should be decryptable")).toBeVisible();
- await expect(page.locator(`.mx_EventTile`).getByText("Unable to decrypt message")).toBeVisible();
-
- // And then we ensure that they are where we expect them to be
- // Alice should see these event tiles:
- // - first message sent by Bob (undecryptable)
- // - Bob invited Alice
- // - Alice joined the room
- // - second message sent by Bob (decryptable)
- // - third message sent by Bob (undecryptable)
- const tiles = await page.locator(".mx_EventTile").all();
- expect(tiles.length).toBeGreaterThanOrEqual(5);
-
- // The first message from Bob was sent before Alice was in the room, so should
- // be different from the standard UTD message
- await expect(tiles[tiles.length - 5]).toContainText("You don't have access to this message");
- await expect(tiles[tiles.length - 5].locator(".mx_EventTile_e2eIcon_decryption_failure")).toBeVisible();
-
- // The second message from Bob should be decryptable
- await expect(tiles[tiles.length - 2]).toContainText("This should be decryptable");
- // this tile won't have an e2e icon since we got the key from the sender
-
- // The third message from Bob is undecryptable, but was sent while Alice was
- // in the room and is expected to be decryptable, so this should have the
- // standard UTD message
- await expect(tiles[tiles.length - 1]).toContainText("Unable to decrypt message");
- await expect(tiles[tiles.length - 1].locator(".mx_EventTile_e2eIcon_decryption_failure")).toBeVisible();
- });
-
- test("should be able to jump to a message sent before our last join event", async ({
- homeserver,
- page,
- app,
- credentials: aliceCredentials,
- user: alice,
- cryptoBackend,
- bot: bob,
- }) => {
- // The old pre-join UTD hiding code would hide events sent
- // before our latest join event, even if the event that we're
- // jumping to was decryptable. We test that this no longer happens.
-
- test.skip(cryptoBackend === "legacy", "Not implemented for legacy crypto");
-
- // Bob:
- // - creates an encrypted room,
- // - invites Alice,
- // - sends a message to it,
- // - kicks Alice,
- // - sends a bunch more events
- // - invites Alice again
- // In this way, there will be an event that Alice can decrypt,
- // followed by a bunch of undecryptable events which Alice shouldn't
- // expect to be able to decrypt. The old code would have hidden all
- // the events, even the decryptable event (which it wouldn't have
- // even tried to fetch, if it was far enough back).
- const { roomId, eventId } = await bob.evaluate(
- async (client, { alice }) => {
- const { room_id: roomId } = await client.createRoom({
- initial_state: [
- {
- type: "m.room.encryption",
- content: {
- algorithm: "m.megolm.v1.aes-sha2",
- },
- },
- ],
- name: "Test room",
- preset: "private_chat" as Preset,
- });
-
- // invite Alice
- const inviteAlicePromise = new Promise((resolve) => {
- client.on("RoomMember.membership" as EmittedEvents, (_event, member, _oldMembership?) => {
- if (member.userId === alice.userId && member.membership === "invite") {
- resolve();
- }
- });
- });
- await client.invite(roomId, alice.userId);
- // wait for the invite to come back so that we encrypt to Alice
- await inviteAlicePromise;
-
- // send a message that Alice should be able to decrypt
- const { event_id: eventId } = await client.sendTextMessage(
- roomId,
- "This should be decryptable",
- );
-
- // kick Alice
- const kickAlicePromise = new Promise((resolve) => {
- client.on("RoomMember.membership" as EmittedEvents, (_event, member, _oldMembership?) => {
- if (member.userId === alice.userId && member.membership === "leave") {
- resolve();
- }
- });
- });
- await client.kick(roomId, alice.userId);
- await kickAlicePromise;
-
- // send a bunch of messages that Alice won't be able to decrypt
- for (let i = 0; i < 20; i++) {
- await client.sendTextMessage(roomId, `${i}`);
- }
-
- // invite Alice again
- await client.invite(roomId, alice.userId);
-
- return { roomId, eventId };
- },
- { alice },
- );
-
- // Alice accepts the invite
- await expect(
- page.getByRole("group", { name: "Invites" }).locator(".mx_RoomSublist_tiles").getByRole("treeitem"),
- ).toHaveCount(1);
- await page.getByRole("treeitem", { name: "Test room" }).click();
- await page.locator(".mx_RoomView").getByRole("button", { name: "Accept" }).click();
-
- // wait until we're joined and see the timeline
- await expect(page.locator(`.mx_EventTile`).getByText("Alice joined the room")).toBeVisible();
-
- // we should be able to jump to the decryptable message that Bob sent
- await page.goto(`#/room/${roomId}/${eventId}`);
-
- await expect(page.locator(`.mx_EventTile`).getByText("This should be decryptable")).toBeVisible();
- });
- });
+ await verify(app, bob);
});
});
diff --git a/playwright/e2e/crypto/decryption-failure-messages.spec.ts b/playwright/e2e/crypto/decryption-failure-messages.spec.ts
new file mode 100644
index 00000000000..bcefa947adb
--- /dev/null
+++ b/playwright/e2e/crypto/decryption-failure-messages.spec.ts
@@ -0,0 +1,302 @@
+/*
+Copyright 2022-2024 The Matrix.org Foundation C.I.C.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+import type { EmittedEvents, Preset } from "matrix-js-sdk/src/matrix";
+import { expect, test } from "../../element-web-test";
+import {
+ createRoom,
+ enableKeyBackup,
+ logIntoElement,
+ logOutOfElement,
+ sendMessageInCurrentRoom,
+ verifySession,
+} from "./utils";
+import { isDendrite } from "../../plugins/homeserver/dendrite";
+
+test.describe("Cryptography", function () {
+ test.use({
+ displayName: "Alice",
+ botCreateOpts: {
+ displayName: "Bob",
+ autoAcceptInvites: false,
+ },
+ });
+
+ test.describe("decryption failure messages", () => {
+ test("should handle device-relative historical messages", async ({
+ homeserver,
+ page,
+ app,
+ credentials,
+ user,
+ }) => {
+ test.setTimeout(60000);
+
+ // Start with a logged-in session, without key backup, and send a message.
+ await createRoom(page, "Test room", true);
+ await sendMessageInCurrentRoom(page, "test test");
+
+ // Log out, discarding the key for the sent message.
+ await logOutOfElement(page, true);
+
+ // Log in again, and see how the message looks.
+ await logIntoElement(page, homeserver, credentials);
+ await app.viewRoomByName("Test room");
+ const lastTile = page.locator(".mx_EventTile").last();
+ await expect(lastTile).toContainText("Historical messages are not available on this device");
+ await expect(lastTile.locator(".mx_EventTile_e2eIcon_decryption_failure")).toBeVisible();
+
+ // Now, we set up key backup, and then send another message.
+ const secretStorageKey = await enableKeyBackup(app);
+ await app.viewRoomByName("Test room");
+ await sendMessageInCurrentRoom(page, "test2 test2");
+
+ // Workaround for https://github.com/element-hq/element-web/issues/27267. It can take up to 10 seconds for
+ // the key to be backed up.
+ await page.waitForTimeout(10000);
+
+ // Finally, log out again, and back in, skipping verification for now, and see what we see.
+ await logOutOfElement(page);
+ await logIntoElement(page, homeserver, credentials);
+ await page.locator(".mx_AuthPage").getByRole("button", { name: "Skip verification for now" }).click();
+ await page.locator(".mx_AuthPage").getByRole("button", { name: "I'll verify later" }).click();
+ await app.viewRoomByName("Test room");
+
+ // There should be two historical events in the timeline
+ const tiles = await page.locator(".mx_EventTile").all();
+ expect(tiles.length).toBeGreaterThanOrEqual(2);
+ // look at the last two tiles only
+ for (const tile of tiles.slice(-2)) {
+ await expect(tile).toContainText("You need to verify this device for access to historical messages");
+ await expect(tile.locator(".mx_EventTile_e2eIcon_decryption_failure")).toBeVisible();
+ }
+
+ // Now verify our device (setting up key backup), and check what happens
+ await verifySession(app, secretStorageKey);
+ const tilesAfterVerify = (await page.locator(".mx_EventTile").all()).slice(-2);
+
+ // The first message still cannot be decrypted, because it was never backed up. It's now a regular UTD though.
+ await expect(tilesAfterVerify[0]).toContainText("Unable to decrypt message");
+ await expect(tilesAfterVerify[0].locator(".mx_EventTile_e2eIcon_decryption_failure")).toBeVisible();
+
+ // The second message should now be decrypted, with a grey shield
+ await expect(tilesAfterVerify[1]).toContainText("test2 test2");
+ await expect(tilesAfterVerify[1].locator(".mx_EventTile_e2eIcon_normal")).toBeVisible();
+ });
+
+ test.describe("non-joined historical messages", () => {
+ test.skip(isDendrite, "does not yet support membership on events");
+
+ test("should display undecryptable non-joined historical messages with a different message", async ({
+ homeserver,
+ page,
+ app,
+ credentials: aliceCredentials,
+ user: alice,
+ bot: bob,
+ }) => {
+ // Bob creates an encrypted room and sends a message to it. He then invites Alice
+ const roomId = await bob.evaluate(
+ async (client, { alice }) => {
+ const encryptionStatePromise = new Promise((resolve) => {
+ client.on("RoomState.events" as EmittedEvents, (event, _state, _lastStateEvent) => {
+ if (event.getType() === "m.room.encryption") {
+ resolve();
+ }
+ });
+ });
+
+ const { room_id: roomId } = await client.createRoom({
+ initial_state: [
+ {
+ type: "m.room.encryption",
+ content: {
+ algorithm: "m.megolm.v1.aes-sha2",
+ },
+ },
+ ],
+ name: "Test room",
+ preset: "private_chat" as Preset,
+ });
+
+ // wait for m.room.encryption event, so that when we send a
+ // message, it will be encrypted
+ await encryptionStatePromise;
+
+ await client.sendTextMessage(roomId, "This should be undecryptable");
+
+ await client.invite(roomId, alice.userId);
+
+ return roomId;
+ },
+ { alice },
+ );
+
+ // Alice accepts the invite
+ await expect(
+ page.getByRole("group", { name: "Invites" }).locator(".mx_RoomSublist_tiles").getByRole("treeitem"),
+ ).toHaveCount(1);
+ await page.getByRole("treeitem", { name: "Test room" }).click();
+ await page.locator(".mx_RoomView").getByRole("button", { name: "Accept" }).click();
+
+ // Bob sends an encrypted event and an undecryptable event
+ await bob.evaluate(
+ async (client, { roomId }) => {
+ await client.sendTextMessage(roomId, "This should be decryptable");
+ await client.sendEvent(
+ roomId,
+ "m.room.encrypted" as any,
+ {
+ algorithm: "m.megolm.v1.aes-sha2",
+ ciphertext: "this+message+will+be+undecryptable",
+ device_id: client.getDeviceId()!,
+ sender_key: (await client.getCrypto()!.getOwnDeviceKeys()).ed25519,
+ session_id: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
+ } as any,
+ );
+ },
+ { roomId },
+ );
+
+ // We wait for the event tiles that we expect from the messages that
+ // Bob sent, in sequence.
+ await expect(
+ page.locator(`.mx_EventTile`).getByText("You don't have access to this message"),
+ ).toBeVisible();
+ await expect(page.locator(`.mx_EventTile`).getByText("This should be decryptable")).toBeVisible();
+ await expect(page.locator(`.mx_EventTile`).getByText("Unable to decrypt message")).toBeVisible();
+
+ // And then we ensure that they are where we expect them to be
+ // Alice should see these event tiles:
+ // - first message sent by Bob (undecryptable)
+ // - Bob invited Alice
+ // - Alice joined the room
+ // - second message sent by Bob (decryptable)
+ // - third message sent by Bob (undecryptable)
+ const tiles = await page.locator(".mx_EventTile").all();
+ expect(tiles.length).toBeGreaterThanOrEqual(5);
+
+ // The first message from Bob was sent before Alice was in the room, so should
+ // be different from the standard UTD message
+ await expect(tiles[tiles.length - 5]).toContainText("You don't have access to this message");
+ await expect(tiles[tiles.length - 5].locator(".mx_EventTile_e2eIcon_decryption_failure")).toBeVisible();
+
+ // The second message from Bob should be decryptable
+ await expect(tiles[tiles.length - 2]).toContainText("This should be decryptable");
+ // this tile won't have an e2e icon since we got the key from the sender
+
+ // The third message from Bob is undecryptable, but was sent while Alice was
+ // in the room and is expected to be decryptable, so this should have the
+ // standard UTD message
+ await expect(tiles[tiles.length - 1]).toContainText("Unable to decrypt message");
+ await expect(tiles[tiles.length - 1].locator(".mx_EventTile_e2eIcon_decryption_failure")).toBeVisible();
+ });
+
+ test("should be able to jump to a message sent before our last join event", async ({
+ homeserver,
+ page,
+ app,
+ credentials: aliceCredentials,
+ user: alice,
+ bot: bob,
+ }) => {
+ // Bob:
+ // - creates an encrypted room,
+ // - invites Alice,
+ // - sends a message to it,
+ // - kicks Alice,
+ // - sends a bunch more events
+ // - invites Alice again
+ // In this way, there will be an event that Alice can decrypt,
+ // followed by a bunch of undecryptable events which Alice shouldn't
+ // expect to be able to decrypt. The old code would have hidden all
+ // the events, even the decryptable event (which it wouldn't have
+ // even tried to fetch, if it was far enough back).
+ const { roomId, eventId } = await bob.evaluate(
+ async (client, { alice }) => {
+ const { room_id: roomId } = await client.createRoom({
+ initial_state: [
+ {
+ type: "m.room.encryption",
+ content: {
+ algorithm: "m.megolm.v1.aes-sha2",
+ },
+ },
+ ],
+ name: "Test room",
+ preset: "private_chat" as Preset,
+ });
+
+ // invite Alice
+ const inviteAlicePromise = new Promise((resolve) => {
+ client.on("RoomMember.membership" as EmittedEvents, (_event, member, _oldMembership?) => {
+ if (member.userId === alice.userId && member.membership === "invite") {
+ resolve();
+ }
+ });
+ });
+ await client.invite(roomId, alice.userId);
+ // wait for the invite to come back so that we encrypt to Alice
+ await inviteAlicePromise;
+
+ // send a message that Alice should be able to decrypt
+ const { event_id: eventId } = await client.sendTextMessage(
+ roomId,
+ "This should be decryptable",
+ );
+
+ // kick Alice
+ const kickAlicePromise = new Promise((resolve) => {
+ client.on("RoomMember.membership" as EmittedEvents, (_event, member, _oldMembership?) => {
+ if (member.userId === alice.userId && member.membership === "leave") {
+ resolve();
+ }
+ });
+ });
+ await client.kick(roomId, alice.userId);
+ await kickAlicePromise;
+
+ // send a bunch of messages that Alice won't be able to decrypt
+ for (let i = 0; i < 20; i++) {
+ await client.sendTextMessage(roomId, `${i}`);
+ }
+
+ // invite Alice again
+ await client.invite(roomId, alice.userId);
+
+ return { roomId, eventId };
+ },
+ { alice },
+ );
+
+ // Alice accepts the invite
+ await expect(
+ page.getByRole("group", { name: "Invites" }).locator(".mx_RoomSublist_tiles").getByRole("treeitem"),
+ ).toHaveCount(1);
+ await page.getByRole("treeitem", { name: "Test room" }).click();
+ await page.locator(".mx_RoomView").getByRole("button", { name: "Accept" }).click();
+
+ // wait until we're joined and see the timeline
+ await expect(page.locator(`.mx_EventTile`).getByText("Alice joined the room")).toBeVisible();
+
+ // we should be able to jump to the decryptable message that Bob sent
+ await page.goto(`#/room/${roomId}/${eventId}`);
+
+ await expect(page.locator(`.mx_EventTile`).getByText("This should be decryptable")).toBeVisible();
+ });
+ });
+ });
+});
diff --git a/playwright/e2e/crypto/dehydration.spec.ts b/playwright/e2e/crypto/dehydration.spec.ts
index 13da99ccad4..eb9efde4eed 100644
--- a/playwright/e2e/crypto/dehydration.spec.ts
+++ b/playwright/e2e/crypto/dehydration.spec.ts
@@ -102,7 +102,7 @@ test.describe("Dehydration", () => {
await viewRoomSummaryByName(page, app, ROOM_NAME);
- await page.getByRole("menuitem", { name: "People" }).click();
+ await page.locator(".mx_RightPanelTabs").getByText("People").click();
await expect(page.locator(".mx_MemberList")).toBeVisible();
await getMemberTileByName(page, NAME).click();
diff --git a/playwright/e2e/crypto/verification.spec.ts b/playwright/e2e/crypto/device-verification.spec.ts
similarity index 64%
rename from playwright/e2e/crypto/verification.spec.ts
rename to playwright/e2e/crypto/device-verification.spec.ts
index e471b6b2f52..929da09106d 100644
--- a/playwright/e2e/crypto/verification.spec.ts
+++ b/playwright/e2e/crypto/device-verification.spec.ts
@@ -15,19 +15,18 @@ limitations under the License.
*/
import jsQR from "jsqr";
-import { type Preset, type Visibility } from "matrix-js-sdk/src/matrix";
import type { JSHandle, Locator, Page } from "@playwright/test";
-import type { VerificationRequest, Verifier } from "matrix-js-sdk/src/crypto-api";
+import type { VerificationRequest } from "matrix-js-sdk/src/crypto-api";
import { test, expect } from "../../element-web-test";
import {
+ awaitVerifier,
checkDeviceIsConnectedKeyBackup,
checkDeviceIsCrossSigned,
doTwoWaySasVerification,
logIntoElement,
waitForVerificationRequest,
} from "./utils";
-import { Client } from "../../pages/client";
import { Bot } from "../../pages/bot";
test.describe("Device verification", () => {
@@ -40,25 +39,26 @@ test.describe("Device verification", () => {
// Visit the login page of the app, to load the matrix sdk
await page.goto("/#/login");
- await page.pause();
-
// wait for the page to load
await page.waitForSelector(".mx_AuthPage", { timeout: 30000 });
// Create a new device for alice
aliceBotClient = new Bot(page, homeserver, {
- rustCrypto: true,
bootstrapCrossSigning: true,
bootstrapSecretStorage: true,
});
aliceBotClient.setCredentials(credentials);
- const mxClientHandle = await aliceBotClient.prepareClient();
-
- await page.waitForTimeout(20000);
- expectedBackupVersion = await mxClientHandle.evaluate(async (mxClient) => {
- return await mxClient.getCrypto()!.getActiveSessionBackupVersion();
- });
+ // Backup is prepared in the background. Poll until it is ready.
+ const botClientHandle = await aliceBotClient.prepareClient();
+ await expect
+ .poll(async () => {
+ expectedBackupVersion = await botClientHandle.evaluate((cli) =>
+ cli.getCrypto()!.getActiveSessionBackupVersion(),
+ );
+ return expectedBackupVersion;
+ })
+ .not.toBe(null);
});
// Click the "Verify with another device" button, and have the bot client auto-accept it.
@@ -234,115 +234,6 @@ test.describe("Device verification", () => {
});
});
-test.describe("User verification", () => {
- // note that there are other tests that check user verification works in `crypto.spec.ts`.
-
- test.use({
- displayName: "Alice",
- botCreateOpts: { displayName: "Bob", autoAcceptInvites: true, userIdPrefix: "bob_" },
- room: async ({ page, app, bot: bob, user: aliceCredentials }, use) => {
- await app.client.bootstrapCrossSigning(aliceCredentials);
-
- // the other user creates a DM
- const dmRoomId = await createDMRoom(bob, aliceCredentials.userId);
-
- // accept the DM
- await app.viewRoomByName("Bob");
- await page.getByRole("button", { name: "Start chatting" }).click();
- await use({ roomId: dmRoomId });
- },
- });
-
- test("can receive a verification request when there is no existing DM", async ({
- page,
- bot: bob,
- user: aliceCredentials,
- toasts,
- room: { roomId: dmRoomId },
- }) => {
- // once Alice has joined, Bob starts the verification
- const bobVerificationRequest = await bob.evaluateHandle(
- async (client, { dmRoomId, aliceCredentials }) => {
- const room = client.getRoom(dmRoomId);
- while (room.getMember(aliceCredentials.userId)?.membership !== "join") {
- await new Promise((resolve) => {
- room.once(window.matrixcs.RoomStateEvent.Members, resolve);
- });
- }
-
- return client.getCrypto().requestVerificationDM(aliceCredentials.userId, dmRoomId);
- },
- { dmRoomId, aliceCredentials },
- );
-
- // there should also be a toast
- const toast = await toasts.getToast("Verification requested");
- // it should contain the details of the requesting user
- await expect(toast.getByText(`Bob (${bob.credentials.userId})`)).toBeVisible();
- // Accept
- await toast.getByRole("button", { name: "Verify Session" }).click();
-
- // request verification by emoji
- await page.locator("#mx_RightPanel").getByRole("button", { name: "Verify by emoji" }).click();
-
- /* on the bot side, wait for the verifier to exist ... */
- const botVerifier = await awaitVerifier(bobVerificationRequest);
- // ... confirm ...
- botVerifier.evaluate((verifier) => verifier.verify());
- // ... and then check the emoji match
- await doTwoWaySasVerification(page, botVerifier);
-
- await page.getByRole("button", { name: "They match" }).click();
- await expect(page.getByText("You've successfully verified Bob!")).toBeVisible();
- await page.getByRole("button", { name: "Got it" }).click();
- });
-
- test("can abort emoji verification when emoji mismatch", async ({
- page,
- bot: bob,
- user: aliceCredentials,
- toasts,
- room: { roomId: dmRoomId },
- cryptoBackend,
- }) => {
- test.skip(cryptoBackend === "legacy", "Not implemented for legacy crypto");
-
- // once Alice has joined, Bob starts the verification
- const bobVerificationRequest = await bob.evaluateHandle(
- async (client, { dmRoomId, aliceCredentials }) => {
- const room = client.getRoom(dmRoomId);
- while (room.getMember(aliceCredentials.userId)?.membership !== "join") {
- await new Promise((resolve) => {
- room.once(window.matrixcs.RoomStateEvent.Members, resolve);
- });
- }
-
- return client.getCrypto().requestVerificationDM(aliceCredentials.userId, dmRoomId);
- },
- { dmRoomId, aliceCredentials },
- );
-
- // Accept verification via toast
- const toast = await toasts.getToast("Verification requested");
- await toast.getByRole("button", { name: "Verify Session" }).click();
-
- // request verification by emoji
- await page.locator("#mx_RightPanel").getByRole("button", { name: "Verify by emoji" }).click();
-
- /* on the bot side, wait for the verifier to exist ... */
- const botVerifier = await awaitVerifier(bobVerificationRequest);
- // ... confirm ...
- botVerifier.evaluate((verifier) => verifier.verify()).catch(() => {});
- // ... and abort the verification
- await page.getByRole("button", { name: "They don't match" }).click();
-
- const dialog = page.locator(".mx_Dialog");
- await expect(dialog.getByText("Your messages are not secure")).toBeVisible();
- await dialog.getByRole("button", { name: "OK" }).click();
- await expect(dialog).not.toBeVisible();
- });
-});
-
/** Extract the qrcode out of an on-screen html element */
async function readQrCode(base: Locator) {
const qrCode = base.locator('[alt="QR Code"]');
@@ -374,35 +265,3 @@ async function readQrCode(base: Locator) {
const result = jsQR(new Uint8ClampedArray(imageData.buffer), imageData.width, imageData.height);
return new Uint8Array(result.binaryData);
}
-
-async function createDMRoom(client: Client, userId: string): Promise {
- return client.createRoom({
- preset: "trusted_private_chat" as Preset,
- visibility: "private" as Visibility,
- invite: [userId],
- is_direct: true,
- initial_state: [
- {
- type: "m.room.encryption",
- state_key: "",
- content: {
- algorithm: "m.megolm.v1.aes-sha2",
- },
- },
- ],
- });
-}
-
-/**
- * Wait for a verifier to exist for a VerificationRequest
- *
- * @param botVerificationRequest
- */
-async function awaitVerifier(botVerificationRequest: JSHandle): Promise> {
- return botVerificationRequest.evaluateHandle(async (verificationRequest) => {
- while (!verificationRequest.verifier) {
- await new Promise((r) => verificationRequest.once("change" as any, r));
- }
- return verificationRequest.verifier;
- });
-}
diff --git a/playwright/e2e/crypto/event-shields.spec.ts b/playwright/e2e/crypto/event-shields.spec.ts
new file mode 100644
index 00000000000..b242dd060c5
--- /dev/null
+++ b/playwright/e2e/crypto/event-shields.spec.ts
@@ -0,0 +1,269 @@
+/*
+Copyright 2022-2024 The Matrix.org Foundation C.I.C.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+import { expect, test } from "../../element-web-test";
+import { autoJoin, createSharedRoomWithUser, enableKeyBackup, logIntoElement, logOutOfElement, verify } from "./utils";
+import { Bot } from "../../pages/bot";
+
+test.describe("Cryptography", function () {
+ test.use({
+ displayName: "Alice",
+ botCreateOpts: {
+ displayName: "Bob",
+ autoAcceptInvites: false,
+ },
+ });
+
+ test.describe("event shields", () => {
+ let testRoomId: string;
+
+ test.beforeEach(async ({ page, bot: bob, user: aliceCredentials, app }) => {
+ await app.client.bootstrapCrossSigning(aliceCredentials);
+ await autoJoin(bob);
+
+ // create an encrypted room
+ testRoomId = await createSharedRoomWithUser(app, bob.credentials.userId, {
+ name: "TestRoom",
+ initial_state: [
+ {
+ type: "m.room.encryption",
+ state_key: "",
+ content: {
+ algorithm: "m.megolm.v1.aes-sha2",
+ },
+ },
+ ],
+ });
+ });
+
+ test("should show the correct shield on e2e events", async ({ page, app, bot: bob, homeserver }) => {
+ // Bob has a second, not cross-signed, device
+ const bobSecondDevice = new Bot(page, homeserver, {
+ bootstrapSecretStorage: false,
+ bootstrapCrossSigning: false,
+ });
+ bobSecondDevice.setCredentials(
+ await homeserver.loginUser(bob.credentials.userId, bob.credentials.password),
+ );
+ await bobSecondDevice.prepareClient();
+
+ await bob.sendEvent(testRoomId, null, "m.room.encrypted", {
+ algorithm: "m.megolm.v1.aes-sha2",
+ ciphertext: "the bird is in the hand",
+ });
+
+ const last = page.locator(".mx_EventTile_last");
+ await expect(last).toContainText("Unable to decrypt message");
+ const lastE2eIcon = last.locator(".mx_EventTile_e2eIcon");
+ await expect(lastE2eIcon).toHaveClass(/mx_EventTile_e2eIcon_decryption_failure/);
+ await lastE2eIcon.focus();
+ await expect(page.getByRole("tooltip")).toContainText("This message could not be decrypted");
+
+ /* Should show a red padlock for an unencrypted message in an e2e room */
+ await bob.evaluate(
+ (cli, testRoomId) =>
+ cli.http.authedRequest(
+ window.matrixcs.Method.Put,
+ `/rooms/${encodeURIComponent(testRoomId)}/send/m.room.message/test_txn_1`,
+ undefined,
+ {
+ msgtype: "m.text",
+ body: "test unencrypted",
+ },
+ ),
+ testRoomId,
+ );
+
+ await expect(last).toContainText("test unencrypted");
+ await expect(lastE2eIcon).toHaveClass(/mx_EventTile_e2eIcon_warning/);
+ await lastE2eIcon.focus();
+ await expect(page.getByRole("tooltip")).toContainText("Not encrypted");
+
+ /* Should show no padlock for an unverified user */
+ // bob sends a valid event
+ await bob.sendMessage(testRoomId, "test encrypted 1");
+
+ // the message should appear, decrypted, with no warning, but also no "verified"
+ const lastTile = page.locator(".mx_EventTile_last");
+ const lastTileE2eIcon = lastTile.locator(".mx_EventTile_e2eIcon");
+ await expect(lastTile).toContainText("test encrypted 1");
+ // no e2e icon
+ await expect(lastTileE2eIcon).not.toBeVisible();
+
+ /* Now verify Bob */
+ await verify(app, bob);
+
+ /* Existing message should be updated when user is verified. */
+ await expect(last).toContainText("test encrypted 1");
+ // still no e2e icon
+ await expect(last.locator(".mx_EventTile_e2eIcon")).not.toBeVisible();
+
+ /* should show no padlock, and be verified, for a message from a verified device */
+ await bob.sendMessage(testRoomId, "test encrypted 2");
+
+ await expect(lastTile).toContainText("test encrypted 2");
+ // no e2e icon
+ await expect(lastTileE2eIcon).not.toBeVisible();
+
+ /* should show red padlock for a message from an unverified device */
+ await bobSecondDevice.sendMessage(testRoomId, "test encrypted from unverified");
+ await expect(lastTile).toContainText("test encrypted from unverified");
+ await expect(lastTileE2eIcon).toHaveClass(/mx_EventTile_e2eIcon_warning/);
+ await lastTileE2eIcon.focus();
+ await expect(page.getByRole("tooltip")).toContainText("Encrypted by a device not verified by its owner.");
+
+ /* Should show a grey padlock for a message from an unknown device */
+ // bob deletes his second device
+ await bobSecondDevice.evaluate((cli) => cli.logout(true));
+
+ // wait for the logout to propagate. Workaround for https://github.com/vector-im/element-web/issues/26263 by repeatedly closing and reopening Bob's user info.
+ async function awaitOneDevice(iterations = 1) {
+ const rightPanel = page.locator(".mx_RightPanel");
+ await rightPanel.getByTestId("base-card-back-button").click();
+ await rightPanel.getByText("Bob").click();
+ const sessionCountText = await rightPanel
+ .locator(".mx_UserInfo_devices")
+ .getByText(" session", { exact: false })
+ .textContent();
+ // cf https://github.com/vector-im/element-web/issues/26279: Element-R uses the wrong text here
+ if (sessionCountText != "1 session" && sessionCountText != "1 verified session") {
+ if (iterations >= 10) {
+ throw new Error(`Bob still has ${sessionCountText} after 10 iterations`);
+ }
+ await awaitOneDevice(iterations + 1);
+ }
+ }
+
+ await awaitOneDevice();
+
+ // close and reopen the room, to get the shield to update.
+ await app.viewRoomByName("Bob");
+ await app.viewRoomByName("TestRoom");
+
+ await expect(last).toContainText("test encrypted from unverified");
+ await expect(lastE2eIcon).toHaveClass(/mx_EventTile_e2eIcon_warning/);
+ await lastE2eIcon.focus();
+ await expect(page.getByRole("tooltip")).toContainText("Encrypted by an unknown or deleted device.");
+ });
+
+ test("Should show a grey padlock for a key restored from backup", async ({
+ page,
+ app,
+ bot: bob,
+ homeserver,
+ user: aliceCredentials,
+ }) => {
+ test.slow();
+ const securityKey = await enableKeyBackup(app);
+
+ // bob sends a valid event
+ await bob.sendMessage(testRoomId, "test encrypted 1");
+
+ const lastTile = page.locator(".mx_EventTile_last");
+ const lastTileE2eIcon = lastTile.locator(".mx_EventTile_e2eIcon");
+ await expect(lastTile).toContainText("test encrypted 1");
+ // no e2e icon
+ await expect(lastTileE2eIcon).not.toBeVisible();
+
+ // Workaround for https://github.com/element-hq/element-web/issues/27267. It can take up to 10 seconds for
+ // the key to be backed up.
+ await page.waitForTimeout(10000);
+
+ /* log out, and back in */
+ await logOutOfElement(page);
+ // Reload to work around a Rust crypto bug where it can hold onto the indexeddb even after logout
+ // https://github.com/element-hq/element-web/issues/25779
+ await page.addInitScript(() => {
+ // When we reload, the initScript created by the `user`/`pageWithCredentials` fixtures
+ // will re-inject the original credentials into localStorage, which we don't want.
+ // To work around, we add a second initScript which will clear localStorage again.
+ window.localStorage.clear();
+ });
+ await page.reload();
+ await logIntoElement(page, homeserver, aliceCredentials, securityKey);
+
+ /* go back to the test room and find Bob's message again */
+ await app.viewRoomById(testRoomId);
+ await expect(lastTile).toContainText("test encrypted 1");
+ // The gray shield would be a mx_EventTile_e2eIcon_normal. The red shield would be a mx_EventTile_e2eIcon_warning.
+ // No shield would have no div mx_EventTile_e2eIcon at all.
+ await expect(lastTileE2eIcon).toHaveClass(/mx_EventTile_e2eIcon_normal/);
+ await lastTileE2eIcon.hover();
+ // The key is coming from backup, so it is not anymore possible to establish if the claimed device
+ // creator of this key is authentic. The tooltip should be "The authenticity of this encrypted message can't be guaranteed on this device."
+ // It is not "Encrypted by an unknown or deleted device." even if the claimed device is actually deleted.
+ await expect(page.getByRole("tooltip")).toContainText(
+ "The authenticity of this encrypted message can't be guaranteed on this device.",
+ );
+ });
+
+ test("should show the correct shield on edited e2e events", async ({ page, app, bot: bob, homeserver }) => {
+ // bob has a second, not cross-signed, device
+ const bobSecondDevice = new Bot(page, homeserver, {
+ bootstrapSecretStorage: false,
+ bootstrapCrossSigning: false,
+ });
+ bobSecondDevice.setCredentials(
+ await homeserver.loginUser(bob.credentials.userId, bob.credentials.password),
+ );
+ await bobSecondDevice.prepareClient();
+
+ // verify Bob
+ await verify(app, bob);
+
+ // bob sends a valid event
+ const testEvent = await bob.sendMessage(testRoomId, "Hoo!");
+
+ // the message should appear, decrypted, with no warning
+ await expect(
+ page.locator(".mx_EventTile", { hasText: "Hoo!" }).locator(".mx_EventTile_e2eIcon_warning"),
+ ).not.toBeVisible();
+
+ // bob sends an edit to the first message with his unverified device
+ await bobSecondDevice.sendMessage(testRoomId, {
+ "m.new_content": {
+ msgtype: "m.text",
+ body: "Haa!",
+ },
+ "m.relates_to": {
+ rel_type: "m.replace",
+ event_id: testEvent.event_id,
+ },
+ });
+
+ // the edit should have a warning
+ await expect(
+ page.locator(".mx_EventTile", { hasText: "Haa!" }).locator(".mx_EventTile_e2eIcon_warning"),
+ ).toBeVisible();
+
+ // a second edit from the verified device should be ok
+ await bob.sendMessage(testRoomId, {
+ "m.new_content": {
+ msgtype: "m.text",
+ body: "Hee!",
+ },
+ "m.relates_to": {
+ rel_type: "m.replace",
+ event_id: testEvent.event_id,
+ },
+ });
+
+ await expect(
+ page.locator(".mx_EventTile", { hasText: "Hee!" }).locator(".mx_EventTile_e2eIcon_warning"),
+ ).not.toBeVisible();
+ });
+ });
+});
diff --git a/playwright/e2e/crypto/staged-rollout.spec.ts b/playwright/e2e/crypto/staged-rollout.spec.ts
deleted file mode 100644
index acdd20bc899..00000000000
--- a/playwright/e2e/crypto/staged-rollout.spec.ts
+++ /dev/null
@@ -1,290 +0,0 @@
-/*
-Copyright 2024 The Matrix.org Foundation C.I.C.
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
- http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
-*/
-
-import { test, expect } from "../../element-web-test";
-import { createRoom, enableKeyBackup, logIntoElement, logOutOfElement, sendMessageInCurrentRoom } from "./utils";
-import { SettingLevel } from "../../../src/settings/SettingLevel";
-
-test.describe("Adoption of rust stack", () => {
- test("Test migration of existing logins when rollout is 100%", async ({
- page,
- context,
- app,
- credentials,
- homeserver,
- }, workerInfo) => {
- test.skip(
- workerInfo.project.name === "Rust Crypto",
- "No need to test this on Rust Crypto as we override the config manually",
- );
- await page.goto("/#/login");
- test.slow();
-
- let featureRustCrypto = false;
- let stagedRolloutPercent = 0;
-
- await context.route(`http://localhost:8080/config.json*`, async (route) => {
- const json = {
- default_server_config: {
- "m.homeserver": {
- base_url: "https://server.invalid",
- },
- },
- };
- json["features"] = {
- feature_rust_crypto: featureRustCrypto,
- };
- json["setting_defaults"] = {
- "language": "en-GB",
- "RustCrypto.staged_rollout_percent": stagedRolloutPercent,
- };
- await route.fulfill({ json });
- });
-
- // reload to ensure we read the config
- await page.reload();
-
- await logIntoElement(page, homeserver, credentials);
-
- await app.settings.openUserSettings("Help & About");
- await expect(page.getByText("Crypto version: Olm")).toBeVisible();
-
- featureRustCrypto = true;
-
- await page.reload();
-
- await app.settings.openUserSettings("Help & About");
- await expect(page.getByText("Crypto version: Olm")).toBeVisible();
-
- stagedRolloutPercent = 100;
-
- await page.reload();
-
- await app.settings.openUserSettings("Help & About");
- await expect(page.getByText("Crypto version: Rust SDK")).toBeVisible();
- });
-
- test("Test new logins by default on rust stack", async ({
- page,
- context,
- app,
- credentials,
- homeserver,
- }, workerInfo) => {
- test.skip(
- workerInfo.project.name === "Rust Crypto",
- "No need to test this on Rust Crypto as we override the config manually",
- );
- test.slow();
- await page.goto("/#/login");
-
- await context.route(`http://localhost:8080/config.json*`, async (route) => {
- const json = {
- default_server_config: {
- "m.homeserver": {
- base_url: "https://server.invalid",
- },
- },
- };
- // we only want to test the default
- json["features"] = {};
- json["setting_defaults"] = {
- language: "en-GB",
- };
- await route.fulfill({ json });
- });
-
- // reload to get the new config
- await page.reload();
- await logIntoElement(page, homeserver, credentials);
-
- await app.settings.openUserSettings("Help & About");
- await expect(page.getByText("Crypto version: Rust SDK")).toBeVisible();
- });
-
- test("Test default is to not rollout existing logins", async ({
- page,
- context,
- app,
- credentials,
- homeserver,
- }, workerInfo) => {
- test.skip(
- workerInfo.project.name === "Rust Crypto",
- "No need to test this on Rust Crypto as we override the config manually",
- );
- test.slow();
-
- await page.goto("/#/login");
-
- // In the project.name = "Legacy crypto" it will be olm crypto
- await logIntoElement(page, homeserver, credentials);
-
- await app.settings.openUserSettings("Help & About");
- await expect(page.getByText("Crypto version: Olm")).toBeVisible();
-
- // Now simulate a refresh with `feature_rust_crypto` enabled but ensure we use the default rollout
- await context.route(`http://localhost:8080/config.json*`, async (route) => {
- const json = {};
- json["features"] = {
- feature_rust_crypto: true,
- };
- json["setting_defaults"] = {
- // We want to test the default so we don't set this
- // "RustCrypto.staged_rollout_percent": 0,
- };
- await route.fulfill({ json });
- });
-
- await page.reload();
-
- await app.settings.openUserSettings("Help & About");
- await expect(page.getByText("Crypto version: Olm")).toBeVisible();
- });
-
- test("Migrate using labflag should work", async ({ page, context, app, credentials, homeserver }, workerInfo) => {
- test.skip(
- workerInfo.project.name === "Rust Crypto",
- "No need to test this on Rust Crypto as we override the config manually",
- );
- test.slow();
-
- await page.goto("/#/login");
-
- // In the project.name = "Legacy crypto" it will be olm crypto
- await logIntoElement(page, homeserver, credentials);
-
- await app.settings.openUserSettings("Help & About");
- await expect(page.getByText("Crypto version: Olm")).toBeVisible();
-
- // We need to enable devtools for this test
- await app.settings.setValue("developerMode", null, SettingLevel.ACCOUNT, true);
-
- // Now simulate a refresh with `feature_rust_crypto` enabled but ensure no automatic migration
- await context.route(`http://localhost:8080/config.json*`, async (route) => {
- const json = {};
- json["features"] = {
- feature_rust_crypto: true,
- };
- json["setting_defaults"] = {
- "RustCrypto.staged_rollout_percent": 0,
- };
- await route.fulfill({ json });
- });
-
- await page.reload();
-
- // Go to the labs flag and enable the migration
- await app.settings.openUserSettings("Labs");
- await page.getByRole("switch", { name: "Rust cryptography implementation" }).click();
-
- // Fixes a bug where a missing session data was shown
- // https://github.com/element-hq/element-web/issues/26970
-
- await app.settings.openUserSettings("Help & About");
- await expect(page.getByText("Crypto version: Rust SDK")).toBeVisible();
- });
-
- test("Test migration of room shields", async ({ page, context, app, credentials, homeserver }, workerInfo) => {
- test.skip(
- workerInfo.project.name === "Rust Crypto",
- "No need to test this on Rust Crypto as we override the config manually",
- );
- test.slow();
-
- await page.goto("/#/login");
-
- // In the project.name = "Legacy crypto" it will be olm crypto
- await logIntoElement(page, homeserver, credentials);
-
- // create a room and send a message
- await createRoom(page, "Room1", true);
- await sendMessageInCurrentRoom(page, "Hello");
-
- // enable backup to save this room key
- const securityKey = await enableKeyBackup(app);
-
- // wait a bit for upload to complete, there is a random timout on key upload
- await page.waitForTimeout(6000);
-
- // logout
- await logOutOfElement(page);
-
- // We logout and log back in in order to get the historical key from backup and have a gray shield
- await page.reload();
- await page.goto("/#/login");
- // login again and verify
- await logIntoElement(page, homeserver, credentials, securityKey);
-
- await app.viewRoomByName("Room1");
-
- {
- const messageDiv = page.locator(".mx_EventTile_line").filter({ hasText: "Hello" });
- // there should be a shield
- await expect(messageDiv.locator(".mx_EventTile_e2eIcon")).toBeVisible();
- }
-
- // Now type a new message
- await sendMessageInCurrentRoom(page, "World");
-
- // wait a bit for the message to be sent
- await expect(
- page
- .locator(".mx_EventTile_line")
- .filter({ hasText: "World" })
- .locator("..")
- .locator(".mx_EventTile_receiptSent"),
- ).toBeVisible();
- {
- const messageDiv = page.locator(".mx_EventTile_line").filter({ hasText: "World" });
- // there should not be a shield
- expect(await messageDiv.locator(".mx_EventTile_e2eIcon").count()).toEqual(0);
- }
-
- // trigger a migration
- await context.route(`http://localhost:8080/config.json*`, async (route) => {
- const json = {};
- json["features"] = {
- feature_rust_crypto: true,
- };
- json["setting_defaults"] = {
- "RustCrypto.staged_rollout_percent": 100,
- };
- await route.fulfill({ json });
- });
-
- await page.reload();
-
- await app.viewRoomByName("Room1");
-
- // The shields should be migrated properly
- {
- const messageDiv = page.locator(".mx_EventTile_line").filter({ hasText: "Hello" });
- await expect(messageDiv).toBeVisible();
- // there should be a shield
- await expect(messageDiv.locator(".mx_EventTile_e2eIcon")).toBeVisible();
- }
- {
- const messageDiv = page.locator(".mx_EventTile_line").filter({ hasText: "World" });
- await expect(messageDiv).toBeVisible();
- // there should not be a shield
- expect(await messageDiv.locator(".mx_EventTile_e2eIcon").count()).toEqual(0);
- }
-
- await app.settings.openUserSettings("Help & About");
- await expect(page.getByText("Crypto version: Rust SDK")).toBeVisible();
- });
-});
diff --git a/playwright/e2e/crypto/user-verification.spec.ts b/playwright/e2e/crypto/user-verification.spec.ts
new file mode 100644
index 00000000000..eac0fb639e1
--- /dev/null
+++ b/playwright/e2e/crypto/user-verification.spec.ts
@@ -0,0 +1,145 @@
+/*
+Copyright 2023 The Matrix.org Foundation C.I.C.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+import { type Preset, type Visibility } from "matrix-js-sdk/src/matrix";
+
+import { test, expect } from "../../element-web-test";
+import { doTwoWaySasVerification, awaitVerifier } from "./utils";
+import { Client } from "../../pages/client";
+
+test.describe("User verification", () => {
+ // note that there are other tests that check user verification works in `crypto.spec.ts`.
+
+ test.use({
+ displayName: "Alice",
+ botCreateOpts: { displayName: "Bob", autoAcceptInvites: true, userIdPrefix: "bob_" },
+ room: async ({ page, app, bot: bob, user: aliceCredentials }, use) => {
+ await app.client.bootstrapCrossSigning(aliceCredentials);
+
+ // the other user creates a DM
+ const dmRoomId = await createDMRoom(bob, aliceCredentials.userId);
+
+ // accept the DM
+ await app.viewRoomByName("Bob");
+ await page.getByRole("button", { name: "Start chatting" }).click();
+ await use({ roomId: dmRoomId });
+ },
+ });
+
+ test("can receive a verification request when there is no existing DM", async ({
+ page,
+ bot: bob,
+ user: aliceCredentials,
+ toasts,
+ room: { roomId: dmRoomId },
+ }) => {
+ // once Alice has joined, Bob starts the verification
+ const bobVerificationRequest = await bob.evaluateHandle(
+ async (client, { dmRoomId, aliceCredentials }) => {
+ const room = client.getRoom(dmRoomId);
+ while (room.getMember(aliceCredentials.userId)?.membership !== "join") {
+ await new Promise((resolve) => {
+ room.once(window.matrixcs.RoomStateEvent.Members, resolve);
+ });
+ }
+
+ return client.getCrypto().requestVerificationDM(aliceCredentials.userId, dmRoomId);
+ },
+ { dmRoomId, aliceCredentials },
+ );
+
+ // there should also be a toast
+ const toast = await toasts.getToast("Verification requested");
+ // it should contain the details of the requesting user
+ await expect(toast.getByText(`Bob (${bob.credentials.userId})`)).toBeVisible();
+ // Accept
+ await toast.getByRole("button", { name: "Verify User" }).click();
+
+ // request verification by emoji
+ await page.locator("#mx_RightPanel").getByRole("button", { name: "Verify by emoji" }).click();
+
+ /* on the bot side, wait for the verifier to exist ... */
+ const botVerifier = await awaitVerifier(bobVerificationRequest);
+ // ... confirm ...
+ botVerifier.evaluate((verifier) => verifier.verify());
+ // ... and then check the emoji match
+ await doTwoWaySasVerification(page, botVerifier);
+
+ await page.getByRole("button", { name: "They match" }).click();
+ await expect(page.getByText("You've successfully verified Bob!")).toBeVisible();
+ await page.getByRole("button", { name: "Got it" }).click();
+ });
+
+ test("can abort emoji verification when emoji mismatch", async ({
+ page,
+ bot: bob,
+ user: aliceCredentials,
+ toasts,
+ room: { roomId: dmRoomId },
+ }) => {
+ // once Alice has joined, Bob starts the verification
+ const bobVerificationRequest = await bob.evaluateHandle(
+ async (client, { dmRoomId, aliceCredentials }) => {
+ const room = client.getRoom(dmRoomId);
+ while (room.getMember(aliceCredentials.userId)?.membership !== "join") {
+ await new Promise((resolve) => {
+ room.once(window.matrixcs.RoomStateEvent.Members, resolve);
+ });
+ }
+
+ return client.getCrypto().requestVerificationDM(aliceCredentials.userId, dmRoomId);
+ },
+ { dmRoomId, aliceCredentials },
+ );
+
+ // Accept verification via toast
+ const toast = await toasts.getToast("Verification requested");
+ await toast.getByRole("button", { name: "Verify User" }).click();
+
+ // request verification by emoji
+ await page.locator("#mx_RightPanel").getByRole("button", { name: "Verify by emoji" }).click();
+
+ /* on the bot side, wait for the verifier to exist ... */
+ const botVerifier = await awaitVerifier(bobVerificationRequest);
+ // ... confirm ...
+ botVerifier.evaluate((verifier) => verifier.verify()).catch(() => {});
+ // ... and abort the verification
+ await page.getByRole("button", { name: "They don't match" }).click();
+
+ const dialog = page.locator(".mx_Dialog");
+ await expect(dialog.getByText("Your messages are not secure")).toBeVisible();
+ await dialog.getByRole("button", { name: "OK" }).click();
+ await expect(dialog).not.toBeVisible();
+ });
+});
+
+async function createDMRoom(client: Client, userId: string): Promise {
+ return client.createRoom({
+ preset: "trusted_private_chat" as Preset,
+ visibility: "private" as Visibility,
+ invite: [userId],
+ is_direct: true,
+ initial_state: [
+ {
+ type: "m.room.encryption",
+ state_key: "",
+ content: {
+ algorithm: "m.megolm.v1.aes-sha2",
+ },
+ },
+ ],
+ });
+}
diff --git a/playwright/e2e/crypto/utils.ts b/playwright/e2e/crypto/utils.ts
index 5b0bf29b976..3c1e267111c 100644
--- a/playwright/e2e/crypto/utils.ts
+++ b/playwright/e2e/crypto/utils.ts
@@ -27,6 +27,7 @@ import type {
import { Credentials, HomeserverInstance } from "../../plugins/homeserver";
import { Client } from "../../pages/client";
import { ElementAppPage } from "../../pages/ElementAppPage";
+import { Bot } from "../../pages/bot";
/**
* wait for the given client to receive an incoming verification request, and automatically accept it
@@ -113,6 +114,13 @@ export async function checkDeviceIsConnectedKeyBackup(
expectedBackupVersion: string,
checkBackupKeyInCache: boolean,
): Promise {
+ // Sanity check the given backup version: if it's null, something went wrong earlier in the test.
+ if (!expectedBackupVersion) {
+ throw new Error(
+ `Invalid backup version passed to \`checkDeviceIsConnectedKeyBackup\`: ${expectedBackupVersion}`,
+ );
+ }
+
await page.getByRole("button", { name: "User menu" }).click();
await page.locator(".mx_UserMenu_contextMenu").getByRole("menuitem", { name: "Security & Privacy" }).click();
await expect(page.locator(".mx_Dialog").getByRole("button", { name: "Restore from Backup" })).toBeVisible();
@@ -320,3 +328,60 @@ export async function createRoom(page: Page, roomName: string, isEncrypted: bool
await expect(page.getByText("Encryption enabled")).toBeVisible();
}
}
+
+/**
+ * Configure the given MatrixClient to auto-accept any invites
+ * @param client - the client to configure
+ */
+export async function autoJoin(client: Client) {
+ await client.evaluate((cli) => {
+ cli.on(window.matrixcs.RoomMemberEvent.Membership, (event, member) => {
+ if (member.membership === "invite" && member.userId === cli.getUserId()) {
+ cli.joinRoom(member.roomId);
+ }
+ });
+ });
+}
+
+/**
+ * Verify a user by emoji
+ * @param page - the page to use
+ * @param bob - the user to verify
+ */
+export const verify = async (app: ElementAppPage, bob: Bot) => {
+ const page = app.page;
+ const bobsVerificationRequestPromise = waitForVerificationRequest(bob);
+
+ const roomInfo = await app.toggleRoomInfoPanel();
+ await page.locator(".mx_RightPanelTabs").getByText("People").click();
+ await roomInfo.getByText("Bob").click();
+ await roomInfo.getByRole("button", { name: "Verify" }).click();
+ await roomInfo.getByRole("button", { name: "Start Verification" }).click();
+
+ // this requires creating a DM, so can take a while. Give it a longer timeout.
+ await roomInfo.getByRole("button", { name: "Verify by emoji" }).click({ timeout: 30000 });
+
+ const request = await bobsVerificationRequestPromise;
+ // the bot user races with the Element user to hit the "verify by emoji" button
+ const verifier = await request.evaluateHandle((request) => request.startVerification("m.sas.v1"));
+ await doTwoWaySasVerification(page, verifier);
+ await roomInfo.getByRole("button", { name: "They match" }).click();
+ await expect(roomInfo.getByText("You've successfully verified Bob!")).toBeVisible();
+ await roomInfo.getByRole("button", { name: "Got it" }).click();
+};
+
+/**
+ * Wait for a verifier to exist for a VerificationRequest
+ *
+ * @param botVerificationRequest
+ */
+export async function awaitVerifier(
+ botVerificationRequest: JSHandle,
+): Promise> {
+ return botVerificationRequest.evaluateHandle(async (verificationRequest) => {
+ while (!verificationRequest.verifier) {
+ await new Promise((r) => verificationRequest.once("change" as any, r));
+ }
+ return verificationRequest.verifier;
+ });
+}
diff --git a/playwright/e2e/file-upload/image-upload.spec.ts b/playwright/e2e/file-upload/image-upload.spec.ts
index 8f0403af31c..d75d20f441b 100644
--- a/playwright/e2e/file-upload/image-upload.spec.ts
+++ b/playwright/e2e/file-upload/image-upload.spec.ts
@@ -38,8 +38,8 @@ test.describe("Image Upload", () => {
.locator(".mx_MessageComposer_actions input[type='file']")
.setInputFiles("playwright/sample-files/riot.png");
- expect(page.getByRole("button", { name: "Upload" })).toBeEnabled();
- expect(page.getByRole("button", { name: "Close dialog" })).toBeEnabled();
- expect(page).toMatchScreenshot("image-upload-preview.png");
+ await expect(page.getByRole("button", { name: "Upload" })).toBeEnabled();
+ await expect(page.getByRole("button", { name: "Close dialog" })).toBeEnabled();
+ await expect(page).toMatchScreenshot("image-upload-preview.png");
});
});
diff --git a/playwright/e2e/forgot-password/forgot-password.spec.ts b/playwright/e2e/forgot-password/forgot-password.spec.ts
new file mode 100644
index 00000000000..260242ebc6c
--- /dev/null
+++ b/playwright/e2e/forgot-password/forgot-password.spec.ts
@@ -0,0 +1,77 @@
+/*
+Copyright 2024 The Matrix.org Foundation C.I.C.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+import { expect, test } from "../../element-web-test";
+import { selectHomeserver } from "../utils";
+
+const username = "user1234";
+// this has to be password-like enough to please zxcvbn. Needless to say it's just from pwgen.
+const password = "oETo7MPf0o";
+const email = "user@nowhere.dummy";
+
+test.describe("Forgot Password", () => {
+ test.use({
+ startHomeserverOpts: ({ mailhog }, use) =>
+ use({
+ template: "email",
+ variables: {
+ SMTP_HOST: "host.containers.internal",
+ SMTP_PORT: mailhog.instance.smtpPort,
+ },
+ }),
+ });
+
+ test("renders properly", async ({ page, homeserver }) => {
+ await page.goto("/");
+
+ await page.getByRole("link", { name: "Sign in" }).click();
+
+ // need to select a homeserver at this stage, before entering the forgot password flow
+ await selectHomeserver(page, homeserver.config.baseUrl);
+
+ await page.getByRole("button", { name: "Forgot password?" }).click();
+
+ await expect(page.getByRole("main")).toMatchScreenshot("forgot-password.png");
+ });
+
+ test("renders email verification dialog properly", async ({ page, homeserver }) => {
+ const user = await homeserver.registerUser(username, password);
+
+ await homeserver.setThreepid(user.userId, "email", email);
+
+ await page.goto("/");
+
+ await page.getByRole("link", { name: "Sign in" }).click();
+ await selectHomeserver(page, homeserver.config.baseUrl);
+
+ await page.getByRole("button", { name: "Forgot password?" }).click();
+
+ await page.getByRole("textbox", { name: "Email address" }).fill(email);
+
+ await page.getByRole("button", { name: "Send email" }).click();
+
+ await page.getByRole("button", { name: "Next" }).click();
+
+ await page.getByRole("textbox", { name: "New Password", exact: true }).fill(password);
+ await page.getByRole("textbox", { name: "Confirm new password", exact: true }).fill(password);
+
+ await page.getByRole("button", { name: "Reset password" }).click();
+
+ await expect(page.getByRole("button", { name: "Resend" })).toBeInViewport();
+
+ await expect(page.locator(".mx_Dialog")).toMatchScreenshot("forgot-password-verify-email.png");
+ });
+});
diff --git a/playwright/e2e/integration-manager/get-openid-token.spec.ts b/playwright/e2e/integration-manager/get-openid-token.spec.ts
index c107bb2cbcb..a0f099cb62b 100644
--- a/playwright/e2e/integration-manager/get-openid-token.spec.ts
+++ b/playwright/e2e/integration-manager/get-openid-token.spec.ts
@@ -118,8 +118,8 @@ test.describe("Integration Manager: Get OpenID Token", () => {
await app.viewRoomByName(ROOM_NAME);
});
- test("should successfully obtain an openID token", async ({ page }) => {
- await openIntegrationManager(page);
+ test("should successfully obtain an openID token", async ({ page, app }) => {
+ await openIntegrationManager(app);
await sendActionFromIntegrationManager(page, integrationManagerUrl);
const iframe = page.frameLocator(`iframe[src*="${integrationManagerUrl}"]`);
diff --git a/playwright/e2e/integration-manager/kick.spec.ts b/playwright/e2e/integration-manager/kick.spec.ts
index b5ca6a1b3a5..afe2de0f194 100644
--- a/playwright/e2e/integration-manager/kick.spec.ts
+++ b/playwright/e2e/integration-manager/kick.spec.ts
@@ -167,7 +167,7 @@ test.describe("Integration Manager: Kick", () => {
await app.client.inviteUser(room.roomId, targetUser.credentials.userId);
await expect(page.getByText(`${BOT_DISPLAY_NAME} joined the room`)).toBeVisible();
- await openIntegrationManager(page);
+ await openIntegrationManager(app);
await sendActionFromIntegrationManager(page, integrationManagerUrl, room.roomId, targetUser.credentials.userId);
await closeIntegrationManager(page, integrationManagerUrl);
await expectKickedMessage(page, true);
@@ -185,7 +185,7 @@ test.describe("Integration Manager: Kick", () => {
},
});
- await openIntegrationManager(page);
+ await openIntegrationManager(app);
await sendActionFromIntegrationManager(page, integrationManagerUrl, room.roomId, targetUser.credentials.userId);
await closeIntegrationManager(page, integrationManagerUrl);
await expectKickedMessage(page, false);
@@ -197,7 +197,7 @@ test.describe("Integration Manager: Kick", () => {
await expect(page.getByText(`${BOT_DISPLAY_NAME} joined the room`)).toBeVisible();
await targetUser.leave(room.roomId);
- await openIntegrationManager(page);
+ await openIntegrationManager(app);
await sendActionFromIntegrationManager(page, integrationManagerUrl, room.roomId, targetUser.credentials.userId);
await closeIntegrationManager(page, integrationManagerUrl);
await expectKickedMessage(page, false);
@@ -209,7 +209,7 @@ test.describe("Integration Manager: Kick", () => {
await expect(page.getByText(`${BOT_DISPLAY_NAME} joined the room`)).toBeVisible();
await app.client.ban(room.roomId, targetUser.credentials.userId);
- await openIntegrationManager(page);
+ await openIntegrationManager(app);
await sendActionFromIntegrationManager(page, integrationManagerUrl, room.roomId, targetUser.credentials.userId);
await closeIntegrationManager(page, integrationManagerUrl);
await expectKickedMessage(page, false);
@@ -218,7 +218,7 @@ test.describe("Integration Manager: Kick", () => {
test("should no-op if the target was never a room member", async ({ page, app, bot: targetUser, room }) => {
await app.viewRoomByName(ROOM_NAME);
- await openIntegrationManager(page);
+ await openIntegrationManager(app);
await sendActionFromIntegrationManager(page, integrationManagerUrl, room.roomId, targetUser.credentials.userId);
await closeIntegrationManager(page, integrationManagerUrl);
await expectKickedMessage(page, false);
diff --git a/playwright/e2e/integration-manager/read_events.spec.ts b/playwright/e2e/integration-manager/read_events.spec.ts
index b178596674d..2e2ee8d1871 100644
--- a/playwright/e2e/integration-manager/read_events.spec.ts
+++ b/playwright/e2e/integration-manager/read_events.spec.ts
@@ -142,7 +142,7 @@ test.describe("Integration Manager: Read Events", () => {
// Send a state event
const sendEventResponse = await app.client.sendStateEvent(room.roomId, eventType, eventContent, stateKey);
- await openIntegrationManager(page);
+ await openIntegrationManager(app);
// Read state events
await sendActionFromIntegrationManager(page, integrationManagerUrl, room.roomId, eventType, stateKey);
@@ -162,7 +162,7 @@ test.describe("Integration Manager: Read Events", () => {
// Send a state event
const sendEventResponse = await app.client.sendStateEvent(room.roomId, eventType, eventContent, stateKey);
- await openIntegrationManager(page);
+ await openIntegrationManager(app);
// Read state events
await sendActionFromIntegrationManager(page, integrationManagerUrl, room.roomId, eventType, stateKey);
@@ -196,7 +196,7 @@ test.describe("Integration Manager: Read Events", () => {
app.client.sendStateEvent(room.roomId, eventType, eventContent3, stateKey3),
]);
- await openIntegrationManager(page);
+ await openIntegrationManager(app);
// Read state events
await sendActionFromIntegrationManager(
@@ -217,11 +217,11 @@ test.describe("Integration Manager: Read Events", () => {
await expect(iframe.locator("#message-response")).toContainText(`"content":${JSON.stringify(eventContent3)}`);
});
- test("should fail to read an event type which is not allowed", async ({ page, room }) => {
+ test("should fail to read an event type which is not allowed", async ({ page, app, room }) => {
const eventType = "com.example.event";
const stateKey = "";
- await openIntegrationManager(page);
+ await openIntegrationManager(app);
// Read state events
await sendActionFromIntegrationManager(page, integrationManagerUrl, room.roomId, eventType, stateKey);
diff --git a/playwright/e2e/integration-manager/send_event.spec.ts b/playwright/e2e/integration-manager/send_event.spec.ts
index 61bad8a3ec7..ea2c3553045 100644
--- a/playwright/e2e/integration-manager/send_event.spec.ts
+++ b/playwright/e2e/integration-manager/send_event.spec.ts
@@ -137,7 +137,7 @@ test.describe("Integration Manager: Send Event", () => {
);
await app.viewRoomByName(ROOM_NAME);
- await openIntegrationManager(page);
+ await openIntegrationManager(app);
});
test("should send a state event", async ({ page, app, room }) => {
diff --git a/playwright/e2e/integration-manager/utils.ts b/playwright/e2e/integration-manager/utils.ts
index 259ff732c79..c6a2fb998ee 100644
--- a/playwright/e2e/integration-manager/utils.ts
+++ b/playwright/e2e/integration-manager/utils.ts
@@ -14,10 +14,11 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
-import type { Page } from "@playwright/test";
+import type { ElementAppPage } from "../../pages/ElementAppPage";
-export async function openIntegrationManager(page: Page) {
- await page.getByRole("button", { name: "Room info" }).click();
+export async function openIntegrationManager(app: ElementAppPage) {
+ const { page } = app;
+ await app.toggleRoomInfoPanel();
await page
.locator(".mx_RoomSummaryCard_appsGroup")
.getByRole("button", { name: "Add widgets, bridges & bots" })
diff --git a/playwright/e2e/invite/invite-dialog.spec.ts b/playwright/e2e/invite/invite-dialog.spec.ts
index 98a57c8eb1a..d9e086aaa1f 100644
--- a/playwright/e2e/invite/invite-dialog.spec.ts
+++ b/playwright/e2e/invite/invite-dialog.spec.ts
@@ -36,7 +36,7 @@ test.describe("Invite dialog", function () {
await expect(page.getByText("Hanako created and configured the room.")).toBeVisible();
// Open the room info panel
- await page.getByRole("button", { name: "Room info" }).click();
+ await app.toggleRoomInfoPanel();
await page.locator(".mx_BaseCard").getByRole("menuitem", { name: "Invite" }).click();
@@ -114,12 +114,9 @@ test.describe("Invite dialog", function () {
// Assert that the hovered user name on invitation UI does not have background color
// TODO: implement the test on room-header.spec.ts
- const roomHeader = page.locator(".mx_LegacyRoomHeader");
- await roomHeader.locator(".mx_LegacyRoomHeader_name--textonly").hover();
- await expect(roomHeader.locator(".mx_LegacyRoomHeader_name--textonly")).toHaveCSS(
- "background-color",
- "rgba(0, 0, 0, 0)",
- );
+ const roomHeader = page.locator(".mx_RoomHeader");
+ await roomHeader.locator(".mx_RoomHeader_heading").hover();
+ await expect(roomHeader.locator(".mx_RoomHeader_heading")).toHaveCSS("background-color", "rgba(0, 0, 0, 0)");
// Send a message to invite the bots
const composer = app.getComposer().locator("[contenteditable]");
diff --git a/playwright/e2e/knock/create-knock-room.spec.ts b/playwright/e2e/knock/create-knock-room.spec.ts
index 8763c0fd6a8..9e610766d32 100644
--- a/playwright/e2e/knock/create-knock-room.spec.ts
+++ b/playwright/e2e/knock/create-knock-room.spec.ts
@@ -31,7 +31,7 @@ test.describe("Create Knock Room", () => {
await dialog.getByRole("option", { name: "Ask to join" }).click();
await dialog.getByRole("button", { name: "Create room" }).click();
- await expect(page.locator(".mx_LegacyRoomHeader").getByText("Cybersecurity")).toBeVisible();
+ await expect(page.locator(".mx_RoomHeader").getByText("Cybersecurity")).toBeVisible();
const urlHash = await page.evaluate(() => window.location.hash);
const roomId = urlHash.replace("#/room/", "");
@@ -48,7 +48,7 @@ test.describe("Create Knock Room", () => {
await dialog.getByRole("textbox", { name: "Name" }).fill("Cybersecurity");
await dialog.getByRole("button", { name: "Create room" }).click();
- await expect(page.locator(".mx_LegacyRoomHeader").getByText("Cybersecurity")).toBeVisible();
+ await expect(page.locator(".mx_RoomHeader").getByText("Cybersecurity")).toBeVisible();
const urlHash = await page.evaluate(() => window.location.hash);
const roomId = urlHash.replace("#/room/", "");
@@ -74,7 +74,7 @@ test.describe("Create Knock Room", () => {
await dialog.getByText("Make this room visible in the public room directory.").click();
await dialog.getByRole("button", { name: "Create room" }).click();
- await expect(page.locator(".mx_LegacyRoomHeader").getByText("Cybersecurity")).toBeVisible();
+ await expect(page.locator(".mx_RoomHeader").getByText("Cybersecurity")).toBeVisible();
const urlHash = await page.evaluate(() => window.location.hash);
const roomId = urlHash.replace("#/room/", "");
diff --git a/playwright/e2e/lazy-loading/lazy-loading.spec.ts b/playwright/e2e/lazy-loading/lazy-loading.spec.ts
index 8b815898136..1a20100d1a2 100644
--- a/playwright/e2e/lazy-loading/lazy-loading.spec.ts
+++ b/playwright/e2e/lazy-loading/lazy-loading.spec.ts
@@ -78,9 +78,10 @@ test.describe("Lazy Loading", () => {
}
}
- async function openMemberlist(page: Page): Promise {
- await page.locator(".mx_LegacyRoomHeader").getByRole("button", { name: "Room info" }).click();
- await page.locator(".mx_RoomSummaryCard").getByRole("menuitem", { name: "People" }).click(); // \d represents the number of the room members
+ async function openMemberlist(app: ElementAppPage): Promise {
+ await app.toggleRoomInfoPanel();
+ const { page } = app;
+ await page.locator(".mx_RightPanelTabs").getByText("People").click();
}
function getMemberInMemberlist(page: Page, name: string): Locator {
@@ -123,7 +124,7 @@ test.describe("Lazy Loading", () => {
// Alice should see 2 messages from every charly with the correct display name
await checkPaginatedDisplayNames(app, charly1to5);
- await openMemberlist(page);
+ await openMemberlist(app);
await checkMemberList(page, charly1to5);
await joinCharliesWhileAliceIsOffline(page, app, charly6to10);
await checkMemberList(page, charly6to10);
diff --git a/playwright/e2e/login/login.spec.ts b/playwright/e2e/login/login.spec.ts
index 8d2e324e93a..bbadf02d08c 100644
--- a/playwright/e2e/login/login.spec.ts
+++ b/playwright/e2e/login/login.spec.ts
@@ -14,36 +14,37 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
-import { test, expect } from "../../element-web-test";
+import { expect, test } from "../../element-web-test";
import { doTokenRegistration } from "./utils";
import { isDendrite } from "../../plugins/homeserver/dendrite";
+import { selectHomeserver } from "../utils";
test.describe("Login", () => {
- test.describe("m.login.password", () => {
+ test.describe("Password login", () => {
test.use({ startHomeserverOpts: "consent" });
const username = "user1234";
const password = ""; // :TCHAP: remove pwd to pass git guardian
- test.beforeEach(async ({ page, homeserver }) => {
+ test.beforeEach(async ({ homeserver }) => {
await homeserver.registerUser(username, password);
- await page.goto("/#/login");
});
- test("logs in with an existing account and lands on the home screen", async ({
+ test("Loads the welcome page by default; then logs in with an existing account and lands on the home screen", async ({
page,
homeserver,
checkA11y,
}) => {
- // first pick the homeserver, as otherwise the user picker won't be visible
- await page.getByRole("button", { name: "Edit" }).click();
- await page.getByRole("textbox", { name: "Other homeserver" }).fill(homeserver.config.baseUrl);
- await page.getByRole("button", { name: "Continue", exact: true }).click();
- // wait for the dialog to go away
- await expect(page.locator(".mx_ServerPickerDialog")).toHaveCount(0);
+ await page.goto("/");
- await expect(page.locator(".mx_Spinner")).toHaveCount(0);
- await expect(page.locator(".mx_ServerPicker_server")).toHaveText(homeserver.config.baseUrl);
+ // Should give us the welcome page initially
+ await expect(page.getByRole("heading", { name: "Welcome to Element!" })).toBeVisible();
+
+ // Start the login process
+ await page.getByRole("link", { name: "Sign in" }).click();
+
+ // first pick the homeserver, as otherwise the user picker won't be visible
+ await selectHomeserver(page, homeserver.config.baseUrl);
await page.getByRole("button", { name: "Edit" }).click();
@@ -56,14 +57,7 @@ test.describe("Login", () => {
await expect(page.locator(".mx_ServerPicker_server")).toHaveText("server.invalid");
// switch back to the custom homeserver
- await page.getByRole("button", { name: "Edit" }).click();
- await page.getByRole("textbox", { name: "Other homeserver" }).fill(homeserver.config.baseUrl);
- await page.getByRole("button", { name: "Continue", exact: true }).click();
- // wait for the dialog to go away
- await expect(page.locator(".mx_ServerPickerDialog")).toHaveCount(0);
-
- await expect(page.locator(".mx_Spinner")).toHaveCount(0);
- await expect(page.locator(".mx_ServerPicker_server")).toHaveText(homeserver.config.baseUrl);
+ await selectHomeserver(page, homeserver.config.baseUrl);
await expect(page.getByRole("textbox", { name: "Username" })).toBeVisible();
// Disabled because flaky - see https://github.com/vector-im/element-web/issues/24688
@@ -76,6 +70,20 @@ test.describe("Login", () => {
await expect(page).toHaveURL(/\/#\/home$/);
});
+
+ test("Follows the original link after login", async ({ page, homeserver }) => {
+ await page.goto("/#/room/!room:id"); // should redirect to the welcome page
+ await page.getByRole("link", { name: "Sign in" }).click();
+
+ await selectHomeserver(page, homeserver.config.baseUrl);
+
+ await page.getByRole("textbox", { name: "Username" }).fill(username);
+ await page.getByPlaceholder("Password").fill(password);
+ await page.getByRole("button", { name: "Sign in" }).click();
+
+ await expect(page).toHaveURL(/\/#\/room\/!room:id$/);
+ await expect(page.getByRole("button", { name: "Join the discussion" })).toBeVisible();
+ });
});
// tests for old-style SSO login, in which we exchange tokens with Synapse, and Synapse talks to an auth server
diff --git a/playwright/e2e/one-to-one-chat/one-to-one-chat.spec.ts b/playwright/e2e/one-to-one-chat/one-to-one-chat.spec.ts
index 287ac77cd41..3070d5fad0d 100644
--- a/playwright/e2e/one-to-one-chat/one-to-one-chat.spec.ts
+++ b/playwright/e2e/one-to-one-chat/one-to-one-chat.spec.ts
@@ -35,10 +35,10 @@ test.describe("1:1 chat room", () => {
await page.goto(`/#/user/${user2.userId}?action=chat`);
});
- test("should open new 1:1 chat room after leaving the old one", async ({ page, user2 }) => {
+ test("should open new 1:1 chat room after leaving the old one", async ({ page, app, user2 }) => {
// leave 1:1 chat room
- await page.locator(".mx_LegacyRoomHeader_nametext").getByText(user2.displayName).click();
- await page.getByRole("menuitem", { name: "Leave" }).click();
+ await app.toggleRoomInfoPanel();
+ await page.getByRole("menuitem", { name: "Leave room" }).click();
await page.getByRole("button", { name: "Leave" }).click();
// wait till the room was left
@@ -49,6 +49,6 @@ test.describe("1:1 chat room", () => {
// open new 1:1 chat room
await page.goto(`/#/user/${user2.userId}?action=chat`);
- await expect(page.locator(".mx_LegacyRoomHeader_nametext").getByText(user2.displayName)).toBeVisible();
+ await expect(page.locator(".mx_RoomHeader_heading").getByText(user2.displayName)).toBeVisible();
});
});
diff --git a/playwright/e2e/polls/pollHistory.spec.ts b/playwright/e2e/polls/pollHistory.spec.ts
index 458bb544c7c..e9ebf0a30db 100644
--- a/playwright/e2e/polls/pollHistory.spec.ts
+++ b/playwright/e2e/polls/pollHistory.spec.ts
@@ -14,9 +14,9 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import { test, expect } from "../../element-web-test";
-import type { Page } from "@playwright/test";
import type { Bot } from "../../pages/bot";
import type { Client } from "../../pages/client";
+import { ElementAppPage } from "../../pages/ElementAppPage";
test.describe("Poll history", () => {
type CreatePollOptions = {
@@ -66,8 +66,9 @@ test.describe("Poll history", () => {
});
};
- async function openPollHistory(page: Page): Promise {
- await page.getByRole("button", { name: "Room info" }).click();
+ async function openPollHistory(app: ElementAppPage): Promise {
+ const { page } = app;
+ await app.toggleRoomInfoPanel();
await page.locator(".mx_RoomSummaryCard").getByRole("menuitem", { name: "Poll history" }).click();
}
@@ -116,7 +117,7 @@ test.describe("Poll history", () => {
await botVoteForOption(bot, roomId, pollId2, pollParams1.options[1].id);
await endPoll(bot, roomId, pollId2);
- await openPollHistory(page);
+ await openPollHistory(app);
// these polls are also in the timeline
// focus on the poll history dialog
diff --git a/playwright/e2e/presence/presence.spec.ts b/playwright/e2e/presence/presence.spec.ts
index 861181ba56a..e52b97844b8 100644
--- a/playwright/e2e/presence/presence.spec.ts
+++ b/playwright/e2e/presence/presence.spec.ts
@@ -59,7 +59,7 @@ test.describe("Presence tests", () => {
);
await app.client.createRoom({}); // trigger sync
- await page.getByRole("button", { name: "Room info" }).click();
+ await app.toggleRoomInfoPanel();
await page.locator(".mx_RightPanel").getByText("People").click();
await expect(page.locator(".mx_EntityTile_unreachable")).toContainText("Bob");
await expect(page.locator(".mx_EntityTile_unreachable")).toContainText("User's server unreachable");
diff --git a/playwright/e2e/read-receipts/editing-messages-in-threads.spec.ts b/playwright/e2e/read-receipts/editing-messages-in-threads.spec.ts
new file mode 100644
index 00000000000..62394cccb5b
--- /dev/null
+++ b/playwright/e2e/read-receipts/editing-messages-in-threads.spec.ts
@@ -0,0 +1,191 @@
+/*
+Copyright 2023 The Matrix.org Foundation C.I.C.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+/* See readme.md for tips on writing these tests. */
+
+import { test } from ".";
+
+test.describe("Read receipts", () => {
+ test.describe("editing messages", () => {
+ test.describe("in threads", () => {
+ test("An edit of a threaded message makes the room unread", async ({
+ roomAlpha: room1,
+ roomBeta: room2,
+ util,
+ msg,
+ }) => {
+ // Given we have read the thread
+ await util.goTo(room1);
+ await util.receiveMessages(room2, ["Msg1", msg.threadedOff("Msg1", "Resp1")]);
+ await util.assertUnread(room2, 1);
+ await util.goTo(room2);
+ await util.openThread("Msg1");
+ await util.assertRead(room2);
+ await util.assertReadThread("Resp1");
+ await util.goTo(room1);
+
+ // When a message inside it is edited
+ await util.receiveMessages(room2, [msg.editOf("Resp1", "Edit1")]);
+
+ // Then the room and thread are read
+ await util.assertStillRead(room2);
+ await util.goTo(room2);
+ await util.assertReadThread("Msg1");
+ });
+
+ test("Reading an edit of a threaded message makes the room read", async ({
+ roomAlpha: room1,
+ roomBeta: room2,
+ util,
+ msg,
+ }) => {
+ // Given an edited thread message appears after we read it
+ await util.goTo(room1);
+ await util.receiveMessages(room2, ["Msg1", msg.threadedOff("Msg1", "Resp1")]);
+ await util.assertUnread(room2, 1);
+ await util.goTo(room2);
+ await util.openThread("Msg1");
+ await util.assertRead(room2);
+ await util.assertReadThread("Resp1");
+ await util.goTo(room1);
+ await util.receiveMessages(room2, [msg.editOf("Resp1", "Edit1")]);
+ await util.assertStillRead(room2);
+
+ // When I read it
+ await util.goTo(room2);
+ await util.openThread("Msg1");
+
+ // Then the room and thread are still read
+ await util.assertStillRead(room2);
+ await util.assertReadThread("Msg1");
+ });
+
+ test("Marking a room as read after an edit in a thread makes it read", async ({
+ roomAlpha: room1,
+ roomBeta: room2,
+ util,
+ msg,
+ }) => {
+ // Given an edit in a thread is making the room unread
+ await util.goTo(room1);
+ await util.receiveMessages(room2, [
+ "Msg1",
+ msg.threadedOff("Msg1", "Resp1"),
+ msg.editOf("Resp1", "Edit1"),
+ ]);
+ await util.assertUnread(room2, 1);
+
+ // When I mark the room as read
+ await util.markAsRead(room2);
+
+ // Then it is read
+ await util.assertRead(room2);
+ await util.assertReadThread("Msg1");
+ });
+
+ test("Editing a thread message after marking as read leaves the room read", async ({
+ roomAlpha: room1,
+ roomBeta: room2,
+ util,
+ msg,
+ }) => {
+ // Given a room is marked as read
+ await util.goTo(room1);
+ await util.receiveMessages(room2, ["Msg1", msg.threadedOff("Msg1", "Resp1")]);
+ await util.assertUnread(room2, 1);
+ await util.markAsRead(room2);
+ await util.assertRead(room2);
+
+ // When a message is edited
+ await util.receiveMessages(room2, [msg.editOf("Resp1", "Edit1")]);
+
+ // Then the room remains read
+ await util.assertStillRead(room2);
+ await util.assertReadThread("Msg1");
+ });
+
+ test("A room with an edited threaded message is still read after restart", async ({
+ roomAlpha: room1,
+ roomBeta: room2,
+ util,
+ msg,
+ }) => {
+ // Given an edit in a thread is leaving a room read
+ await util.goTo(room1);
+ await util.receiveMessages(room2, ["Msg1", msg.threadedOff("Msg1", "Resp1")]);
+ await util.markAsRead(room2);
+ await util.receiveMessages(room2, [msg.editOf("Resp1", "Edit1")]);
+ await util.assertStillRead(room2);
+
+ // When I restart
+ await util.saveAndReload();
+
+ // Then is it still read
+ await util.assertRead(room2);
+ });
+
+ test("A room where all threaded edits are read is still read after restart", async ({
+ roomAlpha: room1,
+ roomBeta: room2,
+ util,
+ msg,
+ }) => {
+ await util.goTo(room1);
+ await util.receiveMessages(room2, ["Msg1", msg.threadedOff("Msg1", "Resp1")]);
+ await util.assertUnread(room2, 1);
+ await util.receiveMessages(room2, [msg.editOf("Resp1", "Edit1")]);
+ await util.assertUnread(room2, 1);
+
+ await util.goTo(room2);
+
+ await util.openThread("Msg1");
+ await util.assertRead(room2);
+ await util.assertReadThread("Msg1");
+ await util.goTo(room1); // Make sure we are looking at room1 after reload
+ await util.assertStillRead(room2);
+
+ await util.saveAndReload();
+ await util.assertRead(room2);
+ await util.assertReadThread("Msg1");
+ });
+
+ test("A room where all threaded edits are marked as read is still read after restart", async ({
+ roomAlpha: room1,
+ roomBeta: room2,
+ util,
+ msg,
+ }) => {
+ await util.goTo(room1);
+ await util.receiveMessages(room2, [
+ "Msg1",
+ msg.threadedOff("Msg1", "Resp1"),
+ msg.editOf("Resp1", "Edit1"),
+ ]);
+ await util.assertUnread(room2, 1);
+ await util.markAsRead(room2);
+ await util.assertRead(room2);
+ await util.assertReadThread("Msg1");
+
+ // When I restart
+ await util.saveAndReload();
+
+ // It is still read
+ await util.assertRead(room2);
+ await util.assertReadThread("Msg1");
+ });
+ });
+ });
+});
diff --git a/playwright/e2e/read-receipts/editing-messages-main-timeline.spec.ts b/playwright/e2e/read-receipts/editing-messages-main-timeline.spec.ts
new file mode 100644
index 00000000000..e03a011a4d6
--- /dev/null
+++ b/playwright/e2e/read-receipts/editing-messages-main-timeline.spec.ts
@@ -0,0 +1,180 @@
+/*
+Copyright 2023 The Matrix.org Foundation C.I.C.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+/* See readme.md for tips on writing these tests. */
+
+import { test } from ".";
+
+test.describe("Read receipts", () => {
+ test.describe("editing messages", () => {
+ test.describe("in the main timeline", () => {
+ test("Editing a message leaves a room read", async ({ roomAlpha: room1, roomBeta: room2, util, msg }) => {
+ // Given I am not looking at the room
+ await util.goTo(room1);
+
+ await util.receiveMessages(room2, ["Msg1"]);
+ await util.assertUnread(room2, 1);
+ await util.goTo(room2);
+ await util.assertRead(room2);
+ await util.goTo(room1);
+
+ // When an edit appears in the room
+ await util.receiveMessages(room2, [msg.editOf("Msg1", "Msg1 Edit1")]);
+
+ // Then it remains read
+ await util.assertStillRead(room2);
+ });
+ test("Reading an edit leaves the room read", async ({ roomAlpha: room1, roomBeta: room2, util, msg }) => {
+ // Given an edit is making the room unread
+ await util.goTo(room1);
+ await util.receiveMessages(room2, ["Msg1"]);
+ await util.assertUnread(room2, 1);
+
+ await util.goTo(room2);
+ await util.assertRead(room2);
+ await util.goTo(room1);
+
+ await util.receiveMessages(room2, [msg.editOf("Msg1", "Msg1 Edit1")]);
+ await util.assertStillRead(room2);
+
+ // When I read it
+ await util.goTo(room2);
+
+ // Then the room stays read
+ await util.assertStillRead(room2);
+ await util.goTo(room1);
+ await util.assertStillRead(room2);
+ });
+ test("Editing a message after marking as read leaves the room read", async ({
+ roomAlpha: room1,
+ roomBeta: room2,
+ util,
+ msg,
+ }) => {
+ // Given the room is marked as read
+ await util.goTo(room1);
+ await util.receiveMessages(room2, ["Msg1"]);
+ await util.assertUnread(room2, 1);
+ await util.markAsRead(room2);
+ await util.assertRead(room2);
+
+ // When a message is edited
+ await util.receiveMessages(room2, [msg.editOf("Msg1", "Msg1 Edit1")]);
+
+ // Then the room remains read
+ await util.assertStillRead(room2);
+ });
+ test("Editing a reply after reading it makes the room unread", async ({
+ roomAlpha: room1,
+ roomBeta: room2,
+ util,
+ msg,
+ }) => {
+ // Given the room is all read
+ await util.goTo(room1);
+ await util.receiveMessages(room2, ["Msg1", msg.replyTo("Msg1", "Reply1")]);
+ await util.assertUnread(room2, 2);
+ await util.goTo(room2);
+ await util.assertRead(room2);
+ await util.goTo(room1);
+
+ // When a message is edited
+ await util.receiveMessages(room2, [msg.editOf("Reply1", "Reply1 Edit1")]);
+
+ // Then it remains read
+ await util.assertStillRead(room2);
+ });
+ test("Editing a reply after marking as read makes the room unread", async ({
+ roomAlpha: room1,
+ roomBeta: room2,
+ util,
+ msg,
+ }) => {
+ // Given a reply is marked as read
+ await util.goTo(room1);
+ await util.receiveMessages(room2, ["Msg1", msg.replyTo("Msg1", "Reply1")]);
+ await util.assertUnread(room2, 2);
+ await util.markAsRead(room2);
+ await util.assertRead(room2);
+
+ // When the reply is edited
+ await util.receiveMessages(room2, [msg.editOf("Reply1", "Reply1 Edit1")]);
+
+ // Then the room remains read
+ await util.assertStillRead(room2);
+ });
+ test("A room with an edit is still read after restart", async ({
+ roomAlpha: room1,
+ roomBeta: room2,
+ util,
+ msg,
+ }) => {
+ // Given a message is marked as read
+ await util.goTo(room1);
+ await util.receiveMessages(room2, ["Msg1"]);
+ await util.assertUnread(room2, 1);
+ await util.markAsRead(room2);
+ await util.assertRead(room2);
+
+ // When an edit appears in the room
+ await util.receiveMessages(room2, [msg.editOf("Msg1", "Msg1 Edit1")]);
+
+ // Then it remains read
+ await util.assertStillRead(room2);
+
+ // And remains so after a reload
+ await util.saveAndReload();
+ await util.assertStillRead(room2);
+ });
+ test("An edited message becomes read if it happens while I am looking", async ({
+ roomAlpha: room1,
+ roomBeta: room2,
+ util,
+ msg,
+ }) => {
+ // Given a message is marked as read
+ await util.goTo(room2);
+ await util.receiveMessages(room2, ["Msg1"]);
+ await util.assertRead(room2);
+
+ // When I see an edit appear in the room I am looking at
+ await util.receiveMessages(room2, [msg.editOf("Msg1", "Msg1 Edit1")]);
+
+ // Then it becomes read
+ await util.assertStillRead(room2);
+ });
+ test("A room where all edits are read is still read after restart", async ({
+ roomAlpha: room1,
+ roomBeta: room2,
+ util,
+ msg,
+ }) => {
+ // Given a message was edited and read
+ await util.goTo(room1);
+ await util.receiveMessages(room2, ["Msg1", msg.editOf("Msg1", "Msg1 Edit1")]);
+ await util.assertUnread(room2, 1);
+ await util.goTo(room2);
+ await util.assertRead(room2);
+
+ // When I reload
+ await util.saveAndReload();
+
+ // Then the room is still read
+ await util.assertRead(room2);
+ });
+ });
+ });
+});
diff --git a/playwright/e2e/read-receipts/editing-messages-thread-roots.spec.ts b/playwright/e2e/read-receipts/editing-messages-thread-roots.spec.ts
new file mode 100644
index 00000000000..279845f5d2d
--- /dev/null
+++ b/playwright/e2e/read-receipts/editing-messages-thread-roots.spec.ts
@@ -0,0 +1,179 @@
+/*
+Copyright 2023 The Matrix.org Foundation C.I.C.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+/* See readme.md for tips on writing these tests. */
+
+import { test } from ".";
+
+test.describe("Read receipts", () => {
+ test.describe("editing messages", () => {
+ test.describe("thread roots", () => {
+ test("An edit of a thread root leaves the room read", async ({
+ roomAlpha: room1,
+ roomBeta: room2,
+ util,
+ msg,
+ }) => {
+ // Given I have read a thread
+ await util.goTo(room1);
+ await util.receiveMessages(room2, ["Msg1", msg.threadedOff("Msg1", "Resp1")]);
+ await util.assertUnread(room2, 1);
+ await util.goTo(room2);
+ await util.openThread("Msg1");
+ await util.backToThreadsList();
+ await util.assertRead(room2);
+ await util.goTo(room1);
+
+ // When the thread root is edited
+ await util.receiveMessages(room2, [msg.editOf("Msg1", "Edit1")]);
+
+ // Then the room is read
+ await util.assertStillRead(room2);
+
+ // And the thread is read
+ await util.goTo(room2);
+ await util.assertStillRead(room2);
+ await util.assertReadThread("Edit1");
+ });
+
+ test("Reading an edit of a thread root leaves the room read", async ({
+ roomAlpha: room1,
+ roomBeta: room2,
+ util,
+ msg,
+ }) => {
+ // Given a fully-read thread exists
+ await util.goTo(room2);
+ await util.receiveMessages(room2, ["Msg1", msg.threadedOff("Msg1", "Resp1")]);
+ await util.openThread("Msg1");
+ await util.assertRead(room2);
+ await util.goTo(room1);
+ await util.assertRead(room2);
+
+ // When the thread root is edited
+ await util.receiveMessages(room2, [msg.editOf("Msg1", "Msg1 Edit1")]);
+
+ // And I read that edit
+ await util.goTo(room2);
+
+ // Then the room becomes read and stays read
+ await util.assertStillRead(room2);
+ await util.goTo(room1);
+ await util.assertStillRead(room2);
+ });
+
+ test("Editing a thread root after reading leaves the room read", async ({
+ roomAlpha: room1,
+ roomBeta: room2,
+ util,
+ msg,
+ }) => {
+ // Given a fully-read thread exists
+ await util.goTo(room2);
+ await util.receiveMessages(room2, ["Msg1", msg.threadedOff("Msg1", "Resp1")]);
+ await util.openThread("Msg1");
+ await util.assertRead(room2);
+ await util.goTo(room1);
+
+ // When the thread root is edited
+ await util.receiveMessages(room2, [msg.editOf("Msg1", "Msg1 Edit1")]);
+
+ // Then the room stays read
+ await util.assertStillRead(room2);
+ });
+
+ test("Marking a room as read after an edit of a thread root keeps it read", async ({
+ roomAlpha: room1,
+ roomBeta: room2,
+ util,
+ msg,
+ }) => {
+ // Given a fully-read thread exists
+ await util.goTo(room2);
+ await util.receiveMessages(room2, ["Msg1", msg.threadedOff("Msg1", "Resp1")]);
+ await util.openThread("Msg1");
+ await util.assertRead(room2);
+ await util.goTo(room1);
+ await util.assertRead(room2);
+
+ // When the thread root is edited (and I receive another message
+ // to allow Mark as read)
+ await util.receiveMessages(room2, [msg.editOf("Msg1", "Msg1 Edit1"), "Msg2"]);
+
+ // And when I mark the room as read
+ await util.markAsRead(room2);
+
+ // Then the room becomes read and stays read
+ await util.assertStillRead(room2);
+ await util.goTo(room1);
+ await util.assertStillRead(room2);
+ });
+
+ test("Editing a thread root that is a reply after marking as read leaves the room read", async ({
+ roomAlpha: room1,
+ roomBeta: room2,
+ util,
+ msg,
+ }) => {
+ // Given a thread based on a reply exists and is read because it is marked as read
+ await util.goTo(room1);
+ await util.receiveMessages(room2, [
+ "Msg",
+ msg.replyTo("Msg", "Reply"),
+ msg.threadedOff("Reply", "InThread"),
+ ]);
+ await util.assertUnread(room2, 2);
+ await util.markAsRead(room2);
+ await util.assertRead(room2);
+
+ // When I edit the thread root
+ await util.receiveMessages(room2, [msg.editOf("Reply", "Edited Reply")]);
+
+ // Then the room is read
+ await util.assertStillRead(room2);
+
+ // And the thread is read
+ await util.goTo(room2);
+ await util.assertReadThread("Edited Reply");
+ });
+
+ test("Marking a room as read after an edit of a thread root that is a reply leaves it read", async ({
+ roomAlpha: room1,
+ roomBeta: room2,
+ util,
+ msg,
+ }) => {
+ // Given a thread based on a reply exists and the reply has been edited
+ await util.goTo(room1);
+ await util.receiveMessages(room2, [
+ "Msg",
+ msg.replyTo("Msg", "Reply"),
+ msg.threadedOff("Reply", "InThread"),
+ ]);
+ await util.receiveMessages(room2, [msg.editOf("Reply", "Edited Reply")]);
+ await util.assertUnread(room2, 2);
+
+ // When I mark the room as read
+ await util.markAsRead(room2);
+
+ // Then the room and thread are read
+ await util.assertStillRead(room2);
+ await util.goTo(room2);
+ await util.assertReadThread("Edited Reply");
+ });
+ });
+ });
+});
diff --git a/playwright/e2e/read-receipts/editing-messages.spec.ts b/playwright/e2e/read-receipts/editing-messages.spec.ts
deleted file mode 100644
index 5005ad62bfb..00000000000
--- a/playwright/e2e/read-receipts/editing-messages.spec.ts
+++ /dev/null
@@ -1,504 +0,0 @@
-/*
-Copyright 2023 The Matrix.org Foundation C.I.C.
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
- http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
-*/
-
-/* See readme.md for tips on writing these tests. */
-
-import { test } from ".";
-
-test.describe("Read receipts", () => {
- test.describe("editing messages", () => {
- test.describe("in the main timeline", () => {
- test("Editing a message leaves a room read", async ({ roomAlpha: room1, roomBeta: room2, util, msg }) => {
- // Given I am not looking at the room
- await util.goTo(room1);
-
- await util.receiveMessages(room2, ["Msg1"]);
- await util.assertUnread(room2, 1);
- await util.goTo(room2);
- await util.assertRead(room2);
- await util.goTo(room1);
-
- // When an edit appears in the room
- await util.receiveMessages(room2, [msg.editOf("Msg1", "Msg1 Edit1")]);
-
- // Then it remains read
- await util.assertStillRead(room2);
- });
- test("Reading an edit leaves the room read", async ({ roomAlpha: room1, roomBeta: room2, util, msg }) => {
- // Given an edit is making the room unread
- await util.goTo(room1);
- await util.receiveMessages(room2, ["Msg1"]);
- await util.assertUnread(room2, 1);
-
- await util.goTo(room2);
- await util.assertRead(room2);
- await util.goTo(room1);
-
- await util.receiveMessages(room2, [msg.editOf("Msg1", "Msg1 Edit1")]);
- await util.assertStillRead(room2);
-
- // When I read it
- await util.goTo(room2);
-
- // Then the room stays read
- await util.assertStillRead(room2);
- await util.goTo(room1);
- await util.assertStillRead(room2);
- });
- test("Editing a message after marking as read leaves the room read", async ({
- roomAlpha: room1,
- roomBeta: room2,
- util,
- msg,
- }) => {
- // Given the room is marked as read
- await util.goTo(room1);
- await util.receiveMessages(room2, ["Msg1"]);
- await util.assertUnread(room2, 1);
- await util.markAsRead(room2);
- await util.assertRead(room2);
-
- // When a message is edited
- await util.receiveMessages(room2, [msg.editOf("Msg1", "Msg1 Edit1")]);
-
- // Then the room remains read
- await util.assertStillRead(room2);
- });
- test("Editing a reply after reading it makes the room unread", async ({
- roomAlpha: room1,
- roomBeta: room2,
- util,
- msg,
- }) => {
- // Given the room is all read
- await util.goTo(room1);
- await util.receiveMessages(room2, ["Msg1", msg.replyTo("Msg1", "Reply1")]);
- await util.assertUnread(room2, 2);
- await util.goTo(room2);
- await util.assertRead(room2);
- await util.goTo(room1);
-
- // When a message is edited
- await util.receiveMessages(room2, [msg.editOf("Reply1", "Reply1 Edit1")]);
-
- // Then it remains read
- await util.assertStillRead(room2);
- });
- test("Editing a reply after marking as read makes the room unread", async ({
- roomAlpha: room1,
- roomBeta: room2,
- util,
- msg,
- }) => {
- // Given a reply is marked as read
- await util.goTo(room1);
- await util.receiveMessages(room2, ["Msg1", msg.replyTo("Msg1", "Reply1")]);
- await util.assertUnread(room2, 2);
- await util.markAsRead(room2);
- await util.assertRead(room2);
-
- // When the reply is edited
- await util.receiveMessages(room2, [msg.editOf("Reply1", "Reply1 Edit1")]);
-
- // Then the room remains read
- await util.assertStillRead(room2);
- });
- test("A room with an edit is still read after restart", async ({
- roomAlpha: room1,
- roomBeta: room2,
- util,
- msg,
- }) => {
- // Given a message is marked as read
- await util.goTo(room1);
- await util.receiveMessages(room2, ["Msg1"]);
- await util.assertUnread(room2, 1);
- await util.markAsRead(room2);
- await util.assertRead(room2);
-
- // When an edit appears in the room
- await util.receiveMessages(room2, [msg.editOf("Msg1", "Msg1 Edit1")]);
-
- // Then it remains read
- await util.assertStillRead(room2);
-
- // And remains so after a reload
- await util.saveAndReload();
- await util.assertStillRead(room2);
- });
- test("An edited message becomes read if it happens while I am looking", async ({
- roomAlpha: room1,
- roomBeta: room2,
- util,
- msg,
- }) => {
- // Given a message is marked as read
- await util.goTo(room2);
- await util.receiveMessages(room2, ["Msg1"]);
- await util.assertRead(room2);
-
- // When I see an edit appear in the room I am looking at
- await util.receiveMessages(room2, [msg.editOf("Msg1", "Msg1 Edit1")]);
-
- // Then it becomes read
- await util.assertStillRead(room2);
- });
- test("A room where all edits are read is still read after restart", async ({
- roomAlpha: room1,
- roomBeta: room2,
- util,
- msg,
- }) => {
- // Given a message was edited and read
- await util.goTo(room1);
- await util.receiveMessages(room2, ["Msg1", msg.editOf("Msg1", "Msg1 Edit1")]);
- await util.assertUnread(room2, 1);
- await util.goTo(room2);
- await util.assertRead(room2);
-
- // When I reload
- await util.saveAndReload();
-
- // Then the room is still read
- await util.assertRead(room2);
- });
- });
-
- test.describe("in threads", () => {
- test("An edit of a threaded message makes the room unread", async ({
- roomAlpha: room1,
- roomBeta: room2,
- util,
- msg,
- }) => {
- // Given we have read the thread
- await util.goTo(room1);
- await util.receiveMessages(room2, ["Msg1", msg.threadedOff("Msg1", "Resp1")]);
- await util.assertUnread(room2, 1);
- await util.goTo(room2);
- await util.openThread("Msg1");
- await util.assertRead(room2);
- await util.assertReadThread("Resp1");
- await util.goTo(room1);
-
- // When a message inside it is edited
- await util.receiveMessages(room2, [msg.editOf("Resp1", "Edit1")]);
-
- // Then the room and thread are read
- await util.assertStillRead(room2);
- await util.goTo(room2);
- await util.assertReadThread("Msg1");
- });
-
- test("Reading an edit of a threaded message makes the room read", async ({
- roomAlpha: room1,
- roomBeta: room2,
- util,
- msg,
- }) => {
- // Given an edited thread message appears after we read it
- await util.goTo(room1);
- await util.receiveMessages(room2, ["Msg1", msg.threadedOff("Msg1", "Resp1")]);
- await util.assertUnread(room2, 1);
- await util.goTo(room2);
- await util.openThread("Msg1");
- await util.assertRead(room2);
- await util.assertReadThread("Resp1");
- await util.goTo(room1);
- await util.receiveMessages(room2, [msg.editOf("Resp1", "Edit1")]);
- await util.assertStillRead(room2);
-
- // When I read it
- await util.goTo(room2);
- await util.openThread("Msg1");
-
- // Then the room and thread are still read
- await util.assertStillRead(room2);
- await util.assertReadThread("Msg1");
- });
-
- test("Marking a room as read after an edit in a thread makes it read", async ({
- roomAlpha: room1,
- roomBeta: room2,
- util,
- msg,
- }) => {
- // Given an edit in a thread is making the room unread
- await util.goTo(room1);
- await util.receiveMessages(room2, [
- "Msg1",
- msg.threadedOff("Msg1", "Resp1"),
- msg.editOf("Resp1", "Edit1"),
- ]);
- await util.assertUnread(room2, 1);
-
- // When I mark the room as read
- await util.markAsRead(room2);
-
- // Then it is read
- await util.assertRead(room2);
- await util.assertReadThread("Msg1");
- });
-
- test("Editing a thread message after marking as read leaves the room read", async ({
- roomAlpha: room1,
- roomBeta: room2,
- util,
- msg,
- }) => {
- // Given a room is marked as read
- await util.goTo(room1);
- await util.receiveMessages(room2, ["Msg1", msg.threadedOff("Msg1", "Resp1")]);
- await util.assertUnread(room2, 1);
- await util.markAsRead(room2);
- await util.assertRead(room2);
-
- // When a message is edited
- await util.receiveMessages(room2, [msg.editOf("Resp1", "Edit1")]);
-
- // Then the room remains read
- await util.assertStillRead(room2);
- await util.assertReadThread("Msg1");
- });
-
- test("A room with an edited threaded message is still read after restart", async ({
- roomAlpha: room1,
- roomBeta: room2,
- util,
- msg,
- }) => {
- // Given an edit in a thread is leaving a room read
- await util.goTo(room1);
- await util.receiveMessages(room2, ["Msg1", msg.threadedOff("Msg1", "Resp1")]);
- await util.markAsRead(room2);
- await util.receiveMessages(room2, [msg.editOf("Resp1", "Edit1")]);
- await util.assertStillRead(room2);
-
- // When I restart
- await util.saveAndReload();
-
- // Then is it still read
- await util.assertRead(room2);
- });
-
- test("A room where all threaded edits are read is still read after restart", async ({
- roomAlpha: room1,
- roomBeta: room2,
- util,
- msg,
- }) => {
- await util.goTo(room1);
- await util.receiveMessages(room2, ["Msg1", msg.threadedOff("Msg1", "Resp1")]);
- await util.assertUnread(room2, 1);
- await util.receiveMessages(room2, [msg.editOf("Resp1", "Edit1")]);
- await util.assertUnread(room2, 1);
-
- await util.goTo(room2);
-
- await util.openThread("Msg1");
- await util.assertRead(room2);
- await util.assertReadThread("Msg1");
- await util.goTo(room1); // Make sure we are looking at room1 after reload
- await util.assertStillRead(room2);
-
- await util.saveAndReload();
- await util.assertRead(room2);
- await util.assertReadThread("Msg1");
- });
-
- test("A room where all threaded edits are marked as read is still read after restart", async ({
- roomAlpha: room1,
- roomBeta: room2,
- util,
- msg,
- }) => {
- await util.goTo(room1);
- await util.receiveMessages(room2, [
- "Msg1",
- msg.threadedOff("Msg1", "Resp1"),
- msg.editOf("Resp1", "Edit1"),
- ]);
- await util.assertUnread(room2, 1);
- await util.markAsRead(room2);
- await util.assertRead(room2);
- await util.assertReadThread("Msg1");
-
- // When I restart
- await util.saveAndReload();
-
- // It is still read
- await util.assertRead(room2);
- await util.assertReadThread("Msg1");
- });
- });
-
- test.describe("thread roots", () => {
- test("An edit of a thread root leaves the room read", async ({
- roomAlpha: room1,
- roomBeta: room2,
- util,
- msg,
- }) => {
- // Given I have read a thread
- await util.goTo(room1);
- await util.receiveMessages(room2, ["Msg1", msg.threadedOff("Msg1", "Resp1")]);
- await util.assertUnread(room2, 1);
- await util.goTo(room2);
- await util.openThread("Msg1");
- await util.backToThreadsList();
- await util.assertRead(room2);
- await util.goTo(room1);
-
- // When the thread root is edited
- await util.receiveMessages(room2, [msg.editOf("Msg1", "Edit1")]);
-
- // Then the room is read
- await util.assertStillRead(room2);
-
- // And the thread is read
- await util.goTo(room2);
- await util.assertStillRead(room2);
- await util.assertReadThread("Edit1");
- });
-
- test("Reading an edit of a thread root leaves the room read", async ({
- roomAlpha: room1,
- roomBeta: room2,
- util,
- msg,
- }) => {
- // Given a fully-read thread exists
- await util.goTo(room2);
- await util.receiveMessages(room2, ["Msg1", msg.threadedOff("Msg1", "Resp1")]);
- await util.openThread("Msg1");
- await util.assertRead(room2);
- await util.goTo(room1);
- await util.assertRead(room2);
-
- // When the thread root is edited
- await util.receiveMessages(room2, [msg.editOf("Msg1", "Msg1 Edit1")]);
-
- // And I read that edit
- await util.goTo(room2);
-
- // Then the room becomes read and stays read
- await util.assertStillRead(room2);
- await util.goTo(room1);
- await util.assertStillRead(room2);
- });
-
- test("Editing a thread root after reading leaves the room read", async ({
- roomAlpha: room1,
- roomBeta: room2,
- util,
- msg,
- }) => {
- // Given a fully-read thread exists
- await util.goTo(room2);
- await util.receiveMessages(room2, ["Msg1", msg.threadedOff("Msg1", "Resp1")]);
- await util.openThread("Msg1");
- await util.assertRead(room2);
- await util.goTo(room1);
-
- // When the thread root is edited
- await util.receiveMessages(room2, [msg.editOf("Msg1", "Msg1 Edit1")]);
-
- // Then the room stays read
- await util.assertStillRead(room2);
- });
-
- test("Marking a room as read after an edit of a thread root keeps it read", async ({
- roomAlpha: room1,
- roomBeta: room2,
- util,
- msg,
- }) => {
- // Given a fully-read thread exists
- await util.goTo(room2);
- await util.receiveMessages(room2, ["Msg1", msg.threadedOff("Msg1", "Resp1")]);
- await util.openThread("Msg1");
- await util.assertRead(room2);
- await util.goTo(room1);
- await util.assertRead(room2);
-
- // When the thread root is edited (and I receive another message
- // to allow Mark as read)
- await util.receiveMessages(room2, [msg.editOf("Msg1", "Msg1 Edit1"), "Msg2"]);
-
- // And when I mark the room as read
- await util.markAsRead(room2);
-
- // Then the room becomes read and stays read
- await util.assertStillRead(room2);
- await util.goTo(room1);
- await util.assertStillRead(room2);
- });
-
- test("Editing a thread root that is a reply after marking as read leaves the room read", async ({
- roomAlpha: room1,
- roomBeta: room2,
- util,
- msg,
- }) => {
- // Given a thread based on a reply exists and is read because it is marked as read
- await util.goTo(room1);
- await util.receiveMessages(room2, [
- "Msg",
- msg.replyTo("Msg", "Reply"),
- msg.threadedOff("Reply", "InThread"),
- ]);
- await util.assertUnread(room2, 2);
- await util.markAsRead(room2);
- await util.assertRead(room2);
-
- // When I edit the thread root
- await util.receiveMessages(room2, [msg.editOf("Reply", "Edited Reply")]);
-
- // Then the room is read
- await util.assertStillRead(room2);
-
- // And the thread is read
- await util.goTo(room2);
- await util.assertReadThread("Edited Reply");
- });
-
- test("Marking a room as read after an edit of a thread root that is a reply leaves it read", async ({
- roomAlpha: room1,
- roomBeta: room2,
- util,
- msg,
- }) => {
- // Given a thread based on a reply exists and the reply has been edited
- await util.goTo(room1);
- await util.receiveMessages(room2, [
- "Msg",
- msg.replyTo("Msg", "Reply"),
- msg.threadedOff("Reply", "InThread"),
- ]);
- await util.receiveMessages(room2, [msg.editOf("Reply", "Edited Reply")]);
- await util.assertUnread(room2, 2);
-
- // When I mark the room as read
- await util.markAsRead(room2);
-
- // Then the room and thread are read
- await util.assertStillRead(room2);
- await util.goTo(room2);
- await util.assertReadThread("Edited Reply");
- });
- });
- });
-});
diff --git a/playwright/e2e/read-receipts/high-level.spec.ts b/playwright/e2e/read-receipts/high-level.spec.ts
index e237afd64a8..a3c2c0de3dd 100644
--- a/playwright/e2e/read-receipts/high-level.spec.ts
+++ b/playwright/e2e/read-receipts/high-level.spec.ts
@@ -19,77 +19,6 @@ limitations under the License.
import { customEvent, many, test } from ".";
test.describe("Read receipts", () => {
- test.describe("Message ordering", () => {
- test.describe("in the main timeline", () => {
- test.fixme(
- "A receipt for the last event in sync order (even with wrong ts) marks a room as read",
- () => {},
- );
- test.fixme(
- "A receipt for a non-last event in sync order (even when ts makes it last) leaves room unread",
- () => {},
- );
- });
-
- test.describe("in threads", () => {
- // These don't pass yet - we need MSC4033 - we don't even know the Sync order yet
- test.fixme(
- "A receipt for the last event in sync order (even with wrong ts) marks a thread as read",
- () => {},
- );
- test.fixme(
- "A receipt for a non-last event in sync order (even when ts makes it last) leaves thread unread",
- () => {},
- );
-
- // These pass now and should not later - we should use order from MSC4033 instead of ts
- // These are broken out
- test.fixme(
- "A receipt for last threaded event in ts order (even when it was received non-last) marks a thread as read",
- () => {},
- );
- test.fixme(
- "A receipt for non-last threaded event in ts order (even when it was received last) leaves thread unread",
- () => {},
- );
- test.fixme(
- "A receipt for last threaded edit in ts order (even when it was received non-last) marks a thread as read",
- () => {},
- );
- test.fixme(
- "A receipt for non-last threaded edit in ts order (even when it was received last) leaves thread unread",
- () => {},
- );
- test.fixme(
- "A receipt for last threaded reaction in ts order (even when it was received non-last) marks a thread as read",
- () => {},
- );
- test.fixme(
- "A receipt for non-last threaded reaction in ts order (even when it was received last) leaves thread unread",
- () => {},
- );
- });
-
- test.describe("thread roots", () => {
- test.fixme(
- "A receipt for last reaction to thread root in sync order (even when ts makes it last) marks room as read",
- () => {},
- );
- test.fixme(
- "A receipt for non-last reaction to thread root in sync order (even when ts makes it last) leaves room unread",
- () => {},
- );
- test.fixme(
- "A receipt for last edit to thread root in sync order (even when ts makes it last) marks room as read",
- () => {},
- );
- test.fixme(
- "A receipt for non-last edit to thread root in sync order (even when ts makes it last) leaves room unread",
- () => {},
- );
- });
- });
-
test.describe("Ignored events", () => {
test("If all events after receipt are unimportant, the room is read", async ({
roomAlpha: room1,
@@ -249,7 +178,6 @@ test.describe("Read receipts", () => {
});
test("Paging up to find old threads that were never read keeps the room unread", async ({
- cryptoBackend,
roomAlpha: room1,
roomBeta: room2,
util,
@@ -338,7 +266,6 @@ test.describe("Read receipts", () => {
});
test("After marking room as read, paging up to find old threads that were never read leaves the room read", async ({
- cryptoBackend,
roomAlpha: room1,
roomBeta: room2,
util,
@@ -416,79 +343,4 @@ test.describe("Read receipts", () => {
await util.assertReadThread("Root3");
});
});
-
- test.describe("Room list order", () => {
- test("Rooms with unread messages appear at the top of room list if 'unread first' is selected", async ({
- roomAlpha: room1,
- roomBeta: room2,
- util,
- msg,
- page,
- }) => {
- await util.goTo(room2);
-
- // Display the unread first room
- await util.toggleRoomUnreadOrder();
- await util.receiveMessages(room1, ["Msg1"]);
- await page.reload();
-
- // Room 1 has an unread message and should be displayed first
- await util.assertRoomListOrder([room1, room2]);
- });
-
- test("Rooms with unread threads appear at the top of room list if 'unread first' is selected", async ({
- roomAlpha: room1,
- roomBeta: room2,
- util,
- msg,
- }) => {
- await util.goTo(room2);
- await util.receiveMessages(room1, ["Msg1"]);
- await util.markAsRead(room1);
- await util.assertRead(room1);
-
- // Display the unread first room
- await util.toggleRoomUnreadOrder();
- await util.receiveMessages(room1, [msg.threadedOff("Msg1", "Resp1")]);
- await util.saveAndReload();
-
- // Room 1 has an unread message and should be displayed first
- await util.assertRoomListOrder([room1, room2]);
- });
- });
-
- test.describe("Notifications", () => {
- test.describe("in the main timeline", () => {
- test.fixme("A new message that mentions me shows a notification", () => {});
- test.fixme(
- "Reading a notifying message reduces the notification count in the room list, space and tab",
- () => {},
- );
- test.fixme(
- "Reading the last notifying message removes the notification marker from room list, space and tab",
- () => {},
- );
- test.fixme("Editing a message to mentions me shows a notification", () => {});
- test.fixme("Reading the last notifying edited message removes the notification marker", () => {});
- test.fixme("Redacting a notifying message removes the notification marker", () => {});
- });
-
- test.describe("in threads", () => {
- test.fixme("A new threaded message that mentions me shows a notification", () => {});
- test.fixme("Reading a notifying threaded message removes the notification count", () => {});
- test.fixme(
- "Notification count remains steady when reading threads that contain seen notifications",
- () => {},
- );
- test.fixme(
- "Notification count remains steady when paging up thread view even when threads contain seen notifications",
- () => {},
- );
- test.fixme(
- "Notification count remains steady when paging up thread view after mark as unread even if older threads contain notifications",
- () => {},
- );
- test.fixme("Redacting a notifying threaded message removes the notification marker", () => {});
- });
- });
});
diff --git a/playwright/e2e/read-receipts/index.ts b/playwright/e2e/read-receipts/index.ts
index 4dd0450fb9c..1b671929074 100644
--- a/playwright/e2e/read-receipts/index.ts
+++ b/playwright/e2e/read-receipts/index.ts
@@ -399,11 +399,10 @@ class Helpers {
}
/**
- * Close the threads panel. (Actually, close any right panel, but for these
- * tests we only open the threads panel.)
+ * Close the threads panel.
*/
async closeThreadsPanel() {
- await this.page.locator(".mx_RightPanel").getByLabel("Close").click();
+ await this.page.locator(".mx_RoomHeader").getByLabel("Threads").click();
await expect(this.page.locator(".mx_RightPanel")).not.toBeVisible();
}
@@ -411,7 +410,7 @@ class Helpers {
* Return to the list of threads, given we are viewing a single thread.
*/
async backToThreadsList() {
- await this.page.locator(".mx_RightPanel").getByLabel("Threads").click();
+ await this.page.locator(".mx_RoomHeader").getByLabel("Threads").click();
}
/**
@@ -531,15 +530,14 @@ class Helpers {
// whether it's open or not - wait here to give it a chance to settle.
await this.page.waitForTimeout(200);
- const ariaCurrent = await this.page.getByTestId("threadsButton").getAttribute("aria-current");
- if (ariaCurrent !== "true") {
- await this.page.getByTestId("threadsButton").click();
- }
-
const threadPanel = this.page.locator(".mx_ThreadPanel");
+ const isThreadPanelOpen = (await threadPanel.count()) !== 0;
+ if (!isThreadPanelOpen) {
+ await this.page.locator(".mx_RoomHeader").getByLabel("Threads").click();
+ }
await expect(threadPanel).toBeVisible();
await threadPanel.evaluate(($panel) => {
- const $button = $panel.querySelector('.mx_BaseCard_back[aria-label="Threads"]');
+ const $button = $panel.querySelector('[data-testid="base-card-back-button"]');
// If the Threads back button is present then click it - the
// threads button can open either threads list or thread panel
if ($button) {
diff --git a/playwright/e2e/read-receipts/message-ordering.spec.ts b/playwright/e2e/read-receipts/message-ordering.spec.ts
new file mode 100644
index 00000000000..73c640d35a6
--- /dev/null
+++ b/playwright/e2e/read-receipts/message-ordering.spec.ts
@@ -0,0 +1,92 @@
+/*
+Copyright 2023 The Matrix.org Foundation C.I.C.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+/* See readme.md for tips on writing these tests. */
+
+import { test } from ".";
+
+test.describe("Read receipts", () => {
+ test.describe("Message ordering", () => {
+ test.describe("in the main timeline", () => {
+ test.fixme(
+ "A receipt for the last event in sync order (even with wrong ts) marks a room as read",
+ () => {},
+ );
+ test.fixme(
+ "A receipt for a non-last event in sync order (even when ts makes it last) leaves room unread",
+ () => {},
+ );
+ });
+
+ test.describe("in threads", () => {
+ // These don't pass yet - we need MSC4033 - we don't even know the Sync order yet
+ test.fixme(
+ "A receipt for the last event in sync order (even with wrong ts) marks a thread as read",
+ () => {},
+ );
+ test.fixme(
+ "A receipt for a non-last event in sync order (even when ts makes it last) leaves thread unread",
+ () => {},
+ );
+
+ // These pass now and should not later - we should use order from MSC4033 instead of ts
+ // These are broken out
+ test.fixme(
+ "A receipt for last threaded event in ts order (even when it was received non-last) marks a thread as read",
+ () => {},
+ );
+ test.fixme(
+ "A receipt for non-last threaded event in ts order (even when it was received last) leaves thread unread",
+ () => {},
+ );
+ test.fixme(
+ "A receipt for last threaded edit in ts order (even when it was received non-last) marks a thread as read",
+ () => {},
+ );
+ test.fixme(
+ "A receipt for non-last threaded edit in ts order (even when it was received last) leaves thread unread",
+ () => {},
+ );
+ test.fixme(
+ "A receipt for last threaded reaction in ts order (even when it was received non-last) marks a thread as read",
+ () => {},
+ );
+ test.fixme(
+ "A receipt for non-last threaded reaction in ts order (even when it was received last) leaves thread unread",
+ () => {},
+ );
+ });
+
+ test.describe("thread roots", () => {
+ test.fixme(
+ "A receipt for last reaction to thread root in sync order (even when ts makes it last) marks room as read",
+ () => {},
+ );
+ test.fixme(
+ "A receipt for non-last reaction to thread root in sync order (even when ts makes it last) leaves room unread",
+ () => {},
+ );
+ test.fixme(
+ "A receipt for last edit to thread root in sync order (even when ts makes it last) marks room as read",
+ () => {},
+ );
+ test.fixme(
+ "A receipt for non-last edit to thread root in sync order (even when ts makes it last) leaves room unread",
+ () => {},
+ );
+ });
+ });
+});
diff --git a/playwright/e2e/read-receipts/new-messages.spec.ts b/playwright/e2e/read-receipts/new-messages-in-threads.spec.ts
similarity index 55%
rename from playwright/e2e/read-receipts/new-messages.spec.ts
rename to playwright/e2e/read-receipts/new-messages-in-threads.spec.ts
index 97308a4bb29..37b43bae1dc 100644
--- a/playwright/e2e/read-receipts/new-messages.spec.ts
+++ b/playwright/e2e/read-receipts/new-messages-in-threads.spec.ts
@@ -20,151 +20,6 @@ import { many, test } from ".";
test.describe("Read receipts", () => {
test.describe("new messages", () => {
- test.describe("in the main timeline", () => {
- test("Receiving a message makes a room unread", async ({
- roomAlpha: room1,
- roomBeta: room2,
- util,
- msg,
- }) => {
- // Given I am in a different room
- await util.goTo(room1);
- await util.assertRead(room2);
-
- // When I receive some messages
- await util.receiveMessages(room2, ["Msg1"]);
-
- // Then the room is marked as unread
- await util.assertUnread(room2, 1);
- });
- test("Reading latest message makes the room read", async ({
- roomAlpha: room1,
- roomBeta: room2,
- util,
- msg,
- }) => {
- // Given I have some unread messages
- await util.goTo(room1);
- await util.assertRead(room2);
- await util.receiveMessages(room2, ["Msg1"]);
- await util.assertUnread(room2, 1);
-
- // When I read the main timeline
- await util.goTo(room2);
-
- // Then the room becomes read
- await util.assertRead(room2);
- });
- test("Reading an older message leaves the room unread", async ({
- roomAlpha: room1,
- roomBeta: room2,
- util,
- msg,
- }) => {
- // Given there are lots of messages in a room
- await util.goTo(room1);
- await util.receiveMessages(room2, many("Msg", 30));
- await util.assertUnread(room2, 30);
-
- // When I jump to one of the older messages
- await msg.jumpTo(room2.name, "Msg0001");
-
- // Then the room is still unread, but some messages were read
- await util.assertUnreadLessThan(room2, 30);
- });
- test("Marking a room as read makes it read", async ({ roomAlpha: room1, roomBeta: room2, util, msg }) => {
- // Given I have some unread messages
- await util.goTo(room1);
- await util.assertRead(room2);
- await util.receiveMessages(room2, ["Msg1"]);
- await util.assertUnread(room2, 1);
-
- // When I mark the room as read
- await util.markAsRead(room2);
-
- // Then it is read
- await util.assertRead(room2);
- });
- test("Receiving a new message after marking as read makes it unread", async ({
- roomAlpha: room1,
- roomBeta: room2,
- util,
- msg,
- }) => {
- // Given I have marked my messages as read
- await util.goTo(room1);
- await util.assertRead(room2);
- await util.receiveMessages(room2, ["Msg1"]);
- await util.assertUnread(room2, 1);
- await util.markAsRead(room2);
- await util.assertRead(room2);
-
- // When I receive a new message
- await util.receiveMessages(room2, ["Msg2"]);
-
- // Then the room is unread
- await util.assertUnread(room2, 1);
- });
- test("A room with a new message is still unread after restart", async ({
- roomAlpha: room1,
- roomBeta: room2,
- util,
- msg,
- }) => {
- // Given I have an unread message
- await util.goTo(room1);
- await util.assertRead(room2);
- await util.receiveMessages(room2, ["Msg1"]);
- await util.assertUnread(room2, 1);
-
- // When I restart
- await util.saveAndReload();
-
- // Then I still have an unread message
- await util.assertUnread(room2, 1);
- });
- test("A room where all messages are read is still read after restart", async ({
- roomAlpha: room1,
- roomBeta: room2,
- util,
- msg,
- }) => {
- // Given I have read all messages
- await util.goTo(room1);
- await util.assertRead(room2);
- await util.receiveMessages(room2, ["Msg1"]);
- await util.assertUnread(room2, 1);
- await util.goTo(room2);
- await util.assertRead(room2);
-
- // When I restart
- await util.saveAndReload();
-
- // Then all messages are still read
- await util.assertRead(room2);
- });
- test("A room that was marked as read is still read after restart", async ({
- roomAlpha: room1,
- roomBeta: room2,
- util,
- msg,
- }) => {
- // Given I have marked all messages as read
- await util.goTo(room1);
- await util.assertRead(room2);
- await util.receiveMessages(room2, ["Msg1"]);
- await util.assertUnread(room2, 1);
- await util.markAsRead(room2);
- await util.assertRead(room2);
-
- // When I restart
- await util.saveAndReload();
-
- // Then all messages are still read
- await util.assertRead(room2);
- });
- });
-
test.describe("in threads", () => {
test("Receiving a message makes a room unread", async ({
roomAlpha: room1,
@@ -450,100 +305,5 @@ test.describe("Read receipts", () => {
await util.assertReadThread("Msg1");
});
});
-
- test.describe("thread roots", () => {
- test("Reading a thread root does not mark the thread as read", async ({
- roomAlpha: room1,
- roomBeta: room2,
- util,
- msg,
- }) => {
- // Given a thread exists
- await util.goTo(room1);
- await util.receiveMessages(room2, ["Msg1", msg.threadedOff("Msg1", "Resp1")]);
- await util.assertUnread(room2, 1); // (Sanity)
-
- // When I read the main timeline
- await util.goTo(room2);
-
- // Then room doesn't appear unread but the thread does
- await util.assertRead(room2);
- await util.assertUnreadThread("Msg1");
- });
-
- test("Reading a thread root within the thread view marks it as read in the main timeline", async ({
- roomAlpha: room1,
- roomBeta: room2,
- util,
- msg,
- }) => {
- // Given lots of messages are on the main timeline, and one has a thread off it
- await util.goTo(room1);
- await util.receiveMessages(room2, [
- ...many("beforeThread", 30),
- "ThreadRoot",
- msg.threadedOff("ThreadRoot", "InThread"),
- ...many("afterThread", 30),
- ]);
- await util.assertUnread(room2, 61); // Sanity
-
- // When I jump to an old message and read the thread
- await msg.jumpTo(room2.name, "beforeThread0000");
- // When the thread is opened, the timeline is scrolled until the thread root reached the center
- await util.openThread("ThreadRoot");
-
- // Then the thread root is marked as read in the main timeline,
- // 30 remaining messages are unread - 7 messages are displayed under the thread root
- await util.assertUnread(room2, 30 - 7);
- });
-
- test("Creating a new thread based on a reply makes the room unread", async ({
- roomAlpha: room1,
- roomBeta: room2,
- util,
- msg,
- }) => {
- // Given a message and reply exist and are read
- await util.goTo(room1);
- await util.receiveMessages(room2, ["Msg1", msg.replyTo("Msg1", "Reply1")]);
- await util.goTo(room2);
- await util.assertRead(room2);
- await util.goTo(room1);
- await util.assertRead(room2);
-
- // When I receive a thread message created on the reply
- await util.receiveMessages(room2, [msg.threadedOff("Reply1", "Resp1")]);
-
- // Then the thread is unread
- await util.goTo(room2);
- await util.assertUnreadThread("Reply1");
- });
-
- test("Reading a thread whose root is a reply makes the thread read", async ({
- roomAlpha: room1,
- roomBeta: room2,
- util,
- msg,
- }) => {
- // Given an unread thread off a reply exists
- await util.goTo(room1);
- await util.receiveMessages(room2, [
- "Msg1",
- msg.replyTo("Msg1", "Reply1"),
- msg.threadedOff("Reply1", "Resp1"),
- ]);
- await util.assertUnread(room2, 2);
- await util.goTo(room2);
- await util.assertRead(room2);
- await util.assertUnreadThread("Reply1");
-
- // When I read the thread
- await util.openThread("Reply1");
-
- // Then the room and thread are read
- await util.assertRead(room2);
- await util.assertReadThread("Reply1");
- });
- });
});
});
diff --git a/playwright/e2e/read-receipts/new-messages-main-timeline.spec.ts b/playwright/e2e/read-receipts/new-messages-main-timeline.spec.ts
new file mode 100644
index 00000000000..eb528f28161
--- /dev/null
+++ b/playwright/e2e/read-receipts/new-messages-main-timeline.spec.ts
@@ -0,0 +1,168 @@
+/*
+Copyright 2023 The Matrix.org Foundation C.I.C.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+/* See readme.md for tips on writing these tests. */
+
+import { many, test } from ".";
+
+test.describe("Read receipts", () => {
+ test.describe("new messages", () => {
+ test.describe("in the main timeline", () => {
+ test("Receiving a message makes a room unread", async ({
+ roomAlpha: room1,
+ roomBeta: room2,
+ util,
+ msg,
+ }) => {
+ // Given I am in a different room
+ await util.goTo(room1);
+ await util.assertRead(room2);
+
+ // When I receive some messages
+ await util.receiveMessages(room2, ["Msg1"]);
+
+ // Then the room is marked as unread
+ await util.assertUnread(room2, 1);
+ });
+ test("Reading latest message makes the room read", async ({
+ roomAlpha: room1,
+ roomBeta: room2,
+ util,
+ msg,
+ }) => {
+ // Given I have some unread messages
+ await util.goTo(room1);
+ await util.assertRead(room2);
+ await util.receiveMessages(room2, ["Msg1"]);
+ await util.assertUnread(room2, 1);
+
+ // When I read the main timeline
+ await util.goTo(room2);
+
+ // Then the room becomes read
+ await util.assertRead(room2);
+ });
+ test("Reading an older message leaves the room unread", async ({
+ roomAlpha: room1,
+ roomBeta: room2,
+ util,
+ msg,
+ }) => {
+ // Given there are lots of messages in a room
+ await util.goTo(room1);
+ await util.receiveMessages(room2, many("Msg", 30));
+ await util.assertUnread(room2, 30);
+
+ // When I jump to one of the older messages
+ await msg.jumpTo(room2.name, "Msg0001");
+
+ // Then the room is still unread, but some messages were read
+ await util.assertUnreadLessThan(room2, 30);
+ });
+ test("Marking a room as read makes it read", async ({ roomAlpha: room1, roomBeta: room2, util, msg }) => {
+ // Given I have some unread messages
+ await util.goTo(room1);
+ await util.assertRead(room2);
+ await util.receiveMessages(room2, ["Msg1"]);
+ await util.assertUnread(room2, 1);
+
+ // When I mark the room as read
+ await util.markAsRead(room2);
+
+ // Then it is read
+ await util.assertRead(room2);
+ });
+ test("Receiving a new message after marking as read makes it unread", async ({
+ roomAlpha: room1,
+ roomBeta: room2,
+ util,
+ msg,
+ }) => {
+ // Given I have marked my messages as read
+ await util.goTo(room1);
+ await util.assertRead(room2);
+ await util.receiveMessages(room2, ["Msg1"]);
+ await util.assertUnread(room2, 1);
+ await util.markAsRead(room2);
+ await util.assertRead(room2);
+
+ // When I receive a new message
+ await util.receiveMessages(room2, ["Msg2"]);
+
+ // Then the room is unread
+ await util.assertUnread(room2, 1);
+ });
+ test("A room with a new message is still unread after restart", async ({
+ roomAlpha: room1,
+ roomBeta: room2,
+ util,
+ msg,
+ }) => {
+ // Given I have an unread message
+ await util.goTo(room1);
+ await util.assertRead(room2);
+ await util.receiveMessages(room2, ["Msg1"]);
+ await util.assertUnread(room2, 1);
+
+ // When I restart
+ await util.saveAndReload();
+
+ // Then I still have an unread message
+ await util.assertUnread(room2, 1);
+ });
+ test("A room where all messages are read is still read after restart", async ({
+ roomAlpha: room1,
+ roomBeta: room2,
+ util,
+ msg,
+ }) => {
+ // Given I have read all messages
+ await util.goTo(room1);
+ await util.assertRead(room2);
+ await util.receiveMessages(room2, ["Msg1"]);
+ await util.assertUnread(room2, 1);
+ await util.goTo(room2);
+ await util.assertRead(room2);
+
+ // When I restart
+ await util.saveAndReload();
+
+ // Then all messages are still read
+ await util.assertRead(room2);
+ });
+ test("A room that was marked as read is still read after restart", async ({
+ roomAlpha: room1,
+ roomBeta: room2,
+ util,
+ msg,
+ }) => {
+ // Given I have marked all messages as read
+ await util.goTo(room1);
+ await util.assertRead(room2);
+ await util.receiveMessages(room2, ["Msg1"]);
+ await util.assertUnread(room2, 1);
+ await util.markAsRead(room2);
+ await util.assertRead(room2);
+
+ // When I restart
+ await util.saveAndReload();
+
+ // Then all messages are still read
+ await util.assertRead(room2);
+ });
+ });
+ });
+});
diff --git a/playwright/e2e/read-receipts/new-messages-thread-roots.spec.ts b/playwright/e2e/read-receipts/new-messages-thread-roots.spec.ts
new file mode 100644
index 00000000000..526bac4bffc
--- /dev/null
+++ b/playwright/e2e/read-receipts/new-messages-thread-roots.spec.ts
@@ -0,0 +1,118 @@
+/*
+Copyright 2023 The Matrix.org Foundation C.I.C.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+/* See readme.md for tips on writing these tests. */
+
+import { many, test } from ".";
+
+test.describe("Read receipts", () => {
+ test.describe("new messages", () => {
+ test.describe("thread roots", () => {
+ test("Reading a thread root does not mark the thread as read", async ({
+ roomAlpha: room1,
+ roomBeta: room2,
+ util,
+ msg,
+ }) => {
+ // Given a thread exists
+ await util.goTo(room1);
+ await util.receiveMessages(room2, ["Msg1", msg.threadedOff("Msg1", "Resp1")]);
+ await util.assertUnread(room2, 1); // (Sanity)
+
+ // When I read the main timeline
+ await util.goTo(room2);
+
+ // Then room doesn't appear unread but the thread does
+ await util.assertRead(room2);
+ await util.assertUnreadThread("Msg1");
+ });
+
+ test("Reading a thread root within the thread view marks it as read in the main timeline", async ({
+ roomAlpha: room1,
+ roomBeta: room2,
+ util,
+ msg,
+ }) => {
+ // Given lots of messages are on the main timeline, and one has a thread off it
+ await util.goTo(room1);
+ await util.receiveMessages(room2, [
+ ...many("beforeThread", 30),
+ "ThreadRoot",
+ msg.threadedOff("ThreadRoot", "InThread"),
+ ...many("afterThread", 30),
+ ]);
+ await util.assertUnread(room2, 61); // Sanity
+
+ // When I jump to an old message and read the thread
+ await msg.jumpTo(room2.name, "beforeThread0000");
+ // When the thread is opened, the timeline is scrolled until the thread root reached the center
+ await util.openThread("ThreadRoot");
+
+ // Then the thread root is marked as read in the main timeline,
+ // 30 remaining messages are unread - 7 messages are displayed under the thread root
+ await util.assertUnread(room2, 30 - 7);
+ });
+
+ test("Creating a new thread based on a reply makes the room unread", async ({
+ roomAlpha: room1,
+ roomBeta: room2,
+ util,
+ msg,
+ }) => {
+ // Given a message and reply exist and are read
+ await util.goTo(room1);
+ await util.receiveMessages(room2, ["Msg1", msg.replyTo("Msg1", "Reply1")]);
+ await util.goTo(room2);
+ await util.assertRead(room2);
+ await util.goTo(room1);
+ await util.assertRead(room2);
+
+ // When I receive a thread message created on the reply
+ await util.receiveMessages(room2, [msg.threadedOff("Reply1", "Resp1")]);
+
+ // Then the thread is unread
+ await util.goTo(room2);
+ await util.assertUnreadThread("Reply1");
+ });
+
+ test("Reading a thread whose root is a reply makes the thread read", async ({
+ roomAlpha: room1,
+ roomBeta: room2,
+ util,
+ msg,
+ }) => {
+ // Given an unread thread off a reply exists
+ await util.goTo(room1);
+ await util.receiveMessages(room2, [
+ "Msg1",
+ msg.replyTo("Msg1", "Reply1"),
+ msg.threadedOff("Reply1", "Resp1"),
+ ]);
+ await util.assertUnread(room2, 2);
+ await util.goTo(room2);
+ await util.assertRead(room2);
+ await util.assertUnreadThread("Reply1");
+
+ // When I read the thread
+ await util.openThread("Reply1");
+
+ // Then the room and thread are read
+ await util.assertRead(room2);
+ await util.assertReadThread("Reply1");
+ });
+ });
+ });
+});
diff --git a/playwright/e2e/read-receipts/notifications.spec.ts b/playwright/e2e/read-receipts/notifications.spec.ts
new file mode 100644
index 00000000000..5d87de1bb6c
--- /dev/null
+++ b/playwright/e2e/read-receipts/notifications.spec.ts
@@ -0,0 +1,56 @@
+/*
+Copyright 2023 The Matrix.org Foundation C.I.C.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+/* See readme.md for tips on writing these tests. */
+
+import { test } from ".";
+
+test.describe("Read receipts", () => {
+ test.describe("Notifications", () => {
+ test.describe("in the main timeline", () => {
+ test.fixme("A new message that mentions me shows a notification", () => {});
+ test.fixme(
+ "Reading a notifying message reduces the notification count in the room list, space and tab",
+ () => {},
+ );
+ test.fixme(
+ "Reading the last notifying message removes the notification marker from room list, space and tab",
+ () => {},
+ );
+ test.fixme("Editing a message to mentions me shows a notification", () => {});
+ test.fixme("Reading the last notifying edited message removes the notification marker", () => {});
+ test.fixme("Redacting a notifying message removes the notification marker", () => {});
+ });
+
+ test.describe("in threads", () => {
+ test.fixme("A new threaded message that mentions me shows a notification", () => {});
+ test.fixme("Reading a notifying threaded message removes the notification count", () => {});
+ test.fixme(
+ "Notification count remains steady when reading threads that contain seen notifications",
+ () => {},
+ );
+ test.fixme(
+ "Notification count remains steady when paging up thread view even when threads contain seen notifications",
+ () => {},
+ );
+ test.fixme(
+ "Notification count remains steady when paging up thread view after mark as unread even if older threads contain notifications",
+ () => {},
+ );
+ test.fixme("Redacting a notifying threaded message removes the notification marker", () => {});
+ });
+ });
+});
diff --git a/playwright/e2e/read-receipts/reactions.spec.ts b/playwright/e2e/read-receipts/reactions-in-threads.spec.ts
similarity index 56%
rename from playwright/e2e/read-receipts/reactions.spec.ts
rename to playwright/e2e/read-receipts/reactions-in-threads.spec.ts
index 69208e5fc9e..dcd97ac431b 100644
--- a/playwright/e2e/read-receipts/reactions.spec.ts
+++ b/playwright/e2e/read-receipts/reactions-in-threads.spec.ts
@@ -20,82 +20,6 @@ import { test, expect } from ".";
test.describe("Read receipts", () => {
test.describe("reactions", () => {
- test.describe("in the main timeline", () => {
- test("Receiving a reaction to a message does not make a room unread", async ({
- roomAlpha: room1,
- roomBeta: room2,
- util,
- msg,
- }) => {
- await util.goTo(room1);
- await util.assertRead(room2);
- await util.receiveMessages(room2, ["Msg1", "Msg2"]);
- await util.assertUnread(room2, 2);
-
- // When I read the main timeline
- await util.goTo(room2);
- await util.assertRead(room2);
-
- await util.goTo(room1);
- await util.receiveMessages(room2, [msg.reactionTo("Msg2", "🪿")]);
- await util.assertRead(room2);
- });
- test("Reacting to a message after marking as read does not make the room unread", async ({
- roomAlpha: room1,
- roomBeta: room2,
- util,
- msg,
- }) => {
- await util.goTo(room1);
- await util.assertRead(room2);
- await util.receiveMessages(room2, ["Msg1", "Msg2"]);
- await util.assertUnread(room2, 2);
-
- await util.markAsRead(room2);
- await util.assertRead(room2);
-
- await util.receiveMessages(room2, [msg.reactionTo("Msg2", "🪿")]);
- await util.assertRead(room2);
- });
- test("A room with an unread reaction is still read after restart", async ({
- roomAlpha: room1,
- roomBeta: room2,
- util,
- msg,
- }) => {
- await util.goTo(room1);
- await util.assertRead(room2);
- await util.receiveMessages(room2, ["Msg1", "Msg2"]);
- await util.assertUnread(room2, 2);
-
- await util.markAsRead(room2);
- await util.assertRead(room2);
-
- await util.receiveMessages(room2, [msg.reactionTo("Msg2", "🪿")]);
- await util.assertRead(room2);
-
- await util.saveAndReload();
- await util.assertRead(room2);
- });
- test("A room where all reactions are read is still read after restart", async ({
- roomAlpha: room1,
- roomBeta: room2,
- util,
- msg,
- }) => {
- await util.goTo(room1);
- await util.assertRead(room2);
- await util.receiveMessages(room2, ["Msg1", "Msg2", msg.reactionTo("Msg2", "🪿")]);
- await util.assertUnread(room2, 2);
-
- await util.markAsRead(room2);
- await util.assertRead(room2);
-
- await util.saveAndReload();
- await util.assertRead(room2);
- });
- });
-
test.describe("in threads", () => {
test("A reaction to a threaded message does not make the room unread", async ({
roomAlpha: room1,
@@ -281,97 +205,5 @@ test.describe("Read receipts", () => {
await expect(await page.locator(".mx_ThreadPanel").getByLabel("Mae reacted with 😀")).not.toBeVisible();
});
});
-
- test.describe("thread roots", () => {
- test("A reaction to a thread root does not make the room unread", async ({
- page,
- roomAlpha: room1,
- roomBeta: room2,
- util,
- msg,
- }) => {
- // Given a read thread root exists
- await util.goTo(room1);
- await util.assertRead(room2);
- await util.receiveMessages(room2, ["Msg1", msg.threadedOff("Msg1", "Reply1")]);
- await util.assertUnread(room2, 1);
- await util.goTo(room2);
- await util.openThread("Msg1");
- await util.assertRead(room2);
- await util.assertReadThread("Msg1");
-
- // When someone reacts to it
- await util.goTo(room1);
- await util.receiveMessages(room2, [msg.reactionTo("Msg1", "🪿")]);
- await page.waitForTimeout(200);
-
- // Then the room is still read
- await util.assertRead(room2);
- // as is the thread
- await util.assertReadThread("Msg1");
- });
-
- test("Reading a reaction to a thread root leaves the room read", async ({
- page,
- roomAlpha: room1,
- roomBeta: room2,
- util,
- msg,
- }) => {
- // Given a read thread root exists
- await util.goTo(room1);
- await util.assertRead(room2);
- await util.receiveMessages(room2, ["Msg1", msg.threadedOff("Msg1", "Reply1")]);
- await util.assertUnread(room2, 1);
- await util.goTo(room2);
- await util.openThread("Msg1");
- await util.assertRead(room2);
-
- // And the reaction to it does not make us unread
- await util.goTo(room1);
- await util.receiveMessages(room2, [msg.reactionTo("Msg1", "🪿")]);
- await util.assertRead(room2);
- await util.assertReadThread("Msg1");
-
- // When we read the reaction and go away again
- await util.goTo(room2);
- await util.openThread("Msg1");
- await util.assertRead(room2);
- await util.goTo(room1);
- await page.waitForTimeout(200);
-
- // Then the room is still read
- await util.assertRead(room2);
- await util.assertReadThread("Msg1");
- });
-
- test("Reacting to a thread root after marking as read makes the room unread but not the thread", async ({
- page,
- roomAlpha: room1,
- roomBeta: room2,
- util,
- msg,
- }) => {
- // Given a thread root exists
- await util.goTo(room1);
- await util.assertRead(room2);
- await util.receiveMessages(room2, ["Msg1", msg.threadedOff("Msg1", "Reply1")]);
- await util.assertUnread(room2, 1);
-
- // And we have marked the room as read
- await util.markAsRead(room2);
- await util.assertRead(room2);
- await util.assertReadThread("Msg1");
-
- // When someone reacts to it
- await util.receiveMessages(room2, [msg.reactionTo("Msg1", "🪿")]);
- await page.waitForTimeout(200);
-
- // Then the room is still read
- await util.assertRead(room2);
- // as is the thread
- await util.assertReadThread("Msg1");
- });
- });
});
});
diff --git a/playwright/e2e/read-receipts/reactions-main-timeline.spec.ts b/playwright/e2e/read-receipts/reactions-main-timeline.spec.ts
new file mode 100644
index 00000000000..54f0c89afe0
--- /dev/null
+++ b/playwright/e2e/read-receipts/reactions-main-timeline.spec.ts
@@ -0,0 +1,99 @@
+/*
+Copyright 2023 The Matrix.org Foundation C.I.C.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+/* See readme.md for tips on writing these tests. */
+
+import { test } from ".";
+
+test.describe("Read receipts", () => {
+ test.describe("reactions", () => {
+ test.describe("in the main timeline", () => {
+ test("Receiving a reaction to a message does not make a room unread", async ({
+ roomAlpha: room1,
+ roomBeta: room2,
+ util,
+ msg,
+ }) => {
+ await util.goTo(room1);
+ await util.assertRead(room2);
+ await util.receiveMessages(room2, ["Msg1", "Msg2"]);
+ await util.assertUnread(room2, 2);
+
+ // When I read the main timeline
+ await util.goTo(room2);
+ await util.assertRead(room2);
+
+ await util.goTo(room1);
+ await util.receiveMessages(room2, [msg.reactionTo("Msg2", "🪿")]);
+ await util.assertRead(room2);
+ });
+ test("Reacting to a message after marking as read does not make the room unread", async ({
+ roomAlpha: room1,
+ roomBeta: room2,
+ util,
+ msg,
+ }) => {
+ await util.goTo(room1);
+ await util.assertRead(room2);
+ await util.receiveMessages(room2, ["Msg1", "Msg2"]);
+ await util.assertUnread(room2, 2);
+
+ await util.markAsRead(room2);
+ await util.assertRead(room2);
+
+ await util.receiveMessages(room2, [msg.reactionTo("Msg2", "🪿")]);
+ await util.assertRead(room2);
+ });
+ test("A room with an unread reaction is still read after restart", async ({
+ roomAlpha: room1,
+ roomBeta: room2,
+ util,
+ msg,
+ }) => {
+ await util.goTo(room1);
+ await util.assertRead(room2);
+ await util.receiveMessages(room2, ["Msg1", "Msg2"]);
+ await util.assertUnread(room2, 2);
+
+ await util.markAsRead(room2);
+ await util.assertRead(room2);
+
+ await util.receiveMessages(room2, [msg.reactionTo("Msg2", "🪿")]);
+ await util.assertRead(room2);
+
+ await util.saveAndReload();
+ await util.assertRead(room2);
+ });
+ test("A room where all reactions are read is still read after restart", async ({
+ roomAlpha: room1,
+ roomBeta: room2,
+ util,
+ msg,
+ }) => {
+ await util.goTo(room1);
+ await util.assertRead(room2);
+ await util.receiveMessages(room2, ["Msg1", "Msg2", msg.reactionTo("Msg2", "🪿")]);
+ await util.assertUnread(room2, 2);
+
+ await util.markAsRead(room2);
+ await util.assertRead(room2);
+
+ await util.saveAndReload();
+ await util.assertRead(room2);
+ });
+ });
+ });
+});
diff --git a/playwright/e2e/read-receipts/reactions-thread-roots.spec.ts b/playwright/e2e/read-receipts/reactions-thread-roots.spec.ts
new file mode 100644
index 00000000000..9c1be63e5b5
--- /dev/null
+++ b/playwright/e2e/read-receipts/reactions-thread-roots.spec.ts
@@ -0,0 +1,115 @@
+/*
+Copyright 2023 The Matrix.org Foundation C.I.C.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+/* See readme.md for tips on writing these tests. */
+
+import { test } from ".";
+
+test.describe("Read receipts", () => {
+ test.describe("reactions", () => {
+ test.describe("thread roots", () => {
+ test("A reaction to a thread root does not make the room unread", async ({
+ page,
+ roomAlpha: room1,
+ roomBeta: room2,
+ util,
+ msg,
+ }) => {
+ // Given a read thread root exists
+ await util.goTo(room1);
+ await util.assertRead(room2);
+ await util.receiveMessages(room2, ["Msg1", msg.threadedOff("Msg1", "Reply1")]);
+ await util.assertUnread(room2, 1);
+ await util.goTo(room2);
+ await util.openThread("Msg1");
+ await util.assertRead(room2);
+ await util.assertReadThread("Msg1");
+
+ // When someone reacts to it
+ await util.goTo(room1);
+ await util.receiveMessages(room2, [msg.reactionTo("Msg1", "🪿")]);
+ await page.waitForTimeout(200);
+
+ // Then the room is still read
+ await util.assertRead(room2);
+ // as is the thread
+ await util.assertReadThread("Msg1");
+ });
+
+ test("Reading a reaction to a thread root leaves the room read", async ({
+ page,
+ roomAlpha: room1,
+ roomBeta: room2,
+ util,
+ msg,
+ }) => {
+ // Given a read thread root exists
+ await util.goTo(room1);
+ await util.assertRead(room2);
+ await util.receiveMessages(room2, ["Msg1", msg.threadedOff("Msg1", "Reply1")]);
+ await util.assertUnread(room2, 1);
+ await util.goTo(room2);
+ await util.openThread("Msg1");
+ await util.assertRead(room2);
+
+ // And the reaction to it does not make us unread
+ await util.goTo(room1);
+ await util.receiveMessages(room2, [msg.reactionTo("Msg1", "🪿")]);
+ await util.assertRead(room2);
+ await util.assertReadThread("Msg1");
+
+ // When we read the reaction and go away again
+ await util.goTo(room2);
+ await util.openThread("Msg1");
+ await util.assertRead(room2);
+ await util.goTo(room1);
+ await page.waitForTimeout(200);
+
+ // Then the room is still read
+ await util.assertRead(room2);
+ await util.assertReadThread("Msg1");
+ });
+
+ test("Reacting to a thread root after marking as read makes the room unread but not the thread", async ({
+ page,
+ roomAlpha: room1,
+ roomBeta: room2,
+ util,
+ msg,
+ }) => {
+ // Given a thread root exists
+ await util.goTo(room1);
+ await util.assertRead(room2);
+ await util.receiveMessages(room2, ["Msg1", msg.threadedOff("Msg1", "Reply1")]);
+ await util.assertUnread(room2, 1);
+
+ // And we have marked the room as read
+ await util.markAsRead(room2);
+ await util.assertRead(room2);
+ await util.assertReadThread("Msg1");
+
+ // When someone reacts to it
+ await util.receiveMessages(room2, [msg.reactionTo("Msg1", "🪿")]);
+ await page.waitForTimeout(200);
+
+ // Then the room is still read
+ await util.assertRead(room2);
+ // as is the thread
+ await util.assertReadThread("Msg1");
+ });
+ });
+ });
+});
diff --git a/playwright/e2e/read-receipts/redactions.spec.ts b/playwright/e2e/read-receipts/redactions-in-threads.spec.ts
similarity index 52%
rename from playwright/e2e/read-receipts/redactions.spec.ts
rename to playwright/e2e/read-receipts/redactions-in-threads.spec.ts
index f7affbed212..323748e7e08 100644
--- a/playwright/e2e/read-receipts/redactions.spec.ts
+++ b/playwright/e2e/read-receipts/redactions-in-threads.spec.ts
@@ -20,314 +20,6 @@ import { test } from ".";
test.describe("Read receipts", () => {
test.describe("redactions", () => {
- test.describe("in the main timeline", () => {
- test("Redacting the message pointed to by my receipt leaves the room read", async ({
- roomAlpha: room1,
- roomBeta: room2,
- util,
- msg,
- }) => {
- // Given I have read the messages in a room
- await util.goTo(room1);
- await util.receiveMessages(room2, ["Msg1", "Msg2"]);
- await util.assertUnread(room2, 2);
- await util.goTo(room2);
- await util.assertRead(room2);
- await util.goTo(room1);
-
- // When the latest message is redacted
- await util.receiveMessages(room2, [msg.redactionOf("Msg2")]);
-
- // Then the room remains read
- await util.assertStillRead(room2);
- });
-
- test("Reading an unread room after a redaction of the latest message makes it read", async ({
- roomAlpha: room1,
- roomBeta: room2,
- util,
- msg,
- }) => {
- // Given an unread room
- await util.goTo(room1);
- await util.receiveMessages(room2, ["Msg1", "Msg2"]);
- await util.assertUnread(room2, 2);
-
- // And the latest message has been redacted
- await util.receiveMessages(room2, [msg.redactionOf("Msg2")]);
-
- // When I read the room
- await util.goTo(room2);
- await util.assertRead(room2);
- await util.goTo(room1);
-
- // Then it becomes read
- await util.assertStillRead(room2);
- });
- test("Reading an unread room after a redaction of an older message makes it read", async ({
- roomAlpha: room1,
- roomBeta: room2,
- util,
- msg,
- }) => {
- // Given an unread room with an earlier redaction
- await util.goTo(room1);
- await util.receiveMessages(room2, ["Msg1", "Msg2"]);
- await util.assertUnread(room2, 2);
- await util.receiveMessages(room2, [msg.redactionOf("Msg1")]);
-
- // When I read the room
- await util.goTo(room2);
- await util.assertRead(room2);
- await util.goTo(room1);
-
- // Then it becomes read
- await util.assertStillRead(room2);
- });
- test("Marking an unread room as read after a redaction makes it read", async ({
- roomAlpha: room1,
- roomBeta: room2,
- util,
- msg,
- }) => {
- // Given an unread room where latest message is redacted
- await util.goTo(room1);
- await util.receiveMessages(room2, ["Msg1", "Msg2"]);
- await util.assertUnread(room2, 2);
- await util.receiveMessages(room2, [msg.redactionOf("Msg2")]);
- await util.assertUnread(room2, 1);
-
- // When I mark it as read
- await util.markAsRead(room2);
-
- // Then it becomes read
- await util.assertRead(room2);
- });
- test("Sending and redacting a message after marking the room as read makes it read", async ({
- roomAlpha: room1,
- roomBeta: room2,
- util,
- msg,
- }) => {
- // Given a room that is marked as read
- await util.goTo(room1);
- await util.receiveMessages(room2, ["Msg1", "Msg2"]);
- await util.assertUnread(room2, 2);
- await util.markAsRead(room2);
- await util.assertRead(room2);
-
- // When a message is sent and then redacted
- await util.receiveMessages(room2, ["Msg3"]);
- await util.assertUnread(room2, 1);
- await util.receiveMessages(room2, [msg.redactionOf("Msg3")]);
-
- // Then the room is read
- await util.assertRead(room2);
- });
- test("Redacting a message after marking the room as read leaves it read", async ({
- roomAlpha: room1,
- roomBeta: room2,
- util,
- msg,
- }) => {
- // Given a room that is marked as read
- await util.goTo(room1);
- await util.receiveMessages(room2, ["Msg1", "Msg2", "Msg3"]);
- await util.assertUnread(room2, 3);
- await util.markAsRead(room2);
- await util.assertRead(room2);
-
- // When we redact some messages
- await util.receiveMessages(room2, [msg.redactionOf("Msg3")]);
- await util.receiveMessages(room2, [msg.redactionOf("Msg1")]);
-
- // Then it is still read
- await util.assertStillRead(room2);
- });
- test("Redacting one of the unread messages reduces the unread count", async ({
- roomAlpha: room1,
- roomBeta: room2,
- util,
- msg,
- }) => {
- // Given an unread room
- await util.goTo(room1);
- await util.receiveMessages(room2, ["Msg1", "Msg2", "Msg3"]);
- await util.assertUnread(room2, 3);
-
- // When I redact a non-latest message
- await util.receiveMessages(room2, [msg.redactionOf("Msg2")]);
-
- // Then the unread count goes down
- await util.assertUnread(room2, 2);
-
- // And when I redact the latest message
- await util.receiveMessages(room2, [msg.redactionOf("Msg3")]);
-
- // Then the unread count goes down again
- await util.assertUnread(room2, 1);
- });
- test("Redacting one of the unread messages reduces the unread count after restart", async ({
- roomAlpha: room1,
- roomBeta: room2,
- util,
- msg,
- }) => {
- // Given unread count was reduced by redacting messages
- await util.goTo(room1);
- await util.receiveMessages(room2, ["Msg1", "Msg2", "Msg3"]);
- await util.assertUnread(room2, 3);
- await util.receiveMessages(room2, [msg.redactionOf("Msg2")]);
- await util.assertUnread(room2, 2);
- await util.receiveMessages(room2, [msg.redactionOf("Msg3")]);
- await util.assertUnread(room2, 1);
-
- // When I restart
- await util.saveAndReload();
-
- // Then the unread count is still reduced
- await util.assertUnread(room2, 1);
- });
- test("Redacting all unread messages makes the room read", async ({
- roomAlpha: room1,
- roomBeta: room2,
- util,
- msg,
- }) => {
- // Given an unread room
- await util.goTo(room1);
- await util.receiveMessages(room2, ["Msg1", "Msg2"]);
- await util.assertUnread(room2, 2);
-
- // When I redact all the unread messages
- await util.receiveMessages(room2, [msg.redactionOf("Msg2")]);
- await util.receiveMessages(room2, [msg.redactionOf("Msg1")]);
-
- // Then the room is back to being read
- await util.assertRead(room2);
- });
- test("Redacting all unread messages makes the room read after restart", async ({
- roomAlpha: room1,
- roomBeta: room2,
- util,
- msg,
- }) => {
- // Given all unread messages were redacted
- await util.goTo(room1);
- await util.receiveMessages(room2, ["Msg1", "Msg2"]);
- await util.assertUnread(room2, 2);
- await util.receiveMessages(room2, [msg.redactionOf("Msg2")]);
- await util.receiveMessages(room2, [msg.redactionOf("Msg1")]);
- await util.assertRead(room2);
-
- // When I restart
- await util.saveAndReload();
-
- // Then the room is still read
- await util.assertRead(room2);
- });
- test("Reacting to a redacted message leaves the room read", async ({
- page,
- roomAlpha: room1,
- roomBeta: room2,
- util,
- msg,
- }) => {
- // Given a redacted message exists
- await util.goTo(room1);
- await util.receiveMessages(room2, ["Msg1", "Msg2"]);
- await util.assertUnread(room2, 2);
- await util.receiveMessages(room2, [msg.redactionOf("Msg2")]);
- await util.assertUnread(room2, 1);
-
- // And the room is read
- await util.goTo(room2);
- await util.assertRead(room2);
- await page.waitForTimeout(200);
- await util.goTo(room1);
-
- // When I react to the redacted message
- await util.receiveMessages(room2, [msg.reactionTo("Msg2", "🪿")]);
-
- // Then the room is still read
- await util.assertStillRead(room2);
- });
- test("Editing a redacted message leaves the room read", async ({
- roomAlpha: room1,
- roomBeta: room2,
- util,
- msg,
- }) => {
- // Given a redacted message exists
- await util.goTo(room1);
- await util.receiveMessages(room2, ["Msg1", "Msg2"]);
- await util.assertUnread(room2, 2);
- await util.receiveMessages(room2, [msg.redactionOf("Msg2")]);
- await util.assertUnread(room2, 1);
-
- // And the room is read
- await util.goTo(room2);
- await util.assertRead(room2);
- await util.goTo(room1);
-
- // When I attempt to edit the redacted message
- await util.receiveMessages(room2, [msg.editOf("Msg2", "Msg2 is BACK")]);
-
- // Then the room is still read
- await util.assertStillRead(room2);
- });
- test("A reply to a redacted message makes the room unread", async ({
- roomAlpha: room1,
- roomBeta: room2,
- util,
- msg,
- }) => {
- // Given a message was redacted
- await util.goTo(room1);
- await util.receiveMessages(room2, ["Msg1", "Msg2"]);
- await util.assertUnread(room2, 2);
- await util.receiveMessages(room2, [msg.redactionOf("Msg2")]);
- await util.assertUnread(room2, 1);
-
- // And the room is read
- await util.goTo(room2);
- await util.assertRead(room2);
- await util.goTo(room1);
-
- // When I receive a reply to the redacted message
- await util.receiveMessages(room2, [msg.replyTo("Msg2", "Reply to Msg2")]);
-
- // Then the room is unread
- await util.assertUnread(room2, 1);
- });
- test("Reading a reply to a redacted message marks the room as read", async ({
- roomAlpha: room1,
- roomBeta: room2,
- util,
- msg,
- }) => {
- // Given someone replied to a redacted message
- await util.goTo(room1);
- await util.receiveMessages(room2, ["Msg1", "Msg2"]);
- await util.assertUnread(room2, 2);
- await util.receiveMessages(room2, [msg.redactionOf("Msg2")]);
- await util.assertUnread(room2, 1);
- await util.goTo(room2);
- await util.assertRead(room2);
- await util.goTo(room1);
- await util.receiveMessages(room2, [msg.replyTo("Msg2", "Reply to Msg2")]);
- await util.assertUnread(room2, 1);
-
- // When I read the reply
- await util.goTo(room2);
- await util.assertRead(room2);
-
- // Then the room is unread
- await util.goTo(room1);
- await util.assertStillRead(room2);
- });
- });
-
test.describe("in threads", () => {
test("Redacting the threaded message pointed to by my receipt leaves the room read", async ({
roomAlpha: room1,
@@ -866,214 +558,5 @@ test.describe("Read receipts", () => {
await util.assertReadThread("Root");
});
});
-
- test.describe("thread roots", () => {
- test("Redacting a thread root after it was read leaves the room read", async ({
- roomAlpha: room1,
- roomBeta: room2,
- util,
- msg,
- }) => {
- test.slow();
-
- // Given a thread exists and is read
- await util.goTo(room1);
- await util.receiveMessages(room2, [
- "Root",
- msg.threadedOff("Root", "Msg2"),
- msg.threadedOff("Root", "Msg3"),
- ]);
- await util.assertUnread(room2, 1);
- await util.goTo(room2);
- await util.openThread("Root");
- await util.assertRead(room2);
- await util.assertReadThread("Root");
-
- // When someone redacts the thread root
- await util.receiveMessages(room2, [msg.redactionOf("Root")]);
-
- // Then the room is still read
- await util.assertStillRead(room2);
- });
-
- /*
- * Disabled for the same reason as "A thread with a read redaction is still read after restart"
- * above
- */
- test.skip("Redacting a thread root still allows us to read the thread", async ({
- roomAlpha: room1,
- roomBeta: room2,
- util,
- msg,
- }) => {
- // Given an unread thread exists
- await util.goTo(room1);
- await util.receiveMessages(room2, [
- "Root",
- msg.threadedOff("Root", "Msg2"),
- msg.threadedOff("Root", "Msg3"),
- ]);
- await util.assertUnread(room2, 1);
-
- // When someone redacts the thread root
- await util.receiveMessages(room2, [msg.redactionOf("Root")]);
-
- // Then the room is still unread
- await util.assertUnread(room2, 1);
-
- // And I can open the thread and read it
- await util.goTo(room2);
- await util.assertRead(room2);
- // The redacted message gets collapsed into, "foo was invited, joined and removed a message"
- await util.openCollapsedMessage(1);
- await util.openThread("Message deleted");
- await util.assertRead(room2);
- await util.assertReadThread("Root");
- });
-
- test("Sending a threaded message onto a redacted thread root leaves the room unread", async ({
- roomAlpha: room1,
- roomBeta: room2,
- util,
- msg,
- }) => {
- // Given a thread exists, is read and its root is redacted
- await util.goTo(room1);
- await util.receiveMessages(room2, [
- "Root",
- msg.threadedOff("Root", "Msg2"),
- msg.threadedOff("Root", "Msg3"),
- ]);
- await util.assertUnread(room2, 1);
- await util.goTo(room2);
- await util.openThread("Root");
- await util.assertRead(room2);
- await util.assertReadThread("Root");
- await util.receiveMessages(room2, [msg.redactionOf("Root")]);
-
- // When we receive a new message on it
- await util.receiveMessages(room2, [msg.threadedOff("Root", "Msg4")]);
-
- // Then the room is read but the thread is unread
- await util.assertRead(room2);
- await util.goTo(room2);
- await util.assertUnreadThread("Message deleted");
- });
-
- test("Reacting to a redacted thread root leaves the room read", async ({
- roomAlpha: room1,
- roomBeta: room2,
- util,
- msg,
- }) => {
- // Given a thread exists, is read and the root was redacted
- await util.goTo(room1);
- await util.receiveMessages(room2, [
- "Root",
- msg.threadedOff("Root", "Msg2"),
- msg.threadedOff("Root", "Msg3"),
- ]);
- await util.assertUnread(room2, 1);
- await util.goTo(room2);
- await util.openThread("Root");
- await util.assertRead(room2);
- await util.assertReadThread("Root");
- await util.receiveMessages(room2, [msg.redactionOf("Root")]);
-
- // When I react to the old root
- await util.receiveMessages(room2, [msg.reactionTo("Root", "y")]);
-
- // Then the room is still read
- await util.assertRead(room2);
- await util.assertReadThread("Root");
- });
-
- test("Editing a redacted thread root leaves the room read", async ({
- roomAlpha: room1,
- roomBeta: room2,
- util,
- msg,
- }) => {
- // Given a thread exists, is read and the root was redacted
- await util.goTo(room1);
- await util.receiveMessages(room2, [
- "Root",
- msg.threadedOff("Root", "Msg2"),
- msg.threadedOff("Root", "Msg3"),
- ]);
- await util.assertUnread(room2, 1);
- await util.goTo(room2);
- await util.openThread("Root");
- await util.assertRead(room2);
- await util.assertReadThread("Root");
- await util.receiveMessages(room2, [msg.redactionOf("Root")]);
-
- // When I edit the old root
- await util.receiveMessages(room2, [msg.editOf("Root", "New Root")]);
-
- // Then the room is still read
- await util.assertRead(room2);
- // as is the thread
- await util.assertReadThread("Root");
- });
-
- test("Replying to a redacted thread root makes the room unread", async ({
- roomAlpha: room1,
- roomBeta: room2,
- util,
- msg,
- }) => {
- // Given a thread exists, is read and the root was redacted
- await util.goTo(room1);
- await util.receiveMessages(room2, [
- "Root",
- msg.threadedOff("Root", "Msg2"),
- msg.threadedOff("Root", "Msg3"),
- ]);
- await util.assertUnread(room2, 1);
- await util.goTo(room2);
- await util.openThread("Root");
- await util.assertRead(room2);
- await util.assertReadThread("Root");
- await util.receiveMessages(room2, [msg.redactionOf("Root")]);
-
- // When I reply to the old root
- await util.receiveMessages(room2, [msg.replyTo("Root", "Reply!")]);
-
- // Then the room is unread
- await util.assertUnread(room2, 1);
- });
-
- test("Reading a reply to a redacted thread root makes the room read", async ({
- roomAlpha: room1,
- roomBeta: room2,
- util,
- msg,
- }) => {
- // Given a thread exists, is read and the root was redacted, and
- // someone replied to it
- await util.goTo(room1);
- await util.receiveMessages(room2, [
- "Root",
- msg.threadedOff("Root", "Msg2"),
- msg.threadedOff("Root", "Msg3"),
- ]);
- await util.assertUnread(room2, 1);
- await util.goTo(room2);
- await util.openThread("Root");
- await util.assertRead(room2);
- await util.assertReadThread("Root");
- await util.receiveMessages(room2, [msg.redactionOf("Root")]);
- await util.assertStillRead(room2);
- await util.receiveMessages(room2, [msg.replyTo("Root", "Reply!")]);
- await util.assertUnread(room2, 1);
-
- // When I read the room
- await util.goTo(room2);
-
- // Then it becomes read
- await util.assertRead(room2);
- });
- });
});
});
diff --git a/playwright/e2e/read-receipts/redactions-main-timeline.spec.ts b/playwright/e2e/read-receipts/redactions-main-timeline.spec.ts
new file mode 100644
index 00000000000..cb7393a63fd
--- /dev/null
+++ b/playwright/e2e/read-receipts/redactions-main-timeline.spec.ts
@@ -0,0 +1,331 @@
+/*
+Copyright 2023 The Matrix.org Foundation C.I.C.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+/* See readme.md for tips on writing these tests. */
+
+import { test } from ".";
+
+test.describe("Read receipts", () => {
+ test.describe("redactions", () => {
+ test.describe("in the main timeline", () => {
+ test("Redacting the message pointed to by my receipt leaves the room read", async ({
+ roomAlpha: room1,
+ roomBeta: room2,
+ util,
+ msg,
+ }) => {
+ // Given I have read the messages in a room
+ await util.goTo(room1);
+ await util.receiveMessages(room2, ["Msg1", "Msg2"]);
+ await util.assertUnread(room2, 2);
+ await util.goTo(room2);
+ await util.assertRead(room2);
+ await util.goTo(room1);
+
+ // When the latest message is redacted
+ await util.receiveMessages(room2, [msg.redactionOf("Msg2")]);
+
+ // Then the room remains read
+ await util.assertStillRead(room2);
+ });
+
+ test("Reading an unread room after a redaction of the latest message makes it read", async ({
+ roomAlpha: room1,
+ roomBeta: room2,
+ util,
+ msg,
+ }) => {
+ // Given an unread room
+ await util.goTo(room1);
+ await util.receiveMessages(room2, ["Msg1", "Msg2"]);
+ await util.assertUnread(room2, 2);
+
+ // And the latest message has been redacted
+ await util.receiveMessages(room2, [msg.redactionOf("Msg2")]);
+
+ // When I read the room
+ await util.goTo(room2);
+ await util.assertRead(room2);
+ await util.goTo(room1);
+
+ // Then it becomes read
+ await util.assertStillRead(room2);
+ });
+ test("Reading an unread room after a redaction of an older message makes it read", async ({
+ roomAlpha: room1,
+ roomBeta: room2,
+ util,
+ msg,
+ }) => {
+ // Given an unread room with an earlier redaction
+ await util.goTo(room1);
+ await util.receiveMessages(room2, ["Msg1", "Msg2"]);
+ await util.assertUnread(room2, 2);
+ await util.receiveMessages(room2, [msg.redactionOf("Msg1")]);
+
+ // When I read the room
+ await util.goTo(room2);
+ await util.assertRead(room2);
+ await util.goTo(room1);
+
+ // Then it becomes read
+ await util.assertStillRead(room2);
+ });
+ test("Marking an unread room as read after a redaction makes it read", async ({
+ roomAlpha: room1,
+ roomBeta: room2,
+ util,
+ msg,
+ }) => {
+ // Given an unread room where latest message is redacted
+ await util.goTo(room1);
+ await util.receiveMessages(room2, ["Msg1", "Msg2"]);
+ await util.assertUnread(room2, 2);
+ await util.receiveMessages(room2, [msg.redactionOf("Msg2")]);
+ await util.assertUnread(room2, 1);
+
+ // When I mark it as read
+ await util.markAsRead(room2);
+
+ // Then it becomes read
+ await util.assertRead(room2);
+ });
+ test("Sending and redacting a message after marking the room as read makes it read", async ({
+ roomAlpha: room1,
+ roomBeta: room2,
+ util,
+ msg,
+ }) => {
+ // Given a room that is marked as read
+ await util.goTo(room1);
+ await util.receiveMessages(room2, ["Msg1", "Msg2"]);
+ await util.assertUnread(room2, 2);
+ await util.markAsRead(room2);
+ await util.assertRead(room2);
+
+ // When a message is sent and then redacted
+ await util.receiveMessages(room2, ["Msg3"]);
+ await util.assertUnread(room2, 1);
+ await util.receiveMessages(room2, [msg.redactionOf("Msg3")]);
+
+ // Then the room is read
+ await util.assertRead(room2);
+ });
+ test("Redacting a message after marking the room as read leaves it read", async ({
+ roomAlpha: room1,
+ roomBeta: room2,
+ util,
+ msg,
+ }) => {
+ // Given a room that is marked as read
+ await util.goTo(room1);
+ await util.receiveMessages(room2, ["Msg1", "Msg2", "Msg3"]);
+ await util.assertUnread(room2, 3);
+ await util.markAsRead(room2);
+ await util.assertRead(room2);
+
+ // When we redact some messages
+ await util.receiveMessages(room2, [msg.redactionOf("Msg3")]);
+ await util.receiveMessages(room2, [msg.redactionOf("Msg1")]);
+
+ // Then it is still read
+ await util.assertStillRead(room2);
+ });
+ test("Redacting one of the unread messages reduces the unread count", async ({
+ roomAlpha: room1,
+ roomBeta: room2,
+ util,
+ msg,
+ }) => {
+ // Given an unread room
+ await util.goTo(room1);
+ await util.receiveMessages(room2, ["Msg1", "Msg2", "Msg3"]);
+ await util.assertUnread(room2, 3);
+
+ // When I redact a non-latest message
+ await util.receiveMessages(room2, [msg.redactionOf("Msg2")]);
+
+ // Then the unread count goes down
+ await util.assertUnread(room2, 2);
+
+ // And when I redact the latest message
+ await util.receiveMessages(room2, [msg.redactionOf("Msg3")]);
+
+ // Then the unread count goes down again
+ await util.assertUnread(room2, 1);
+ });
+ test("Redacting one of the unread messages reduces the unread count after restart", async ({
+ roomAlpha: room1,
+ roomBeta: room2,
+ util,
+ msg,
+ }) => {
+ // Given unread count was reduced by redacting messages
+ await util.goTo(room1);
+ await util.receiveMessages(room2, ["Msg1", "Msg2", "Msg3"]);
+ await util.assertUnread(room2, 3);
+ await util.receiveMessages(room2, [msg.redactionOf("Msg2")]);
+ await util.assertUnread(room2, 2);
+ await util.receiveMessages(room2, [msg.redactionOf("Msg3")]);
+ await util.assertUnread(room2, 1);
+
+ // When I restart
+ await util.saveAndReload();
+
+ // Then the unread count is still reduced
+ await util.assertUnread(room2, 1);
+ });
+ test("Redacting all unread messages makes the room read", async ({
+ roomAlpha: room1,
+ roomBeta: room2,
+ util,
+ msg,
+ }) => {
+ // Given an unread room
+ await util.goTo(room1);
+ await util.receiveMessages(room2, ["Msg1", "Msg2"]);
+ await util.assertUnread(room2, 2);
+
+ // When I redact all the unread messages
+ await util.receiveMessages(room2, [msg.redactionOf("Msg2")]);
+ await util.receiveMessages(room2, [msg.redactionOf("Msg1")]);
+
+ // Then the room is back to being read
+ await util.assertRead(room2);
+ });
+ test("Redacting all unread messages makes the room read after restart", async ({
+ roomAlpha: room1,
+ roomBeta: room2,
+ util,
+ msg,
+ }) => {
+ // Given all unread messages were redacted
+ await util.goTo(room1);
+ await util.receiveMessages(room2, ["Msg1", "Msg2"]);
+ await util.assertUnread(room2, 2);
+ await util.receiveMessages(room2, [msg.redactionOf("Msg2")]);
+ await util.receiveMessages(room2, [msg.redactionOf("Msg1")]);
+ await util.assertRead(room2);
+
+ // When I restart
+ await util.saveAndReload();
+
+ // Then the room is still read
+ await util.assertRead(room2);
+ });
+ test("Reacting to a redacted message leaves the room read", async ({
+ page,
+ roomAlpha: room1,
+ roomBeta: room2,
+ util,
+ msg,
+ }) => {
+ // Given a redacted message exists
+ await util.goTo(room1);
+ await util.receiveMessages(room2, ["Msg1", "Msg2"]);
+ await util.assertUnread(room2, 2);
+ await util.receiveMessages(room2, [msg.redactionOf("Msg2")]);
+ await util.assertUnread(room2, 1);
+
+ // And the room is read
+ await util.goTo(room2);
+ await util.assertRead(room2);
+ await page.waitForTimeout(200);
+ await util.goTo(room1);
+
+ // When I react to the redacted message
+ await util.receiveMessages(room2, [msg.reactionTo("Msg2", "🪿")]);
+
+ // Then the room is still read
+ await util.assertStillRead(room2);
+ });
+ test("Editing a redacted message leaves the room read", async ({
+ roomAlpha: room1,
+ roomBeta: room2,
+ util,
+ msg,
+ }) => {
+ // Given a redacted message exists
+ await util.goTo(room1);
+ await util.receiveMessages(room2, ["Msg1", "Msg2"]);
+ await util.assertUnread(room2, 2);
+ await util.receiveMessages(room2, [msg.redactionOf("Msg2")]);
+ await util.assertUnread(room2, 1);
+
+ // And the room is read
+ await util.goTo(room2);
+ await util.assertRead(room2);
+ await util.goTo(room1);
+
+ // When I attempt to edit the redacted message
+ await util.receiveMessages(room2, [msg.editOf("Msg2", "Msg2 is BACK")]);
+
+ // Then the room is still read
+ await util.assertStillRead(room2);
+ });
+ test("A reply to a redacted message makes the room unread", async ({
+ roomAlpha: room1,
+ roomBeta: room2,
+ util,
+ msg,
+ }) => {
+ // Given a message was redacted
+ await util.goTo(room1);
+ await util.receiveMessages(room2, ["Msg1", "Msg2"]);
+ await util.assertUnread(room2, 2);
+ await util.receiveMessages(room2, [msg.redactionOf("Msg2")]);
+ await util.assertUnread(room2, 1);
+
+ // And the room is read
+ await util.goTo(room2);
+ await util.assertRead(room2);
+ await util.goTo(room1);
+
+ // When I receive a reply to the redacted message
+ await util.receiveMessages(room2, [msg.replyTo("Msg2", "Reply to Msg2")]);
+
+ // Then the room is unread
+ await util.assertUnread(room2, 1);
+ });
+ test("Reading a reply to a redacted message marks the room as read", async ({
+ roomAlpha: room1,
+ roomBeta: room2,
+ util,
+ msg,
+ }) => {
+ // Given someone replied to a redacted message
+ await util.goTo(room1);
+ await util.receiveMessages(room2, ["Msg1", "Msg2"]);
+ await util.assertUnread(room2, 2);
+ await util.receiveMessages(room2, [msg.redactionOf("Msg2")]);
+ await util.assertUnread(room2, 1);
+ await util.goTo(room2);
+ await util.assertRead(room2);
+ await util.goTo(room1);
+ await util.receiveMessages(room2, [msg.replyTo("Msg2", "Reply to Msg2")]);
+ await util.assertUnread(room2, 1);
+
+ // When I read the reply
+ await util.goTo(room2);
+ await util.assertRead(room2);
+
+ // Then the room is unread
+ await util.goTo(room1);
+ await util.assertStillRead(room2);
+ });
+ });
+ });
+});
diff --git a/playwright/e2e/read-receipts/redactions-thread-roots.spec.ts b/playwright/e2e/read-receipts/redactions-thread-roots.spec.ts
new file mode 100644
index 00000000000..0ded3957fbc
--- /dev/null
+++ b/playwright/e2e/read-receipts/redactions-thread-roots.spec.ts
@@ -0,0 +1,232 @@
+/*
+Copyright 2023 The Matrix.org Foundation C.I.C.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+/* See readme.md for tips on writing these tests. */
+
+import { test } from ".";
+
+test.describe("Read receipts", () => {
+ test.describe("redactions", () => {
+ test.describe("thread roots", () => {
+ test("Redacting a thread root after it was read leaves the room read", async ({
+ roomAlpha: room1,
+ roomBeta: room2,
+ util,
+ msg,
+ }) => {
+ test.slow();
+
+ // Given a thread exists and is read
+ await util.goTo(room1);
+ await util.receiveMessages(room2, [
+ "Root",
+ msg.threadedOff("Root", "Msg2"),
+ msg.threadedOff("Root", "Msg3"),
+ ]);
+ await util.assertUnread(room2, 1);
+ await util.goTo(room2);
+ await util.openThread("Root");
+ await util.assertRead(room2);
+ await util.assertReadThread("Root");
+
+ // When someone redacts the thread root
+ await util.receiveMessages(room2, [msg.redactionOf("Root")]);
+
+ // Then the room is still read
+ await util.assertStillRead(room2);
+ });
+
+ /*
+ * Disabled for the same reason as "A thread with a read redaction is still read after restart"
+ * above
+ */
+ test.skip("Redacting a thread root still allows us to read the thread", async ({
+ roomAlpha: room1,
+ roomBeta: room2,
+ util,
+ msg,
+ }) => {
+ // Given an unread thread exists
+ await util.goTo(room1);
+ await util.receiveMessages(room2, [
+ "Root",
+ msg.threadedOff("Root", "Msg2"),
+ msg.threadedOff("Root", "Msg3"),
+ ]);
+ await util.assertUnread(room2, 1);
+
+ // When someone redacts the thread root
+ await util.receiveMessages(room2, [msg.redactionOf("Root")]);
+
+ // Then the room is still unread
+ await util.assertUnread(room2, 1);
+
+ // And I can open the thread and read it
+ await util.goTo(room2);
+ await util.assertRead(room2);
+ // The redacted message gets collapsed into, "foo was invited, joined and removed a message"
+ await util.openCollapsedMessage(1);
+ await util.openThread("Message deleted");
+ await util.assertRead(room2);
+ await util.assertReadThread("Root");
+ });
+
+ test("Sending a threaded message onto a redacted thread root leaves the room unread", async ({
+ roomAlpha: room1,
+ roomBeta: room2,
+ util,
+ msg,
+ }) => {
+ // Given a thread exists, is read and its root is redacted
+ await util.goTo(room1);
+ await util.receiveMessages(room2, [
+ "Root",
+ msg.threadedOff("Root", "Msg2"),
+ msg.threadedOff("Root", "Msg3"),
+ ]);
+ await util.assertUnread(room2, 1);
+ await util.goTo(room2);
+ await util.openThread("Root");
+ await util.assertRead(room2);
+ await util.assertReadThread("Root");
+ await util.receiveMessages(room2, [msg.redactionOf("Root")]);
+
+ // When we receive a new message on it
+ await util.receiveMessages(room2, [msg.threadedOff("Root", "Msg4")]);
+
+ // Then the room is read but the thread is unread
+ await util.assertRead(room2);
+ await util.goTo(room2);
+ await util.assertUnreadThread("Message deleted");
+ });
+
+ test("Reacting to a redacted thread root leaves the room read", async ({
+ roomAlpha: room1,
+ roomBeta: room2,
+ util,
+ msg,
+ }) => {
+ // Given a thread exists, is read and the root was redacted
+ await util.goTo(room1);
+ await util.receiveMessages(room2, [
+ "Root",
+ msg.threadedOff("Root", "Msg2"),
+ msg.threadedOff("Root", "Msg3"),
+ ]);
+ await util.assertUnread(room2, 1);
+ await util.goTo(room2);
+ await util.openThread("Root");
+ await util.assertRead(room2);
+ await util.assertReadThread("Root");
+ await util.receiveMessages(room2, [msg.redactionOf("Root")]);
+
+ // When I react to the old root
+ await util.receiveMessages(room2, [msg.reactionTo("Root", "y")]);
+
+ // Then the room is still read
+ await util.assertRead(room2);
+ await util.assertReadThread("Root");
+ });
+
+ test("Editing a redacted thread root leaves the room read", async ({
+ roomAlpha: room1,
+ roomBeta: room2,
+ util,
+ msg,
+ }) => {
+ // Given a thread exists, is read and the root was redacted
+ await util.goTo(room1);
+ await util.receiveMessages(room2, [
+ "Root",
+ msg.threadedOff("Root", "Msg2"),
+ msg.threadedOff("Root", "Msg3"),
+ ]);
+ await util.assertUnread(room2, 1);
+ await util.goTo(room2);
+ await util.openThread("Root");
+ await util.assertRead(room2);
+ await util.assertReadThread("Root");
+ await util.receiveMessages(room2, [msg.redactionOf("Root")]);
+
+ // When I edit the old root
+ await util.receiveMessages(room2, [msg.editOf("Root", "New Root")]);
+
+ // Then the room is still read
+ await util.assertRead(room2);
+ // as is the thread
+ await util.assertReadThread("Root");
+ });
+
+ test("Replying to a redacted thread root makes the room unread", async ({
+ roomAlpha: room1,
+ roomBeta: room2,
+ util,
+ msg,
+ }) => {
+ // Given a thread exists, is read and the root was redacted
+ await util.goTo(room1);
+ await util.receiveMessages(room2, [
+ "Root",
+ msg.threadedOff("Root", "Msg2"),
+ msg.threadedOff("Root", "Msg3"),
+ ]);
+ await util.assertUnread(room2, 1);
+ await util.goTo(room2);
+ await util.openThread("Root");
+ await util.assertRead(room2);
+ await util.assertReadThread("Root");
+ await util.receiveMessages(room2, [msg.redactionOf("Root")]);
+
+ // When I reply to the old root
+ await util.receiveMessages(room2, [msg.replyTo("Root", "Reply!")]);
+
+ // Then the room is unread
+ await util.assertUnread(room2, 1);
+ });
+
+ test("Reading a reply to a redacted thread root makes the room read", async ({
+ roomAlpha: room1,
+ roomBeta: room2,
+ util,
+ msg,
+ }) => {
+ // Given a thread exists, is read and the root was redacted, and
+ // someone replied to it
+ await util.goTo(room1);
+ await util.receiveMessages(room2, [
+ "Root",
+ msg.threadedOff("Root", "Msg2"),
+ msg.threadedOff("Root", "Msg3"),
+ ]);
+ await util.assertUnread(room2, 1);
+ await util.goTo(room2);
+ await util.openThread("Root");
+ await util.assertRead(room2);
+ await util.assertReadThread("Root");
+ await util.receiveMessages(room2, [msg.redactionOf("Root")]);
+ await util.assertStillRead(room2);
+ await util.receiveMessages(room2, [msg.replyTo("Root", "Reply!")]);
+ await util.assertUnread(room2, 1);
+
+ // When I read the room
+ await util.goTo(room2);
+
+ // Then it becomes read
+ await util.assertRead(room2);
+ });
+ });
+ });
+});
diff --git a/playwright/e2e/read-receipts/room-list-order.spec.ts b/playwright/e2e/read-receipts/room-list-order.spec.ts
new file mode 100644
index 00000000000..2b430229189
--- /dev/null
+++ b/playwright/e2e/read-receipts/room-list-order.spec.ts
@@ -0,0 +1,61 @@
+/*
+Copyright 2023 The Matrix.org Foundation C.I.C.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+/* See readme.md for tips on writing these tests. */
+
+import { test } from ".";
+
+test.describe("Read receipts", () => {
+ test.describe("Room list order", () => {
+ test("Rooms with unread messages appear at the top of room list if 'unread first' is selected", async ({
+ roomAlpha: room1,
+ roomBeta: room2,
+ util,
+ msg,
+ page,
+ }) => {
+ await util.goTo(room2);
+
+ // Display the unread first room
+ await util.toggleRoomUnreadOrder();
+ await util.receiveMessages(room1, ["Msg1"]);
+ await page.reload();
+
+ // Room 1 has an unread message and should be displayed first
+ await util.assertRoomListOrder([room1, room2]);
+ });
+
+ test("Rooms with unread threads appear at the top of room list if 'unread first' is selected", async ({
+ roomAlpha: room1,
+ roomBeta: room2,
+ util,
+ msg,
+ }) => {
+ await util.goTo(room2);
+ await util.receiveMessages(room1, ["Msg1"]);
+ await util.markAsRead(room1);
+ await util.assertRead(room1);
+
+ // Display the unread first room
+ await util.toggleRoomUnreadOrder();
+ await util.receiveMessages(room1, [msg.threadedOff("Msg1", "Resp1")]);
+ await util.saveAndReload();
+
+ // Room 1 has an unread message and should be displayed first
+ await util.assertRoomListOrder([room1, room2]);
+ });
+ });
+});
diff --git a/playwright/e2e/right-panel/file-panel.spec.ts b/playwright/e2e/right-panel/file-panel.spec.ts
index 84e7614e8ef..52dd1133143 100644
--- a/playwright/e2e/right-panel/file-panel.spec.ts
+++ b/playwright/e2e/right-panel/file-panel.spec.ts
@@ -50,7 +50,7 @@ test.describe("FilePanel", () => {
test.describe("render", () => {
test("should render empty state", async ({ page }) => {
// Wait until the information about the empty state is rendered
- await expect(page.locator(".mx_FilePanel_empty")).toBeVisible();
+ await expect(page.locator(".mx_EmptyState")).toBeVisible();
// Take a snapshot of RightPanel - fix https://github.com/vector-im/element-web/issues/25332
await expect(page.locator(".mx_RightPanel")).toMatchScreenshot("empty.png");
diff --git a/playwright/e2e/right-panel/notification-panel.spec.ts b/playwright/e2e/right-panel/notification-panel.spec.ts
index 6223c1c13f7..aa7dedf73ab 100644
--- a/playwright/e2e/right-panel/notification-panel.spec.ts
+++ b/playwright/e2e/right-panel/notification-panel.spec.ts
@@ -35,7 +35,7 @@ test.describe("NotificationPanel", () => {
await page.getByRole("button", { name: "Notifications" }).click();
// Wait until the information about the empty state is rendered
- await expect(page.locator(".mx_NotificationPanel_empty")).toBeVisible();
+ await expect(page.locator(".mx_EmptyState")).toBeVisible();
// Take a snapshot of RightPanel
await expect(page.locator(".mx_RightPanel")).toMatchScreenshot("empty.png");
diff --git a/playwright/e2e/right-panel/right-panel.spec.ts b/playwright/e2e/right-panel/right-panel.spec.ts
index 4f578748d6e..f282d83d62c 100644
--- a/playwright/e2e/right-panel/right-panel.spec.ts
+++ b/playwright/e2e/right-panel/right-panel.spec.ts
@@ -63,9 +63,9 @@ test.describe("RightPanel", () => {
await app.closeDialog();
// Close and reopen the right panel to render the room address
- await page.getByRole("button", { name: "Room info" }).click();
+ await app.toggleRoomInfoPanel();
await expect(page.locator(".mx_RightPanel")).not.toBeVisible();
- await page.getByRole("button", { name: "Room info" }).click();
+ await app.toggleRoomInfoPanel();
await expect(page.locator(".mx_RightPanel")).toMatchScreenshot("with-name-and-address.png");
});
@@ -104,26 +104,26 @@ test.describe("RightPanel", () => {
await page.getByRole("menuitem", { name: "Files" }).click();
await expect(page.locator(".mx_FilePanel")).toBeVisible();
- await expect(page.locator(".mx_FilePanel_empty")).toBeVisible();
+ await expect(page.locator(".mx_EmptyState")).toBeVisible();
- await page.getByRole("button", { name: "Room information" }).click();
+ await page.getByTestId("base-card-back-button").click();
await checkRoomSummaryCard(page, ROOM_NAME);
});
test("should handle viewing room member", async ({ page, app }) => {
await viewRoomSummaryByName(page, app, ROOM_NAME);
- await page.getByRole("menuitem", { name: "People" }).click();
+ await page.locator(".mx_RightPanelTabs").getByText("People").click();
await expect(page.locator(".mx_MemberList")).toBeVisible();
await getMemberTileByName(page, NAME).click();
await expect(page.locator(".mx_UserInfo")).toBeVisible();
await expect(page.locator(".mx_UserInfo_profile").getByText(NAME)).toBeVisible();
- await page.getByRole("button", { name: "Room members" }).click();
+ await page.getByTestId("base-card-back-button").click();
await expect(page.locator(".mx_MemberList")).toBeVisible();
- await page.getByRole("button", { name: "Room information" }).click();
+ await page.locator(".mx_RightPanelTabs").getByText("Info").click();
await checkRoomSummaryCard(page, ROOM_NAME);
});
});
@@ -138,14 +138,12 @@ test.describe("RightPanel", () => {
.getByRole("button", { name: /\d member/ })
.click();
await expect(page.locator(".mx_MemberList")).toBeVisible();
- await expect(page.locator(".mx_SpaceScopeHeader").getByText(SPACE_NAME)).toBeVisible();
await getMemberTileByName(page, NAME).click();
await expect(page.locator(".mx_UserInfo")).toBeVisible();
await expect(page.locator(".mx_UserInfo_profile").getByText(NAME)).toBeVisible();
- await expect(page.locator(".mx_SpaceScopeHeader").getByText(SPACE_NAME)).toBeVisible();
- await page.getByRole("button", { name: "Back" }).click();
+ await page.getByTestId("base-card-back-button").click();
await expect(page.locator(".mx_MemberList")).toBeVisible();
});
});
diff --git a/playwright/e2e/right-panel/utils.ts b/playwright/e2e/right-panel/utils.ts
index a8dac8394d0..5e2e39be0d4 100644
--- a/playwright/e2e/right-panel/utils.ts
+++ b/playwright/e2e/right-panel/utils.ts
@@ -20,7 +20,7 @@ import { ElementAppPage } from "../../pages/ElementAppPage";
export async function viewRoomSummaryByName(page: Page, app: ElementAppPage, name: string): Promise {
await app.viewRoomByName(name);
- await page.getByRole("button", { name: "Room info" }).click();
+ await app.toggleRoomInfoPanel();
return checkRoomSummaryCard(page, name);
}
diff --git a/playwright/e2e/room/room-header.spec.ts b/playwright/e2e/room/room-header.spec.ts
index 4008517d093..ca49f1190bc 100644
--- a/playwright/e2e/room/room-header.spec.ts
+++ b/playwright/e2e/room/room-header.spec.ts
@@ -18,7 +18,6 @@ import { Page } from "@playwright/test";
import { test, expect } from "../../element-web-test";
import { ElementAppPage } from "../../pages/ElementAppPage";
-import type { Container } from "../../../src/stores/widgets/types";
test.describe("Room Header", () => {
test.use({
@@ -33,24 +32,28 @@ test.describe("Room Header", () => {
await app.client.createRoom({ name: "Test Room" });
await app.viewRoomByName("Test Room");
- const header = page.locator(".mx_LegacyRoomHeader");
- // Names (aria-label) of every button rendered on mx_LegacyRoomHeader by default
- const expectedButtonNames = [
- "Room options", // The room name button next to the room avatar, which renders dropdown menu on click
- "Voice call",
- "Video call",
- "Search",
- "Threads",
- "Notifications",
- "Room info",
- ];
-
- // Assert they are found and visible
- for (const name of expectedButtonNames) {
- await expect(header.getByRole("button", { name })).toBeVisible();
- }
+ const header = page.locator(".mx_RoomHeader");
+
+ // There's two room info button - the header itself and the i button
+ const infoButtons = header.getByRole("button", { name: "Room info" });
+ await expect(infoButtons).toHaveCount(2);
+ await expect(infoButtons.first()).toBeVisible();
+ await expect(infoButtons.last()).toBeVisible();
+
+ // Memberlist button
+ await expect(header.locator(".mx_FacePile")).toBeVisible();
- // Assert that just those seven buttons exist on mx_LegacyRoomHeader by default
+ // There should be both a voice and a video call button
+ // but they'll be disabled
+ const callButtons = header.getByRole("button", { name: "There's no one here to call" });
+ await expect(callButtons).toHaveCount(2);
+ await expect(callButtons.first()).toBeVisible();
+ await expect(callButtons.last()).toBeVisible();
+
+ await expect(header.getByRole("button", { name: "Threads" })).toBeVisible();
+ await expect(header.getByRole("button", { name: "Notifications" })).toBeVisible();
+
+ // Assert that there are six buttons in total
await expect(header.getByRole("button")).toHaveCount(7);
await expect(header).toMatchScreenshot("room-header.png");
@@ -67,14 +70,15 @@ test.describe("Room Header", () => {
await app.client.createRoom({ name: LONG_ROOM_NAME });
await app.viewRoomByName(LONG_ROOM_NAME);
- const header = page.locator(".mx_LegacyRoomHeader");
+ const header = page.locator(".mx_RoomHeader");
// Wait until the room name is set
- await expect(page.locator(".mx_LegacyRoomHeader_nametext").getByText(LONG_ROOM_NAME)).toBeVisible();
+ await expect(page.locator(".mx_RoomHeader_heading").getByText(LONG_ROOM_NAME)).toBeVisible();
// Assert the size of buttons on RoomHeader are specified and the buttons are not compressed
// Note these assertions do not check the size of mx_LegacyRoomHeader_name button
- const buttons = page.locator(".mx_LegacyRoomHeader_button");
- await expect(buttons).toHaveCount(6);
+ const buttons = header.locator(".mx_Flex").getByRole("button");
+ await expect(buttons).toHaveCount(5);
+
for (const button of await buttons.all()) {
await expect(button).toBeVisible();
await expect(button).toHaveCSS("height", "32px");
@@ -83,44 +87,6 @@ test.describe("Room Header", () => {
await expect(header).toMatchScreenshot("room-header-long-name.png");
});
-
- test("should have buttons highlighted by being clicked", async ({ page, app, user }) => {
- await app.client.createRoom({ name: "Test Room" });
- await app.viewRoomByName("Test Room");
-
- const header = page.locator(".mx_LegacyRoomHeader");
- // Check these buttons
- const buttonsHighlighted = ["Threads", "Notifications", "Room info"];
-
- for (const name of buttonsHighlighted) {
- await header.getByRole("button", { name: name }).click(); // Highlight the button
- }
-
- await expect(header).toMatchScreenshot("room-header-highlighted.png");
- });
- });
-
- test.describe("with feature_pinning enabled", () => {
- test.use({ labsFlags: ["feature_pinning"] });
-
- test("should render the pin button for pinned messages card", async ({ page, app, user }) => {
- await app.client.createRoom({ name: "Test Room" });
- await app.viewRoomByName("Test Room");
-
- const composer = app.getComposer().locator("[contenteditable]");
- await composer.fill("Test message");
- await composer.press("Enter");
-
- const lastTile = page.locator(".mx_EventTile_last");
- await lastTile.hover();
- await lastTile.getByRole("button", { name: "Options" }).click();
-
- await page.getByRole("menuitem", { name: "Pin" }).click();
-
- await expect(
- page.locator(".mx_LegacyRoomHeader").getByRole("button", { name: "Pinned messages" }),
- ).toBeVisible();
- });
});
test.describe("with a video room", () => {
@@ -141,30 +107,27 @@ test.describe("Room Header", () => {
test.describe("and with feature_notifications enabled", () => {
test.use({ labsFlags: ["feature_video_rooms", "feature_notifications"] });
- test("should render buttons for room options, beta pill, invite, chat, and room info", async ({
- page,
- app,
- user,
- }) => {
+ test("should render buttons for chat, room info, threads and facepile", async ({ page, app, user }) => {
await createVideoRoom(page, app);
- const header = page.locator(".mx_LegacyRoomHeader");
- // Names (aria-label) of the buttons on the video room header
- const expectedButtonNames = [
- "Room options",
- "Video rooms are a beta feature Click for more info", // Beta pill
- "Invite",
- "Chat",
- "Room info",
- ];
-
- // Assert they are found and visible
- for (const name of expectedButtonNames) {
- await expect(header.getByRole("button", { name })).toBeVisible();
- }
+ const header = page.locator(".mx_RoomHeader");
+
+ // There's two room info button - the header itself and the i button
+ const infoButtons = header.getByRole("button", { name: "Room info" });
+ await expect(infoButtons).toHaveCount(2);
+ await expect(infoButtons.first()).toBeVisible();
+ await expect(infoButtons.last()).toBeVisible();
+
+ // Facepile
+ await expect(header.locator(".mx_FacePile")).toBeVisible();
+
+ // Chat, Threads and Notification buttons
+ await expect(header.getByRole("button", { name: "Chat" })).toBeVisible();
+ await expect(header.getByRole("button", { name: "Threads" })).toBeVisible();
+ await expect(header.getByRole("button", { name: "Notifications" })).toBeVisible();
// Assert that there is not a button except those buttons
- await expect(header.getByRole("button")).toHaveCount(7);
+ await expect(header.getByRole("button")).toHaveCount(6);
await expect(header).toMatchScreenshot("room-header-video-room.png");
});
@@ -177,7 +140,7 @@ test.describe("Room Header", () => {
}) => {
await createVideoRoom(page, app);
- await page.locator(".mx_LegacyRoomHeader").getByRole("button", { name: "Chat" }).click();
+ await page.locator(".mx_RoomHeader").getByRole("button", { name: "Chat" }).click();
// Assert that the call view is still visible
await expect(page.locator(".mx_CallView")).toBeVisible();
@@ -188,114 +151,4 @@ test.describe("Room Header", () => {
).toBeVisible();
});
});
-
- test.describe("with a widget", () => {
- const ROOM_NAME = "Test Room with a widget";
- const WIDGET_ID = "fake-widget";
- const WIDGET_HTML = `
-
-
- Fake Widget
-
-
- Hello World
-
-
- `;
-
- test.beforeEach(async ({ page, app, user, webserver }) => {
- const widgetUrl = webserver.start(WIDGET_HTML);
- const roomId = await app.client.createRoom({ name: ROOM_NAME });
-
- // setup widget via state event
- await app.client.evaluate(
- async (matrixClient, { roomId, widgetUrl, id }) => {
- await matrixClient.sendStateEvent(
- roomId,
- "im.vector.modular.widgets",
- {
- id,
- creatorUserId: "somebody",
- type: "widget",
- name: "widget",
- url: widgetUrl,
- },
- id,
- );
- await matrixClient.sendStateEvent(
- roomId,
- "io.element.widgets.layout",
- {
- widgets: {
- [id]: {
- container: "top" as Container,
- index: 1,
- width: 100,
- height: 0,
- },
- },
- },
- "",
- );
- },
- {
- roomId,
- widgetUrl,
- id: WIDGET_ID,
- },
- );
-
- // open the room
- await app.viewRoomByName(ROOM_NAME);
- });
-
- test("should highlight the apps button", async ({ page, app, user }) => {
- // Assert that AppsDrawer is rendered
- await expect(page.locator(".mx_AppsDrawer")).toBeVisible();
-
- const header = page.locator(".mx_LegacyRoomHeader");
- // Assert that "Hide Widgets" button is rendered and aria-checked is set to true
- await expect(header.getByRole("button", { name: "Hide Widgets" })).toHaveAttribute("aria-checked", "true");
-
- await expect(header).toMatchScreenshot("room-header-with-apps-button-highlighted.png");
- });
-
- test("should support hiding a widget", async ({ page, app, user }) => {
- await expect(page.locator(".mx_AppsDrawer")).toBeVisible();
-
- const header = page.locator(".mx_LegacyRoomHeader");
- // Click the apps button to hide AppsDrawer
- await header.getByRole("button", { name: "Hide Widgets" }).click();
-
- // Assert that "Show widgets" button is rendered and aria-checked is set to false
- await expect(header.getByRole("button", { name: "Show Widgets" })).toHaveAttribute("aria-checked", "false");
-
- // Assert that AppsDrawer is not rendered
- await expect(page.locator(".mx_AppsDrawer")).not.toBeVisible();
-
- await expect(header).toMatchScreenshot("room-header-with-apps-button-not-highlighted.png");
- });
- });
-
- test.describe("with encryption", () => {
- test("should render the E2E icon and the buttons", async ({ page, app, user }) => {
- // Create an encrypted room
- await app.client.createRoom({
- name: "Test Encrypted Room",
- initial_state: [
- {
- type: "m.room.encryption",
- state_key: "",
- content: {
- algorithm: "m.megolm.v1.aes-sha2",
- },
- },
- ],
- });
- await app.viewRoomByName("Test Encrypted Room");
-
- const header = page.locator(".mx_LegacyRoomHeader");
- await expect(header).toMatchScreenshot("encrypted-room-header.png");
- });
- });
});
diff --git a/playwright/e2e/settings/appearance-user-settings-tab.spec.ts b/playwright/e2e/settings/appearance-user-settings-tab.spec.ts
deleted file mode 100644
index 7e16d739558..00000000000
--- a/playwright/e2e/settings/appearance-user-settings-tab.spec.ts
+++ /dev/null
@@ -1,219 +0,0 @@
-/*
-Copyright 2023 Suguru Hirahara
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
- http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
-*/
-
-import { test, expect } from "../../element-web-test";
-import { SettingLevel } from "../../../src/settings/SettingLevel";
-
-test.describe("Appearance user settings tab", () => {
- test.use({
- displayName: "Hanako",
- });
-
- test("should be rendered properly", async ({ page, user, app }) => {
- const tab = await app.settings.openUserSettings("Appearance");
-
- // Click "Show advanced" link button
- await tab.getByRole("button", { name: "Show advanced" }).click();
-
- // Assert that "Hide advanced" link button is rendered
- await expect(tab.getByRole("button", { name: "Hide advanced" })).toBeVisible();
-
- await expect(tab).toMatchScreenshot("appearance-tab.png");
- });
-
- test("should support switching layouts", async ({ page, user, app }) => {
- // Create and view a room first
- await app.client.createRoom({ name: "Test Room" });
- await app.viewRoomByName("Test Room");
-
- await app.settings.openUserSettings("Appearance");
-
- const buttons = page.locator(".mx_LayoutSwitcher_RadioButton");
-
- // Assert that the layout selected by default is "Modern"
- await expect(
- buttons.locator(".mx_StyledRadioButton_enabled", {
- hasText: "Modern",
- }),
- ).toBeVisible();
-
- // Assert that the room layout is set to group (modern) layout
- await expect(page.locator(".mx_RoomView_body[data-layout='group']")).toBeVisible();
-
- // Select the first layout
- await buttons.first().click();
- // Assert that the layout selected is "IRC (Experimental)"
- await expect(buttons.locator(".mx_StyledRadioButton_enabled", { hasText: "IRC (Experimental)" })).toBeVisible();
-
- // Assert that the room layout is set to IRC layout
- await expect(page.locator(".mx_RoomView_body[data-layout='irc']")).toBeVisible();
-
- // Select the last layout
- await buttons.last().click();
-
- // Assert that the layout selected is "Message bubbles"
- await expect(buttons.locator(".mx_StyledRadioButton_enabled", { hasText: "Message bubbles" })).toBeVisible();
-
- // Assert that the room layout is set to bubble layout
- await expect(page.locator(".mx_RoomView_body[data-layout='bubble']")).toBeVisible();
- });
-
- test("should support changing font size by using the font size dropdown", async ({ page, app, user }) => {
- await app.settings.openUserSettings("Appearance");
-
- const tab = page.getByTestId("mx_AppearanceUserSettingsTab");
- const fontDropdown = tab.locator(".mx_FontScalingPanel_Dropdown");
- await expect(fontDropdown.getByLabel("Font size")).toBeVisible();
-
- // Default browser font size is 16px and the select value is 0
- // -4 value is 12px
- await fontDropdown.getByLabel("Font size").selectOption({ value: "-4" });
-
- await expect(page).toMatchScreenshot("window-12px.png");
- });
-
- test("should support enabling compact group (modern) layout", async ({ page, app, user }) => {
- // Create and view a room first
- await app.client.createRoom({ name: "Test Room" });
- await app.viewRoomByName("Test Room");
-
- await app.settings.openUserSettings("Appearance");
-
- // Click "Show advanced" link button
- const tab = page.getByTestId("mx_AppearanceUserSettingsTab");
- await tab.getByRole("button", { name: "Show advanced" }).click();
-
- await tab.locator("label", { hasText: "Use a more compact 'Modern' layout" }).click();
-
- // Assert that the room layout is set to compact group (modern) layout
- await expect(page.locator("#matrixchat .mx_MatrixChat_wrapper.mx_MatrixChat_useCompactLayout")).toBeVisible();
- });
-
- test("should disable compact group (modern) layout option on IRC layout and bubble layout", async ({
- page,
- app,
- user,
- }) => {
- await app.settings.openUserSettings("Appearance");
- const tab = page.getByTestId("mx_AppearanceUserSettingsTab");
-
- const checkDisabled = async () => {
- await expect(tab.getByRole("checkbox", { name: "Use a more compact 'Modern' layout" })).toBeDisabled();
- };
-
- // Click "Show advanced" link button
- await tab.getByRole("button", { name: "Show advanced" }).click();
-
- const buttons = page.locator(".mx_LayoutSwitcher_RadioButton");
-
- // Enable IRC layout
- await buttons.first().click();
-
- // Assert that the layout selected is "IRC (Experimental)"
- await expect(buttons.locator(".mx_StyledRadioButton_enabled", { hasText: "IRC (Experimental)" })).toBeVisible();
-
- await checkDisabled();
-
- // Enable bubble layout
- await buttons.last().click();
-
- // Assert that the layout selected is "IRC (Experimental)"
- await expect(buttons.locator(".mx_StyledRadioButton_enabled", { hasText: "Message bubbles" })).toBeVisible();
-
- await checkDisabled();
- });
-
- test("should support enabling system font", async ({ page, app, user }) => {
- await app.settings.openUserSettings("Appearance");
- const tab = page.getByTestId("mx_AppearanceUserSettingsTab");
-
- // Click "Show advanced" link button
- await tab.getByRole("button", { name: "Show advanced" }).click();
-
- await tab.locator(".mx_Checkbox", { hasText: "Use bundled emoji font" }).click();
- await tab.locator(".mx_Checkbox", { hasText: "Use a system font" }).click();
-
- // Assert that the font-family value was removed
- await expect(page.locator("body")).toHaveCSS("font-family", '""');
- });
-
- test.describe("Theme Choice Panel", () => {
- test.beforeEach(async ({ app, user }) => {
- // Disable the default theme for consistency in case ThemeWatcher automatically chooses it
- await app.settings.setValue("use_system_theme", null, SettingLevel.DEVICE, false);
- });
-
- test("should be rendered with the light theme selected", async ({ page, app }) => {
- await app.settings.openUserSettings("Appearance");
- const themePanel = page.getByTestId("mx_ThemeChoicePanel");
-
- const useSystemTheme = themePanel.getByTestId("checkbox-use-system-theme");
- await expect(useSystemTheme.getByText("Match system theme")).toBeVisible();
- // Assert that 'Match system theme' is not checked
- // Note that mx_Checkbox_checkmark exists and is hidden by CSS if it is not checked
- await expect(useSystemTheme.locator(".mx_Checkbox_checkmark")).not.toBeVisible();
-
- const selectors = themePanel.getByTestId("theme-choice-panel-selectors");
- await expect(selectors.locator(".mx_ThemeSelector_light")).toBeVisible();
- await expect(selectors.locator(".mx_ThemeSelector_dark")).toBeVisible();
- // Assert that the light theme is selected
- await expect(selectors.locator(".mx_ThemeSelector_light.mx_StyledRadioButton_enabled")).toBeVisible();
- // Assert that the buttons for the light and dark theme are not enabled
- await expect(selectors.locator(".mx_ThemeSelector_light.mx_StyledRadioButton_disabled")).not.toBeVisible();
- await expect(selectors.locator(".mx_ThemeSelector_dark.mx_StyledRadioButton_disabled")).not.toBeVisible();
-
- // Assert that the checkbox for the high contrast theme is rendered
- await expect(themePanel.locator(".mx_Checkbox", { hasText: "Use high contrast" })).toBeVisible();
- });
-
- test("should disable the labels for themes and the checkbox for the high contrast theme if the checkbox for the system theme is clicked", async ({
- page,
- app,
- }) => {
- await app.settings.openUserSettings("Appearance");
- const themePanel = page.getByTestId("mx_ThemeChoicePanel");
-
- await themePanel.locator(".mx_Checkbox", { hasText: "Match system theme" }).click();
-
- // Assert that the labels for the light theme and dark theme are disabled
- await expect(themePanel.locator(".mx_ThemeSelector_light.mx_StyledRadioButton_disabled")).toBeVisible();
- await expect(themePanel.locator(".mx_ThemeSelector_dark.mx_StyledRadioButton_disabled")).toBeVisible();
-
- // Assert that there does not exist a label for an enabled theme
- await expect(themePanel.locator("label.mx_StyledRadioButton_enabled")).not.toBeVisible();
-
- // Assert that the checkbox and label to enable the high contrast theme should not exist
- await expect(themePanel.locator(".mx_Checkbox", { hasText: "Use high contrast" })).not.toBeVisible();
- });
-
- test("should not render the checkbox and the label for the high contrast theme if the dark theme is selected", async ({
- page,
- app,
- }) => {
- await app.settings.openUserSettings("Appearance");
- const themePanel = page.getByTestId("mx_ThemeChoicePanel");
-
- // Assert that the checkbox and the label to enable the high contrast theme should exist
- await expect(themePanel.locator(".mx_Checkbox", { hasText: "Use high contrast" })).toBeVisible();
-
- // Enable the dark theme
- await themePanel.locator(".mx_ThemeSelector_dark").click();
-
- // Assert that the checkbox and the label should not exist
- await expect(themePanel.locator(".mx_Checkbox", { hasText: "Use high contrast" })).not.toBeVisible();
- });
- });
-});
diff --git a/playwright/e2e/settings/appearance-user-settings-tab/appearance-user-settings-tab.spec.ts b/playwright/e2e/settings/appearance-user-settings-tab/appearance-user-settings-tab.spec.ts
new file mode 100644
index 00000000000..aa00681f61b
--- /dev/null
+++ b/playwright/e2e/settings/appearance-user-settings-tab/appearance-user-settings-tab.spec.ts
@@ -0,0 +1,63 @@
+/*
+Copyright 2023 Suguru Hirahara
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+import { expect, test } from ".";
+
+test.describe("Appearance user settings tab", () => {
+ test.use({
+ displayName: "Hanako",
+ });
+
+ test("should be rendered properly", async ({ page, user, app }) => {
+ const tab = await app.settings.openUserSettings("Appearance");
+
+ // Click "Show advanced" link button
+ await tab.getByRole("button", { name: "Show advanced" }).click();
+
+ // Assert that "Hide advanced" link button is rendered
+ await expect(tab.getByRole("button", { name: "Hide advanced" })).toBeVisible();
+
+ await expect(tab).toMatchScreenshot("appearance-tab.png");
+ });
+
+ test("should support changing font size by using the font size dropdown", async ({ page, app, user }) => {
+ await app.settings.openUserSettings("Appearance");
+
+ const tab = page.getByTestId("mx_AppearanceUserSettingsTab");
+ const fontDropdown = tab.locator(".mx_FontScalingPanel_Dropdown");
+ await expect(fontDropdown.getByLabel("Font size")).toBeVisible();
+
+ // Default browser font size is 16px and the select value is 0
+ // -4 value is 12px
+ await fontDropdown.getByLabel("Font size").selectOption({ value: "-4" });
+
+ await expect(page).toMatchScreenshot("window-12px.png");
+ });
+
+ test("should support enabling system font", async ({ page, app, user }) => {
+ await app.settings.openUserSettings("Appearance");
+ const tab = page.getByTestId("mx_AppearanceUserSettingsTab");
+
+ // Click "Show advanced" link button
+ await tab.getByRole("button", { name: "Show advanced" }).click();
+
+ await tab.locator(".mx_Checkbox", { hasText: "Use bundled emoji font" }).click();
+ await tab.locator(".mx_Checkbox", { hasText: "Use a system font" }).click();
+
+ // Assert that the font-family value was removed
+ await expect(page.locator("body")).toHaveCSS("font-family", '""');
+ });
+});
diff --git a/playwright/e2e/settings/appearance-user-settings-tab/index.ts b/playwright/e2e/settings/appearance-user-settings-tab/index.ts
new file mode 100644
index 00000000000..e8641306ed5
--- /dev/null
+++ b/playwright/e2e/settings/appearance-user-settings-tab/index.ts
@@ -0,0 +1,241 @@
+/*
+ * Copyright 2024 The Matrix.org Foundation C.I.C.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { Locator, Page } from "@playwright/test";
+
+import { ElementAppPage } from "../../../pages/ElementAppPage";
+import { test as base, expect } from "../../../element-web-test";
+import { SettingLevel } from "../../../../src/settings/SettingLevel";
+import { Layout } from "../../../../src/settings/enums/Layout";
+
+export { expect };
+
+/**
+ * Set up for the appearance tab test
+ */
+export const test = base.extend<{
+ util: Helpers;
+}>({
+ util: async ({ page, app }, use) => {
+ await use(new Helpers(page, app));
+ },
+});
+
+/**
+ * A collection of helper functions for the appearance tab test
+ * The goal is to make easier to get and interact with the button, input, or other elements of the appearance tab
+ */
+class Helpers {
+ private CUSTOM_THEME_URL = "http://custom.theme";
+ private CUSTOM_THEME = {
+ name: "Custom theme",
+ isDark: false,
+ colors: {},
+ };
+
+ constructor(
+ private page: Page,
+ private app: ElementAppPage,
+ ) {}
+
+ /**
+ * Open the appearance tab
+ */
+ openAppearanceTab() {
+ return this.app.settings.openUserSettings("Appearance");
+ }
+
+ /**
+ * Compare screenshot and hide the matrix chat
+ * @param locator
+ * @param screenshot
+ */
+ assertScreenshot(locator: Locator, screenshot: `${string}.png`) {
+ return expect(locator).toMatchScreenshot(screenshot, {
+ css: `
+ #matrixchat {
+ display: none;
+ }
+ `,
+ });
+ }
+
+ // Theme Panel
+
+ /**
+ * Disable in the settings the system theme
+ */
+ disableSystemTheme() {
+ return this.app.settings.setValue("use_system_theme", null, SettingLevel.DEVICE, false);
+ }
+
+ /**
+ * Return the theme section
+ */
+ getThemePanel() {
+ return this.page.getByTestId("themePanel");
+ }
+
+ /**
+ * Return the system theme toggle
+ */
+ getMatchSystemThemeCheckbox() {
+ return this.getThemePanel().getByRole("checkbox", { name: "Match system theme" });
+ }
+
+ /**
+ * Return the theme radio button
+ * @param theme - the theme to select
+ * @private
+ */
+ private getThemeRadio(theme: string) {
+ return this.getThemePanel().getByRole("radio", { name: theme });
+ }
+
+ /**
+ * Return the light theme radio button
+ */
+ getLightTheme() {
+ return this.getThemeRadio("Light");
+ }
+
+ /**
+ * Return the dark theme radio button
+ */
+ getDarkTheme() {
+ return this.getThemeRadio("Dark");
+ }
+
+ /**
+ * Return the custom theme radio button
+ */
+ getCustomTheme() {
+ return this.getThemeRadio(this.CUSTOM_THEME.name);
+ }
+
+ /**
+ * Return the high contrast theme radio button
+ */
+ getHighContrastTheme() {
+ return this.getThemeRadio("High contrast");
+ }
+
+ /**
+ * Add a custom theme
+ * Mock the request to the custom and return a fake local custom theme
+ */
+ async addCustomTheme() {
+ await this.page.route(this.CUSTOM_THEME_URL, (route) =>
+ route.fulfill({ body: JSON.stringify(this.CUSTOM_THEME) }),
+ );
+ await this.page.getByRole("textbox", { name: "Add custom theme" }).fill(this.CUSTOM_THEME_URL);
+ await this.page.getByRole("button", { name: "Add custom theme" }).click();
+ await this.page.unroute(this.CUSTOM_THEME_URL);
+ }
+
+ /**
+ * Remove the custom theme
+ */
+ removeCustomTheme() {
+ return this.getThemePanel().getByRole("listitem", { name: this.CUSTOM_THEME.name }).getByRole("button").click();
+ }
+
+ // Message layout Panel
+
+ /**
+ * Create and display a room named Test Room
+ */
+ async createAndDisplayRoom() {
+ await this.app.client.createRoom({ name: "Test Room" });
+ await this.app.viewRoomByName("Test Room");
+ }
+
+ /**
+ * Assert the room layout
+ * @param layout
+ * @private
+ */
+ private assertRoomLayout(layout: Layout) {
+ return expect(this.page.locator(`.mx_RoomView_body[data-layout=${layout}]`)).toBeVisible();
+ }
+
+ /**
+ * Assert the room layout is modern
+ */
+ assertModernLayout() {
+ return this.assertRoomLayout(Layout.Group);
+ }
+
+ /**
+ * Assert the room layout is bubble
+ */
+ assertBubbleLayout() {
+ return this.assertRoomLayout(Layout.Bubble);
+ }
+
+ /**
+ * Return the layout panel
+ */
+ getMessageLayoutPanel() {
+ return this.page.getByTestId("layoutPanel");
+ }
+
+ /**
+ * Return the layout radio button
+ * @param layoutName
+ * @private
+ */
+ private getLayout(layoutName: string) {
+ return this.getMessageLayoutPanel().getByRole("radio", { name: layoutName });
+ }
+
+ /**
+ * Return the message bubbles layout radio button
+ */
+ getBubbleLayout() {
+ return this.getLayout("Message bubbles");
+ }
+
+ /**
+ * Return the modern layout radio button
+ */
+ getModernLayout() {
+ return this.getLayout("Modern");
+ }
+
+ /**
+ * Return the IRC layout radio button
+ */
+ getIRCLayout() {
+ return this.getLayout("IRC (experimental)");
+ }
+
+ /**
+ * Return the compact layout checkbox
+ */
+ getCompactLayoutCheckbox() {
+ return this.getMessageLayoutPanel().getByRole("checkbox", { name: "Show compact text and messages" });
+ }
+
+ /**
+ * Assert the compact layout is enabled
+ */
+ assertCompactLayout() {
+ return expect(
+ this.page.locator("#matrixchat .mx_MatrixChat_wrapper.mx_MatrixChat_useCompactLayout"),
+ ).toBeVisible();
+ }
+}
diff --git a/playwright/e2e/settings/appearance-user-settings-tab/message-layout-panel.ts b/playwright/e2e/settings/appearance-user-settings-tab/message-layout-panel.ts
new file mode 100644
index 00000000000..1a22696da12
--- /dev/null
+++ b/playwright/e2e/settings/appearance-user-settings-tab/message-layout-panel.ts
@@ -0,0 +1,66 @@
+/*
+Copyright 2023 Suguru Hirahara
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+import { expect, test } from ".";
+
+test.describe("Appearance user settings tab", () => {
+ test.use({
+ displayName: "Hanako",
+ });
+
+ test.describe("Message Layout Panel", () => {
+ test.beforeEach(async ({ app, user, util }) => {
+ await util.createAndDisplayRoom();
+ await util.assertModernLayout();
+ await util.openAppearanceTab();
+ });
+
+ test("should change the message layout from modern to bubble", async ({ page, app, user, util }) => {
+ await util.assertScreenshot(util.getMessageLayoutPanel(), "message-layout-panel-modern.png");
+
+ await util.getBubbleLayout().click();
+
+ // Assert that modern are irc layout are not selected
+ await expect(util.getBubbleLayout()).toBeChecked();
+ await expect(util.getModernLayout()).not.toBeChecked();
+ await expect(util.getIRCLayout()).not.toBeChecked();
+
+ // Assert that the room layout is set to bubble layout
+ await util.assertBubbleLayout();
+ await util.assertScreenshot(util.getMessageLayoutPanel(), "message-layout-panel-bubble.png");
+ });
+
+ test("should enable compact layout when the modern layout is selected", async ({ page, app, user, util }) => {
+ await expect(util.getCompactLayoutCheckbox()).not.toBeChecked();
+
+ await util.getCompactLayoutCheckbox().click();
+ await util.assertCompactLayout();
+ });
+
+ test("should disable compact layout when the modern layout is not selected", async ({
+ page,
+ app,
+ user,
+ util,
+ }) => {
+ await expect(util.getCompactLayoutCheckbox()).not.toBeDisabled();
+
+ // Select the bubble layout, which should disable the compact layout checkbox
+ await util.getBubbleLayout().click();
+ await expect(util.getCompactLayoutCheckbox()).toBeDisabled();
+ });
+ });
+});
diff --git a/playwright/e2e/settings/appearance-user-settings-tab/theme-choice-panel.spec.ts b/playwright/e2e/settings/appearance-user-settings-tab/theme-choice-panel.spec.ts
new file mode 100644
index 00000000000..2b1e8cc14de
--- /dev/null
+++ b/playwright/e2e/settings/appearance-user-settings-tab/theme-choice-panel.spec.ts
@@ -0,0 +1,89 @@
+/*
+Copyright 2023 Suguru Hirahara
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+import { expect, test } from ".";
+
+test.describe("Appearance user settings tab", () => {
+ test.use({
+ displayName: "Hanako",
+ });
+
+ test.describe("Theme Choice Panel", () => {
+ test.beforeEach(async ({ app, user, util }) => {
+ // Disable the default theme for consistency in case ThemeWatcher automatically chooses it
+ await util.disableSystemTheme();
+ await util.openAppearanceTab();
+ });
+
+ test("should be rendered with the light theme selected", async ({ page, app, util }) => {
+ // Assert that 'Match system theme' is not checked
+ await expect(util.getMatchSystemThemeCheckbox()).not.toBeChecked();
+
+ // Assert that the light theme is selected
+ await expect(util.getLightTheme()).toBeChecked();
+ // Assert that the dark and high contrast themes are not selected
+ await expect(util.getDarkTheme()).not.toBeChecked();
+ await expect(util.getHighContrastTheme()).not.toBeChecked();
+
+ await expect(util.getThemePanel()).toMatchScreenshot("theme-panel-light.png");
+ });
+
+ test("should disable the themes when the system theme is clicked", async ({ page, app, util }) => {
+ await util.getMatchSystemThemeCheckbox().click();
+
+ // Assert that the themes are disabled
+ await expect(util.getLightTheme()).toBeDisabled();
+ await expect(util.getDarkTheme()).toBeDisabled();
+ await expect(util.getHighContrastTheme()).toBeDisabled();
+
+ await expect(util.getThemePanel()).toMatchScreenshot("theme-panel-match-system-enabled.png");
+ });
+
+ test("should change the theme to dark", async ({ page, app, util }) => {
+ // Assert that the light theme is selected
+ await expect(util.getLightTheme()).toBeChecked();
+
+ await util.getDarkTheme().click();
+
+ // Assert that the light and high contrast themes are not selected
+ await expect(util.getLightTheme()).not.toBeChecked();
+ await expect(util.getDarkTheme()).toBeChecked();
+ await expect(util.getHighContrastTheme()).not.toBeChecked();
+
+ await expect(util.getThemePanel()).toMatchScreenshot("theme-panel-dark.png");
+ });
+
+ test.describe("custom theme", () => {
+ test.use({
+ labsFlags: ["feature_custom_themes"],
+ });
+
+ test("should render the custom theme section", async ({ page, app, util }) => {
+ await expect(util.getThemePanel()).toMatchScreenshot("theme-panel-custom-theme.png");
+ });
+
+ test("should be able to add and remove a custom theme", async ({ page, app, util }) => {
+ await util.addCustomTheme();
+
+ await expect(util.getCustomTheme()).not.toBeChecked();
+ await expect(util.getThemePanel()).toMatchScreenshot("theme-panel-custom-theme-added.png");
+
+ await util.removeCustomTheme();
+ await expect(util.getThemePanel()).toMatchScreenshot("theme-panel-custom-theme.png");
+ });
+ });
+ });
+});
diff --git a/playwright/e2e/settings/general-room-settings-tab.spec.ts b/playwright/e2e/settings/general-room-settings-tab.spec.ts
index ec3c14b2ca4..123c214288f 100644
--- a/playwright/e2e/settings/general-room-settings-tab.spec.ts
+++ b/playwright/e2e/settings/general-room-settings-tab.spec.ts
@@ -34,7 +34,7 @@ test.describe("General room settings tab", () => {
// Assert that "Show less" details element is rendered
await expect(settings.getByText("Show less")).toBeVisible();
- await expect(settings).toMatchScreenshot();
+ await expect(settings).toMatchScreenshot("General-room-settings-tab-should-be-rendered-properly-1.png");
// Click the "Show less" details element
await settings.getByText("Show less").click();
diff --git a/playwright/e2e/settings/general-user-settings-tab.spec.ts b/playwright/e2e/settings/general-user-settings-tab.spec.ts
index 41210292a3a..0ba85e890be 100644
--- a/playwright/e2e/settings/general-user-settings-tab.spec.ts
+++ b/playwright/e2e/settings/general-user-settings-tab.spec.ts
@@ -18,7 +18,6 @@ import { test, expect } from "../../element-web-test";
const USER_NAME = "Bob";
const USER_NAME_NEW = "Alice";
-const IntegrationManager = "scalar.vector.im";
test.describe("General user settings tab", () => {
test.use({
@@ -73,40 +72,6 @@ test.describe("General user settings tab", () => {
// Assert that the add button is rendered
await expect(phoneNumbers.getByRole("button", { name: "Add" })).toBeVisible();
- // Check language and region setting dropdown
- const languageInput = uut.locator(".mx_GeneralUserSettingsTab_section_languageInput");
- await languageInput.scrollIntoViewIfNeeded();
- // Check the default value
- await expect(languageInput.getByText("English")).toBeVisible();
- // Click the button to display the dropdown menu
- await languageInput.getByRole("button", { name: "Language Dropdown" }).click();
- // Assert that the default option is rendered and highlighted
- languageInput.getByRole("option", { name: /Albanian/ });
- await expect(languageInput.getByRole("option", { name: /Albanian/ })).toHaveClass(
- /mx_Dropdown_option_highlight/,
- );
- await expect(languageInput.getByRole("option", { name: /Deutsch/ })).toBeVisible();
- // Click again to close the dropdown
- await languageInput.getByRole("button", { name: "Language Dropdown" }).click();
- // Assert that the default value is rendered again
- await expect(languageInput.getByText("English")).toBeVisible();
-
- const setIdServer = uut.locator(".mx_SetIdServer");
- await setIdServer.scrollIntoViewIfNeeded();
- // Assert that an input area for identity server exists
- await expect(setIdServer.getByRole("textbox", { name: "Enter a new identity server" })).toBeVisible();
-
- const setIntegrationManager = uut.locator(".mx_SetIntegrationManager");
- await setIntegrationManager.scrollIntoViewIfNeeded();
- await expect(
- setIntegrationManager.locator(".mx_SetIntegrationManager_heading_manager", { hasText: IntegrationManager }),
- ).toBeVisible();
- // Make sure integration manager's toggle switch is enabled
- await expect(setIntegrationManager.locator(".mx_ToggleSwitch_enabled")).toBeVisible();
- await expect(setIntegrationManager.locator(".mx_SetIntegrationManager_heading_manager")).toHaveText(
- "Manage integrations(scalar.vector.im)",
- );
-
// Assert the account deactivation button is displayed
const accountManagementSection = uut.getByTestId("account-management-section");
await accountManagementSection.scrollIntoViewIfNeeded();
@@ -120,6 +85,12 @@ test.describe("General user settings tab", () => {
await expect(uut).toMatchScreenshot("general-smallscreen.png");
});
+ test("should show tooltips on narrow screen", async ({ page, uut }) => {
+ await page.setViewportSize({ width: 700, height: 600 });
+ await page.getByRole("tab", { name: "General" }).hover();
+ await expect(page.getByRole("tooltip")).toHaveText("General");
+ });
+
test("should support adding and removing a profile picture", async ({ uut, page }) => {
const profileSettings = uut.locator(".mx_UserProfileSettings");
// Upload a picture
diff --git a/playwright/e2e/settings/preferences-user-settings-tab.spec.ts b/playwright/e2e/settings/preferences-user-settings-tab.spec.ts
index 2dbd267162d..a67909b47be 100644
--- a/playwright/e2e/settings/preferences-user-settings-tab.spec.ts
+++ b/playwright/e2e/settings/preferences-user-settings-tab.spec.ts
@@ -1,5 +1,6 @@
/*
Copyright 2023 Suguru Hirahara
+Copyright 2024 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
@@ -19,6 +20,10 @@ import { test, expect } from "../../element-web-test";
test.describe("Preferences user settings tab", () => {
test.use({
displayName: "Bob",
+ uut: async ({ app, user }, use) => {
+ const locator = await app.settings.openUserSettings("Preferences");
+ await use(locator);
+ },
});
test("should be rendered properly", async ({ app, user }) => {
@@ -26,6 +31,26 @@ test.describe("Preferences user settings tab", () => {
// Assert that the top heading is rendered
await expect(tab.getByRole("heading", { name: "Preferences" })).toBeVisible();
- await expect(tab).toMatchScreenshot();
+ await expect(tab).toMatchScreenshot("Preferences-user-settings-tab-should-be-rendered-properly-1.png");
+ });
+
+ test("should be able to change the app language", async ({ uut, user }) => {
+ // Check language and region setting dropdown
+ const languageInput = uut.locator(".mx_GeneralUserSettingsTab_section_languageInput");
+ await languageInput.scrollIntoViewIfNeeded();
+ // Check the default value
+ await expect(languageInput.getByText("English")).toBeVisible();
+ // Click the button to display the dropdown menu
+ await languageInput.getByRole("button", { name: "Language Dropdown" }).click();
+ // Assert that the default option is rendered and highlighted
+ languageInput.getByRole("option", { name: /Albanian/ });
+ await expect(languageInput.getByRole("option", { name: /Albanian/ })).toHaveClass(
+ /mx_Dropdown_option_highlight/,
+ );
+ await expect(languageInput.getByRole("option", { name: /Deutsch/ })).toBeVisible();
+ // Click again to close the dropdown
+ await languageInput.getByRole("button", { name: "Language Dropdown" }).click();
+ // Assert that the default value is rendered again
+ await expect(languageInput.getByText("English")).toBeVisible();
});
});
diff --git a/playwright/e2e/settings/security-user-settings-tab.spec.ts b/playwright/e2e/settings/security-user-settings-tab.spec.ts
index 08640f603ba..8d1f442ba5c 100644
--- a/playwright/e2e/settings/security-user-settings-tab.spec.ts
+++ b/playwright/e2e/settings/security-user-settings-tab.spec.ts
@@ -1,5 +1,6 @@
/*
Copyright 2023 Suguru Hirahara
+Copyright 2024 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
@@ -16,6 +17,8 @@ limitations under the License.
import { test, expect } from "../../element-web-test";
+const IntegrationManager = "scalar.vector.im";
+
test.describe("Security user settings tab", () => {
test.describe("with posthog enabled", () => {
test.use({
@@ -44,8 +47,36 @@ test.describe("Security user settings tab", () => {
test("should be rendered properly", async ({ app, page }) => {
const tab = await app.settings.openUserSettings("Security");
await tab.getByRole("button", { name: "Learn more" }).click();
- await expect(page.locator(".mx_AnalyticsLearnMoreDialog_wrapper .mx_Dialog")).toMatchScreenshot();
+ await expect(page.locator(".mx_AnalyticsLearnMoreDialog_wrapper .mx_Dialog")).toMatchScreenshot(
+ "Security-user-settings-tab-with-posthog-enable-b5d89-csLearnMoreDialog-should-be-rendered-properly-1.png",
+ );
});
});
+
+ test("should contain section to set ID server", async ({ app }) => {
+ const tab = await app.settings.openUserSettings("Security");
+
+ const setIdServer = tab.locator(".mx_SetIdServer");
+ await setIdServer.scrollIntoViewIfNeeded();
+ // Assert that an input area for identity server exists
+ await expect(setIdServer.getByRole("textbox", { name: "Enter a new identity server" })).toBeVisible();
+ });
+
+ test("should enable show integrations as enabled", async ({ app, page }) => {
+ const tab = await app.settings.openUserSettings("Security");
+
+ const setIntegrationManager = tab.locator(".mx_SetIntegrationManager");
+ await setIntegrationManager.scrollIntoViewIfNeeded();
+ await expect(
+ setIntegrationManager.locator(".mx_SetIntegrationManager_heading_manager", {
+ hasText: IntegrationManager,
+ }),
+ ).toBeVisible();
+ // Make sure integration manager's toggle switch is enabled
+ await expect(setIntegrationManager.locator(".mx_ToggleSwitch_enabled")).toBeVisible();
+ await expect(setIntegrationManager.locator(".mx_SetIntegrationManager_heading_manager")).toHaveText(
+ "Manage integrations(scalar.vector.im)",
+ );
+ });
});
});
diff --git a/playwright/e2e/spaces/threads-activity-centre/index.ts b/playwright/e2e/spaces/threads-activity-centre/index.ts
index 8bafe2e8049..8b013c44bb8 100644
--- a/playwright/e2e/spaces/threads-activity-centre/index.ts
+++ b/playwright/e2e/spaces/threads-activity-centre/index.ts
@@ -337,12 +337,10 @@ export class Helpers {
}
/**
- * Assert that the thread panel is focused (actually the 'close' button, specifically)
+ * Assert that the thread tab is focused
*/
- assertThreadPanelFocused() {
- return expect(
- this.page.locator(".mx_ThreadPanel").locator(".mx_BaseCard_header").getByLabel("Close"),
- ).toBeFocused();
+ assertThreadTabFocused() {
+ return expect(this.page.locator("#thread-panel-tab")).toBeFocused();
}
/**
diff --git a/playwright/e2e/spaces/threads-activity-centre/threadsActivityCentre.spec.ts b/playwright/e2e/spaces/threads-activity-centre/threadsActivityCentre.spec.ts
index 7d0b694ef57..66a3bc58e50 100644
--- a/playwright/e2e/spaces/threads-activity-centre/threadsActivityCentre.spec.ts
+++ b/playwright/e2e/spaces/threads-activity-centre/threadsActivityCentre.spec.ts
@@ -161,17 +161,12 @@ test.describe("Threads Activity Centre", () => {
await util.assertNoTacIndicator();
});
- test("should focus the thread panel close button when clicking an item in the TAC", async ({
- room1,
- room2,
- util,
- msg,
- }) => {
+ test("should focus the thread tab when clicking an item in the TAC", async ({ room1, room2, util, msg }) => {
await util.receiveMessages(room1, ["Msg1", msg.threadedOff("Msg1", "Resp1")]);
await util.openTac();
await util.clickRoomInTac(room1.name);
- await util.assertThreadPanelFocused();
+ await util.assertThreadTabFocused();
});
});
diff --git a/playwright/e2e/spotlight/spotlight.spec.ts b/playwright/e2e/spotlight/spotlight.spec.ts
index 177eccdc106..5d10937b671 100644
--- a/playwright/e2e/spotlight/spotlight.spec.ts
+++ b/playwright/e2e/spotlight/spotlight.spec.ts
@@ -21,7 +21,7 @@ import type { Locator, Page } from "@playwright/test";
import type { ElementAppPage } from "../../pages/ElementAppPage";
function roomHeaderName(page: Page): Locator {
- return page.locator(".mx_LegacyRoomHeader_nametext");
+ return page.locator(".mx_RoomHeader_heading");
}
async function startDM(app: ElementAppPage, page: Page, name: string): Promise {
diff --git a/playwright/e2e/threads/threads.spec.ts b/playwright/e2e/threads/threads.spec.ts
index 9b5ea46511e..7898457d053 100644
--- a/playwright/e2e/threads/threads.spec.ts
+++ b/playwright/e2e/threads/threads.spec.ts
@@ -252,7 +252,8 @@ test.describe("Threads", () => {
await expect(locator.locator(".mx_ThreadSummary_content").getByText("How are things?")).toBeAttached();
locator = page.getByRole("button", { name: "Threads" });
- await expect(locator).toHaveClass(/mx_LegacyRoomHeader_button--unread/); // User asserts thread list unread indicator
+ await expect(locator).toHaveAttribute("data-indicator", "default"); // User asserts thread list unread indicator
+ // await expect(locator).toHaveClass(/mx_LegacyRoomHeader_button--unread/);
await locator.click(); // User opens thread list
// User asserts thread with correct root & latest events & unread dot
@@ -433,7 +434,7 @@ test.describe("Threads", () => {
await textbox.press("Enter");
await expect(locator.locator(".mx_EventTile_last").getByText("Hello Mr. User")).toBeAttached();
// Close thread
- await locator.getByRole("button", { name: "Close" }).click();
+ await locator.getByTestId("base-card-close-button").click();
// Open existing thread
locator = page
@@ -486,7 +487,7 @@ test.describe("Threads", () => {
await textbox.press("Enter");
await expect(threadPanel.locator(".mx_EventTile_last").getByText(threadMessage)).toBeVisible();
// Close thread
- await threadPanel.getByRole("button", { name: "Close" }).click();
+ await threadPanel.getByTestId("base-card-close-button").click();
};
await sendMessage("Hello Mr. Bot");
@@ -495,14 +496,12 @@ test.describe("Threads", () => {
await createThread("Hello again Mr. Bot", "Hello again Mr. User in a thread");
// Open thread panel
- await page.getByTestId("threadsButton").click();
+ await page.locator(".mx_RoomHeader").getByRole("button", { name: "Threads" }).click();
const threadPanel = page.locator(".mx_ThreadPanel");
await expect(
threadPanel.locator(".mx_EventTile_last").getByText("Hello again Mr. User in a thread"),
).toBeVisible();
- // Open threads list
- await page.locator(".mx_BaseCard_back").click();
const rightPanel = page.locator(".mx_RightPanel");
// Check that the threads are listed
await expect(rightPanel.locator(".mx_EventTile").getByText("Hello Mr. User in a thread")).toBeVisible();
diff --git a/playwright/e2e/timeline/timeline.spec.ts b/playwright/e2e/timeline/timeline.spec.ts
index 60aa1e2a27b..6068385194c 100644
--- a/playwright/e2e/timeline/timeline.spec.ts
+++ b/playwright/e2e/timeline/timeline.spec.ts
@@ -410,6 +410,7 @@ test.describe("Timeline", () => {
{
// Exclude timestamp from snapshot of mx_MainSplit
mask: [page.locator(".mx_MessageTimestamp")],
+ hideTooltips: true,
},
);
@@ -427,6 +428,7 @@ test.describe("Timeline", () => {
await expect(page.locator(".mx_MainSplit")).toMatchScreenshot("expanded-gels-and-messages-irc-layout.png", {
// Exclude timestamp from snapshot of mx_MainSplit
mask: [page.locator(".mx_MessageTimestamp")],
+ hideTooltips: true,
});
// 3. Alignment of expanded GELS and placeholder of deleted message
@@ -447,6 +449,7 @@ test.describe("Timeline", () => {
await expect(page.locator(".mx_MainSplit")).toMatchScreenshot("expanded-gels-redaction-placeholder.png", {
// Exclude timestamp from snapshot of mx_MainSplit
mask: [page.locator(".mx_MessageTimestamp")],
+ hideTooltips: true,
});
// 4. Alignment of expanded GELS, placeholder of deleted message, and emote
@@ -469,6 +472,7 @@ test.describe("Timeline", () => {
await expect(page.locator(".mx_MainSplit")).toMatchScreenshot("expanded-gels-emote-irc-layout.png", {
// Exclude timestamp from snapshot of mx_MainSplit
mask: [page.locator(".mx_MessageTimestamp")],
+ hideTooltips: true,
});
});
@@ -481,6 +485,7 @@ test.describe("Timeline", () => {
display: none !important;
}
`,
+ hideTooltips: true,
};
await sendEvent(app.client, room.roomId);
@@ -568,9 +573,9 @@ test.describe("Timeline", () => {
);
});
- test("should set inline start padding to a hidden event line", async ({ page, app, room, cryptoBackend }) => {
+ test("should set inline start padding to a hidden event line", async ({ page, app, room }) => {
test.skip(
- cryptoBackend === "rust",
+ true,
"Disabled due to screenshot test being flaky - https://github.com/element-hq/element-web/issues/26890",
);
await sendEvent(app.client, room.roomId);
@@ -779,12 +784,12 @@ test.describe("Timeline", () => {
await sendEvent(app.client, room.roomId, true);
await page.goto(`/#/room/${room.roomId}`);
- await page.locator(".mx_LegacyRoomHeader").getByRole("button", { name: "Search" }).click();
+ await app.toggleRoomInfoPanel();
- await expect(page.locator(".mx_SearchBar")).toMatchScreenshot("search-bar-on-timeline.png");
+ await page.locator(".mx_RoomSummaryCard_search").getByRole("searchbox").fill("Message");
+ await page.locator(".mx_RoomSummaryCard_search").getByRole("searchbox").press("Enter");
- await page.locator(".mx_SearchBar_input").getByRole("textbox").fill("Message");
- await page.locator(".mx_SearchBar_input").getByRole("textbox").press("Enter");
+ await expect(page.locator(".mx_RoomSearchAuxPanel")).toMatchScreenshot("search-aux-panel.png");
for (const locator of await page
.locator(".mx_EventTile:not(.mx_EventTile_contextual) .mx_EventTile_searchHighlight")
@@ -804,7 +809,7 @@ test.describe("Timeline", () => {
await page.goto(`/#/room/${room.roomId}`);
// Open a room setting dialog
- await page.getByRole("button", { name: "Room options" }).click();
+ await app.toggleRoomInfoPanel();
await page.getByRole("menuitem", { name: "Settings" }).click();
// Set a room topic to render a TextualEvent
@@ -818,12 +823,9 @@ test.describe("Timeline", () => {
page.getByText(`${OLD_NAME} changed the topic to "This is a room for ${stringToSearch}.".`),
).toHaveClass(/mx_TextualEvent/);
- // Display the room search bar
- await page.locator(".mx_LegacyRoomHeader").getByRole("button", { name: "Search" }).click();
-
// Search the string to display both the message and TextualEvent on search results panel
- await page.locator(".mx_SearchBar").getByRole("textbox").fill(stringToSearch);
- await page.locator(".mx_SearchBar").getByRole("textbox").press("Enter");
+ await page.locator(".mx_RoomSummaryCard_search").getByRole("searchbox").fill(stringToSearch);
+ await page.locator(".mx_RoomSummaryCard_search").getByRole("searchbox").press("Enter");
// On search results panel
const resultsPanel = page.locator(".mx_RoomView_searchResultsPanel");
@@ -1199,10 +1201,10 @@ test.describe("Timeline", () => {
// Install our mocks and preventative measures
await context.route("**/_matrix/client/versions", async (route) => {
- // Force enable MSC3916, which may require the service worker's internal cache to be cleared later.
+ // Force enable MSC3916/Matrix 1.11, which may require the service worker's internal cache to be cleared later.
const json = await (await route.fetch()).json();
- if (!json["unstable_features"]) json["unstable_features"] = {};
- json["unstable_features"]["org.matrix.msc3916"] = true;
+ if (!json["versions"]) json["versions"] = [];
+ json["versions"].push("v1.11");
await route.fulfill({ json });
});
await context.route("**/_matrix/media/*/download/**", async (route) => {
@@ -1219,14 +1221,14 @@ test.describe("Timeline", () => {
json: { errcode: "M_UNKNOWN", error: "Unexpected route called." },
});
});
- await context.route("**/_matrix/client/unstable/org.matrix.msc3916/download/**", async (route) => {
+ await context.route("**/_matrix/client/v1/download/**", async (route) => {
expect(route.request().headers()["Authorization"]).toBeDefined();
// we can't use route.continue() because no configured homeserver supports MSC3916 yet
await route.fulfill({
body: NEW_AVATAR,
});
});
- await context.route("**/_matrix/client/unstable/org.matrix.msc3916/thumbnail/**", async (route) => {
+ await context.route("**/_matrix/client/v1/thumbnail/**", async (route) => {
expect(route.request().headers()["Authorization"]).toBeDefined();
// we can't use route.continue() because no configured homeserver supports MSC3916 yet
await route.fulfill({
diff --git a/playwright/e2e/user-onboarding/user-onboarding-new.spec.ts b/playwright/e2e/user-onboarding/user-onboarding-new.spec.ts
index 09a140d4418..0799dc0f604 100644
--- a/playwright/e2e/user-onboarding/user-onboarding-new.spec.ts
+++ b/playwright/e2e/user-onboarding/user-onboarding-new.spec.ts
@@ -35,7 +35,9 @@ test.describe("User Onboarding (new user)", () => {
});
test("page is shown and preference exists", async ({ page, app }) => {
- await expect(page.locator(".mx_UserOnboardingPage")).toMatchScreenshot();
+ await expect(page.locator(".mx_UserOnboardingPage")).toMatchScreenshot(
+ "User-Onboarding-new-user-page-is-shown-and-preference-exists-1.png",
+ );
await app.settings.openUserSettings("Preferences");
await expect(page.getByText("Show shortcut to welcome checklist above the room list")).toBeVisible();
});
diff --git a/playwright/e2e/utils.ts b/playwright/e2e/utils.ts
index 30aff64dd8f..e7587c7dfbc 100644
--- a/playwright/e2e/utils.ts
+++ b/playwright/e2e/utils.ts
@@ -17,8 +17,8 @@ limitations under the License.
*/
import { uniqueId } from "lodash";
+import { expect, type Page } from "@playwright/test";
-import type { Page } from "@playwright/test";
import type { ClientEvent, MatrixEvent, Room } from "matrix-js-sdk/src/matrix";
import { Client } from "../pages/client";
@@ -63,4 +63,15 @@ export async function waitForRoom(
);
}
+export async function selectHomeserver(page: Page, homeserverUrl: string) {
+ await page.getByRole("button", { name: "Edit" }).click();
+ await page.getByRole("textbox", { name: "Other homeserver" }).fill(homeserverUrl);
+ await page.getByRole("button", { name: "Continue", exact: true }).click();
+ // wait for the dialog to go away
+ await expect(page.locator(".mx_ServerPickerDialog")).toHaveCount(0);
+
+ await expect(page.locator(".mx_Spinner")).toHaveCount(0);
+ await expect(page.locator(".mx_ServerPicker_server")).toHaveText(homeserverUrl);
+}
+
export const CommandOrControl = process.platform === "darwin" ? "Meta" : "Control";
diff --git a/playwright/element-web-test.ts b/playwright/element-web-test.ts
index 2317978898d..d6fd1b48c19 100644
--- a/playwright/element-web-test.ts
+++ b/playwright/element-web-test.ts
@@ -15,9 +15,10 @@ limitations under the License.
*/
import { test as base, expect as baseExpect, Locator, Page, ExpectMatcherState, ElementHandle } from "@playwright/test";
+import { sanitizeForFilePath } from "playwright-core/lib/utils";
import AxeBuilder from "@axe-core/playwright";
import _ from "lodash";
-import { basename } from "node:path";
+import { basename, extname } from "node:path";
import type mailhog from "mailhog";
import type { IConfigOptions } from "../src/IConfigOptions";
@@ -63,84 +64,77 @@ const CONFIG_JSON: Partial = {
},
};
-export type TestOptions = {
- cryptoBackend: "legacy" | "rust";
-};
-
interface CredentialsWithDisplayName extends Credentials {
displayName: string;
}
-export const test = base.extend<
- TestOptions & {
- axe: AxeBuilder;
- checkA11y: () => Promise;
-
- /**
- * The contents of the config.json to send when the client requests it.
- */
- config: typeof CONFIG_JSON;
-
- /**
- * The options with which to run the {@link #homeserver} fixture.
- */
- startHomeserverOpts: StartHomeserverOpts | string;
-
- homeserver: HomeserverInstance;
- oAuthServer: { port: number };
-
- /**
- * The displayname to use for the user registered in {@link #credentials}.
- *
- * To set it, call `test.use({ displayName: "myDisplayName" })` in the test file or `describe` block.
- * See {@link https://playwright.dev/docs/api/class-test#test-use}.
- */
- displayName?: string;
-
- /**
- * A test fixture which registers a test user on the {@link #homeserver} and supplies the details
- * of the registered user.
- */
- credentials: CredentialsWithDisplayName;
-
- /**
- * The same as {@link https://playwright.dev/docs/api/class-fixtures#fixtures-page|`page`},
- * but adds an initScript which will populate localStorage with the user's details from
- * {@link #credentials} and {@link #homeserver}.
- *
- * Similar to {@link #user}, but doesn't load the app.
- */
- pageWithCredentials: Page;
-
- /**
- * A (rather poorly-named) test fixture which registers a user per {@link #credentials}, stores
- * the credentials into localStorage per {@link #homeserver}, and then loads the front page of the
- * app.
- */
- user: CredentialsWithDisplayName;
-
- /**
- * The same as {@link https://playwright.dev/docs/api/class-fixtures#fixtures-page|`page`},
- * but wraps the returned `Page` in a class of utilities for interacting with the Element-Web UI,
- * {@link ElementAppPage}.
- */
- app: ElementAppPage;
-
- mailhog: { api: mailhog.API; instance: Instance };
- crypto: Crypto;
- room?: { roomId: string };
- toasts: Toasts;
- uut?: Locator; // Unit Under Test, useful place to refer a prepared locator
- botCreateOpts: CreateBotOpts;
- bot: Bot;
- slidingSyncProxy: ProxyInstance;
- labsFlags: string[];
- webserver: Webserver;
- }
->({
- cryptoBackend: ["legacy", { option: true }],
+export const test = base.extend<{
+ axe: AxeBuilder;
+ checkA11y: () => Promise;
+
+ /**
+ * The contents of the config.json to send when the client requests it.
+ */
+ config: typeof CONFIG_JSON;
+
+ /**
+ * The options with which to run the {@link #homeserver} fixture.
+ */
+ startHomeserverOpts: StartHomeserverOpts | string;
+
+ homeserver: HomeserverInstance;
+ oAuthServer: { port: number };
+
+ /**
+ * The displayname to use for the user registered in {@link #credentials}.
+ *
+ * To set it, call `test.use({ displayName: "myDisplayName" })` in the test file or `describe` block.
+ * See {@link https://playwright.dev/docs/api/class-test#test-use}.
+ */
+ displayName?: string;
+
+ /**
+ * A test fixture which registers a test user on the {@link #homeserver} and supplies the details
+ * of the registered user.
+ */
+ credentials: CredentialsWithDisplayName;
+
+ /**
+ * The same as {@link https://playwright.dev/docs/api/class-fixtures#fixtures-page|`page`},
+ * but adds an initScript which will populate localStorage with the user's details from
+ * {@link #credentials} and {@link #homeserver}.
+ *
+ * Similar to {@link #user}, but doesn't load the app.
+ */
+ pageWithCredentials: Page;
+
+ /**
+ * A (rather poorly-named) test fixture which registers a user per {@link #credentials}, stores
+ * the credentials into localStorage per {@link #homeserver}, and then loads the front page of the
+ * app.
+ */
+ user: CredentialsWithDisplayName;
+
+ /**
+ * The same as {@link https://playwright.dev/docs/api/class-fixtures#fixtures-page|`page`},
+ * but wraps the returned `Page` in a class of utilities for interacting with the Element-Web UI,
+ * {@link ElementAppPage}.
+ */
+ app: ElementAppPage;
+
+ mailhog: { api: mailhog.API; instance: Instance };
+ crypto: Crypto;
+ room?: { roomId: string };
+ toasts: Toasts;
+ uut?: Locator; // Unit Under Test, useful place to refer a prepared locator
+ botCreateOpts: CreateBotOpts;
+ bot: Bot;
+ slidingSyncProxy: ProxyInstance;
+ labsFlags: string[];
+ webserver: Webserver;
+}>({
config: CONFIG_JSON,
- page: async ({ context, page, config, cryptoBackend, labsFlags }, use) => {
+ page: async ({ context, page, config, labsFlags }, use) => {
await context.route(`http://localhost:8080/config.json*`, async (route) => {
const json = { ...CONFIG_JSON, ...config };
json["features"] = {
@@ -151,10 +145,6 @@ export const test = base.extend<
return obj;
}, {}),
};
- // the default is to use rust now, so set to `false` if on legacy backend
- if (cryptoBackend === "legacy") {
- json.features.feature_rust_crypto = false;
- }
await route.fulfill({ json });
});
await use(page);
@@ -309,20 +299,40 @@ export const test = base.extend<
},
});
+// Based on https://github.com/microsoft/playwright/blob/2b77ed4d7aafa85a600caa0b0d101b72c8437eeb/packages/playwright/src/util.ts#L206C8-L210C2
+function sanitizeFilePathBeforeExtension(filePath: string): string {
+ const ext = extname(filePath);
+ const base = filePath.substring(0, filePath.length - ext.length);
+ return sanitizeForFilePath(base) + ext;
+}
+
export const expect = baseExpect.extend({
async toMatchScreenshot(
this: ExpectMatcherState,
receiver: Page | Locator,
- name?: `${string}.png`,
+ name: `${string}.png`,
options?: {
mask?: Array;
omitBackground?: boolean;
+ hideTooltips?: boolean;
timeout?: number;
css?: string;
},
) {
+ const testInfo = test.info();
+ if (!testInfo) throw new Error(`toMatchScreenshot() must be called during the test`);
+
const page = "page" in receiver ? receiver.page() : receiver;
+ let hideTooltipsCss: string | undefined;
+ if (options?.hideTooltips) {
+ hideTooltipsCss = `
+ .mx_Tooltip_visible {
+ visibility: hidden !important;
+ }
+ `;
+ }
+
// We add a custom style tag before taking screenshots
const style = (await page.addStyleTag({
content: `
@@ -342,17 +352,31 @@ export const expect = baseExpect.extend({
.mx_ReplyChain {
border-left-color: var(--cpd-color-blue-1200) !important;
}
+ /* Avoid flakiness from hover styling */
+ .mx_ReplyChain_show {
+ color: var(--cpd-color-text-secondary) !important;
+ }
/* Use monospace font for timestamp for consistent mask width */
.mx_MessageTimestamp {
font-family: Inconsolata !important;
}
+ ${hideTooltipsCss ?? ""}
${options?.css ?? ""}
`,
})) as ElementHandle;
- await baseExpect(receiver).toHaveScreenshot(name, options);
+ const screenshotName = sanitizeFilePathBeforeExtension(name);
+ await baseExpect(receiver).toHaveScreenshot(screenshotName, options);
await style.evaluate((tag) => tag.remove());
+
+ testInfo.annotations.push({
+ // `_` prefix hides it from the HTML reporter
+ type: "_screenshot",
+ // include a path relative to `playwright/snapshots/`
+ description: testInfo.snapshotPath(screenshotName).split("/playwright/snapshots/", 2)[1],
+ });
+
return { pass: true, message: () => "", name: "toMatchScreenshot" };
},
});
diff --git a/playwright/flaky-reporter.ts b/playwright/flaky-reporter.ts
index 3d358bb74d1..95023e31baf 100644
--- a/playwright/flaky-reporter.ts
+++ b/playwright/flaky-reporter.ts
@@ -53,7 +53,10 @@ class FlakyReporter implements Reporter {
const headers = { Authorization: `Bearer ${GITHUB_TOKEN}` };
// Fetch all existing issues with the flaky-test label.
- const issuesRequest = await fetch(`${GITHUB_API_URL}/repos/${REPO}/issues?labels=${LABEL}`, { headers });
+ const issuesRequest = await fetch(
+ `${GITHUB_API_URL}/repos/${REPO}/issues?labels=${LABEL}&state=all&per_page=100&sort=created`,
+ { headers },
+ );
const issues = await issuesRequest.json();
for (const flake of this.flakes) {
const title = ISSUE_TITLE_PREFIX + "`" + flake + "`";
@@ -61,6 +64,12 @@ class FlakyReporter implements Reporter {
if (existingIssue) {
console.log(`Found issue ${existingIssue.number} for ${flake}, adding comment...`);
+ // Ensure that the test is open
+ await fetch(existingIssue.url, {
+ method: "PATCH",
+ headers,
+ body: JSON.stringify({ state: "open" }),
+ });
await fetch(`${existingIssue.url}/comments`, {
method: "POST",
headers,
diff --git a/playwright/pages/ElementAppPage.ts b/playwright/pages/ElementAppPage.ts
index ac9b4ffef80..af79994358c 100644
--- a/playwright/pages/ElementAppPage.ts
+++ b/playwright/pages/ElementAppPage.ts
@@ -171,4 +171,13 @@ export class ElementAppPage {
await spotlight.open();
return spotlight;
}
+
+ /**
+ * Opens/closes the room info panel
+ * @returns locator to the right panel
+ */
+ public async toggleRoomInfoPanel(): Promise {
+ await this.page.getByRole("button", { name: "Room info" }).first().click();
+ return this.page.locator(".mx_RightPanel");
+ }
}
diff --git a/playwright/pages/bot.ts b/playwright/pages/bot.ts
index 333d895dfe9..3b461301088 100644
--- a/playwright/pages/bot.ts
+++ b/playwright/pages/bot.ts
@@ -45,10 +45,6 @@ export interface CreateBotOpts {
* Whether to generate cross-signing keys
*/
bootstrapCrossSigning?: boolean;
- /**
- * Whether to use the rust crypto impl. Defaults to false (for now!)
- */
- rustCrypto?: boolean;
/**
* Whether to bootstrap the secret storage
*/
@@ -188,11 +184,7 @@ export class Bot extends Client {
return cli;
}
- if (opts.rustCrypto) {
- await cli.initRustCrypto({ useIndexedDB: false });
- } else {
- await cli.initCrypto();
- }
+ await cli.initRustCrypto({ useIndexedDB: false });
cli.setGlobalErrorOnUnknownDevices(false);
await cli.startClient();
diff --git a/playwright/pages/settings.ts b/playwright/pages/settings.ts
index c0efb6770c8..1b7d099c6c4 100644
--- a/playwright/pages/settings.ts
+++ b/playwright/pages/settings.ts
@@ -91,12 +91,17 @@ export class Settings {
}
/**
- * Open room settings (via room header menu), returns a locator to the dialog
+ * Open room settings (via room info panel), returns a locator to the dialog
* @param tab the name of the tab to switch to after opening, optional.
*/
public async openRoomSettings(tab?: string): Promise {
- await this.page.getByRole("banner").getByRole("button", { name: "Room options", exact: true }).click();
- await this.page.locator(".mx_RoomTile_contextMenu").getByRole("menuitem", { name: "Settings" }).click();
+ // Open right panel if not open
+ const rightPanel = this.page.locator(".mx_RightPanel");
+ if ((await rightPanel.count()) === 0) {
+ await this.page.getByRole("button", { name: "Room info" }).first().click();
+ }
+ await rightPanel.getByRole("menuitem", { name: "Settings" }).click();
+
if (tab) await this.switchTab(tab);
return this.page.locator(".mx_Dialog").filter({ has: this.page.locator(".mx_RoomSettingsDialog") });
}
diff --git a/playwright/plugins/homeserver/index.ts b/playwright/plugins/homeserver/index.ts
index 1e0cfb3b39c..b14ba70082d 100644
--- a/playwright/plugins/homeserver/index.ts
+++ b/playwright/plugins/homeserver/index.ts
@@ -39,6 +39,15 @@ export interface HomeserverInstance {
* @param password login password
*/
loginUser(userId: string, password: string): Promise;
+
+ /**
+ * Sets a third party identifier for the given user. This only supports setting a single 3pid and will
+ * replace any others.
+ * @param userId The full ID of the user to edit (as returned from registerUser)
+ * @param medium The medium of the 3pid to set
+ * @param address The address of the 3pid to set
+ */
+ setThreepid(userId: string, medium: string, address: string): Promise;
}
export interface StartHomeserverOpts {
diff --git a/playwright/plugins/homeserver/synapse/index.ts b/playwright/plugins/homeserver/synapse/index.ts
index c88fd641d9d..5cf63917203 100644
--- a/playwright/plugins/homeserver/synapse/index.ts
+++ b/playwright/plugins/homeserver/synapse/index.ts
@@ -28,7 +28,7 @@ import { randB64Bytes } from "../../utils/rand";
// Docker tag to use for `matrixdotorg/synapse` image.
// We target a specific digest as every now and then a Synapse update will break our CI.
// This digest is updated by the playwright-image-updates.yaml workflow periodically.
-const DOCKER_TAG = "develop@sha256:c357ea1486c8cd2613932e97bd2f6ff4e8b4c4fafcb2c28d1e8ec0d383c56d9d";
+const DOCKER_TAG = "develop@sha256:9e193236098ae5ff66c9bf79252e318fd561ceb1322d5495780a11d9dbdcfb17";
async function cfgDirFromTemplate(opts: StartHomeserverOpts): Promise> {
const templateDir = path.join(__dirname, "templates", opts.template);
@@ -94,6 +94,8 @@ export class Synapse implements Homeserver, HomeserverInstance {
protected docker: Docker = new Docker();
public config: HomeserverConfig & { serverId: string };
+ private adminToken?: string;
+
public constructor(private readonly request: APIRequestContext) {}
/**
@@ -152,12 +154,17 @@ export class Synapse implements Homeserver, HomeserverInstance {
return [path.join(synapseLogsPath, "stdout.log"), path.join(synapseLogsPath, "stderr.log")];
}
- public async registerUser(username: string, password: string, displayName?: string): Promise {
+ private async registerUserInternal(
+ username: string,
+ password: string,
+ displayName?: string,
+ admin = false,
+ ): Promise {
const url = `${this.config.baseUrl}/_synapse/admin/v1/register`;
const { nonce } = await this.request.get(url).then((r) => r.json());
const mac = crypto
.createHmac("sha1", this.config.registrationSecret)
- .update(`${nonce}\0${username}\0${password}\0notadmin`)
+ .update(`${nonce}\0${username}\0${password}\0${admin ? "" : "not"}admin`)
.digest("hex");
const res = await this.request.post(url, {
data: {
@@ -165,7 +172,7 @@ export class Synapse implements Homeserver, HomeserverInstance {
username,
password,
mac,
- admin: false,
+ admin,
displayname: displayName,
},
});
@@ -185,6 +192,10 @@ export class Synapse implements Homeserver, HomeserverInstance {
};
}
+ public registerUser(username: string, password: string, displayName?: string): Promise {
+ return this.registerUserInternal(username, password, displayName, false);
+ }
+
public async loginUser(userId: string, password: string): Promise {
const url = `${this.config.baseUrl}/_matrix/client/v3/login`;
const res = await this.request.post(url, {
@@ -207,4 +218,30 @@ export class Synapse implements Homeserver, HomeserverInstance {
homeServer: json.home_server,
};
}
+
+ public async setThreepid(userId: string, medium: string, address: string): Promise {
+ if (this.adminToken === undefined) {
+ const result = await this.registerUserInternal("admin", "totalyinsecureadminpassword", undefined, true);
+ this.adminToken = result.accessToken;
+ }
+
+ const url = `${this.config.baseUrl}/_synapse/admin/v2/users/${userId}`;
+ const res = await this.request.put(url, {
+ data: {
+ threepids: [
+ {
+ medium,
+ address,
+ },
+ ],
+ },
+ headers: {
+ Authorization: `Bearer ${this.adminToken}`,
+ },
+ });
+
+ if (!res.ok()) {
+ throw await res.json();
+ }
+ }
}
diff --git a/playwright/plugins/homeserver/synapse/templates/guest-enabled/README.md b/playwright/plugins/homeserver/synapse/templates/guest-enabled/README.md
new file mode 100644
index 00000000000..e1fef0b9d41
--- /dev/null
+++ b/playwright/plugins/homeserver/synapse/templates/guest-enabled/README.md
@@ -0,0 +1 @@
+A synapse configured with guest registration enabled.
diff --git a/playwright/plugins/homeserver/synapse/templates/guest-enabled/homeserver.yaml b/playwright/plugins/homeserver/synapse/templates/guest-enabled/homeserver.yaml
new file mode 100644
index 00000000000..1faa39c3a74
--- /dev/null
+++ b/playwright/plugins/homeserver/synapse/templates/guest-enabled/homeserver.yaml
@@ -0,0 +1,105 @@
+server_name: "localhost"
+pid_file: /data/homeserver.pid
+public_baseurl: "{{PUBLIC_BASEURL}}"
+listeners:
+ - port: 8008
+ tls: false
+ bind_addresses: ["::"]
+ type: http
+ x_forwarded: true
+
+ resources:
+ - names: [client]
+ compress: false
+
+database:
+ name: "sqlite3"
+ args:
+ database: ":memory:"
+
+log_config: "/data/log.config"
+
+rc_messages_per_second: 10000
+rc_message_burst_count: 10000
+rc_registration:
+ per_second: 10000
+ burst_count: 10000
+rc_joins:
+ local:
+ per_second: 9999
+ burst_count: 9999
+ remote:
+ per_second: 9999
+ burst_count: 9999
+rc_joins_per_room:
+ per_second: 9999
+ burst_count: 9999
+rc_3pid_validation:
+ per_second: 1000
+ burst_count: 1000
+
+rc_invites:
+ per_room:
+ per_second: 1000
+ burst_count: 1000
+ per_user:
+ per_second: 1000
+ burst_count: 1000
+
+rc_login:
+ address:
+ per_second: 10000
+ burst_count: 10000
+ account:
+ per_second: 10000
+ burst_count: 10000
+ failed_attempts:
+ per_second: 10000
+ burst_count: 10000
+
+media_store_path: "/data/media_store"
+uploads_path: "/data/uploads"
+allow_guest_access: true
+enable_registration: true
+enable_registration_without_verification: true
+disable_msisdn_registration: false
+registration_shared_secret: "{{REGISTRATION_SECRET}}"
+report_stats: false
+macaroon_secret_key: "{{MACAROON_SECRET_KEY}}"
+form_secret: "{{FORM_SECRET}}"
+signing_key_path: "/data/localhost.signing.key"
+
+trusted_key_servers:
+ - server_name: "matrix.org"
+suppress_key_server_warning: true
+
+ui_auth:
+ session_timeout: "300s"
+
+oidc_providers:
+ - idp_id: test
+ idp_name: "OAuth test"
+ issuer: "http://localhost:{{OAUTH_SERVER_PORT}}/oauth"
+ authorization_endpoint: "http://localhost:{{OAUTH_SERVER_PORT}}/oauth/auth.html"
+ # the token endpoint receives requests from synapse, rather than the webapp, so needs to escape the docker container.
+ token_endpoint: "http://host.containers.internal:{{OAUTH_SERVER_PORT}}/oauth/token"
+ userinfo_endpoint: "http://host.containers.internal:{{OAUTH_SERVER_PORT}}/oauth/userinfo"
+ client_id: "synapse"
+ discover: false
+ scopes: ["profile"]
+ skip_verification: true
+ client_auth_method: none
+ user_mapping_provider:
+ config:
+ display_name_template: "{{ user.name }}"
+
+# Inhibit background updates as this Synapse isn't long-lived
+background_updates:
+ min_batch_size: 100000
+ sleep_duration_ms: 100000
+
+experimental_features:
+ # Needed for e2e/crypto/crypto.spec.ts > Cryptography > decryption failure
+ # messages > non-joined historical messages.
+ # Can be removed after Synapse enables it by default
+ msc4115_membership_on_events: true
diff --git a/playwright/plugins/homeserver/synapse/templates/guest-enabled/log.config b/playwright/plugins/homeserver/synapse/templates/guest-enabled/log.config
new file mode 100644
index 00000000000..ac232762da3
--- /dev/null
+++ b/playwright/plugins/homeserver/synapse/templates/guest-enabled/log.config
@@ -0,0 +1,50 @@
+# Log configuration for Synapse.
+#
+# This is a YAML file containing a standard Python logging configuration
+# dictionary. See [1] for details on the valid settings.
+#
+# Synapse also supports structured logging for machine readable logs which can
+# be ingested by ELK stacks. See [2] for details.
+#
+# [1]: https://docs.python.org/3.7/library/logging.config.html#configuration-dictionary-schema
+# [2]: https://matrix-org.github.io/synapse/latest/structured_logging.html
+
+version: 1
+
+formatters:
+ precise:
+ format: '%(asctime)s - %(name)s - %(lineno)d - %(levelname)s - %(request)s - %(message)s'
+
+handlers:
+ # A handler that writes logs to stderr. Unused by default, but can be used
+ # instead of "buffer" and "file" in the logger handlers.
+ console:
+ class: logging.StreamHandler
+ formatter: precise
+
+loggers:
+ synapse.storage.SQL:
+ # beware: increasing this to DEBUG will make synapse log sensitive
+ # information such as access tokens.
+ level: INFO
+
+ twisted:
+ # We send the twisted logging directly to the file handler,
+ # to work around https://github.com/matrix-org/synapse/issues/3471
+ # when using "buffer" logger. Use "console" to log to stderr instead.
+ handlers: [console]
+ propagate: false
+
+root:
+ level: INFO
+
+ # Write logs to the `buffer` handler, which will buffer them together in memory,
+ # then write them to a file.
+ #
+ # Replace "buffer" with "console" to log to stderr instead. (Note that you'll
+ # also need to update the configuration for the `twisted` logger above, in
+ # this case.)
+ #
+ handlers: [console]
+
+disable_existing_loggers: false
diff --git a/playwright/snapshots/app-loading/feature-detection.spec.ts/unsupported-browser-CompatibilityView-linux.png b/playwright/snapshots/app-loading/feature-detection.spec.ts/unsupported-browser-CompatibilityView-linux.png
new file mode 100644
index 00000000000..df1a44f523c
Binary files /dev/null and b/playwright/snapshots/app-loading/feature-detection.spec.ts/unsupported-browser-CompatibilityView-linux.png differ
diff --git a/playwright/snapshots/app-loading/feature-detection.spec.ts/unsupported-browser-linux.png b/playwright/snapshots/app-loading/feature-detection.spec.ts/unsupported-browser-linux.png
new file mode 100644
index 00000000000..96ad96e3e11
Binary files /dev/null and b/playwright/snapshots/app-loading/feature-detection.spec.ts/unsupported-browser-linux.png differ
diff --git a/playwright/snapshots/chat-export/html-export.spec.ts/html-export-linux.png b/playwright/snapshots/chat-export/html-export.spec.ts/html-export-linux.png
new file mode 100644
index 00000000000..ae11ec9eeca
Binary files /dev/null and b/playwright/snapshots/chat-export/html-export.spec.ts/html-export-linux.png differ
diff --git a/playwright/snapshots/crypto/crypto.spec.ts/RoomSummaryCard-with-verified-e2ee-linux.png b/playwright/snapshots/crypto/crypto.spec.ts/RoomSummaryCard-with-verified-e2ee-linux.png
index 98c1ff245d3..2c6160f2a19 100644
Binary files a/playwright/snapshots/crypto/crypto.spec.ts/RoomSummaryCard-with-verified-e2ee-linux.png and b/playwright/snapshots/crypto/crypto.spec.ts/RoomSummaryCard-with-verified-e2ee-linux.png differ
diff --git a/playwright/snapshots/editing/editing.spec.ts/message-edit-history-dialog-linux.png b/playwright/snapshots/editing/editing.spec.ts/message-edit-history-dialog-linux.png
index 9f46fce516b..bfbfccbaeba 100644
Binary files a/playwright/snapshots/editing/editing.spec.ts/message-edit-history-dialog-linux.png and b/playwright/snapshots/editing/editing.spec.ts/message-edit-history-dialog-linux.png differ
diff --git a/playwright/snapshots/file-upload/image-upload.spec.ts/image-upload-preview-linux.png b/playwright/snapshots/file-upload/image-upload.spec.ts/image-upload-preview-linux.png
index 75a9c353dee..c1880811897 100644
Binary files a/playwright/snapshots/file-upload/image-upload.spec.ts/image-upload-preview-linux.png and b/playwright/snapshots/file-upload/image-upload.spec.ts/image-upload-preview-linux.png differ
diff --git a/playwright/snapshots/forgot-password/forgot-password.spec.ts/forgot-password-linux.png b/playwright/snapshots/forgot-password/forgot-password.spec.ts/forgot-password-linux.png
new file mode 100644
index 00000000000..891f024bf82
Binary files /dev/null and b/playwright/snapshots/forgot-password/forgot-password.spec.ts/forgot-password-linux.png differ
diff --git a/playwright/snapshots/forgot-password/forgot-password.spec.ts/forgot-password-verify-email-linux.png b/playwright/snapshots/forgot-password/forgot-password.spec.ts/forgot-password-verify-email-linux.png
new file mode 100644
index 00000000000..22b4e109c87
Binary files /dev/null and b/playwright/snapshots/forgot-password/forgot-password.spec.ts/forgot-password-verify-email-linux.png differ
diff --git a/playwright/snapshots/invite/invite-dialog.spec.ts/invite-dialog-room-with-user-pill-linux.png b/playwright/snapshots/invite/invite-dialog.spec.ts/invite-dialog-room-with-user-pill-linux.png
index c8b8dba45b6..614533956be 100644
Binary files a/playwright/snapshots/invite/invite-dialog.spec.ts/invite-dialog-room-with-user-pill-linux.png and b/playwright/snapshots/invite/invite-dialog.spec.ts/invite-dialog-room-with-user-pill-linux.png differ
diff --git a/playwright/snapshots/invite/invite-dialog.spec.ts/invite-dialog-room-without-user-linux.png b/playwright/snapshots/invite/invite-dialog.spec.ts/invite-dialog-room-without-user-linux.png
index 852cb85518c..51f365f353f 100644
Binary files a/playwright/snapshots/invite/invite-dialog.spec.ts/invite-dialog-room-without-user-linux.png and b/playwright/snapshots/invite/invite-dialog.spec.ts/invite-dialog-room-without-user-linux.png differ
diff --git a/playwright/snapshots/permalinks/permalinks.spec.ts/permalink-rendering-linux.png b/playwright/snapshots/permalinks/permalinks.spec.ts/permalink-rendering-linux.png
index db91140763a..2214206caba 100644
Binary files a/playwright/snapshots/permalinks/permalinks.spec.ts/permalink-rendering-linux.png and b/playwright/snapshots/permalinks/permalinks.spec.ts/permalink-rendering-linux.png differ
diff --git a/playwright/snapshots/polls/polls.spec.ts/ThreadView-with-a-poll-on-bubble-layout-linux.png b/playwright/snapshots/polls/polls.spec.ts/ThreadView-with-a-poll-on-bubble-layout-linux.png
index c85c583a19a..9992923226d 100644
Binary files a/playwright/snapshots/polls/polls.spec.ts/ThreadView-with-a-poll-on-bubble-layout-linux.png and b/playwright/snapshots/polls/polls.spec.ts/ThreadView-with-a-poll-on-bubble-layout-linux.png differ
diff --git a/playwright/snapshots/polls/polls.spec.ts/ThreadView-with-a-poll-on-group-layout-linux.png b/playwright/snapshots/polls/polls.spec.ts/ThreadView-with-a-poll-on-group-layout-linux.png
index b6990e727ea..050a82a8af8 100644
Binary files a/playwright/snapshots/polls/polls.spec.ts/ThreadView-with-a-poll-on-group-layout-linux.png and b/playwright/snapshots/polls/polls.spec.ts/ThreadView-with-a-poll-on-group-layout-linux.png differ
diff --git a/playwright/snapshots/right-panel/file-panel.spec.ts/empty-linux.png b/playwright/snapshots/right-panel/file-panel.spec.ts/empty-linux.png
index e4f6313c97f..fdbec37b706 100644
Binary files a/playwright/snapshots/right-panel/file-panel.spec.ts/empty-linux.png and b/playwright/snapshots/right-panel/file-panel.spec.ts/empty-linux.png differ
diff --git a/playwright/snapshots/right-panel/notification-panel.spec.ts/empty-linux.png b/playwright/snapshots/right-panel/notification-panel.spec.ts/empty-linux.png
index 7d8884dc4d0..d18266534de 100644
Binary files a/playwright/snapshots/right-panel/notification-panel.spec.ts/empty-linux.png and b/playwright/snapshots/right-panel/notification-panel.spec.ts/empty-linux.png differ
diff --git a/playwright/snapshots/right-panel/right-panel.spec.ts/with-name-and-address-linux.png b/playwright/snapshots/right-panel/right-panel.spec.ts/with-name-and-address-linux.png
index f383a828e2c..943cc9dfc8b 100644
Binary files a/playwright/snapshots/right-panel/right-panel.spec.ts/with-name-and-address-linux.png and b/playwright/snapshots/right-panel/right-panel.spec.ts/with-name-and-address-linux.png differ
diff --git a/playwright/snapshots/room/room-header.spec.ts/encrypted-room-header-linux.png b/playwright/snapshots/room/room-header.spec.ts/encrypted-room-header-linux.png
deleted file mode 100644
index 6dced2e9909..00000000000
Binary files a/playwright/snapshots/room/room-header.spec.ts/encrypted-room-header-linux.png and /dev/null differ
diff --git a/playwright/snapshots/room/room-header.spec.ts/room-header-highlighted-linux.png b/playwright/snapshots/room/room-header.spec.ts/room-header-highlighted-linux.png
deleted file mode 100644
index c792c4bcf08..00000000000
Binary files a/playwright/snapshots/room/room-header.spec.ts/room-header-highlighted-linux.png and /dev/null differ
diff --git a/playwright/snapshots/room/room-header.spec.ts/room-header-linux.png b/playwright/snapshots/room/room-header.spec.ts/room-header-linux.png
index c5fd90be578..ea591089b43 100644
Binary files a/playwright/snapshots/room/room-header.spec.ts/room-header-linux.png and b/playwright/snapshots/room/room-header.spec.ts/room-header-linux.png differ
diff --git a/playwright/snapshots/room/room-header.spec.ts/room-header-long-name-linux.png b/playwright/snapshots/room/room-header.spec.ts/room-header-long-name-linux.png
index 0cfb88b5230..ded42af7a17 100644
Binary files a/playwright/snapshots/room/room-header.spec.ts/room-header-long-name-linux.png and b/playwright/snapshots/room/room-header.spec.ts/room-header-long-name-linux.png differ
diff --git a/playwright/snapshots/room/room-header.spec.ts/room-header-video-room-linux.png b/playwright/snapshots/room/room-header.spec.ts/room-header-video-room-linux.png
index 5d79ae740c7..7271a50cf9d 100644
Binary files a/playwright/snapshots/room/room-header.spec.ts/room-header-video-room-linux.png and b/playwright/snapshots/room/room-header.spec.ts/room-header-video-room-linux.png differ
diff --git a/playwright/snapshots/room/room-header.spec.ts/room-header-with-apps-button-highlighted-linux.png b/playwright/snapshots/room/room-header.spec.ts/room-header-with-apps-button-highlighted-linux.png
deleted file mode 100644
index 6016bb7e7a1..00000000000
Binary files a/playwright/snapshots/room/room-header.spec.ts/room-header-with-apps-button-highlighted-linux.png and /dev/null differ
diff --git a/playwright/snapshots/room/room-header.spec.ts/room-header-with-apps-button-not-highlighted-linux.png b/playwright/snapshots/room/room-header.spec.ts/room-header-with-apps-button-not-highlighted-linux.png
deleted file mode 100644
index 498853a9730..00000000000
Binary files a/playwright/snapshots/room/room-header.spec.ts/room-header-with-apps-button-not-highlighted-linux.png and /dev/null differ
diff --git a/playwright/snapshots/settings/appearance-user-settings-tab.spec.ts/appearance-tab-linux.png b/playwright/snapshots/settings/appearance-user-settings-tab.spec.ts/appearance-tab-linux.png
deleted file mode 100644
index b7fea971922..00000000000
Binary files a/playwright/snapshots/settings/appearance-user-settings-tab.spec.ts/appearance-tab-linux.png and /dev/null differ
diff --git a/playwright/snapshots/settings/appearance-user-settings-tab.spec.ts/font-slider-11-darwin.png b/playwright/snapshots/settings/appearance-user-settings-tab.spec.ts/font-slider-11-darwin.png
deleted file mode 100644
index 12996f4e5b8..00000000000
Binary files a/playwright/snapshots/settings/appearance-user-settings-tab.spec.ts/font-slider-11-darwin.png and /dev/null differ
diff --git a/playwright/snapshots/settings/appearance-user-settings-tab.spec.ts/font-slider-11-linux.png b/playwright/snapshots/settings/appearance-user-settings-tab.spec.ts/font-slider-11-linux.png
deleted file mode 100644
index f5231463488..00000000000
Binary files a/playwright/snapshots/settings/appearance-user-settings-tab.spec.ts/font-slider-11-linux.png and /dev/null differ
diff --git a/playwright/snapshots/settings/appearance-user-settings-tab.spec.ts/font-slider-21-darwin.png b/playwright/snapshots/settings/appearance-user-settings-tab.spec.ts/font-slider-21-darwin.png
deleted file mode 100644
index 6b1e058c6a1..00000000000
Binary files a/playwright/snapshots/settings/appearance-user-settings-tab.spec.ts/font-slider-21-darwin.png and /dev/null differ
diff --git a/playwright/snapshots/settings/appearance-user-settings-tab.spec.ts/font-slider-21-linux.png b/playwright/snapshots/settings/appearance-user-settings-tab.spec.ts/font-slider-21-linux.png
deleted file mode 100644
index 502e60cb1f8..00000000000
Binary files a/playwright/snapshots/settings/appearance-user-settings-tab.spec.ts/font-slider-21-linux.png and /dev/null differ
diff --git a/playwright/snapshots/settings/appearance-user-settings-tab.spec.ts/window-12px-linux.png b/playwright/snapshots/settings/appearance-user-settings-tab.spec.ts/window-12px-linux.png
deleted file mode 100644
index 3247abd7c1b..00000000000
Binary files a/playwright/snapshots/settings/appearance-user-settings-tab.spec.ts/window-12px-linux.png and /dev/null differ
diff --git a/playwright/snapshots/settings/appearance-user-settings-tab/appearance-user-settings-tab.spec.ts/appearance-tab-linux.png b/playwright/snapshots/settings/appearance-user-settings-tab/appearance-user-settings-tab.spec.ts/appearance-tab-linux.png
new file mode 100644
index 00000000000..12ea3aa847f
Binary files /dev/null and b/playwright/snapshots/settings/appearance-user-settings-tab/appearance-user-settings-tab.spec.ts/appearance-tab-linux.png differ
diff --git a/playwright/snapshots/settings/appearance-user-settings-tab/appearance-user-settings-tab.spec.ts/window-12px-linux.png b/playwright/snapshots/settings/appearance-user-settings-tab/appearance-user-settings-tab.spec.ts/window-12px-linux.png
new file mode 100644
index 00000000000..1ce0ffd520a
Binary files /dev/null and b/playwright/snapshots/settings/appearance-user-settings-tab/appearance-user-settings-tab.spec.ts/window-12px-linux.png differ
diff --git a/playwright/snapshots/settings/appearance-user-settings-tab/theme-choice-panel.spec.ts/theme-panel-custom-theme-added-linux.png b/playwright/snapshots/settings/appearance-user-settings-tab/theme-choice-panel.spec.ts/theme-panel-custom-theme-added-linux.png
new file mode 100644
index 00000000000..d44c1073072
Binary files /dev/null and b/playwright/snapshots/settings/appearance-user-settings-tab/theme-choice-panel.spec.ts/theme-panel-custom-theme-added-linux.png differ
diff --git a/playwright/snapshots/settings/appearance-user-settings-tab/theme-choice-panel.spec.ts/theme-panel-custom-theme-linux.png b/playwright/snapshots/settings/appearance-user-settings-tab/theme-choice-panel.spec.ts/theme-panel-custom-theme-linux.png
new file mode 100644
index 00000000000..76a0befd339
Binary files /dev/null and b/playwright/snapshots/settings/appearance-user-settings-tab/theme-choice-panel.spec.ts/theme-panel-custom-theme-linux.png differ
diff --git a/playwright/snapshots/settings/appearance-user-settings-tab/theme-choice-panel.spec.ts/theme-panel-dark-linux.png b/playwright/snapshots/settings/appearance-user-settings-tab/theme-choice-panel.spec.ts/theme-panel-dark-linux.png
new file mode 100644
index 00000000000..3b9c2431380
Binary files /dev/null and b/playwright/snapshots/settings/appearance-user-settings-tab/theme-choice-panel.spec.ts/theme-panel-dark-linux.png differ
diff --git a/playwright/snapshots/settings/appearance-user-settings-tab/theme-choice-panel.spec.ts/theme-panel-light-linux.png b/playwright/snapshots/settings/appearance-user-settings-tab/theme-choice-panel.spec.ts/theme-panel-light-linux.png
new file mode 100644
index 00000000000..ca909171166
Binary files /dev/null and b/playwright/snapshots/settings/appearance-user-settings-tab/theme-choice-panel.spec.ts/theme-panel-light-linux.png differ
diff --git a/playwright/snapshots/settings/appearance-user-settings-tab/theme-choice-panel.spec.ts/theme-panel-match-system-enabled-linux.png b/playwright/snapshots/settings/appearance-user-settings-tab/theme-choice-panel.spec.ts/theme-panel-match-system-enabled-linux.png
new file mode 100644
index 00000000000..1aed777c8d6
Binary files /dev/null and b/playwright/snapshots/settings/appearance-user-settings-tab/theme-choice-panel.spec.ts/theme-panel-match-system-enabled-linux.png differ
diff --git a/playwright/snapshots/settings/general-user-settings-tab.spec.ts/general-linux.png b/playwright/snapshots/settings/general-user-settings-tab.spec.ts/general-linux.png
index af35cc8bb4a..4cf8df1c326 100644
Binary files a/playwright/snapshots/settings/general-user-settings-tab.spec.ts/general-linux.png and b/playwright/snapshots/settings/general-user-settings-tab.spec.ts/general-linux.png differ
diff --git a/playwright/snapshots/settings/general-user-settings-tab.spec.ts/general-smallscreen-linux.png b/playwright/snapshots/settings/general-user-settings-tab.spec.ts/general-smallscreen-linux.png
index d59d2946da7..d0380e6a4fc 100644
Binary files a/playwright/snapshots/settings/general-user-settings-tab.spec.ts/general-smallscreen-linux.png and b/playwright/snapshots/settings/general-user-settings-tab.spec.ts/general-smallscreen-linux.png differ
diff --git a/playwright/snapshots/settings/preferences-user-settings-tab.spec.ts/Preferences-user-settings-tab-should-be-rendered-properly-1-linux.png b/playwright/snapshots/settings/preferences-user-settings-tab.spec.ts/Preferences-user-settings-tab-should-be-rendered-properly-1-linux.png
index e5d1ddef4f8..ca2e75dfbb8 100644
Binary files a/playwright/snapshots/settings/preferences-user-settings-tab.spec.ts/Preferences-user-settings-tab-should-be-rendered-properly-1-linux.png and b/playwright/snapshots/settings/preferences-user-settings-tab.spec.ts/Preferences-user-settings-tab-should-be-rendered-properly-1-linux.png differ
diff --git a/playwright/snapshots/threads/threads.spec.ts/Reply-to-the-location-on-ThreadView-linux.png b/playwright/snapshots/threads/threads.spec.ts/Reply-to-the-location-on-ThreadView-linux.png
index 281f1cebe5b..d30d8e98a8d 100644
Binary files a/playwright/snapshots/threads/threads.spec.ts/Reply-to-the-location-on-ThreadView-linux.png and b/playwright/snapshots/threads/threads.spec.ts/Reply-to-the-location-on-ThreadView-linux.png differ
diff --git a/playwright/snapshots/timeline/timeline.spec.ts/collapsed-gels-and-messages-irc-layout-linux.png b/playwright/snapshots/timeline/timeline.spec.ts/collapsed-gels-and-messages-irc-layout-linux.png
index 5703f384498..f8e94f92696 100644
Binary files a/playwright/snapshots/timeline/timeline.spec.ts/collapsed-gels-and-messages-irc-layout-linux.png and b/playwright/snapshots/timeline/timeline.spec.ts/collapsed-gels-and-messages-irc-layout-linux.png differ
diff --git a/playwright/snapshots/timeline/timeline.spec.ts/collapsed-gels-bubble-layout-linux.png b/playwright/snapshots/timeline/timeline.spec.ts/collapsed-gels-bubble-layout-linux.png
index 0a3d265a601..9d59bfa56fc 100644
Binary files a/playwright/snapshots/timeline/timeline.spec.ts/collapsed-gels-bubble-layout-linux.png and b/playwright/snapshots/timeline/timeline.spec.ts/collapsed-gels-bubble-layout-linux.png differ
diff --git a/playwright/snapshots/timeline/timeline.spec.ts/configured-room-irc-layout-linux.png b/playwright/snapshots/timeline/timeline.spec.ts/configured-room-irc-layout-linux.png
index 0453bf932a9..1c07ac36974 100644
Binary files a/playwright/snapshots/timeline/timeline.spec.ts/configured-room-irc-layout-linux.png and b/playwright/snapshots/timeline/timeline.spec.ts/configured-room-irc-layout-linux.png differ
diff --git a/playwright/snapshots/timeline/timeline.spec.ts/event-line-inline-start-margin-irc-layout-linux.png b/playwright/snapshots/timeline/timeline.spec.ts/event-line-inline-start-margin-irc-layout-linux.png
index 077ecbf7176..48e82f72af1 100644
Binary files a/playwright/snapshots/timeline/timeline.spec.ts/event-line-inline-start-margin-irc-layout-linux.png and b/playwright/snapshots/timeline/timeline.spec.ts/event-line-inline-start-margin-irc-layout-linux.png differ
diff --git a/playwright/snapshots/timeline/timeline.spec.ts/event-tiles-bubble-layout-linux.png b/playwright/snapshots/timeline/timeline.spec.ts/event-tiles-bubble-layout-linux.png
index 194ecd07fbc..9f510402e62 100644
Binary files a/playwright/snapshots/timeline/timeline.spec.ts/event-tiles-bubble-layout-linux.png and b/playwright/snapshots/timeline/timeline.spec.ts/event-tiles-bubble-layout-linux.png differ
diff --git a/playwright/snapshots/timeline/timeline.spec.ts/event-tiles-compact-modern-layout-linux.png b/playwright/snapshots/timeline/timeline.spec.ts/event-tiles-compact-modern-layout-linux.png
index 2b990bb3b86..0430d95ac5c 100644
Binary files a/playwright/snapshots/timeline/timeline.spec.ts/event-tiles-compact-modern-layout-linux.png and b/playwright/snapshots/timeline/timeline.spec.ts/event-tiles-compact-modern-layout-linux.png differ
diff --git a/playwright/snapshots/timeline/timeline.spec.ts/event-tiles-irc-layout-linux.png b/playwright/snapshots/timeline/timeline.spec.ts/event-tiles-irc-layout-linux.png
index 294cd3ec7ab..80666c5ccfd 100644
Binary files a/playwright/snapshots/timeline/timeline.spec.ts/event-tiles-irc-layout-linux.png and b/playwright/snapshots/timeline/timeline.spec.ts/event-tiles-irc-layout-linux.png differ
diff --git a/playwright/snapshots/timeline/timeline.spec.ts/event-tiles-modern-layout-linux.png b/playwright/snapshots/timeline/timeline.spec.ts/event-tiles-modern-layout-linux.png
index f0064c81e19..c08856b44e1 100644
Binary files a/playwright/snapshots/timeline/timeline.spec.ts/event-tiles-modern-layout-linux.png and b/playwright/snapshots/timeline/timeline.spec.ts/event-tiles-modern-layout-linux.png differ
diff --git a/playwright/snapshots/timeline/timeline.spec.ts/expanded-gels-and-messages-irc-layout-linux.png b/playwright/snapshots/timeline/timeline.spec.ts/expanded-gels-and-messages-irc-layout-linux.png
index 001ac64f2a6..eb5a0fca3bc 100644
Binary files a/playwright/snapshots/timeline/timeline.spec.ts/expanded-gels-and-messages-irc-layout-linux.png and b/playwright/snapshots/timeline/timeline.spec.ts/expanded-gels-and-messages-irc-layout-linux.png differ
diff --git a/playwright/snapshots/timeline/timeline.spec.ts/expanded-gels-bubble-layout-linux.png b/playwright/snapshots/timeline/timeline.spec.ts/expanded-gels-bubble-layout-linux.png
index f7a276d2f72..594ac521e0e 100644
Binary files a/playwright/snapshots/timeline/timeline.spec.ts/expanded-gels-bubble-layout-linux.png and b/playwright/snapshots/timeline/timeline.spec.ts/expanded-gels-bubble-layout-linux.png differ
diff --git a/playwright/snapshots/timeline/timeline.spec.ts/expanded-gels-emote-irc-layout-linux.png b/playwright/snapshots/timeline/timeline.spec.ts/expanded-gels-emote-irc-layout-linux.png
index de056e0fa56..1c83d343d0a 100644
Binary files a/playwright/snapshots/timeline/timeline.spec.ts/expanded-gels-emote-irc-layout-linux.png and b/playwright/snapshots/timeline/timeline.spec.ts/expanded-gels-emote-irc-layout-linux.png differ
diff --git a/playwright/snapshots/timeline/timeline.spec.ts/expanded-gels-irc-layout-linux.png b/playwright/snapshots/timeline/timeline.spec.ts/expanded-gels-irc-layout-linux.png
index 077ecbf7176..48e82f72af1 100644
Binary files a/playwright/snapshots/timeline/timeline.spec.ts/expanded-gels-irc-layout-linux.png and b/playwright/snapshots/timeline/timeline.spec.ts/expanded-gels-irc-layout-linux.png differ
diff --git a/playwright/snapshots/timeline/timeline.spec.ts/expanded-gels-modern-layout-linux.png b/playwright/snapshots/timeline/timeline.spec.ts/expanded-gels-modern-layout-linux.png
index e1cd5ab19c3..6ac01ff51be 100644
Binary files a/playwright/snapshots/timeline/timeline.spec.ts/expanded-gels-modern-layout-linux.png and b/playwright/snapshots/timeline/timeline.spec.ts/expanded-gels-modern-layout-linux.png differ
diff --git a/playwright/snapshots/timeline/timeline.spec.ts/expanded-gels-redaction-placeholder-linux.png b/playwright/snapshots/timeline/timeline.spec.ts/expanded-gels-redaction-placeholder-linux.png
index 4fb29024560..2149c46220c 100644
Binary files a/playwright/snapshots/timeline/timeline.spec.ts/expanded-gels-redaction-placeholder-linux.png and b/playwright/snapshots/timeline/timeline.spec.ts/expanded-gels-redaction-placeholder-linux.png differ
diff --git a/playwright/snapshots/timeline/timeline.spec.ts/hidden-event-line-padding-modern-layout-linux.png b/playwright/snapshots/timeline/timeline.spec.ts/hidden-event-line-padding-modern-layout-linux.png
deleted file mode 100644
index ceddab3312b..00000000000
Binary files a/playwright/snapshots/timeline/timeline.spec.ts/hidden-event-line-padding-modern-layout-linux.png and /dev/null differ
diff --git a/playwright/snapshots/timeline/timeline.spec.ts/hidden-event-line-zero-padding-irc-layout-linux.png b/playwright/snapshots/timeline/timeline.spec.ts/hidden-event-line-zero-padding-irc-layout-linux.png
deleted file mode 100644
index 5fba124a929..00000000000
Binary files a/playwright/snapshots/timeline/timeline.spec.ts/hidden-event-line-zero-padding-irc-layout-linux.png and /dev/null differ
diff --git a/playwright/snapshots/timeline/timeline.spec.ts/highlighted-search-results-linux.png b/playwright/snapshots/timeline/timeline.spec.ts/highlighted-search-results-linux.png
index ff75b3473fe..2685052da94 100644
Binary files a/playwright/snapshots/timeline/timeline.spec.ts/highlighted-search-results-linux.png and b/playwright/snapshots/timeline/timeline.spec.ts/highlighted-search-results-linux.png differ
diff --git a/playwright/snapshots/timeline/timeline.spec.ts/image-in-timeline-default-layout-linux.png b/playwright/snapshots/timeline/timeline.spec.ts/image-in-timeline-default-layout-linux.png
index dfc55550aae..1d05e321052 100644
Binary files a/playwright/snapshots/timeline/timeline.spec.ts/image-in-timeline-default-layout-linux.png and b/playwright/snapshots/timeline/timeline.spec.ts/image-in-timeline-default-layout-linux.png differ
diff --git a/playwright/snapshots/timeline/timeline.spec.ts/long-strings-with-reply-bubble-layout-linux.png b/playwright/snapshots/timeline/timeline.spec.ts/long-strings-with-reply-bubble-layout-linux.png
index 800ceefc6e4..88264291980 100644
Binary files a/playwright/snapshots/timeline/timeline.spec.ts/long-strings-with-reply-bubble-layout-linux.png and b/playwright/snapshots/timeline/timeline.spec.ts/long-strings-with-reply-bubble-layout-linux.png differ
diff --git a/playwright/snapshots/timeline/timeline.spec.ts/long-strings-with-reply-irc-layout-linux.png b/playwright/snapshots/timeline/timeline.spec.ts/long-strings-with-reply-irc-layout-linux.png
index 9d2fcdf272d..937bcb34170 100644
Binary files a/playwright/snapshots/timeline/timeline.spec.ts/long-strings-with-reply-irc-layout-linux.png and b/playwright/snapshots/timeline/timeline.spec.ts/long-strings-with-reply-irc-layout-linux.png differ
diff --git a/playwright/snapshots/timeline/timeline.spec.ts/long-strings-with-reply-modern-layout-linux.png b/playwright/snapshots/timeline/timeline.spec.ts/long-strings-with-reply-modern-layout-linux.png
index f85715b0765..a11b81693d0 100644
Binary files a/playwright/snapshots/timeline/timeline.spec.ts/long-strings-with-reply-modern-layout-linux.png and b/playwright/snapshots/timeline/timeline.spec.ts/long-strings-with-reply-modern-layout-linux.png differ
diff --git a/playwright/snapshots/timeline/timeline.spec.ts/search-aux-panel-linux.png b/playwright/snapshots/timeline/timeline.spec.ts/search-aux-panel-linux.png
new file mode 100644
index 00000000000..fe96a9e6be2
Binary files /dev/null and b/playwright/snapshots/timeline/timeline.spec.ts/search-aux-panel-linux.png differ
diff --git a/playwright/snapshots/timeline/timeline.spec.ts/search-bar-on-timeline-linux.png b/playwright/snapshots/timeline/timeline.spec.ts/search-bar-on-timeline-linux.png
deleted file mode 100644
index 64d44a9778b..00000000000
Binary files a/playwright/snapshots/timeline/timeline.spec.ts/search-bar-on-timeline-linux.png and /dev/null differ
diff --git a/playwright/snapshots/timeline/timeline.spec.ts/search-results-with-TextualEvent-linux.png b/playwright/snapshots/timeline/timeline.spec.ts/search-results-with-TextualEvent-linux.png
index 4c553cfdafb..b83fe895f73 100644
Binary files a/playwright/snapshots/timeline/timeline.spec.ts/search-results-with-TextualEvent-linux.png and b/playwright/snapshots/timeline/timeline.spec.ts/search-results-with-TextualEvent-linux.png differ
diff --git a/playwright/snapshots/user-view/user-view.spec.ts/UserView-should-render-the-user-view-as-expected-1-linux.png b/playwright/snapshots/user-view/user-view.spec.ts/UserView-should-render-the-user-view-as-expected-1-linux.png
deleted file mode 100644
index 75b64546d63..00000000000
Binary files a/playwright/snapshots/user-view/user-view.spec.ts/UserView-should-render-the-user-view-as-expected-1-linux.png and /dev/null differ
diff --git a/playwright/snapshots/user-view/user-view.spec.ts/user-info-linux.png b/playwright/snapshots/user-view/user-view.spec.ts/user-info-linux.png
index 61ab6601577..b6d6d2a2108 100644
Binary files a/playwright/snapshots/user-view/user-view.spec.ts/user-info-linux.png and b/playwright/snapshots/user-view/user-view.spec.ts/user-info-linux.png differ
diff --git a/playwright/stale-screenshot-reporter.ts b/playwright/stale-screenshot-reporter.ts
new file mode 100644
index 00000000000..3a3d18e28ca
--- /dev/null
+++ b/playwright/stale-screenshot-reporter.ts
@@ -0,0 +1,74 @@
+/*
+Copyright 2024 The Matrix.org Foundation C.I.C.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+/**
+ * Test reporter which compares the reported screenshots vs those on disk to find stale screenshots
+ * Only intended to run from within GitHub Actions
+ */
+
+import path from "node:path";
+import { glob } from "glob";
+
+import type { Reporter, TestCase } from "@playwright/test/reporter";
+
+const snapshotRoot = path.join(__dirname, "snapshots");
+
+class StaleScreenshotReporter implements Reporter {
+ private screenshots = new Set();
+ private success = true;
+
+ public onTestEnd(test: TestCase): void {
+ for (const annotation of test.annotations) {
+ if (annotation.type === "_screenshot") {
+ this.screenshots.add(annotation.description);
+ }
+ }
+ }
+
+ private error(msg: string, file: string) {
+ if (process.env.GITHUB_ACTIONS) {
+ console.log(`::error file=${file}::${msg}`);
+ }
+ console.error(msg, file);
+ this.success = false;
+ }
+
+ public async onExit(): Promise {
+ const screenshotFiles = new Set(await glob(`**/*.png`, { cwd: snapshotRoot }));
+ for (const screenshot of screenshotFiles) {
+ if (screenshot.split("-").at(-1) !== "linux.png") {
+ this.error(
+ "Found screenshot belonging to different platform, this should not be checked in",
+ screenshot,
+ );
+ }
+ }
+ for (const screenshot of this.screenshots) {
+ screenshotFiles.delete(screenshot);
+ }
+ if (screenshotFiles.size > 0) {
+ for (const screenshot of screenshotFiles) {
+ this.error("Stale screenshot file", screenshot);
+ }
+ }
+
+ if (!this.success) {
+ process.exit(1);
+ }
+ }
+}
+
+export default StaleScreenshotReporter;
diff --git a/res/css/_common.pcss b/res/css/_common.pcss
index d120194491b..8264ccb704c 100644
--- a/res/css/_common.pcss
+++ b/res/css/_common.pcss
@@ -177,9 +177,9 @@ a:visited {
color: $accent-alt;
}
-input[type="text"],
-input[type="search"],
-input[type="password"] {
+:not(.mx_no_textinput):not(.mx_textinput):not(.mx_Field) > input[type="text"],
+:not(.mx_no_textinput):not(.mx_textinput):not(.mx_Field) > input[type="search"],
+:not(.mx_no_textinput):not(.mx_textinput):not(.mx_Field) > input[type="password"] {
padding: 9px;
font: var(--cpd-font-body-md-semibold);
font-weight: var(--cpd-font-weight-semibold);
@@ -378,6 +378,7 @@ legend {
font-family: inherit !important;
white-space: normal !important;
line-height: inherit !important;
+ background-color: inherit;
color: inherit; /* inherit the colour from the dark or light theme by default (but not for code blocks) */
pre,
@@ -521,6 +522,8 @@ legend {
content: "";
width: 28px;
height: 28px;
+ left: 0;
+ top: 0;
position: absolute;
mask-image: url("@vector-im/compound-design-tokens/icons/close.svg");
mask-repeat: no-repeat;
@@ -603,7 +606,7 @@ legend {
.mx_Dialog
button:not(.mx_Dialog_nonDialogButton):not([class|="maplibregl"]):not(.mx_AccessibleButton):not(
.mx_UserProfileSettings button
- ),
+ ):not(.mx_ThemeChoicePanel_CustomTheme button),
.mx_Dialog input[type="submit"],
.mx_Dialog_buttons button:not(.mx_Dialog_nonDialogButton):not(.mx_AccessibleButton),
.mx_Dialog_buttons input[type="submit"] {
@@ -623,14 +626,14 @@ legend {
.mx_Dialog
button:not(.mx_Dialog_nonDialogButton):not([class|="maplibregl"]):not(.mx_AccessibleButton):not(
.mx_UserProfileSettings button
- ):last-child {
+ ):not(.mx_ThemeChoicePanel_CustomTheme button):last-child {
margin-right: 0px;
}
.mx_Dialog
button:not(.mx_Dialog_nonDialogButton):not([class|="maplibregl"]):not(.mx_AccessibleButton):not(
.mx_UserProfileSettings button
- ):focus,
+ ):not(.mx_ThemeChoicePanel_CustomTheme button):focus,
.mx_Dialog input[type="submit"]:focus,
.mx_Dialog_buttons button:not(.mx_Dialog_nonDialogButton):not(.mx_AccessibleButton):focus,
.mx_Dialog_buttons input[type="submit"]:focus {
@@ -642,7 +645,7 @@ legend {
.mx_Dialog_buttons
button.mx_Dialog_primary:not(.mx_Dialog_nonDialogButton):not(.mx_AccessibleButton):not(
.mx_UserProfileSettings button
- ),
+ ):not(.mx_ThemeChoicePanel_CustomTheme button),
.mx_Dialog_buttons input[type="submit"].mx_Dialog_primary {
color: var(--cpd-color-text-on-solid-primary);
background-color: var(--cpd-color-bg-action-primary-rest);
@@ -653,7 +656,9 @@ legend {
.mx_Dialog button.danger:not(.mx_Dialog_nonDialogButton):not([class|="maplibregl"]),
.mx_Dialog input[type="submit"].danger,
.mx_Dialog_buttons
- button.danger:not(.mx_Dialog_nonDialogButton):not(.mx_AccessibleButton):not(.mx_UserProfileSettings button),
+ button.danger:not(.mx_Dialog_nonDialogButton):not(.mx_AccessibleButton):not(.mx_UserProfileSettings button):not(
+ .mx_ThemeChoicePanel_CustomTheme button
+ ),
.mx_Dialog_buttons input[type="submit"].danger {
background-color: var(--cpd-color-bg-critical-primary);
border: solid 1px var(--cpd-color-bg-critical-primary);
@@ -669,7 +674,7 @@ legend {
.mx_Dialog
button:not(.mx_Dialog_nonDialogButton):not([class|="maplibregl"]):not(.mx_AccessibleButton):not(
.mx_UserProfileSettings button
- ):disabled,
+ ):not(.mx_ThemeChoicePanel_CustomTheme button):disabled,
.mx_Dialog input[type="submit"]:disabled,
.mx_Dialog_buttons button:not(.mx_Dialog_nonDialogButton):not(.mx_AccessibleButton):disabled,
.mx_Dialog_buttons input[type="submit"]:disabled {
@@ -846,7 +851,7 @@ legend {
@define-mixin ThreadSummaryIcon {
content: "";
display: inline-block;
- mask-image: url("$(res)/img/element-icons/thread-summary.svg");
+ mask-image: url("@vector-im/compound-design-tokens/icons/threads.svg");
mask-position: center;
mask-repeat: no-repeat;
mask-size: contain;
diff --git a/res/css/_components.pcss b/res/css/_components.pcss
index a7c79bfbf2e..20a6d2fe54b 100644
--- a/res/css/_components.pcss
+++ b/res/css/_components.pcss
@@ -73,7 +73,6 @@
@import "./structures/_MatrixChat.pcss";
@import "./structures/_MessagePanel.pcss";
@import "./structures/_NonUrgentToastContainer.pcss";
-@import "./structures/_NotificationPanel.pcss";
@import "./structures/_QuickSettingsButton.pcss";
@import "./structures/_RightPanel.pcss";
@import "./structures/_RoomSearch.pcss";
@@ -259,8 +258,10 @@
@import "./views/polls/pollHistory/_PollHistory.pcss";
@import "./views/polls/pollHistory/_PollHistoryList.pcss";
@import "./views/right_panel/_BaseCard.pcss";
+@import "./views/right_panel/_EmptyState.pcss";
@import "./views/right_panel/_EncryptionInfo.pcss";
@import "./views/right_panel/_PinnedMessagesCard.pcss";
+@import "./views/right_panel/_RightPanelTabs.pcss";
@import "./views/right_panel/_RoomSummaryCard.pcss";
@import "./views/right_panel/_ThreadPanel.pcss";
@import "./views/right_panel/_TimelineCard.pcss";
@@ -306,12 +307,11 @@
@import "./views/rooms/_RoomListHeader.pcss";
@import "./views/rooms/_RoomPreviewBar.pcss";
@import "./views/rooms/_RoomPreviewCard.pcss";
+@import "./views/rooms/_RoomSearchAuxPanel.pcss";
@import "./views/rooms/_RoomSublist.pcss";
@import "./views/rooms/_RoomTile.pcss";
@import "./views/rooms/_RoomUpgradeWarningBar.pcss";
-@import "./views/rooms/_SearchBar.pcss";
@import "./views/rooms/_SendMessageComposer.pcss";
-@import "./views/rooms/_SpaceScopeHeader.pcss";
@import "./views/rooms/_Stickers.pcss";
@import "./views/rooms/_ThirdPartyMemberInfo.pcss";
@import "./views/rooms/_ThreadSummary.pcss";
diff --git a/res/css/components/views/messages/_MBeaconBody.pcss b/res/css/components/views/messages/_MBeaconBody.pcss
index 82544addaef..1c5a725a0a4 100644
--- a/res/css/components/views/messages/_MBeaconBody.pcss
+++ b/res/css/components/views/messages/_MBeaconBody.pcss
@@ -74,7 +74,8 @@ limitations under the License.
position: absolute;
bottom: 0;
width: 100%;
- background-color: $overlay-background;
+ background-color: var(--cpd-color-bg-canvas-default);
+ opacity: 0.85;
}
.mx_EventTile[data-layout="bubble"] .mx_EventTile_line .mx_MBeaconBody {
diff --git a/res/css/components/views/settings/shared/_SettingsSubsection.pcss b/res/css/components/views/settings/shared/_SettingsSubsection.pcss
index 44d0a344264..f0e31285cbc 100644
--- a/res/css/components/views/settings/shared/_SettingsSubsection.pcss
+++ b/res/css/components/views/settings/shared/_SettingsSubsection.pcss
@@ -17,6 +17,12 @@ limitations under the License.
.mx_SettingsSubsection {
width: 100%;
box-sizing: border-box;
+
+ &.mx_SettingsSubsection_newUi {
+ display: flex;
+ flex-direction: column;
+ gap: var(--cpd-space-8x);
+ }
}
.mx_SettingsSubsection_description {
@@ -54,4 +60,8 @@ limitations under the License.
&.mx_SettingsSubsection_noHeading {
margin-top: 0;
}
+ &.mx_SettingsSubsection_content_newUi {
+ gap: var(--cpd-space-6x);
+ margin-top: 0;
+ }
}
diff --git a/res/css/structures/_FilePanel.pcss b/res/css/structures/_FilePanel.pcss
index 1c80cde9013..186893b24bd 100644
--- a/res/css/structures/_FilePanel.pcss
+++ b/res/css/structures/_FilePanel.pcss
@@ -102,7 +102,3 @@ limitations under the License.
padding-inline-start: 0;
}
}
-
-.mx_FilePanel_empty::before {
- --maskImage: url("$(res)/img/element-icons/room/files.svg"); /* See: _RightPanel.pcss */
-}
diff --git a/res/css/structures/_RightPanel.pcss b/res/css/structures/_RightPanel.pcss
index f8b5cb44085..d21c435b248 100644
--- a/res/css/structures/_RightPanel.pcss
+++ b/res/css/structures/_RightPanel.pcss
@@ -72,30 +72,3 @@ limitations under the License.
order: 2;
margin: auto;
}
-
-.mx_RightPanel_empty {
- margin-right: -28px;
-
- h2 {
- font-weight: 700;
- margin: 16px 0;
- }
-
- h2,
- p {
- font: var(--cpd-font-body-md-regular);
- }
-
- &::before {
- content: "";
- display: block;
- margin: 11px auto 29px auto;
- height: 42px;
- width: 42px;
- background-color: $header-panel-text-primary-color;
- mask-image: var(--maskImage);
- mask-repeat: no-repeat;
- mask-size: contain;
- mask-position: center;
- }
-}
diff --git a/res/css/views/dialogs/_UserSettingsDialog.pcss b/res/css/views/dialogs/_UserSettingsDialog.pcss
index 1e27bb4b6aa..6c09e90886b 100644
--- a/res/css/views/dialogs/_UserSettingsDialog.pcss
+++ b/res/css/views/dialogs/_UserSettingsDialog.pcss
@@ -22,6 +22,15 @@ limitations under the License.
justify-content: center;
}
+.mx_UserSettingsDialog_title {
+ /* Override default dialog font style */
+ font: var(--cpd-font-heading-md-regular) !important;
+
+ .mx_UserSettingsDialog_title_strong {
+ font: var(--cpd-font-heading-md-semibold);
+ }
+}
+
/* ICONS */
/* ========================================================== */
diff --git a/res/css/views/right_panel/_BaseCard.pcss b/res/css/views/right_panel/_BaseCard.pcss
index 67eb9b7e49c..692f7d23b39 100644
--- a/res/css/views/right_panel/_BaseCard.pcss
+++ b/res/css/views/right_panel/_BaseCard.pcss
@@ -27,7 +27,7 @@ limitations under the License.
.mx_BaseCard_header {
height: 64px;
- padding: var(--cpd-space-3x);
+ padding: var(--cpd-space-4x);
box-sizing: border-box;
/* changing the color from $separator to transparent as it is
the best visual output during the transition period. This will be
@@ -36,8 +36,13 @@ limitations under the License.
display: flex;
align-items: center;
justify-content: space-between;
- gap: var(--cpd-space-2x);
+ gap: var(--cpd-space-3x);
flex-shrink: 0;
+ border-block-end: var(--cpd-border-width-1) solid $separator;
+
+ .mx_BaseCard_header_spacer {
+ flex: 1;
+ }
> h2 {
margin: 0 44px;
@@ -155,52 +160,6 @@ limitations under the License.
}
}
-.mx_BaseCard_back,
-.mx_BaseCard_close {
- flex-shrink: 0;
- position: relative;
- /* @TODO(kerrya) background colours here are not semantic
- these buttons to be replaced with IconButton after secondary variant is added
- https://github.com/vector-im/compound/issues/279 */
- background-color: var(--cpd-color-bg-subtle-secondary);
- width: var(--BaseCard_header-button-size);
- height: var(--BaseCard_header-button-size);
- border-radius: 50%;
-
- &:hover {
- background-color: var(--cpd-color-bg-subtle-primary);
- }
-
- &::before {
- content: "";
- position: absolute;
- height: inherit;
- width: inherit;
- top: 0;
- left: 0;
- mask-repeat: no-repeat;
- mask-position: center;
- mask-size: 20px;
- background-color: var(--cpd-color-icon-secondary);
- }
-}
-
-.mx_BaseCard_back {
- order: 0; /* always first! */
- &::before {
- transform: rotate(90deg);
- mask-size: 22px;
- mask-image: url("$(res)/img/feather-customised/chevron-down.svg");
- }
-}
-
-.mx_BaseCard_close {
- order: 999; /* always last */
- &::before {
- mask-image: url("@vector-im/compound-design-tokens/icons/close.svg");
- }
-}
-
.mx_ContextualMenu_wrapper.mx_BaseCard_header_title {
.mx_ContextualMenu {
position: initial;
@@ -235,7 +194,3 @@ limitations under the License.
}
}
}
-
-.mx_BaseCard_headerProp {
- flex: 1 1 100%;
-}
diff --git a/res/css/views/right_panel/_EmptyState.pcss b/res/css/views/right_panel/_EmptyState.pcss
new file mode 100644
index 00000000000..4bf620bae33
--- /dev/null
+++ b/res/css/views/right_panel/_EmptyState.pcss
@@ -0,0 +1,45 @@
+/*
+Copyright 2024 The Matrix.org Foundation C.I.C.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+.mx_EmptyState {
+ height: 100%;
+ box-sizing: border-box;
+ padding: var(--cpd-space-4x);
+ text-align: center;
+
+ svg {
+ width: 56px;
+ height: 56px;
+ box-sizing: border-box;
+ border-radius: 8px;
+ padding: var(--cpd-space-3x);
+ background-color: $panel-actions;
+ }
+
+ &::before {
+ /* Bloom using magic numbers directly out of Figma */
+ content: "";
+ position: absolute;
+ z-index: -1;
+ width: 642px;
+ height: 775px;
+ right: -253.77px;
+ top: 0;
+ background: radial-gradient(49.95% 49.95% at 50% 50%, rgba(13, 189, 139, 0.12) 0%, rgba(18, 115, 235, 0) 100%);
+ transform: rotate(-89.69deg);
+ overflow: hidden;
+ }
+}
diff --git a/res/css/structures/_NotificationPanel.pcss b/res/css/views/right_panel/_RightPanelTabs.pcss
similarity index 75%
rename from res/css/structures/_NotificationPanel.pcss
rename to res/css/views/right_panel/_RightPanelTabs.pcss
index 7a3ede9e503..afaae6c6575 100644
--- a/res/css/structures/_NotificationPanel.pcss
+++ b/res/css/views/right_panel/_RightPanelTabs.pcss
@@ -1,5 +1,5 @@
/*
-Copyright 2015, 2016 OpenMarket Ltd
+Copyright 2024 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
@@ -14,6 +14,12 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
-.mx_NotificationPanel_empty::before {
- --maskImage: url("$(res)/img/element-icons/notifications.svg"); /* See: _RightPanel.pcss */
+.mx_RightPanelTabs {
+ margin: 0;
+ height: 64px;
+ box-sizing: border-box;
+
+ ul {
+ margin-left: 16px;
+ }
}
diff --git a/res/css/views/right_panel/_RoomSummaryCard.pcss b/res/css/views/right_panel/_RoomSummaryCard.pcss
index 4c3ff2f8886..549eb69ee43 100644
--- a/res/css/views/right_panel/_RoomSummaryCard.pcss
+++ b/res/css/views/right_panel/_RoomSummaryCard.pcss
@@ -235,28 +235,15 @@ limitations under the License.
}
.mx_RoomSummaryCard_header {
- padding: 15px 12px;
+ padding: 24px 12px 15px;
}
-.mx_RoomSummaryCard_search input {
- /* Overriding very broad CSS rules */
- border: 0 !important;
- margin: 0 !important;
- cursor: pointer;
-}
+.mx_RoomSummaryCard_search {
+ flex-grow: 1;
+ min-width: 0;
-.mx_RoomSummaryCard_searchBtn {
- background: var(--cpd-color-bg-canvas-default);
- color: var(--cpd-color-icon-primary);
- border: 1px solid var(--cpd-color-gray-400);
- border-radius: 50%;
- width: 36px;
- height: 36px;
- padding: var(--cpd-space-2x);
- cursor: pointer;
-
- &:hover {
- background: var(--cpd-color-bg-subtle-primary);
+ input[type="search"]::-webkit-search-cancel-button {
+ display: unset; /* override _common.pcss which inhibits this */
}
}
diff --git a/res/css/views/right_panel/_ThreadPanel.pcss b/res/css/views/right_panel/_ThreadPanel.pcss
index 104430c190e..fc1d39c9cae 100644
--- a/res/css/views/right_panel/_ThreadPanel.pcss
+++ b/res/css/views/right_panel/_ThreadPanel.pcss
@@ -18,12 +18,13 @@ limitations under the License.
height: 100px;
overflow: visible;
+ /* Unset flex on the thread list, but not the thread view */
+ &:not(.mx_ThreadView) .mx_BaseCard_header .mx_BaseCard_header_title {
+ flex: unset;
+ }
+
.mx_BaseCard_header {
.mx_BaseCard_header_title {
- .mx_BaseCard_header_title_heading {
- margin-right: auto;
- }
-
.mx_AccessibleButton {
font-size: 12px;
color: $secondary-content;
@@ -106,10 +107,17 @@ limitations under the License.
}
.mx_RoomView_messagePanel {
- /* To avoid the rule from being applied to .mx_ThreadPanel_empty */
+ &.mx_RoomView_messageListWrapper {
+ position: initial;
+ }
+
.mx_RoomView_messageListWrapper {
width: calc(100% + 6px); /* 8px - 2px */
}
+
+ .mx_RoomView_empty {
+ display: contents;
+ }
}
.mx_RoomView_MessageList {
@@ -168,72 +176,6 @@ limitations under the License.
mask-image: url("$(res)/img/element-icons/link.svg");
}
-.mx_ThreadPanel_empty {
- border-radius: 8px;
- background: $background;
- display: flex;
- flex-direction: column;
- align-items: center;
- justify-content: center;
- position: absolute;
- top: 0;
- bottom: 0;
- left: 0;
- padding: 20px;
- box-sizing: border-box; /* Include padding and border */
- width: 100%;
-
- h2 {
- color: $primary-content;
- font-weight: var(--cpd-font-weight-semibold);
- font-size: $font-18px;
- margin-top: 24px;
- margin-bottom: 10px;
- }
-
- p {
- font-size: $font-15px;
- color: $secondary-content;
- margin: 10px 0;
- }
-
- button {
- border: none;
- background: none;
- color: $accent;
- font-size: $font-15px;
-
- &:hover,
- &:active {
- text-decoration: underline;
- cursor: pointer;
- }
- }
-
- .mx_ThreadPanel_empty_tip {
- font-size: $font-12px;
- line-height: $font-15px;
-
- > b {
- font-weight: var(--cpd-font-weight-semibold);
- }
- }
-}
-
-.mx_ThreadPanel_largeIcon {
- width: 28px;
- height: 28px;
- padding: 18px;
- background: $system;
- border-radius: 50%;
-
- &::after {
- @mixin ThreadSummaryIcon;
- width: inherit;
- height: inherit;
- }
-}
-
.mx_ContextualMenu_wrapper {
.mx_ThreadPanel_Header_FilterOptionItem {
display: flex;
diff --git a/res/css/views/right_panel/_UserInfo.pcss b/res/css/views/right_panel/_UserInfo.pcss
index 1dfd39dd250..a2e156e0e58 100644
--- a/res/css/views/right_panel/_UserInfo.pcss
+++ b/res/css/views/right_panel/_UserInfo.pcss
@@ -41,35 +41,17 @@ limitations under the License.
}
}
- h2 {
- font-size: $font-18px;
- font-weight: var(--cpd-font-weight-semibold);
- margin: 18px 0 0 0;
- }
-
.mx_UserInfo_container {
- padding: $spacing-8 $spacing-16;
-
- &:not(.mx_UserInfo_separator) {
- padding-top: $spacing-16;
- padding-bottom: 0;
-
- > :not(h3) {
- margin-inline-start: $spacing-8;
- display: flex;
- flex-flow: column;
- align-items: flex-start;
- row-gap: $spacing-8;
- }
- }
+ padding: var(--cpd-space-4x) 0;
+ margin: 0 var(--cpd-space-4x);
.mx_UserInfo_container_verifyButton {
margin-top: $spacing-8;
}
- }
- .mx_UserInfo_separator {
- border-bottom: 1px solid $separator;
+ & + .mx_UserInfo_container {
+ border-top: 1px solid $separator;
+ }
}
.mx_UserInfo_memberDetailsContainer {
@@ -94,7 +76,7 @@ limitations under the License.
margin: $spacing-24 $spacing-32 0 $spacing-32;
.mx_UserInfo_avatar_transition {
- max-width: 30vh;
+ max-width: 120px;
aspect-ratio: 1 / 1;
margin: 0 auto;
transition: 0.5s;
@@ -112,7 +94,7 @@ limitations under the License.
}
}
- h3 {
+ h2 {
text-transform: uppercase;
color: $tertiary-content;
font: var(--cpd-font-heading-sm-semibold);
@@ -125,41 +107,36 @@ limitations under the License.
}
.mx_UserInfo_profile {
- text-align: center;
-
- h2 {
- display: flex;
- font-size: $font-17px;
+ h1 {
+ font-size: $font-20px;
line-height: $font-25px;
- flex: 1;
- justify-content: center;
- /* We reverse things here so for accessible technologies the name comes before the e2e shield */
- flex-direction: row-reverse;
-
- span {
- /* limit to 2 lines, show an ellipsis if it overflows */
- /* this looks webkit specific but is supported by Firefox 68+ */
- display: -webkit-box;
- -webkit-box-orient: vertical;
- -webkit-line-clamp: 2;
-
- overflow: hidden;
- word-break: break-all;
- text-overflow: ellipsis;
- }
- .mx_E2EIcon {
- margin-top: 3px; /* visual vertical centering to the top line of text. */
- margin-inline-end: $spacing-4; /* margin from displayName */
- min-width: 18px; /* convince flexbox to not collapse it */
+ /* limit to 2 lines, show an ellipsis if it overflows */
+ /* this looks webkit specific but is supported by Firefox 68+ */
+ display: -webkit-box;
+ -webkit-box-orient: vertical;
+ -webkit-line-clamp: 2;
+
+ overflow: hidden;
+ word-break: break-all;
+ text-overflow: ellipsis;
+
+ /* E2E icon wrapper */
+ .mx_Flex > span {
+ display: inline-block;
}
}
.mx_UserInfo_profileStatus {
- margin-top: $spacing-12;
+ margin: var(--cpd-space-1x) 0;
}
}
+ .mx_PresenceLabel {
+ font: var(--cpd-font-body-sm-regular);
+ opacity: 1;
+ }
+
.mx_UserInfo_memberDetails {
.mx_UserInfo_profileField {
display: flex;
@@ -184,10 +161,6 @@ limitations under the License.
.mx_UserInfo_field {
line-height: $font-16px;
-
- &.mx_UserInfo_destructive {
- color: $alert;
- }
}
.mx_UserInfo_statusMessage {
diff --git a/res/css/views/rooms/_EventTile.pcss b/res/css/views/rooms/_EventTile.pcss
index ad88a7c7863..66c60f5f155 100644
--- a/res/css/views/rooms/_EventTile.pcss
+++ b/res/css/views/rooms/_EventTile.pcss
@@ -689,6 +689,7 @@ $left-gutter: 64px;
font-family: inherit !important;
white-space: normal !important;
line-height: inherit !important;
+ background-color: inherit;
color: inherit; /* inherit the colour from the dark or light theme by default (but not for code blocks) */
pre,
@@ -701,7 +702,7 @@ $left-gutter: 64px;
background-color: $inlinecode-background-color;
border: 1px solid $inlinecode-border-color;
border-radius: 4px;
- /* The horizontal padding is added by gfm.css .markdown-body */
+ /* The horizontal padding is added by github-markdown-css .markdown-body */
padding: $spacing-2 0;
/* Avoid inline code blocks to be sticked when on multiple lines */
line-height: $font-22px;
@@ -1023,7 +1024,7 @@ $left-gutter: 64px;
$notification-dot-size: 8px; /* notification dot next to the timestamp */
margin: calc(var(--topOffset) + $hrHeight) 0 var(--topOffset); /* include the height of horizontal line */
- padding: $padding $spacing-24 $padding $padding;
+ padding: $padding;
border-radius: $borderRadius;
display: flex;
@@ -1038,7 +1039,7 @@ $left-gutter: 64px;
&::after {
$inset-block-start: auto;
- $inset-inline-end: calc(32px - $padding);
+ $inset-inline-end: calc(-1 * var(--cpd-space-2x));
$inset-block-end: calc(-1 * var(--topOffset) - $hrHeight); /* exclude the height of horizontal line */
$inset-inline-start: calc(var(--leftOffset) + $padding);
inset: $inset-block-start $inset-inline-end $inset-block-end $inset-inline-start;
diff --git a/res/css/views/rooms/_MemberList.pcss b/res/css/views/rooms/_MemberList.pcss
index 086a60810fd..6e2e5a43a4d 100644
--- a/res/css/views/rooms/_MemberList.pcss
+++ b/res/css/views/rooms/_MemberList.pcss
@@ -19,6 +19,7 @@ limitations under the License.
display: flex;
flex-direction: column;
min-height: 0;
+ margin-top: 24px;
.mx_Spinner {
flex: 1 0 auto;
diff --git a/res/css/views/rooms/_MessageComposer.pcss b/res/css/views/rooms/_MessageComposer.pcss
index 2c8fe592c42..d88350e946f 100644
--- a/res/css/views/rooms/_MessageComposer.pcss
+++ b/res/css/views/rooms/_MessageComposer.pcss
@@ -163,7 +163,7 @@ limitations under the License.
word-break: break-word;
/* FIXME: rather unpleasant hack to get rid of margins. */
- /* really we should be mixing in markdown-body from gfm.css instead */
+ /* really we should be mixing in markdown-body from github-markdown-css instead */
> :first-child {
margin-top: 0 !important;
}
diff --git a/res/css/views/rooms/_PresenceLabel.pcss b/res/css/views/rooms/_PresenceLabel.pcss
index 5be83c77d7c..e775fb08ea8 100644
--- a/res/css/views/rooms/_PresenceLabel.pcss
+++ b/res/css/views/rooms/_PresenceLabel.pcss
@@ -18,3 +18,7 @@ limitations under the License.
font-size: $font-11px;
opacity: 0.5;
}
+
+.mx_PresenceLabel_online {
+ color: var(--cpd-color-text-success-primary);
+}
diff --git a/res/css/views/rooms/_RoomHeader.pcss b/res/css/views/rooms/_RoomHeader.pcss
index ca1f8ec48ac..5409c050bb8 100644
--- a/res/css/views/rooms/_RoomHeader.pcss
+++ b/res/css/views/rooms/_RoomHeader.pcss
@@ -20,7 +20,7 @@ limitations under the License.
padding: 0 var(--cpd-space-3x);
border-bottom: 1px solid $separator;
background-color: $background;
- transition: all 0.3s ease;
+ transition: all 0.2s ease;
}
.mx_RoomHeader:hover {
@@ -74,14 +74,17 @@ limitations under the License.
}
}
-.mx_RoomHeader:hover .mx_RoomHeader_topic {
- /* height needed to compute the transition, it equals to the `line-height`
+.mx_RoomHeader:hover,
+.mx_RoomHeader:focus-within {
+ .mx_RoomHeader_topic {
+ /* height needed to compute the transition, it equals to the `line-height`
value in pixels */
- height: calc($font-13px * 1.5);
- opacity: 1;
+ height: calc($font-13px * 1.5);
+ opacity: 1;
- a:hover {
- text-decoration: underline;
+ a:hover {
+ text-decoration: underline;
+ }
}
}
diff --git a/res/css/views/rooms/_RoomSearchAuxPanel.pcss b/res/css/views/rooms/_RoomSearchAuxPanel.pcss
new file mode 100644
index 00000000000..a47616a6857
--- /dev/null
+++ b/res/css/views/rooms/_RoomSearchAuxPanel.pcss
@@ -0,0 +1,72 @@
+/*
+Copyright 2024 The Matrix.org Foundation C.I.C.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+.mx_RoomSearchAuxPanel {
+ /* use `min-height` rather than height, to allow room for the text to wrap if the window is narrow */
+ min-height: 84px;
+ display: flex;
+ align-items: center;
+ border-color: var(--cpd-color-bg-canvas-default);
+ border-style: solid;
+ border-width: 1px 0;
+ padding: var(--cpd-space-3x);
+ box-sizing: border-box;
+ gap: var(--cpd-space-2x);
+
+ .mx_RoomSearchAuxPanel_summary {
+ flex-grow: 1;
+ display: inherit; /* flex */
+ gap: var(--cpd-space-2x);
+ align-items: center;
+ overflow: hidden;
+
+ > svg {
+ padding: var(--cpd-space-2x);
+ border-radius: var(--cpd-space-2x);
+ background-color: var(--cpd-color-bg-subtle-secondary);
+ color: var(--cpd-color-icon-secondary);
+ flex-shrink: 0;
+ }
+
+ .mx_RoomSearchAuxPanel_summary_text {
+ display: flex;
+ flex-direction: column;
+ font-size: $font-15px;
+ line-height: $font-22px;
+ overflow: hidden;
+
+ span {
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ }
+ }
+
+ .mx_SearchWarning {
+ display: contents;
+ font-size: $font-13px;
+ line-height: $font-20px;
+ color: var(--cpd-color-text-secondary);
+ }
+ }
+
+ .mx_RoomSearchAuxPanel_buttons {
+ display: inherit; /* flex */
+ gap: var(--cpd-space-6x);
+ align-items: center;
+ flex-shrink: 0;
+ }
+}
diff --git a/res/css/views/rooms/_SearchBar.pcss b/res/css/views/rooms/_SearchBar.pcss
deleted file mode 100644
index ca999c7beaf..00000000000
--- a/res/css/views/rooms/_SearchBar.pcss
+++ /dev/null
@@ -1,83 +0,0 @@
-/*
-Copyright 2015, 2016 OpenMarket Ltd
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
- http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
-*/
-
-.mx_SearchBar {
- /* use `min-height` rather than height, to allow room for the text to wrap if the window is narrow */
- min-height: 56px;
- display: flex;
- align-items: center;
- border-bottom: 1px solid $primary-hairline-color;
-
- .mx_SearchBar_input {
- --size-button-search: 37px; /* size of the search button inside `input` element */
-
- /* border: 1px solid $input-border-color; */
- /* font-size: $font-15px; */
- flex: 1 1 0;
- margin-left: 22px;
-
- /* do not allow the input element to shrink below the width needed for the placeholder 'Search…'
- and the search button */
- min-width: calc(7em + var(--size-button-search));
-
- input {
- box-sizing: border-box; /* include padding value into width calculation */
- }
- }
-
- .mx_SearchBar_searchButton {
- cursor: pointer;
- width: var(--size-button-search);
- height: var(--size-button-search);
- background-color: $accent;
- mask: url("$(res)/img/feather-customised/search-input.svg");
- mask-repeat: no-repeat;
- mask-position: center;
- }
-
- .mx_SearchBar_buttons {
- display: inherit; /* flex */
- min-width: 0; /* have the close button displayed even on a very narrow timeline */
- }
-
- .mx_SearchBar_button {
- border: 0;
- margin: 0 0 0 22px;
- padding: 5px;
- font-size: $font-15px;
- cursor: pointer;
- color: $primary-content;
- border-bottom: 2px solid $accent;
- font-weight: var(--cpd-font-weight-semibold);
- word-break: break-all; /* prevent the input area and cancel button from being overlapped by BaseCard */
- }
-
- .mx_SearchBar_unselected {
- color: $input-darker-fg-color;
- border-color: transparent;
- }
-
- .mx_SearchBar_cancel {
- background-color: $alert;
- mask: url("$(res)/img/cancel.svg");
- mask-repeat: no-repeat;
- mask-position: center;
- mask-size: 14px;
- padding: 9px;
- margin: 0 12px 0 3px;
- cursor: pointer;
- }
-}
diff --git a/res/css/views/settings/_LayoutSwitcher.pcss b/res/css/views/settings/_LayoutSwitcher.pcss
index 571b9a1cf1c..96fe27b420c 100644
--- a/res/css/views/settings/_LayoutSwitcher.pcss
+++ b/res/css/views/settings/_LayoutSwitcher.pcss
@@ -15,79 +15,80 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
-.mx_LayoutSwitcher_RadioButtons {
+.mx_LayoutSwitcher_LayoutSelector {
display: flex;
- flex-direction: row;
- gap: 24px;
- width: 100%;
-
- color: $primary-content;
-
- > .mx_LayoutSwitcher_RadioButton {
- flex-grow: 0;
- flex-shrink: 1;
- display: flex;
- flex-direction: column;
- overflow: hidden;
-
- flex-basis: 33%;
- min-width: 0;
-
- border: 1px solid $quinary-content;
- border-radius: 10px;
-
- .mx_EventTile_msgOption,
- .mx_MessageActionBar {
- display: none;
- }
-
- .mx_LayoutSwitcher_RadioButton_preview {
- flex-grow: 1;
+ flex-direction: column;
+ /**
+ * The settings form has a default gap of 10px
+ * We want to have a bigger gap between the layout options
+ */
+ gap: var(--cpd-space-4x) !important;
+
+ .mxLayoutSwitcher_LayoutSelector_LayoutRadio {
+ border: 1px solid var(--cpd-color-border-interactive-primary);
+ border-radius: var(--cpd-space-2x);
+
+ .mxLayoutSwitcher_LayoutSelector_LayoutRadio_inline {
display: flex;
+ /*
+ * 10px
+ */
+ gap: calc(var(--cpd-space-2x) + var(--cpd-space-0-5x));
align-items: center;
- padding: 10px;
- pointer-events: none;
-
- .mx_EventTile[data-layout="bubble"] .mx_EventTile_line {
- padding-right: 11px;
- }
}
- .mx_StyledRadioButton {
- flex-grow: 0;
- padding: 10px;
+ .mxLayoutSwitcher_LayoutSelector_LayoutRadio_inline,
+ .mxLayoutSwitcher_LayoutSelector_LayoutRadio_EventTilePreview {
+ margin: var(--cpd-space-3x);
}
- .mx_EventTile_content {
- margin-right: 0;
- }
-
- &.mx_LayoutSwitcher_RadioButton_selected {
- border-color: var(--cpd-color-bg-accent-rest);
- }
- }
-
- .mx_StyledRadioButton {
- border-top: 1px solid $quinary-content;
- }
-
- .mx_StyledRadioButton_checked {
- background-color: var(--cpd-color-bg-subtle-secondary);
- }
+ /**
+ * Override the event tile style to make it fit in the selector
+ * Tweak also hover style and remove action bar
+ */
+ .mxLayoutSwitcher_LayoutSelector_LayoutRadio_EventTilePreview {
+ pointer-events: none;
- .mx_EventTile {
- margin: 0;
- &[data-layout="bubble"] {
- margin-right: 40px;
- flex-shrink: 1;
- }
- &[data-layout="irc"] {
- > a {
- display: none;
+ .mx_EventTile {
+ margin: 0;
+
+ /**
+ * Hide the message options and message action bar in the preview
+ */
+ .mx_EventTile_msgOption,
+ .mx_MessageActionBar {
+ display: none;
+ }
+
+ .mx_EventTile_content {
+ margin-right: 0;
+ }
+
+ &[data-layout="group"] {
+ margin-top: calc(var(--cpd-space-3x) * -1);
+ }
+
+ /**
+ * Add margin to center the bubble
+ */
+ &[data-layout="bubble"] {
+ /**
+ * Add the layout margin and the margin to vertically center the bubble
+ */
+ margin-top: var(--cpd-space-6x);
+ margin-right: 34px;
+ flex-shrink: 1;
+ }
+
+ .mx_EventTile_line {
+ max-width: 100%;
+ }
}
}
- .mx_EventTile_line {
- max-width: 90%;
+
+ .mxLayoutSwitcher_LayoutSelector_LayoutRadio_separator {
+ border-top: 0;
+ border-bottom: 1px solid var(--cpd-color-border-interactive-secondary);
}
}
}
diff --git a/res/css/views/settings/_ThemeChoicePanel.pcss b/res/css/views/settings/_ThemeChoicePanel.pcss
index 8616668224d..f70cdf92e3c 100644
--- a/res/css/views/settings/_ThemeChoicePanel.pcss
+++ b/res/css/views/settings/_ThemeChoicePanel.pcss
@@ -14,48 +14,72 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
-.mx_ThemeChoicePanel_themeSelectors {
- color: $primary-content;
+.mx_ThemeChoicePanel_ThemeSelectors {
display: flex;
- flex-direction: row;
flex-wrap: wrap;
+ /* Override form default style */
+ flex-direction: row !important;
+ gap: var(--cpd-space-4x) !important;
- > .mx_StyledRadioButton {
- align-items: center;
- padding: $font-16px;
- box-sizing: border-box;
- border-radius: 10px;
- width: 180px;
+ .mx_ThemeChoicePanel_themeSelector {
+ border: 1px solid var(--cpd-color-border-interactive-secondary);
+ border-radius: var(--cpd-space-1-5x);
+ padding: var(--cpd-space-3x) var(--cpd-space-5x) var(--cpd-space-3x) var(--cpd-space-3x);
+ gap: var(--cpd-space-2x);
+ background-color: var(--cpd-color-bg-canvas-default);
- background: $accent-200;
- opacity: 0.4;
+ &.mx_ThemeChoicePanel_themeSelector_enabled {
+ border-color: var(--cpd-color-border-interactive-primary);
+ }
- flex-shrink: 1;
- flex-grow: 0;
+ &.mx_ThemeChoicePanel_themeSelector_disabled {
+ border-color: var(--cpd-color-border-disabled);
+ }
- margin-right: 15px;
- margin-top: 10px;
+ .mx_ThemeChoicePanel_themeSelector_Label {
+ color: var(--cpd-color-text-primary);
+ font: var(--cpd-font-body-md-semibold);
+ }
+ }
+}
- font-weight: var(--cpd-font-weight-semibold);
+.mx_ThemeChoicePanel_CustomTheme {
+ width: 100%;
+ display: flex;
+ flex-direction: column;
+ gap: var(--cpd-space-4x);
- > span {
- justify-content: center;
- }
+ .mx_ThemeChoicePanel_CustomTheme_EditInPlace input:focus {
+ /*
+ * When the input is focused, the border is growing
+ * We need to move it a bit to avoid the left border to be under the left panel
+ */
+ margin-left: var(--cpd-space-0-5x);
}
- > .mx_StyledRadioButton_enabled {
- opacity: 1;
+ .mx_ThemeChoicePanel_CustomThemeList {
+ display: flex;
+ flex-direction: column;
+ gap: var(--cpd-space-4x);
+ /*
+ * Override the default padding/margin of the list
+ */
+ padding: 0;
+ margin: 0;
- /* These colors need to be hardcoded because they don't change with the theme */
- &.mx_ThemeSelector_light {
- background-color: #f3f8fd;
- color: #2e2f32;
- }
+ .mx_ThemeChoicePanel_CustomThemeList_theme {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ background-color: var(--cpd-color-gray-200);
+ padding: var(--cpd-space-2x) var(--cpd-space-2x) var(--cpd-space-2x) var(--cpd-space-4x);
- &.mx_ThemeSelector_dark {
- /* 5% lightened version of 181b21 */
- background-color: #25282e;
- color: #f3f8fd;
+ .mx_ThemeChoicePanel_CustomThemeList_name {
+ font: var(--cpd-font-body-sm-semibold);
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ }
}
}
}
diff --git a/res/css/views/settings/_UserProfileSettings.pcss b/res/css/views/settings/_UserProfileSettings.pcss
index 3a9dc7dcc74..31c1ef628f9 100644
--- a/res/css/views/settings/_UserProfileSettings.pcss
+++ b/res/css/views/settings/_UserProfileSettings.pcss
@@ -21,12 +21,11 @@ limitations under the License.
display: flex;
margin-top: var(--cpd-space-6x);
gap: 16px;
- /* This is temporary until the 'Remove' link is replaced by a context menu. */
- margin-bottom: 20px;
.mx_UserProfileSettings_profile_displayName {
flex-grow: 1;
width: 100%;
+ gap: 0;
}
}
@@ -36,6 +35,7 @@ limitations under the License.
.mx_UserProfileSettings_profile_controls_userId {
width: 100%;
+ margin-top: var(--cpd-space-4x);
.mx_CopyableText {
margin-top: var(--cpd-space-1x);
width: 100%;
@@ -47,6 +47,15 @@ limitations under the License.
font-size: 15px;
font-weight: 500;
}
+
+ .mx_UserProfileSettings_profile_buttons {
+ margin-top: var(--cpd-space-8x);
+ margin-bottom: var(--cpd-space-8x);
+ }
+
+ .mx_UserProfileSettings_accountmanageIcon {
+ margin-right: var(--cpd-space-2x);
+ }
}
@media (max-width: 768px) {
diff --git a/res/css/views/settings/tabs/user/_GeneralUserSettingsTab.pcss b/res/css/views/settings/tabs/user/_GeneralUserSettingsTab.pcss
index 76c5834fa83..a59f64b3911 100644
--- a/res/css/views/settings/tabs/user/_GeneralUserSettingsTab.pcss
+++ b/res/css/views/settings/tabs/user/_GeneralUserSettingsTab.pcss
@@ -34,3 +34,8 @@ limitations under the License.
margin-right: $spacing-8;
margin-bottom: 2px;
}
+
+.mx_GeneralUserSettingsTab_section_hint {
+ font: var(--cpd-font-body-sm-regular);
+ color: var(--cpd-color-text-secondary);
+}
diff --git a/res/css/views/terms/_InlineTermsAgreement.pcss b/res/css/views/terms/_InlineTermsAgreement.pcss
index d7732b2a0dd..162d1341e4d 100644
--- a/res/css/views/terms/_InlineTermsAgreement.pcss
+++ b/res/css/views/terms/_InlineTermsAgreement.pcss
@@ -15,6 +15,7 @@ limitations under the License.
*/
.mx_InlineTermsAgreement_cbContainer {
+ margin-top: var(--cpd-space-4x);
margin-bottom: 10px;
font: var(--cpd-font-body-md-regular);
diff --git a/res/img/element-icons/thread-summary.svg b/res/img/element-icons/thread-summary.svg
deleted file mode 100644
index 2c4f0ead0cf..00000000000
--- a/res/img/element-icons/thread-summary.svg
+++ /dev/null
@@ -1 +0,0 @@
-
\ No newline at end of file
diff --git a/res/themes/dark-custom/css/dark-custom.pcss b/res/themes/dark-custom/css/dark-custom.pcss
index 3acf914e69d..166d9cb2921 100644
--- a/res/themes/dark-custom/css/dark-custom.pcss
+++ b/res/themes/dark-custom/css/dark-custom.pcss
@@ -5,3 +5,4 @@
@import "../../legacy-dark/css/_legacy-dark.pcss";
@import "../../light-custom/css/_custom.pcss";
@import "../../../../res/css/_components.pcss";
+@import url("github-markdown-css/github-markdown-dark.css");
diff --git a/res/themes/dark/css/_dark.pcss b/res/themes/dark/css/_dark.pcss
index 326debc0628..d398fb59677 100644
--- a/res/themes/dark/css/_dark.pcss
+++ b/res/themes/dark/css/_dark.pcss
@@ -14,7 +14,7 @@ $overlay-background: var(--cpd-color-alpha-gray-1300);
$panels: var(--cpd-color-bg-subtle-secondary);
$panel-actions: var(--cpd-color-alpha-gray-300);
-$separator: var(--cpd-color-alpha-gray-400);
+$separator: var(--cpd-color-gray-400);
/* ******************** */
diff --git a/res/themes/dark/css/dark.pcss b/res/themes/dark/css/dark.pcss
index d71f544fe21..51262598bea 100644
--- a/res/themes/dark/css/dark.pcss
+++ b/res/themes/dark/css/dark.pcss
@@ -6,3 +6,4 @@
@import "../../light/css/_mods.pcss";
@import "../../../../res/css/_components.pcss";
@import url("highlight.js/styles/atom-one-dark.min.css");
+@import url("github-markdown-css/github-markdown-dark.css");
diff --git a/res/themes/legacy-dark/css/_legacy-dark.pcss b/res/themes/legacy-dark/css/_legacy-dark.pcss
index 7e14e85f10a..c6840f5b907 100644
--- a/res/themes/legacy-dark/css/_legacy-dark.pcss
+++ b/res/themes/legacy-dark/css/_legacy-dark.pcss
@@ -105,7 +105,7 @@ $overlay-background: rgba($background, 0.85);
$panels: rgba($system, 0.9);
$panel-actions: $roomtile-selected-bg-color;
-$separator: var(--cpd-color-alpha-gray-400);
+$separator: var(--cpd-color-gray-400);
/**
* Creating a `semantic` color scale. This will not be needed with the new
diff --git a/res/themes/legacy-dark/css/legacy-dark.pcss b/res/themes/legacy-dark/css/legacy-dark.pcss
index 061934d890a..ffb7e8d0aa3 100644
--- a/res/themes/legacy-dark/css/legacy-dark.pcss
+++ b/res/themes/legacy-dark/css/legacy-dark.pcss
@@ -5,3 +5,4 @@
@import "_legacy-dark.pcss";
@import "../../../../res/css/_components.pcss";
@import url("highlight.js/styles/atom-one-dark.min.css");
+@import url("github-markdown-css/github-markdown-dark.css");
diff --git a/res/themes/legacy-light/css/_legacy-light.pcss b/res/themes/legacy-light/css/_legacy-light.pcss
index 5f9b8fd4520..e40fbde72b6 100644
--- a/res/themes/legacy-light/css/_legacy-light.pcss
+++ b/res/themes/legacy-light/css/_legacy-light.pcss
@@ -163,7 +163,7 @@ $overlay-background: rgba($background, 0.85);
$panels: rgba($system, 0.9);
$panel-actions: $roomtile-selected-bg-color;
-$separator: var(--cpd-color-alpha-gray-400);
+$separator: var(--cpd-color-gray-400);
/* Legacy theme backports */
diff --git a/res/themes/legacy-light/css/legacy-light.pcss b/res/themes/legacy-light/css/legacy-light.pcss
index f44651e6892..4b3905a1f98 100644
--- a/res/themes/legacy-light/css/legacy-light.pcss
+++ b/res/themes/legacy-light/css/legacy-light.pcss
@@ -4,3 +4,4 @@
@import "_legacy-light.pcss";
@import "../../../../res/css/_components.pcss";
@import url("highlight.js/styles/atom-one-light.min.css");
+@import url("github-markdown-css/github-markdown-light.css");
diff --git a/res/themes/light-custom/css/_custom.pcss b/res/themes/light-custom/css/_custom.pcss
index d43740a8d97..0c5758f7290 100644
--- a/res/themes/light-custom/css/_custom.pcss
+++ b/res/themes/light-custom/css/_custom.pcss
@@ -18,7 +18,7 @@ $font-family: var(--font-family, $font-family);
$monospace-font-family: var(--font-family-monospace, $monospace-font-family);
/* Colors from Figma Compound https://www.figma.com/file/X4XTH9iS2KGJ2wFKDqkyed/Compound?node-id=559%3A741 */
-$accent: var(--accent, $accent);
+$accent: var(--accent-color, $accent);
$alert: var(--alert, $alert);
$links: var(--links, $links);
$primary-content: var(--primary-content, $primary-content);
diff --git a/res/themes/light-custom/css/light-custom.pcss b/res/themes/light-custom/css/light-custom.pcss
index eeba1002e58..0bf3bd9ba2b 100644
--- a/res/themes/light-custom/css/light-custom.pcss
+++ b/res/themes/light-custom/css/light-custom.pcss
@@ -4,3 +4,4 @@
@import "../../legacy-light/css/_legacy-light.pcss";
@import "_custom.pcss";
@import "../../../../res/css/_components.pcss";
+@import url("github-markdown-css/github-markdown-light.css");
diff --git a/res/themes/light-high-contrast/css/_light-high-contrast.pcss b/res/themes/light-high-contrast/css/_light-high-contrast.pcss
index 2ba97c5a722..a306e769b00 100644
--- a/res/themes/light-high-contrast/css/_light-high-contrast.pcss
+++ b/res/themes/light-high-contrast/css/_light-high-contrast.pcss
@@ -64,14 +64,7 @@ $accent-1400: var(--cpd-color-green-1400);
outline-offset: 2px;
}
-/* Add padding (and remove margin to compensate), so the outline is not */
-/* chopped off on the left */
-.mx_TabbedView_tabPanel {
- margin-left: 236px !important; /* Remove 4 to allow 4 in mx_SettingsTab */
-}
-.mx_SettingsTab {
- padding-left: 4px !important;
-}
+/* Add padding, so the outline is not chopped off on the left */
.mx_BaseCard {
padding-left: 4px !important; /* Remove 4 to allow 4 in mx_BaseCard_Group */
}
diff --git a/res/themes/light-high-contrast/css/light-high-contrast.pcss b/res/themes/light-high-contrast/css/light-high-contrast.pcss
index 4d05078f787..a843809e510 100644
--- a/res/themes/light-high-contrast/css/light-high-contrast.pcss
+++ b/res/themes/light-high-contrast/css/light-high-contrast.pcss
@@ -6,3 +6,4 @@
@import "../../light/css/_mods.pcss";
@import "../../../../res/css/_components.pcss";
@import url("highlight.js/styles/atom-one-light.min.css");
+@import url("github-markdown-css/github-markdown-light.css");
diff --git a/res/themes/light/css/_light.pcss b/res/themes/light/css/_light.pcss
index 730c1155143..1a237427f21 100644
--- a/res/themes/light/css/_light.pcss
+++ b/res/themes/light/css/_light.pcss
@@ -32,7 +32,7 @@ $overlay-background: var(--cpd-color-alpha-gray-1300);
$panels: var(--cpd-color-bg-subtle-secondary);
$panel-actions: var(--cpd-color-alpha-gray-300);
-$separator: var(--cpd-color-alpha-gray-400);
+$separator: var(--cpd-color-gray-400);
$accent: var(--cpd-color-text-action-accent);
$alert: var(--cpd-color-text-critical-primary);
diff --git a/res/themes/light/css/light.pcss b/res/themes/light/css/light.pcss
index 361484828f2..d3264e491e7 100644
--- a/res/themes/light/css/light.pcss
+++ b/res/themes/light/css/light.pcss
@@ -5,3 +5,4 @@
@import "_mods.pcss";
@import "../../../../res/css/_components.pcss";
@import url("highlight.js/styles/atom-one-light.min.css");
+@import url("github-markdown-css/github-markdown-light.css");
diff --git a/src/@types/global.d.ts b/src/@types/global.d.ts
index c62733c0f07..e42e83d58db 100644
--- a/src/@types/global.d.ts
+++ b/src/@types/global.d.ts
@@ -224,6 +224,14 @@ declare global {
readonly port: MessagePort;
}
+ /**
+ * In future, browsers will support focusVisible option.
+ * See https://developer.mozilla.org/en-US/docs/Web/API/HTMLElement/focus#focusvisible
+ */
+ interface FocusOptions {
+ focusVisible: boolean;
+ }
+
// https://github.com/microsoft/TypeScript/issues/28308#issuecomment-650802278
function registerProcessor(
name: string,
diff --git a/src/DecryptionFailureTracker.ts b/src/DecryptionFailureTracker.ts
index 548555cd1dd..99ad2e8caa1 100644
--- a/src/DecryptionFailureTracker.ts
+++ b/src/DecryptionFailureTracker.ts
@@ -20,6 +20,7 @@ import { Error as ErrorEvent } from "@matrix-org/analytics-events/types/typescri
import { DecryptionFailureCode } from "matrix-js-sdk/src/crypto-api";
import { PosthogAnalytics } from "./PosthogAnalytics";
+import { MEGOLM_ENCRYPTION_ALGORITHM } from "./utils/crypto";
/** The key that we use to store the `reportedEvents` bloom filter in localstorage */
const DECRYPTION_FAILURE_STORAGE_KEY = "mx_decryption_failure_event_ids";
@@ -77,7 +78,10 @@ export class DecryptionFailureTracker {
// Map JS-SDK error codes to tracker codes for aggregation
switch (errorCode) {
case DecryptionFailureCode.MEGOLM_UNKNOWN_INBOUND_SESSION_ID:
+ case DecryptionFailureCode.MEGOLM_KEY_WITHHELD:
return "OlmKeysNotSentError";
+ case DecryptionFailureCode.MEGOLM_KEY_WITHHELD_FOR_UNVERIFIED_DEVICE:
+ return "RoomKeysWithheldForUnverifiedDevice";
case DecryptionFailureCode.OLM_UNKNOWN_MESSAGE_INDEX:
return "OlmIndexError";
case DecryptionFailureCode.HISTORICAL_MESSAGE_NO_KEY_BACKUP:
@@ -207,7 +211,7 @@ export class DecryptionFailureTracker {
*/
private eventDecrypted(e: MatrixEvent, nowTs: number): void {
// for now we only track megolm decryption failures
- if (e.getWireContent().algorithm != "m.megolm.v1.aes-sha2") {
+ if (e.getWireContent().algorithm != MEGOLM_ENCRYPTION_ALGORITHM) {
return;
}
const errCode = e.decryptionFailureReason;
diff --git a/src/HtmlUtils.tsx b/src/HtmlUtils.tsx
index b63ed1dcf0c..888c30d76cc 100644
--- a/src/HtmlUtils.tsx
+++ b/src/HtmlUtils.tsx
@@ -20,13 +20,11 @@ limitations under the License.
import React, { LegacyRef, ReactNode } from "react";
import sanitizeHtml from "sanitize-html";
import classNames from "classnames";
-import EMOJIBASE_REGEX from "emojibase-regex";
import katex from "katex";
import { decode } from "html-entities";
import { IContent } from "matrix-js-sdk/src/matrix";
import { Optional } from "matrix-events-sdk";
import escapeHtml from "escape-html";
-import GraphemeSplitter from "graphemer";
import { getEmojiFromUnicode } from "@matrix-org/emojibase-bindings";
import { IExtendedSanitizeOptions } from "./@types/sanitize-html";
@@ -34,6 +32,7 @@ import SettingsStore from "./settings/SettingsStore";
import { stripHTMLReply, stripPlainReply } from "./utils/Reply";
import { PERMITTED_URL_SCHEMES } from "./utils/UrlUtils";
import { sanitizeHtmlParams, transformTags } from "./Linkify";
+import { graphemeSegmenter } from "./utils/strings";
export { Linkify, linkifyElement, linkifyAndSanitizeHtml } from "./Linkify";
@@ -46,10 +45,35 @@ const SURROGATE_PAIR_PATTERN = /([\ud800-\udbff])([\udc00-\udfff])/;
const SYMBOL_PATTERN = /([\u2100-\u2bff])/;
// Regex pattern for non-emoji characters that can appear in an "all-emoji" message
-// (Zero-Width Joiner, Zero-Width Space, Emoji presentation character, other whitespace)
-const EMOJI_SEPARATOR_REGEX = /[\u200D\u200B\s]|\uFE0F/g;
+// (Zero-Width Space, other whitespace)
+const EMOJI_SEPARATOR_REGEX = /[\u200B\s]/g;
+
+// Regex for emoji. This includes any RGI_Emoji sequence followed by an optional
+// emoji presentation VS (U+FE0F), but not those sequences that are followed by
+// a text presentation VS (U+FE0E). We also count lone regional indicators
+// (U+1F1E6-U+1F1FF). Technically this regex produces false negatives for emoji
+// followed by U+FE0E when the emoji doesn't have a text variant, but in
+// practice this doesn't matter.
+export const EMOJI_REGEX = (() => {
+ try {
+ // Per our support policy, v mode is available to us, but we still don't
+ // want the app to completely crash on older platforms. We use the
+ // constructor here to avoid a syntax error on such platforms.
+ return new RegExp("\\p{RGI_Emoji}(?!\\uFE0E)(?:(? {
+ try {
+ return new RegExp(`^(${EMOJI_REGEX.source})+$`, "iv");
+ } catch (_e) {
+ // Fall back, just like for EMOJI_REGEX
+ return /(?!)/;
+ }
+})();
/*
* Return true if the given string contains emoji
@@ -124,6 +148,7 @@ const topicSanitizeHtmlParams: IExtendedSanitizeOptions = {
allowedTags: [
"font", // custom to matrix for IRC-style font coloring
"del", // for markdown
+ "s",
"a",
"sup",
"sub",
@@ -264,17 +289,16 @@ export function formatEmojis(message: string | undefined, isHtmlMessage?: boolea
let text = "";
let key = 0;
- const splitter = new GraphemeSplitter();
- for (const char of splitter.iterateGraphemes(message)) {
- if (EMOJIBASE_REGEX.test(char)) {
+ for (const data of graphemeSegmenter.segment(message)) {
+ if (EMOJI_REGEX.test(data.segment)) {
if (text) {
result.push(text);
text = "";
}
- result.push(emojiToSpan(char, key));
+ result.push(emojiToSpan(data.segment, key));
key++;
} else {
- text += char;
+ text += data.segment;
}
}
if (text) {
diff --git a/src/IConfigOptions.ts b/src/IConfigOptions.ts
index c3a919648ea..4b57e2098dc 100644
--- a/src/IConfigOptions.ts
+++ b/src/IConfigOptions.ts
@@ -219,6 +219,7 @@ export interface IConfigOptions {
export interface ISsoRedirectOptions {
immediate?: boolean;
on_welcome_page?: boolean;
+ on_login_page?: boolean;
}
/* :tchap: tchap-features-from-config
diff --git a/src/Keyboard.ts b/src/Keyboard.ts
index 7b1ea4031be..33ce4e4e72a 100644
--- a/src/Keyboard.ts
+++ b/src/Keyboard.ts
@@ -29,6 +29,7 @@ export const Key = {
ARROW_DOWN: "ArrowDown",
ARROW_LEFT: "ArrowLeft",
ARROW_RIGHT: "ArrowRight",
+ F6: "F6",
TAB: "Tab",
ESCAPE: "Escape",
ENTER: "Enter",
@@ -77,6 +78,7 @@ export const Key = {
};
export const IS_MAC = navigator.platform.toUpperCase().includes("MAC");
+export const IS_ELECTRON = window.electron;
export function isOnlyCtrlOrCmdKeyEvent(ev: React.KeyboardEvent | KeyboardEvent): boolean {
if (IS_MAC) {
diff --git a/src/LegacyCallHandler.tsx b/src/LegacyCallHandler.tsx
index 7abdb236aeb..145784fe0c4 100644
--- a/src/LegacyCallHandler.tsx
+++ b/src/LegacyCallHandler.tsx
@@ -66,6 +66,7 @@ import { localNotificationsAreSilenced } from "./utils/notifications";
import { SdkContextClass } from "./contexts/SDKContext";
import { showCantStartACallDialog } from "./voice-broadcast/utils/showCantStartACallDialog";
import { isNotNull } from "./Typeguards";
+import { BackgroundAudio } from "./audio/BackgroundAudio";
export const PROTOCOL_PSTN = "m.protocol.pstn";
export const PROTOCOL_PSTN_PREFIXED = "im.vector.protocol.pstn";
@@ -157,8 +158,6 @@ export default class LegacyCallHandler extends EventEmitter {
// Calls started as an attended transfer, ie. with the intention of transferring another
// call with a different party to this one.
private transferees = new Map(); // callId (target) -> call (transferee)
- private audioPromises = new Map>();
- private audioElementsWithListeners = new Map();
private supportsPstnProtocol: boolean | null = null;
private pstnSupportPrefixed: boolean | null = null; // True if the server only support the prefixed pstn protocol
private supportsSipNativeVirtual: boolean | null = null; // im.vector.protocol.sip_virtual and im.vector.protocol.sip_native
@@ -170,6 +169,9 @@ export default class LegacyCallHandler extends EventEmitter {
private silencedCalls = new Set(); // callIds
+ private backgroundAudio = new BackgroundAudio();
+ private playingSources: Record = {}; // Record them for stopping
+
public static get instance(): LegacyCallHandler {
if (!window.mxLegacyCallHandler) {
window.mxLegacyCallHandler = new LegacyCallHandler();
@@ -199,33 +201,11 @@ export default class LegacyCallHandler extends EventEmitter {
}
public start(): void {
- // add empty handlers for media actions, otherwise the media keys
- // end up causing the audio elements with our ring/ringback etc
- // audio clips in to play.
- if (navigator.mediaSession) {
- navigator.mediaSession.setActionHandler("play", function () {});
- navigator.mediaSession.setActionHandler("pause", function () {});
- navigator.mediaSession.setActionHandler("seekbackward", function () {});
- navigator.mediaSession.setActionHandler("seekforward", function () {});
- navigator.mediaSession.setActionHandler("previoustrack", function () {});
- navigator.mediaSession.setActionHandler("nexttrack", function () {});
- }
-
if (SettingsStore.getValue(UIFeature.Voip)) {
MatrixClientPeg.safeGet().on(CallEventHandlerEvent.Incoming, this.onCallIncoming);
}
this.checkProtocols(CHECK_PROTOCOLS_ATTEMPTS);
-
- // Add event listeners for the
- );
- }
- }
} else if (this.props.kind === InviteKind.CallTransfer) {
title = _t("action|transfer");
@@ -1475,7 +1458,6 @@ export default class InviteDialog extends React.PureComponent
- {keySharingWarning}
{this.renderIdentityServerWarning()}
{this.state.errorText}
{onlyOneThreepidNote}
diff --git a/src/components/views/dialogs/UserSettingsDialog.tsx b/src/components/views/dialogs/UserSettingsDialog.tsx
index 9aafeca2fd3..1ab39acb5ce 100644
--- a/src/components/views/dialogs/UserSettingsDialog.tsx
+++ b/src/components/views/dialogs/UserSettingsDialog.tsx
@@ -50,7 +50,7 @@ interface IProps {
function titleForTabID(tabId: UserTab): React.ReactNode {
const subs = {
- strong: (sub: string) => {sub},
+ strong: (sub: string) => {sub},
};
switch (tabId) {
case UserTab.General:
@@ -230,6 +230,7 @@ export default function UserSettingsDialog(props: IProps): JSX.Element {
hasCancel={true}
onFinished={props.onFinished}
title={titleForTabID(activeTabId)}
+ titleClass="mx_UserSettingsDialog_title"
>
= ({ onBack }) => {
const cli = useContext(MatrixClientContext);
- const capabilities = useAsyncMemo(() => cli.getCapabilities(true).catch(() => FAILED_TO_LOAD), [cli]);
+ const capabilities = useAsyncMemo(() => cli.fetchCapabilities().catch(() => FAILED_TO_LOAD), [cli]);
const clientVersions = useAsyncMemo(() => cli.getVersions().catch(() => FAILED_TO_LOAD), [cli]);
const serverVersions = useAsyncMemo(async (): Promise => {
try {
diff --git a/src/components/views/dialogs/security/RestoreKeyBackupDialog.tsx b/src/components/views/dialogs/security/RestoreKeyBackupDialog.tsx
index c535245e61d..c9aade7b9e3 100644
--- a/src/components/views/dialogs/security/RestoreKeyBackupDialog.tsx
+++ b/src/components/views/dialogs/security/RestoreKeyBackupDialog.tsx
@@ -17,8 +17,9 @@ limitations under the License.
import React, { ChangeEvent } from "react";
import { MatrixClient, MatrixError, SecretStorage } from "matrix-js-sdk/src/matrix";
-import { IKeyBackupInfo, IKeyBackupRestoreResult } from "matrix-js-sdk/src/crypto/keybackup";
+import { IKeyBackupRestoreResult } from "matrix-js-sdk/src/crypto/keybackup";
import { logger } from "matrix-js-sdk/src/logger";
+import { KeyBackupInfo } from "matrix-js-sdk/src/crypto-api";
import { MatrixClientPeg } from "../../../../MatrixClientPeg";
import { _t } from "../../../../languageHandler";
@@ -51,7 +52,7 @@ interface IProps {
}
interface IState {
- backupInfo: IKeyBackupInfo | null;
+ backupInfo: KeyBackupInfo | null;
backupKeyStored: Record | null;
loading: boolean;
loadError: boolean | null;
@@ -246,7 +247,7 @@ export default class RestoreKeyBackupDialog extends React.PureComponent {
+ private async restoreWithCachedKey(backupInfo: KeyBackupInfo | null): Promise {
if (!backupInfo) return false;
try {
const recoverInfo = await MatrixClientPeg.safeGet().restoreKeyBackupWithCache(
diff --git a/src/components/views/dialogs/spotlight/SpotlightDialog.tsx b/src/components/views/dialogs/spotlight/SpotlightDialog.tsx
index 2ac7681afae..2630c85872e 100644
--- a/src/components/views/dialogs/spotlight/SpotlightDialog.tsx
+++ b/src/components/views/dialogs/spotlight/SpotlightDialog.tsx
@@ -505,7 +505,7 @@ const SpotlightDialog: React.FC = ({ initialText = "", initialFilter = n
_setQuery(newQuery);
};
useEffect(() => {
- setImmediate(() => {
+ setTimeout(() => {
const ref = rovingContext.state.refs[0];
if (ref) {
rovingContext.dispatch({
diff --git a/src/components/views/dialogs/spotlight/TooltipOption.tsx b/src/components/views/dialogs/spotlight/TooltipOption.tsx
index 0deb4b13111..24c97d601f9 100644
--- a/src/components/views/dialogs/spotlight/TooltipOption.tsx
+++ b/src/components/views/dialogs/spotlight/TooltipOption.tsx
@@ -15,18 +15,23 @@ limitations under the License.
*/
import classNames from "classnames";
-import React, { ComponentProps, ReactNode } from "react";
+import React, { ReactNode } from "react";
import { useRovingTabIndex } from "../../../../accessibility/RovingTabIndex";
-import AccessibleButton from "../../elements/AccessibleButton";
+import AccessibleButton, { ButtonProps } from "../../elements/AccessibleButton";
import { Ref } from "../../../../accessibility/roving/types";
-interface TooltipOptionProps extends ComponentProps {
+type TooltipOptionProps = ButtonProps & {
endAdornment?: ReactNode;
inputRef?: Ref;
-}
+};
-export const TooltipOption: React.FC = ({ inputRef, className, ...props }) => {
+export const TooltipOption = ({
+ inputRef,
+ className,
+ element,
+ ...props
+}: TooltipOptionProps): JSX.Element => {
const [onFocus, isActive, ref] = useRovingTabIndex(inputRef);
return (
= ({ inputRef, classNam
tabIndex={-1}
aria-selected={isActive}
role="option"
+ element={element as keyof JSX.IntrinsicElements}
/>
);
};
diff --git a/src/components/views/elements/AccessibleButton.tsx b/src/components/views/elements/AccessibleButton.tsx
index 76b90506dcc..c80f8a018d4 100644
--- a/src/components/views/elements/AccessibleButton.tsx
+++ b/src/components/views/elements/AccessibleButton.tsx
@@ -113,6 +113,8 @@ type Props = DynamicHtmlElementProps &
disableTooltip?: TooltipProps["disabled"];
};
+export type ButtonProps = Props;
+
/**
* Type of the props passed to the element that is rendered by AccessibleButton.
*/
diff --git a/src/components/views/elements/EventTilePreview.tsx b/src/components/views/elements/EventTilePreview.tsx
index fd11d372dc3..4b8ccb0ed90 100644
--- a/src/components/views/elements/EventTilePreview.tsx
+++ b/src/components/views/elements/EventTilePreview.tsx
@@ -37,7 +37,7 @@ interface IProps {
/**
* classnames to apply to the wrapper of the preview
*/
- className: string;
+ className?: string;
/**
* The ID of the displayed user
diff --git a/src/components/views/elements/FacePile.tsx b/src/components/views/elements/FacePile.tsx
index 67c11e2f357..d459040f0b1 100644
--- a/src/components/views/elements/FacePile.tsx
+++ b/src/components/views/elements/FacePile.tsx
@@ -21,7 +21,7 @@ import { AvatarStack, Tooltip } from "@vector-im/compound-web";
import MemberAvatar from "../avatars/MemberAvatar";
import AccessibleButton, { ButtonEvent } from "./AccessibleButton";
-interface IProps extends HTMLAttributes {
+interface IProps extends Omit, "onChange"> {
members: RoomMember[];
size: string;
overflow: boolean;
@@ -32,6 +32,11 @@ interface IProps extends HTMLAttributes {
onClick?: (e: ButtonEvent) => void | Promise;
}
+/**
+ * A component which displays a list of avatars in a row, with a tooltip showing the names of the users.
+ *
+ * Any additional props, not named explicitly here, are passed to the underlying {@link AccessibleButton}.
+ */
const FacePile: FC = ({
members,
size,
@@ -40,6 +45,7 @@ const FacePile: FC = ({
tooltipShortcut,
children,
viewUserOnClick = true,
+ onClick,
...props
}) => {
const faces = members.map(
@@ -47,12 +53,7 @@ const FacePile: FC = ({
? (m) =>
: (m) => (
-
+
),
);
@@ -65,7 +66,7 @@ const FacePile: FC = ({
);
const content = (
-
+
{pileContents}
{children}
diff --git a/src/components/views/elements/LearnMore.tsx b/src/components/views/elements/LearnMore.tsx
index efce35bfe34..c7c7a1925ee 100644
--- a/src/components/views/elements/LearnMore.tsx
+++ b/src/components/views/elements/LearnMore.tsx
@@ -14,14 +14,14 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
-import React, { ComponentProps } from "react";
+import React from "react";
import { _t } from "../../../languageHandler";
import Modal from "../../../Modal";
import InfoDialog from "../dialogs/InfoDialog";
-import AccessibleButton from "./AccessibleButton";
+import AccessibleButton, { ButtonProps } from "./AccessibleButton";
-type Props = Omit, "kind" | "onClick" | "className"> & {
+type Props = Omit, "element" | "kind" | "onClick" | "className"> & {
title: string;
description: string | React.ReactNode;
};
diff --git a/src/components/views/elements/SearchWarning.tsx b/src/components/views/elements/SearchWarning.tsx
index 2cdfe2f1f6e..5891b245f9d 100644
--- a/src/components/views/elements/SearchWarning.tsx
+++ b/src/components/views/elements/SearchWarning.tsx
@@ -33,9 +33,10 @@ export enum WarningKind {
interface IProps {
isRoomEncrypted?: boolean;
kind: WarningKind;
+ showLogo?: boolean;
}
-export default function SearchWarning({ isRoomEncrypted, kind }: IProps): JSX.Element {
+export default function SearchWarning({ isRoomEncrypted, kind, showLogo = true }: IProps): JSX.Element {
if (!isRoomEncrypted) return <>>;
if (EventIndexPeg.get()) return <>>;
@@ -121,7 +122,7 @@ export default function SearchWarning({ isRoomEncrypted, kind }: IProps): JSX.El
return (
- {logo}
+ {showLogo ? logo : null}
{text}
);
diff --git a/src/components/views/elements/SettingsFlag.tsx b/src/components/views/elements/SettingsFlag.tsx
index d44a3323b71..86b82c64e44 100644
--- a/src/components/views/elements/SettingsFlag.tsx
+++ b/src/components/views/elements/SettingsFlag.tsx
@@ -62,6 +62,13 @@ export default class SettingsFlag extends React.Component {
}
private getSettingValue(): boolean {
+ // If a level defined in props is overridden by a level at a high presedence, it gets disabled
+ // and we should show the overridding value.
+ if (
+ SettingsStore.settingIsOveriddenAtConfigLevel(this.props.name, this.props.roomId ?? null, this.props.level)
+ ) {
+ return !!SettingsStore.getValue(this.props.name);
+ }
return !!SettingsStore.getValueAt(
this.props.level,
this.props.name,
diff --git a/src/components/views/messages/DecryptionFailureBody.tsx b/src/components/views/messages/DecryptionFailureBody.tsx
index 1e94e533cd1..f0bce43b800 100644
--- a/src/components/views/messages/DecryptionFailureBody.tsx
+++ b/src/components/views/messages/DecryptionFailureBody.tsx
@@ -25,8 +25,10 @@ import ExternalLink from "../elements/ExternalLink"; // :TCHAP: better-text-for-
import { LocalDeviceVerificationStateContext } from "../../../contexts/LocalDeviceVerificationStateContext";
function getErrorMessage(mxEvent: MatrixEvent, isVerified: boolean | undefined): string {
- if (mxEvent.isEncryptedDisabledForUnverifiedDevices) return _t("timeline|decryption_failure|blocked");
switch (mxEvent.decryptionFailureReason) {
+ case DecryptionFailureCode.MEGOLM_KEY_WITHHELD_FOR_UNVERIFIED_DEVICE:
+ return _t("timeline|decryption_failure|blocked");
+
case DecryptionFailureCode.HISTORICAL_MESSAGE_NO_KEY_BACKUP:
return _t("timeline|decryption_failure|historical_event_no_key_backup");
diff --git a/src/components/views/messages/EncryptionEvent.tsx b/src/components/views/messages/EncryptionEvent.tsx
index df475d7de6d..67ec020aa56 100644
--- a/src/components/views/messages/EncryptionEvent.tsx
+++ b/src/components/views/messages/EncryptionEvent.tsx
@@ -16,8 +16,8 @@ limitations under the License.
import React, { forwardRef, useContext } from "react";
import { MatrixEvent } from "matrix-js-sdk/src/matrix";
-import { IRoomEncryption } from "matrix-js-sdk/src/crypto/RoomList";
+import type { RoomEncryptionEventContent } from "matrix-js-sdk/src/types";
import { _t } from "../../../languageHandler";
import { MatrixClientPeg } from "../../../MatrixClientPeg";
import EventTileBubble from "./EventTileBubble";
@@ -25,30 +25,29 @@ import MatrixClientContext from "../../../contexts/MatrixClientContext";
import DMRoomMap from "../../../utils/DMRoomMap";
import { objectHasDiff } from "../../../utils/objects";
import { isLocalRoom } from "../../../utils/localRoom/isLocalRoom";
+import { MEGOLM_ENCRYPTION_ALGORITHM } from "../../../utils/crypto";
interface IProps {
mxEvent: MatrixEvent;
timestamp?: JSX.Element;
}
-const ALGORITHM = "m.megolm.v1.aes-sha2";
-
const EncryptionEvent = forwardRef(({ mxEvent, timestamp }, ref) => {
const cli = useContext(MatrixClientContext);
const roomId = mxEvent.getRoomId()!;
const isRoomEncrypted = MatrixClientPeg.safeGet().isRoomEncrypted(roomId);
- const prevContent = mxEvent.getPrevContent() as IRoomEncryption;
- const content = mxEvent.getContent();
+ const prevContent = mxEvent.getPrevContent() as RoomEncryptionEventContent;
+ const content = mxEvent.getContent();
// if no change happened then skip rendering this, a shallow check is enough as all known fields are top-level.
if (!objectHasDiff(prevContent, content)) return null; // nop
- if (content.algorithm === ALGORITHM && isRoomEncrypted) {
+ if (content.algorithm === MEGOLM_ENCRYPTION_ALGORITHM && isRoomEncrypted) {
let subtitle: string;
const dmPartner = DMRoomMap.shared().getUserIdForRoomId(roomId);
const room = cli?.getRoom(roomId);
- if (prevContent.algorithm === ALGORITHM) {
+ if (prevContent.algorithm === MEGOLM_ENCRYPTION_ALGORITHM) {
subtitle = _t("timeline|m.room.encryption|parameters_changed");
} else if (dmPartner) {
const displayName = room?.getMember(dmPartner)?.rawDisplayName || dmPartner;
diff --git a/src/components/views/messages/MFileBody.tsx b/src/components/views/messages/MFileBody.tsx
index 12d4c804168..bf4922615f4 100644
--- a/src/components/views/messages/MFileBody.tsx
+++ b/src/components/views/messages/MFileBody.tsx
@@ -288,7 +288,7 @@ export default class MFileBody extends React.Component {
src={url}
onLoad={() => this.downloadFile(this.fileName, this.linkText)}
ref={this.iframe}
- sandbox="allow-scripts allow-downloads allow-downloads-without-user-activation"
+ sandbox="allow-scripts allow-downloads"
/>
)}
diff --git a/src/components/views/right_panel/BaseCard.tsx b/src/components/views/right_panel/BaseCard.tsx
index 2afae0bc287..aadabeb646f 100644
--- a/src/components/views/right_panel/BaseCard.tsx
+++ b/src/components/views/right_panel/BaseCard.tsx
@@ -14,29 +14,35 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
-import React, { forwardRef, ReactNode, KeyboardEvent, Ref } from "react";
+import React, { forwardRef, ReactNode, KeyboardEvent, Ref, MouseEvent } from "react";
import classNames from "classnames";
+import { IconButton, Text } from "@vector-im/compound-web";
+import CloseIcon from "@vector-im/compound-design-tokens/assets/web/icons/close";
+import { Icon as ChevronLeftIcon } from "@vector-im/compound-design-tokens/icons/chevron-left.svg";
import AutoHideScrollbar from "../../structures/AutoHideScrollbar";
import { _t } from "../../../languageHandler";
-import AccessibleButton, { ButtonEvent } from "../elements/AccessibleButton";
import RightPanelStore from "../../../stores/right-panel/RightPanelStore";
import { backLabelForPhase } from "../../../stores/right-panel/RightPanelStorePhases";
import { CardContext } from "./context";
interface IProps {
header?: ReactNode | null;
+ hideHeaderButtons?: boolean;
footer?: ReactNode;
className?: string;
+ id?: string;
+ role?: "tabpanel";
+ ariaLabelledBy?: string;
withoutScrollContainer?: boolean;
closeLabel?: string;
- onClose?(ev: ButtonEvent): void;
- onBack?(ev: ButtonEvent): void;
+ onClose?(ev: MouseEvent): void;
+ onBack?(ev: MouseEvent): void;
onKeyDown?(ev: KeyboardEvent): void;
cardState?: any;
ref?: Ref;
// Ref for the 'close' button the the card
- closeButtonRef?: Ref;
+ closeButtonRef?: Ref;
children: ReactNode;
}
@@ -62,6 +68,10 @@ const BaseCard: React.FC = forwardRef(
onClose,
onBack,
className,
+ id,
+ ariaLabelledBy,
+ role,
+ hideHeaderButtons,
header,
footer,
withoutScrollContainer,
@@ -73,26 +83,39 @@ const BaseCard: React.FC = forwardRef(
) => {
let backButton;
const cardHistory = RightPanelStore.instance.roomPhaseHistory;
- if (cardHistory.length > 1) {
+ if (cardHistory.length > 1 && !hideHeaderButtons) {
const prevCard = cardHistory[cardHistory.length - 2];
- const onBackClick = (ev: ButtonEvent): void => {
+ const onBackClick = (ev: MouseEvent): void => {
onBack?.(ev);
RightPanelStore.instance.popCard();
};
const label = backLabelForPhase(prevCard.phase) ?? _t("action|back");
- backButton = ;
+ backButton = (
+
+
+
+ );
}
let closeButton;
- if (onClose) {
+ if (onClose && !hideHeaderButtons) {
closeButton = (
-
+ tooltip={closeLabel ?? _t("action|close")}
+ subtleBackground
+ >
+
+
);
}
@@ -100,14 +123,29 @@ const BaseCard: React.FC = forwardRef(
children = {children};
}
+ const shouldRenderHeader = header || !hideHeaderButtons;
+
return (
-
- {header !== null && (
+
+ {shouldRenderHeader && (
{backButton}
+ {typeof header === "string" ? (
+
+ {header}
+
+ ) : (
+ header ??
+ )}
{closeButton}
-
{header}
)}
{children}
diff --git a/src/components/views/right_panel/EmptyState.tsx b/src/components/views/right_panel/EmptyState.tsx
new file mode 100644
index 00000000000..7189cb8b3a7
--- /dev/null
+++ b/src/components/views/right_panel/EmptyState.tsx
@@ -0,0 +1,42 @@
+/*
+Copyright 2024 The Matrix.org Foundation C.I.C.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+import React, { ComponentType } from "react";
+import { Text } from "@vector-im/compound-web";
+
+import { Flex } from "../../utils/Flex";
+
+interface Props {
+ Icon: ComponentType
>;
+ title: string;
+ description: string;
+}
+
+const EmptyState: React.FC = ({ Icon, title, description }) => {
+ return (
+
+
+
+ {title}
+
+
+ {description}
+
+
+ );
+};
+
+export default EmptyState;
diff --git a/src/components/views/right_panel/LegacyRoomHeaderButtons.tsx b/src/components/views/right_panel/LegacyRoomHeaderButtons.tsx
index 0846139f5a4..2263399635f 100644
--- a/src/components/views/right_panel/LegacyRoomHeaderButtons.tsx
+++ b/src/components/views/right_panel/LegacyRoomHeaderButtons.tsx
@@ -215,27 +215,27 @@ export default class LegacyRoomHeaderButtons extends HeaderButtons {
const currentPhase = RightPanelStore.instance.currentCard.phase;
if (currentPhase && ROOM_INFO_PHASES.includes(currentPhase)) {
if (this.state.phase === currentPhase) {
- RightPanelStore.instance.showOrHidePanel(currentPhase);
+ RightPanelStore.instance.showOrHidePhase(currentPhase);
} else {
- RightPanelStore.instance.showOrHidePanel(currentPhase, RightPanelStore.instance.currentCard.state);
+ RightPanelStore.instance.showOrHidePhase(currentPhase, RightPanelStore.instance.currentCard.state);
}
} else {
// This toggles for us, if needed
- RightPanelStore.instance.showOrHidePanel(RightPanelPhases.RoomSummary);
+ RightPanelStore.instance.showOrHidePhase(RightPanelPhases.RoomSummary);
}
};
private onNotificationsClicked = (): void => {
// This toggles for us, if needed
- RightPanelStore.instance.showOrHidePanel(RightPanelPhases.NotificationPanel);
+ RightPanelStore.instance.showOrHidePhase(RightPanelPhases.NotificationPanel);
};
private onPinnedMessagesClicked = (): void => {
// This toggles for us, if needed
- RightPanelStore.instance.showOrHidePanel(RightPanelPhases.PinnedMessages);
+ RightPanelStore.instance.showOrHidePhase(RightPanelPhases.PinnedMessages);
};
private onTimelineCardClicked = (): void => {
- RightPanelStore.instance.showOrHidePanel(RightPanelPhases.Timeline);
+ RightPanelStore.instance.showOrHidePhase(RightPanelPhases.Timeline);
};
private onThreadsPanelClicked = (ev: ButtonEvent): void => {
diff --git a/src/components/views/right_panel/RightPanelTabs.tsx b/src/components/views/right_panel/RightPanelTabs.tsx
new file mode 100644
index 00000000000..fc2eeb17fac
--- /dev/null
+++ b/src/components/views/right_panel/RightPanelTabs.tsx
@@ -0,0 +1,86 @@
+/*
+Copyright 2024 The Matrix.org Foundation C.I.C.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+import React, { useRef } from "react";
+import { NavBar, NavItem } from "@vector-im/compound-web";
+
+import { _t } from "../../../languageHandler";
+import { RightPanelPhases } from "../../../stores/right-panel/RightPanelStorePhases";
+import RightPanelStore from "../../../stores/right-panel/RightPanelStore";
+import PosthogTrackers from "../../../PosthogTrackers";
+import { useDispatcher } from "../../../hooks/useDispatcher";
+import dispatcher from "../../../dispatcher/dispatcher";
+import { Action } from "../../../dispatcher/actions";
+
+function shouldShowTabsForPhase(phase?: RightPanelPhases): boolean {
+ const tabs = [RightPanelPhases.RoomSummary, RightPanelPhases.RoomMemberList, RightPanelPhases.ThreadPanel];
+ return !!phase && tabs.includes(phase);
+}
+
+type Props = {
+ phase: RightPanelPhases;
+};
+
+export const RightPanelTabs: React.FC = ({ phase }): JSX.Element | null => {
+ const threadsTabRef = useRef(null);
+
+ useDispatcher(dispatcher, (payload) => {
+ // This actually focuses the threads tab, as its the only interactive element,
+ // but at least it puts the user in the right area of the app.
+ if (payload.action === Action.FocusThreadsPanel) {
+ threadsTabRef.current?.focus();
+ }
+ });
+
+ if (!shouldShowTabsForPhase(phase)) return null;
+
+ return (
+
+ {
+ RightPanelStore.instance.pushCard({ phase: RightPanelPhases.RoomSummary }, true);
+ }}
+ active={phase === RightPanelPhases.RoomSummary}
+ >
+ {_t("right_panel|info")}
+
+ ) => {
+ RightPanelStore.instance.pushCard({ phase: RightPanelPhases.RoomMemberList }, true);
+ PosthogTrackers.trackInteraction("WebRightPanelRoomInfoPeopleButton", ev);
+ }}
+ active={phase === RightPanelPhases.RoomMemberList}
+ >
+ {_t("common|people")}
+
+ {
+ RightPanelStore.instance.pushCard({ phase: RightPanelPhases.ThreadPanel }, true);
+ }}
+ active={phase === RightPanelPhases.ThreadPanel}
+ ref={threadsTabRef}
+ >
+ {_t("common|threads")}
+
+
+ );
+};
diff --git a/src/components/views/right_panel/RoomSummaryCard.tsx b/src/components/views/right_panel/RoomSummaryCard.tsx
index 54a6d8ecec8..86c163b9101 100644
--- a/src/components/views/right_panel/RoomSummaryCard.tsx
+++ b/src/components/views/right_panel/RoomSummaryCard.tsx
@@ -14,11 +14,19 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
-import React, { SyntheticEvent, useCallback, useContext, useEffect, useMemo, useState } from "react";
+import React, {
+ ChangeEvent,
+ SyntheticEvent,
+ useCallback,
+ useContext,
+ useEffect,
+ useMemo,
+ useRef,
+ useState,
+} from "react";
import classNames from "classnames";
import {
MenuItem,
- Tooltip,
Separator,
ToggleMenuItem,
Text,
@@ -26,22 +34,22 @@ import {
Heading,
IconButton,
Link,
+ Search,
+ Form,
} from "@vector-im/compound-web";
-import { Icon as SearchIcon } from "@vector-im/compound-design-tokens/icons/search.svg";
-import { Icon as FavouriteIcon } from "@vector-im/compound-design-tokens/icons/favourite.svg";
+import FavouriteIcon from "@vector-im/compound-design-tokens/assets/web/icons/favourite";
import { Icon as UserAddIcon } from "@vector-im/compound-design-tokens/icons/user-add.svg";
-import { Icon as UserProfileSolidIcon } from "@vector-im/compound-design-tokens/icons/user-profile-solid.svg";
-import { Icon as LinkIcon } from "@vector-im/compound-design-tokens/icons/link.svg";
-import { Icon as SettingsIcon } from "@vector-im/compound-design-tokens/icons/settings.svg";
+import LinkIcon from "@vector-im/compound-design-tokens/assets/web/icons/link";
+import SettingsIcon from "@vector-im/compound-design-tokens/assets/web/icons/settings";
import { Icon as ExportArchiveIcon } from "@vector-im/compound-design-tokens/icons/export-archive.svg";
-import { Icon as LeaveIcon } from "@vector-im/compound-design-tokens/icons/leave.svg";
-import { Icon as FilesIcon } from "@vector-im/compound-design-tokens/icons/files.svg";
-import { Icon as PollsIcon } from "@vector-im/compound-design-tokens/icons/polls.svg";
-import { Icon as PinIcon } from "@vector-im/compound-design-tokens/icons/pin.svg";
+import LeaveIcon from "@vector-im/compound-design-tokens/assets/web/icons/leave";
+import FilesIcon from "@vector-im/compound-design-tokens/assets/web/icons/files";
+import PollsIcon from "@vector-im/compound-design-tokens/assets/web/icons/polls";
+import PinIcon from "@vector-im/compound-design-tokens/assets/web/icons/pin";
import { Icon as LockIcon } from "@vector-im/compound-design-tokens/icons/lock-solid.svg";
import { Icon as LockOffIcon } from "@vector-im/compound-design-tokens/icons/lock-off.svg";
-import { Icon as PublicIcon } from "@vector-im/compound-design-tokens/icons/public.svg";
-import { Icon as ErrorIcon } from "@vector-im/compound-design-tokens/icons/error.svg";
+import PublicIcon from "@vector-im/compound-design-tokens/assets/web/icons/public";
+import ErrorIcon from "@vector-im/compound-design-tokens/assets/web/icons/error";
import { Icon as ChevronDownIcon } from "@vector-im/compound-design-tokens/icons/chevron-down.svg";
import { EventType, JoinRule, Room, RoomStateEvent } from "matrix-js-sdk/src/matrix";
@@ -63,7 +71,7 @@ import WidgetAvatar from "../avatars/WidgetAvatar";
import WidgetStore, { IApp } from "../../../stores/WidgetStore";
import { E2EStatus } from "../../../utils/ShieldUtils";
import { RoomPermalinkCreator } from "../../../utils/permalinks/Permalinks";
-import RoomContext from "../../../contexts/RoomContext";
+import RoomContext, { TimelineRenderingType } from "../../../contexts/RoomContext";
import { UIComponent, UIFeature } from "../../../settings/UIFeature";
import { ChevronFace, ContextMenuTooltipButton, useContextMenu } from "../../structures/ContextMenu";
import { WidgetContextMenu } from "../context_menus/WidgetContextMenu";
@@ -89,6 +97,10 @@ import { useTopic } from "../../../hooks/room/useTopic";
import { Linkify, topicToHtml } from "../../../HtmlUtils";
import { Box } from "../../utils/Box";
import { onRoomTopicLinkClick } from "../elements/RoomTopic";
+import { useDispatcher } from "../../../hooks/useDispatcher";
+import { Action } from "../../../dispatcher/actions";
+import { Key } from "../../../Keyboard";
+import { useTransition } from "../../../hooks/useTransition";
import DecoratedRoomAvatar from "../avatars/DecoratedRoomAvatar"; // :TCHAP: tchap-room-icons
@@ -96,8 +108,9 @@ import DecoratedRoomAvatar from "../avatars/DecoratedRoomAvatar"; // :TCHAP: tch
interface IProps {
room: Room;
permalinkCreator: RoomPermalinkCreator;
- onClose(): void;
- onSearchClick?: () => void;
+ onSearchChange?: (e: ChangeEvent) => void;
+ onSearchCancel?: () => void;
+ focusRoomSearch?: boolean;
}
interface IAppsSectionProps {
@@ -367,7 +380,13 @@ const RoomTopic: React.FC> = ({ room }): JSX.Element | null
);
};
-const RoomSummaryCard: React.FC = ({ room, permalinkCreator, onClose, onSearchClick }) => {
+const RoomSummaryCard: React.FC = ({
+ room,
+ permalinkCreator,
+ onSearchChange,
+ onSearchCancel,
+ focusRoomSearch,
+}) => {
const cli = useContext(MatrixClientContext);
const onShareRoomClick = (): void => {
@@ -397,11 +416,6 @@ const RoomSummaryCard: React.FC = ({ room, permalinkCreator, onClose, on
});
};
- const onRoomMembersClick = (ev: Event): void => {
- RightPanelStore.instance.pushCard({ phase: RightPanelPhases.RoomMemberList }, true);
- PosthogTrackers.trackInteraction("WebRightPanelRoomInfoPeopleButton", ev);
- };
-
const isRoomEncrypted = useIsEncrypted(cli, room);
const roomContext = useContext(RoomContext);
const e2eStatus = roomContext.e2eStatus;
@@ -422,6 +436,26 @@ const RoomSummaryCard: React.FC = ({ room, permalinkCreator, onClose, on
}
}, [room, directRoomsList]);
+ const searchInputRef = useRef(null);
+ useDispatcher(defaultDispatcher, (payload) => {
+ if (payload.action === Action.FocusMessageSearch) {
+ searchInputRef.current?.focus();
+ }
+ });
+ // Clear the search field when the user leaves the search view
+ useTransition(
+ (prevTimelineRenderingType) => {
+ if (
+ prevTimelineRenderingType === TimelineRenderingType.Search &&
+ roomContext.timelineRenderingType !== TimelineRenderingType.Search &&
+ searchInputRef.current
+ ) {
+ searchInputRef.current.value = "";
+ }
+ },
+ [roomContext.timelineRenderingType],
+ );
+
const alias = room.getCanonicalAlias() || room.getAltAliases()[0] || "";
const header = (
@@ -499,7 +533,13 @@ const RoomSummaryCard: React.FC = ({ room, permalinkCreator, onClose, on
const isFavorite = roomTags.includes(DefaultTagID.Favourite);
return (
-
+
= ({ room, permalinkCreator, onClose, on
align="center"
justify="space-between"
>
-
-
-
-
+ {onSearchChange && (
+ e.preventDefault()}>
+ {
+ if (searchInputRef.current && e.key === Key.ESCAPE) {
+ searchInputRef.current.value = "";
+ onSearchCancel?.();
+ }
+ }}
+ />
+
+ )}
{header}
- tagRoom(room, DefaultTagID.Favourite)}
- // XXX: https://github.com/element-hq/compound/issues/288
- onSelect={() => {}}
- />
-