Compare commits

...

49 Commits

Author SHA1 Message Date
rbondesson bb4a7e9613 Move RovingTabIndex to shared component and use it in ActionBarView (#33263)
Build / Build on ubuntu-24.04 (push) Failing after 42s
Build and Deploy develop / Build & Deploy develop.element.io (push) Has been skipped
Deploy documentation / GitHub Pages (push) Failing after 35s
Deploy documentation / deploy (push) Has been skipped
Publish shared component storybook / Build storybook (push) Failing after 3m4s
Publish shared component storybook / Publish storybook (push) Has been skipped
Shared Component Visual Tests / Run Visual Tests (push) Failing after 49s
Static Analysis / Docs (push) Failing after 38s
Static Analysis / ESLint (push) Failing after 28s
Static Analysis / Analyse Dead Code (push) Failing after 44s
Static Analysis / Prettier (push) Failing after 36s
Static Analysis / Style Lint (push) Failing after 38s
Static Analysis / Typescript Syntax Check (push) Failing after 38s
Static Analysis / Workflow Lint (push) Failing after 42s
Static Analysis / Rethemendex Check (push) Failing after 41s
Static Analysis / Zizmor Github Actions lint (push) Failing after 37s
Static Analysis / i18n Check (Element Desktop) (push) Failing after 0s
Static Analysis / i18n Check (Shared Components) (push) Failing after 0s
Static Analysis / i18n Check (Element Web) (push) Failing after 0s
Static Analysis / Static Analysis (push) Successful in 1s
Build / Build on macos-14 (push) Has been cancelled
Build / Build on windows-2022 (push) Has been cancelled
* Create a new shared component and a wrapper in app/web

* Move unit tests and add new for better coverage

* Refactor ActionBarView to use the RovingTabIndexProvider

* Clean up the interface and adjust callers

* Added documentation and renamed type for better readabililty

* Reverting the clean up of IContext

* Fix Sonar issues

* More Sonar issus fixed
2026-04-23 09:33:32 +00:00
Michael Telatynski 1a6b0e22a1 Add CI to detect stale vis screenshots (#33274)
* Add CI to detect stale vis screenshots

* Remove already installed `tree` command

* Remove workaround for vis silently adding missing screenshots in CI

* Fix stale screenshot detection

* Test that CI doesn't silently create new screenshots

* Discard changes to packages/shared-components/src/room/composer/Banner/Banner.stories.tsx

* Delete stale snapshots
2026-04-23 09:24:31 +00:00
Michael Telatynski 8f9953f419 Fix flaky test src/room/composer/Banner/Banner.stories.tsx > With Avatar Image (#33275)
* Fix flaky test `src/room/composer/Banner/Banner.stories.tsx > With Avatar Image`

it was previously loading an external (slow) image which was random (!) by design

Fixes https://github.com/element-hq/element-web/issues/33273

* Fix styling

* Iterate

* Update snapshot
2026-04-23 08:26:48 +00:00
Michael Telatynski 12df09bd4b Move playwright-common wait-on from devDependencies to dependencies (#33272)
as it is needed by the `playwright-screenshots.sh` script which is exposed to dependants
2026-04-23 07:51:39 +00:00
Richard van der Hoff cd515444a8 Confirm before inviting unknown users to a DM/room (#33171)
* InviteDialog: factor out startDmOrSendInvites

Factor out the logic of calling `startDm` or `inviteUsers` to a helper
function. We're going to need to call this from a second location soon, so this
is useful groundwork.

* Add `UnknownIdentityUsersWarningDialog`

* Add unit tests

* Update playwright tests

* Convert if/else to switch statement

* Convert helper functions to React components

* Factor out "onRemove" callback

* Add clarifying comment
2026-04-22 20:05:31 +00:00
Florian Duros f4c62abbcd Room list: assign room to custom section (#33238)
* feat(sc): add new toast type for room list

* feat(sc): add section entries in room list item menu

* feat(rls): expose util functions

* feat: allows to tag room with custom sections

* feat(vm): add new Chat moved toast to room list vm

* feat(vm): add section selection to room list item vm

* feat(e2e): add tests for adding room in a custom section

* test(e2e): update existing screenshots

* chore: fix lint after merge

* chore: remove outline in test
2026-04-22 19:50:54 +00:00
Richard van der Hoff 73e1b87075 Sonar: exclude tests from duplication check (#33271)
* Sonar: exclude tests from duplication check

* cleanup

* more cleanup
2026-04-22 15:54:41 +00:00
Michael Telatynski 4b4289e211 Implement new design for Welcome page (#33211)
* Convert welcome.html to React component

In advance of changes to use Compound

* Fix types

* Fix tests

* Update styling to match Figma

* Fix random capitalisation

* Tweak styling

* Regenerate i18n

* Update tests

* Make linter happy

* Iterate
2026-04-22 15:32:05 +00:00
renovate[bot] 7b89d84acb Update npm non-major dependencies (#33246)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-04-22 13:44:05 +00:00
Will Hunt 9df7182c0c Redesign link previews (#33061)
* Commit design update

* Add figma links

* Check in other changes

* revert accidental change

* Iterative update

* linting n test fiddles

* linting

* Cleanup

* update snaps

* Move URL previews to new home

* Fix paths

* compress img

* Add back all the stories

* Improved rendering

* Fixup

* Update previews again

* lint

* update stories

* Update snaps again

* More screenshots

* Also these

* Update snaps

* include site name

* Update snaps again

* Use a scale so the images don't go blur

* update snaps again

* Update snaps

* remove mistaken playwright cfg

* update pw snaps

* update snap

* update previews

* Update with new designs

* Update screenshots
2026-04-22 13:23:24 +00:00
renovate[bot] 2d16498fe6 Update dependency typescript to v6 (#32927)
* Update dependency typescript to v6

* Switch to unplugin-vts

Workaround for https://github.com/qmhc/unplugin-dts/issues/467

And tweak tsconfigs

* tweak tsconfig

* Make tsc happy

---------

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Michael Telatynski <7t3chguy@gmail.com>
2026-04-22 12:28:54 +00:00
renovate[bot] 021e222719 Update nginxinc/nginx-unprivileged:alpine-slim Docker digest to 360465d (#33231)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-04-22 12:26:26 +00:00
Florian Duros 9df9fb9428 Room list: scroll to newly creation section (#33210)
* feat(rls): emit tag when section is created

* feat(vm): scroll to newly section tag

* feat(view): scroll to new section
2026-04-22 12:21:41 +00:00
Michael Telatynski 29411f0ded Speed up PR CI (#33239)
* Speed up PR CI

By skipping some desktop tests in pull requests and instead only running them in the Merge Queue

* Fix comment
2026-04-22 10:55:29 +00:00
Michael Telatynski 5fc98d0a36 Add stopUpdatingLabel to Renovate configuration (#33237) 2026-04-22 10:15:42 +00:00
renovate[bot] a08c34142f Update dependency vite to v8 (#33252)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-04-22 08:48:19 +00:00
renovate[bot] 44a2c9936d Update react (#33247)
* Update react

* Pin back react-resizeable-panels as it overwrites onFocus/onBlur

* Roll back react-resizable panels fully

---------

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Michael Telatynski <7t3chguy@gmail.com>
2026-04-22 08:44:21 +00:00
Michael Telatynski f0eb95495e Renovate group resolutions/overrides separately (#33260)
* Renovate group resolutions/overrides separately

* Update pnpm resolutions to pnpm overrides

* Update pnpm overrides group configuration

* Update Renovate configuration for pnpm overrides

* Update renovate.json

* Update renovate.json

* Update renovate.json

* Fix formatting in renovate.json for groupSlug

* Update renovate.json
2026-04-22 09:01:43 +00:00
renovate[bot] 4437dadef6 Update dependency storybook-addon-vis to v4 (#33251)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-04-22 08:30:27 +00:00
ElementRobot 193cdff562 [create-pull-request] automated change (#33262)
Co-authored-by: t3chguy <2403652+t3chguy@users.noreply.github.com>
2026-04-22 08:24:30 +00:00
renovate[bot] d01f40bf27 Update dependency html-react-parser to v6 (#33250)
* Update dependency html-react-parser to v6

* Fix renderer utils

* Fix CodeBlock similarly

---------

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Michael Telatynski <7t3chguy@gmail.com>
2026-04-21 22:20:09 +00:00
renovate[bot] 1a87865134 Update vite (#33258)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Michael Telatynski <7t3chguy@gmail.com>
2026-04-21 22:13:01 +00:00
renovate[bot] 764892bd41 Update tj-actions/changed-files action to v47 (#33259)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-04-21 21:49:44 +00:00
Michael Telatynski fb263ee511 Fix Module API versioning (#33233)
* Fix Module API versioning

* Attempt #2
2026-04-21 21:47:07 +00:00
renovate[bot] 8fa7b5ca2c Update dependency babel-loader to v10.1.1 (#33235)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-04-21 20:31:17 +00:00
renovate[bot] e568ed8aac Update dependency caniuse-lite to v1.0.30001788 (#33245)
* Update dependency caniuse-lite to v1.0.30001788

* Update tests

---------

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Michael Telatynski <7t3chguy@gmail.com>
2026-04-21 20:27:27 +00:00
renovate[bot] abb014553b Update peter-evans/create-pull-request digest to 5f6978f (#33256)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-04-21 20:09:23 +00:00
renovate[bot] ae8769e12d Update dependency uuid to v14 (#33229)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-04-21 19:49:00 +00:00
renovate[bot] fd86405338 Update sigstore/cosign-installer action to v4 (#33253)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-04-21 19:42:42 +00:00
renovate[bot] a0195fc4d6 Update dependency @vector-im/compound-web to v9 (#33249)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-04-21 19:40:28 +00:00
renovate[bot] ced3c25785 Update Node.js to d2059a9 (#33243)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-04-21 19:39:41 +00:00
renovate[bot] 370d2ec7d2 Update Node.js to d1b3b4d (#33242)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-04-21 19:39:27 +00:00
renovate[bot] 14917f9df5 Update robinraju/release-downloader digest to 28fc21f (#33244)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-04-21 19:38:30 +00:00
renovate[bot] 6423f2d8c0 Update dependency electron to v41.2.2 (#33254)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-04-21 19:34:49 +00:00
renovate[bot] feae8ed8b5 Update eslint-plugins (#33248)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-04-21 19:32:26 +00:00
renovate[bot] 549bdb8cb7 Update ghcr.io/element-hq/synapse:develop Docker digest to b2fec2c (#33241)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-04-21 19:31:26 +00:00
renovate[bot] e1b62c3370 Update css (#33234)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-04-21 19:28:54 +00:00
Michael Telatynski 354a05d89f Remove dependency on uuid (#33230)
* Remove dependency on `uuid`

* Delint
2026-04-21 16:36:27 +00:00
renovate[bot] 86ea6bd6b9 Update rust:bullseye Docker digest to 949b090 (#33232)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-04-21 15:22:24 +00:00
renovate[bot] a054e785ea Update docker.io/docker/dockerfile Docker tag to v1.23 (#33236)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-04-21 15:22:08 +00:00
renovate[bot] 42009ce6ec Update vite (#33224)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-04-21 14:47:42 +00:00
renovate[bot] 356119da79 Update dependency @axe-core/playwright to v4.11.2 (#33223)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-04-21 14:32:50 +00:00
renovate[bot] d823d633e3 Update dependency sanitize-filename to v1.6.4 (#33227)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-04-21 14:19:54 +00:00
Richard van der Hoff 1f6d1dbc0d Avoid nx jest executor for running unit tests (#33220)
* Avoid nx jest executor for running unit tests

The jest executor mangles the "summary of failing tests" from jest.

* Remove unneded dep on nx/jest
2026-04-21 14:07:54 +00:00
renovate[bot] 12a3abc0d5 Update dependency @element-hq/element-call-embedded to v0.19.1 (#33225)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-04-21 14:03:32 +00:00
renovate[bot] e90bc4a2f3 Update electron-builder to v26.9.0 (#33222)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-04-21 14:00:02 +00:00
renovate[bot] 0d9f205505 Update webpack (#33228)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-04-21 13:10:20 +00:00
renovate[bot] ac9ef6c2a2 Update Node.js to v24.15.0 (#33226)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-04-21 12:55:05 +00:00
renovate[bot] 4c474f5639 Update electron (#33221)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-04-21 12:48:56 +00:00
228 changed files with 6336 additions and 4410 deletions
@@ -11,7 +11,7 @@ runs:
using: composite
steps:
- name: Download release tarball
uses: robinraju/release-downloader@daf26c55d821e836577a15f77d86ddc078948b05 # v1
uses: robinraju/release-downloader@28fc21f50d76778e7023361aa1f863e717d3d56f # v1
with:
tag: ${{ inputs.tag }}
fileName: element-*.tar.gz*
+15 -1
View File
@@ -2,25 +2,39 @@
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
"extends": ["github>matrix-org/renovate-config-element-web"],
"postUpdateOptions": ["pnpmDedupe"],
"stopUpdatingLabel": "X-Blocked",
"packageRules": [
{
"description": "Group all testcontainers docker digests",
"groupName": "testcontainers docker digests",
"groupSlug": "testcontainers-docker",
"matchDepTypes": ["testcontainers-docker"],
"matchPackageNames": ["*"]
},
{
"description": "Separate updates to overrides from other groups",
"matchDepTypes": ["pnpm.overrides"],
"groupSlug": null
},
{
"description": "Disable any major updates to overrides as this almost always is wrong",
"matchDepTypes": ["pnpm.overrides"],
"matchUpdateTypes": ["major"],
"enabled": false
}
],
"customManagers": [
{
"description": "Update testcontainers docker digests",
"customType": "regex",
"datasourceTemplate": "docker",
"versioningTemplate": "loose",
"description": "Update testcontainers docker digests",
"managerFilePatterns": ["**/testcontainers/*.ts"],
"matchStrings": ["\\s+\"(?<depName>[^@]+):(?<currentValue>[^@]+)@(?<currentDigest>sha256:[a-f0-9]+)\""],
"depTypeTemplate": "testcontainers-docker"
},
{
"description": "Update element-desktop hakDependencies",
"customType": "jsonata",
"managerFilePatterns": ["/(^|/)package\\.json$/"],
"fileFormat": "json",
+9 -1
View File
@@ -206,6 +206,8 @@ jobs:
needs: prepare_ed
name: "Desktop Windows"
uses: ./.github/workflows/build_desktop_windows.yaml
# Skip Windows builds on PRs, as the Linux amd64 build is enough of a smoke test and includes the screenshot tests
if: github.event_name != 'pull_request' || contains(github.event.pull_request.labels.*.name, 'X-Run-All-Tests')
strategy:
matrix:
arch: [x64, ia32, arm64]
@@ -223,10 +225,13 @@ jobs:
arch: [amd64, arm64]
runAllTests:
- ${{ github.event_name != 'pull_request' || contains(github.event.pull_request.labels.*.name, 'X-Run-All-Tests') }}
# We ship static sqlcipher builds, so delegate testing the system builds to the merge queue
exclude:
# We ship static sqlcipher builds, so delegate testing the system builds to the merge queue
- runAllTests: false
sqlcipher: system
# Additionally skip arm64 system builds on PRs, as the amd64 test is enough for a smoke test and includes the screenshot tests
- runAllTests: false
arch: arm64
with:
sqlcipher: ${{ matrix.sqlcipher }}
arch: ${{ matrix.arch }}
@@ -236,6 +241,9 @@ jobs:
needs: prepare_ed
name: "Desktop macOS"
uses: ./.github/workflows/build_desktop_macos.yaml
# Skip macOS builds on PRs, as the Linux amd64 build is enough of a smoke test and includes the screenshot tests
# and we have a very low limit of concurrent macos runners (5) across the Github org.
if: github.event_name != 'pull_request' || contains(github.event.pull_request.labels.*.name, 'X-Run-All-Tests')
with:
blob_report: true
+1 -1
View File
@@ -126,7 +126,7 @@ jobs:
- name: "Get modified files"
id: changed_files
if: steps.cache.outputs.cache-hit != 'true' && github.event_name == 'pull_request' && github.repository == 'element-hq/element-web'
uses: tj-actions/changed-files@823fcebdb31bb35fdf2229d9f769b400309430d0 # v46
uses: tj-actions/changed-files@9426d40962ed5378910ee2e21d5f8c6fcbf2dd96 # v47
with:
files: |
apps/desktop/dockerbuild/**
+1 -1
View File
@@ -31,7 +31,7 @@ jobs:
persist-credentials: false
- name: Install Cosign
uses: sigstore/cosign-installer@398d4b0eeef1380460a10c8013a76f728fb906ac # v3
uses: sigstore/cosign-installer@cad07c2e89fa2edd6e2d7bab4c1aa38e53f76003 # v4.1.1
- name: Set up QEMU
uses: docker/setup-qemu-action@ce360397dd3f832beb865e1373c09c0e9f86d70a # v4
+1 -1
View File
@@ -26,7 +26,7 @@ jobs:
persist-credentials: false
- name: Install Cosign
uses: sigstore/cosign-installer@398d4b0eeef1380460a10c8013a76f728fb906ac # v3
uses: sigstore/cosign-installer@cad07c2e89fa2edd6e2d7bab4c1aa38e53f76003 # v4.1.1
if: github.event_name != 'pull_request'
- name: Set up QEMU
@@ -25,9 +25,6 @@ jobs:
actions: read
deployments: write
steps:
- name: Install tree
run: "sudo apt-get install -y tree"
- name: Download Diffs
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
with:
@@ -45,11 +45,12 @@ jobs:
working-directory: packages/shared-components
run: "pnpm test:storybook --run"
# Workaround for vis silently adding new baselines if they didn't exist
# Can be removed once https://github.com/repobuddy/visual-testing/issues/516 is released
- run: |
git add -N .
git diff --exit-code
- name: Detect stale screenshots
run: |
if diff -rq __baselines__ __results__ | grep "^Only in __baselines__"; then
exit 1
fi
working-directory: packages/shared-components/__vis__/linux
- name: Upload received images & diffs
if: always()
+1 -1
View File
@@ -103,7 +103,7 @@ jobs:
voip|element_call
error|invalid_json
error|misconfigured
welcome_to_element
welcome|title_element
devtools|settings|elementCallUrl
labs|sliding_sync_description
settings|voip|noise_suppression_description
+1 -1
View File
@@ -27,7 +27,7 @@ jobs:
run: "pnpm vendor:jitsi"
- name: Create Pull Request
uses: peter-evans/create-pull-request@c0f553fe549906ede9cf27b5156039d195d2ece0 # v8
uses: peter-evans/create-pull-request@5f6978faf089d4d20b00c7766989d076bb2fc7f1 # v8
with:
token: ${{ secrets.ELEMENT_BOT_TOKEN }}
branch: actions/jitsi-update
+1 -1
View File
@@ -1 +1 @@
24.14.1
24.15.0
+1 -1
View File
@@ -1,7 +1,7 @@
# Docker image to facilitate building Element Desktop's native bits using a glibc version (2.31)
# with broader compatibility, down to Debian bullseye & Ubuntu focal.
FROM rust:bullseye@sha256:bc19574c121fe10c1bc68fc2b1ea9b420d87d047a0c50fb1622b282199700cee
FROM rust:bullseye@sha256:949b0903defbfc4e374dc85f947b153859e9ee0104e425cd9a74d94474a9a335
ENV DEBIAN_FRONTEND=noninteractive
+2 -1
View File
@@ -1,6 +1,7 @@
{
"compilerOptions": {
"moduleResolution": "node",
"moduleResolution": "node16",
"module": "Node16",
"esModuleInterop": true,
"target": "es2022",
"sourceMap": false,
+7 -7
View File
@@ -62,13 +62,13 @@
"electron-window-state": "^5.0.3",
"minimist": "^1.2.6",
"png-to-ico": "^3.0.0",
"uuid": "^13.0.0"
"uuid": "^14.0.0"
},
"devDependencies": {
"@babel/core": "^7.18.10",
"@babel/preset-env": "^7.18.10",
"@babel/preset-typescript": "^7.18.6",
"@electron/asar": "4.1.2",
"@electron/asar": "4.2.0",
"@electron/fuses": "^2.1.1",
"@playwright/test": "catalog:",
"@stylistic/eslint-plugin": "^5.0.0",
@@ -79,12 +79,12 @@
"@types/pacote": "^11.1.1",
"@typescript-eslint/eslint-plugin": "^8.0.0",
"@typescript-eslint/parser": "^8.0.0",
"app-builder-lib": "26.8.2",
"app-builder-lib": "26.9.0",
"chokidar": "^5.0.0",
"detect-libc": "^2.0.0",
"electron": "41.1.0",
"electron-builder": "26.8.2",
"electron-builder-squirrel-windows": "26.8.2",
"electron": "41.2.2",
"electron-builder": "26.9.0",
"electron-builder-squirrel-windows": "26.9.0",
"electron-devtools-installer": "^4.0.0",
"eslint": "^8.26.0",
"eslint-config-google": "^0.14.0",
@@ -100,7 +100,7 @@
"prettier": "^3.0.0",
"rimraf": "^6.0.0",
"tar": "^7.5.8",
"typescript": "5.9.3"
"typescript": "6.0.3"
},
"hakDependencies": {
"matrix-seshat": "4.2.0"
@@ -17,14 +17,14 @@ import { PassThrough } from "node:stream";
* A PassThrough stream that captures all data written to it.
*/
class CapturedPassThrough extends PassThrough {
private _chunks = [];
private _chunks: any[] = [];
public constructor() {
super();
super.on("data", this.onData);
}
private onData = (chunk): void => {
private onData = (chunk: any): void => {
this._chunks.push(chunk);
};
Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 MiB

After

Width:  |  Height:  |  Size: 957 KiB

+4 -3
View File
@@ -1,11 +1,12 @@
{
"compilerOptions": {
"resolveJsonModule": true,
"moduleResolution": "node",
"moduleResolution": "bundler",
"esModuleInterop": true,
"target": "es2022",
"module": "es2022",
"lib": ["es2022", "dom"],
"module": "ESNext",
"lib": ["es2024", "dom", "dom.iterable"],
"strictNullChecks": false,
"types": ["node"]
},
"include": ["**/*.ts"]
+3 -3
View File
@@ -1,8 +1,8 @@
# syntax=docker.io/docker/dockerfile:1.22-labs@sha256:4c116b618ed48404d579b5467127b20986f2a6b29e4b9be2fee841f632db6a86
# syntax=docker.io/docker/dockerfile:1.23-labs@sha256:7eca9451d94f9b8ad22e44988b92d595d3e4d65163794237949a8c3413fbed5d
# Context must be the root of the monorepo
# Builder
FROM --platform=$BUILDPLATFORM node:24-bullseye@sha256:27e462f5db2402700867dfa8ec35e3a68b127fdf61b505db0dd6ab98c38284bb AS builder
FROM --platform=$BUILDPLATFORM node:24-bullseye@sha256:d2059a9c157c9f70739736979fa3635008bf3ca74560b30930dc181228bc427f AS builder
# Support custom branch of the js-sdk. This also helps us build images of element-web develop.
ARG USE_CUSTOM_SDKS=false
@@ -25,7 +25,7 @@ RUN --mount=type=bind,source=.git,target=/src/.git /src/scripts/docker-package.s
RUN cp /src/apps/web/config.sample.json /src/apps/web/webapp/config.json
# App
FROM nginxinc/nginx-unprivileged:alpine-slim@sha256:b5831ee7f7aa827cbae87df4a30a642f62c747d8525f5674365389f3adab278d
FROM nginxinc/nginx-unprivileged:alpine-slim@sha256:360465db60105a4cbf5215cd9e5a2ba40ef956978dd94f99707e9674050e38ea
# Need root user to install packages & manipulate the usr directory
USER root
+6 -7
View File
@@ -72,7 +72,7 @@
"glob-to-regexp": "^0.4.1",
"highlight.js": "^11.3.1",
"html-entities": "^2.0.0",
"html-react-parser": "^5.2.2",
"html-react-parser": "^6.0.0",
"is-ip": "^5.0.0",
"js-xxhash": "^5.0.0",
"jsrsasign": "^11.0.0",
@@ -89,7 +89,7 @@
"opus-recorder": "^8.0.3",
"pako": "^2.0.3",
"png-chunks-extract": "^1.0.0",
"posthog-js": "1.364.7",
"posthog-js": "1.369.3",
"qrcode": "1.5.4",
"re-resizable": "6.11.2",
"react": "catalog:",
@@ -104,7 +104,6 @@
"sanitize-html": "2.17.3",
"tar-js": "^0.3.0",
"ua-parser-js": "1.0.40",
"uuid": "^13.0.0",
"what-input": "^5.2.10"
},
"devDependencies": {
@@ -126,7 +125,7 @@
"@babel/preset-react": "^7.12.10",
"@babel/preset-typescript": "^7.12.7",
"@casualbot/jest-sonar-reporter": "2.6.0",
"@element-hq/element-call-embedded": "0.18.0",
"@element-hq/element-call-embedded": "0.19.1",
"@element-hq/element-web-playwright-common": "workspace:*",
"@fetch-mock/jest": "^0.2.20",
"@jest/globals": "^30.2.0",
@@ -205,17 +204,17 @@
"mini-css-extract-plugin": "2.10.2",
"modernizr": "^3.12.0",
"playwright-core": "catalog:",
"postcss": "8.5.8",
"postcss": "8.5.10",
"postcss-easings": "4.0.0",
"postcss-hexrgba": "2.1.0",
"postcss-import": "16.1.1",
"postcss-loader": "8.2.1",
"postcss-mixins": "12.1.2",
"postcss-nested": "7.0.2",
"postcss-preset-env": "11.2.0",
"postcss-preset-env": "11.2.1",
"postcss-scss": "4.0.9",
"postcss-simple-vars": "7.0.1",
"prettier": "3.8.1",
"prettier": "3.8.3",
"process": "^0.11.10",
"raw-loader": "^4.0.2",
"semver": "^7.5.2",
@@ -20,7 +20,7 @@ test.use({
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("heading", { name: "Be in your element" })).toBeVisible();
await expect(page.getByRole("link", { name: "Sign in" })).toBeVisible();
});
@@ -8,7 +8,7 @@ Please see LICENSE files in the repository root for full details.
*/
import type { Locator, Page } from "@playwright/test";
import { test, expect } from "../../element-web-test";
import { test, expect, type ExtendedToMatchScreenshotOptions } from "../../element-web-test";
import { SettingLevel } from "../../../src/settings/SettingLevel";
import { Layout } from "../../../src/settings/enums/Layout";
import { type ElementAppPage } from "../../pages/ElementAppPage";
@@ -94,7 +94,7 @@ test.describe("Audio player", { tag: ["@no-firefox", "@no-webkit"] }, () => {
// Assert that rendering of the player settled and the play button is visible before taking a snapshot
await checkPlayerVisibility(ircTile);
const screenshotOptions = {
const screenshotOptions: ExtendedToMatchScreenshotOptions = {
css: `
/* The timestamp is of inconsistent width depending on the time the test runs at */
.mx_MessageTimestamp {
@@ -120,7 +120,7 @@ test.describe("Audio player", { tag: ["@no-firefox", "@no-webkit"] }, () => {
};
// Take a snapshot of mx_EventTile_last on IRC layout
screenshotOptions.clip = await page.locator(".mx_EventTile_last").boundingBox();
screenshotOptions.clip = (await page.locator(".mx_EventTile_last").boundingBox()) ?? undefined;
await scrollToBottomOfTimeline(page);
await expect(page).toMatchScreenshot(`${detail.replaceAll(" ", "-")}-irc-layout.png`, screenshotOptions);
@@ -129,7 +129,7 @@ test.describe("Audio player", { tag: ["@no-firefox", "@no-webkit"] }, () => {
const groupTile = page.locator(".mx_EventTile_last[data-layout='group']");
await groupTile.locator(".mx_MessageTimestamp").click();
await checkPlayerVisibility(groupTile);
screenshotOptions.clip = await page.locator(".mx_EventTile_last").boundingBox();
screenshotOptions.clip = (await page.locator(".mx_EventTile_last").boundingBox()) ?? undefined;
await scrollToBottomOfTimeline(page);
await expect(page).toMatchScreenshot(`${detail.replaceAll(" ", "-")}-group-layout.png`, screenshotOptions);
@@ -138,7 +138,7 @@ test.describe("Audio player", { tag: ["@no-firefox", "@no-webkit"] }, () => {
const bubbleTile = page.locator(".mx_EventTile_last[data-layout='bubble']");
await bubbleTile.locator(".mx_MessageTimestamp").click();
await checkPlayerVisibility(bubbleTile);
screenshotOptions.clip = await page.locator(".mx_EventTile_last").boundingBox();
screenshotOptions.clip = (await page.locator(".mx_EventTile_last").boundingBox()) ?? undefined;
await scrollToBottomOfTimeline(page);
await expect(page).toMatchScreenshot(`${detail.replaceAll(" ", "-")}-bubble-layout.png`, screenshotOptions);
};
@@ -27,6 +27,9 @@ const startDMWithBob = async (page: Page, bob: Bot) => {
await page.getByRole("option", { name: bob.credentials.displayName }).click();
await expect(page.getByTestId("invite-dialog-input-wrapper").getByText("Bob")).toBeVisible();
await page.getByRole("button", { name: "Go" }).click();
await expect(page.getByRole("heading", { name: "Start a chat with this new contact?" })).toBeVisible();
await page.getByRole("button", { name: "Continue" }).click();
};
const testMessages = async (page: Page, bob: Bot, bobRoomId: string) => {
@@ -44,7 +47,7 @@ const bobJoin = async (page: Page, bob: Bot) => {
const bobRooms = cli.getRooms();
if (!bobRooms.length) {
await new Promise<void>((resolve) => {
const onMembership = (_event) => {
const onMembership = () => {
cli.off(window.matrixcs.RoomMemberEvent.Membership, onMembership);
resolve();
};
@@ -6,7 +6,7 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com
Please see LICENSE files in the repository root for full details.
*/
import type { EmittedEvents, Preset } from "matrix-js-sdk/src/matrix";
import type { Preset, RoomMemberEvent, RoomStateEvent } from "matrix-js-sdk/src/matrix";
import { expect, test } from "../../element-web-test";
import {
createRoom,
@@ -122,7 +122,7 @@ test.describe("Cryptography", function () {
const roomId = await bob.evaluate(
async (client, { alice }) => {
const encryptionStatePromise = new Promise<void>((resolve) => {
client.on("RoomState.events" as EmittedEvents, (event, _state, _lastStateEvent) => {
client.on("RoomState.events" as RoomStateEvent.Events, (event, _state, _lastStateEvent) => {
if (event.getType() === "m.room.encryption") {
resolve();
}
@@ -253,11 +253,14 @@ test.describe("Cryptography", function () {
// invite Alice
const inviteAlicePromise = new Promise<void>((resolve) => {
client.on("RoomMember.membership" as EmittedEvents, (_event, member, _oldMembership?) => {
if (member.userId === alice.userId && member.membership === "invite") {
resolve();
}
});
client.on(
"RoomMember.membership" as RoomMemberEvent.Membership,
(_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
@@ -271,11 +274,14 @@ test.describe("Cryptography", function () {
// kick Alice
const kickAlicePromise = new Promise<void>((resolve) => {
client.on("RoomMember.membership" as EmittedEvents, (_event, member, _oldMembership?) => {
if (member.userId === alice.userId && member.membership === "leave") {
resolve();
}
});
client.on(
"RoomMember.membership" as RoomMemberEvent.Membership,
(_event, member, _oldMembership?) => {
if (member.userId === alice.userId && member.membership === "leave") {
resolve();
}
},
);
});
await client.kick(roomId, alice.userId);
await kickAlicePromise;
@@ -166,13 +166,9 @@ async function getDehydratedDeviceIds(client: Client): Promise<string[]> {
return await client.evaluate(async (client) => {
const userId = client.getUserId();
const devices = await client.getCrypto().getUserDeviceInfo([userId]);
return Array.from(
devices
.get(userId)
.values()
.filter((d) => d.dehydrated)
.map((d) => d.deviceId),
);
return Array.from(devices.get(userId).values())
.filter((d) => d.dehydrated)
.map((d) => d.deviceId);
});
}
@@ -49,7 +49,7 @@ test.describe("History sharing", function () {
await sendMessageInCurrentRoom(alicePage, "A message from Alice");
// Send the invite to Bob
await aliceElementApp.inviteUserToCurrentRoom(bobCredentials.userId);
await aliceElementApp.inviteUserToCurrentRoom(bobCredentials.userId, { confirmUnknownUser: true });
// Bob accepts the invite
await bobPage.getByRole("option", { name: "TestRoom" }).click();
@@ -105,7 +105,7 @@ test.describe("History sharing", function () {
// Alice invites Bob, and Bob accepts
const roomId = await aliceElementApp.getCurrentRoomIdFromUrl();
await aliceElementApp.inviteUserToCurrentRoom(bobCredentials.userId);
await aliceElementApp.inviteUserToCurrentRoom(bobCredentials.userId, { confirmUnknownUser: true });
await bobPage.getByRole("option", { name: "TestRoom" }).click();
await bobPage.getByRole("button", { name: "Accept" }).click();
@@ -143,7 +143,7 @@ test.describe("History sharing", function () {
await sendMessageInCurrentRoom(bobPage, "Message3: 'shared' visibility, but Bob thinks it is still 'joined'");
// Alice now invites Charlie
await aliceElementApp.inviteUserToCurrentRoom(charlieCredentials.userId);
await aliceElementApp.inviteUserToCurrentRoom(charlieCredentials.userId, { confirmUnknownUser: true });
await charliePage.getByRole("option", { name: "TestRoom" }).click();
await charliePage.getByRole("button", { name: "Accept" }).click();
+2 -2
View File
@@ -579,8 +579,8 @@ export async function deleteCachedSecrets(page: Page) {
await page.evaluate(async () => {
const removeCachedSecrets = new Promise((resolve) => {
const request = window.indexedDB.open("matrix-js-sdk::matrix-sdk-crypto");
request.onsuccess = (event: Event & { target: { result: IDBDatabase } }) => {
const db = event.target.result;
request.onsuccess = function (this: IDBRequest) {
const db = this.result as IDBDatabase;
const request = db.transaction("core", "readwrite").objectStore("core").delete("private_identity");
request.onsuccess = () => {
db.close();
@@ -9,6 +9,15 @@ Please see LICENSE files in the repository root for full details.
import { test, expect } from "../../element-web-test";
/**
* CSS which will hide the mxid in the user list of the "unknown users" confirmation dialog. This is useful because the
* MXID is not stable and the screenshot tests will otherwise fail.
*
* Ideally RichItem would give us a way to do this that doesn't involve gnarly CSS.
*/
const UNKNOWN_IDENTITY_USERS_DIALOG_HIDE_MXID_CSS =
'[data-testid="userlist"] li > span:nth-last-child(1) { display: none }';
test.describe("Invite dialog", function () {
test.use({
displayName: "Hanako",
@@ -62,6 +71,15 @@ test.describe("Invite dialog", function () {
// Invite the bot
await other.getByRole("button", { name: "Invite" }).click();
// Expect a confirmation dialog, screenshot, and dismiss
await expect(
page.locator(".mx_Dialog").getByRole("heading", { name: "Invite new contacts to this room?" }),
).toBeVisible();
await expect(page.locator(".mx_Dialog")).toMatchScreenshot("confirm-invite-new-contact.png", {
css: UNKNOWN_IDENTITY_USERS_DIALOG_HIDE_MXID_CSS,
});
await page.locator(".mx_Dialog").getByRole("button", { name: "Invite" }).click();
// Assert that the invite dialog disappears
await expect(page.locator(".mx_InviteDialog_other")).not.toBeVisible();
@@ -104,6 +122,15 @@ test.describe("Invite dialog", function () {
// Open a direct message UI
await other.getByRole("button", { name: "Go" }).click();
// Expect a confirmation dialog, screenshot, and dismiss
await expect(
page.locator(".mx_Dialog").getByRole("heading", { name: "Start a chat with this new contact?" }),
).toBeVisible();
await expect(page.locator(".mx_Dialog")).toMatchScreenshot("confirm-chat-with-new-contact.png", {
css: UNKNOWN_IDENTITY_USERS_DIALOG_HIDE_MXID_CSS,
});
await page.locator(".mx_Dialog").getByRole("button", { name: "Continue" }).click();
// Assert that the invite dialog disappears
await expect(page.locator(".mx_InviteDialog_other")).not.toBeVisible();
@@ -175,4 +175,93 @@ test.describe("Room list custom sections", () => {
await expect(getSectionHeader(page, "Low Priority")).toBeVisible();
});
});
test.describe("Adding a room to a custom section", () => {
/**
* Asserts a room is nested under a specific section using the treegrid aria-level hierarchy.
* Section header rows sit at aria-level=1; room rows nested within a section sit at aria-level=2.
* Verifies that the closest preceding aria-level=1 row is the expected section header.
*/
async function assertRoomInSection(page: Page, sectionName: string, roomName: string): Promise<void> {
const roomList = getRoomList(page);
const roomRow = roomList.getByRole("row", { name: `Open room ${roomName}` });
// Room row must be at aria-level=2 (i.e. inside a section)
await expect(roomRow).toHaveAttribute("aria-level", "2");
// The closest preceding aria-level=1 row must be the expected section header.
// XPath preceding:: axis returns nodes before the context in document order; [1] picks the nearest one.
const closestSectionHeader = roomRow.locator(`xpath=preceding::*[@role="row" and @aria-level="1"][1]`);
await expect(closestSectionHeader).toContainText(sectionName);
}
test("should add a room to a custom section via the More Options menu", async ({ page, app }) => {
await app.client.createRoom({ name: "my room" });
await createCustomSection(page, "Work");
const roomList = getRoomList(page);
// Room starts in Chats section (aria-level=2)
const roomItem = roomList.getByRole("row", { name: "Open room my room" });
await expect(roomItem).toBeVisible();
// Open More Options and move to the Work section
await roomItem.hover();
await roomItem.getByRole("button", { name: "More Options" }).click();
await page.getByRole("menuitem", { name: "Move to" }).hover();
await page.getByRole("menuitem", { name: "Work" }).click();
// Room should now be nested under the Work section header (aria-level=1 → aria-level=2)
await assertRoomInSection(page, "Work", "my room");
});
test(
"should show 'Chat moved' toast when adding a room to a custom section",
{ tag: "@screenshot" },
async ({ page, app }) => {
await app.client.createRoom({ name: "my room" });
await createCustomSection(page, "Work");
const roomList = getRoomList(page);
const roomItem = roomList.getByRole("row", { name: "Open room my room" });
await roomItem.hover();
await roomItem.getByRole("button", { name: "More Options" }).click();
await page.getByRole("menuitem", { name: "Move to" }).hover();
await page.getByRole("menuitem", { name: "Work" }).click();
// The "Chat moved" toast should appear
await expect(page.getByText("Chat moved")).toBeVisible();
// Remove focus outline from the room item before taking the screenshot
await page.getByRole("button", { name: "User menu" }).focus();
await expect(roomList).toMatchScreenshot("room-list-sections-chat-moved-toast.png");
},
);
test("should remove a room from a custom section when toggling the same section", async ({ page, app }) => {
await app.client.createRoom({ name: "my room" });
await createCustomSection(page, "Work");
const roomList = getRoomList(page);
// Move to Work section and verify placement via aria-level
let roomItem = roomList.getByRole("row", { name: "Open room my room" });
await roomItem.hover();
await roomItem.getByRole("button", { name: "More Options" }).click();
await page.getByRole("menuitem", { name: "Move to" }).hover();
await page.getByRole("menuitem", { name: "Work" }).click();
await assertRoomInSection(page, "Work", "my room");
// Toggle off by selecting the same section again
roomItem = roomList.getByRole("row", { name: "Open room my room" });
await roomItem.hover();
await roomItem.getByRole("button", { name: "More Options" }).click();
await page.getByRole("menuitem", { name: "Move to" }).hover();
await page.getByRole("menuitem", { name: "Work" }).click();
// Room is back in the Chats section
await assertRoomInSection(page, "Chats", "my room");
});
});
});
@@ -126,7 +126,7 @@ test.describe("Login", () => {
await page.goto("/");
// Should give us the welcome page initially
await expect(page.getByRole("heading", { name: "Welcome to Element!" })).toBeVisible();
await expect(page.getByRole("heading", { name: "Be in your element" })).toBeVisible();
// Start the login process
await expect(axe).toHaveNoViolations();
@@ -252,6 +252,7 @@ test.describe("Message url previews", () => {
"og:title": "A simple site",
"og:description": "And with a brief description",
"og:image": mxc,
"og:image:alt": "The riot logo",
},
});
});
@@ -7,7 +7,7 @@ Please see LICENSE files in the repository root for full details.
*/
import type { JSHandle } from "@playwright/test";
import type { MatrixEvent, ISendEventResponse, ReceiptType } from "matrix-js-sdk/src/matrix";
import type { MatrixEvent, ISendEventResponse, ReceiptType, RelationType } from "matrix-js-sdk/src/matrix";
import { expect } from "../../element-web-test";
import { type ElementAppPage } from "../../pages/ElementAppPage";
import { type Bot } from "../../pages/bot";
@@ -47,7 +47,7 @@ test.describe("Read receipts", { tag: "@mergequeue" }, () => {
getId: () => eventResponse.event_id,
threadRootId,
getTs: () => 1,
isRelation: (relType) => {
isRelation: (relType: RelationType) => {
return !relType || relType === "m.thread";
},
} as any as MatrixEvent;
@@ -57,6 +57,9 @@ test.describe("Create Room", () => {
await page.getByRole("button", { name: "Go" }).click();
await expect(page.getByRole("heading", { name: "Start a chat with this new contact?" })).toBeVisible();
await page.getByRole("button", { name: "Continue" }).click();
await expect(page.getByText("Encryption enabled")).toBeVisible();
await expect(page.getByText("Send your first message to")).toBeVisible();
@@ -163,6 +163,10 @@ test.describe("Room Status Bar", () => {
).toBeVisible();
await other.getByRole("option", { name: "Alice" }).click();
await other.getByRole("button", { name: "Go" }).click();
await expect(page.getByRole("heading", { name: "Start a chat with this new contact?" })).toBeVisible();
await page.getByRole("button", { name: "Continue" }).click();
// Send a message to invite the bots
const composer = app.getComposerField();
await composer.fill("Hello");
@@ -33,7 +33,7 @@ test.describe("Other people's devices section in Encryption tab", () => {
// Create the room and invite bob
await createRoom(alicePage, "TestRoom", true);
await aliceElementApp.inviteUserToCurrentRoom(bobCredentials.userId);
await aliceElementApp.inviteUserToCurrentRoom(bobCredentials.userId, { confirmUnknownUser: true });
// Bob accepts the invite
await bobPage.getByRole("option", { name: "TestRoom" }).click();
@@ -72,7 +72,7 @@ test.describe("Other people's devices section in Encryption tab", () => {
// Create the room and invite bob
await createRoom(alicePage, "TestRoom", true);
await aliceElementApp.inviteUserToCurrentRoom(bobCredentials.userId);
await aliceElementApp.inviteUserToCurrentRoom(bobCredentials.userId, { confirmUnknownUser: true });
// Bob accepts the invite
await bobPage.getByRole("option", { name: "TestRoom" }).click();
@@ -115,7 +115,7 @@ test.describe("Other people's devices section in Encryption tab", () => {
// Create the room and invite bob
await createRoom(alicePage, "TestRoom", true);
await aliceElementApp.inviteUserToCurrentRoom(bobCredentials.userId);
await aliceElementApp.inviteUserToCurrentRoom(bobCredentials.userId, { confirmUnknownUser: true });
// Bob accepts the invite and dismisses the warnings.
await bobPage.getByRole("option", { name: "TestRoom" }).click();
@@ -149,7 +149,7 @@ test.describe("Other people's devices section in Encryption tab", () => {
// Alice creates the room and invite Bob.
await createRoom(alicePage, "TestRoom", true);
await aliceElementApp.inviteUserToCurrentRoom(bobCredentials.userId);
await aliceElementApp.inviteUserToCurrentRoom(bobCredentials.userId, { confirmUnknownUser: true });
// Bob accepts the invite.
await bobPage.getByRole("option", { name: "TestRoom" }).click();
@@ -214,7 +214,7 @@ test.describe("Other people's devices section in Encryption tab", () => {
// Alice creates the room and invite Bob.
await createRoom(alicePage, "TestRoom", true);
await aliceElementApp.inviteUserToCurrentRoom(bobCredentials.userId);
await aliceElementApp.inviteUserToCurrentRoom(bobCredentials.userId, { confirmUnknownUser: true });
// Bob accepts the invite.
await bobPage.getByRole("option", { name: "TestRoom" }).click();
@@ -7,7 +7,7 @@ Please see LICENSE files in the repository root for full details.
*/
import type { Locator, Page } from "@playwright/test";
import type { ISendEventResponse, EventType, MsgType } from "matrix-js-sdk/src/matrix";
import type { ISendEventResponse, EventType, MsgType, IContent } from "matrix-js-sdk/src/matrix";
import { test, expect } from "../../element-web-test";
import { SettingLevel } from "../../../src/settings/SettingLevel";
import { Layout } from "../../../src/settings/enums/Layout";
@@ -50,11 +50,9 @@ const expectAvatar = async (cli: Client, e: Locator, avatarUrl: string): Promise
};
const sendEvent = async (client: Client, roomId: string, html = false): Promise<ISendEventResponse> => {
const content = {
const content: IContent = {
msgtype: "m.text" as MsgType,
body: "Message",
format: undefined,
formatted_body: undefined,
};
if (html) {
content.format = "org.matrix.custom.html";
+2 -2
View File
@@ -42,7 +42,7 @@ export async function waitForRoom(
return new Promise<Room>((resolve) => {
const room = matrixClient.getRoom(roomId);
if (window[predicateId](room)) {
if ((<any>window)[predicateId](room)) {
resolve(room);
return;
}
@@ -50,7 +50,7 @@ export async function waitForRoom(
function onEvent(ev: MatrixEvent) {
if (ev.getRoomId() !== roomId) return;
if (window[predicateId](room)) {
if ((<any>window)[predicateId](room)) {
matrixClient.removeListener("event" as ClientEvent, onEvent);
resolve(room);
}
+1 -1
View File
@@ -107,7 +107,7 @@ export const test = base.extend<TestFixtures>({
},
});
interface ExtendedToMatchScreenshotOptions extends ToMatchScreenshotOptions {
export interface ExtendedToMatchScreenshotOptions extends ToMatchScreenshotOptions {
includeDialogBackground?: boolean;
showTooltips?: boolean;
timeout?: number;
+18 -3
View File
@@ -233,15 +233,30 @@ export class ElementAppPage {
* Open the room info panel, and use it to send an invite to the given user.
*
* @param userId - The user to invite to the room.
* @param options - Options object
*/
public async inviteUserToCurrentRoom(userId: string): Promise<void> {
public async inviteUserToCurrentRoom(
userId: string,
options?: {
/** If true, expect and acknowledge "Confirm inviting new users" page */
confirmUnknownUser?: boolean;
},
): Promise<void> {
const rightPanel = await this.openRoomInfoPanel();
await rightPanel.getByRole("menuitem", { name: "Invite" }).click();
const input = this.page.getByRole("dialog").getByTestId("invite-dialog-input");
const dialogLocator = this.page.getByRole("dialog");
const input = dialogLocator.getByTestId("invite-dialog-input");
await input.fill(userId);
await input.press("Enter");
await this.page.getByRole("dialog").getByRole("button", { name: "Invite" }).click();
await dialogLocator.getByRole("button", { name: "Invite" }).click();
if (options?.confirmUnknownUser) {
await expect(
dialogLocator.getByRole("heading", { name: "Invite new contacts to this room?" }),
).toBeVisible();
await dialogLocator.getByRole("button", { name: "Invite" }).click();
}
}
/**
+29 -1
View File
@@ -7,7 +7,7 @@ Please see LICENSE files in the repository root for full details.
*/
import { type JSHandle, type Page } from "@playwright/test";
import { type PageFunctionOn } from "playwright-core/types/structs";
import { type ElementHandle } from "playwright-core";
import { Network } from "./network";
import type {
@@ -30,6 +30,34 @@ import type {
import type { RoomMessageEventContent } from "matrix-js-sdk/src/types";
import { type CredentialsOptionalAccessToken } from "./bot";
/** Types cribbed from playwright-core/types/structs as they are not importable */
export type NoHandles<Arg> = Arg extends JSHandle
? never
: Arg extends object
? { [Key in keyof Arg]: NoHandles<Arg[Key]> }
: Arg;
export type Unboxed<Arg> =
Arg extends ElementHandle<infer T>
? T
: Arg extends JSHandle<infer T>
? T
: Arg extends NoHandles<Arg>
? Arg
: Arg extends [infer A0]
? [Unboxed<A0>]
: Arg extends [infer A0, infer A1]
? [Unboxed<A0>, Unboxed<A1>]
: Arg extends [infer A0, infer A1, infer A2]
? [Unboxed<A0>, Unboxed<A1>, Unboxed<A2>]
: Arg extends [infer A0, infer A1, infer A2, infer A3]
? [Unboxed<A0>, Unboxed<A1>, Unboxed<A2>, Unboxed<A3>]
: Arg extends Array<infer T>
? Array<Unboxed<T>>
: Arg extends object
? { [Key in keyof Arg]: Unboxed<Arg[Key]> }
: Arg;
export type PageFunctionOn<On, Arg2, R> = string | ((on: On, arg2: Unboxed<Arg2>) => R | Promise<R>);
export class Client {
public network: Network;
protected client: JSHandle<MatrixClient>;
Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.1 KiB

After

Width:  |  Height:  |  Size: 9.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.4 KiB

After

Width:  |  Height:  |  Size: 9.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 29 KiB

+13 -13
View File
@@ -21,7 +21,7 @@ const DEFAULT_CONFIG = {
global: {
server_name: "localhost",
private_key: "matrix_key.pem",
old_private_keys: null,
old_private_keys: null as any,
key_validity_period: "168h0m0s",
cache: {
max_size_estimated: "1gb",
@@ -47,7 +47,7 @@ const DEFAULT_CONFIG = {
room_name: "Server Alerts",
},
jetstream: {
addresses: null,
addresses: null as any,
disable_tls_validation: false,
storage_path: "./",
topic_prefix: "Dendrite",
@@ -67,7 +67,7 @@ const DEFAULT_CONFIG = {
},
app_service_api: {
disable_tls_validation: false,
config_files: null,
config_files: null as any,
},
client_api: {
registration_disabled: false,
@@ -79,14 +79,14 @@ const DEFAULT_CONFIG = {
recaptcha_bypass_secret: "",
turn: {
turn_user_lifetime: "5m",
turn_uris: null,
turn_uris: null as any,
turn_shared_secret: "",
},
rate_limiting: {
enabled: true,
threshold: 20,
cooloff_ms: 500,
exempt_user_ids: null,
exempt_user_ids: null as any,
},
},
federation_api: {
@@ -140,7 +140,7 @@ const DEFAULT_CONFIG = {
},
},
mscs: {
mscs: null,
mscs: null as any,
database: {
connection_string: "file:dendrite-msc.db",
},
@@ -157,7 +157,7 @@ const DEFAULT_CONFIG = {
},
user_api: {
bcrypt_cost: 10,
auto_join_rooms: null,
auto_join_rooms: null as any,
account_database: {
connection_string: "file:dendrite-userapi.db",
},
@@ -183,12 +183,12 @@ const DEFAULT_CONFIG = {
serviceName: "",
disabled: false,
rpc_metrics: false,
tags: [],
sampler: null,
reporter: null,
headers: null,
baggage_restrictions: null,
throttler: null,
tags: [] as any[],
sampler: null as any,
reporter: null as any,
headers: null as any,
baggage_restrictions: null as any,
throttler: null as any,
},
},
logging: [
@@ -8,7 +8,7 @@ Please see LICENSE files in the repository root for full details.
import { SynapseContainer as BaseSynapseContainer } from "@element-hq/element-web-playwright-common/lib/testcontainers/index.js";
const DOCKER_IMAGE =
"ghcr.io/element-hq/synapse:develop@sha256:926d95954cba30a2568dbe907da6628d8e10e06f2b19901f0ec61eb2993be450";
"ghcr.io/element-hq/synapse:develop@sha256:b2fec2c9460f5b297a3a4ce78037902590240a1978301ed1d4bc97918c451041";
/**
* SynapseContainer which freezes the docker digest to stabilise tests,
+5 -3
View File
@@ -2,12 +2,14 @@
"compilerOptions": {
"target": "es2022",
"jsx": "react",
"lib": ["ESNext", "es2022", "dom", "dom.iterable"],
"lib": ["es2024", "dom", "dom.iterable"],
"resolveJsonModule": true,
"esModuleInterop": true,
"moduleResolution": "node",
"module": "es2022",
"moduleResolution": "bundler",
"module": "ESNext",
"allowImportingTsExtensions": true,
"strictNullChecks": false,
"noImplicitAny": false,
"types": ["node"]
},
"include": [
+3 -5
View File
@@ -47,11 +47,9 @@
"dependsOn": ["^build", "^build:playwright"]
},
"test:unit": {
"executor": "@nx/jest:jest",
"options": {
"jestConfig": "{projectRoot}/jest.config.ts",
"cwd": "apps/web"
},
// We avoid the jest executor because it doesn't seem to give any benefit, and it mangles the summary of failing tests.
"command": "jest",
"options": { "cwd": "apps/web" },
"dependsOn": ["^build"]
},
"test:playwright": {
+11 -5
View File
@@ -598,6 +598,7 @@ legend {
.mx_AccessSecretStorageDialog button,
.mx_InviteDialog_section button,
.mx_InviteDialog_editor button,
.mx_UnknownIdentityUsersWarningDialog button,
[class|="maplibregl"]
),
.mx_Dialog_buttons button:not(.mx_Dialog_nonDialogButton, .mx_AccessibleButton),
@@ -625,7 +626,8 @@ legend {
.mx_ThemeChoicePanel_CustomTheme button,
.mx_UnpinAllDialog button,
.mx_ShareDialog button,
.mx_EncryptionUserSettingsTab button
.mx_EncryptionUserSettingsTab button,
.mx_UnknownIdentityUsersWarningDialog button
):last-child {
margin-right: 0px;
}
@@ -641,7 +643,8 @@ legend {
.mx_ShareDialog button,
.mx_EncryptionUserSettingsTab button,
.mx_InviteDialog_section button,
.mx_InviteDialog_editor button
.mx_InviteDialog_editor button,
.mx_UnknownIdentityUsersWarningDialog button
):focus,
.mx_Dialog input[type="submit"]:focus,
.mx_Dialog_buttons button:not(.mx_Dialog_nonDialogButton, .mx_AccessibleButton):focus,
@@ -659,7 +662,8 @@ legend {
.mx_ThemeChoicePanel_CustomTheme button,
.mx_UnpinAllDialog button,
.mx_ShareDialog button,
.mx_EncryptionUserSettingsTab button
.mx_EncryptionUserSettingsTab button,
.mx_UnknownIdentityUsersWarningDialog button
),
.mx_Dialog_buttons input[type="submit"].mx_Dialog_primary {
color: var(--cpd-color-text-on-solid-primary);
@@ -678,7 +682,8 @@ legend {
.mx_ThemeChoicePanel_CustomTheme button,
.mx_UnpinAllDialog button,
.mx_ShareDialog button,
.mx_EncryptionUserSettingsTab button
.mx_EncryptionUserSettingsTab button,
.mx_UnknownIdentityUsersWarningDialog button
),
.mx_Dialog_buttons input[type="submit"].danger {
background-color: var(--cpd-color-bg-critical-primary);
@@ -701,7 +706,8 @@ legend {
.mx_ThemeChoicePanel_CustomTheme button,
.mx_UnpinAllDialog button,
.mx_ShareDialog button,
.mx_EncryptionUserSettingsTab button
.mx_EncryptionUserSettingsTab button,
.mx_UnknownIdentityUsersWarningDialog button
):disabled,
.mx_Dialog input[type="submit"]:disabled,
.mx_Dialog_buttons button:not(.mx_Dialog_nonDialogButton, .mx_AccessibleButton):disabled,
+2
View File
@@ -105,6 +105,7 @@
@import "./views/auth/_AuthPage.pcss";
@import "./views/auth/_CompleteSecurityBody.pcss";
@import "./views/auth/_CountryDropdown.pcss";
@import "./views/auth/_DefaultWelcome.pcss";
@import "./views/auth/_InteractiveAuthEntryComponents.pcss";
@import "./views/auth/_LanguageSelector.pcss";
@import "./views/auth/_LoginWithQR.pcss";
@@ -170,6 +171,7 @@
@import "./views/dialogs/_UserSettingsDialog.pcss";
@import "./views/dialogs/_VerifyEMailDialog.pcss";
@import "./views/dialogs/_WidgetCapabilitiesPromptDialog.pcss";
@import "./views/dialogs/invite/_UnknownIdentityUsersWarningDialog.pcss";
@import "./views/dialogs/security/_AccessSecretStorageDialog.pcss";
@import "./views/dialogs/security/_CreateCrossSigningDialog.pcss";
@import "./views/dialogs/security/_CreateSecretStorageDialog.pcss";
@@ -0,0 +1,43 @@
/*
Copyright 2026 Element Creations Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
.mx_DefaultWelcome {
text-align: center;
.mx_DefaultWelcome_logo img {
height: 48px;
aspect-ratio: auto;
display: block;
margin: 0 auto;
}
h1 {
margin: var(--cpd-space-4x) 0 var(--cpd-space-2x);
}
p {
color: var(--cpd-color-text-secondary);
margin-top: var(--cpd-space-2x);
}
.mx_DefaultWelcome_buttons {
margin: var(--cpd-space-6x) 0 var(--cpd-space-1x);
padding-bottom: var(--cpd-space-4x);
border-bottom: 1px solid var(--cpd-color-separator-primary);
a {
width: 380px;
margin-bottom: var(--cpd-space-4x);
}
}
}
.mx_WelcomePage_registrationDisabled {
.mx_DefaultWelcome_buttons_register {
display: none;
}
}
+5 -1
View File
@@ -9,6 +9,10 @@ Please see LICENSE files in the repository root for full details.
display: flex;
flex-direction: column;
align-items: center;
background-color: var(--cpd-color-bg-canvas-default);
box-sizing: border-box;
padding: var(--cpd-space-11x) var(--cpd-space-12x) var(--cpd-space-4x);
&.mx_WelcomePage_registrationDisabled {
.mx_ButtonCreateAccount {
display: none;
@@ -18,7 +22,7 @@ Please see LICENSE files in the repository root for full details.
.mx_Welcome .mx_AuthBody_language {
width: 160px;
margin-bottom: 10px;
margin: var(--cpd-space-1x) 0;
}
/* Invert image colours in dark mode. */
@@ -0,0 +1,45 @@
/*
Copyright 2026 Element Creations Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
.mx_UnknownIdentityUsersWarningDialog {
display: flex;
flex-direction: column;
height: 600px; /* Consistency with InviteDialog */
}
.mx_UnknownIdentityUsersWarningDialog_headerContainer {
/* Centre the PageHeader component horizontally */
display: flex;
justify-content: center;
/* Styling for the regular text inside the header */
font: var(--cpd-font-body-lg-regular);
/* Space before the list */
padding-bottom: var(--cpd-space-6x);
}
.mx_UnknownIdentityUsersWarningDialog_userList {
width: 100%;
overflow: auto;
/* Fill available vertical space, but don't allow it to shrink to less than 60px (about the height of a single tile) */
flex: 1 0 60px;
/* Remove browser default ul padding/margin */
padding: 0;
margin: 0;
}
.mx_UnknownIdentityUsersWarningDialog_buttons {
display: flex;
gap: var(--cpd-space-4x);
> button {
flex: 1;
}
}
-191
View File
@@ -1,191 +0,0 @@
<style type="text/css">
/* we deliberately inline style here to avoid flash-of-CSS problems, and to avoid
* voodoo where we have to set display: none by default
*/
.mx_Header_title::after {
content: "!";
}
.mx_Parent {
display: -webkit-box;
display: -webkit-flex;
display: -ms-flexbox;
display: flex;
-webkit-box-orient: vertical;
-webkit-box-direction: normal;
-webkit-flex-direction: column;
-ms-flex-direction: column;
flex-direction: column;
-webkit-box-pack: center;
-webkit-justify-content: center;
-ms-flex-pack: center;
justify-content: center;
-webkit-box-align: center;
-webkit-align-items: center;
-ms-flex-align: center;
align-items: center;
text-align: center;
padding: 25px 35px;
}
.mx_Logo {
height: 54px;
margin-top: 2px;
}
.mx_ButtonGroup {
margin-top: 10px;
}
.mx_ButtonRow {
display: -webkit-box;
display: -webkit-flex;
display: -ms-flexbox;
display: flex;
-webkit-justify-content: space-around;
-ms-flex-pack: distribute;
-webkit-box-align: center;
-webkit-align-items: center;
-ms-flex-align: center;
align-items: center;
justify-content: space-between;
box-sizing: border-box;
margin: 12px 0 0;
}
.mx_ButtonRow > * {
margin: 0 10px;
}
.mx_ButtonRow > *:first-child {
margin-left: 0;
}
.mx_ButtonRow > *:last-child {
margin-right: 0;
}
.mx_ButtonParent {
display: -webkit-box;
display: -webkit-flex;
display: -ms-flexbox;
display: flex;
padding: 10px 20px;
-webkit-box-orient: horizontal;
-webkit-box-direction: normal;
-webkit-flex-direction: row;
-ms-flex-direction: row;
flex-direction: row;
-webkit-box-pack: center;
-webkit-justify-content: center;
-ms-flex-pack: center;
justify-content: center;
-webkit-box-align: center;
-webkit-align-items: center;
-ms-flex-align: center;
align-items: center;
border-radius: 4px;
width: 150px;
background-repeat: no-repeat;
background-position: 10px center;
text-decoration: none;
color: #2e2f32 !important;
}
.mx_ButtonLabel {
margin-left: 20px;
}
.mx_Header_title {
font-size: 24px;
font-weight: 600;
margin: 20px 0 0;
}
.mx_Header_subtitle {
font-size: 12px;
font-weight: normal;
margin: 8px 0 0;
}
.mx_ButtonSignIn {
background-color: #368bd6;
color: white !important;
}
.mx_ButtonCreateAccount {
background-color: #0dbd8b;
color: white !important;
}
.mx_SecondaryButton {
background-color: #ffffff;
color: #2e2f32;
}
.mx_Button_iconSignIn {
background-image: url("welcome/images/icon-sign-in.svg");
}
.mx_Button_iconCreateAccount {
background-image: url("welcome/images/icon-create-account.svg");
}
.mx_Button_iconHelp {
background-image: url("welcome/images/icon-help.svg");
}
.mx_Button_iconRoomDirectory {
background-image: url("welcome/images/icon-room-directory.svg");
}
/*
.mx_WelcomePage_loggedIn is applied by EmbeddedPage from the Welcome component
If it is set on the page, we should show the buttons. Otherwise, we have to assume
we don't have an account and should hide them. No account == no guest account either.
*/
.mx_WelcomePage:not(.mx_WelcomePage_loggedIn) .mx_WelcomePage_guestFunctions {
display: none;
}
.mx_ButtonRow.mx_WelcomePage_guestFunctions {
margin-top: 20px;
}
.mx_ButtonRow.mx_WelcomePage_guestFunctions > div {
margin: 0 auto;
}
@media only screen and (max-width: 480px) {
.mx_ButtonRow {
flex-direction: column;
}
.mx_ButtonRow > * {
margin: 0 0 10px 0;
}
}
</style>
<div class="mx_Parent">
<a href="https://element.io" target="_blank" rel="noopener">
<img src="$logoUrl" alt="$brand" class="mx_Logo" />
</a>
<h1 class="mx_Header_title">_t("welcome_to_element")</h1>
<!-- XXX: Our translations system isn't smart enough to recognize variables in the HTML, so we manually do it -->
<h2 class="mx_Header_subtitle">_t("powered_by_matrix_with_logo")</h2>
<div class="mx_ButtonGroup">
<div class="mx_ButtonRow">
<a href="#/login" class="mx_ButtonParent mx_ButtonSignIn mx_Button_iconSignIn">
<div class="mx_ButtonLabel">_t("action|sign_in")</div>
</a>
<a href="#/register" class="mx_ButtonParent mx_ButtonCreateAccount mx_Button_iconCreateAccount">
<div class="mx_ButtonLabel">_t("action|create_account")</div>
</a>
</div>
<div class="mx_ButtonRow mx_WelcomePage_guestFunctions">
<div>
<a href="#/directory" class="mx_ButtonParent mx_SecondaryButton mx_Button_iconRoomDirectory">
<div class="mx_ButtonLabel">_t("action|explore_rooms")</div>
</a>
</div>
</div>
</div>
</div>
@@ -1,3 +0,0 @@
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M17 9C17 13.4183 13.4183 17 9 17C4.58172 17 1 13.4183 1 9C1 4.58172 4.58172 1 9 1C13.4183 1 17 4.58172 17 9ZM5.25 9C5.25 8.58579 5.58579 8.25 6 8.25H8.25V6C8.25 5.58579 8.58579 5.25 9 5.25C9.41421 5.25 9.75 5.58579 9.75 6V8.25H12C12.4142 8.25 12.75 8.58579 12.75 9C12.75 9.41421 12.4142 9.75 12 9.75H9.75V12C9.75 12.4142 9.41421 12.75 9 12.75C8.58579 12.75 8.25 12.4142 8.25 12V9.75H6C5.58579 9.75 5.25 9.41421 5.25 9Z" fill="white"/>
</svg>

Before

Width:  |  Height:  |  Size: 587 B

-16
View File
@@ -1,16 +0,0 @@
<svg width="22px" height="22px" viewBox="0 0 22 22" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<g id="Experiments" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd" stroke-linecap="round" stroke-linejoin="round">
<g id="Home" transform="translate(-672.000000, -577.000000)" stroke="#000000" stroke-width="1.6">
<g id="Group-11" transform="translate(621.000000, 176.000000)">
<g id="Group-10" transform="translate(39.000000, 391.000000)">
<g id="help-circle" transform="translate(13.000000, 11.000000)">
<circle id="Oval" cx="10" cy="10" r="10"></circle>
<path d="M7.09,7 C7.57543688,5.62004444 8.98538362,4.79140632 10.4271763,5.0387121 C11.868969,5.28601788 12.9221794,6.53715293 12.92,8 C12.92,10 9.92,11 9.92,11" id="Path"></path>
<path d="M10,15 L10.0050017,15.0050017" id="Path"></path>
</g>
</g>
</g>
</g>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 1.1 KiB

@@ -1,4 +0,0 @@
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M17 9C17 13.4183 13.4183 17 9 17C4.58172 17 1 13.4183 1 9C1 4.58172 4.58172 1 9 1C13.4183 1 17 4.58172 17 9ZM13.375 5.3266C13.5583 4.92826 13.0716 4.44152 12.6733 4.62491L7.66968 6.9285C7.33893 7.08077 7.08014 7.33956 6.92787 7.67031L4.62428 12.6739C4.44089 13.0722 4.92763 13.559 5.32597 13.3756L10.3295 11.072C10.6603 10.9197 10.9191 10.6609 11.0714 10.3302L13.375 5.3266Z" fill="black"/>
<path d="M9.8835 9.88413C9.39534 10.3723 8.60389 10.3723 8.11573 9.88413C7.62757 9.39597 7.62757 8.60452 8.11573 8.11636C8.60389 7.62821 9.39534 7.62821 9.8835 8.11636C10.3717 8.60452 10.3717 9.39597 9.8835 9.88413Z" fill="black"/>
</svg>

Before

Width:  |  Height:  |  Size: 775 B

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 53 KiB

+10
View File
@@ -52,3 +52,13 @@ export type AtLeastOne<T, U = { [K in keyof T]: Pick<T, K> }> = Partial<T> & U[k
export type Assignable<Object, Item> = {
[Key in keyof Object]: Object[Key] extends Item ? Key : never;
}[keyof Object];
/**
* Like `Partial` but for applied to all nested objects.
* Based on https://dev.to/perennialautodidact/adventures-in-typescript-deeppartial-2f2a
*/
export type DeepPartial<T> = T extends object
? {
[P in keyof T]?: DeepPartial<T[P]>;
}
: T;
@@ -1,9 +1,8 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2020 The Matrix.org Foundation C.I.C.
Copyright 2026 Element Creations Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
export type FocusHandler = () => void;
declare module "*.css";
-9
View File
@@ -8,7 +8,6 @@ Please see LICENSE files in the repository root for full details.
// eslint-disable-next-line no-restricted-imports
import "matrix-js-sdk/src/@types/global"; // load matrix-js-sdk's type extensions first
import "@types/modernizr";
import type { ModuleLoader } from "@element-hq/element-web-module-api";
import type { logger } from "matrix-js-sdk/src/logger";
@@ -186,14 +185,6 @@ 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,
+8
View File
@@ -0,0 +1,8 @@
/*
Copyright 2026 Element Creations Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
declare module "*.pcss";
+3 -2
View File
@@ -52,8 +52,9 @@ export interface IConfigOptions {
disable_3pid_login?: boolean;
brand: string;
branding?: {
welcome_background_url?: string | string[]; // chosen at random if array
branding: {
welcome_background_url: string | string[]; // chosen at random if array
logo_link_url: string;
auth_header_logo_url?: string;
auth_footer_links?: { text: string; url: string }[];
};
+1 -2
View File
@@ -13,7 +13,6 @@ import { type Interaction as InteractionEvent } from "@matrix-org/analytics-even
import { type PinUnpinAction } from "@matrix-org/analytics-events/types/typescript/PinUnpinAction";
import { type RoomListSortingAlgorithmChanged } from "@matrix-org/analytics-events/types/typescript/RoomListSortingAlgorithmChanged";
import { type UrlPreviewRendered } from "@matrix-org/analytics-events/types/typescript/UrlPreviewRendered";
import { type UrlPreview } from "@element-hq/web-shared-components";
import PageType from "./PageTypes";
import Views from "./Views";
@@ -151,7 +150,7 @@ export default class PosthogTrackers {
* @param isEncrypted Whether the event (and effectively the room) was encrypted.
* @param previews The previews generated from the event.
*/
public trackUrlPreview(eventId: string, isEncrypted: boolean, previews: UrlPreview[]): void {
public trackUrlPreview(eventId: string, isEncrypted: boolean, previews: { image?: unknown }[]): void {
// Discount any previews that we have already tracked.
if (this.previewedEventIds.get(eventId)) {
return;
+8 -3
View File
@@ -12,11 +12,16 @@ import { mergeWith } from "lodash";
import { SnakedObject } from "./utils/SnakedObject";
import { type IConfigOptions } from "./IConfigOptions";
import { isObject, objectClone } from "./utils/objects";
import { type DeepReadonly, type Defaultize } from "./@types/common";
import { type DeepPartial, type DeepReadonly, type Defaultize } from "./@types/common";
// see element-web config.md for docs, or the IConfigOptions interface for dev docs
export const DEFAULTS: DeepReadonly<IConfigOptions> = {
brand: "Element",
branding: {
logo_link_url: "https://element.io",
auth_header_logo_url: "themes/element/img/logos/element-logo.svg",
welcome_background_url: "themes/element/img/backgrounds/lake.jpg",
},
help_url: "https://element.io/help",
help_encryption_url: "https://element.io/help#encryption",
help_key_storage_url: "https://element.io/help#encryption5",
@@ -70,7 +75,7 @@ export type ConfigOptions = Defaultize<IConfigOptions, typeof DEFAULTS>;
function mergeConfig(
config: DeepReadonly<IConfigOptions>,
changes: DeepReadonly<Partial<IConfigOptions>>,
changes: DeepReadonly<DeepPartial<IConfigOptions>>,
): DeepReadonly<IConfigOptions> {
// return { ...config, ...changes };
return mergeWith(objectClone(config), changes, (objValue, srcValue) => {
@@ -136,7 +141,7 @@ export default class SdkConfig {
SdkConfig.setInstance(mergeConfig(DEFAULTS, {})); // safe to cast - defaults will be applied
}
public static add(cfg: Partial<ConfigOptions>): void {
public static add(cfg: DeepPartial<ConfigOptions>): void {
SdkConfig.put(mergeConfig(SdkConfig.get(), cfg));
}
}
+32 -373
View File
@@ -6,23 +6,21 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com
Please see LICENSE files in the repository root for full details.
*/
import React, {
createContext,
useCallback,
useContext,
useMemo,
useRef,
useReducer,
type Reducer,
type Dispatch,
type RefObject,
type ReactNode,
type RefCallback,
} from "react";
import React from "react";
import {
RovingAction,
RovingTabIndexProvider as SharedRovingTabIndexProvider,
type RovingTabIndexProviderProps,
} from "@element-hq/web-shared-components";
import { getKeyBindingsManager } from "../KeyBindingsManager";
import { KeyBindingAction } from "./KeyboardShortcuts";
import { type FocusHandler } from "./roving/types";
export { findNextSiblingElement, RovingTabIndexContext } from "@element-hq/web-shared-components";
export { checkInputableElement } from "@element-hq/web-shared-components";
export { RovingStateActionType } from "@element-hq/web-shared-components";
export { useRovingTabIndex } from "@element-hq/web-shared-components";
export type { IAction, IState } from "@element-hq/web-shared-components";
/**
* Module to simplify implementing the Roving TabIndex accessibility technique
@@ -37,370 +35,31 @@ import { type FocusHandler } from "./roving/types";
* https://developer.mozilla.org/en-US/docs/Web/Accessibility/Keyboard-navigable_JavaScript_widgets#Technique_1_Roving_tabindex
*/
// Check for form elements which utilize the arrow keys for native functions
// like many of the text input varieties.
//
// i.e. it's ok to press the down arrow on a radio button to move to the next
// radio. But it's not ok to press the down arrow on a <input type="text"> to
// move away because the down arrow should move the cursor to the end of the
// input.
export function checkInputableElement(el: HTMLElement): boolean {
return el.matches('input:not([type="radio"]):not([type="checkbox"]), textarea, select, [contenteditable=true]');
}
export interface IState {
activeNode?: HTMLElement;
nodes: HTMLElement[];
}
export interface IContext {
state: IState;
dispatch: Dispatch<IAction>;
}
export const RovingTabIndexContext = createContext<IContext>({
state: {
nodes: [], // list of nodes in DOM order
},
dispatch: () => {},
});
RovingTabIndexContext.displayName = "RovingTabIndexContext";
export enum Type {
Register = "REGISTER",
Unregister = "UNREGISTER",
SetFocus = "SET_FOCUS",
Update = "UPDATE",
}
export interface IAction {
type: Exclude<Type, Type.Update>;
payload: {
node: HTMLElement;
};
}
interface UpdateAction {
type: Type.Update;
payload?: undefined;
}
type Action = IAction | UpdateAction;
const nodeSorter = (a: HTMLElement, b: HTMLElement): number => {
if (a === b) {
return 0;
}
const position = a.compareDocumentPosition(b);
if (position & Node.DOCUMENT_POSITION_FOLLOWING || position & Node.DOCUMENT_POSITION_CONTAINED_BY) {
return -1;
} else if (position & Node.DOCUMENT_POSITION_PRECEDING || position & Node.DOCUMENT_POSITION_CONTAINS) {
return 1;
} else {
return 0;
}
};
export const reducer: Reducer<IState, Action> = (state: IState, action: Action) => {
switch (action.type) {
case Type.Register: {
if (!state.activeNode) {
// Our list of nodes was empty, set activeNode to this first item
state.activeNode = action.payload.node;
}
if (state.nodes.includes(action.payload.node)) return state;
// Sadly due to the potential of DOM elements swapping order we can't do anything fancy like a binary insert
state.nodes.push(action.payload.node);
state.nodes.sort(nodeSorter);
return { ...state };
}
case Type.Unregister: {
const oldIndex = state.nodes.findIndex((r) => r === action.payload.node);
if (oldIndex === -1) {
return state; // already removed, this should not happen
}
if (state.nodes.splice(oldIndex, 1)[0] === state.activeNode) {
// we just removed the active node, need to replace it
// pick the node closest to the index the old node was in
if (oldIndex >= state.nodes.length) {
state.activeNode = findSiblingElement(state.nodes, state.nodes.length - 1, true);
} else {
state.activeNode =
findSiblingElement(state.nodes, oldIndex) || findSiblingElement(state.nodes, oldIndex, true);
}
if (document.activeElement === document.body) {
// if the focus got reverted to the body then the user was likely focused on the unmounted element
setTimeout(() => state.activeNode?.focus(), 0);
}
}
// update the nodes list
return { ...state };
}
case Type.SetFocus: {
// if the node doesn't change just return the same object reference to skip a re-render
if (state.activeNode === action.payload.node) return state;
// update active node
state.activeNode = action.payload.node;
return { ...state };
}
case Type.Update: {
state.nodes.sort(nodeSorter);
return { ...state };
}
const getWebRovingAction = (ev: React.KeyboardEvent): RovingAction | undefined => {
switch (getKeyBindingsManager().getAccessibilityAction(ev)) {
case KeyBindingAction.Home:
return RovingAction.Home;
case KeyBindingAction.End:
return RovingAction.End;
case KeyBindingAction.ArrowLeft:
return RovingAction.ArrowLeft;
case KeyBindingAction.ArrowUp:
return RovingAction.ArrowUp;
case KeyBindingAction.ArrowRight:
return RovingAction.ArrowRight;
case KeyBindingAction.ArrowDown:
return RovingAction.ArrowDown;
case KeyBindingAction.Tab:
return RovingAction.Tab;
default:
return state;
return undefined;
}
};
interface IProps {
handleLoop?: boolean;
handleHomeEnd?: boolean;
handleUpDown?: boolean;
handleLeftRight?: boolean;
handleInputFields?: boolean;
scrollIntoView?: boolean | ScrollIntoViewOptions;
children(
this: void,
renderProps: {
onKeyDownHandler(this: void, ev: React.KeyboardEvent): void;
onDragEndHandler(this: void): void;
},
): ReactNode;
onKeyDown?(this: void, ev: React.KeyboardEvent, state: IState, dispatch: Dispatch<IAction>): void;
}
type IProps = Omit<RovingTabIndexProviderProps, "getAction">;
export const findSiblingElement = (
nodes: HTMLElement[],
startIndex: number,
backwards = false,
loop = false,
): HTMLElement | undefined => {
if (backwards) {
for (let i = startIndex; i < nodes.length && i >= 0; i--) {
if (nodes[i]?.offsetParent !== null) {
return nodes[i];
}
}
if (loop) {
return findSiblingElement(nodes.slice(startIndex + 1), nodes.length - 1, true, false);
}
} else {
for (let i = startIndex; i < nodes.length && i >= 0; i++) {
if (nodes[i]?.offsetParent !== null) {
return nodes[i];
}
}
if (loop) {
return findSiblingElement(nodes.slice(0, startIndex), 0, false, false);
}
}
};
export const RovingTabIndexProvider: React.FC<IProps> = ({
children,
handleHomeEnd,
handleUpDown,
handleLeftRight,
handleLoop,
handleInputFields,
scrollIntoView,
onKeyDown,
}) => {
const [state, dispatch] = useReducer<IState, [Action]>(reducer, {
nodes: [],
});
const context = useMemo<IContext>(() => ({ state, dispatch }), [state]);
const onKeyDownHandler = useCallback(
(ev: React.KeyboardEvent) => {
if (onKeyDown) {
onKeyDown(ev, context.state, context.dispatch);
if (ev.defaultPrevented) {
return;
}
}
let handled = false;
const action = getKeyBindingsManager().getAccessibilityAction(ev);
let focusNode: HTMLElement | undefined;
// Don't interfere with input default keydown behaviour
// but allow people to move focus from it with Tab.
if (!handleInputFields && checkInputableElement(ev.target as HTMLElement)) {
switch (action) {
case KeyBindingAction.Tab:
handled = true;
if (context.state.nodes.length > 0) {
const idx = context.state.nodes.indexOf(context.state.activeNode!);
focusNode = findSiblingElement(
context.state.nodes,
idx + (ev.shiftKey ? -1 : 1),
ev.shiftKey,
);
}
break;
}
} else {
// check if we actually have any items
switch (action) {
case KeyBindingAction.Home:
if (handleHomeEnd) {
handled = true;
// move focus to first (visible) item
focusNode = findSiblingElement(context.state.nodes, 0);
}
break;
case KeyBindingAction.End:
if (handleHomeEnd) {
handled = true;
// move focus to last (visible) item
focusNode = findSiblingElement(context.state.nodes, context.state.nodes.length - 1, true);
}
break;
case KeyBindingAction.ArrowDown:
case KeyBindingAction.ArrowRight:
if (
(action === KeyBindingAction.ArrowDown && handleUpDown) ||
(action === KeyBindingAction.ArrowRight && handleLeftRight)
) {
handled = true;
if (context.state.nodes.length > 0) {
const idx = context.state.nodes.indexOf(context.state.activeNode!);
focusNode = findSiblingElement(context.state.nodes, idx + 1, false, handleLoop);
}
}
break;
case KeyBindingAction.ArrowUp:
case KeyBindingAction.ArrowLeft:
if (
(action === KeyBindingAction.ArrowUp && handleUpDown) ||
(action === KeyBindingAction.ArrowLeft && handleLeftRight)
) {
handled = true;
if (context.state.nodes.length > 0) {
const idx = context.state.nodes.indexOf(context.state.activeNode!);
focusNode = findSiblingElement(context.state.nodes, idx - 1, true, handleLoop);
}
}
break;
}
}
if (handled) {
ev.preventDefault();
ev.stopPropagation();
}
if (focusNode) {
focusNode?.focus();
// programmatic focus doesn't fire the onFocus handler, so we must do the do ourselves
dispatch({
type: Type.SetFocus,
payload: {
node: focusNode,
},
});
if (scrollIntoView) {
focusNode?.scrollIntoView(scrollIntoView);
}
}
},
[
context,
onKeyDown,
handleHomeEnd,
handleUpDown,
handleLeftRight,
handleLoop,
handleInputFields,
scrollIntoView,
],
);
const onDragEndHandler = useCallback(() => {
dispatch({
type: Type.Update,
});
}, []);
return (
<RovingTabIndexContext.Provider value={context}>
{children({ onKeyDownHandler, onDragEndHandler })}
</RovingTabIndexContext.Provider>
);
};
/**
* Hook to register a roving tab index.
*
* inputRef is an optional argument; when passed this ref points to the DOM element
* to which the callback ref is attached.
*
* Returns:
* onFocus should be called when the index gained focus in any manner.
* isActive should be used to set tabIndex in a manner such as `tabIndex={isActive ? 0 : -1}`.
* ref is a callback ref that should be passed to a DOM node which will be used for DOM compareDocumentPosition.
* nodeRef is a ref that points to the DOM element to which the ref mentioned above is attached.
*
* nodeRef = inputRef when inputRef argument is provided.
*/
export const useRovingTabIndex = <T extends HTMLElement>(
inputRef?: RefObject<T | null>,
): [FocusHandler, boolean, RefCallback<T>, RefObject<T | null>] => {
const context = useContext(RovingTabIndexContext);
let nodeRef = useRef<T | null>(null);
if (inputRef) {
// if we are given a ref, use it instead of ours
nodeRef = inputRef;
}
const ref = useCallback((node: T | null) => {
if (node) {
nodeRef.current = node;
context.dispatch({
type: Type.Register,
payload: { node },
});
} else {
context.dispatch({
type: Type.Unregister,
payload: { node: nodeRef.current! },
});
nodeRef.current = null;
}
}, []); // eslint-disable-line react-hooks/exhaustive-deps
const onFocus = useCallback(() => {
if (!nodeRef.current) {
console.warn("useRovingTabIndex.onFocus called but the react ref does not point to any DOM element!");
return;
}
context.dispatch({
type: Type.SetFocus,
payload: { node: nodeRef.current },
});
}, []); // eslint-disable-line react-hooks/exhaustive-deps
// eslint-disable-next-line react-compiler/react-compiler
const isActive = context.state.activeNode === nodeRef.current;
return [onFocus, isActive, ref, nodeRef];
export const RovingTabIndexProvider: React.FC<IProps> = (props) => {
return <SharedRovingTabIndexProvider {...props} getAction={getWebRovingAction} />;
};
// re-export the semantic helper components for simplicity
@@ -6,26 +6,4 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com
Please see LICENSE files in the repository root for full details.
*/
import { type ReactElement, type RefCallback, type RefObject } from "react";
import type React from "react";
import { useRovingTabIndex } from "../RovingTabIndex";
import { type FocusHandler } from "./types";
interface IProps {
inputRef?: RefObject<HTMLElement | null>;
children(
this: void,
renderProps: {
onFocus: FocusHandler;
isActive: boolean;
ref: RefCallback<HTMLElement>;
},
): ReactElement<any, any>;
}
// Wrapper to allow use of useRovingTabIndex outside of React Functional Components.
export const RovingTabIndexWrapper: React.FC<IProps> = ({ children, inputRef }) => {
const [onFocus, isActive, ref] = useRovingTabIndex(inputRef);
return children({ onFocus, isActive, ref });
};
export { RovingTabIndexWrapper } from "@element-hq/web-shared-components";
+20
View File
@@ -0,0 +1,20 @@
/*
Copyright 2026 Element Creations Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import SdkConfig from "./SdkConfig.ts";
const ELEMENT_BRAND = "Element";
/**
* Returns whether the app is currently branded.
* This is currently a naive check of whether the `brand` config starts with the substring `Element ` or is the literal `Element`,
* which correctly covers `Element` (release), `Element Nightly` & `Element Pro`.
*/
export const isElementBranded = (): boolean => {
const brand = SdkConfig.get("brand");
return brand === ELEMENT_BRAND || brand.startsWith(ELEMENT_BRAND + " ");
};
@@ -31,16 +31,13 @@ export default class AuthPage extends React.PureComponent<React.PropsWithChildre
if (AuthPage.welcomeBackgroundUrl) return AuthPage.welcomeBackgroundUrl;
const brandingConfig = SdkConfig.getObject("branding");
AuthPage.welcomeBackgroundUrl = "themes/element/img/backgrounds/lake.jpg";
const configuredUrl = brandingConfig?.get("welcome_background_url");
if (configuredUrl) {
if (Array.isArray(configuredUrl)) {
const index = Math.floor(Math.random() * configuredUrl.length);
AuthPage.welcomeBackgroundUrl = configuredUrl[index];
} else {
AuthPage.welcomeBackgroundUrl = configuredUrl;
}
const urls = brandingConfig.get("welcome_background_url");
if (Array.isArray(urls)) {
const index = Math.floor(Math.random() * urls.length);
AuthPage.welcomeBackgroundUrl = urls[index];
} else {
AuthPage.welcomeBackgroundUrl = urls;
}
return AuthPage.welcomeBackgroundUrl;
@@ -0,0 +1,51 @@
/*
Copyright 2026 Element Creations Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import React from "react";
import { Button, Heading, Text } from "@vector-im/compound-web";
import { _t } from "../../../languageHandler";
import SdkConfig from "../../../SdkConfig.ts";
import { MatrixClientPeg } from "../../../MatrixClientPeg.ts";
import { isElementBranded } from "../../../branding.ts";
const DefaultWelcome: React.FC = () => {
const brand = SdkConfig.get("brand");
const branding = SdkConfig.getObject("branding");
const logoUrl = branding.get("auth_header_logo_url");
const showGuestFunctions = !!MatrixClientPeg.get();
const isElement = isElementBranded();
return (
<div className="mx_DefaultWelcome">
<a href={branding.get("logo_link_url")} target="_blank" rel="noopener" className="mx_DefaultWelcome_logo">
<img src={logoUrl} alt={brand} />
</a>
<Heading as="h1" weight="semibold">
{isElement ? _t("welcome|title_element") : _t("welcome|title_generic", { brand })}
</Heading>
{isElement && <Text size="md">{_t("welcome|tagline_element")}</Text>}
<div className="mx_DefaultWelcome_buttons">
<Button as="a" href="#/login" kind="primary" size="sm">
{_t("action|sign_in")}
</Button>
<Button as="a" href="#/register" kind="secondary" size="sm">
{_t("action|create_account")}
</Button>
{showGuestFunctions && (
<Button as="a" href="#/directory" kind="tertiary" size="sm">
{_t("action|explore_rooms")}
</Button>
)}
</div>
</div>
);
};
export default DefaultWelcome;
+20 -21
View File
@@ -5,9 +5,10 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com
Please see LICENSE files in the repository root for full details.
*/
import React from "react";
import React, { type ReactNode } from "react";
import classNames from "classnames";
import { type EmptyObject } from "matrix-js-sdk/src/matrix";
import { Glass } from "@vector-im/compound-web";
import SdkConfig from "../../../SdkConfig";
import AuthPage from "./AuthPage";
@@ -16,14 +17,12 @@ import { UIFeature } from "../../../settings/UIFeature";
import LanguageSelector from "./LanguageSelector";
import EmbeddedPage from "../../structures/EmbeddedPage";
import { MATRIX_LOGO_HTML } from "../../structures/static-page-vars";
import DefaultWelcome from "./DefaultWelcome.tsx";
export default class Welcome extends React.PureComponent<EmptyObject> {
public render(): React.ReactNode {
const pagesConfig = SdkConfig.getObject("embedded_pages");
let pageUrl: string | undefined;
if (pagesConfig) {
pageUrl = pagesConfig.get("welcome_url");
}
const pageUrl = pagesConfig?.get("welcome_url");
const replaceMap: Record<string, string> = {
"$brand": SdkConfig.get("brand"),
@@ -33,25 +32,25 @@ export default class Welcome extends React.PureComponent<EmptyObject> {
"[matrix]": MATRIX_LOGO_HTML,
};
if (!pageUrl) {
// Fall back to default and replace $logoUrl in welcome.html
const brandingConfig = SdkConfig.getObject("branding");
const logoUrl = brandingConfig?.get("auth_header_logo_url") ?? "themes/element/img/logos/element-logo.svg";
replaceMap["$logoUrl"] = logoUrl;
pageUrl = "welcome.html";
let body: ReactNode;
if (pageUrl) {
body = <EmbeddedPage className="mx_WelcomePage" url={pageUrl} replaceMap={replaceMap} />;
} else {
body = <DefaultWelcome />;
}
return (
<AuthPage>
<div
className={classNames("mx_Welcome", {
mx_WelcomePage_registrationDisabled: !SettingsStore.getValue(UIFeature.Registration),
})}
data-testid="mx_welcome_screen"
>
<EmbeddedPage className="mx_WelcomePage" url={pageUrl} replaceMap={replaceMap} />
<LanguageSelector />
</div>
<AuthPage addBlur={false}>
<Glass>
<div
className={classNames("mx_Welcome", {
mx_WelcomePage_registrationDisabled: !SettingsStore.getValue(UIFeature.Registration),
})}
>
{body}
<LanguageSelector />
</div>
</Glass>
</AuthPage>
);
}
@@ -50,9 +50,9 @@ import { RoomContextDetails } from "../rooms/RoomContextDetails";
import { filterBoolean } from "../../../utils/arrays";
import {
type IState,
RovingStateActionType,
RovingTabIndexContext,
RovingTabIndexProvider,
Type,
useRovingTabIndex,
} from "../../../accessibility/RovingTabIndex";
import { getKeyBindingsManager } from "../../../KeyBindingsManager";
@@ -368,7 +368,7 @@ const ForwardDialog: React.FC<IProps> = ({ matrixClient: cli, event, permalinkCr
const node = context.state.nodes[0];
if (node) {
context.dispatch({
type: Type.SetFocus,
type: RovingStateActionType.SetFocus,
payload: { node },
});
node?.scrollIntoView?.({
@@ -61,6 +61,9 @@ import { type UserProfilesStore } from "../../../stores/UserProfilesStore";
import InviteProgressBody from "./InviteProgressBody.tsx";
import MultiInviter, { type CompletionStates as MultiInviterCompletionStates } from "../../../utils/MultiInviter.ts";
import { DMRoomTile } from "./invite/DMRoomTile.tsx";
import { logErrorAndShowErrorDialog } from "../../../utils/ErrorUtils.tsx";
import UnknownIdentityUsersWarningDialog from "./invite/UnknownIdentityUsersWarningDialog.tsx";
import { AddressType, getAddressType } from "../../../UserAddress.ts";
interface Result {
userId: string;
@@ -161,6 +164,14 @@ interface IInviteDialogState {
dialPadValue: string;
currentTabId: TabId;
/**
* If we tried to invite some users whose identity we don't know, we will show a warning.
* This is the list of users. (If it is `null`, we are not showing that warning.)
*
* Will never be the empty list.
*/
unknownIdentityUsers: Member[] | null;
/**
* True if we are sending the invites.
*
@@ -230,7 +241,8 @@ export default class InviteDialog extends React.PureComponent<Props, IInviteDial
dialPadValue: "",
currentTabId: TabId.UserDirectory,
// These two flags are used for the 'Go' button to communicate what is going on.
unknownIdentityUsers: null,
busy: false,
};
}
@@ -444,6 +456,21 @@ export default class InviteDialog extends React.PureComponent<Props, IInviteDial
}
};
/**
* Start the process of actually sending invites or creating a DM.
*
* Called once we have shown the user all the necessary warnings.
*/
private async startDmOrSendInvites(): Promise<void> {
if (this.props.kind === InviteKind.Dm) {
await this.startDm();
} else if (this.props.kind === InviteKind.Invite) {
await this.inviteUsers();
} else {
throw new Error("Unknown InviteKind: " + this.props.kind);
}
}
private transferCall = async (): Promise<void> => {
if (this.props.kind !== InviteKind.CallTransfer) return;
if (this.state.currentTabId == TabId.UserDirectory) {
@@ -1123,14 +1150,49 @@ export default class InviteDialog extends React.PureComponent<Props, IInviteDial
);
}
/**
* Handle the user pressing the Go/Invite button in the "Start Chat" or "Invite users" view.
*
* We check if any of the users lack a known cryptographic identity, and show a warning if so.
*/
private async onGoButtonPressed(): Promise<void> {
this.setBusy(true);
const targets = this.convertFilter();
const unknownIdentityUsers: Member[] = [];
const cli = MatrixClientPeg.safeGet();
const crypto = cli.getCrypto();
if (crypto) {
for (const t of targets) {
const addressType = getAddressType(t.userId);
if (
addressType !== AddressType.MatrixUserId ||
!(await crypto.getUserVerificationStatus(t.userId)).known
) {
unknownIdentityUsers.push(t);
}
}
}
// If we have some users with unknown identities, show the warning page.
if (unknownIdentityUsers.length > 0) {
logger.debug(
"InviteDialog: Warning about users with unknown identities:",
unknownIdentityUsers.map((u) => u.userId),
);
this.setState({ unknownIdentityUsers: unknownIdentityUsers, busy: false });
} else {
// Otherwise, transition directly to sending the relevant invites.
await this.startDmOrSendInvites();
}
}
/**
* Render content of the "users" that is used for both invites and "start chat".
*/
private renderMainTab(): JSX.Element {
let helpText;
let buttonText;
let goButtonFn: (() => Promise<void>) | null = null;
const identityServersEnabled = SettingsStore.getValue(UIFeature.IdentityServer);
const cli = MatrixClientPeg.safeGet();
@@ -1167,7 +1229,6 @@ export default class InviteDialog extends React.PureComponent<Props, IInviteDial
}
buttonText = _t("action|go");
goButtonFn = this.startDm;
} else if (this.props.kind === InviteKind.Invite) {
const roomId = this.props.roomId;
const room = MatrixClientPeg.get()?.getRoom(roomId);
@@ -1211,11 +1272,14 @@ export default class InviteDialog extends React.PureComponent<Props, IInviteDial
);
buttonText = _t("action|invite");
goButtonFn = this.inviteUsers;
} else {
throw new Error("Unknown InviteDialog kind: " + this.props.kind);
}
const onGoButtonPressed = (): void => {
this.onGoButtonPressed().catch((e) => logErrorAndShowErrorDialog("Error processing invites", e));
};
return (
<React.Fragment>
<p className="mx_InviteDialog_helpText">{helpText}</p>
@@ -1223,7 +1287,7 @@ export default class InviteDialog extends React.PureComponent<Props, IInviteDial
{this.renderEditor()}
<AccessibleButton
kind="primary"
onClick={goButtonFn}
onClick={onGoButtonPressed}
className="mx_InviteDialog_goButton"
disabled={this.state.busy || !this.hasSelection()}
>
@@ -1235,12 +1299,49 @@ export default class InviteDialog extends React.PureComponent<Props, IInviteDial
);
}
/** Callback function, which handles the user clicking "Remove" on the {@link UnknwownIdentityUsersWarningDialog}. */
private onRemoveUnknownIdentityUsersClicked = (): void => {
// Remove the unknown identity users, then return to the previous screen
const newTargets: Member[] = [];
for (const target of this.state.targets) {
if (!this.state.unknownIdentityUsers?.find((m) => m.userId == target.userId)) {
newTargets.push(target);
}
}
this.setState({
targets: newTargets,
unknownIdentityUsers: null,
});
};
/**
* Render the complete dialog, given this is not a call transfer dialog.
*
* See also: {@link renderCallTransferDialog}.
*/
private renderRegularDialog(): React.ReactNode {
if (this.props.kind !== InviteKind.Dm && this.props.kind !== InviteKind.Invite) {
throw new Error("Unsupported InviteDialog kind: " + this.props.kind);
}
if (this.state.unknownIdentityUsers !== null) {
return (
<UnknownIdentityUsersWarningDialog
onCancel={this.props.onFinished}
onContinue={() => {
this.setState({ unknownIdentityUsers: null });
this.startDmOrSendInvites().catch((e) =>
logErrorAndShowErrorDialog("Error processing invites", e),
);
}}
onRemove={this.onRemoveUnknownIdentityUsersClicked}
screenName={this.screenName}
kind={this.props.kind}
users={this.state.unknownIdentityUsers}
/>
);
}
let title;
if (this.props.kind === InviteKind.Dm) {
title = _t("space|add_existing_room_space|dm_heading");
@@ -9,7 +9,6 @@ import React, { type ChangeEvent, useContext, useEffect, useMemo, useState } fro
import { Pill } from "@element-hq/web-shared-components";
import { MatrixEvent, type IContent, RoomStickyEventsEvent } from "matrix-js-sdk/src/matrix";
import { Alert, Form, SettingsToggleInput } from "@vector-im/compound-web";
import { v4 as uuidv4 } from "uuid";
import BaseTool, { DevtoolsContext, type IDevtoolsProps } from "./BaseTool.tsx";
import { _t, _td, UserFriendlyError } from "../../../../languageHandler.tsx";
@@ -330,7 +329,7 @@ export const StickyEventEditor: React.FC<IEditorProps> = ({ mxEvent, onBack }) =
const defaultContent = mxEvent
? stringify(mxEvent.getContent())
: stringify({
msc4354_sticky_key: uuidv4(),
msc4354_sticky_key: window.crypto.randomUUID(),
});
return <EventEditor fieldDefs={fields} defaultContent={defaultContent} onSend={onSend} onBack={onBack} />;
};
@@ -19,8 +19,8 @@ import { Icon as EmailPillAvatarIcon } from "../../../../../res/img/icon-email-p
interface IDMRoomTileProps {
member: Member;
lastActiveTs?: number;
onToggle(member: Member): void;
isSelected: boolean;
onToggle?(member: Member): void;
isSelected?: boolean;
}
/** A tile representing a single user in the "suggestions"/"recents" section of the invite dialog. */
@@ -30,7 +30,7 @@ export class DMRoomTile extends React.PureComponent<IDMRoomTileProps> {
e.preventDefault();
e.stopPropagation();
this.props.onToggle(this.props.member);
this.props.onToggle?.(this.props.member);
};
public render(): React.ReactNode {
@@ -0,0 +1,121 @@
/*
Copyright 2026 Element Creations Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import React, { type JSX, useCallback } from "react";
import { CheckIcon, CloseIcon, UserAddSolidIcon } from "@vector-im/compound-design-tokens/assets/web/icons";
import { Button, PageHeader } from "@vector-im/compound-web";
import { InviteKind } from "../InviteDialogTypes.ts";
import { type Member } from "../../../../utils/direct-messages.ts";
import BaseDialog from "../BaseDialog.tsx";
import { type ScreenName } from "../../../../PosthogTrackers.ts";
import { DMRoomTile } from "./DMRoomTile.tsx";
import { _t } from "../../../../languageHandler.tsx";
interface Props {
/** Callback that will be called when the 'Continue' or 'Invite' button is clicked. */
onContinue: () => void;
/** Callback that will be called when the 'Cancel' button is clicked. Unused unless {@link kind} is {@link InviteKind.Dm}. */
onCancel: () => void;
/** Callback that will be called when the 'Remove' button is clicked. Unused unless {@link kind} is {@link InviteKind.Invite}. */
onRemove: () => void;
/** Optional Posthog ScreenName to supply during the lifetime of this dialog. */
screenName: ScreenName | undefined;
/** The type of invite dialog: whether we are starting a new DM, or inviting users to an existing room */
kind: InviteKind.Dm | InviteKind.Invite;
/** The users whose identities we don't know */
users: Member[];
}
/**
* Part of the invite dialog: a screen that appears if there are any users whose cryptographic identity we don't know,
* to confirm that they are the right users.
*
* Figma: https://www.figma.com/design/chAcaQAluTuRg6BsG4Npc0/-3163--Inviting-Unknown-People?node-id=150-17719&t=ISAikbnj97LM4NwT-0
*/
const UnknownIdentityUsersWarningDialog: React.FC<Props> = (props) => {
const userListItem = useCallback((u: Member) => <DMRoomTile member={u} key={u.userId} />, []);
let title: string;
let headerText: string;
let buttons: JSX.Element;
switch (props.kind) {
case InviteKind.Invite:
title = _t("invite|confirm_unknown_users|invite_title");
headerText = _t("invite|confirm_unknown_users|invite_subtitle");
buttons = <InviteButtons onInvite={props.onContinue} onRemove={props.onRemove} />;
break;
case InviteKind.Dm:
title =
props.users.length == 1
? _t("invite|confirm_unknown_users|start_chat_title_one_user")
: _t("invite|confirm_unknown_users|start_chat_title_multiple_users");
headerText =
props.users.length == 1
? _t("invite|confirm_unknown_users|start_chat_subtitle_one_user")
: _t("invite|confirm_unknown_users|start_chat_subtitle_multiple_users");
buttons = <DmButtons onCancel={props.onCancel} onContinue={props.onContinue} />;
break;
}
return (
<BaseDialog
onFinished={props.onCancel}
className="mx_UnknownIdentityUsersWarningDialog"
screenName={props.screenName}
>
<div className="mx_UnknownIdentityUsersWarningDialog_headerContainer">
<PageHeader Icon={UserAddSolidIcon} heading={title}>
<p>{headerText}</p>
</PageHeader>
</div>
<ul className="mx_UnknownIdentityUsersWarningDialog_userList" data-testid="userlist">
{props.users.map(userListItem)}
</ul>
<div className="mx_UnknownIdentityUsersWarningDialog_buttons">{buttons}</div>
</BaseDialog>
);
};
const DmButtons: React.FC<{ onContinue: () => void; onCancel: () => void }> = (props) => {
return (
<>
<Button size="lg" kind="secondary" onClick={props.onCancel}>
{_t("action|cancel")}
</Button>
<Button size="lg" kind="primary" onClick={props.onContinue}>
{_t("action|continue")}
</Button>
</>
);
};
const InviteButtons: React.FC<{ onInvite: () => void; onRemove: () => void }> = (props) => {
return (
<>
<Button size="lg" kind="secondary" onClick={props.onRemove} Icon={CloseIcon}>
{_t("action|remove")}
</Button>
<Button size="lg" kind="primary" onClick={props.onInvite} Icon={CheckIcon}>
{_t("action|invite")}
</Button>
</>
);
};
export default UnknownIdentityUsersWarningDialog;
@@ -44,10 +44,10 @@ import {
import { KeyBindingAction } from "../../../../accessibility/KeyboardShortcuts";
import {
findSiblingElement,
findNextSiblingElement,
RovingStateActionType,
RovingTabIndexContext,
RovingTabIndexProvider,
Type,
} from "../../../../accessibility/RovingTabIndex";
import { mediaFromMxc } from "../../../../customisations/Media";
import { Action } from "../../../../dispatcher/actions";
@@ -537,7 +537,7 @@ const SpotlightDialog: React.FC<IProps> = ({ initialText = "", initialFilter = n
const node = rovingContext.state.nodes[0];
if (node) {
rovingContext.dispatch({
type: Type.SetFocus,
type: RovingStateActionType.SetFocus,
payload: { node },
});
node?.scrollIntoView?.({
@@ -1181,7 +1181,10 @@ const SpotlightDialog: React.FC<IProps> = ({ initialText = "", initialFilter = n
}
const idx = nodes.indexOf(rovingContext.state.activeNode);
node = findSiblingElement(nodes, idx + (accessibilityAction === KeyBindingAction.ArrowUp ? -1 : 1));
node = findNextSiblingElement(
nodes,
idx + (accessibilityAction === KeyBindingAction.ArrowUp ? -1 : 1),
);
}
break;
@@ -1201,7 +1204,7 @@ const SpotlightDialog: React.FC<IProps> = ({ initialText = "", initialFilter = n
const nodes = rovingContext.state.nodes.filter(nodeIsForRecentlyViewed);
const idx = nodes.indexOf(rovingContext.state.activeNode);
node = findSiblingElement(
node = findNextSiblingElement(
nodes,
idx + (accessibilityAction === KeyBindingAction.ArrowLeft ? -1 : 1),
);
@@ -1211,7 +1214,7 @@ const SpotlightDialog: React.FC<IProps> = ({ initialText = "", initialFilter = n
if (node) {
rovingContext.dispatch({
type: Type.SetFocus,
type: RovingStateActionType.SetFocus,
payload: { node },
});
node?.scrollIntoView({
@@ -25,7 +25,7 @@ import {
type IAction as RovingAction,
type IState as RovingState,
RovingTabIndexProvider,
Type,
RovingStateActionType,
} from "../../../accessibility/RovingTabIndex";
import { Key } from "../../../Keyboard";
import { type ButtonEvent } from "../elements/AccessibleButton";
@@ -187,7 +187,7 @@ class EmojiPicker extends React.Component<IProps, IState> {
focusNode?.focus();
}
dispatch({
type: Type.SetFocus,
type: RovingStateActionType.SetFocus,
payload: { node: focusNode },
});
@@ -212,7 +212,7 @@ class EmojiPicker extends React.Component<IProps, IState> {
// Reset to first emoji when showing highlight for the first time (or after it was hidden)
if (state.nodes.length > 0) {
dispatch({
type: Type.SetFocus,
type: RovingStateActionType.SetFocus,
payload: { node: state.nodes[0] },
});
}
@@ -7,7 +7,7 @@ Please see LICENSE files in the repository root for full details.
import React, { type JSX, useState } from "react";
import classNames from "classnames";
import { type DOMNode, Element as ParserElement, domToReact } from "html-react-parser";
import { type DOMNode, type Element as ParserElement, domToReact } from "html-react-parser";
import { textContent, getInnerHTML } from "domutils";
import { CollapseIcon, CopyIcon, ExpandIcon } from "@vector-im/compound-design-tokens/assets/web/icons";
@@ -113,7 +113,7 @@ const CodeBlock: React.FC<Props> = ({ preNode }) => {
let content = domToReact(preNode.children as DOMNode[]);
// Add code element if it's missing since we depend on it
if (!preNode.children.some((child) => child instanceof ParserElement && child.tagName.toUpperCase() === "CODE")) {
if (!preNode.children.some((child) => child.type === "tag" && child.tagName.toUpperCase() === "CODE")) {
content = <code>{content}</code>;
}
+14 -3
View File
@@ -42,7 +42,7 @@
"copy_link": "Copy link",
"create": "Create",
"create_a_room": "Create a room",
"create_account": "Create Account",
"create_account": "Create account",
"decline": "Decline",
"decline_and_block": "Decline and block",
"decline_invite": "Decline invite",
@@ -1368,6 +1368,14 @@
"impossible_dialog_title": "Integrations not allowed"
},
"invite": {
"confirm_unknown_users": {
"invite_subtitle": "You currently don't have any chats with these contacts. Confirm inviting them to this room before continuing.",
"invite_title": "Invite new contacts to this room?",
"start_chat_subtitle_multiple_users": "You currently don't have any chats with these people. Confirm inviting them before continuing.",
"start_chat_subtitle_one_user": "You currently don't have any chats with this person. Confirm inviting them before continuing.",
"start_chat_title_multiple_users": "Start a chat with these new contacts?",
"start_chat_title_one_user": "Start a chat with this new contact?"
},
"email_caption": "Invite by email",
"email_limit_one": "Invites by email can only be sent one at a time",
"email_use_default_is": "Use an identity server to invite by email. <default>Use the default (%(defaultIdentityServerName)s)</default> or manage in <settings>Settings</settings>.",
@@ -1816,7 +1824,6 @@
"restricted": "Restricted"
},
"powered_by_matrix": "Powered by Matrix",
"powered_by_matrix_with_logo": "Decentralised, encrypted chat &amp; collaboration powered by $matrixLogo",
"presence": {
"away": "Away",
"busy": "Busy",
@@ -3981,7 +3988,11 @@
"you_are_presenting": "You are presenting"
},
"web_default_device_name": "%(appName)s: %(browserName)s on %(osName)s",
"welcome_to_element": "Welcome to Element",
"welcome": {
"tagline_element": "Supercharged for speed and simplicity.",
"title_element": "Be in your element",
"title_generic": "Welcome to %(brand)s"
},
"widget": {
"added_by": "Widget added by",
"capabilities_dialog": {
+7 -1
View File
@@ -680,6 +680,12 @@
"unfederated_label_default_on": "如果此房间将用于与拥有自己主服务器的外部团队协作,你可以禁用此功能。此设置以后无法更改。",
"unsupported_version": "服务器不支持指定的房间版本。"
},
"create_section_dialog": {
"create_section": "创建区域",
"description": "区域仅对你可见",
"label": "区域名称",
"title": "创建区域"
},
"create_space": {
"add_details_prompt": "添加一些信息以便人们识别。",
"add_details_prompt_2": "你可以随时更改。",
@@ -3077,7 +3083,7 @@
"category_messages": "消息",
"category_other": "其它",
"command_error": "指令出错",
"converttodm": "转换房间私聊",
"converttodm": "转换房间私聊",
"converttoroom": "转换私聊到房间",
"could_not_find_room": "无法找到房间",
"deop": "通过指定的 ID 降权用户",
+2 -2
View File
@@ -6,7 +6,7 @@ Please see LICENSE files in the repository root for full details.
*/
import { type JSX } from "react";
import { type DOMNode, Element, type HTMLReactParserOptions, type Text } from "html-react-parser";
import { type DOMNode, type Element, type HTMLReactParserOptions, type Text } from "html-react-parser";
import { type MatrixEvent, type Room } from "matrix-js-sdk/src/matrix";
/**
@@ -89,7 +89,7 @@ export const combineRenderers =
if (result) return result;
}
}
if (node instanceof Element) {
if (node.type === "tag") {
const tagName = node.tagName.toLowerCase() as keyof HTMLElementTagNameMap;
for (const replacer of renderers) {
const result = replacer[tagName]?.(node, parametersWithReplace, index);
@@ -62,6 +62,8 @@ export enum RoomListStoreV3Event {
ListsLoaded = "lists_loaded",
/** Fired when a new section is created in the room list. */
SectionCreated = "section_created",
/** Fired when a room's tags change. */
RoomTagged = "room_tagged",
}
// The result object for returning rooms from the store
@@ -93,6 +95,7 @@ export const CHATS_TAG = "chats";
export const LISTS_UPDATE_EVENT = RoomListStoreV3Event.ListsUpdate;
export const LISTS_LOADED_EVENT = RoomListStoreV3Event.ListsLoaded;
export const SECTION_CREATED_EVENT = RoomListStoreV3Event.SectionCreated;
export const ROOM_TAGGED_EVENT = RoomListStoreV3Event.RoomTagged;
/**
* This store allows for fast retrieval of the room list in a sorted and filtered manner.
@@ -243,6 +246,7 @@ export class RoomListStoreV3Class extends AsyncStoreWithClient<EmptyObject> {
case "MatrixActions.Room.tags": {
const room = payload.room;
this.addRoomAndEmit(room);
this.emit(ROOM_TAGGED_EVENT);
break;
}
@@ -485,13 +489,19 @@ export class RoomListStoreV3Class extends AsyncStoreWithClient<EmptyObject> {
/**
* Create a new section.
* Emits {@link SECTION_CREATED_EVENT} and {@link LISTS_UPDATE_EVENT} if the section was successfully created.
* Emits {@link SECTION_CREATED_EVENT} if the section was successfully created.
*/
public async createSection(): Promise<void> {
const sectionIsCreated = await createSection();
if (!sectionIsCreated) return;
this.emit(SECTION_CREATED_EVENT);
this.scheduleEmit();
const tag = await createSection();
if (!tag) return;
this.emit(SECTION_CREATED_EVENT, tag);
}
/**
* Returns the ordered section tags.
*/
public get orderedSectionTags(): string[] {
return this.sortedTags;
}
/**
+19 -7
View File
@@ -5,8 +5,6 @@
* Please see LICENSE files in the repository root for full details.
*/
import { v4 as uuidv4 } from "uuid";
import { SettingLevel } from "../../settings/SettingLevel";
import SettingsStore from "../../settings/SettingsStore";
import Modal from "../../Modal";
@@ -14,6 +12,20 @@ import { CreateSectionDialog } from "../../components/views/dialogs/CreateSectio
type Tag = string;
/**
* Prefix for custom section tags.
*/
export const CUSTOM_SECTION_TAG_PREFIX = "element.io.section.";
/**
* Checks if a given tag is a custom section tag.
* @param tag - The tag to check.
* @returns True if the tag is a custom section tag, false otherwise.
*/
export function isCustomSectionTag(tag: string): boolean {
return tag.startsWith(CUSTOM_SECTION_TAG_PREFIX);
}
/**
* Structure of the custom section stored in the settings. The tag is used as a unique identifier for the section, and the name is given by the user.
*/
@@ -35,15 +47,15 @@ export type OrderedCustomSections = Tag[];
* Creates a new custom section by showing a dialog to the user to enter the section name.
* If the user confirms, it generates a unique tag for the section, saves the section data in the settings, and updates the ordered list of sections.
*
* @return A promise that resolves to true if the section was created, or false if the user cancelled the creation or if there was an error.
* @return A promise that resolves to the new section tag if created, or undefined if cancelled.
*/
export async function createSection(): Promise<boolean> {
export async function createSection(): Promise<string | undefined> {
const modal = Modal.createDialog(CreateSectionDialog);
const [shouldCreateSection, sectionName] = await modal.finished;
if (!shouldCreateSection || !sectionName) return false;
if (!shouldCreateSection || !sectionName) return undefined;
const tag = `element.io.section.${uuidv4()}`;
const tag = `${CUSTOM_SECTION_TAG_PREFIX}${window.crypto.randomUUID()}`;
const newSection: CustomSection = { tag, name: sectionName };
// Save the new section data
@@ -55,5 +67,5 @@ export async function createSection(): Promise<boolean> {
const orderedSections = SettingsStore.getValue("RoomList.OrderedCustomSections") || [];
orderedSections.push(tag);
await SettingsStore.setValue("RoomList.OrderedCustomSections", null, SettingLevel.ACCOUNT, orderedSections);
return true;
return tag;
}
+1 -2
View File
@@ -7,7 +7,6 @@ Please see LICENSE files in the repository root for full details.
*/
import { logger } from "matrix-js-sdk/src/logger";
import { v4 as uuidv4 } from "uuid";
/*
* Functionality for checking that only one instance is running at once
@@ -107,7 +106,7 @@ export function checkSessionLockFree(): boolean {
*/
export async function getSessionLock(onNewInstance: () => Promise<void>): Promise<boolean> {
/** unique ID for this session */
const sessionIdentifier = uuidv4();
const sessionIdentifier = window.crypto.randomUUID();
const prefixedLogger = logger.getChild(`getSessionLock[${sessionIdentifier}]`);
+17 -8
View File
@@ -13,20 +13,29 @@ import { DefaultTagID, type TagID } from "../../stores/room-list-v3/skip-list/ta
import RoomListActions from "../../actions/RoomListActions";
import dis from "../../dispatcher/dispatcher";
import { getTagsForRoom } from "./getTagsForRoom";
import { isCustomSectionTag } from "../../stores/room-list-v3/section";
/**
* Toggle tag for a given room
* Toggle tag for a given room.
* A room can only be in one section: either a custom section, Favourite, or LowPriority.
* Applying any of these will atomically replace the current section tag.
* @param room The room to tag
* @param tagId The tag to invert
*/
export function tagRoom(room: Room, tagId: TagID): void {
if (tagId === DefaultTagID.Favourite || tagId === DefaultTagID.LowPriority) {
const inverseTag = tagId === DefaultTagID.Favourite ? DefaultTagID.LowPriority : DefaultTagID.Favourite;
const isApplied = getTagsForRoom(room).includes(tagId);
const removeTag = isApplied ? tagId : inverseTag;
const addTag = isApplied ? null : tagId;
dis.dispatch(RoomListActions.tagRoom(room.client, room, removeTag, addTag));
} else {
if (tagId !== DefaultTagID.Favourite && tagId !== DefaultTagID.LowPriority && !isCustomSectionTag(tagId)) {
logger.warn(`Unexpected tag ${tagId} applied to ${room.roomId}`);
return;
}
// Find the section tag currently applied (Fav, LowPriority, or custom) — at most one exists
const currentSectionTag =
getTagsForRoom(room).find(
(t) => t === DefaultTagID.Favourite || t === DefaultTagID.LowPriority || isCustomSectionTag(t),
) ?? null;
const isApplied = currentSectionTag === tagId;
const removeTag = currentSectionTag;
const addTag = isApplied ? null : tagId;
dis.dispatch(RoomListActions.tagRoom(room.client, room, removeTag, addTag));
}
@@ -34,8 +34,10 @@ export interface UrlPreviewGroupViewModelProps {
}
export const MAX_PREVIEWS_WHEN_LIMITED = 2;
export const PREVIEW_WIDTH = 100;
export const PREVIEW_HEIGHT = 100;
export const PREVIEW_WIDTH_PX = 478;
export const PREVIEW_HEIGHT_PX = 200;
export const MIN_PREVIEW_PX = 96;
export const MIN_IMAGE_SIZE_BYTES = 8192;
export enum PreviewVisibility {
/**
@@ -100,21 +102,26 @@ export class UrlPreviewGroupViewModel
typeof response["og:description"] === "string" && response["og:description"].trim()
? response["og:description"].trim()
: undefined;
let siteName =
const siteName =
typeof response["og:site_name"] === "string" && response["og:site_name"].trim()
? response["og:site_name"].trim()
: undefined;
: new URL(link).hostname;
// If there is no title, use the description as the title.
if (!title && description) {
title = description;
description = undefined;
} else if (!title && siteName) {
title = siteName;
siteName = undefined;
} else if (!title) {
title = link;
}
// If the description matches the site name, don't bother with a description.
if (description && description.toLowerCase() === siteName.toLowerCase()) {
description = undefined;
}
return {
title,
description: description && decode(description),
@@ -122,6 +129,50 @@ export class UrlPreviewGroupViewModel
};
}
/**
* Calculate the best possible author from an opengraph response.
* @param response The opengraph response
* @returns The author value, or undefined if no valid author could be found.
*/
private static getAuthorFromResponse(response: IPreviewUrlResponse): UrlPreview["author"] {
let calculatedAuthor: string | undefined;
if (response["og:type"] === "article") {
if (typeof response["article:author"] === "string" && response["article:author"]) {
calculatedAuthor = response["article:author"];
}
// Otherwise fall through to check the profile.
}
if (typeof response["profile:username"] === "string" && response["profile:username"]) {
calculatedAuthor = response["profile:username"];
}
if (calculatedAuthor && URL.canParse(calculatedAuthor)) {
// Some sites return URLs as authors which doesn't look good in Element, so discard it.
return;
}
return calculatedAuthor;
}
/**
* Calculate whether the provided image from the preview response is an full size preview or
* a site icon.
* @returns `true` if the image should be used as a preview, otherwise `false`
*/
private static isImagePreview(width?: number, height?: number, bytes?: number): boolean {
// We can't currently distinguish from a preview image and a favicon. Neither OpenGraph nor Matrix
// have a clear distinction, so we're using a heuristic here to check the dimensions & size of the file and
// deciding whether to render it as a full preview or icon.
if (width && width < MIN_PREVIEW_PX) {
return false;
}
if (height && height < MIN_PREVIEW_PX) {
return false;
}
if (bytes && bytes < MIN_IMAGE_SIZE_BYTES) {
return false;
}
return true;
}
/**
* Determine if an anchor element can be rendered into a preview.
* If it can, return the value of `href`
@@ -278,6 +329,7 @@ export class UrlPreviewGroupViewModel
}
const { title, description, siteName } = UrlPreviewGroupViewModel.getBaseMetadataFromResponse(preview, link);
const author = UrlPreviewGroupViewModel.getAuthorFromResponse(preview);
const hasImage = preview["og:image"] && typeof preview?.["og:image"] === "string";
// Ensure we have something relevant to render.
// The title must not just be the link, or we must have an image.
@@ -285,31 +337,46 @@ export class UrlPreviewGroupViewModel
return null;
}
let image: UrlPreview["image"];
let siteIcon: string | undefined;
if (typeof preview["og:image"] === "string" && this.visibility > PreviewVisibility.MediaHidden) {
const media = mediaFromMxc(preview["og:image"], this.client);
const declaredHeight = UrlPreviewGroupViewModel.getNumberFromOpenGraph(preview["og:image:height"]);
const declaredWidth = UrlPreviewGroupViewModel.getNumberFromOpenGraph(preview["og:image:width"]);
const width = Math.min(declaredWidth ?? PREVIEW_WIDTH, PREVIEW_WIDTH);
const height = thumbHeight(width, declaredHeight, PREVIEW_WIDTH, PREVIEW_WIDTH) ?? PREVIEW_WIDTH;
const thumb = media.getThumbnailOfSourceHttp(PREVIEW_WIDTH, PREVIEW_HEIGHT, "scale");
// No thumb, no preview.
if (thumb) {
image = {
imageThumb: thumb,
imageFull: media.srcHttp ?? thumb,
width,
height,
fileSize: UrlPreviewGroupViewModel.getNumberFromOpenGraph(preview["matrix:image:size"]),
};
const imageSize = UrlPreviewGroupViewModel.getNumberFromOpenGraph(preview["matrix:image:size"]);
const alt = typeof preview["og:image:alt"] === "string" ? preview["og:image:alt"] : undefined;
const isImagePreview = UrlPreviewGroupViewModel.isImagePreview(declaredWidth, declaredHeight, imageSize);
if (isImagePreview) {
const width = Math.min(declaredWidth ?? PREVIEW_WIDTH_PX, PREVIEW_WIDTH_PX);
const height =
thumbHeight(width, declaredHeight, PREVIEW_WIDTH_PX, PREVIEW_WIDTH_PX) ?? PREVIEW_WIDTH_PX;
const thumb = media.getThumbnailOfSourceHttp(PREVIEW_WIDTH_PX, PREVIEW_HEIGHT_PX, "scale");
const playable = !!preview["og:video"] || !!preview["og:video:type"] || !!preview["og:audio"];
// No thumb, no preview.
if (thumb) {
image = {
imageThumb: thumb,
imageFull: media.srcHttp ?? thumb,
width,
height,
fileSize: UrlPreviewGroupViewModel.getNumberFromOpenGraph(preview["matrix:image:size"]),
alt,
playable,
};
}
} else if (media.srcHttp) {
siteIcon = media.srcHttp;
}
}
const result = {
link,
title,
author,
description,
siteName,
showTooltipOnLink: link !== title && PlatformPeg.get()?.needsUrlTooltips(),
siteIcon,
showTooltipOnLink: !!(link !== title && PlatformPeg.get()?.needsUrlTooltips()),
image,
} satisfies UrlPreview;
this.previewCache.set(link, result);
@@ -10,6 +10,7 @@ import {
RoomNotifState,
type RoomListItemViewSnapshot,
type RoomListItemViewActions,
type Section,
} from "@element-hq/web-shared-components";
import { RoomEvent } from "matrix-js-sdk/src/matrix";
import { CallType } from "matrix-js-sdk/src/webrtc/call";
@@ -37,7 +38,8 @@ import { Action } from "../../dispatcher/actions";
import type { ViewRoomPayload } from "../../dispatcher/payloads/ViewRoomPayload";
import PosthogTrackers from "../../PosthogTrackers";
import { type Call, CallEvent } from "../../models/Call";
import RoomListStoreV3 from "../../stores/room-list-v3/RoomListStoreV3";
import RoomListStoreV3, { CHATS_TAG } from "../../stores/room-list-v3/RoomListStoreV3";
import { _t } from "../../languageHandler";
interface RoomItemProps {
room: Room;
@@ -96,6 +98,13 @@ export class RoomListItemViewModel
this.disposables.trackListener(props.room, RoomEvent.Name, this.onRoomChanged);
this.disposables.trackListener(props.room, RoomEvent.Tags, this.onRoomChanged);
const orderSectionsRef = SettingsStore.watchSetting("RoomList.OrderedCustomSections", null, () =>
this.onOrderedCustomSectionsChange(),
);
this.disposables.track(() => {
SettingsStore.unwatchSetting(orderSectionsRef);
});
// Load message preview asynchronously (sync data is already complete)
void this.loadAndSetMessagePreview();
}
@@ -181,6 +190,7 @@ export class RoomListItemViewModel
this.snapshot.merge({
...newItem,
notification: keepIfSame(this.snapshot.current.notification, newItem.notification),
sections: keepIfSame(this.snapshot.current.sections, newItem.sections),
// Preserve message preview - it's managed separately by loadAndSetMessagePreview
messagePreview: this.snapshot.current.messagePreview,
});
@@ -279,6 +289,9 @@ export class RoomListItemViewModel
const canMoveToSection = SettingsStore.getValue("feature_room_list_sections");
// Build sections list for the "Move to section" submenu
const sections: Section[] = canMoveToSection ? RoomListItemViewModel.buildSections(roomTags) : [];
return {
id: room.roomId,
room,
@@ -307,6 +320,7 @@ export class RoomListItemViewModel
canMarkAsUnread,
roomNotifState,
canMoveToSection,
sections,
};
}
@@ -389,4 +403,42 @@ export class RoomListItemViewModel
public onCreateSection = (): void => {
RoomListStoreV3.instance.createSection();
};
public onToggleSection = (tag: string): void => {
tagRoom(this.props.room, tag);
};
private onOrderedCustomSectionsChange = (): void => {
// Rebuild sections list to reflect new order
const sections = RoomListItemViewModel.buildSections(this.props.room.tags);
this.snapshot.merge({ sections: keepIfSame(this.snapshot.current.sections, sections) });
};
/**
* Build the list of available sections for the "Move to section" submenu.
* Order follows the canonical section order from RoomListStoreV3.
*/
private static buildSections(roomTags: Room["tags"]): Section[] {
const customSectionData = SettingsStore.getValue("RoomList.CustomSectionData") || {};
return (
RoomListStoreV3.instance.orderedSectionTags
// Exclude the Chats section because the user toggle the other sections to move rooms in and out of the Chats section.
.filter((tag) => tag !== CHATS_TAG)
.map((tag) => ({
tag,
name: RoomListItemViewModel.getSectionName(tag, customSectionData),
isSelected: Boolean(roomTags[tag]),
}))
);
}
/**
* Get the display name for a section based on its tag.
*/
private static getSectionName(tag: string, customSectionData: Record<string, { name: string }>): string {
if (tag === DefaultTagID.Favourite) return _t("room_list|section|favourites");
if (tag === DefaultTagID.LowPriority) return _t("room_list|section|low_priority");
return customSectionData[tag]?.name || tag;
}
}
@@ -13,6 +13,7 @@ import {
type RoomListViewState,
type RoomListSection,
_t,
type ToastType,
} from "@element-hq/web-shared-components";
import { type MatrixClient, type Room } from "matrix-js-sdk/src/matrix";
@@ -153,7 +154,14 @@ export class RoomListViewModel
this.disposables.trackListener(
RoomListStoreV3.instance,
RoomListStoreV3Event.SectionCreated as any,
this.onSectionCreated,
this.onSectionCreated as (...args: unknown[]) => void,
);
// Subscribe to room tagging
this.disposables.trackListener(
RoomListStoreV3.instance,
RoomListStoreV3Event.RoomTagged as any,
this.onRoomTagged,
);
// Subscribe to active room changes to update selected room
@@ -500,6 +508,7 @@ export class RoomListViewModel
private async updateRoomListData(
isRoomChange: boolean = false,
roomIdOverride: string | null = null,
scrollToSectionTag: string | undefined = undefined,
): Promise<void> {
// Determine the room ID to use for calculations
// Use override if provided (e.g., during space changes), otherwise fall back to RoomViewStore
@@ -544,17 +553,23 @@ export class RoomListViewModel
// Update filter keys - only update if they have actually changed to prevent unnecessary re-renders of the room list
const previousFilterKeys = this.snapshot.current.roomListState.filterKeys;
const newFilterKeys = this.roomsResult.filterKeys?.map((k) => String(k));
const viewSections = toRoomListSection(this.sections);
const resolvedScrollToSectionTag =
scrollToSectionTag && viewSections.some((s) => s.id === scrollToSectionTag)
? scrollToSectionTag
: undefined;
const roomListState: RoomListViewState = {
activeRoomIndex,
spaceId: this.roomsResult.spaceId,
filterKeys: keepIfSame(previousFilterKeys, newFilterKeys),
scrollToSectionTag: resolvedScrollToSectionTag,
};
const activeFilterId = this.activeFilter !== undefined ? filterKeyToIdMap.get(this.activeFilter) : undefined;
const isRoomListEmpty = this.roomsResult.sections.every((section) => section.rooms.length === 0);
const isLoadingRooms = RoomListStoreV3.instance.isLoadingRooms;
const viewSections = toRoomListSection(this.sections);
const previousSections = this.snapshot.current.sections;
// Single atomic snapshot update
@@ -586,15 +601,13 @@ export class RoomListViewModel
}
};
public onSectionCreated = (): void => {
clearTimeout(this.toastRef);
this.snapshot.merge({
toast: "section_created",
});
// Automatically close the toast after 15 seconds
this.toastRef = setTimeout(() => {
this.closeToast();
}, 15 * 1000);
public onSectionCreated = (tag: string): void => {
this.updateRoomListData(false, null, tag);
this.showToast("section_created");
};
public onRoomTagged = (): void => {
this.showToast("chat_moved");
};
public closeToast: () => void = () => {
@@ -603,6 +616,15 @@ export class RoomListViewModel
toast: undefined,
});
};
private showToast(toast: ToastType): void {
clearTimeout(this.toastRef);
this.snapshot.merge({ toast });
// Automatically close the toast after 15 seconds
this.toastRef = setTimeout(() => {
this.closeToast();
}, 15 * 1000);
}
}
/**
@@ -18,20 +18,10 @@ describe("PosthogTrackers", () => {
const tracker = new PosthogTrackers();
tracker.trackUrlPreview("$123456", false, [
{
title: "A preview",
image: {
imageThumb: "abc",
imageFull: "abc",
},
link: "a-link",
},
]);
tracker.trackUrlPreview("$123456", false, [
{
title: "A second preview",
link: "a-link",
image: {},
},
]);
tracker.trackUrlPreview("$123456", false, [{}]);
// Ignores subsequent calls.
expect(PosthogAnalytics.instance.trackEvent).toHaveBeenCalledWith({
eventName: "UrlPreviewRendered",
@@ -67,15 +67,15 @@ describe("SupportedBrowser", () => {
// Safari 26.0 on macOS
"Mozilla/5.0 (Macintosh; Intel Mac OS X 15_7_2) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.0 Safari/605.1.15",
// Latest Firefox on macOS Sonoma
"Mozilla/5.0 (Macintosh; Intel Mac OS X 15.7; rv:145.0) Gecko/20100101 Firefox/147.0",
"Mozilla/5.0 (Macintosh; Intel Mac OS X 15.7; rv:150.0) Gecko/20100101 Firefox/150.0",
// Latest Edge on Windows
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36 Edg/146.0.3856.84",
// Latest Edge on macOS
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36 Edg/146.0.3856.84",
// Latest Firefox on Windows
"Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:147.0) Gecko/20100101 Firefox/147.0",
"Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:150.0) Gecko/20100101 Firefox/150.0",
// Latest Firefox on Linux
"Mozilla/5.0 (X11; Linux i686; rv:147.0) Gecko/20100101 Firefox/147.0",
"Mozilla/5.0 (X11; Linux i686; rv:150.0) Gecko/20100101 Firefox/150.0",
// Latest Chrome on Windows
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36",
])("should not warn for supported browsers", testUserAgentFactory());
@@ -0,0 +1,96 @@
/*
Copyright 2026 Element Creations Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import React from "react";
import { render } from "jest-matrix-react";
import { RovingAction, type RovingTabIndexProviderProps } from "@element-hq/web-shared-components";
import * as KeyBindingsManagerModule from "../../../src/KeyBindingsManager";
import { KeyBindingAction } from "../../../src/accessibility/KeyboardShortcuts";
import { RovingTabIndexProvider } from "../../../src/accessibility/RovingTabIndex";
jest.mock("@element-hq/web-shared-components", () => {
const actual = jest.requireActual("@element-hq/web-shared-components");
const mockSharedRovingTabIndexProvider = jest.fn(({ children }: RovingTabIndexProviderProps) => {
return <>{children({ onDragEndHandler: jest.fn(), onKeyDownHandler: jest.fn() })}</>;
});
return {
__mockSharedRovingTabIndexProvider: mockSharedRovingTabIndexProvider,
...actual,
RovingTabIndexProvider: mockSharedRovingTabIndexProvider,
};
});
const getMockSharedRovingTabIndexProvider = (): jest.Mock => {
return jest.requireMock("@element-hq/web-shared-components").__mockSharedRovingTabIndexProvider as jest.Mock;
};
const getInjectedGetAction = (): NonNullable<RovingTabIndexProviderProps["getAction"]> => {
const mockSharedRovingTabIndexProvider = getMockSharedRovingTabIndexProvider();
expect(mockSharedRovingTabIndexProvider).toHaveBeenCalled();
const getAction = (mockSharedRovingTabIndexProvider.mock.calls.at(-1)![0] as RovingTabIndexProviderProps).getAction;
expect(getAction).toBeDefined();
return getAction!;
};
describe("RovingTabIndex adapter", () => {
beforeEach(() => {
const mockSharedRovingTabIndexProvider = getMockSharedRovingTabIndexProvider();
mockSharedRovingTabIndexProvider.mockClear();
jest.restoreAllMocks();
});
it.each([
[KeyBindingAction.ArrowDown, RovingAction.ArrowDown],
[KeyBindingAction.ArrowUp, RovingAction.ArrowUp],
[KeyBindingAction.ArrowRight, RovingAction.ArrowRight],
[KeyBindingAction.ArrowLeft, RovingAction.ArrowLeft],
[KeyBindingAction.Home, RovingAction.Home],
[KeyBindingAction.End, RovingAction.End],
[KeyBindingAction.Tab, RovingAction.Tab],
])("maps %s to %s", (accessibilityAction, expectedRovingAction) => {
const manager = new KeyBindingsManagerModule.KeyBindingsManager();
jest.spyOn(KeyBindingsManagerModule, "getKeyBindingsManager").mockReturnValue(manager);
jest.spyOn(manager, "getAccessibilityAction").mockReturnValue(accessibilityAction);
render(<RovingTabIndexProvider>{() => null}</RovingTabIndexProvider>);
const getAction = getInjectedGetAction();
expect(getAction({ key: "irrelevant" } as React.KeyboardEvent)).toBe(expectedRovingAction);
});
it("returns undefined when there is no matching accessibility action", () => {
const manager = new KeyBindingsManagerModule.KeyBindingsManager();
jest.spyOn(KeyBindingsManagerModule, "getKeyBindingsManager").mockReturnValue(manager);
jest.spyOn(manager, "getAccessibilityAction").mockReturnValue(undefined);
render(<RovingTabIndexProvider>{() => null}</RovingTabIndexProvider>);
const getAction = getInjectedGetAction();
expect(getAction({ key: "x" } as React.KeyboardEvent)).toBeUndefined();
});
it("forwards provider props to shared-components", () => {
const onKeyDown = jest.fn();
render(
<RovingTabIndexProvider handleHomeEnd handleLoop handleUpDown onKeyDown={onKeyDown} scrollIntoView>
{() => null}
</RovingTabIndexProvider>,
);
const mockSharedRovingTabIndexProvider = getMockSharedRovingTabIndexProvider();
const props = mockSharedRovingTabIndexProvider.mock.calls.at(-1)![0] as RovingTabIndexProviderProps;
expect(props.handleHomeEnd).toBe(true);
expect(props.handleLoop).toBe(true);
expect(props.handleUpDown).toBe(true);
expect(props.onKeyDown).toBe(onKeyDown);
expect(props.scrollIntoView).toBe(true);
expect(props.getAction).toEqual(expect.any(Function));
});
});
@@ -9,7 +9,6 @@ Please see LICENSE files in the repository root for full details.
import "fake-indexeddb/auto";
import React, { type ComponentProps } from "react";
import { fireEvent, render, type RenderResult, screen, waitFor, within, act } from "jest-matrix-react";
import fetchMock from "@fetch-mock/jest";
import { type Mocked, mocked } from "jest-mock";
import { ClientEvent, type MatrixClient, MatrixEvent, Room, SyncState } from "matrix-js-sdk/src/matrix";
import { type MediaHandler } from "matrix-js-sdk/src/webrtc/mediaHandler";
@@ -1637,7 +1636,6 @@ describe("<MatrixChat />", () => {
// Flaky test, see https://github.com/element-hq/element-web/issues/30337
it("waits for other tab to stop during startup", async () => {
fetchMock.get("end:/welcome.html", { body: "<h1>Hello</h1>" });
jest.spyOn(Lifecycle, "attemptDelegatedAuthLogin");
// simulate an active window
@@ -1668,7 +1666,7 @@ describe("<MatrixChat />", () => {
expect(Lifecycle.attemptDelegatedAuthLogin).toHaveBeenCalled();
// should just show the welcome screen
await rendered.findByText("Hello");
await rendered.findByText("Welcome to Test");
expect(rendered.container).toMatchSnapshot();
});
@@ -124,13 +124,9 @@ exports[`<MatrixChat /> Multi-tab lockout waits for other tab to stop during sta
class="mx_AuthPage"
>
<div
class="mx_AuthPage_modal mx_AuthPage_modal_withBlur"
class="mx_AuthPage_modal"
style="position: relative;"
>
<div
class="mx_AuthPage_modalBlur"
style="position: absolute; top: 0px; right: 0px; bottom: 0px; left: 0px; filter: blur(40px);"
/>
<main
aria-live="polite"
class="mx_AuthPage_modalContent"
@@ -138,53 +134,99 @@ exports[`<MatrixChat /> Multi-tab lockout waits for other tab to stop during sta
tabindex="-1"
>
<div
class="mx_Welcome"
data-testid="mx_welcome_screen"
class="_glass_sepwu_8"
>
<div
class="mx_WelcomePage mx_WelcomePage_loggedIn"
class="mx_Welcome"
>
<div
class="mx_WelcomePage_body"
class="mx_DefaultWelcome"
>
<h1>
Hello
<a
class="mx_DefaultWelcome_logo"
href="https://element.io"
rel="noopener"
target="_blank"
>
<img
alt="Test"
src="themes/element/img/logos/element-logo.svg"
/>
</a>
<h1
class="_typography_6v6n8_153 _font-heading-md-semibold_6v6n8_112"
>
Welcome to Test
</h1>
<div
class="mx_DefaultWelcome_buttons"
>
<a
class="_button_13vu4_8"
data-kind="primary"
data-size="sm"
href="#/login"
role="link"
tabindex="0"
>
Sign in
</a>
<a
class="_button_13vu4_8"
data-kind="secondary"
data-size="sm"
href="#/register"
role="link"
tabindex="0"
>
Create account
</a>
<a
class="_button_13vu4_8"
data-kind="tertiary"
data-size="sm"
href="#/directory"
role="link"
tabindex="0"
>
Explore rooms
</a>
</div>
</div>
</div>
<div
class="mx_Dropdown mx_LanguageDropdown mx_AuthBody_language"
>
<div
aria-describedby="mx_LanguageDropdown_value"
aria-expanded="false"
aria-haspopup="listbox"
aria-label="Language Dropdown"
aria-owns="mx_LanguageDropdown_input"
class="mx_AccessibleButton mx_Dropdown_input mx_no_textinput"
role="button"
tabindex="0"
class="mx_Dropdown mx_LanguageDropdown mx_AuthBody_language"
>
<div
class="mx_Dropdown_option"
id="mx_LanguageDropdown_value"
aria-describedby="mx_LanguageDropdown_value"
aria-expanded="false"
aria-haspopup="listbox"
aria-label="Language Dropdown"
aria-owns="mx_LanguageDropdown_input"
class="mx_AccessibleButton mx_Dropdown_input mx_no_textinput"
role="button"
tabindex="0"
>
<div>
English
<div
class="mx_Dropdown_option"
id="mx_LanguageDropdown_value"
>
<div>
English
</div>
</div>
<svg
class="mx_Dropdown_arrow"
fill="currentColor"
height="1em"
viewBox="0 0 24 24"
width="1em"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M12 14.95q-.2 0-.375-.062a.9.9 0 0 1-.325-.213l-4.6-4.6a.95.95 0 0 1-.275-.7q0-.425.275-.7a.95.95 0 0 1 .7-.275q.425 0 .7.275l3.9 3.9 3.9-3.9a.95.95 0 0 1 .7-.275q.425 0 .7.275a.95.95 0 0 1 .275.7.95.95 0 0 1-.275.7l-4.6 4.6q-.15.15-.325.212a1.1 1.1 0 0 1-.375.063"
/>
</svg>
</div>
<svg
class="mx_Dropdown_arrow"
fill="currentColor"
height="1em"
viewBox="0 0 24 24"
width="1em"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M12 14.95q-.2 0-.375-.062a.9.9 0 0 1-.325-.213l-4.6-4.6a.95.95 0 0 1-.275-.7q0-.425.275-.7a.95.95 0 0 1 .7-.275q.425 0 .7.275l3.9 3.9 3.9-3.9a.95.95 0 0 1 .7-.275q.425 0 .7.275a.95.95 0 0 1 .275.7.95.95 0 0 1-.275.7l-4.6 4.6q-.15.15-.325.212a1.1 1.1 0 0 1-.375.063"
/>
</svg>
</div>
</div>
</div>
@@ -213,12 +213,12 @@ exports[`RoomView for a local room in state ERROR should match the snapshot 1`]
</div>
<div
aria-labelledby="_r_1c3_"
class="_banner_193k4_8"
class="_banner_n7ud0_8"
data-type="critical"
role="status"
>
<div
class="_icon_193k4_50"
class="_icon_n7ud0_50"
>
<svg
fill="currentColor"
@@ -234,7 +234,7 @@ exports[`RoomView for a local room in state ERROR should match the snapshot 1`]
</svg>
</div>
<div
class="_content_193k4_38"
class="_content_n7ud0_38"
>
<p
class="_typography_6v6n8_153 _font-body-md-medium_6v6n8_60 _title_1xryk_24"
@@ -244,7 +244,7 @@ exports[`RoomView for a local room in state ERROR should match the snapshot 1`]
</p>
</div>
<div
class="_actions_193k4_60"
class="_actions_n7ud0_61"
>
<button
class="_button_13vu4_8 _primaryAction_1xryk_20 _has-icon_13vu4_60"
@@ -7,12 +7,13 @@ Please see LICENSE files in the repository root for full details.
*/
import React from "react";
import { fireEvent, render, screen, findByText } from "jest-matrix-react";
import { findByText, fireEvent, render, screen } from "jest-matrix-react";
import userEvent from "@testing-library/user-event";
import { RoomType, type MatrixClient, MatrixError, Room } from "matrix-js-sdk/src/matrix";
import { type MatrixClient, MatrixError, Room, RoomType } from "matrix-js-sdk/src/matrix";
import { KnownMembership } from "matrix-js-sdk/src/types";
import { sleep } from "matrix-js-sdk/src/utils";
import { mocked, type Mocked } from "jest-mock";
import { UserVerificationStatus } from "matrix-js-sdk/src/crypto-api";
import InviteDialog from "../../../../../src/components/views/dialogs/InviteDialog";
import { InviteKind } from "../../../../../src/components/views/dialogs/InviteDialogTypes";
@@ -103,6 +104,11 @@ describe("InviteDialog", () => {
beforeEach(() => {
mockClient = getMockClientWithEventEmitter({
getCrypto: jest.fn().mockReturnValue({
getUserVerificationStatus: jest
.fn()
.mockResolvedValue(new UserVerificationStatus(false, false, true, false)),
}),
getDomain: jest.fn().mockReturnValue(serverDomain),
getUserId: jest.fn().mockReturnValue(bobId),
getSafeUserId: jest.fn().mockReturnValue(bobId),
@@ -449,4 +455,44 @@ describe("InviteDialog", () => {
await flushPromises();
expect(screen.queryByText("@localpart:server.tld")).not.toBeInTheDocument();
});
describe("when inviting a user whose cryptographic identity we do not know", () => {
beforeEach(() => {
mocked(mockClient.getCrypto()!.getUserVerificationStatus).mockImplementation(async (u) => {
return new UserVerificationStatus(false, false, false, false);
});
});
describe.each([InviteKind.Invite, InviteKind.Dm])("with invitekind '%s'", (kind) => {
const goButtonName = kind == InviteKind.Invite ? "Invite" : "Go";
beforeEach(() => {
render(
<InviteDialog
kind={kind as InviteKind.Invite | InviteKind.Dm}
roomId={roomId}
onFinished={jest.fn()}
/>,
);
});
it("should show a warning when inviting by user id", async () => {
await enterIntoSearchField(aliceId);
await userEvent.click(screen.getByRole("button", { name: goButtonName }));
await screen.findByText("Confirm inviting them", { exact: false });
expect(mocked(mockClient.getCrypto()!.getUserVerificationStatus)).toHaveBeenCalledTimes(1);
expect(mocked(mockClient.getCrypto()!.getUserVerificationStatus)).toHaveBeenCalledWith(aliceId);
});
it("should show a warning when inviting by email address", async () => {
await enterIntoSearchField("aaa@bbb");
await userEvent.click(screen.getByRole("button", { name: goButtonName }));
await screen.findByText("Confirm inviting them", { exact: false });
// We shouldn't call getUserVerificationStatus on an email address
expect(mocked(mockClient.getCrypto()!.getUserVerificationStatus)).not.toHaveBeenCalled();
});
});
});
});
@@ -0,0 +1,104 @@
/*
Copyright 2026 Element Creations Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import React, { type ComponentProps } from "react";
import { render, type RenderResult } from "jest-matrix-react";
import { getAllByRole, getAllByText, getByText } from "@testing-library/dom";
import UnknownIdentityUsersWarningDialog from "../../../../../../src/components/views/dialogs/invite/UnknownIdentityUsersWarningDialog.tsx";
import { InviteKind } from "../../../../../../src/components/views/dialogs/InviteDialogTypes.ts";
import { DirectoryMember, ThreepidMember } from "../../../../../../src/utils/direct-messages.ts";
import { getMockClientWithEventEmitter, mockClientMethodsUser } from "../../../../../test-utils";
describe("UnknownIdentityUsersWarningDialog", () => {
beforeEach(() => {
getMockClientWithEventEmitter({
...mockClientMethodsUser(),
});
});
afterEach(() => {
jest.restoreAllMocks();
});
it("should show entries for each user", () => {
const result = renderComponent({
users: [
new DirectoryMember({ user_id: "@alice:example.com" }),
new DirectoryMember({
user_id: "@bob:example.net",
display_name: "Bob",
avatar_url: "mxc://example.com/abc",
}),
new ThreepidMember("charlie@example.com"),
],
});
const list = result.getByTestId("userlist");
const entries = getAllByRole(list, "option");
expect(entries).toHaveLength(3);
// No displayname so mxid is displayed twice
expect(getAllByText(entries[0], "@alice:example.com")).toHaveLength(2);
getByText(entries[1], "Bob");
getByText(entries[2], "charlie@example.com");
});
describe("in DM mode", () => {
const kind = InviteKind.Dm;
it("shows a 'Continue' button", () => {
const onContinue = jest.fn();
const result = renderComponent({ kind, onContinue });
const continueButton = result.getByRole("button", { name: "Continue" });
continueButton.click();
expect(onContinue).toHaveBeenCalled();
});
it("shows a 'Cancel' button", () => {
const onCancel = jest.fn();
const result = renderComponent({ kind, onCancel });
const cancelButton = result.getByRole("button", { name: "Cancel" });
cancelButton.click();
expect(onCancel).toHaveBeenCalled();
});
});
describe("in Invite mode", () => {
const kind = InviteKind.Invite;
it("shows an 'Invite' button", () => {
const onContinue = jest.fn();
const result = renderComponent({ kind, onContinue });
const continueButton = result.getByRole("button", { name: "Invite" });
continueButton.click();
expect(onContinue).toHaveBeenCalled();
});
it("shows a 'Remove' button", () => {
const onRemove = jest.fn();
const result = renderComponent({ kind, onRemove });
const removeButton = result.getByRole("button", { name: "Remove" });
removeButton.click();
expect(onRemove).toHaveBeenCalled();
});
});
});
function renderComponent(props: Partial<ComponentProps<typeof UnknownIdentityUsersWarningDialog>>): RenderResult {
const props1: ComponentProps<typeof UnknownIdentityUsersWarningDialog> = {
onContinue: () => {},
onCancel: () => {},
onRemove: () => {},
screenName: undefined,
kind: InviteKind.Dm,
users: [],
...props,
};
return render(<UnknownIdentityUsersWarningDialog {...props1} />);
}
@@ -1015,7 +1015,7 @@ describe("RoomListStoreV3", () => {
it("emits SECTION_CREATED_EVENT and LISTS_UPDATE_EVENT when section is created", async () => {
enableSections();
getClientAndRooms();
jest.spyOn(sectionModule, "createSection").mockResolvedValue(true);
jest.spyOn(sectionModule, "createSection").mockResolvedValue("element.io.section.test-tag");
const store = new RoomListStoreV3Class(dispatcher);
await store.start();
@@ -1027,14 +1027,13 @@ describe("RoomListStoreV3", () => {
await store.createSection();
expect(sectionCreatedListener).toHaveBeenCalled();
expect(listsUpdateListener).toHaveBeenCalled();
expect(sectionCreatedListener).toHaveBeenCalledWith("element.io.section.test-tag");
});
it("does not emit when section creation is cancelled", async () => {
enableSections();
getClientAndRooms();
jest.spyOn(sectionModule, "createSection").mockResolvedValue(false);
jest.spyOn(sectionModule, "createSection").mockResolvedValue(undefined);
const store = new RoomListStoreV3Class(dispatcher);
await store.start();

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