Compare commits

..

2 Commits

Author SHA1 Message Date
RiotRobot e80f9b287f v1.12.16-rc.0
Docker / Docker Buildx (push) Failing after 3m13s
2026-04-21 12:21:50 +00:00
RiotRobot 77c1a14ce5 Upgrade dependency to matrix-js-sdk@41.4.0-rc.0 2026-04-21 12:15:16 +00:00
228 changed files with 4414 additions and 6341 deletions
@@ -11,7 +11,7 @@ runs:
using: composite
steps:
- name: Download release tarball
uses: robinraju/release-downloader@28fc21f50d76778e7023361aa1f863e717d3d56f # v1
uses: robinraju/release-downloader@daf26c55d821e836577a15f77d86ddc078948b05 # v1
with:
tag: ${{ inputs.tag }}
fileName: element-*.tar.gz*
+1 -15
View File
@@ -2,39 +2,25 @@
"$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",
+1 -9
View File
@@ -206,8 +206,6 @@ 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]
@@ -225,13 +223,10 @@ 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 }}
@@ -241,9 +236,6 @@ 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@9426d40962ed5378910ee2e21d5f8c6fcbf2dd96 # v47
uses: tj-actions/changed-files@823fcebdb31bb35fdf2229d9f769b400309430d0 # v46
with:
files: |
apps/desktop/dockerbuild/**
+1 -1
View File
@@ -31,7 +31,7 @@ jobs:
persist-credentials: false
- name: Install Cosign
uses: sigstore/cosign-installer@cad07c2e89fa2edd6e2d7bab4c1aa38e53f76003 # v4.1.1
uses: sigstore/cosign-installer@398d4b0eeef1380460a10c8013a76f728fb906ac # v3
- 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@cad07c2e89fa2edd6e2d7bab4c1aa38e53f76003 # v4.1.1
uses: sigstore/cosign-installer@398d4b0eeef1380460a10c8013a76f728fb906ac # v3
if: github.event_name != 'pull_request'
- name: Set up QEMU
@@ -25,6 +25,9 @@ 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,12 +45,11 @@ jobs:
working-directory: packages/shared-components
run: "pnpm test:storybook --run"
- 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
# 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: Upload received images & diffs
if: always()
+1 -1
View File
@@ -103,7 +103,7 @@ jobs:
voip|element_call
error|invalid_json
error|misconfigured
welcome|title_element
welcome_to_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@5f6978faf089d4d20b00c7766989d076bb2fc7f1 # v8
uses: peter-evans/create-pull-request@c0f553fe549906ede9cf27b5156039d195d2ece0 # v8
with:
token: ${{ secrets.ELEMENT_BOT_TOKEN }}
branch: actions/jitsi-update
+1 -1
View File
@@ -1 +1 @@
24.15.0
24.14.1
+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:949b0903defbfc4e374dc85f947b153859e9ee0104e425cd9a74d94474a9a335
FROM rust:bullseye@sha256:bc19574c121fe10c1bc68fc2b1ea9b420d87d047a0c50fb1622b282199700cee
ENV DEBIAN_FRONTEND=noninteractive
+1 -2
View File
@@ -1,7 +1,6 @@
{
"compilerOptions": {
"moduleResolution": "node16",
"module": "Node16",
"moduleResolution": "node",
"esModuleInterop": true,
"target": "es2022",
"sourceMap": false,
+8 -8
View File
@@ -3,7 +3,7 @@
"productName": "Element",
"main": "lib/electron-main.js",
"exports": "./lib/electron-main.js",
"version": "1.12.15",
"version": "1.12.16-rc.0",
"description": "Element: the future of secure communication",
"author": {
"name": "Element",
@@ -62,13 +62,13 @@
"electron-window-state": "^5.0.3",
"minimist": "^1.2.6",
"png-to-ico": "^3.0.0",
"uuid": "^14.0.0"
"uuid": "^13.0.0"
},
"devDependencies": {
"@babel/core": "^7.18.10",
"@babel/preset-env": "^7.18.10",
"@babel/preset-typescript": "^7.18.6",
"@electron/asar": "4.2.0",
"@electron/asar": "4.1.2",
"@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.9.0",
"app-builder-lib": "26.8.2",
"chokidar": "^5.0.0",
"detect-libc": "^2.0.0",
"electron": "41.2.2",
"electron-builder": "26.9.0",
"electron-builder-squirrel-windows": "26.9.0",
"electron": "41.1.0",
"electron-builder": "26.8.2",
"electron-builder-squirrel-windows": "26.8.2",
"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": "6.0.3"
"typescript": "5.9.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: any[] = [];
private _chunks = [];
public constructor() {
super();
super.on("data", this.onData);
}
private onData = (chunk: any): void => {
private onData = (chunk): void => {
this._chunks.push(chunk);
};
Binary file not shown.

Before

Width:  |  Height:  |  Size: 957 KiB

After

Width:  |  Height:  |  Size: 1.1 MiB

+3 -4
View File
@@ -1,12 +1,11 @@
{
"compilerOptions": {
"resolveJsonModule": true,
"moduleResolution": "bundler",
"moduleResolution": "node",
"esModuleInterop": true,
"target": "es2022",
"module": "ESNext",
"lib": ["es2024", "dom", "dom.iterable"],
"strictNullChecks": false,
"module": "es2022",
"lib": ["es2022", "dom"],
"types": ["node"]
},
"include": ["**/*.ts"]
+3 -3
View File
@@ -1,8 +1,8 @@
# syntax=docker.io/docker/dockerfile:1.23-labs@sha256:7eca9451d94f9b8ad22e44988b92d595d3e4d65163794237949a8c3413fbed5d
# syntax=docker.io/docker/dockerfile:1.22-labs@sha256:4c116b618ed48404d579b5467127b20986f2a6b29e4b9be2fee841f632db6a86
# Context must be the root of the monorepo
# Builder
FROM --platform=$BUILDPLATFORM node:24-bullseye@sha256:d2059a9c157c9f70739736979fa3635008bf3ca74560b30930dc181228bc427f AS builder
FROM --platform=$BUILDPLATFORM node:24-bullseye@sha256:27e462f5db2402700867dfa8ec35e3a68b127fdf61b505db0dd6ab98c38284bb 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:360465db60105a4cbf5215cd9e5a2ba40ef956978dd94f99707e9674050e38ea
FROM nginxinc/nginx-unprivileged:alpine-slim@sha256:b5831ee7f7aa827cbae87df4a30a642f62c747d8525f5674365389f3adab278d
# Need root user to install packages & manipulate the usr directory
USER root
+9 -8
View File
@@ -1,6 +1,6 @@
{
"name": "element-web",
"version": "1.12.15",
"version": "1.12.16-rc.0",
"description": "Element: the future of secure communication",
"author": "New Vector Ltd.",
"repository": {
@@ -72,7 +72,7 @@
"glob-to-regexp": "^0.4.1",
"highlight.js": "^11.3.1",
"html-entities": "^2.0.0",
"html-react-parser": "^6.0.0",
"html-react-parser": "^5.2.2",
"is-ip": "^5.0.0",
"js-xxhash": "^5.0.0",
"jsrsasign": "^11.0.0",
@@ -81,7 +81,7 @@
"lodash": "npm:lodash-es@4.18.1",
"maplibre-gl": "^5.0.0",
"matrix-encrypt-attachment": "^1.0.3",
"matrix-js-sdk": "github:matrix-org/matrix-js-sdk#develop",
"matrix-js-sdk": "41.4.0-rc.0",
"matrix-widget-api": "^1.16.1",
"memoize-one": "^6.0.0",
"mime": "^4.0.4",
@@ -89,7 +89,7 @@
"opus-recorder": "^8.0.3",
"pako": "^2.0.3",
"png-chunks-extract": "^1.0.0",
"posthog-js": "1.369.3",
"posthog-js": "1.364.7",
"qrcode": "1.5.4",
"re-resizable": "6.11.2",
"react": "catalog:",
@@ -104,6 +104,7 @@
"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": {
@@ -125,7 +126,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.19.1",
"@element-hq/element-call-embedded": "0.18.0",
"@element-hq/element-web-playwright-common": "workspace:*",
"@fetch-mock/jest": "^0.2.20",
"@jest/globals": "^30.2.0",
@@ -204,17 +205,17 @@
"mini-css-extract-plugin": "2.10.2",
"modernizr": "^3.12.0",
"playwright-core": "catalog:",
"postcss": "8.5.10",
"postcss": "8.5.8",
"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.1",
"postcss-preset-env": "11.2.0",
"postcss-scss": "4.0.9",
"postcss-simple-vars": "7.0.1",
"prettier": "3.8.3",
"prettier": "3.8.1",
"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: "Be in your element" })).toBeVisible();
await expect(page.getByRole("heading", { name: "Welcome to 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, type ExtendedToMatchScreenshotOptions } from "../../element-web-test";
import { test, expect } 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: ExtendedToMatchScreenshotOptions = {
const screenshotOptions = {
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()) ?? undefined;
screenshotOptions.clip = await page.locator(".mx_EventTile_last").boundingBox();
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()) ?? undefined;
screenshotOptions.clip = await page.locator(".mx_EventTile_last").boundingBox();
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()) ?? undefined;
screenshotOptions.clip = await page.locator(".mx_EventTile_last").boundingBox();
await scrollToBottomOfTimeline(page);
await expect(page).toMatchScreenshot(`${detail.replaceAll(" ", "-")}-bubble-layout.png`, screenshotOptions);
};
@@ -27,9 +27,6 @@ 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) => {
@@ -47,7 +44,7 @@ const bobJoin = async (page: Page, bob: Bot) => {
const bobRooms = cli.getRooms();
if (!bobRooms.length) {
await new Promise<void>((resolve) => {
const onMembership = () => {
const onMembership = (_event) => {
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 { Preset, RoomMemberEvent, RoomStateEvent } from "matrix-js-sdk/src/matrix";
import type { EmittedEvents, Preset } 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 RoomStateEvent.Events, (event, _state, _lastStateEvent) => {
client.on("RoomState.events" as EmittedEvents, (event, _state, _lastStateEvent) => {
if (event.getType() === "m.room.encryption") {
resolve();
}
@@ -253,14 +253,11 @@ test.describe("Cryptography", function () {
// invite Alice
const inviteAlicePromise = new Promise<void>((resolve) => {
client.on(
"RoomMember.membership" as RoomMemberEvent.Membership,
(_event, member, _oldMembership?) => {
if (member.userId === alice.userId && member.membership === "invite") {
resolve();
}
},
);
client.on("RoomMember.membership" as EmittedEvents, (_event, member, _oldMembership?) => {
if (member.userId === alice.userId && member.membership === "invite") {
resolve();
}
});
});
await client.invite(roomId, alice.userId);
// wait for the invite to come back so that we encrypt to Alice
@@ -274,14 +271,11 @@ test.describe("Cryptography", function () {
// kick Alice
const kickAlicePromise = new Promise<void>((resolve) => {
client.on(
"RoomMember.membership" as RoomMemberEvent.Membership,
(_event, member, _oldMembership?) => {
if (member.userId === alice.userId && member.membership === "leave") {
resolve();
}
},
);
client.on("RoomMember.membership" as EmittedEvents, (_event, member, _oldMembership?) => {
if (member.userId === alice.userId && member.membership === "leave") {
resolve();
}
});
});
await client.kick(roomId, alice.userId);
await kickAlicePromise;
@@ -166,9 +166,13 @@ 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, { confirmUnknownUser: true });
await aliceElementApp.inviteUserToCurrentRoom(bobCredentials.userId);
// 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, { confirmUnknownUser: true });
await aliceElementApp.inviteUserToCurrentRoom(bobCredentials.userId);
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, { confirmUnknownUser: true });
await aliceElementApp.inviteUserToCurrentRoom(charlieCredentials.userId);
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 = function (this: IDBRequest) {
const db = this.result as IDBDatabase;
request.onsuccess = (event: Event & { target: { result: IDBDatabase } }) => {
const db = event.target.result;
const request = db.transaction("core", "readwrite").objectStore("core").delete("private_identity");
request.onsuccess = () => {
db.close();
@@ -9,15 +9,6 @@ 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",
@@ -71,15 +62,6 @@ 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();
@@ -122,15 +104,6 @@ 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,93 +175,4 @@ 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: "Be in your element" })).toBeVisible();
await expect(page.getByRole("heading", { name: "Welcome to Element!" })).toBeVisible();
// Start the login process
await expect(axe).toHaveNoViolations();
@@ -252,7 +252,6 @@ 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, RelationType } from "matrix-js-sdk/src/matrix";
import type { MatrixEvent, ISendEventResponse, ReceiptType } 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: RelationType) => {
isRelation: (relType) => {
return !relType || relType === "m.thread";
},
} as any as MatrixEvent;
@@ -57,9 +57,6 @@ 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,10 +163,6 @@ 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, { confirmUnknownUser: true });
await aliceElementApp.inviteUserToCurrentRoom(bobCredentials.userId);
// 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, { confirmUnknownUser: true });
await aliceElementApp.inviteUserToCurrentRoom(bobCredentials.userId);
// 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, { confirmUnknownUser: true });
await aliceElementApp.inviteUserToCurrentRoom(bobCredentials.userId);
// 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, { confirmUnknownUser: true });
await aliceElementApp.inviteUserToCurrentRoom(bobCredentials.userId);
// 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, { confirmUnknownUser: true });
await aliceElementApp.inviteUserToCurrentRoom(bobCredentials.userId);
// 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, IContent } from "matrix-js-sdk/src/matrix";
import type { ISendEventResponse, EventType, MsgType } 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,9 +50,11 @@ const expectAvatar = async (cli: Client, e: Locator, avatarUrl: string): Promise
};
const sendEvent = async (client: Client, roomId: string, html = false): Promise<ISendEventResponse> => {
const content: IContent = {
const content = {
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 ((<any>window)[predicateId](room)) {
if (window[predicateId](room)) {
resolve(room);
return;
}
@@ -50,7 +50,7 @@ export async function waitForRoom(
function onEvent(ev: MatrixEvent) {
if (ev.getRoomId() !== roomId) return;
if ((<any>window)[predicateId](room)) {
if (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>({
},
});
export interface ExtendedToMatchScreenshotOptions extends ToMatchScreenshotOptions {
interface ExtendedToMatchScreenshotOptions extends ToMatchScreenshotOptions {
includeDialogBackground?: boolean;
showTooltips?: boolean;
timeout?: number;
+3 -18
View File
@@ -233,30 +233,15 @@ 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,
options?: {
/** If true, expect and acknowledge "Confirm inviting new users" page */
confirmUnknownUser?: boolean;
},
): Promise<void> {
public async inviteUserToCurrentRoom(userId: string): Promise<void> {
const rightPanel = await this.openRoomInfoPanel();
await rightPanel.getByRole("menuitem", { name: "Invite" }).click();
const dialogLocator = this.page.getByRole("dialog");
const input = dialogLocator.getByTestId("invite-dialog-input");
const input = this.page.getByRole("dialog").getByTestId("invite-dialog-input");
await input.fill(userId);
await input.press("Enter");
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();
}
await this.page.getByRole("dialog").getByRole("button", { name: "Invite" }).click();
}
/**
+1 -29
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 ElementHandle } from "playwright-core";
import { type PageFunctionOn } from "playwright-core/types/structs";
import { Network } from "./network";
import type {
@@ -30,34 +30,6 @@ 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.

Before

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.6 KiB

After

Width:  |  Height:  |  Size: 7.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.6 KiB

After

Width:  |  Height:  |  Size: 7.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 29 KiB

After

Width:  |  Height:  |  Size: 16 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 as any,
old_private_keys: null,
key_validity_period: "168h0m0s",
cache: {
max_size_estimated: "1gb",
@@ -47,7 +47,7 @@ const DEFAULT_CONFIG = {
room_name: "Server Alerts",
},
jetstream: {
addresses: null as any,
addresses: null,
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 as any,
config_files: null,
},
client_api: {
registration_disabled: false,
@@ -79,14 +79,14 @@ const DEFAULT_CONFIG = {
recaptcha_bypass_secret: "",
turn: {
turn_user_lifetime: "5m",
turn_uris: null as any,
turn_uris: null,
turn_shared_secret: "",
},
rate_limiting: {
enabled: true,
threshold: 20,
cooloff_ms: 500,
exempt_user_ids: null as any,
exempt_user_ids: null,
},
},
federation_api: {
@@ -140,7 +140,7 @@ const DEFAULT_CONFIG = {
},
},
mscs: {
mscs: null as any,
mscs: null,
database: {
connection_string: "file:dendrite-msc.db",
},
@@ -157,7 +157,7 @@ const DEFAULT_CONFIG = {
},
user_api: {
bcrypt_cost: 10,
auto_join_rooms: null as any,
auto_join_rooms: null,
account_database: {
connection_string: "file:dendrite-userapi.db",
},
@@ -183,12 +183,12 @@ const DEFAULT_CONFIG = {
serviceName: "",
disabled: false,
rpc_metrics: false,
tags: [] as any[],
sampler: null as any,
reporter: null as any,
headers: null as any,
baggage_restrictions: null as any,
throttler: null as any,
tags: [],
sampler: null,
reporter: null,
headers: null,
baggage_restrictions: null,
throttler: null,
},
},
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:b2fec2c9460f5b297a3a4ce78037902590240a1978301ed1d4bc97918c451041";
"ghcr.io/element-hq/synapse:develop@sha256:926d95954cba30a2568dbe907da6628d8e10e06f2b19901f0ec61eb2993be450";
/**
* SynapseContainer which freezes the docker digest to stabilise tests,
+3 -5
View File
@@ -2,14 +2,12 @@
"compilerOptions": {
"target": "es2022",
"jsx": "react",
"lib": ["es2024", "dom", "dom.iterable"],
"lib": ["ESNext", "es2022", "dom", "dom.iterable"],
"resolveJsonModule": true,
"esModuleInterop": true,
"moduleResolution": "bundler",
"module": "ESNext",
"moduleResolution": "node",
"module": "es2022",
"allowImportingTsExtensions": true,
"strictNullChecks": false,
"noImplicitAny": false,
"types": ["node"]
},
"include": [
+5 -3
View File
@@ -47,9 +47,11 @@
"dependsOn": ["^build", "^build:playwright"]
},
"test:unit": {
// 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" },
"executor": "@nx/jest:jest",
"options": {
"jestConfig": "{projectRoot}/jest.config.ts",
"cwd": "apps/web"
},
"dependsOn": ["^build"]
},
"test:playwright": {
+5 -11
View File
@@ -598,7 +598,6 @@ 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),
@@ -626,8 +625,7 @@ legend {
.mx_ThemeChoicePanel_CustomTheme button,
.mx_UnpinAllDialog button,
.mx_ShareDialog button,
.mx_EncryptionUserSettingsTab button,
.mx_UnknownIdentityUsersWarningDialog button
.mx_EncryptionUserSettingsTab button
):last-child {
margin-right: 0px;
}
@@ -643,8 +641,7 @@ legend {
.mx_ShareDialog button,
.mx_EncryptionUserSettingsTab button,
.mx_InviteDialog_section button,
.mx_InviteDialog_editor button,
.mx_UnknownIdentityUsersWarningDialog button
.mx_InviteDialog_editor button
):focus,
.mx_Dialog input[type="submit"]:focus,
.mx_Dialog_buttons button:not(.mx_Dialog_nonDialogButton, .mx_AccessibleButton):focus,
@@ -662,8 +659,7 @@ legend {
.mx_ThemeChoicePanel_CustomTheme button,
.mx_UnpinAllDialog button,
.mx_ShareDialog button,
.mx_EncryptionUserSettingsTab button,
.mx_UnknownIdentityUsersWarningDialog button
.mx_EncryptionUserSettingsTab button
),
.mx_Dialog_buttons input[type="submit"].mx_Dialog_primary {
color: var(--cpd-color-text-on-solid-primary);
@@ -682,8 +678,7 @@ legend {
.mx_ThemeChoicePanel_CustomTheme button,
.mx_UnpinAllDialog button,
.mx_ShareDialog button,
.mx_EncryptionUserSettingsTab button,
.mx_UnknownIdentityUsersWarningDialog button
.mx_EncryptionUserSettingsTab button
),
.mx_Dialog_buttons input[type="submit"].danger {
background-color: var(--cpd-color-bg-critical-primary);
@@ -706,8 +701,7 @@ legend {
.mx_ThemeChoicePanel_CustomTheme button,
.mx_UnpinAllDialog button,
.mx_ShareDialog button,
.mx_EncryptionUserSettingsTab button,
.mx_UnknownIdentityUsersWarningDialog button
.mx_EncryptionUserSettingsTab button
):disabled,
.mx_Dialog input[type="submit"]:disabled,
.mx_Dialog_buttons button:not(.mx_Dialog_nonDialogButton, .mx_AccessibleButton):disabled,
-2
View File
@@ -105,7 +105,6 @@
@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";
@@ -171,7 +170,6 @@
@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";
@@ -1,43 +0,0 @@
/*
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;
}
}
+1 -5
View File
@@ -9,10 +9,6 @@ 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;
@@ -22,7 +18,7 @@ Please see LICENSE files in the repository root for full details.
.mx_Welcome .mx_AuthBody_language {
width: 160px;
margin: var(--cpd-space-1x) 0;
margin-bottom: 10px;
}
/* Invert image colours in dark mode. */
@@ -1,45 +0,0 @@
/*
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
@@ -0,0 +1,191 @@
<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>
@@ -0,0 +1,3 @@
<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>

After

Width:  |  Height:  |  Size: 587 B

+16
View File
@@ -0,0 +1,16 @@
<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>

After

Width:  |  Height:  |  Size: 1.1 KiB

@@ -0,0 +1,4 @@
<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>

After

Width:  |  Height:  |  Size: 775 B

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 53 KiB

-10
View File
@@ -52,13 +52,3 @@ 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;
+9
View File
@@ -8,6 +8,7 @@ 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";
@@ -185,6 +186,14 @@ declare global {
readonly port: MessagePort;
}
/**
* In future, browsers will support focusVisible option.
* See https://developer.mozilla.org/en-US/docs/Web/API/HTMLElement/focus#focusvisible
*/
interface FocusOptions {
focusVisible: boolean;
}
// https://github.com/microsoft/TypeScript/issues/28308#issuecomment-650802278
function registerProcessor(
name: string,
-8
View File
@@ -1,8 +0,0 @@
/*
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";
+2 -3
View File
@@ -52,9 +52,8 @@ export interface IConfigOptions {
disable_3pid_login?: boolean;
brand: string;
branding: {
welcome_background_url: string | string[]; // chosen at random if array
logo_link_url: string;
branding?: {
welcome_background_url?: string | string[]; // chosen at random if array
auth_header_logo_url?: string;
auth_footer_links?: { text: string; url: string }[];
};
+2 -1
View File
@@ -13,6 +13,7 @@ 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";
@@ -150,7 +151,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: { image?: unknown }[]): void {
public trackUrlPreview(eventId: string, isEncrypted: boolean, previews: UrlPreview[]): void {
// Discount any previews that we have already tracked.
if (this.previewedEventIds.get(eventId)) {
return;
+3 -8
View File
@@ -12,16 +12,11 @@ import { mergeWith } from "lodash";
import { SnakedObject } from "./utils/SnakedObject";
import { type IConfigOptions } from "./IConfigOptions";
import { isObject, objectClone } from "./utils/objects";
import { type DeepPartial, type DeepReadonly, type Defaultize } from "./@types/common";
import { type DeepReadonly, type Defaultize } from "./@types/common";
// see element-web config.md for docs, or the IConfigOptions interface for dev docs
export const DEFAULTS: DeepReadonly<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",
@@ -75,7 +70,7 @@ export type ConfigOptions = Defaultize<IConfigOptions, typeof DEFAULTS>;
function mergeConfig(
config: DeepReadonly<IConfigOptions>,
changes: DeepReadonly<DeepPartial<IConfigOptions>>,
changes: DeepReadonly<Partial<IConfigOptions>>,
): DeepReadonly<IConfigOptions> {
// return { ...config, ...changes };
return mergeWith(objectClone(config), changes, (objValue, srcValue) => {
@@ -141,7 +136,7 @@ export default class SdkConfig {
SdkConfig.setInstance(mergeConfig(DEFAULTS, {})); // safe to cast - defaults will be applied
}
public static add(cfg: DeepPartial<ConfigOptions>): void {
public static add(cfg: Partial<ConfigOptions>): void {
SdkConfig.put(mergeConfig(SdkConfig.get(), cfg));
}
}
+374 -33
View File
@@ -6,21 +6,23 @@ 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 {
RovingAction,
RovingTabIndexProvider as SharedRovingTabIndexProvider,
type RovingTabIndexProviderProps,
} from "@element-hq/web-shared-components";
import React, {
createContext,
useCallback,
useContext,
useMemo,
useRef,
useReducer,
type Reducer,
type Dispatch,
type RefObject,
type ReactNode,
type RefCallback,
} from "react";
import { getKeyBindingsManager } from "../KeyBindingsManager";
import { KeyBindingAction } from "./KeyboardShortcuts";
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";
import { type FocusHandler } from "./roving/types";
/**
* Module to simplify implementing the Roving TabIndex accessibility technique
@@ -35,31 +37,370 @@ export type { IAction, IState } from "@element-hq/web-shared-components";
* https://developer.mozilla.org/en-US/docs/Web/Accessibility/Keyboard-navigable_JavaScript_widgets#Technique_1_Roving_tabindex
*/
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 undefined;
// 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;
}
};
type IProps = Omit<RovingTabIndexProviderProps, "getAction">;
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;
}
export const RovingTabIndexProvider: React.FC<IProps> = (props) => {
return <SharedRovingTabIndexProvider {...props} getAction={getWebRovingAction} />;
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 };
}
default:
return state;
}
};
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;
}
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];
};
// re-export the semantic helper components for simplicity
@@ -6,4 +6,26 @@ 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.
*/
export { RovingTabIndexWrapper } from "@element-hq/web-shared-components";
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 });
};
@@ -1,8 +1,9 @@
/*
Copyright 2026 Element Creations Ltd.
Copyright 2024 New Vector Ltd.
Copyright 2020 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
declare module "*.css";
export type FocusHandler = () => void;
-20
View File
@@ -1,20 +0,0 @@
/*
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,13 +31,16 @@ 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 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;
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;
}
}
return AuthPage.welcomeBackgroundUrl;
@@ -1,51 +0,0 @@
/*
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;
+21 -20
View File
@@ -5,10 +5,9 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com
Please see LICENSE files in the repository root for full details.
*/
import React, { type ReactNode } from "react";
import React 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";
@@ -17,12 +16,14 @@ 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");
const pageUrl = pagesConfig?.get("welcome_url");
let pageUrl: string | undefined;
if (pagesConfig) {
pageUrl = pagesConfig.get("welcome_url");
}
const replaceMap: Record<string, string> = {
"$brand": SdkConfig.get("brand"),
@@ -32,25 +33,25 @@ export default class Welcome extends React.PureComponent<EmptyObject> {
"[matrix]": MATRIX_LOGO_HTML,
};
let body: ReactNode;
if (pageUrl) {
body = <EmbeddedPage className="mx_WelcomePage" url={pageUrl} replaceMap={replaceMap} />;
} else {
body = <DefaultWelcome />;
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";
}
return (
<AuthPage addBlur={false}>
<Glass>
<div
className={classNames("mx_Welcome", {
mx_WelcomePage_registrationDisabled: !SettingsStore.getValue(UIFeature.Registration),
})}
>
{body}
<LanguageSelector />
</div>
</Glass>
<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>
);
}
@@ -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: RovingStateActionType.SetFocus,
type: Type.SetFocus,
payload: { node },
});
node?.scrollIntoView?.({
@@ -61,9 +61,6 @@ 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;
@@ -164,14 +161,6 @@ 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.
*
@@ -241,8 +230,7 @@ export default class InviteDialog extends React.PureComponent<Props, IInviteDial
dialPadValue: "",
currentTabId: TabId.UserDirectory,
unknownIdentityUsers: null,
// These two flags are used for the 'Go' button to communicate what is going on.
busy: false,
};
}
@@ -456,21 +444,6 @@ 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) {
@@ -1150,49 +1123,14 @@ 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();
@@ -1229,6 +1167,7 @@ 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);
@@ -1272,14 +1211,11 @@ 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>
@@ -1287,7 +1223,7 @@ export default class InviteDialog extends React.PureComponent<Props, IInviteDial
{this.renderEditor()}
<AccessibleButton
kind="primary"
onClick={onGoButtonPressed}
onClick={goButtonFn}
className="mx_InviteDialog_goButton"
disabled={this.state.busy || !this.hasSelection()}
>
@@ -1299,49 +1235,12 @@ 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,6 +9,7 @@ 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";
@@ -329,7 +330,7 @@ export const StickyEventEditor: React.FC<IEditorProps> = ({ mxEvent, onBack }) =
const defaultContent = mxEvent
? stringify(mxEvent.getContent())
: stringify({
msc4354_sticky_key: window.crypto.randomUUID(),
msc4354_sticky_key: uuidv4(),
});
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 {
@@ -1,121 +0,0 @@
/*
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 {
findNextSiblingElement,
RovingStateActionType,
findSiblingElement,
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: RovingStateActionType.SetFocus,
type: Type.SetFocus,
payload: { node },
});
node?.scrollIntoView?.({
@@ -1181,10 +1181,7 @@ const SpotlightDialog: React.FC<IProps> = ({ initialText = "", initialFilter = n
}
const idx = nodes.indexOf(rovingContext.state.activeNode);
node = findNextSiblingElement(
nodes,
idx + (accessibilityAction === KeyBindingAction.ArrowUp ? -1 : 1),
);
node = findSiblingElement(nodes, idx + (accessibilityAction === KeyBindingAction.ArrowUp ? -1 : 1));
}
break;
@@ -1204,7 +1201,7 @@ const SpotlightDialog: React.FC<IProps> = ({ initialText = "", initialFilter = n
const nodes = rovingContext.state.nodes.filter(nodeIsForRecentlyViewed);
const idx = nodes.indexOf(rovingContext.state.activeNode);
node = findNextSiblingElement(
node = findSiblingElement(
nodes,
idx + (accessibilityAction === KeyBindingAction.ArrowLeft ? -1 : 1),
);
@@ -1214,7 +1211,7 @@ const SpotlightDialog: React.FC<IProps> = ({ initialText = "", initialFilter = n
if (node) {
rovingContext.dispatch({
type: RovingStateActionType.SetFocus,
type: Type.SetFocus,
payload: { node },
});
node?.scrollIntoView({
@@ -25,7 +25,7 @@ import {
type IAction as RovingAction,
type IState as RovingState,
RovingTabIndexProvider,
RovingStateActionType,
Type,
} 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: RovingStateActionType.SetFocus,
type: Type.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: RovingStateActionType.SetFocus,
type: Type.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, type Element as ParserElement, domToReact } from "html-react-parser";
import { type DOMNode, 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.type === "tag" && child.tagName.toUpperCase() === "CODE")) {
if (!preNode.children.some((child) => child instanceof ParserElement && child.tagName.toUpperCase() === "CODE")) {
content = <code>{content}</code>;
}
+3 -14
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,14 +1368,6 @@
"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>.",
@@ -1824,6 +1816,7 @@
"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",
@@ -3988,11 +3981,7 @@
"you_are_presenting": "You are presenting"
},
"web_default_device_name": "%(appName)s: %(browserName)s on %(osName)s",
"welcome": {
"tagline_element": "Supercharged for speed and simplicity.",
"title_element": "Be in your element",
"title_generic": "Welcome to %(brand)s"
},
"welcome_to_element": "Welcome to Element",
"widget": {
"added_by": "Widget added by",
"capabilities_dialog": {
+1 -7
View File
@@ -680,12 +680,6 @@
"unfederated_label_default_on": "如果此房间将用于与拥有自己主服务器的外部团队协作,你可以禁用此功能。此设置以后无法更改。",
"unsupported_version": "服务器不支持指定的房间版本。"
},
"create_section_dialog": {
"create_section": "创建区域",
"description": "区域仅对你可见",
"label": "区域名称",
"title": "创建区域"
},
"create_space": {
"add_details_prompt": "添加一些信息以便人们识别。",
"add_details_prompt_2": "你可以随时更改。",
@@ -3083,7 +3077,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, type Element, type HTMLReactParserOptions, type Text } from "html-react-parser";
import { type DOMNode, 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.type === "tag") {
if (node instanceof Element) {
const tagName = node.tagName.toLowerCase() as keyof HTMLElementTagNameMap;
for (const replacer of renderers) {
const result = replacer[tagName]?.(node, parametersWithReplace, index);
@@ -62,8 +62,6 @@ 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
@@ -95,7 +93,6 @@ 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.
@@ -246,7 +243,6 @@ export class RoomListStoreV3Class extends AsyncStoreWithClient<EmptyObject> {
case "MatrixActions.Room.tags": {
const room = payload.room;
this.addRoomAndEmit(room);
this.emit(ROOM_TAGGED_EVENT);
break;
}
@@ -489,19 +485,13 @@ export class RoomListStoreV3Class extends AsyncStoreWithClient<EmptyObject> {
/**
* Create a new section.
* Emits {@link SECTION_CREATED_EVENT} if the section was successfully created.
* Emits {@link SECTION_CREATED_EVENT} and {@link LISTS_UPDATE_EVENT} if the section was successfully created.
*/
public async createSection(): Promise<void> {
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;
const sectionIsCreated = await createSection();
if (!sectionIsCreated) return;
this.emit(SECTION_CREATED_EVENT);
this.scheduleEmit();
}
/**
+7 -19
View File
@@ -5,6 +5,8 @@
* 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";
@@ -12,20 +14,6 @@ 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.
*/
@@ -47,15 +35,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 the new section tag if created, or undefined if cancelled.
* @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.
*/
export async function createSection(): Promise<string | undefined> {
export async function createSection(): Promise<boolean> {
const modal = Modal.createDialog(CreateSectionDialog);
const [shouldCreateSection, sectionName] = await modal.finished;
if (!shouldCreateSection || !sectionName) return undefined;
if (!shouldCreateSection || !sectionName) return false;
const tag = `${CUSTOM_SECTION_TAG_PREFIX}${window.crypto.randomUUID()}`;
const tag = `element.io.section.${uuidv4()}`;
const newSection: CustomSection = { tag, name: sectionName };
// Save the new section data
@@ -67,5 +55,5 @@ export async function createSection(): Promise<string | undefined> {
const orderedSections = SettingsStore.getValue("RoomList.OrderedCustomSections") || [];
orderedSections.push(tag);
await SettingsStore.setValue("RoomList.OrderedCustomSections", null, SettingLevel.ACCOUNT, orderedSections);
return tag;
return true;
}
+2 -1
View File
@@ -7,6 +7,7 @@ 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
@@ -106,7 +107,7 @@ export function checkSessionLockFree(): boolean {
*/
export async function getSessionLock(onNewInstance: () => Promise<void>): Promise<boolean> {
/** unique ID for this session */
const sessionIdentifier = window.crypto.randomUUID();
const sessionIdentifier = uuidv4();
const prefixedLogger = logger.getChild(`getSessionLock[${sessionIdentifier}]`);
+8 -17
View File
@@ -13,29 +13,20 @@ 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.
* 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.
* Toggle tag for a given room
* @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 && !isCustomSectionTag(tagId)) {
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 {
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,10 +34,8 @@ export interface UrlPreviewGroupViewModelProps {
}
export const MAX_PREVIEWS_WHEN_LIMITED = 2;
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 const PREVIEW_WIDTH = 100;
export const PREVIEW_HEIGHT = 100;
export enum PreviewVisibility {
/**
@@ -102,26 +100,21 @@ export class UrlPreviewGroupViewModel
typeof response["og:description"] === "string" && response["og:description"].trim()
? response["og:description"].trim()
: undefined;
const siteName =
let siteName =
typeof response["og:site_name"] === "string" && response["og:site_name"].trim()
? response["og:site_name"].trim()
: new URL(link).hostname;
: undefined;
// 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),
@@ -129,50 +122,6 @@ 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`
@@ -329,7 +278,6 @@ 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.
@@ -337,46 +285,31 @@ 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 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 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 result = {
link,
title,
author,
description,
siteName,
siteIcon,
showTooltipOnLink: !!(link !== title && PlatformPeg.get()?.needsUrlTooltips()),
showTooltipOnLink: link !== title && PlatformPeg.get()?.needsUrlTooltips(),
image,
} satisfies UrlPreview;
this.previewCache.set(link, result);
@@ -10,7 +10,6 @@ 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";
@@ -38,8 +37,7 @@ 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, { CHATS_TAG } from "../../stores/room-list-v3/RoomListStoreV3";
import { _t } from "../../languageHandler";
import RoomListStoreV3 from "../../stores/room-list-v3/RoomListStoreV3";
interface RoomItemProps {
room: Room;
@@ -98,13 +96,6 @@ 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();
}
@@ -190,7 +181,6 @@ 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,
});
@@ -289,9 +279,6 @@ 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,
@@ -320,7 +307,6 @@ export class RoomListItemViewModel
canMarkAsUnread,
roomNotifState,
canMoveToSection,
sections,
};
}
@@ -403,42 +389,4 @@ 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,7 +13,6 @@ 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";
@@ -154,14 +153,7 @@ export class RoomListViewModel
this.disposables.trackListener(
RoomListStoreV3.instance,
RoomListStoreV3Event.SectionCreated as any,
this.onSectionCreated as (...args: unknown[]) => void,
);
// Subscribe to room tagging
this.disposables.trackListener(
RoomListStoreV3.instance,
RoomListStoreV3Event.RoomTagged as any,
this.onRoomTagged,
this.onSectionCreated,
);
// Subscribe to active room changes to update selected room
@@ -508,7 +500,6 @@ 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
@@ -553,23 +544,17 @@ 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
@@ -601,13 +586,15 @@ export class RoomListViewModel
}
};
public onSectionCreated = (tag: string): void => {
this.updateRoomListData(false, null, tag);
this.showToast("section_created");
};
public onRoomTagged = (): void => {
this.showToast("chat_moved");
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 closeToast: () => void = () => {
@@ -616,15 +603,6 @@ 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,10 +18,20 @@ describe("PosthogTrackers", () => {
const tracker = new PosthogTrackers();
tracker.trackUrlPreview("$123456", false, [
{
image: {},
title: "A preview",
image: {
imageThumb: "abc",
imageFull: "abc",
},
link: "a-link",
},
]);
tracker.trackUrlPreview("$123456", false, [
{
title: "A second preview",
link: "a-link",
},
]);
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:150.0) Gecko/20100101 Firefox/150.0",
"Mozilla/5.0 (Macintosh; Intel Mac OS X 15.7; rv:145.0) Gecko/20100101 Firefox/147.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:150.0) Gecko/20100101 Firefox/150.0",
"Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:147.0) Gecko/20100101 Firefox/147.0",
// Latest Firefox on Linux
"Mozilla/5.0 (X11; Linux i686; rv:150.0) Gecko/20100101 Firefox/150.0",
"Mozilla/5.0 (X11; Linux i686; rv:147.0) Gecko/20100101 Firefox/147.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());
@@ -1,31 +1,30 @@
/*
* 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.
*/
Copyright 2024 New Vector Ltd.
Copyright 2020, 2021 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import React, { type HTMLAttributes } from "react";
import { act, render } from "jest-matrix-react";
import userEvent from "@testing-library/user-event";
import { act, fireEvent, render } from "@test-utils";
import { describe, expect, it, vi } from "vitest";
import {
RovingAction,
RovingStateActionType,
type IState,
reducer,
RovingTabIndexProvider,
RovingTabIndexWrapper,
Type,
useRovingTabIndex,
} from ".";
import type { IState } from ".";
import { reducer } from "./RovingTabIndex";
} from "../../../src/accessibility/RovingTabIndex";
const Button = (props: HTMLAttributes<HTMLButtonElement>): React.JSX.Element => {
const Button = (props: HTMLAttributes<HTMLButtonElement>) => {
const [onFocus, isActive, ref] = useRovingTabIndex<HTMLButtonElement>();
return <button {...props} onFocus={onFocus} tabIndex={isActive ? 0 : -1} ref={ref} />;
};
const checkTabIndexes = (buttons: NodeListOf<HTMLElement>, expectations: number[]): void => {
const checkTabIndexes = (buttons: NodeListOf<HTMLElement>, expectations: number[]) => {
expect([...buttons].map((b) => b.tabIndex)).toStrictEqual(expectations);
};
@@ -35,26 +34,13 @@ const createButtonElement = (text: string): HTMLButtonElement => {
return button;
};
const renderToolbar = (
ui: React.ReactNode,
props: Partial<React.ComponentProps<typeof RovingTabIndexProvider>> = {},
): ReturnType<typeof render> => {
return render(
<RovingTabIndexProvider {...props}>
{({ onKeyDownHandler }) => (
<div aria-label="Roving test container" onKeyDown={onKeyDownHandler} role="toolbar">
{ui}
</div>
)}
</RovingTabIndexProvider>,
);
};
// give the buttons keys for the fibre reconciler to not treat them all as the same
const button1 = <Button key={1}>a</Button>;
const button2 = <Button key={2}>b</Button>;
const button3 = <Button key={3}>c</Button>;
const button4 = <Button key={4}>d</Button>;
// mock offsetParent
Object.defineProperty(HTMLElement.prototype, "offsetParent", {
get() {
return this.parentNode;
@@ -62,7 +48,7 @@ Object.defineProperty(HTMLElement.prototype, "offsetParent", {
});
describe("RovingTabIndex", () => {
it("renders children as expected", () => {
it("RovingTabIndexProvider renders children as expected", () => {
const { container } = render(
<RovingTabIndexProvider>
{() => (
@@ -76,81 +62,88 @@ describe("RovingTabIndex", () => {
expect(container.innerHTML).toBe("<div><span>Test</span></div>");
});
it("works as expected with useRovingTabIndex", () => {
it("RovingTabIndexProvider works as expected with useRovingTabIndex", () => {
const { container, rerender } = render(
<RovingTabIndexProvider>
{() => (
<>
<React.Fragment>
{button1}
{button2}
{button3}
</>
</React.Fragment>
)}
</RovingTabIndexProvider>,
);
// should begin with 0th being active
checkTabIndexes(container.querySelectorAll("button"), [0, -1, -1]);
// focus on 2nd button and test it is the only active one
act(() => container.querySelectorAll("button")[2].focus());
checkTabIndexes(container.querySelectorAll("button"), [-1, -1, 0]);
// focus on 1st button and test it is the only active one
act(() => container.querySelectorAll("button")[1].focus());
checkTabIndexes(container.querySelectorAll("button"), [-1, 0, -1]);
// check that the active button does not change even on an explicit blur event
act(() => container.querySelectorAll("button")[1].blur());
checkTabIndexes(container.querySelectorAll("button"), [-1, 0, -1]);
// update the children, it should remain on the same button
rerender(
<RovingTabIndexProvider>
{() => (
<>
<React.Fragment>
{button1}
{button4}
{button2}
{button3}
</>
</React.Fragment>
)}
</RovingTabIndexProvider>,
);
checkTabIndexes(container.querySelectorAll("button"), [-1, -1, 0, -1]);
// update the children, remove the active button, it should move to the next one
rerender(
<RovingTabIndexProvider>
{() => (
<>
<React.Fragment>
{button1}
{button4}
{button3}
</>
</React.Fragment>
)}
</RovingTabIndexProvider>,
);
checkTabIndexes(container.querySelectorAll("button"), [-1, -1, 0]);
});
it("provides a ref to the dom element", () => {
it("RovingTabIndexProvider provides a ref to the dom element", () => {
const nodeRef = React.createRef<HTMLButtonElement>();
const MyButton = (props: HTMLAttributes<HTMLButtonElement>): React.JSX.Element => {
const MyButton = (props: HTMLAttributes<HTMLButtonElement>) => {
const [onFocus, isActive, ref] = useRovingTabIndex<HTMLButtonElement>(nodeRef);
return <button {...props} onFocus={onFocus} tabIndex={isActive ? 0 : -1} ref={ref} />;
};
const { container } = render(
<RovingTabIndexProvider>
{() => (
<>
<React.Fragment>
<MyButton />
</>
</React.Fragment>
)}
</RovingTabIndexProvider>,
);
// nodeRef should point to button
expect(nodeRef.current).toBe(container.querySelector("button"));
});
it("works as expected with RovingTabIndexWrapper", () => {
it("RovingTabIndexProvider works as expected with RovingTabIndexWrapper", () => {
const { container } = render(
<RovingTabIndexProvider>
{() => (
<>
<React.Fragment>
{button1}
{button2}
<RovingTabIndexWrapper>
@@ -160,13 +153,15 @@ describe("RovingTabIndex", () => {
</button>
)}
</RovingTabIndexWrapper>
</>
</React.Fragment>
)}
</RovingTabIndexProvider>,
);
// should begin with 0th being active
checkTabIndexes(container.querySelectorAll("button"), [0, -1, -1]);
// focus on 2nd button and test it is the only active one
act(() => container.querySelectorAll("button")[2].focus());
checkTabIndexes(container.querySelectorAll("button"), [-1, -1, 0]);
});
@@ -182,7 +177,7 @@ describe("RovingTabIndex", () => {
nodes: [node1, node2],
},
{
type: RovingStateActionType.SetFocus,
type: Type.SetFocus,
payload: {
node: node2,
},
@@ -195,49 +190,49 @@ describe("RovingTabIndex", () => {
});
it("Unregister works as expected", () => {
const unregisterButton1 = createButtonElement("Button 1");
const unregisterButton2 = createButtonElement("Button 2");
const unregisterButton3 = createButtonElement("Button 3");
const unregisterButton4 = createButtonElement("Button 4");
const button1 = createButtonElement("Button 1");
const button2 = createButtonElement("Button 2");
const button3 = createButtonElement("Button 3");
const button4 = createButtonElement("Button 4");
let state: IState = {
nodes: [unregisterButton1, unregisterButton2, unregisterButton3, unregisterButton4],
nodes: [button1, button2, button3, button4],
};
state = reducer(state, {
type: RovingStateActionType.Unregister,
type: Type.Unregister,
payload: {
node: unregisterButton2,
node: button2,
},
});
expect(state).toStrictEqual({
nodes: [unregisterButton1, unregisterButton3, unregisterButton4],
nodes: [button1, button3, button4],
});
state = reducer(state, {
type: RovingStateActionType.Unregister,
type: Type.Unregister,
payload: {
node: unregisterButton3,
node: button3,
},
});
expect(state).toStrictEqual({
nodes: [unregisterButton1, unregisterButton4],
nodes: [button1, button4],
});
state = reducer(state, {
type: RovingStateActionType.Unregister,
type: Type.Unregister,
payload: {
node: unregisterButton4,
node: button4,
},
});
expect(state).toStrictEqual({
nodes: [unregisterButton1],
nodes: [button1],
});
state = reducer(state, {
type: RovingStateActionType.Unregister,
type: Type.Unregister,
payload: {
node: unregisterButton1,
node: button1,
},
});
expect(state).toStrictEqual({
@@ -252,12 +247,12 @@ describe("RovingTabIndex", () => {
const ref4 = React.createRef<HTMLElement>();
render(
<>
<React.Fragment>
<span ref={ref1} />
<span ref={ref2} />
<span ref={ref3} />
<span ref={ref4} />
</>,
</React.Fragment>,
);
let state: IState = {
@@ -265,7 +260,7 @@ describe("RovingTabIndex", () => {
};
state = reducer(state, {
type: RovingStateActionType.Register,
type: Type.Register,
payload: {
node: ref1.current!,
},
@@ -276,7 +271,7 @@ describe("RovingTabIndex", () => {
});
state = reducer(state, {
type: RovingStateActionType.Register,
type: Type.Register,
payload: {
node: ref2.current!,
},
@@ -287,7 +282,7 @@ describe("RovingTabIndex", () => {
});
state = reducer(state, {
type: RovingStateActionType.Register,
type: Type.Register,
payload: {
node: ref3.current!,
},
@@ -298,7 +293,7 @@ describe("RovingTabIndex", () => {
});
state = reducer(state, {
type: RovingStateActionType.Register,
type: Type.Register,
payload: {
node: ref4.current!,
},
@@ -308,8 +303,9 @@ describe("RovingTabIndex", () => {
nodes: [ref1.current, ref2.current, ref3.current, ref4.current],
});
// test that the automatic focus switch works for unmounting
state = reducer(state, {
type: RovingStateActionType.SetFocus,
type: Type.SetFocus,
payload: {
node: ref2.current!,
},
@@ -320,7 +316,7 @@ describe("RovingTabIndex", () => {
});
state = reducer(state, {
type: RovingStateActionType.Unregister,
type: Type.Unregister,
payload: {
node: ref2.current!,
},
@@ -330,8 +326,9 @@ describe("RovingTabIndex", () => {
nodes: [ref1.current, ref3.current, ref4.current],
});
// test that the insert into the middle works as expected
state = reducer(state, {
type: RovingStateActionType.Register,
type: Type.Register,
payload: {
node: ref2.current!,
},
@@ -341,14 +338,15 @@ describe("RovingTabIndex", () => {
nodes: [ref1.current, ref2.current, ref3.current, ref4.current],
});
// test that insertion at the edges works
state = reducer(state, {
type: RovingStateActionType.Unregister,
type: Type.Unregister,
payload: {
node: ref1.current!,
},
});
state = reducer(state, {
type: RovingStateActionType.Unregister,
type: Type.Unregister,
payload: {
node: ref4.current!,
},
@@ -359,14 +357,14 @@ describe("RovingTabIndex", () => {
});
state = reducer(state, {
type: RovingStateActionType.Register,
type: Type.Register,
payload: {
node: ref1.current!,
},
});
state = reducer(state, {
type: RovingStateActionType.Register,
type: Type.Register,
payload: {
node: ref4.current!,
},
@@ -378,15 +376,18 @@ describe("RovingTabIndex", () => {
});
});
describe("handles keyboard navigation", () => {
it("handles up/down arrow keys when handleUpDown=true", async () => {
const { container } = renderToolbar(
<>
{button1}
{button2}
{button3}
</>,
{ handleUpDown: true },
describe("handles arrow keys", () => {
it("should handle up/down arrow keys work when handleUpDown=true", async () => {
const { container } = render(
<RovingTabIndexProvider handleUpDown>
{({ onKeyDownHandler }) => (
<div onKeyDown={onKeyDownHandler}>
{button1}
{button2}
{button3}
</div>
)}
</RovingTabIndexProvider>,
);
act(() => container.querySelectorAll("button")[0].focus());
@@ -404,160 +405,29 @@ describe("RovingTabIndex", () => {
await userEvent.keyboard("[ArrowUp]");
checkTabIndexes(container.querySelectorAll("button"), [0, -1, -1]);
// Does not loop without
await userEvent.keyboard("[ArrowUp]");
checkTabIndexes(container.querySelectorAll("button"), [0, -1, -1]);
});
it("handles left/right arrow keys when handleLeftRight=true", async () => {
const { container } = renderToolbar(
<>
{button1}
{button2}
{button3}
</>,
{ handleLeftRight: true },
);
act(() => container.querySelectorAll("button")[0].focus());
await userEvent.keyboard("[ArrowRight]");
checkTabIndexes(container.querySelectorAll("button"), [-1, 0, -1]);
await userEvent.keyboard("[ArrowLeft]");
checkTabIndexes(container.querySelectorAll("button"), [0, -1, -1]);
});
it("handles Home and End when handleHomeEnd=true", async () => {
const { container } = renderToolbar(
<>
{button1}
{button2}
{button3}
</>,
{ handleHomeEnd: true },
);
act(() => container.querySelectorAll("button")[1].focus());
checkTabIndexes(container.querySelectorAll("button"), [-1, 0, -1]);
await userEvent.keyboard("[End]");
checkTabIndexes(container.querySelectorAll("button"), [-1, -1, 0]);
await userEvent.keyboard("[Home]");
checkTabIndexes(container.querySelectorAll("button"), [0, -1, -1]);
});
it("loops when handleLoop=true", async () => {
const { container } = renderToolbar(
<>
{button1}
{button2}
{button3}
</>,
{ handleUpDown: true, handleLoop: true },
);
act(() => container.querySelectorAll("button")[2].focus());
await userEvent.keyboard("[ArrowDown]");
checkTabIndexes(container.querySelectorAll("button"), [0, -1, -1]);
await userEvent.keyboard("[ArrowUp]");
checkTabIndexes(container.querySelectorAll("button"), [-1, -1, 0]);
});
it("uses a custom getAction mapper", async () => {
const getAction = vi.fn((ev: React.KeyboardEvent): RovingAction | undefined => {
if (ev.key === "j") {
return RovingAction.ArrowDown;
}
return undefined;
});
const { container } = renderToolbar(
<>
{button1}
{button2}
{button3}
</>,
{ handleUpDown: true, getAction },
);
act(() => container.querySelectorAll("button")[0].focus());
await userEvent.keyboard("j");
expect(getAction).toHaveBeenCalled();
checkTabIndexes(container.querySelectorAll("button"), [-1, 0, -1]);
});
it("handles input fields when handleInputFields=true", () => {
const { container, getByRole } = renderToolbar(
<>
{button1}
<input aria-label="Search input" />
{button2}
</>,
{ handleUpDown: true, handleInputFields: true },
);
act(() => container.querySelectorAll("button")[0].focus());
const input = getByRole("textbox", { name: "Search input" });
fireEvent.keyDown(input, { key: "ArrowDown" });
checkTabIndexes(container.querySelectorAll("button"), [-1, 0]);
});
it("moves from an input field with Tab when handleInputFields=false", () => {
const { container, getByRole } = renderToolbar(
<>
{button1}
<input aria-label="Search input" />
{button2}
</>,
);
act(() => container.querySelectorAll("button")[0].focus());
const input = getByRole("textbox", { name: "Search input" });
act(() => (input as HTMLElement).focus());
fireEvent.keyDown(input, { key: "Tab" });
checkTabIndexes(container.querySelectorAll("button"), [-1, 0]);
});
it("stops provider processing when onKeyDown prevents default", () => {
const onKeyDown = vi.fn((event: React.KeyboardEvent): void => {
event.preventDefault();
});
const { container } = renderToolbar(
<>
{button1}
{button2}
{button3}
</>,
{ handleUpDown: true, onKeyDown },
);
act(() => container.querySelectorAll("button")[0].focus());
fireEvent.keyDown(container.querySelector('[role="toolbar"]')!, { key: "ArrowDown" });
expect(onKeyDown).toHaveBeenCalled();
checkTabIndexes(container.querySelectorAll("button"), [0, -1, -1]);
});
it("calls scrollIntoView if specified", async () => {
const { container } = renderToolbar(
<>
{button1}
{button2}
{button3}
</>,
{ handleUpDown: true, scrollIntoView: true },
it("should call scrollIntoView if specified", async () => {
const { container } = render(
<RovingTabIndexProvider handleUpDown scrollIntoView>
{({ onKeyDownHandler }) => (
<div onKeyDown={onKeyDownHandler}>
{button1}
{button2}
{button3}
</div>
)}
</RovingTabIndexProvider>,
);
act(() => container.querySelectorAll("button")[0].focus());
checkTabIndexes(container.querySelectorAll("button"), [0, -1, -1]);
const button = container.querySelectorAll("button")[1];
const mock = vi.spyOn(button, "scrollIntoView");
const mock = jest.spyOn(button, "scrollIntoView");
await userEvent.keyboard("[ArrowDown]");
expect(mock).toHaveBeenCalled();
});
@@ -1,96 +0,0 @@
/*
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,6 +9,7 @@ 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";
@@ -1636,6 +1637,7 @@ 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
@@ -1666,7 +1668,7 @@ describe("<MatrixChat />", () => {
expect(Lifecycle.attemptDelegatedAuthLogin).toHaveBeenCalled();
// should just show the welcome screen
await rendered.findByText("Welcome to Test");
await rendered.findByText("Hello");
expect(rendered.container).toMatchSnapshot();
});
@@ -124,9 +124,13 @@ exports[`<MatrixChat /> Multi-tab lockout waits for other tab to stop during sta
class="mx_AuthPage"
>
<div
class="mx_AuthPage_modal"
class="mx_AuthPage_modal mx_AuthPage_modal_withBlur"
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"
@@ -134,99 +138,53 @@ exports[`<MatrixChat /> Multi-tab lockout waits for other tab to stop during sta
tabindex="-1"
>
<div
class="_glass_sepwu_8"
class="mx_Welcome"
data-testid="mx_welcome_screen"
>
<div
class="mx_Welcome"
class="mx_WelcomePage mx_WelcomePage_loggedIn"
>
<div
class="mx_DefaultWelcome"
class="mx_WelcomePage_body"
>
<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>
Hello
</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
class="mx_Dropdown mx_LanguageDropdown mx_AuthBody_language"
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
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_option"
id="mx_LanguageDropdown_value"
>
<div
class="mx_Dropdown_option"
id="mx_LanguageDropdown_value"
>
<div>
English
</div>
<div>
English
</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_n7ud0_8"
class="_banner_193k4_8"
data-type="critical"
role="status"
>
<div
class="_icon_n7ud0_50"
class="_icon_193k4_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_n7ud0_38"
class="_content_193k4_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_n7ud0_61"
class="_actions_193k4_60"
>
<button
class="_button_13vu4_8 _primaryAction_1xryk_20 _has-icon_13vu4_60"
@@ -7,13 +7,12 @@ Please see LICENSE files in the repository root for full details.
*/
import React from "react";
import { findByText, fireEvent, render, screen } from "jest-matrix-react";
import { fireEvent, render, screen, findByText } from "jest-matrix-react";
import userEvent from "@testing-library/user-event";
import { type MatrixClient, MatrixError, Room, RoomType } from "matrix-js-sdk/src/matrix";
import { RoomType, type MatrixClient, MatrixError, Room } 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";
@@ -104,11 +103,6 @@ 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),
@@ -455,44 +449,4 @@ 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();
});
});
});
});
@@ -1,104 +0,0 @@
/*
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} />);
}

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