Compare commits
49 Commits
v1.12.16-rc.0
...
develop
| Author | SHA1 | Date | |
|---|---|---|---|
| bb4a7e9613 | |||
| 1a6b0e22a1 | |||
| 8f9953f419 | |||
| 12df09bd4b | |||
| cd515444a8 | |||
| f4c62abbcd | |||
| 73e1b87075 | |||
| 4b4289e211 | |||
| 7b89d84acb | |||
| 9df7182c0c | |||
| 2d16498fe6 | |||
| 021e222719 | |||
| 9df9fb9428 | |||
| 29411f0ded | |||
| 5fc98d0a36 | |||
| a08c34142f | |||
| 44a2c9936d | |||
| f0eb95495e | |||
| 4437dadef6 | |||
| 193cdff562 | |||
| d01f40bf27 | |||
| 1a87865134 | |||
| 764892bd41 | |||
| fb263ee511 | |||
| 8fa7b5ca2c | |||
| e568ed8aac | |||
| abb014553b | |||
| ae8769e12d | |||
| fd86405338 | |||
| a0195fc4d6 | |||
| ced3c25785 | |||
| 370d2ec7d2 | |||
| 14917f9df5 | |||
| 6423f2d8c0 | |||
| feae8ed8b5 | |||
| 549bdb8cb7 | |||
| e1b62c3370 | |||
| 354a05d89f | |||
| 86ea6bd6b9 | |||
| a054e785ea | |||
| 42009ce6ec | |||
| 356119da79 | |||
| d823d633e3 | |||
| 1f6d1dbc0d | |||
| 12a3abc0d5 | |||
| e90bc4a2f3 | |||
| 0d9f205505 | |||
| ac9ef6c2a2 | |||
| 4c474f5639 |
@@ -11,7 +11,7 @@ runs:
|
||||
using: composite
|
||||
steps:
|
||||
- name: Download release tarball
|
||||
uses: robinraju/release-downloader@daf26c55d821e836577a15f77d86ddc078948b05 # v1
|
||||
uses: robinraju/release-downloader@28fc21f50d76778e7023361aa1f863e717d3d56f # v1
|
||||
with:
|
||||
tag: ${{ inputs.tag }}
|
||||
fileName: element-*.tar.gz*
|
||||
|
||||
@@ -2,25 +2,39 @@
|
||||
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
|
||||
"extends": ["github>matrix-org/renovate-config-element-web"],
|
||||
"postUpdateOptions": ["pnpmDedupe"],
|
||||
"stopUpdatingLabel": "X-Blocked",
|
||||
"packageRules": [
|
||||
{
|
||||
"description": "Group all testcontainers docker digests",
|
||||
"groupName": "testcontainers docker digests",
|
||||
"groupSlug": "testcontainers-docker",
|
||||
"matchDepTypes": ["testcontainers-docker"],
|
||||
"matchPackageNames": ["*"]
|
||||
},
|
||||
{
|
||||
"description": "Separate updates to overrides from other groups",
|
||||
"matchDepTypes": ["pnpm.overrides"],
|
||||
"groupSlug": null
|
||||
},
|
||||
{
|
||||
"description": "Disable any major updates to overrides as this almost always is wrong",
|
||||
"matchDepTypes": ["pnpm.overrides"],
|
||||
"matchUpdateTypes": ["major"],
|
||||
"enabled": false
|
||||
}
|
||||
],
|
||||
"customManagers": [
|
||||
{
|
||||
"description": "Update testcontainers docker digests",
|
||||
"customType": "regex",
|
||||
"datasourceTemplate": "docker",
|
||||
"versioningTemplate": "loose",
|
||||
"description": "Update testcontainers docker digests",
|
||||
"managerFilePatterns": ["**/testcontainers/*.ts"],
|
||||
"matchStrings": ["\\s+\"(?<depName>[^@]+):(?<currentValue>[^@]+)@(?<currentDigest>sha256:[a-f0-9]+)\""],
|
||||
"depTypeTemplate": "testcontainers-docker"
|
||||
},
|
||||
{
|
||||
"description": "Update element-desktop hakDependencies",
|
||||
"customType": "jsonata",
|
||||
"managerFilePatterns": ["/(^|/)package\\.json$/"],
|
||||
"fileFormat": "json",
|
||||
|
||||
@@ -206,6 +206,8 @@ jobs:
|
||||
needs: prepare_ed
|
||||
name: "Desktop Windows"
|
||||
uses: ./.github/workflows/build_desktop_windows.yaml
|
||||
# Skip Windows builds on PRs, as the Linux amd64 build is enough of a smoke test and includes the screenshot tests
|
||||
if: github.event_name != 'pull_request' || contains(github.event.pull_request.labels.*.name, 'X-Run-All-Tests')
|
||||
strategy:
|
||||
matrix:
|
||||
arch: [x64, ia32, arm64]
|
||||
@@ -223,10 +225,13 @@ jobs:
|
||||
arch: [amd64, arm64]
|
||||
runAllTests:
|
||||
- ${{ github.event_name != 'pull_request' || contains(github.event.pull_request.labels.*.name, 'X-Run-All-Tests') }}
|
||||
# We ship static sqlcipher builds, so delegate testing the system builds to the merge queue
|
||||
exclude:
|
||||
# We ship static sqlcipher builds, so delegate testing the system builds to the merge queue
|
||||
- runAllTests: false
|
||||
sqlcipher: system
|
||||
# Additionally skip arm64 system builds on PRs, as the amd64 test is enough for a smoke test and includes the screenshot tests
|
||||
- runAllTests: false
|
||||
arch: arm64
|
||||
with:
|
||||
sqlcipher: ${{ matrix.sqlcipher }}
|
||||
arch: ${{ matrix.arch }}
|
||||
@@ -236,6 +241,9 @@ jobs:
|
||||
needs: prepare_ed
|
||||
name: "Desktop macOS"
|
||||
uses: ./.github/workflows/build_desktop_macos.yaml
|
||||
# Skip macOS builds on PRs, as the Linux amd64 build is enough of a smoke test and includes the screenshot tests
|
||||
# and we have a very low limit of concurrent macos runners (5) across the Github org.
|
||||
if: github.event_name != 'pull_request' || contains(github.event.pull_request.labels.*.name, 'X-Run-All-Tests')
|
||||
with:
|
||||
blob_report: true
|
||||
|
||||
|
||||
@@ -126,7 +126,7 @@ jobs:
|
||||
- name: "Get modified files"
|
||||
id: changed_files
|
||||
if: steps.cache.outputs.cache-hit != 'true' && github.event_name == 'pull_request' && github.repository == 'element-hq/element-web'
|
||||
uses: tj-actions/changed-files@823fcebdb31bb35fdf2229d9f769b400309430d0 # v46
|
||||
uses: tj-actions/changed-files@9426d40962ed5378910ee2e21d5f8c6fcbf2dd96 # v47
|
||||
with:
|
||||
files: |
|
||||
apps/desktop/dockerbuild/**
|
||||
|
||||
@@ -31,7 +31,7 @@ jobs:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Install Cosign
|
||||
uses: sigstore/cosign-installer@398d4b0eeef1380460a10c8013a76f728fb906ac # v3
|
||||
uses: sigstore/cosign-installer@cad07c2e89fa2edd6e2d7bab4c1aa38e53f76003 # v4.1.1
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@ce360397dd3f832beb865e1373c09c0e9f86d70a # v4
|
||||
|
||||
@@ -26,7 +26,7 @@ jobs:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Install Cosign
|
||||
uses: sigstore/cosign-installer@398d4b0eeef1380460a10c8013a76f728fb906ac # v3
|
||||
uses: sigstore/cosign-installer@cad07c2e89fa2edd6e2d7bab4c1aa38e53f76003 # v4.1.1
|
||||
if: github.event_name != 'pull_request'
|
||||
|
||||
- name: Set up QEMU
|
||||
|
||||
@@ -25,9 +25,6 @@ jobs:
|
||||
actions: read
|
||||
deployments: write
|
||||
steps:
|
||||
- name: Install tree
|
||||
run: "sudo apt-get install -y tree"
|
||||
|
||||
- name: Download Diffs
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
|
||||
with:
|
||||
|
||||
@@ -45,11 +45,12 @@ jobs:
|
||||
working-directory: packages/shared-components
|
||||
run: "pnpm test:storybook --run"
|
||||
|
||||
# Workaround for vis silently adding new baselines if they didn't exist
|
||||
# Can be removed once https://github.com/repobuddy/visual-testing/issues/516 is released
|
||||
- run: |
|
||||
git add -N .
|
||||
git diff --exit-code
|
||||
- name: Detect stale screenshots
|
||||
run: |
|
||||
if diff -rq __baselines__ __results__ | grep "^Only in __baselines__"; then
|
||||
exit 1
|
||||
fi
|
||||
working-directory: packages/shared-components/__vis__/linux
|
||||
|
||||
- name: Upload received images & diffs
|
||||
if: always()
|
||||
|
||||
@@ -103,7 +103,7 @@ jobs:
|
||||
voip|element_call
|
||||
error|invalid_json
|
||||
error|misconfigured
|
||||
welcome_to_element
|
||||
welcome|title_element
|
||||
devtools|settings|elementCallUrl
|
||||
labs|sliding_sync_description
|
||||
settings|voip|noise_suppression_description
|
||||
|
||||
@@ -27,7 +27,7 @@ jobs:
|
||||
run: "pnpm vendor:jitsi"
|
||||
|
||||
- name: Create Pull Request
|
||||
uses: peter-evans/create-pull-request@c0f553fe549906ede9cf27b5156039d195d2ece0 # v8
|
||||
uses: peter-evans/create-pull-request@5f6978faf089d4d20b00c7766989d076bb2fc7f1 # v8
|
||||
with:
|
||||
token: ${{ secrets.ELEMENT_BOT_TOKEN }}
|
||||
branch: actions/jitsi-update
|
||||
|
||||
@@ -1 +1 @@
|
||||
24.14.1
|
||||
24.15.0
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# Docker image to facilitate building Element Desktop's native bits using a glibc version (2.31)
|
||||
# with broader compatibility, down to Debian bullseye & Ubuntu focal.
|
||||
|
||||
FROM rust:bullseye@sha256:bc19574c121fe10c1bc68fc2b1ea9b420d87d047a0c50fb1622b282199700cee
|
||||
FROM rust:bullseye@sha256:949b0903defbfc4e374dc85f947b153859e9ee0104e425cd9a74d94474a9a335
|
||||
|
||||
ENV DEBIAN_FRONTEND=noninteractive
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"moduleResolution": "node",
|
||||
"moduleResolution": "node16",
|
||||
"module": "Node16",
|
||||
"esModuleInterop": true,
|
||||
"target": "es2022",
|
||||
"sourceMap": false,
|
||||
|
||||
@@ -62,13 +62,13 @@
|
||||
"electron-window-state": "^5.0.3",
|
||||
"minimist": "^1.2.6",
|
||||
"png-to-ico": "^3.0.0",
|
||||
"uuid": "^13.0.0"
|
||||
"uuid": "^14.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "^7.18.10",
|
||||
"@babel/preset-env": "^7.18.10",
|
||||
"@babel/preset-typescript": "^7.18.6",
|
||||
"@electron/asar": "4.1.2",
|
||||
"@electron/asar": "4.2.0",
|
||||
"@electron/fuses": "^2.1.1",
|
||||
"@playwright/test": "catalog:",
|
||||
"@stylistic/eslint-plugin": "^5.0.0",
|
||||
@@ -79,12 +79,12 @@
|
||||
"@types/pacote": "^11.1.1",
|
||||
"@typescript-eslint/eslint-plugin": "^8.0.0",
|
||||
"@typescript-eslint/parser": "^8.0.0",
|
||||
"app-builder-lib": "26.8.2",
|
||||
"app-builder-lib": "26.9.0",
|
||||
"chokidar": "^5.0.0",
|
||||
"detect-libc": "^2.0.0",
|
||||
"electron": "41.1.0",
|
||||
"electron-builder": "26.8.2",
|
||||
"electron-builder-squirrel-windows": "26.8.2",
|
||||
"electron": "41.2.2",
|
||||
"electron-builder": "26.9.0",
|
||||
"electron-builder-squirrel-windows": "26.9.0",
|
||||
"electron-devtools-installer": "^4.0.0",
|
||||
"eslint": "^8.26.0",
|
||||
"eslint-config-google": "^0.14.0",
|
||||
@@ -100,7 +100,7 @@
|
||||
"prettier": "^3.0.0",
|
||||
"rimraf": "^6.0.0",
|
||||
"tar": "^7.5.8",
|
||||
"typescript": "5.9.3"
|
||||
"typescript": "6.0.3"
|
||||
},
|
||||
"hakDependencies": {
|
||||
"matrix-seshat": "4.2.0"
|
||||
|
||||
@@ -17,14 +17,14 @@ import { PassThrough } from "node:stream";
|
||||
* A PassThrough stream that captures all data written to it.
|
||||
*/
|
||||
class CapturedPassThrough extends PassThrough {
|
||||
private _chunks = [];
|
||||
private _chunks: any[] = [];
|
||||
|
||||
public constructor() {
|
||||
super();
|
||||
super.on("data", this.onData);
|
||||
}
|
||||
|
||||
private onData = (chunk): void => {
|
||||
private onData = (chunk: any): void => {
|
||||
this._chunks.push(chunk);
|
||||
};
|
||||
|
||||
|
||||
|
Before Width: | Height: | Size: 1.1 MiB After Width: | Height: | Size: 957 KiB |
@@ -1,11 +1,12 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"resolveJsonModule": true,
|
||||
"moduleResolution": "node",
|
||||
"moduleResolution": "bundler",
|
||||
"esModuleInterop": true,
|
||||
"target": "es2022",
|
||||
"module": "es2022",
|
||||
"lib": ["es2022", "dom"],
|
||||
"module": "ESNext",
|
||||
"lib": ["es2024", "dom", "dom.iterable"],
|
||||
"strictNullChecks": false,
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["**/*.ts"]
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
# syntax=docker.io/docker/dockerfile:1.22-labs@sha256:4c116b618ed48404d579b5467127b20986f2a6b29e4b9be2fee841f632db6a86
|
||||
# syntax=docker.io/docker/dockerfile:1.23-labs@sha256:7eca9451d94f9b8ad22e44988b92d595d3e4d65163794237949a8c3413fbed5d
|
||||
# Context must be the root of the monorepo
|
||||
|
||||
# Builder
|
||||
FROM --platform=$BUILDPLATFORM node:24-bullseye@sha256:27e462f5db2402700867dfa8ec35e3a68b127fdf61b505db0dd6ab98c38284bb AS builder
|
||||
FROM --platform=$BUILDPLATFORM node:24-bullseye@sha256:d2059a9c157c9f70739736979fa3635008bf3ca74560b30930dc181228bc427f AS builder
|
||||
|
||||
# Support custom branch of the js-sdk. This also helps us build images of element-web develop.
|
||||
ARG USE_CUSTOM_SDKS=false
|
||||
@@ -25,7 +25,7 @@ RUN --mount=type=bind,source=.git,target=/src/.git /src/scripts/docker-package.s
|
||||
RUN cp /src/apps/web/config.sample.json /src/apps/web/webapp/config.json
|
||||
|
||||
# App
|
||||
FROM nginxinc/nginx-unprivileged:alpine-slim@sha256:b5831ee7f7aa827cbae87df4a30a642f62c747d8525f5674365389f3adab278d
|
||||
FROM nginxinc/nginx-unprivileged:alpine-slim@sha256:360465db60105a4cbf5215cd9e5a2ba40ef956978dd94f99707e9674050e38ea
|
||||
|
||||
# Need root user to install packages & manipulate the usr directory
|
||||
USER root
|
||||
|
||||
@@ -72,7 +72,7 @@
|
||||
"glob-to-regexp": "^0.4.1",
|
||||
"highlight.js": "^11.3.1",
|
||||
"html-entities": "^2.0.0",
|
||||
"html-react-parser": "^5.2.2",
|
||||
"html-react-parser": "^6.0.0",
|
||||
"is-ip": "^5.0.0",
|
||||
"js-xxhash": "^5.0.0",
|
||||
"jsrsasign": "^11.0.0",
|
||||
@@ -89,7 +89,7 @@
|
||||
"opus-recorder": "^8.0.3",
|
||||
"pako": "^2.0.3",
|
||||
"png-chunks-extract": "^1.0.0",
|
||||
"posthog-js": "1.364.7",
|
||||
"posthog-js": "1.369.3",
|
||||
"qrcode": "1.5.4",
|
||||
"re-resizable": "6.11.2",
|
||||
"react": "catalog:",
|
||||
@@ -104,7 +104,6 @@
|
||||
"sanitize-html": "2.17.3",
|
||||
"tar-js": "^0.3.0",
|
||||
"ua-parser-js": "1.0.40",
|
||||
"uuid": "^13.0.0",
|
||||
"what-input": "^5.2.10"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -126,7 +125,7 @@
|
||||
"@babel/preset-react": "^7.12.10",
|
||||
"@babel/preset-typescript": "^7.12.7",
|
||||
"@casualbot/jest-sonar-reporter": "2.6.0",
|
||||
"@element-hq/element-call-embedded": "0.18.0",
|
||||
"@element-hq/element-call-embedded": "0.19.1",
|
||||
"@element-hq/element-web-playwright-common": "workspace:*",
|
||||
"@fetch-mock/jest": "^0.2.20",
|
||||
"@jest/globals": "^30.2.0",
|
||||
@@ -205,17 +204,17 @@
|
||||
"mini-css-extract-plugin": "2.10.2",
|
||||
"modernizr": "^3.12.0",
|
||||
"playwright-core": "catalog:",
|
||||
"postcss": "8.5.8",
|
||||
"postcss": "8.5.10",
|
||||
"postcss-easings": "4.0.0",
|
||||
"postcss-hexrgba": "2.1.0",
|
||||
"postcss-import": "16.1.1",
|
||||
"postcss-loader": "8.2.1",
|
||||
"postcss-mixins": "12.1.2",
|
||||
"postcss-nested": "7.0.2",
|
||||
"postcss-preset-env": "11.2.0",
|
||||
"postcss-preset-env": "11.2.1",
|
||||
"postcss-scss": "4.0.9",
|
||||
"postcss-simple-vars": "7.0.1",
|
||||
"prettier": "3.8.1",
|
||||
"prettier": "3.8.3",
|
||||
"process": "^0.11.10",
|
||||
"raw-loader": "^4.0.2",
|
||||
"semver": "^7.5.2",
|
||||
|
||||
@@ -20,7 +20,7 @@ test.use({
|
||||
|
||||
test("Shows the welcome page by default", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
await expect(page.getByRole("heading", { name: "Welcome to Element!" })).toBeVisible();
|
||||
await expect(page.getByRole("heading", { name: "Be in your element" })).toBeVisible();
|
||||
await expect(page.getByRole("link", { name: "Sign in" })).toBeVisible();
|
||||
});
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import type { Locator, Page } from "@playwright/test";
|
||||
import { test, expect } from "../../element-web-test";
|
||||
import { test, expect, type ExtendedToMatchScreenshotOptions } from "../../element-web-test";
|
||||
import { SettingLevel } from "../../../src/settings/SettingLevel";
|
||||
import { Layout } from "../../../src/settings/enums/Layout";
|
||||
import { type ElementAppPage } from "../../pages/ElementAppPage";
|
||||
@@ -94,7 +94,7 @@ test.describe("Audio player", { tag: ["@no-firefox", "@no-webkit"] }, () => {
|
||||
// Assert that rendering of the player settled and the play button is visible before taking a snapshot
|
||||
await checkPlayerVisibility(ircTile);
|
||||
|
||||
const screenshotOptions = {
|
||||
const screenshotOptions: ExtendedToMatchScreenshotOptions = {
|
||||
css: `
|
||||
/* The timestamp is of inconsistent width depending on the time the test runs at */
|
||||
.mx_MessageTimestamp {
|
||||
@@ -120,7 +120,7 @@ test.describe("Audio player", { tag: ["@no-firefox", "@no-webkit"] }, () => {
|
||||
};
|
||||
|
||||
// Take a snapshot of mx_EventTile_last on IRC layout
|
||||
screenshotOptions.clip = await page.locator(".mx_EventTile_last").boundingBox();
|
||||
screenshotOptions.clip = (await page.locator(".mx_EventTile_last").boundingBox()) ?? undefined;
|
||||
await scrollToBottomOfTimeline(page);
|
||||
await expect(page).toMatchScreenshot(`${detail.replaceAll(" ", "-")}-irc-layout.png`, screenshotOptions);
|
||||
|
||||
@@ -129,7 +129,7 @@ test.describe("Audio player", { tag: ["@no-firefox", "@no-webkit"] }, () => {
|
||||
const groupTile = page.locator(".mx_EventTile_last[data-layout='group']");
|
||||
await groupTile.locator(".mx_MessageTimestamp").click();
|
||||
await checkPlayerVisibility(groupTile);
|
||||
screenshotOptions.clip = await page.locator(".mx_EventTile_last").boundingBox();
|
||||
screenshotOptions.clip = (await page.locator(".mx_EventTile_last").boundingBox()) ?? undefined;
|
||||
await scrollToBottomOfTimeline(page);
|
||||
await expect(page).toMatchScreenshot(`${detail.replaceAll(" ", "-")}-group-layout.png`, screenshotOptions);
|
||||
|
||||
@@ -138,7 +138,7 @@ test.describe("Audio player", { tag: ["@no-firefox", "@no-webkit"] }, () => {
|
||||
const bubbleTile = page.locator(".mx_EventTile_last[data-layout='bubble']");
|
||||
await bubbleTile.locator(".mx_MessageTimestamp").click();
|
||||
await checkPlayerVisibility(bubbleTile);
|
||||
screenshotOptions.clip = await page.locator(".mx_EventTile_last").boundingBox();
|
||||
screenshotOptions.clip = (await page.locator(".mx_EventTile_last").boundingBox()) ?? undefined;
|
||||
await scrollToBottomOfTimeline(page);
|
||||
await expect(page).toMatchScreenshot(`${detail.replaceAll(" ", "-")}-bubble-layout.png`, screenshotOptions);
|
||||
};
|
||||
|
||||
@@ -27,6 +27,9 @@ const startDMWithBob = async (page: Page, bob: Bot) => {
|
||||
await page.getByRole("option", { name: bob.credentials.displayName }).click();
|
||||
await expect(page.getByTestId("invite-dialog-input-wrapper").getByText("Bob")).toBeVisible();
|
||||
await page.getByRole("button", { name: "Go" }).click();
|
||||
|
||||
await expect(page.getByRole("heading", { name: "Start a chat with this new contact?" })).toBeVisible();
|
||||
await page.getByRole("button", { name: "Continue" }).click();
|
||||
};
|
||||
|
||||
const testMessages = async (page: Page, bob: Bot, bobRoomId: string) => {
|
||||
@@ -44,7 +47,7 @@ const bobJoin = async (page: Page, bob: Bot) => {
|
||||
const bobRooms = cli.getRooms();
|
||||
if (!bobRooms.length) {
|
||||
await new Promise<void>((resolve) => {
|
||||
const onMembership = (_event) => {
|
||||
const onMembership = () => {
|
||||
cli.off(window.matrixcs.RoomMemberEvent.Membership, onMembership);
|
||||
resolve();
|
||||
};
|
||||
|
||||
@@ -6,7 +6,7 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com
|
||||
Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import type { EmittedEvents, Preset } from "matrix-js-sdk/src/matrix";
|
||||
import type { Preset, RoomMemberEvent, RoomStateEvent } from "matrix-js-sdk/src/matrix";
|
||||
import { expect, test } from "../../element-web-test";
|
||||
import {
|
||||
createRoom,
|
||||
@@ -122,7 +122,7 @@ test.describe("Cryptography", function () {
|
||||
const roomId = await bob.evaluate(
|
||||
async (client, { alice }) => {
|
||||
const encryptionStatePromise = new Promise<void>((resolve) => {
|
||||
client.on("RoomState.events" as EmittedEvents, (event, _state, _lastStateEvent) => {
|
||||
client.on("RoomState.events" as RoomStateEvent.Events, (event, _state, _lastStateEvent) => {
|
||||
if (event.getType() === "m.room.encryption") {
|
||||
resolve();
|
||||
}
|
||||
@@ -253,11 +253,14 @@ test.describe("Cryptography", function () {
|
||||
|
||||
// invite Alice
|
||||
const inviteAlicePromise = new Promise<void>((resolve) => {
|
||||
client.on("RoomMember.membership" as EmittedEvents, (_event, member, _oldMembership?) => {
|
||||
if (member.userId === alice.userId && member.membership === "invite") {
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
client.on(
|
||||
"RoomMember.membership" as RoomMemberEvent.Membership,
|
||||
(_event, member, _oldMembership?) => {
|
||||
if (member.userId === alice.userId && member.membership === "invite") {
|
||||
resolve();
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
await client.invite(roomId, alice.userId);
|
||||
// wait for the invite to come back so that we encrypt to Alice
|
||||
@@ -271,11 +274,14 @@ test.describe("Cryptography", function () {
|
||||
|
||||
// kick Alice
|
||||
const kickAlicePromise = new Promise<void>((resolve) => {
|
||||
client.on("RoomMember.membership" as EmittedEvents, (_event, member, _oldMembership?) => {
|
||||
if (member.userId === alice.userId && member.membership === "leave") {
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
client.on(
|
||||
"RoomMember.membership" as RoomMemberEvent.Membership,
|
||||
(_event, member, _oldMembership?) => {
|
||||
if (member.userId === alice.userId && member.membership === "leave") {
|
||||
resolve();
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
await client.kick(roomId, alice.userId);
|
||||
await kickAlicePromise;
|
||||
|
||||
@@ -166,13 +166,9 @@ async function getDehydratedDeviceIds(client: Client): Promise<string[]> {
|
||||
return await client.evaluate(async (client) => {
|
||||
const userId = client.getUserId();
|
||||
const devices = await client.getCrypto().getUserDeviceInfo([userId]);
|
||||
return Array.from(
|
||||
devices
|
||||
.get(userId)
|
||||
.values()
|
||||
.filter((d) => d.dehydrated)
|
||||
.map((d) => d.deviceId),
|
||||
);
|
||||
return Array.from(devices.get(userId).values())
|
||||
.filter((d) => d.dehydrated)
|
||||
.map((d) => d.deviceId);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -49,7 +49,7 @@ test.describe("History sharing", function () {
|
||||
await sendMessageInCurrentRoom(alicePage, "A message from Alice");
|
||||
|
||||
// Send the invite to Bob
|
||||
await aliceElementApp.inviteUserToCurrentRoom(bobCredentials.userId);
|
||||
await aliceElementApp.inviteUserToCurrentRoom(bobCredentials.userId, { confirmUnknownUser: true });
|
||||
|
||||
// Bob accepts the invite
|
||||
await bobPage.getByRole("option", { name: "TestRoom" }).click();
|
||||
@@ -105,7 +105,7 @@ test.describe("History sharing", function () {
|
||||
|
||||
// Alice invites Bob, and Bob accepts
|
||||
const roomId = await aliceElementApp.getCurrentRoomIdFromUrl();
|
||||
await aliceElementApp.inviteUserToCurrentRoom(bobCredentials.userId);
|
||||
await aliceElementApp.inviteUserToCurrentRoom(bobCredentials.userId, { confirmUnknownUser: true });
|
||||
await bobPage.getByRole("option", { name: "TestRoom" }).click();
|
||||
await bobPage.getByRole("button", { name: "Accept" }).click();
|
||||
|
||||
@@ -143,7 +143,7 @@ test.describe("History sharing", function () {
|
||||
await sendMessageInCurrentRoom(bobPage, "Message3: 'shared' visibility, but Bob thinks it is still 'joined'");
|
||||
|
||||
// Alice now invites Charlie
|
||||
await aliceElementApp.inviteUserToCurrentRoom(charlieCredentials.userId);
|
||||
await aliceElementApp.inviteUserToCurrentRoom(charlieCredentials.userId, { confirmUnknownUser: true });
|
||||
await charliePage.getByRole("option", { name: "TestRoom" }).click();
|
||||
await charliePage.getByRole("button", { name: "Accept" }).click();
|
||||
|
||||
|
||||
@@ -579,8 +579,8 @@ export async function deleteCachedSecrets(page: Page) {
|
||||
await page.evaluate(async () => {
|
||||
const removeCachedSecrets = new Promise((resolve) => {
|
||||
const request = window.indexedDB.open("matrix-js-sdk::matrix-sdk-crypto");
|
||||
request.onsuccess = (event: Event & { target: { result: IDBDatabase } }) => {
|
||||
const db = event.target.result;
|
||||
request.onsuccess = function (this: IDBRequest) {
|
||||
const db = this.result as IDBDatabase;
|
||||
const request = db.transaction("core", "readwrite").objectStore("core").delete("private_identity");
|
||||
request.onsuccess = () => {
|
||||
db.close();
|
||||
|
||||
@@ -9,6 +9,15 @@ Please see LICENSE files in the repository root for full details.
|
||||
|
||||
import { test, expect } from "../../element-web-test";
|
||||
|
||||
/**
|
||||
* CSS which will hide the mxid in the user list of the "unknown users" confirmation dialog. This is useful because the
|
||||
* MXID is not stable and the screenshot tests will otherwise fail.
|
||||
*
|
||||
* Ideally RichItem would give us a way to do this that doesn't involve gnarly CSS.
|
||||
*/
|
||||
const UNKNOWN_IDENTITY_USERS_DIALOG_HIDE_MXID_CSS =
|
||||
'[data-testid="userlist"] li > span:nth-last-child(1) { display: none }';
|
||||
|
||||
test.describe("Invite dialog", function () {
|
||||
test.use({
|
||||
displayName: "Hanako",
|
||||
@@ -62,6 +71,15 @@ test.describe("Invite dialog", function () {
|
||||
// Invite the bot
|
||||
await other.getByRole("button", { name: "Invite" }).click();
|
||||
|
||||
// Expect a confirmation dialog, screenshot, and dismiss
|
||||
await expect(
|
||||
page.locator(".mx_Dialog").getByRole("heading", { name: "Invite new contacts to this room?" }),
|
||||
).toBeVisible();
|
||||
await expect(page.locator(".mx_Dialog")).toMatchScreenshot("confirm-invite-new-contact.png", {
|
||||
css: UNKNOWN_IDENTITY_USERS_DIALOG_HIDE_MXID_CSS,
|
||||
});
|
||||
await page.locator(".mx_Dialog").getByRole("button", { name: "Invite" }).click();
|
||||
|
||||
// Assert that the invite dialog disappears
|
||||
await expect(page.locator(".mx_InviteDialog_other")).not.toBeVisible();
|
||||
|
||||
@@ -104,6 +122,15 @@ test.describe("Invite dialog", function () {
|
||||
// Open a direct message UI
|
||||
await other.getByRole("button", { name: "Go" }).click();
|
||||
|
||||
// Expect a confirmation dialog, screenshot, and dismiss
|
||||
await expect(
|
||||
page.locator(".mx_Dialog").getByRole("heading", { name: "Start a chat with this new contact?" }),
|
||||
).toBeVisible();
|
||||
await expect(page.locator(".mx_Dialog")).toMatchScreenshot("confirm-chat-with-new-contact.png", {
|
||||
css: UNKNOWN_IDENTITY_USERS_DIALOG_HIDE_MXID_CSS,
|
||||
});
|
||||
await page.locator(".mx_Dialog").getByRole("button", { name: "Continue" }).click();
|
||||
|
||||
// Assert that the invite dialog disappears
|
||||
await expect(page.locator(".mx_InviteDialog_other")).not.toBeVisible();
|
||||
|
||||
|
||||
@@ -175,4 +175,93 @@ test.describe("Room list custom sections", () => {
|
||||
await expect(getSectionHeader(page, "Low Priority")).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("Adding a room to a custom section", () => {
|
||||
/**
|
||||
* Asserts a room is nested under a specific section using the treegrid aria-level hierarchy.
|
||||
* Section header rows sit at aria-level=1; room rows nested within a section sit at aria-level=2.
|
||||
* Verifies that the closest preceding aria-level=1 row is the expected section header.
|
||||
*/
|
||||
async function assertRoomInSection(page: Page, sectionName: string, roomName: string): Promise<void> {
|
||||
const roomList = getRoomList(page);
|
||||
const roomRow = roomList.getByRole("row", { name: `Open room ${roomName}` });
|
||||
// Room row must be at aria-level=2 (i.e. inside a section)
|
||||
await expect(roomRow).toHaveAttribute("aria-level", "2");
|
||||
// The closest preceding aria-level=1 row must be the expected section header.
|
||||
// XPath preceding:: axis returns nodes before the context in document order; [1] picks the nearest one.
|
||||
const closestSectionHeader = roomRow.locator(`xpath=preceding::*[@role="row" and @aria-level="1"][1]`);
|
||||
await expect(closestSectionHeader).toContainText(sectionName);
|
||||
}
|
||||
|
||||
test("should add a room to a custom section via the More Options menu", async ({ page, app }) => {
|
||||
await app.client.createRoom({ name: "my room" });
|
||||
await createCustomSection(page, "Work");
|
||||
|
||||
const roomList = getRoomList(page);
|
||||
|
||||
// Room starts in Chats section (aria-level=2)
|
||||
const roomItem = roomList.getByRole("row", { name: "Open room my room" });
|
||||
await expect(roomItem).toBeVisible();
|
||||
|
||||
// Open More Options and move to the Work section
|
||||
await roomItem.hover();
|
||||
await roomItem.getByRole("button", { name: "More Options" }).click();
|
||||
await page.getByRole("menuitem", { name: "Move to" }).hover();
|
||||
await page.getByRole("menuitem", { name: "Work" }).click();
|
||||
|
||||
// Room should now be nested under the Work section header (aria-level=1 → aria-level=2)
|
||||
await assertRoomInSection(page, "Work", "my room");
|
||||
});
|
||||
|
||||
test(
|
||||
"should show 'Chat moved' toast when adding a room to a custom section",
|
||||
{ tag: "@screenshot" },
|
||||
async ({ page, app }) => {
|
||||
await app.client.createRoom({ name: "my room" });
|
||||
await createCustomSection(page, "Work");
|
||||
|
||||
const roomList = getRoomList(page);
|
||||
const roomItem = roomList.getByRole("row", { name: "Open room my room" });
|
||||
|
||||
await roomItem.hover();
|
||||
await roomItem.getByRole("button", { name: "More Options" }).click();
|
||||
await page.getByRole("menuitem", { name: "Move to" }).hover();
|
||||
await page.getByRole("menuitem", { name: "Work" }).click();
|
||||
|
||||
// The "Chat moved" toast should appear
|
||||
await expect(page.getByText("Chat moved")).toBeVisible();
|
||||
|
||||
// Remove focus outline from the room item before taking the screenshot
|
||||
await page.getByRole("button", { name: "User menu" }).focus();
|
||||
|
||||
await expect(roomList).toMatchScreenshot("room-list-sections-chat-moved-toast.png");
|
||||
},
|
||||
);
|
||||
|
||||
test("should remove a room from a custom section when toggling the same section", async ({ page, app }) => {
|
||||
await app.client.createRoom({ name: "my room" });
|
||||
await createCustomSection(page, "Work");
|
||||
|
||||
const roomList = getRoomList(page);
|
||||
|
||||
// Move to Work section and verify placement via aria-level
|
||||
let roomItem = roomList.getByRole("row", { name: "Open room my room" });
|
||||
await roomItem.hover();
|
||||
await roomItem.getByRole("button", { name: "More Options" }).click();
|
||||
await page.getByRole("menuitem", { name: "Move to" }).hover();
|
||||
await page.getByRole("menuitem", { name: "Work" }).click();
|
||||
|
||||
await assertRoomInSection(page, "Work", "my room");
|
||||
|
||||
// Toggle off by selecting the same section again
|
||||
roomItem = roomList.getByRole("row", { name: "Open room my room" });
|
||||
await roomItem.hover();
|
||||
await roomItem.getByRole("button", { name: "More Options" }).click();
|
||||
await page.getByRole("menuitem", { name: "Move to" }).hover();
|
||||
await page.getByRole("menuitem", { name: "Work" }).click();
|
||||
|
||||
// Room is back in the Chats section
|
||||
await assertRoomInSection(page, "Chats", "my room");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -126,7 +126,7 @@ test.describe("Login", () => {
|
||||
await page.goto("/");
|
||||
|
||||
// Should give us the welcome page initially
|
||||
await expect(page.getByRole("heading", { name: "Welcome to Element!" })).toBeVisible();
|
||||
await expect(page.getByRole("heading", { name: "Be in your element" })).toBeVisible();
|
||||
|
||||
// Start the login process
|
||||
await expect(axe).toHaveNoViolations();
|
||||
|
||||
@@ -252,6 +252,7 @@ test.describe("Message url previews", () => {
|
||||
"og:title": "A simple site",
|
||||
"og:description": "And with a brief description",
|
||||
"og:image": mxc,
|
||||
"og:image:alt": "The riot logo",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,7 +7,7 @@ Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import type { JSHandle } from "@playwright/test";
|
||||
import type { MatrixEvent, ISendEventResponse, ReceiptType } from "matrix-js-sdk/src/matrix";
|
||||
import type { MatrixEvent, ISendEventResponse, ReceiptType, RelationType } from "matrix-js-sdk/src/matrix";
|
||||
import { expect } from "../../element-web-test";
|
||||
import { type ElementAppPage } from "../../pages/ElementAppPage";
|
||||
import { type Bot } from "../../pages/bot";
|
||||
@@ -47,7 +47,7 @@ test.describe("Read receipts", { tag: "@mergequeue" }, () => {
|
||||
getId: () => eventResponse.event_id,
|
||||
threadRootId,
|
||||
getTs: () => 1,
|
||||
isRelation: (relType) => {
|
||||
isRelation: (relType: RelationType) => {
|
||||
return !relType || relType === "m.thread";
|
||||
},
|
||||
} as any as MatrixEvent;
|
||||
|
||||
@@ -57,6 +57,9 @@ test.describe("Create Room", () => {
|
||||
|
||||
await page.getByRole("button", { name: "Go" }).click();
|
||||
|
||||
await expect(page.getByRole("heading", { name: "Start a chat with this new contact?" })).toBeVisible();
|
||||
await page.getByRole("button", { name: "Continue" }).click();
|
||||
|
||||
await expect(page.getByText("Encryption enabled")).toBeVisible();
|
||||
await expect(page.getByText("Send your first message to")).toBeVisible();
|
||||
|
||||
|
||||
@@ -163,6 +163,10 @@ test.describe("Room Status Bar", () => {
|
||||
).toBeVisible();
|
||||
await other.getByRole("option", { name: "Alice" }).click();
|
||||
await other.getByRole("button", { name: "Go" }).click();
|
||||
|
||||
await expect(page.getByRole("heading", { name: "Start a chat with this new contact?" })).toBeVisible();
|
||||
await page.getByRole("button", { name: "Continue" }).click();
|
||||
|
||||
// Send a message to invite the bots
|
||||
const composer = app.getComposerField();
|
||||
await composer.fill("Hello");
|
||||
|
||||
@@ -33,7 +33,7 @@ test.describe("Other people's devices section in Encryption tab", () => {
|
||||
|
||||
// Create the room and invite bob
|
||||
await createRoom(alicePage, "TestRoom", true);
|
||||
await aliceElementApp.inviteUserToCurrentRoom(bobCredentials.userId);
|
||||
await aliceElementApp.inviteUserToCurrentRoom(bobCredentials.userId, { confirmUnknownUser: true });
|
||||
|
||||
// Bob accepts the invite
|
||||
await bobPage.getByRole("option", { name: "TestRoom" }).click();
|
||||
@@ -72,7 +72,7 @@ test.describe("Other people's devices section in Encryption tab", () => {
|
||||
|
||||
// Create the room and invite bob
|
||||
await createRoom(alicePage, "TestRoom", true);
|
||||
await aliceElementApp.inviteUserToCurrentRoom(bobCredentials.userId);
|
||||
await aliceElementApp.inviteUserToCurrentRoom(bobCredentials.userId, { confirmUnknownUser: true });
|
||||
|
||||
// Bob accepts the invite
|
||||
await bobPage.getByRole("option", { name: "TestRoom" }).click();
|
||||
@@ -115,7 +115,7 @@ test.describe("Other people's devices section in Encryption tab", () => {
|
||||
|
||||
// Create the room and invite bob
|
||||
await createRoom(alicePage, "TestRoom", true);
|
||||
await aliceElementApp.inviteUserToCurrentRoom(bobCredentials.userId);
|
||||
await aliceElementApp.inviteUserToCurrentRoom(bobCredentials.userId, { confirmUnknownUser: true });
|
||||
|
||||
// Bob accepts the invite and dismisses the warnings.
|
||||
await bobPage.getByRole("option", { name: "TestRoom" }).click();
|
||||
@@ -149,7 +149,7 @@ test.describe("Other people's devices section in Encryption tab", () => {
|
||||
|
||||
// Alice creates the room and invite Bob.
|
||||
await createRoom(alicePage, "TestRoom", true);
|
||||
await aliceElementApp.inviteUserToCurrentRoom(bobCredentials.userId);
|
||||
await aliceElementApp.inviteUserToCurrentRoom(bobCredentials.userId, { confirmUnknownUser: true });
|
||||
|
||||
// Bob accepts the invite.
|
||||
await bobPage.getByRole("option", { name: "TestRoom" }).click();
|
||||
@@ -214,7 +214,7 @@ test.describe("Other people's devices section in Encryption tab", () => {
|
||||
|
||||
// Alice creates the room and invite Bob.
|
||||
await createRoom(alicePage, "TestRoom", true);
|
||||
await aliceElementApp.inviteUserToCurrentRoom(bobCredentials.userId);
|
||||
await aliceElementApp.inviteUserToCurrentRoom(bobCredentials.userId, { confirmUnknownUser: true });
|
||||
|
||||
// Bob accepts the invite.
|
||||
await bobPage.getByRole("option", { name: "TestRoom" }).click();
|
||||
|
||||
@@ -7,7 +7,7 @@ Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import type { Locator, Page } from "@playwright/test";
|
||||
import type { ISendEventResponse, EventType, MsgType } from "matrix-js-sdk/src/matrix";
|
||||
import type { ISendEventResponse, EventType, MsgType, IContent } from "matrix-js-sdk/src/matrix";
|
||||
import { test, expect } from "../../element-web-test";
|
||||
import { SettingLevel } from "../../../src/settings/SettingLevel";
|
||||
import { Layout } from "../../../src/settings/enums/Layout";
|
||||
@@ -50,11 +50,9 @@ const expectAvatar = async (cli: Client, e: Locator, avatarUrl: string): Promise
|
||||
};
|
||||
|
||||
const sendEvent = async (client: Client, roomId: string, html = false): Promise<ISendEventResponse> => {
|
||||
const content = {
|
||||
const content: IContent = {
|
||||
msgtype: "m.text" as MsgType,
|
||||
body: "Message",
|
||||
format: undefined,
|
||||
formatted_body: undefined,
|
||||
};
|
||||
if (html) {
|
||||
content.format = "org.matrix.custom.html";
|
||||
|
||||
@@ -42,7 +42,7 @@ export async function waitForRoom(
|
||||
return new Promise<Room>((resolve) => {
|
||||
const room = matrixClient.getRoom(roomId);
|
||||
|
||||
if (window[predicateId](room)) {
|
||||
if ((<any>window)[predicateId](room)) {
|
||||
resolve(room);
|
||||
return;
|
||||
}
|
||||
@@ -50,7 +50,7 @@ export async function waitForRoom(
|
||||
function onEvent(ev: MatrixEvent) {
|
||||
if (ev.getRoomId() !== roomId) return;
|
||||
|
||||
if (window[predicateId](room)) {
|
||||
if ((<any>window)[predicateId](room)) {
|
||||
matrixClient.removeListener("event" as ClientEvent, onEvent);
|
||||
resolve(room);
|
||||
}
|
||||
|
||||
@@ -107,7 +107,7 @@ export const test = base.extend<TestFixtures>({
|
||||
},
|
||||
});
|
||||
|
||||
interface ExtendedToMatchScreenshotOptions extends ToMatchScreenshotOptions {
|
||||
export interface ExtendedToMatchScreenshotOptions extends ToMatchScreenshotOptions {
|
||||
includeDialogBackground?: boolean;
|
||||
showTooltips?: boolean;
|
||||
timeout?: number;
|
||||
|
||||
@@ -233,15 +233,30 @@ export class ElementAppPage {
|
||||
* Open the room info panel, and use it to send an invite to the given user.
|
||||
*
|
||||
* @param userId - The user to invite to the room.
|
||||
* @param options - Options object
|
||||
*/
|
||||
public async inviteUserToCurrentRoom(userId: string): Promise<void> {
|
||||
public async inviteUserToCurrentRoom(
|
||||
userId: string,
|
||||
options?: {
|
||||
/** If true, expect and acknowledge "Confirm inviting new users" page */
|
||||
confirmUnknownUser?: boolean;
|
||||
},
|
||||
): Promise<void> {
|
||||
const rightPanel = await this.openRoomInfoPanel();
|
||||
await rightPanel.getByRole("menuitem", { name: "Invite" }).click();
|
||||
|
||||
const input = this.page.getByRole("dialog").getByTestId("invite-dialog-input");
|
||||
const dialogLocator = this.page.getByRole("dialog");
|
||||
const input = dialogLocator.getByTestId("invite-dialog-input");
|
||||
await input.fill(userId);
|
||||
await input.press("Enter");
|
||||
await this.page.getByRole("dialog").getByRole("button", { name: "Invite" }).click();
|
||||
await dialogLocator.getByRole("button", { name: "Invite" }).click();
|
||||
|
||||
if (options?.confirmUnknownUser) {
|
||||
await expect(
|
||||
dialogLocator.getByRole("heading", { name: "Invite new contacts to this room?" }),
|
||||
).toBeVisible();
|
||||
await dialogLocator.getByRole("button", { name: "Invite" }).click();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -7,7 +7,7 @@ Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { type JSHandle, type Page } from "@playwright/test";
|
||||
import { type PageFunctionOn } from "playwright-core/types/structs";
|
||||
import { type ElementHandle } from "playwright-core";
|
||||
|
||||
import { Network } from "./network";
|
||||
import type {
|
||||
@@ -30,6 +30,34 @@ import type {
|
||||
import type { RoomMessageEventContent } from "matrix-js-sdk/src/types";
|
||||
import { type CredentialsOptionalAccessToken } from "./bot";
|
||||
|
||||
/** Types cribbed from playwright-core/types/structs as they are not importable */
|
||||
export type NoHandles<Arg> = Arg extends JSHandle
|
||||
? never
|
||||
: Arg extends object
|
||||
? { [Key in keyof Arg]: NoHandles<Arg[Key]> }
|
||||
: Arg;
|
||||
export type Unboxed<Arg> =
|
||||
Arg extends ElementHandle<infer T>
|
||||
? T
|
||||
: Arg extends JSHandle<infer T>
|
||||
? T
|
||||
: Arg extends NoHandles<Arg>
|
||||
? Arg
|
||||
: Arg extends [infer A0]
|
||||
? [Unboxed<A0>]
|
||||
: Arg extends [infer A0, infer A1]
|
||||
? [Unboxed<A0>, Unboxed<A1>]
|
||||
: Arg extends [infer A0, infer A1, infer A2]
|
||||
? [Unboxed<A0>, Unboxed<A1>, Unboxed<A2>]
|
||||
: Arg extends [infer A0, infer A1, infer A2, infer A3]
|
||||
? [Unboxed<A0>, Unboxed<A1>, Unboxed<A2>, Unboxed<A3>]
|
||||
: Arg extends Array<infer T>
|
||||
? Array<Unboxed<T>>
|
||||
: Arg extends object
|
||||
? { [Key in keyof Arg]: Unboxed<Arg[Key]> }
|
||||
: Arg;
|
||||
export type PageFunctionOn<On, Arg2, R> = string | ((on: On, arg2: Unboxed<Arg2>) => R | Promise<R>);
|
||||
|
||||
export class Client {
|
||||
public network: Network;
|
||||
protected client: JSHandle<MatrixClient>;
|
||||
|
||||
|
After Width: | Height: | Size: 29 KiB |
|
After Width: | Height: | Size: 30 KiB |
|
After Width: | Height: | Size: 7.6 KiB |
|
Before Width: | Height: | Size: 7.1 KiB After Width: | Height: | Size: 9.6 KiB |
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 7.4 KiB After Width: | Height: | Size: 9.6 KiB |
|
Before Width: | Height: | Size: 16 KiB After Width: | Height: | Size: 29 KiB |
@@ -21,7 +21,7 @@ const DEFAULT_CONFIG = {
|
||||
global: {
|
||||
server_name: "localhost",
|
||||
private_key: "matrix_key.pem",
|
||||
old_private_keys: null,
|
||||
old_private_keys: null as any,
|
||||
key_validity_period: "168h0m0s",
|
||||
cache: {
|
||||
max_size_estimated: "1gb",
|
||||
@@ -47,7 +47,7 @@ const DEFAULT_CONFIG = {
|
||||
room_name: "Server Alerts",
|
||||
},
|
||||
jetstream: {
|
||||
addresses: null,
|
||||
addresses: null as any,
|
||||
disable_tls_validation: false,
|
||||
storage_path: "./",
|
||||
topic_prefix: "Dendrite",
|
||||
@@ -67,7 +67,7 @@ const DEFAULT_CONFIG = {
|
||||
},
|
||||
app_service_api: {
|
||||
disable_tls_validation: false,
|
||||
config_files: null,
|
||||
config_files: null as any,
|
||||
},
|
||||
client_api: {
|
||||
registration_disabled: false,
|
||||
@@ -79,14 +79,14 @@ const DEFAULT_CONFIG = {
|
||||
recaptcha_bypass_secret: "",
|
||||
turn: {
|
||||
turn_user_lifetime: "5m",
|
||||
turn_uris: null,
|
||||
turn_uris: null as any,
|
||||
turn_shared_secret: "",
|
||||
},
|
||||
rate_limiting: {
|
||||
enabled: true,
|
||||
threshold: 20,
|
||||
cooloff_ms: 500,
|
||||
exempt_user_ids: null,
|
||||
exempt_user_ids: null as any,
|
||||
},
|
||||
},
|
||||
federation_api: {
|
||||
@@ -140,7 +140,7 @@ const DEFAULT_CONFIG = {
|
||||
},
|
||||
},
|
||||
mscs: {
|
||||
mscs: null,
|
||||
mscs: null as any,
|
||||
database: {
|
||||
connection_string: "file:dendrite-msc.db",
|
||||
},
|
||||
@@ -157,7 +157,7 @@ const DEFAULT_CONFIG = {
|
||||
},
|
||||
user_api: {
|
||||
bcrypt_cost: 10,
|
||||
auto_join_rooms: null,
|
||||
auto_join_rooms: null as any,
|
||||
account_database: {
|
||||
connection_string: "file:dendrite-userapi.db",
|
||||
},
|
||||
@@ -183,12 +183,12 @@ const DEFAULT_CONFIG = {
|
||||
serviceName: "",
|
||||
disabled: false,
|
||||
rpc_metrics: false,
|
||||
tags: [],
|
||||
sampler: null,
|
||||
reporter: null,
|
||||
headers: null,
|
||||
baggage_restrictions: null,
|
||||
throttler: null,
|
||||
tags: [] as any[],
|
||||
sampler: null as any,
|
||||
reporter: null as any,
|
||||
headers: null as any,
|
||||
baggage_restrictions: null as any,
|
||||
throttler: null as any,
|
||||
},
|
||||
},
|
||||
logging: [
|
||||
|
||||
@@ -8,7 +8,7 @@ Please see LICENSE files in the repository root for full details.
|
||||
import { SynapseContainer as BaseSynapseContainer } from "@element-hq/element-web-playwright-common/lib/testcontainers/index.js";
|
||||
|
||||
const DOCKER_IMAGE =
|
||||
"ghcr.io/element-hq/synapse:develop@sha256:926d95954cba30a2568dbe907da6628d8e10e06f2b19901f0ec61eb2993be450";
|
||||
"ghcr.io/element-hq/synapse:develop@sha256:b2fec2c9460f5b297a3a4ce78037902590240a1978301ed1d4bc97918c451041";
|
||||
|
||||
/**
|
||||
* SynapseContainer which freezes the docker digest to stabilise tests,
|
||||
|
||||
@@ -2,12 +2,14 @@
|
||||
"compilerOptions": {
|
||||
"target": "es2022",
|
||||
"jsx": "react",
|
||||
"lib": ["ESNext", "es2022", "dom", "dom.iterable"],
|
||||
"lib": ["es2024", "dom", "dom.iterable"],
|
||||
"resolveJsonModule": true,
|
||||
"esModuleInterop": true,
|
||||
"moduleResolution": "node",
|
||||
"module": "es2022",
|
||||
"moduleResolution": "bundler",
|
||||
"module": "ESNext",
|
||||
"allowImportingTsExtensions": true,
|
||||
"strictNullChecks": false,
|
||||
"noImplicitAny": false,
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": [
|
||||
|
||||
@@ -47,11 +47,9 @@
|
||||
"dependsOn": ["^build", "^build:playwright"]
|
||||
},
|
||||
"test:unit": {
|
||||
"executor": "@nx/jest:jest",
|
||||
"options": {
|
||||
"jestConfig": "{projectRoot}/jest.config.ts",
|
||||
"cwd": "apps/web"
|
||||
},
|
||||
// We avoid the jest executor because it doesn't seem to give any benefit, and it mangles the summary of failing tests.
|
||||
"command": "jest",
|
||||
"options": { "cwd": "apps/web" },
|
||||
"dependsOn": ["^build"]
|
||||
},
|
||||
"test:playwright": {
|
||||
|
||||
@@ -598,6 +598,7 @@ legend {
|
||||
.mx_AccessSecretStorageDialog button,
|
||||
.mx_InviteDialog_section button,
|
||||
.mx_InviteDialog_editor button,
|
||||
.mx_UnknownIdentityUsersWarningDialog button,
|
||||
[class|="maplibregl"]
|
||||
),
|
||||
.mx_Dialog_buttons button:not(.mx_Dialog_nonDialogButton, .mx_AccessibleButton),
|
||||
@@ -625,7 +626,8 @@ legend {
|
||||
.mx_ThemeChoicePanel_CustomTheme button,
|
||||
.mx_UnpinAllDialog button,
|
||||
.mx_ShareDialog button,
|
||||
.mx_EncryptionUserSettingsTab button
|
||||
.mx_EncryptionUserSettingsTab button,
|
||||
.mx_UnknownIdentityUsersWarningDialog button
|
||||
):last-child {
|
||||
margin-right: 0px;
|
||||
}
|
||||
@@ -641,7 +643,8 @@ legend {
|
||||
.mx_ShareDialog button,
|
||||
.mx_EncryptionUserSettingsTab button,
|
||||
.mx_InviteDialog_section button,
|
||||
.mx_InviteDialog_editor button
|
||||
.mx_InviteDialog_editor button,
|
||||
.mx_UnknownIdentityUsersWarningDialog button
|
||||
):focus,
|
||||
.mx_Dialog input[type="submit"]:focus,
|
||||
.mx_Dialog_buttons button:not(.mx_Dialog_nonDialogButton, .mx_AccessibleButton):focus,
|
||||
@@ -659,7 +662,8 @@ legend {
|
||||
.mx_ThemeChoicePanel_CustomTheme button,
|
||||
.mx_UnpinAllDialog button,
|
||||
.mx_ShareDialog button,
|
||||
.mx_EncryptionUserSettingsTab button
|
||||
.mx_EncryptionUserSettingsTab button,
|
||||
.mx_UnknownIdentityUsersWarningDialog button
|
||||
),
|
||||
.mx_Dialog_buttons input[type="submit"].mx_Dialog_primary {
|
||||
color: var(--cpd-color-text-on-solid-primary);
|
||||
@@ -678,7 +682,8 @@ legend {
|
||||
.mx_ThemeChoicePanel_CustomTheme button,
|
||||
.mx_UnpinAllDialog button,
|
||||
.mx_ShareDialog button,
|
||||
.mx_EncryptionUserSettingsTab button
|
||||
.mx_EncryptionUserSettingsTab button,
|
||||
.mx_UnknownIdentityUsersWarningDialog button
|
||||
),
|
||||
.mx_Dialog_buttons input[type="submit"].danger {
|
||||
background-color: var(--cpd-color-bg-critical-primary);
|
||||
@@ -701,7 +706,8 @@ legend {
|
||||
.mx_ThemeChoicePanel_CustomTheme button,
|
||||
.mx_UnpinAllDialog button,
|
||||
.mx_ShareDialog button,
|
||||
.mx_EncryptionUserSettingsTab button
|
||||
.mx_EncryptionUserSettingsTab button,
|
||||
.mx_UnknownIdentityUsersWarningDialog button
|
||||
):disabled,
|
||||
.mx_Dialog input[type="submit"]:disabled,
|
||||
.mx_Dialog_buttons button:not(.mx_Dialog_nonDialogButton, .mx_AccessibleButton):disabled,
|
||||
|
||||
@@ -105,6 +105,7 @@
|
||||
@import "./views/auth/_AuthPage.pcss";
|
||||
@import "./views/auth/_CompleteSecurityBody.pcss";
|
||||
@import "./views/auth/_CountryDropdown.pcss";
|
||||
@import "./views/auth/_DefaultWelcome.pcss";
|
||||
@import "./views/auth/_InteractiveAuthEntryComponents.pcss";
|
||||
@import "./views/auth/_LanguageSelector.pcss";
|
||||
@import "./views/auth/_LoginWithQR.pcss";
|
||||
@@ -170,6 +171,7 @@
|
||||
@import "./views/dialogs/_UserSettingsDialog.pcss";
|
||||
@import "./views/dialogs/_VerifyEMailDialog.pcss";
|
||||
@import "./views/dialogs/_WidgetCapabilitiesPromptDialog.pcss";
|
||||
@import "./views/dialogs/invite/_UnknownIdentityUsersWarningDialog.pcss";
|
||||
@import "./views/dialogs/security/_AccessSecretStorageDialog.pcss";
|
||||
@import "./views/dialogs/security/_CreateCrossSigningDialog.pcss";
|
||||
@import "./views/dialogs/security/_CreateSecretStorageDialog.pcss";
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
/*
|
||||
Copyright 2026 Element Creations Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
.mx_DefaultWelcome {
|
||||
text-align: center;
|
||||
|
||||
.mx_DefaultWelcome_logo img {
|
||||
height: 48px;
|
||||
aspect-ratio: auto;
|
||||
display: block;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin: var(--cpd-space-4x) 0 var(--cpd-space-2x);
|
||||
}
|
||||
|
||||
p {
|
||||
color: var(--cpd-color-text-secondary);
|
||||
margin-top: var(--cpd-space-2x);
|
||||
}
|
||||
|
||||
.mx_DefaultWelcome_buttons {
|
||||
margin: var(--cpd-space-6x) 0 var(--cpd-space-1x);
|
||||
padding-bottom: var(--cpd-space-4x);
|
||||
border-bottom: 1px solid var(--cpd-color-separator-primary);
|
||||
|
||||
a {
|
||||
width: 380px;
|
||||
margin-bottom: var(--cpd-space-4x);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.mx_WelcomePage_registrationDisabled {
|
||||
.mx_DefaultWelcome_buttons_register {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,10 @@ Please see LICENSE files in the repository root for full details.
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
background-color: var(--cpd-color-bg-canvas-default);
|
||||
box-sizing: border-box;
|
||||
padding: var(--cpd-space-11x) var(--cpd-space-12x) var(--cpd-space-4x);
|
||||
|
||||
&.mx_WelcomePage_registrationDisabled {
|
||||
.mx_ButtonCreateAccount {
|
||||
display: none;
|
||||
@@ -18,7 +22,7 @@ Please see LICENSE files in the repository root for full details.
|
||||
|
||||
.mx_Welcome .mx_AuthBody_language {
|
||||
width: 160px;
|
||||
margin-bottom: 10px;
|
||||
margin: var(--cpd-space-1x) 0;
|
||||
}
|
||||
|
||||
/* Invert image colours in dark mode. */
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
/*
|
||||
Copyright 2026 Element Creations Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
.mx_UnknownIdentityUsersWarningDialog {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 600px; /* Consistency with InviteDialog */
|
||||
}
|
||||
|
||||
.mx_UnknownIdentityUsersWarningDialog_headerContainer {
|
||||
/* Centre the PageHeader component horizontally */
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
|
||||
/* Styling for the regular text inside the header */
|
||||
font: var(--cpd-font-body-lg-regular);
|
||||
|
||||
/* Space before the list */
|
||||
padding-bottom: var(--cpd-space-6x);
|
||||
}
|
||||
|
||||
.mx_UnknownIdentityUsersWarningDialog_userList {
|
||||
width: 100%;
|
||||
overflow: auto;
|
||||
|
||||
/* Fill available vertical space, but don't allow it to shrink to less than 60px (about the height of a single tile) */
|
||||
flex: 1 0 60px;
|
||||
|
||||
/* Remove browser default ul padding/margin */
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.mx_UnknownIdentityUsersWarningDialog_buttons {
|
||||
display: flex;
|
||||
gap: var(--cpd-space-4x);
|
||||
|
||||
> button {
|
||||
flex: 1;
|
||||
}
|
||||
}
|
||||
@@ -1,191 +0,0 @@
|
||||
<style type="text/css">
|
||||
/* we deliberately inline style here to avoid flash-of-CSS problems, and to avoid
|
||||
* voodoo where we have to set display: none by default
|
||||
*/
|
||||
|
||||
.mx_Header_title::after {
|
||||
content: "!";
|
||||
}
|
||||
|
||||
.mx_Parent {
|
||||
display: -webkit-box;
|
||||
display: -webkit-flex;
|
||||
display: -ms-flexbox;
|
||||
display: flex;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-box-direction: normal;
|
||||
-webkit-flex-direction: column;
|
||||
-ms-flex-direction: column;
|
||||
flex-direction: column;
|
||||
-webkit-box-pack: center;
|
||||
-webkit-justify-content: center;
|
||||
-ms-flex-pack: center;
|
||||
justify-content: center;
|
||||
-webkit-box-align: center;
|
||||
-webkit-align-items: center;
|
||||
-ms-flex-align: center;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
padding: 25px 35px;
|
||||
}
|
||||
|
||||
.mx_Logo {
|
||||
height: 54px;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.mx_ButtonGroup {
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.mx_ButtonRow {
|
||||
display: -webkit-box;
|
||||
display: -webkit-flex;
|
||||
display: -ms-flexbox;
|
||||
display: flex;
|
||||
-webkit-justify-content: space-around;
|
||||
-ms-flex-pack: distribute;
|
||||
-webkit-box-align: center;
|
||||
-webkit-align-items: center;
|
||||
-ms-flex-align: center;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
box-sizing: border-box;
|
||||
margin: 12px 0 0;
|
||||
}
|
||||
|
||||
.mx_ButtonRow > * {
|
||||
margin: 0 10px;
|
||||
}
|
||||
|
||||
.mx_ButtonRow > *:first-child {
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
.mx_ButtonRow > *:last-child {
|
||||
margin-right: 0;
|
||||
}
|
||||
|
||||
.mx_ButtonParent {
|
||||
display: -webkit-box;
|
||||
display: -webkit-flex;
|
||||
display: -ms-flexbox;
|
||||
display: flex;
|
||||
padding: 10px 20px;
|
||||
-webkit-box-orient: horizontal;
|
||||
-webkit-box-direction: normal;
|
||||
-webkit-flex-direction: row;
|
||||
-ms-flex-direction: row;
|
||||
flex-direction: row;
|
||||
-webkit-box-pack: center;
|
||||
-webkit-justify-content: center;
|
||||
-ms-flex-pack: center;
|
||||
justify-content: center;
|
||||
-webkit-box-align: center;
|
||||
-webkit-align-items: center;
|
||||
-ms-flex-align: center;
|
||||
align-items: center;
|
||||
border-radius: 4px;
|
||||
width: 150px;
|
||||
background-repeat: no-repeat;
|
||||
background-position: 10px center;
|
||||
text-decoration: none;
|
||||
color: #2e2f32 !important;
|
||||
}
|
||||
|
||||
.mx_ButtonLabel {
|
||||
margin-left: 20px;
|
||||
}
|
||||
|
||||
.mx_Header_title {
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
margin: 20px 0 0;
|
||||
}
|
||||
|
||||
.mx_Header_subtitle {
|
||||
font-size: 12px;
|
||||
font-weight: normal;
|
||||
margin: 8px 0 0;
|
||||
}
|
||||
|
||||
.mx_ButtonSignIn {
|
||||
background-color: #368bd6;
|
||||
color: white !important;
|
||||
}
|
||||
|
||||
.mx_ButtonCreateAccount {
|
||||
background-color: #0dbd8b;
|
||||
color: white !important;
|
||||
}
|
||||
|
||||
.mx_SecondaryButton {
|
||||
background-color: #ffffff;
|
||||
color: #2e2f32;
|
||||
}
|
||||
|
||||
.mx_Button_iconSignIn {
|
||||
background-image: url("welcome/images/icon-sign-in.svg");
|
||||
}
|
||||
.mx_Button_iconCreateAccount {
|
||||
background-image: url("welcome/images/icon-create-account.svg");
|
||||
}
|
||||
.mx_Button_iconHelp {
|
||||
background-image: url("welcome/images/icon-help.svg");
|
||||
}
|
||||
.mx_Button_iconRoomDirectory {
|
||||
background-image: url("welcome/images/icon-room-directory.svg");
|
||||
}
|
||||
|
||||
/*
|
||||
.mx_WelcomePage_loggedIn is applied by EmbeddedPage from the Welcome component
|
||||
If it is set on the page, we should show the buttons. Otherwise, we have to assume
|
||||
we don't have an account and should hide them. No account == no guest account either.
|
||||
*/
|
||||
.mx_WelcomePage:not(.mx_WelcomePage_loggedIn) .mx_WelcomePage_guestFunctions {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.mx_ButtonRow.mx_WelcomePage_guestFunctions {
|
||||
margin-top: 20px;
|
||||
}
|
||||
.mx_ButtonRow.mx_WelcomePage_guestFunctions > div {
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
@media only screen and (max-width: 480px) {
|
||||
.mx_ButtonRow {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.mx_ButtonRow > * {
|
||||
margin: 0 0 10px 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<div class="mx_Parent">
|
||||
<a href="https://element.io" target="_blank" rel="noopener">
|
||||
<img src="$logoUrl" alt="$brand" class="mx_Logo" />
|
||||
</a>
|
||||
<h1 class="mx_Header_title">_t("welcome_to_element")</h1>
|
||||
<!-- XXX: Our translations system isn't smart enough to recognize variables in the HTML, so we manually do it -->
|
||||
<h2 class="mx_Header_subtitle">_t("powered_by_matrix_with_logo")</h2>
|
||||
<div class="mx_ButtonGroup">
|
||||
<div class="mx_ButtonRow">
|
||||
<a href="#/login" class="mx_ButtonParent mx_ButtonSignIn mx_Button_iconSignIn">
|
||||
<div class="mx_ButtonLabel">_t("action|sign_in")</div>
|
||||
</a>
|
||||
<a href="#/register" class="mx_ButtonParent mx_ButtonCreateAccount mx_Button_iconCreateAccount">
|
||||
<div class="mx_ButtonLabel">_t("action|create_account")</div>
|
||||
</a>
|
||||
</div>
|
||||
<div class="mx_ButtonRow mx_WelcomePage_guestFunctions">
|
||||
<div>
|
||||
<a href="#/directory" class="mx_ButtonParent mx_SecondaryButton mx_Button_iconRoomDirectory">
|
||||
<div class="mx_ButtonLabel">_t("action|explore_rooms")</div>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,3 +0,0 @@
|
||||
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M17 9C17 13.4183 13.4183 17 9 17C4.58172 17 1 13.4183 1 9C1 4.58172 4.58172 1 9 1C13.4183 1 17 4.58172 17 9ZM5.25 9C5.25 8.58579 5.58579 8.25 6 8.25H8.25V6C8.25 5.58579 8.58579 5.25 9 5.25C9.41421 5.25 9.75 5.58579 9.75 6V8.25H12C12.4142 8.25 12.75 8.58579 12.75 9C12.75 9.41421 12.4142 9.75 12 9.75H9.75V12C9.75 12.4142 9.41421 12.75 9 12.75C8.58579 12.75 8.25 12.4142 8.25 12V9.75H6C5.58579 9.75 5.25 9.41421 5.25 9Z" fill="white"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 587 B |
@@ -1,16 +0,0 @@
|
||||
|
||||
<svg width="22px" height="22px" viewBox="0 0 22 22" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
|
||||
<g id="Experiments" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd" stroke-linecap="round" stroke-linejoin="round">
|
||||
<g id="Home" transform="translate(-672.000000, -577.000000)" stroke="#000000" stroke-width="1.6">
|
||||
<g id="Group-11" transform="translate(621.000000, 176.000000)">
|
||||
<g id="Group-10" transform="translate(39.000000, 391.000000)">
|
||||
<g id="help-circle" transform="translate(13.000000, 11.000000)">
|
||||
<circle id="Oval" cx="10" cy="10" r="10"></circle>
|
||||
<path d="M7.09,7 C7.57543688,5.62004444 8.98538362,4.79140632 10.4271763,5.0387121 C11.868969,5.28601788 12.9221794,6.53715293 12.92,8 C12.92,10 9.92,11 9.92,11" id="Path"></path>
|
||||
<path d="M10,15 L10.0050017,15.0050017" id="Path"></path>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 1.1 KiB |
@@ -1,4 +0,0 @@
|
||||
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M17 9C17 13.4183 13.4183 17 9 17C4.58172 17 1 13.4183 1 9C1 4.58172 4.58172 1 9 1C13.4183 1 17 4.58172 17 9ZM13.375 5.3266C13.5583 4.92826 13.0716 4.44152 12.6733 4.62491L7.66968 6.9285C7.33893 7.08077 7.08014 7.33956 6.92787 7.67031L4.62428 12.6739C4.44089 13.0722 4.92763 13.559 5.32597 13.3756L10.3295 11.072C10.6603 10.9197 10.9191 10.6609 11.0714 10.3302L13.375 5.3266Z" fill="black"/>
|
||||
<path d="M9.8835 9.88413C9.39534 10.3723 8.60389 10.3723 8.11573 9.88413C7.62757 9.39597 7.62757 8.60452 8.11573 8.11636C8.60389 7.62821 9.39534 7.62821 9.8835 8.11636C10.3717 8.60452 10.3717 9.39597 9.8835 9.88413Z" fill="black"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 775 B |
|
Before Width: | Height: | Size: 53 KiB |
@@ -52,3 +52,13 @@ export type AtLeastOne<T, U = { [K in keyof T]: Pick<T, K> }> = Partial<T> & U[k
|
||||
export type Assignable<Object, Item> = {
|
||||
[Key in keyof Object]: Object[Key] extends Item ? Key : never;
|
||||
}[keyof Object];
|
||||
|
||||
/**
|
||||
* Like `Partial` but for applied to all nested objects.
|
||||
* Based on https://dev.to/perennialautodidact/adventures-in-typescript-deeppartial-2f2a
|
||||
*/
|
||||
export type DeepPartial<T> = T extends object
|
||||
? {
|
||||
[P in keyof T]?: DeepPartial<T[P]>;
|
||||
}
|
||||
: T;
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
/*
|
||||
Copyright 2024 New Vector Ltd.
|
||||
Copyright 2020 The Matrix.org Foundation C.I.C.
|
||||
Copyright 2026 Element Creations Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
export type FocusHandler = () => void;
|
||||
declare module "*.css";
|
||||
@@ -8,7 +8,6 @@ Please see LICENSE files in the repository root for full details.
|
||||
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import "matrix-js-sdk/src/@types/global"; // load matrix-js-sdk's type extensions first
|
||||
import "@types/modernizr";
|
||||
|
||||
import type { ModuleLoader } from "@element-hq/element-web-module-api";
|
||||
import type { logger } from "matrix-js-sdk/src/logger";
|
||||
@@ -186,14 +185,6 @@ declare global {
|
||||
readonly port: MessagePort;
|
||||
}
|
||||
|
||||
/**
|
||||
* In future, browsers will support focusVisible option.
|
||||
* See https://developer.mozilla.org/en-US/docs/Web/API/HTMLElement/focus#focusvisible
|
||||
*/
|
||||
interface FocusOptions {
|
||||
focusVisible: boolean;
|
||||
}
|
||||
|
||||
// https://github.com/microsoft/TypeScript/issues/28308#issuecomment-650802278
|
||||
function registerProcessor(
|
||||
name: string,
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
/*
|
||||
Copyright 2026 Element Creations Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
declare module "*.pcss";
|
||||
@@ -52,8 +52,9 @@ export interface IConfigOptions {
|
||||
disable_3pid_login?: boolean;
|
||||
|
||||
brand: string;
|
||||
branding?: {
|
||||
welcome_background_url?: string | string[]; // chosen at random if array
|
||||
branding: {
|
||||
welcome_background_url: string | string[]; // chosen at random if array
|
||||
logo_link_url: string;
|
||||
auth_header_logo_url?: string;
|
||||
auth_footer_links?: { text: string; url: string }[];
|
||||
};
|
||||
|
||||
@@ -13,7 +13,6 @@ import { type Interaction as InteractionEvent } from "@matrix-org/analytics-even
|
||||
import { type PinUnpinAction } from "@matrix-org/analytics-events/types/typescript/PinUnpinAction";
|
||||
import { type RoomListSortingAlgorithmChanged } from "@matrix-org/analytics-events/types/typescript/RoomListSortingAlgorithmChanged";
|
||||
import { type UrlPreviewRendered } from "@matrix-org/analytics-events/types/typescript/UrlPreviewRendered";
|
||||
import { type UrlPreview } from "@element-hq/web-shared-components";
|
||||
|
||||
import PageType from "./PageTypes";
|
||||
import Views from "./Views";
|
||||
@@ -151,7 +150,7 @@ export default class PosthogTrackers {
|
||||
* @param isEncrypted Whether the event (and effectively the room) was encrypted.
|
||||
* @param previews The previews generated from the event.
|
||||
*/
|
||||
public trackUrlPreview(eventId: string, isEncrypted: boolean, previews: UrlPreview[]): void {
|
||||
public trackUrlPreview(eventId: string, isEncrypted: boolean, previews: { image?: unknown }[]): void {
|
||||
// Discount any previews that we have already tracked.
|
||||
if (this.previewedEventIds.get(eventId)) {
|
||||
return;
|
||||
|
||||
@@ -12,11 +12,16 @@ import { mergeWith } from "lodash";
|
||||
import { SnakedObject } from "./utils/SnakedObject";
|
||||
import { type IConfigOptions } from "./IConfigOptions";
|
||||
import { isObject, objectClone } from "./utils/objects";
|
||||
import { type DeepReadonly, type Defaultize } from "./@types/common";
|
||||
import { type DeepPartial, type DeepReadonly, type Defaultize } from "./@types/common";
|
||||
|
||||
// see element-web config.md for docs, or the IConfigOptions interface for dev docs
|
||||
export const DEFAULTS: DeepReadonly<IConfigOptions> = {
|
||||
brand: "Element",
|
||||
branding: {
|
||||
logo_link_url: "https://element.io",
|
||||
auth_header_logo_url: "themes/element/img/logos/element-logo.svg",
|
||||
welcome_background_url: "themes/element/img/backgrounds/lake.jpg",
|
||||
},
|
||||
help_url: "https://element.io/help",
|
||||
help_encryption_url: "https://element.io/help#encryption",
|
||||
help_key_storage_url: "https://element.io/help#encryption5",
|
||||
@@ -70,7 +75,7 @@ export type ConfigOptions = Defaultize<IConfigOptions, typeof DEFAULTS>;
|
||||
|
||||
function mergeConfig(
|
||||
config: DeepReadonly<IConfigOptions>,
|
||||
changes: DeepReadonly<Partial<IConfigOptions>>,
|
||||
changes: DeepReadonly<DeepPartial<IConfigOptions>>,
|
||||
): DeepReadonly<IConfigOptions> {
|
||||
// return { ...config, ...changes };
|
||||
return mergeWith(objectClone(config), changes, (objValue, srcValue) => {
|
||||
@@ -136,7 +141,7 @@ export default class SdkConfig {
|
||||
SdkConfig.setInstance(mergeConfig(DEFAULTS, {})); // safe to cast - defaults will be applied
|
||||
}
|
||||
|
||||
public static add(cfg: Partial<ConfigOptions>): void {
|
||||
public static add(cfg: DeepPartial<ConfigOptions>): void {
|
||||
SdkConfig.put(mergeConfig(SdkConfig.get(), cfg));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,23 +6,21 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com
|
||||
Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import React, {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useMemo,
|
||||
useRef,
|
||||
useReducer,
|
||||
type Reducer,
|
||||
type Dispatch,
|
||||
type RefObject,
|
||||
type ReactNode,
|
||||
type RefCallback,
|
||||
} from "react";
|
||||
import React from "react";
|
||||
import {
|
||||
RovingAction,
|
||||
RovingTabIndexProvider as SharedRovingTabIndexProvider,
|
||||
type RovingTabIndexProviderProps,
|
||||
} from "@element-hq/web-shared-components";
|
||||
|
||||
import { getKeyBindingsManager } from "../KeyBindingsManager";
|
||||
import { KeyBindingAction } from "./KeyboardShortcuts";
|
||||
import { type FocusHandler } from "./roving/types";
|
||||
|
||||
export { findNextSiblingElement, RovingTabIndexContext } from "@element-hq/web-shared-components";
|
||||
export { checkInputableElement } from "@element-hq/web-shared-components";
|
||||
export { RovingStateActionType } from "@element-hq/web-shared-components";
|
||||
export { useRovingTabIndex } from "@element-hq/web-shared-components";
|
||||
export type { IAction, IState } from "@element-hq/web-shared-components";
|
||||
|
||||
/**
|
||||
* Module to simplify implementing the Roving TabIndex accessibility technique
|
||||
@@ -37,370 +35,31 @@ import { type FocusHandler } from "./roving/types";
|
||||
* https://developer.mozilla.org/en-US/docs/Web/Accessibility/Keyboard-navigable_JavaScript_widgets#Technique_1_Roving_tabindex
|
||||
*/
|
||||
|
||||
// Check for form elements which utilize the arrow keys for native functions
|
||||
// like many of the text input varieties.
|
||||
//
|
||||
// i.e. it's ok to press the down arrow on a radio button to move to the next
|
||||
// radio. But it's not ok to press the down arrow on a <input type="text"> to
|
||||
// move away because the down arrow should move the cursor to the end of the
|
||||
// input.
|
||||
export function checkInputableElement(el: HTMLElement): boolean {
|
||||
return el.matches('input:not([type="radio"]):not([type="checkbox"]), textarea, select, [contenteditable=true]');
|
||||
}
|
||||
|
||||
export interface IState {
|
||||
activeNode?: HTMLElement;
|
||||
nodes: HTMLElement[];
|
||||
}
|
||||
|
||||
export interface IContext {
|
||||
state: IState;
|
||||
dispatch: Dispatch<IAction>;
|
||||
}
|
||||
|
||||
export const RovingTabIndexContext = createContext<IContext>({
|
||||
state: {
|
||||
nodes: [], // list of nodes in DOM order
|
||||
},
|
||||
dispatch: () => {},
|
||||
});
|
||||
RovingTabIndexContext.displayName = "RovingTabIndexContext";
|
||||
|
||||
export enum Type {
|
||||
Register = "REGISTER",
|
||||
Unregister = "UNREGISTER",
|
||||
SetFocus = "SET_FOCUS",
|
||||
Update = "UPDATE",
|
||||
}
|
||||
|
||||
export interface IAction {
|
||||
type: Exclude<Type, Type.Update>;
|
||||
payload: {
|
||||
node: HTMLElement;
|
||||
};
|
||||
}
|
||||
|
||||
interface UpdateAction {
|
||||
type: Type.Update;
|
||||
payload?: undefined;
|
||||
}
|
||||
|
||||
type Action = IAction | UpdateAction;
|
||||
|
||||
const nodeSorter = (a: HTMLElement, b: HTMLElement): number => {
|
||||
if (a === b) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const position = a.compareDocumentPosition(b);
|
||||
|
||||
if (position & Node.DOCUMENT_POSITION_FOLLOWING || position & Node.DOCUMENT_POSITION_CONTAINED_BY) {
|
||||
return -1;
|
||||
} else if (position & Node.DOCUMENT_POSITION_PRECEDING || position & Node.DOCUMENT_POSITION_CONTAINS) {
|
||||
return 1;
|
||||
} else {
|
||||
return 0;
|
||||
}
|
||||
};
|
||||
|
||||
export const reducer: Reducer<IState, Action> = (state: IState, action: Action) => {
|
||||
switch (action.type) {
|
||||
case Type.Register: {
|
||||
if (!state.activeNode) {
|
||||
// Our list of nodes was empty, set activeNode to this first item
|
||||
state.activeNode = action.payload.node;
|
||||
}
|
||||
|
||||
if (state.nodes.includes(action.payload.node)) return state;
|
||||
|
||||
// Sadly due to the potential of DOM elements swapping order we can't do anything fancy like a binary insert
|
||||
state.nodes.push(action.payload.node);
|
||||
state.nodes.sort(nodeSorter);
|
||||
|
||||
return { ...state };
|
||||
}
|
||||
|
||||
case Type.Unregister: {
|
||||
const oldIndex = state.nodes.findIndex((r) => r === action.payload.node);
|
||||
|
||||
if (oldIndex === -1) {
|
||||
return state; // already removed, this should not happen
|
||||
}
|
||||
|
||||
if (state.nodes.splice(oldIndex, 1)[0] === state.activeNode) {
|
||||
// we just removed the active node, need to replace it
|
||||
// pick the node closest to the index the old node was in
|
||||
if (oldIndex >= state.nodes.length) {
|
||||
state.activeNode = findSiblingElement(state.nodes, state.nodes.length - 1, true);
|
||||
} else {
|
||||
state.activeNode =
|
||||
findSiblingElement(state.nodes, oldIndex) || findSiblingElement(state.nodes, oldIndex, true);
|
||||
}
|
||||
if (document.activeElement === document.body) {
|
||||
// if the focus got reverted to the body then the user was likely focused on the unmounted element
|
||||
setTimeout(() => state.activeNode?.focus(), 0);
|
||||
}
|
||||
}
|
||||
|
||||
// update the nodes list
|
||||
return { ...state };
|
||||
}
|
||||
|
||||
case Type.SetFocus: {
|
||||
// if the node doesn't change just return the same object reference to skip a re-render
|
||||
if (state.activeNode === action.payload.node) return state;
|
||||
// update active node
|
||||
state.activeNode = action.payload.node;
|
||||
return { ...state };
|
||||
}
|
||||
|
||||
case Type.Update: {
|
||||
state.nodes.sort(nodeSorter);
|
||||
return { ...state };
|
||||
}
|
||||
|
||||
const getWebRovingAction = (ev: React.KeyboardEvent): RovingAction | undefined => {
|
||||
switch (getKeyBindingsManager().getAccessibilityAction(ev)) {
|
||||
case KeyBindingAction.Home:
|
||||
return RovingAction.Home;
|
||||
case KeyBindingAction.End:
|
||||
return RovingAction.End;
|
||||
case KeyBindingAction.ArrowLeft:
|
||||
return RovingAction.ArrowLeft;
|
||||
case KeyBindingAction.ArrowUp:
|
||||
return RovingAction.ArrowUp;
|
||||
case KeyBindingAction.ArrowRight:
|
||||
return RovingAction.ArrowRight;
|
||||
case KeyBindingAction.ArrowDown:
|
||||
return RovingAction.ArrowDown;
|
||||
case KeyBindingAction.Tab:
|
||||
return RovingAction.Tab;
|
||||
default:
|
||||
return state;
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
|
||||
interface IProps {
|
||||
handleLoop?: boolean;
|
||||
handleHomeEnd?: boolean;
|
||||
handleUpDown?: boolean;
|
||||
handleLeftRight?: boolean;
|
||||
handleInputFields?: boolean;
|
||||
scrollIntoView?: boolean | ScrollIntoViewOptions;
|
||||
children(
|
||||
this: void,
|
||||
renderProps: {
|
||||
onKeyDownHandler(this: void, ev: React.KeyboardEvent): void;
|
||||
onDragEndHandler(this: void): void;
|
||||
},
|
||||
): ReactNode;
|
||||
onKeyDown?(this: void, ev: React.KeyboardEvent, state: IState, dispatch: Dispatch<IAction>): void;
|
||||
}
|
||||
type IProps = Omit<RovingTabIndexProviderProps, "getAction">;
|
||||
|
||||
export const findSiblingElement = (
|
||||
nodes: HTMLElement[],
|
||||
startIndex: number,
|
||||
backwards = false,
|
||||
loop = false,
|
||||
): HTMLElement | undefined => {
|
||||
if (backwards) {
|
||||
for (let i = startIndex; i < nodes.length && i >= 0; i--) {
|
||||
if (nodes[i]?.offsetParent !== null) {
|
||||
return nodes[i];
|
||||
}
|
||||
}
|
||||
if (loop) {
|
||||
return findSiblingElement(nodes.slice(startIndex + 1), nodes.length - 1, true, false);
|
||||
}
|
||||
} else {
|
||||
for (let i = startIndex; i < nodes.length && i >= 0; i++) {
|
||||
if (nodes[i]?.offsetParent !== null) {
|
||||
return nodes[i];
|
||||
}
|
||||
}
|
||||
if (loop) {
|
||||
return findSiblingElement(nodes.slice(0, startIndex), 0, false, false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export const RovingTabIndexProvider: React.FC<IProps> = ({
|
||||
children,
|
||||
handleHomeEnd,
|
||||
handleUpDown,
|
||||
handleLeftRight,
|
||||
handleLoop,
|
||||
handleInputFields,
|
||||
scrollIntoView,
|
||||
onKeyDown,
|
||||
}) => {
|
||||
const [state, dispatch] = useReducer<IState, [Action]>(reducer, {
|
||||
nodes: [],
|
||||
});
|
||||
|
||||
const context = useMemo<IContext>(() => ({ state, dispatch }), [state]);
|
||||
|
||||
const onKeyDownHandler = useCallback(
|
||||
(ev: React.KeyboardEvent) => {
|
||||
if (onKeyDown) {
|
||||
onKeyDown(ev, context.state, context.dispatch);
|
||||
if (ev.defaultPrevented) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
let handled = false;
|
||||
const action = getKeyBindingsManager().getAccessibilityAction(ev);
|
||||
let focusNode: HTMLElement | undefined;
|
||||
// Don't interfere with input default keydown behaviour
|
||||
// but allow people to move focus from it with Tab.
|
||||
if (!handleInputFields && checkInputableElement(ev.target as HTMLElement)) {
|
||||
switch (action) {
|
||||
case KeyBindingAction.Tab:
|
||||
handled = true;
|
||||
if (context.state.nodes.length > 0) {
|
||||
const idx = context.state.nodes.indexOf(context.state.activeNode!);
|
||||
focusNode = findSiblingElement(
|
||||
context.state.nodes,
|
||||
idx + (ev.shiftKey ? -1 : 1),
|
||||
ev.shiftKey,
|
||||
);
|
||||
}
|
||||
break;
|
||||
}
|
||||
} else {
|
||||
// check if we actually have any items
|
||||
switch (action) {
|
||||
case KeyBindingAction.Home:
|
||||
if (handleHomeEnd) {
|
||||
handled = true;
|
||||
// move focus to first (visible) item
|
||||
focusNode = findSiblingElement(context.state.nodes, 0);
|
||||
}
|
||||
break;
|
||||
|
||||
case KeyBindingAction.End:
|
||||
if (handleHomeEnd) {
|
||||
handled = true;
|
||||
// move focus to last (visible) item
|
||||
focusNode = findSiblingElement(context.state.nodes, context.state.nodes.length - 1, true);
|
||||
}
|
||||
break;
|
||||
|
||||
case KeyBindingAction.ArrowDown:
|
||||
case KeyBindingAction.ArrowRight:
|
||||
if (
|
||||
(action === KeyBindingAction.ArrowDown && handleUpDown) ||
|
||||
(action === KeyBindingAction.ArrowRight && handleLeftRight)
|
||||
) {
|
||||
handled = true;
|
||||
if (context.state.nodes.length > 0) {
|
||||
const idx = context.state.nodes.indexOf(context.state.activeNode!);
|
||||
focusNode = findSiblingElement(context.state.nodes, idx + 1, false, handleLoop);
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
case KeyBindingAction.ArrowUp:
|
||||
case KeyBindingAction.ArrowLeft:
|
||||
if (
|
||||
(action === KeyBindingAction.ArrowUp && handleUpDown) ||
|
||||
(action === KeyBindingAction.ArrowLeft && handleLeftRight)
|
||||
) {
|
||||
handled = true;
|
||||
if (context.state.nodes.length > 0) {
|
||||
const idx = context.state.nodes.indexOf(context.state.activeNode!);
|
||||
focusNode = findSiblingElement(context.state.nodes, idx - 1, true, handleLoop);
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (handled) {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
}
|
||||
|
||||
if (focusNode) {
|
||||
focusNode?.focus();
|
||||
// programmatic focus doesn't fire the onFocus handler, so we must do the do ourselves
|
||||
dispatch({
|
||||
type: Type.SetFocus,
|
||||
payload: {
|
||||
node: focusNode,
|
||||
},
|
||||
});
|
||||
if (scrollIntoView) {
|
||||
focusNode?.scrollIntoView(scrollIntoView);
|
||||
}
|
||||
}
|
||||
},
|
||||
[
|
||||
context,
|
||||
onKeyDown,
|
||||
handleHomeEnd,
|
||||
handleUpDown,
|
||||
handleLeftRight,
|
||||
handleLoop,
|
||||
handleInputFields,
|
||||
scrollIntoView,
|
||||
],
|
||||
);
|
||||
|
||||
const onDragEndHandler = useCallback(() => {
|
||||
dispatch({
|
||||
type: Type.Update,
|
||||
});
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<RovingTabIndexContext.Provider value={context}>
|
||||
{children({ onKeyDownHandler, onDragEndHandler })}
|
||||
</RovingTabIndexContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Hook to register a roving tab index.
|
||||
*
|
||||
* inputRef is an optional argument; when passed this ref points to the DOM element
|
||||
* to which the callback ref is attached.
|
||||
*
|
||||
* Returns:
|
||||
* onFocus should be called when the index gained focus in any manner.
|
||||
* isActive should be used to set tabIndex in a manner such as `tabIndex={isActive ? 0 : -1}`.
|
||||
* ref is a callback ref that should be passed to a DOM node which will be used for DOM compareDocumentPosition.
|
||||
* nodeRef is a ref that points to the DOM element to which the ref mentioned above is attached.
|
||||
*
|
||||
* nodeRef = inputRef when inputRef argument is provided.
|
||||
*/
|
||||
export const useRovingTabIndex = <T extends HTMLElement>(
|
||||
inputRef?: RefObject<T | null>,
|
||||
): [FocusHandler, boolean, RefCallback<T>, RefObject<T | null>] => {
|
||||
const context = useContext(RovingTabIndexContext);
|
||||
|
||||
let nodeRef = useRef<T | null>(null);
|
||||
|
||||
if (inputRef) {
|
||||
// if we are given a ref, use it instead of ours
|
||||
nodeRef = inputRef;
|
||||
}
|
||||
|
||||
const ref = useCallback((node: T | null) => {
|
||||
if (node) {
|
||||
nodeRef.current = node;
|
||||
context.dispatch({
|
||||
type: Type.Register,
|
||||
payload: { node },
|
||||
});
|
||||
} else {
|
||||
context.dispatch({
|
||||
type: Type.Unregister,
|
||||
payload: { node: nodeRef.current! },
|
||||
});
|
||||
nodeRef.current = null;
|
||||
}
|
||||
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const onFocus = useCallback(() => {
|
||||
if (!nodeRef.current) {
|
||||
console.warn("useRovingTabIndex.onFocus called but the react ref does not point to any DOM element!");
|
||||
return;
|
||||
}
|
||||
context.dispatch({
|
||||
type: Type.SetFocus,
|
||||
payload: { node: nodeRef.current },
|
||||
});
|
||||
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// eslint-disable-next-line react-compiler/react-compiler
|
||||
const isActive = context.state.activeNode === nodeRef.current;
|
||||
return [onFocus, isActive, ref, nodeRef];
|
||||
export const RovingTabIndexProvider: React.FC<IProps> = (props) => {
|
||||
return <SharedRovingTabIndexProvider {...props} getAction={getWebRovingAction} />;
|
||||
};
|
||||
|
||||
// re-export the semantic helper components for simplicity
|
||||
|
||||
@@ -6,26 +6,4 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com
|
||||
Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { type ReactElement, type RefCallback, type RefObject } from "react";
|
||||
|
||||
import type React from "react";
|
||||
import { useRovingTabIndex } from "../RovingTabIndex";
|
||||
import { type FocusHandler } from "./types";
|
||||
|
||||
interface IProps {
|
||||
inputRef?: RefObject<HTMLElement | null>;
|
||||
children(
|
||||
this: void,
|
||||
renderProps: {
|
||||
onFocus: FocusHandler;
|
||||
isActive: boolean;
|
||||
ref: RefCallback<HTMLElement>;
|
||||
},
|
||||
): ReactElement<any, any>;
|
||||
}
|
||||
|
||||
// Wrapper to allow use of useRovingTabIndex outside of React Functional Components.
|
||||
export const RovingTabIndexWrapper: React.FC<IProps> = ({ children, inputRef }) => {
|
||||
const [onFocus, isActive, ref] = useRovingTabIndex(inputRef);
|
||||
return children({ onFocus, isActive, ref });
|
||||
};
|
||||
export { RovingTabIndexWrapper } from "@element-hq/web-shared-components";
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
/*
|
||||
Copyright 2026 Element Creations Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import SdkConfig from "./SdkConfig.ts";
|
||||
|
||||
const ELEMENT_BRAND = "Element";
|
||||
|
||||
/**
|
||||
* Returns whether the app is currently branded.
|
||||
* This is currently a naive check of whether the `brand` config starts with the substring `Element ` or is the literal `Element`,
|
||||
* which correctly covers `Element` (release), `Element Nightly` & `Element Pro`.
|
||||
*/
|
||||
export const isElementBranded = (): boolean => {
|
||||
const brand = SdkConfig.get("brand");
|
||||
return brand === ELEMENT_BRAND || brand.startsWith(ELEMENT_BRAND + " ");
|
||||
};
|
||||
@@ -31,16 +31,13 @@ export default class AuthPage extends React.PureComponent<React.PropsWithChildre
|
||||
if (AuthPage.welcomeBackgroundUrl) return AuthPage.welcomeBackgroundUrl;
|
||||
|
||||
const brandingConfig = SdkConfig.getObject("branding");
|
||||
AuthPage.welcomeBackgroundUrl = "themes/element/img/backgrounds/lake.jpg";
|
||||
|
||||
const configuredUrl = brandingConfig?.get("welcome_background_url");
|
||||
if (configuredUrl) {
|
||||
if (Array.isArray(configuredUrl)) {
|
||||
const index = Math.floor(Math.random() * configuredUrl.length);
|
||||
AuthPage.welcomeBackgroundUrl = configuredUrl[index];
|
||||
} else {
|
||||
AuthPage.welcomeBackgroundUrl = configuredUrl;
|
||||
}
|
||||
const urls = brandingConfig.get("welcome_background_url");
|
||||
if (Array.isArray(urls)) {
|
||||
const index = Math.floor(Math.random() * urls.length);
|
||||
AuthPage.welcomeBackgroundUrl = urls[index];
|
||||
} else {
|
||||
AuthPage.welcomeBackgroundUrl = urls;
|
||||
}
|
||||
|
||||
return AuthPage.welcomeBackgroundUrl;
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
/*
|
||||
Copyright 2026 Element Creations Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import React from "react";
|
||||
import { Button, Heading, Text } from "@vector-im/compound-web";
|
||||
|
||||
import { _t } from "../../../languageHandler";
|
||||
import SdkConfig from "../../../SdkConfig.ts";
|
||||
import { MatrixClientPeg } from "../../../MatrixClientPeg.ts";
|
||||
import { isElementBranded } from "../../../branding.ts";
|
||||
|
||||
const DefaultWelcome: React.FC = () => {
|
||||
const brand = SdkConfig.get("brand");
|
||||
const branding = SdkConfig.getObject("branding");
|
||||
const logoUrl = branding.get("auth_header_logo_url");
|
||||
|
||||
const showGuestFunctions = !!MatrixClientPeg.get();
|
||||
const isElement = isElementBranded();
|
||||
|
||||
return (
|
||||
<div className="mx_DefaultWelcome">
|
||||
<a href={branding.get("logo_link_url")} target="_blank" rel="noopener" className="mx_DefaultWelcome_logo">
|
||||
<img src={logoUrl} alt={brand} />
|
||||
</a>
|
||||
<Heading as="h1" weight="semibold">
|
||||
{isElement ? _t("welcome|title_element") : _t("welcome|title_generic", { brand })}
|
||||
</Heading>
|
||||
{isElement && <Text size="md">{_t("welcome|tagline_element")}</Text>}
|
||||
|
||||
<div className="mx_DefaultWelcome_buttons">
|
||||
<Button as="a" href="#/login" kind="primary" size="sm">
|
||||
{_t("action|sign_in")}
|
||||
</Button>
|
||||
<Button as="a" href="#/register" kind="secondary" size="sm">
|
||||
{_t("action|create_account")}
|
||||
</Button>
|
||||
{showGuestFunctions && (
|
||||
<Button as="a" href="#/directory" kind="tertiary" size="sm">
|
||||
{_t("action|explore_rooms")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default DefaultWelcome;
|
||||
@@ -5,9 +5,10 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com
|
||||
Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import React from "react";
|
||||
import React, { type ReactNode } from "react";
|
||||
import classNames from "classnames";
|
||||
import { type EmptyObject } from "matrix-js-sdk/src/matrix";
|
||||
import { Glass } from "@vector-im/compound-web";
|
||||
|
||||
import SdkConfig from "../../../SdkConfig";
|
||||
import AuthPage from "./AuthPage";
|
||||
@@ -16,14 +17,12 @@ import { UIFeature } from "../../../settings/UIFeature";
|
||||
import LanguageSelector from "./LanguageSelector";
|
||||
import EmbeddedPage from "../../structures/EmbeddedPage";
|
||||
import { MATRIX_LOGO_HTML } from "../../structures/static-page-vars";
|
||||
import DefaultWelcome from "./DefaultWelcome.tsx";
|
||||
|
||||
export default class Welcome extends React.PureComponent<EmptyObject> {
|
||||
public render(): React.ReactNode {
|
||||
const pagesConfig = SdkConfig.getObject("embedded_pages");
|
||||
let pageUrl: string | undefined;
|
||||
if (pagesConfig) {
|
||||
pageUrl = pagesConfig.get("welcome_url");
|
||||
}
|
||||
const pageUrl = pagesConfig?.get("welcome_url");
|
||||
|
||||
const replaceMap: Record<string, string> = {
|
||||
"$brand": SdkConfig.get("brand"),
|
||||
@@ -33,25 +32,25 @@ export default class Welcome extends React.PureComponent<EmptyObject> {
|
||||
"[matrix]": MATRIX_LOGO_HTML,
|
||||
};
|
||||
|
||||
if (!pageUrl) {
|
||||
// Fall back to default and replace $logoUrl in welcome.html
|
||||
const brandingConfig = SdkConfig.getObject("branding");
|
||||
const logoUrl = brandingConfig?.get("auth_header_logo_url") ?? "themes/element/img/logos/element-logo.svg";
|
||||
replaceMap["$logoUrl"] = logoUrl;
|
||||
pageUrl = "welcome.html";
|
||||
let body: ReactNode;
|
||||
if (pageUrl) {
|
||||
body = <EmbeddedPage className="mx_WelcomePage" url={pageUrl} replaceMap={replaceMap} />;
|
||||
} else {
|
||||
body = <DefaultWelcome />;
|
||||
}
|
||||
|
||||
return (
|
||||
<AuthPage>
|
||||
<div
|
||||
className={classNames("mx_Welcome", {
|
||||
mx_WelcomePage_registrationDisabled: !SettingsStore.getValue(UIFeature.Registration),
|
||||
})}
|
||||
data-testid="mx_welcome_screen"
|
||||
>
|
||||
<EmbeddedPage className="mx_WelcomePage" url={pageUrl} replaceMap={replaceMap} />
|
||||
<LanguageSelector />
|
||||
</div>
|
||||
<AuthPage addBlur={false}>
|
||||
<Glass>
|
||||
<div
|
||||
className={classNames("mx_Welcome", {
|
||||
mx_WelcomePage_registrationDisabled: !SettingsStore.getValue(UIFeature.Registration),
|
||||
})}
|
||||
>
|
||||
{body}
|
||||
<LanguageSelector />
|
||||
</div>
|
||||
</Glass>
|
||||
</AuthPage>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -50,9 +50,9 @@ import { RoomContextDetails } from "../rooms/RoomContextDetails";
|
||||
import { filterBoolean } from "../../../utils/arrays";
|
||||
import {
|
||||
type IState,
|
||||
RovingStateActionType,
|
||||
RovingTabIndexContext,
|
||||
RovingTabIndexProvider,
|
||||
Type,
|
||||
useRovingTabIndex,
|
||||
} from "../../../accessibility/RovingTabIndex";
|
||||
import { getKeyBindingsManager } from "../../../KeyBindingsManager";
|
||||
@@ -368,7 +368,7 @@ const ForwardDialog: React.FC<IProps> = ({ matrixClient: cli, event, permalinkCr
|
||||
const node = context.state.nodes[0];
|
||||
if (node) {
|
||||
context.dispatch({
|
||||
type: Type.SetFocus,
|
||||
type: RovingStateActionType.SetFocus,
|
||||
payload: { node },
|
||||
});
|
||||
node?.scrollIntoView?.({
|
||||
|
||||
@@ -61,6 +61,9 @@ import { type UserProfilesStore } from "../../../stores/UserProfilesStore";
|
||||
import InviteProgressBody from "./InviteProgressBody.tsx";
|
||||
import MultiInviter, { type CompletionStates as MultiInviterCompletionStates } from "../../../utils/MultiInviter.ts";
|
||||
import { DMRoomTile } from "./invite/DMRoomTile.tsx";
|
||||
import { logErrorAndShowErrorDialog } from "../../../utils/ErrorUtils.tsx";
|
||||
import UnknownIdentityUsersWarningDialog from "./invite/UnknownIdentityUsersWarningDialog.tsx";
|
||||
import { AddressType, getAddressType } from "../../../UserAddress.ts";
|
||||
|
||||
interface Result {
|
||||
userId: string;
|
||||
@@ -161,6 +164,14 @@ interface IInviteDialogState {
|
||||
dialPadValue: string;
|
||||
currentTabId: TabId;
|
||||
|
||||
/**
|
||||
* If we tried to invite some users whose identity we don't know, we will show a warning.
|
||||
* This is the list of users. (If it is `null`, we are not showing that warning.)
|
||||
*
|
||||
* Will never be the empty list.
|
||||
*/
|
||||
unknownIdentityUsers: Member[] | null;
|
||||
|
||||
/**
|
||||
* True if we are sending the invites.
|
||||
*
|
||||
@@ -230,7 +241,8 @@ export default class InviteDialog extends React.PureComponent<Props, IInviteDial
|
||||
dialPadValue: "",
|
||||
currentTabId: TabId.UserDirectory,
|
||||
|
||||
// These two flags are used for the 'Go' button to communicate what is going on.
|
||||
unknownIdentityUsers: null,
|
||||
|
||||
busy: false,
|
||||
};
|
||||
}
|
||||
@@ -444,6 +456,21 @@ export default class InviteDialog extends React.PureComponent<Props, IInviteDial
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Start the process of actually sending invites or creating a DM.
|
||||
*
|
||||
* Called once we have shown the user all the necessary warnings.
|
||||
*/
|
||||
private async startDmOrSendInvites(): Promise<void> {
|
||||
if (this.props.kind === InviteKind.Dm) {
|
||||
await this.startDm();
|
||||
} else if (this.props.kind === InviteKind.Invite) {
|
||||
await this.inviteUsers();
|
||||
} else {
|
||||
throw new Error("Unknown InviteKind: " + this.props.kind);
|
||||
}
|
||||
}
|
||||
|
||||
private transferCall = async (): Promise<void> => {
|
||||
if (this.props.kind !== InviteKind.CallTransfer) return;
|
||||
if (this.state.currentTabId == TabId.UserDirectory) {
|
||||
@@ -1123,14 +1150,49 @@ export default class InviteDialog extends React.PureComponent<Props, IInviteDial
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle the user pressing the Go/Invite button in the "Start Chat" or "Invite users" view.
|
||||
*
|
||||
* We check if any of the users lack a known cryptographic identity, and show a warning if so.
|
||||
*/
|
||||
private async onGoButtonPressed(): Promise<void> {
|
||||
this.setBusy(true);
|
||||
|
||||
const targets = this.convertFilter();
|
||||
const unknownIdentityUsers: Member[] = [];
|
||||
const cli = MatrixClientPeg.safeGet();
|
||||
const crypto = cli.getCrypto();
|
||||
if (crypto) {
|
||||
for (const t of targets) {
|
||||
const addressType = getAddressType(t.userId);
|
||||
if (
|
||||
addressType !== AddressType.MatrixUserId ||
|
||||
!(await crypto.getUserVerificationStatus(t.userId)).known
|
||||
) {
|
||||
unknownIdentityUsers.push(t);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If we have some users with unknown identities, show the warning page.
|
||||
if (unknownIdentityUsers.length > 0) {
|
||||
logger.debug(
|
||||
"InviteDialog: Warning about users with unknown identities:",
|
||||
unknownIdentityUsers.map((u) => u.userId),
|
||||
);
|
||||
this.setState({ unknownIdentityUsers: unknownIdentityUsers, busy: false });
|
||||
} else {
|
||||
// Otherwise, transition directly to sending the relevant invites.
|
||||
await this.startDmOrSendInvites();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Render content of the "users" that is used for both invites and "start chat".
|
||||
*/
|
||||
private renderMainTab(): JSX.Element {
|
||||
let helpText;
|
||||
let buttonText;
|
||||
let goButtonFn: (() => Promise<void>) | null = null;
|
||||
|
||||
const identityServersEnabled = SettingsStore.getValue(UIFeature.IdentityServer);
|
||||
|
||||
const cli = MatrixClientPeg.safeGet();
|
||||
@@ -1167,7 +1229,6 @@ export default class InviteDialog extends React.PureComponent<Props, IInviteDial
|
||||
}
|
||||
|
||||
buttonText = _t("action|go");
|
||||
goButtonFn = this.startDm;
|
||||
} else if (this.props.kind === InviteKind.Invite) {
|
||||
const roomId = this.props.roomId;
|
||||
const room = MatrixClientPeg.get()?.getRoom(roomId);
|
||||
@@ -1211,11 +1272,14 @@ export default class InviteDialog extends React.PureComponent<Props, IInviteDial
|
||||
);
|
||||
|
||||
buttonText = _t("action|invite");
|
||||
goButtonFn = this.inviteUsers;
|
||||
} else {
|
||||
throw new Error("Unknown InviteDialog kind: " + this.props.kind);
|
||||
}
|
||||
|
||||
const onGoButtonPressed = (): void => {
|
||||
this.onGoButtonPressed().catch((e) => logErrorAndShowErrorDialog("Error processing invites", e));
|
||||
};
|
||||
|
||||
return (
|
||||
<React.Fragment>
|
||||
<p className="mx_InviteDialog_helpText">{helpText}</p>
|
||||
@@ -1223,7 +1287,7 @@ export default class InviteDialog extends React.PureComponent<Props, IInviteDial
|
||||
{this.renderEditor()}
|
||||
<AccessibleButton
|
||||
kind="primary"
|
||||
onClick={goButtonFn}
|
||||
onClick={onGoButtonPressed}
|
||||
className="mx_InviteDialog_goButton"
|
||||
disabled={this.state.busy || !this.hasSelection()}
|
||||
>
|
||||
@@ -1235,12 +1299,49 @@ export default class InviteDialog extends React.PureComponent<Props, IInviteDial
|
||||
);
|
||||
}
|
||||
|
||||
/** Callback function, which handles the user clicking "Remove" on the {@link UnknwownIdentityUsersWarningDialog}. */
|
||||
private onRemoveUnknownIdentityUsersClicked = (): void => {
|
||||
// Remove the unknown identity users, then return to the previous screen
|
||||
const newTargets: Member[] = [];
|
||||
for (const target of this.state.targets) {
|
||||
if (!this.state.unknownIdentityUsers?.find((m) => m.userId == target.userId)) {
|
||||
newTargets.push(target);
|
||||
}
|
||||
}
|
||||
this.setState({
|
||||
targets: newTargets,
|
||||
unknownIdentityUsers: null,
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Render the complete dialog, given this is not a call transfer dialog.
|
||||
*
|
||||
* See also: {@link renderCallTransferDialog}.
|
||||
*/
|
||||
private renderRegularDialog(): React.ReactNode {
|
||||
if (this.props.kind !== InviteKind.Dm && this.props.kind !== InviteKind.Invite) {
|
||||
throw new Error("Unsupported InviteDialog kind: " + this.props.kind);
|
||||
}
|
||||
|
||||
if (this.state.unknownIdentityUsers !== null) {
|
||||
return (
|
||||
<UnknownIdentityUsersWarningDialog
|
||||
onCancel={this.props.onFinished}
|
||||
onContinue={() => {
|
||||
this.setState({ unknownIdentityUsers: null });
|
||||
this.startDmOrSendInvites().catch((e) =>
|
||||
logErrorAndShowErrorDialog("Error processing invites", e),
|
||||
);
|
||||
}}
|
||||
onRemove={this.onRemoveUnknownIdentityUsersClicked}
|
||||
screenName={this.screenName}
|
||||
kind={this.props.kind}
|
||||
users={this.state.unknownIdentityUsers}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
let title;
|
||||
if (this.props.kind === InviteKind.Dm) {
|
||||
title = _t("space|add_existing_room_space|dm_heading");
|
||||
|
||||
@@ -9,7 +9,6 @@ import React, { type ChangeEvent, useContext, useEffect, useMemo, useState } fro
|
||||
import { Pill } from "@element-hq/web-shared-components";
|
||||
import { MatrixEvent, type IContent, RoomStickyEventsEvent } from "matrix-js-sdk/src/matrix";
|
||||
import { Alert, Form, SettingsToggleInput } from "@vector-im/compound-web";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
|
||||
import BaseTool, { DevtoolsContext, type IDevtoolsProps } from "./BaseTool.tsx";
|
||||
import { _t, _td, UserFriendlyError } from "../../../../languageHandler.tsx";
|
||||
@@ -330,7 +329,7 @@ export const StickyEventEditor: React.FC<IEditorProps> = ({ mxEvent, onBack }) =
|
||||
const defaultContent = mxEvent
|
||||
? stringify(mxEvent.getContent())
|
||||
: stringify({
|
||||
msc4354_sticky_key: uuidv4(),
|
||||
msc4354_sticky_key: window.crypto.randomUUID(),
|
||||
});
|
||||
return <EventEditor fieldDefs={fields} defaultContent={defaultContent} onSend={onSend} onBack={onBack} />;
|
||||
};
|
||||
|
||||
@@ -19,8 +19,8 @@ import { Icon as EmailPillAvatarIcon } from "../../../../../res/img/icon-email-p
|
||||
interface IDMRoomTileProps {
|
||||
member: Member;
|
||||
lastActiveTs?: number;
|
||||
onToggle(member: Member): void;
|
||||
isSelected: boolean;
|
||||
onToggle?(member: Member): void;
|
||||
isSelected?: boolean;
|
||||
}
|
||||
|
||||
/** A tile representing a single user in the "suggestions"/"recents" section of the invite dialog. */
|
||||
@@ -30,7 +30,7 @@ export class DMRoomTile extends React.PureComponent<IDMRoomTileProps> {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
this.props.onToggle(this.props.member);
|
||||
this.props.onToggle?.(this.props.member);
|
||||
};
|
||||
|
||||
public render(): React.ReactNode {
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
/*
|
||||
Copyright 2026 Element Creations Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import React, { type JSX, useCallback } from "react";
|
||||
import { CheckIcon, CloseIcon, UserAddSolidIcon } from "@vector-im/compound-design-tokens/assets/web/icons";
|
||||
import { Button, PageHeader } from "@vector-im/compound-web";
|
||||
|
||||
import { InviteKind } from "../InviteDialogTypes.ts";
|
||||
import { type Member } from "../../../../utils/direct-messages.ts";
|
||||
import BaseDialog from "../BaseDialog.tsx";
|
||||
import { type ScreenName } from "../../../../PosthogTrackers.ts";
|
||||
import { DMRoomTile } from "./DMRoomTile.tsx";
|
||||
import { _t } from "../../../../languageHandler.tsx";
|
||||
|
||||
interface Props {
|
||||
/** Callback that will be called when the 'Continue' or 'Invite' button is clicked. */
|
||||
onContinue: () => void;
|
||||
|
||||
/** Callback that will be called when the 'Cancel' button is clicked. Unused unless {@link kind} is {@link InviteKind.Dm}. */
|
||||
onCancel: () => void;
|
||||
|
||||
/** Callback that will be called when the 'Remove' button is clicked. Unused unless {@link kind} is {@link InviteKind.Invite}. */
|
||||
onRemove: () => void;
|
||||
|
||||
/** Optional Posthog ScreenName to supply during the lifetime of this dialog. */
|
||||
screenName: ScreenName | undefined;
|
||||
|
||||
/** The type of invite dialog: whether we are starting a new DM, or inviting users to an existing room */
|
||||
kind: InviteKind.Dm | InviteKind.Invite;
|
||||
|
||||
/** The users whose identities we don't know */
|
||||
users: Member[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Part of the invite dialog: a screen that appears if there are any users whose cryptographic identity we don't know,
|
||||
* to confirm that they are the right users.
|
||||
*
|
||||
* Figma: https://www.figma.com/design/chAcaQAluTuRg6BsG4Npc0/-3163--Inviting-Unknown-People?node-id=150-17719&t=ISAikbnj97LM4NwT-0
|
||||
*/
|
||||
const UnknownIdentityUsersWarningDialog: React.FC<Props> = (props) => {
|
||||
const userListItem = useCallback((u: Member) => <DMRoomTile member={u} key={u.userId} />, []);
|
||||
|
||||
let title: string;
|
||||
let headerText: string;
|
||||
let buttons: JSX.Element;
|
||||
|
||||
switch (props.kind) {
|
||||
case InviteKind.Invite:
|
||||
title = _t("invite|confirm_unknown_users|invite_title");
|
||||
headerText = _t("invite|confirm_unknown_users|invite_subtitle");
|
||||
buttons = <InviteButtons onInvite={props.onContinue} onRemove={props.onRemove} />;
|
||||
break;
|
||||
|
||||
case InviteKind.Dm:
|
||||
title =
|
||||
props.users.length == 1
|
||||
? _t("invite|confirm_unknown_users|start_chat_title_one_user")
|
||||
: _t("invite|confirm_unknown_users|start_chat_title_multiple_users");
|
||||
|
||||
headerText =
|
||||
props.users.length == 1
|
||||
? _t("invite|confirm_unknown_users|start_chat_subtitle_one_user")
|
||||
: _t("invite|confirm_unknown_users|start_chat_subtitle_multiple_users");
|
||||
|
||||
buttons = <DmButtons onCancel={props.onCancel} onContinue={props.onContinue} />;
|
||||
break;
|
||||
}
|
||||
|
||||
return (
|
||||
<BaseDialog
|
||||
onFinished={props.onCancel}
|
||||
className="mx_UnknownIdentityUsersWarningDialog"
|
||||
screenName={props.screenName}
|
||||
>
|
||||
<div className="mx_UnknownIdentityUsersWarningDialog_headerContainer">
|
||||
<PageHeader Icon={UserAddSolidIcon} heading={title}>
|
||||
<p>{headerText}</p>
|
||||
</PageHeader>
|
||||
</div>
|
||||
|
||||
<ul className="mx_UnknownIdentityUsersWarningDialog_userList" data-testid="userlist">
|
||||
{props.users.map(userListItem)}
|
||||
</ul>
|
||||
|
||||
<div className="mx_UnknownIdentityUsersWarningDialog_buttons">{buttons}</div>
|
||||
</BaseDialog>
|
||||
);
|
||||
};
|
||||
|
||||
const DmButtons: React.FC<{ onContinue: () => void; onCancel: () => void }> = (props) => {
|
||||
return (
|
||||
<>
|
||||
<Button size="lg" kind="secondary" onClick={props.onCancel}>
|
||||
{_t("action|cancel")}
|
||||
</Button>
|
||||
<Button size="lg" kind="primary" onClick={props.onContinue}>
|
||||
{_t("action|continue")}
|
||||
</Button>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const InviteButtons: React.FC<{ onInvite: () => void; onRemove: () => void }> = (props) => {
|
||||
return (
|
||||
<>
|
||||
<Button size="lg" kind="secondary" onClick={props.onRemove} Icon={CloseIcon}>
|
||||
{_t("action|remove")}
|
||||
</Button>
|
||||
<Button size="lg" kind="primary" onClick={props.onInvite} Icon={CheckIcon}>
|
||||
{_t("action|invite")}
|
||||
</Button>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default UnknownIdentityUsersWarningDialog;
|
||||
@@ -44,10 +44,10 @@ import {
|
||||
|
||||
import { KeyBindingAction } from "../../../../accessibility/KeyboardShortcuts";
|
||||
import {
|
||||
findSiblingElement,
|
||||
findNextSiblingElement,
|
||||
RovingStateActionType,
|
||||
RovingTabIndexContext,
|
||||
RovingTabIndexProvider,
|
||||
Type,
|
||||
} from "../../../../accessibility/RovingTabIndex";
|
||||
import { mediaFromMxc } from "../../../../customisations/Media";
|
||||
import { Action } from "../../../../dispatcher/actions";
|
||||
@@ -537,7 +537,7 @@ const SpotlightDialog: React.FC<IProps> = ({ initialText = "", initialFilter = n
|
||||
const node = rovingContext.state.nodes[0];
|
||||
if (node) {
|
||||
rovingContext.dispatch({
|
||||
type: Type.SetFocus,
|
||||
type: RovingStateActionType.SetFocus,
|
||||
payload: { node },
|
||||
});
|
||||
node?.scrollIntoView?.({
|
||||
@@ -1181,7 +1181,10 @@ const SpotlightDialog: React.FC<IProps> = ({ initialText = "", initialFilter = n
|
||||
}
|
||||
|
||||
const idx = nodes.indexOf(rovingContext.state.activeNode);
|
||||
node = findSiblingElement(nodes, idx + (accessibilityAction === KeyBindingAction.ArrowUp ? -1 : 1));
|
||||
node = findNextSiblingElement(
|
||||
nodes,
|
||||
idx + (accessibilityAction === KeyBindingAction.ArrowUp ? -1 : 1),
|
||||
);
|
||||
}
|
||||
break;
|
||||
|
||||
@@ -1201,7 +1204,7 @@ const SpotlightDialog: React.FC<IProps> = ({ initialText = "", initialFilter = n
|
||||
|
||||
const nodes = rovingContext.state.nodes.filter(nodeIsForRecentlyViewed);
|
||||
const idx = nodes.indexOf(rovingContext.state.activeNode);
|
||||
node = findSiblingElement(
|
||||
node = findNextSiblingElement(
|
||||
nodes,
|
||||
idx + (accessibilityAction === KeyBindingAction.ArrowLeft ? -1 : 1),
|
||||
);
|
||||
@@ -1211,7 +1214,7 @@ const SpotlightDialog: React.FC<IProps> = ({ initialText = "", initialFilter = n
|
||||
|
||||
if (node) {
|
||||
rovingContext.dispatch({
|
||||
type: Type.SetFocus,
|
||||
type: RovingStateActionType.SetFocus,
|
||||
payload: { node },
|
||||
});
|
||||
node?.scrollIntoView({
|
||||
|
||||
@@ -25,7 +25,7 @@ import {
|
||||
type IAction as RovingAction,
|
||||
type IState as RovingState,
|
||||
RovingTabIndexProvider,
|
||||
Type,
|
||||
RovingStateActionType,
|
||||
} from "../../../accessibility/RovingTabIndex";
|
||||
import { Key } from "../../../Keyboard";
|
||||
import { type ButtonEvent } from "../elements/AccessibleButton";
|
||||
@@ -187,7 +187,7 @@ class EmojiPicker extends React.Component<IProps, IState> {
|
||||
focusNode?.focus();
|
||||
}
|
||||
dispatch({
|
||||
type: Type.SetFocus,
|
||||
type: RovingStateActionType.SetFocus,
|
||||
payload: { node: focusNode },
|
||||
});
|
||||
|
||||
@@ -212,7 +212,7 @@ class EmojiPicker extends React.Component<IProps, IState> {
|
||||
// Reset to first emoji when showing highlight for the first time (or after it was hidden)
|
||||
if (state.nodes.length > 0) {
|
||||
dispatch({
|
||||
type: Type.SetFocus,
|
||||
type: RovingStateActionType.SetFocus,
|
||||
payload: { node: state.nodes[0] },
|
||||
});
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ Please see LICENSE files in the repository root for full details.
|
||||
|
||||
import React, { type JSX, useState } from "react";
|
||||
import classNames from "classnames";
|
||||
import { type DOMNode, Element as ParserElement, domToReact } from "html-react-parser";
|
||||
import { type DOMNode, type Element as ParserElement, domToReact } from "html-react-parser";
|
||||
import { textContent, getInnerHTML } from "domutils";
|
||||
import { CollapseIcon, CopyIcon, ExpandIcon } from "@vector-im/compound-design-tokens/assets/web/icons";
|
||||
|
||||
@@ -113,7 +113,7 @@ const CodeBlock: React.FC<Props> = ({ preNode }) => {
|
||||
let content = domToReact(preNode.children as DOMNode[]);
|
||||
|
||||
// Add code element if it's missing since we depend on it
|
||||
if (!preNode.children.some((child) => child instanceof ParserElement && child.tagName.toUpperCase() === "CODE")) {
|
||||
if (!preNode.children.some((child) => child.type === "tag" && child.tagName.toUpperCase() === "CODE")) {
|
||||
content = <code>{content}</code>;
|
||||
}
|
||||
|
||||
|
||||
@@ -42,7 +42,7 @@
|
||||
"copy_link": "Copy link",
|
||||
"create": "Create",
|
||||
"create_a_room": "Create a room",
|
||||
"create_account": "Create Account",
|
||||
"create_account": "Create account",
|
||||
"decline": "Decline",
|
||||
"decline_and_block": "Decline and block",
|
||||
"decline_invite": "Decline invite",
|
||||
@@ -1368,6 +1368,14 @@
|
||||
"impossible_dialog_title": "Integrations not allowed"
|
||||
},
|
||||
"invite": {
|
||||
"confirm_unknown_users": {
|
||||
"invite_subtitle": "You currently don't have any chats with these contacts. Confirm inviting them to this room before continuing.",
|
||||
"invite_title": "Invite new contacts to this room?",
|
||||
"start_chat_subtitle_multiple_users": "You currently don't have any chats with these people. Confirm inviting them before continuing.",
|
||||
"start_chat_subtitle_one_user": "You currently don't have any chats with this person. Confirm inviting them before continuing.",
|
||||
"start_chat_title_multiple_users": "Start a chat with these new contacts?",
|
||||
"start_chat_title_one_user": "Start a chat with this new contact?"
|
||||
},
|
||||
"email_caption": "Invite by email",
|
||||
"email_limit_one": "Invites by email can only be sent one at a time",
|
||||
"email_use_default_is": "Use an identity server to invite by email. <default>Use the default (%(defaultIdentityServerName)s)</default> or manage in <settings>Settings</settings>.",
|
||||
@@ -1816,7 +1824,6 @@
|
||||
"restricted": "Restricted"
|
||||
},
|
||||
"powered_by_matrix": "Powered by Matrix",
|
||||
"powered_by_matrix_with_logo": "Decentralised, encrypted chat & collaboration powered by $matrixLogo",
|
||||
"presence": {
|
||||
"away": "Away",
|
||||
"busy": "Busy",
|
||||
@@ -3981,7 +3988,11 @@
|
||||
"you_are_presenting": "You are presenting"
|
||||
},
|
||||
"web_default_device_name": "%(appName)s: %(browserName)s on %(osName)s",
|
||||
"welcome_to_element": "Welcome to Element",
|
||||
"welcome": {
|
||||
"tagline_element": "Supercharged for speed and simplicity.",
|
||||
"title_element": "Be in your element",
|
||||
"title_generic": "Welcome to %(brand)s"
|
||||
},
|
||||
"widget": {
|
||||
"added_by": "Widget added by",
|
||||
"capabilities_dialog": {
|
||||
|
||||
@@ -680,6 +680,12 @@
|
||||
"unfederated_label_default_on": "如果此房间将用于与拥有自己主服务器的外部团队协作,你可以禁用此功能。此设置以后无法更改。",
|
||||
"unsupported_version": "服务器不支持指定的房间版本。"
|
||||
},
|
||||
"create_section_dialog": {
|
||||
"create_section": "创建区域",
|
||||
"description": "区域仅对你可见",
|
||||
"label": "区域名称",
|
||||
"title": "创建区域"
|
||||
},
|
||||
"create_space": {
|
||||
"add_details_prompt": "添加一些信息以便人们识别。",
|
||||
"add_details_prompt_2": "你可以随时更改。",
|
||||
@@ -3077,7 +3083,7 @@
|
||||
"category_messages": "消息",
|
||||
"category_other": "其它",
|
||||
"command_error": "指令出错",
|
||||
"converttodm": "转换房间到私聊",
|
||||
"converttodm": "转换房间为私聊",
|
||||
"converttoroom": "转换私聊到房间",
|
||||
"could_not_find_room": "无法找到房间",
|
||||
"deop": "通过指定的 ID 降权用户",
|
||||
|
||||
@@ -6,7 +6,7 @@ Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { type JSX } from "react";
|
||||
import { type DOMNode, Element, type HTMLReactParserOptions, type Text } from "html-react-parser";
|
||||
import { type DOMNode, type Element, type HTMLReactParserOptions, type Text } from "html-react-parser";
|
||||
import { type MatrixEvent, type Room } from "matrix-js-sdk/src/matrix";
|
||||
|
||||
/**
|
||||
@@ -89,7 +89,7 @@ export const combineRenderers =
|
||||
if (result) return result;
|
||||
}
|
||||
}
|
||||
if (node instanceof Element) {
|
||||
if (node.type === "tag") {
|
||||
const tagName = node.tagName.toLowerCase() as keyof HTMLElementTagNameMap;
|
||||
for (const replacer of renderers) {
|
||||
const result = replacer[tagName]?.(node, parametersWithReplace, index);
|
||||
|
||||
@@ -62,6 +62,8 @@ export enum RoomListStoreV3Event {
|
||||
ListsLoaded = "lists_loaded",
|
||||
/** Fired when a new section is created in the room list. */
|
||||
SectionCreated = "section_created",
|
||||
/** Fired when a room's tags change. */
|
||||
RoomTagged = "room_tagged",
|
||||
}
|
||||
|
||||
// The result object for returning rooms from the store
|
||||
@@ -93,6 +95,7 @@ export const CHATS_TAG = "chats";
|
||||
export const LISTS_UPDATE_EVENT = RoomListStoreV3Event.ListsUpdate;
|
||||
export const LISTS_LOADED_EVENT = RoomListStoreV3Event.ListsLoaded;
|
||||
export const SECTION_CREATED_EVENT = RoomListStoreV3Event.SectionCreated;
|
||||
export const ROOM_TAGGED_EVENT = RoomListStoreV3Event.RoomTagged;
|
||||
|
||||
/**
|
||||
* This store allows for fast retrieval of the room list in a sorted and filtered manner.
|
||||
@@ -243,6 +246,7 @@ export class RoomListStoreV3Class extends AsyncStoreWithClient<EmptyObject> {
|
||||
case "MatrixActions.Room.tags": {
|
||||
const room = payload.room;
|
||||
this.addRoomAndEmit(room);
|
||||
this.emit(ROOM_TAGGED_EVENT);
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -485,13 +489,19 @@ export class RoomListStoreV3Class extends AsyncStoreWithClient<EmptyObject> {
|
||||
|
||||
/**
|
||||
* Create a new section.
|
||||
* Emits {@link SECTION_CREATED_EVENT} and {@link LISTS_UPDATE_EVENT} if the section was successfully created.
|
||||
* Emits {@link SECTION_CREATED_EVENT} if the section was successfully created.
|
||||
*/
|
||||
public async createSection(): Promise<void> {
|
||||
const sectionIsCreated = await createSection();
|
||||
if (!sectionIsCreated) return;
|
||||
this.emit(SECTION_CREATED_EVENT);
|
||||
this.scheduleEmit();
|
||||
const tag = await createSection();
|
||||
if (!tag) return;
|
||||
this.emit(SECTION_CREATED_EVENT, tag);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the ordered section tags.
|
||||
*/
|
||||
public get orderedSectionTags(): string[] {
|
||||
return this.sortedTags;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -5,8 +5,6 @@
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
|
||||
import { SettingLevel } from "../../settings/SettingLevel";
|
||||
import SettingsStore from "../../settings/SettingsStore";
|
||||
import Modal from "../../Modal";
|
||||
@@ -14,6 +12,20 @@ import { CreateSectionDialog } from "../../components/views/dialogs/CreateSectio
|
||||
|
||||
type Tag = string;
|
||||
|
||||
/**
|
||||
* Prefix for custom section tags.
|
||||
*/
|
||||
export const CUSTOM_SECTION_TAG_PREFIX = "element.io.section.";
|
||||
|
||||
/**
|
||||
* Checks if a given tag is a custom section tag.
|
||||
* @param tag - The tag to check.
|
||||
* @returns True if the tag is a custom section tag, false otherwise.
|
||||
*/
|
||||
export function isCustomSectionTag(tag: string): boolean {
|
||||
return tag.startsWith(CUSTOM_SECTION_TAG_PREFIX);
|
||||
}
|
||||
|
||||
/**
|
||||
* Structure of the custom section stored in the settings. The tag is used as a unique identifier for the section, and the name is given by the user.
|
||||
*/
|
||||
@@ -35,15 +47,15 @@ export type OrderedCustomSections = Tag[];
|
||||
* Creates a new custom section by showing a dialog to the user to enter the section name.
|
||||
* If the user confirms, it generates a unique tag for the section, saves the section data in the settings, and updates the ordered list of sections.
|
||||
*
|
||||
* @return A promise that resolves to true if the section was created, or false if the user cancelled the creation or if there was an error.
|
||||
* @return A promise that resolves to the new section tag if created, or undefined if cancelled.
|
||||
*/
|
||||
export async function createSection(): Promise<boolean> {
|
||||
export async function createSection(): Promise<string | undefined> {
|
||||
const modal = Modal.createDialog(CreateSectionDialog);
|
||||
|
||||
const [shouldCreateSection, sectionName] = await modal.finished;
|
||||
if (!shouldCreateSection || !sectionName) return false;
|
||||
if (!shouldCreateSection || !sectionName) return undefined;
|
||||
|
||||
const tag = `element.io.section.${uuidv4()}`;
|
||||
const tag = `${CUSTOM_SECTION_TAG_PREFIX}${window.crypto.randomUUID()}`;
|
||||
const newSection: CustomSection = { tag, name: sectionName };
|
||||
|
||||
// Save the new section data
|
||||
@@ -55,5 +67,5 @@ export async function createSection(): Promise<boolean> {
|
||||
const orderedSections = SettingsStore.getValue("RoomList.OrderedCustomSections") || [];
|
||||
orderedSections.push(tag);
|
||||
await SettingsStore.setValue("RoomList.OrderedCustomSections", null, SettingLevel.ACCOUNT, orderedSections);
|
||||
return true;
|
||||
return tag;
|
||||
}
|
||||
|
||||
@@ -7,7 +7,6 @@ Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { logger } from "matrix-js-sdk/src/logger";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
|
||||
/*
|
||||
* Functionality for checking that only one instance is running at once
|
||||
@@ -107,7 +106,7 @@ export function checkSessionLockFree(): boolean {
|
||||
*/
|
||||
export async function getSessionLock(onNewInstance: () => Promise<void>): Promise<boolean> {
|
||||
/** unique ID for this session */
|
||||
const sessionIdentifier = uuidv4();
|
||||
const sessionIdentifier = window.crypto.randomUUID();
|
||||
|
||||
const prefixedLogger = logger.getChild(`getSessionLock[${sessionIdentifier}]`);
|
||||
|
||||
|
||||
@@ -13,20 +13,29 @@ import { DefaultTagID, type TagID } from "../../stores/room-list-v3/skip-list/ta
|
||||
import RoomListActions from "../../actions/RoomListActions";
|
||||
import dis from "../../dispatcher/dispatcher";
|
||||
import { getTagsForRoom } from "./getTagsForRoom";
|
||||
import { isCustomSectionTag } from "../../stores/room-list-v3/section";
|
||||
|
||||
/**
|
||||
* Toggle tag for a given room
|
||||
* Toggle tag for a given room.
|
||||
* A room can only be in one section: either a custom section, Favourite, or LowPriority.
|
||||
* Applying any of these will atomically replace the current section tag.
|
||||
* @param room The room to tag
|
||||
* @param tagId The tag to invert
|
||||
*/
|
||||
export function tagRoom(room: Room, tagId: TagID): void {
|
||||
if (tagId === DefaultTagID.Favourite || tagId === DefaultTagID.LowPriority) {
|
||||
const inverseTag = tagId === DefaultTagID.Favourite ? DefaultTagID.LowPriority : DefaultTagID.Favourite;
|
||||
const isApplied = getTagsForRoom(room).includes(tagId);
|
||||
const removeTag = isApplied ? tagId : inverseTag;
|
||||
const addTag = isApplied ? null : tagId;
|
||||
dis.dispatch(RoomListActions.tagRoom(room.client, room, removeTag, addTag));
|
||||
} else {
|
||||
if (tagId !== DefaultTagID.Favourite && tagId !== DefaultTagID.LowPriority && !isCustomSectionTag(tagId)) {
|
||||
logger.warn(`Unexpected tag ${tagId} applied to ${room.roomId}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Find the section tag currently applied (Fav, LowPriority, or custom) — at most one exists
|
||||
const currentSectionTag =
|
||||
getTagsForRoom(room).find(
|
||||
(t) => t === DefaultTagID.Favourite || t === DefaultTagID.LowPriority || isCustomSectionTag(t),
|
||||
) ?? null;
|
||||
|
||||
const isApplied = currentSectionTag === tagId;
|
||||
const removeTag = currentSectionTag;
|
||||
const addTag = isApplied ? null : tagId;
|
||||
dis.dispatch(RoomListActions.tagRoom(room.client, room, removeTag, addTag));
|
||||
}
|
||||
|
||||
@@ -34,8 +34,10 @@ export interface UrlPreviewGroupViewModelProps {
|
||||
}
|
||||
|
||||
export const MAX_PREVIEWS_WHEN_LIMITED = 2;
|
||||
export const PREVIEW_WIDTH = 100;
|
||||
export const PREVIEW_HEIGHT = 100;
|
||||
export const PREVIEW_WIDTH_PX = 478;
|
||||
export const PREVIEW_HEIGHT_PX = 200;
|
||||
export const MIN_PREVIEW_PX = 96;
|
||||
export const MIN_IMAGE_SIZE_BYTES = 8192;
|
||||
|
||||
export enum PreviewVisibility {
|
||||
/**
|
||||
@@ -100,21 +102,26 @@ export class UrlPreviewGroupViewModel
|
||||
typeof response["og:description"] === "string" && response["og:description"].trim()
|
||||
? response["og:description"].trim()
|
||||
: undefined;
|
||||
let siteName =
|
||||
const siteName =
|
||||
typeof response["og:site_name"] === "string" && response["og:site_name"].trim()
|
||||
? response["og:site_name"].trim()
|
||||
: undefined;
|
||||
: new URL(link).hostname;
|
||||
|
||||
// If there is no title, use the description as the title.
|
||||
if (!title && description) {
|
||||
title = description;
|
||||
description = undefined;
|
||||
} else if (!title && siteName) {
|
||||
title = siteName;
|
||||
siteName = undefined;
|
||||
} else if (!title) {
|
||||
title = link;
|
||||
}
|
||||
|
||||
// If the description matches the site name, don't bother with a description.
|
||||
if (description && description.toLowerCase() === siteName.toLowerCase()) {
|
||||
description = undefined;
|
||||
}
|
||||
|
||||
return {
|
||||
title,
|
||||
description: description && decode(description),
|
||||
@@ -122,6 +129,50 @@ export class UrlPreviewGroupViewModel
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate the best possible author from an opengraph response.
|
||||
* @param response The opengraph response
|
||||
* @returns The author value, or undefined if no valid author could be found.
|
||||
*/
|
||||
private static getAuthorFromResponse(response: IPreviewUrlResponse): UrlPreview["author"] {
|
||||
let calculatedAuthor: string | undefined;
|
||||
if (response["og:type"] === "article") {
|
||||
if (typeof response["article:author"] === "string" && response["article:author"]) {
|
||||
calculatedAuthor = response["article:author"];
|
||||
}
|
||||
// Otherwise fall through to check the profile.
|
||||
}
|
||||
if (typeof response["profile:username"] === "string" && response["profile:username"]) {
|
||||
calculatedAuthor = response["profile:username"];
|
||||
}
|
||||
if (calculatedAuthor && URL.canParse(calculatedAuthor)) {
|
||||
// Some sites return URLs as authors which doesn't look good in Element, so discard it.
|
||||
return;
|
||||
}
|
||||
return calculatedAuthor;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate whether the provided image from the preview response is an full size preview or
|
||||
* a site icon.
|
||||
* @returns `true` if the image should be used as a preview, otherwise `false`
|
||||
*/
|
||||
private static isImagePreview(width?: number, height?: number, bytes?: number): boolean {
|
||||
// We can't currently distinguish from a preview image and a favicon. Neither OpenGraph nor Matrix
|
||||
// have a clear distinction, so we're using a heuristic here to check the dimensions & size of the file and
|
||||
// deciding whether to render it as a full preview or icon.
|
||||
if (width && width < MIN_PREVIEW_PX) {
|
||||
return false;
|
||||
}
|
||||
if (height && height < MIN_PREVIEW_PX) {
|
||||
return false;
|
||||
}
|
||||
if (bytes && bytes < MIN_IMAGE_SIZE_BYTES) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine if an anchor element can be rendered into a preview.
|
||||
* If it can, return the value of `href`
|
||||
@@ -278,6 +329,7 @@ export class UrlPreviewGroupViewModel
|
||||
}
|
||||
|
||||
const { title, description, siteName } = UrlPreviewGroupViewModel.getBaseMetadataFromResponse(preview, link);
|
||||
const author = UrlPreviewGroupViewModel.getAuthorFromResponse(preview);
|
||||
const hasImage = preview["og:image"] && typeof preview?.["og:image"] === "string";
|
||||
// Ensure we have something relevant to render.
|
||||
// The title must not just be the link, or we must have an image.
|
||||
@@ -285,31 +337,46 @@ export class UrlPreviewGroupViewModel
|
||||
return null;
|
||||
}
|
||||
let image: UrlPreview["image"];
|
||||
let siteIcon: string | undefined;
|
||||
if (typeof preview["og:image"] === "string" && this.visibility > PreviewVisibility.MediaHidden) {
|
||||
const media = mediaFromMxc(preview["og:image"], this.client);
|
||||
const declaredHeight = UrlPreviewGroupViewModel.getNumberFromOpenGraph(preview["og:image:height"]);
|
||||
const declaredWidth = UrlPreviewGroupViewModel.getNumberFromOpenGraph(preview["og:image:width"]);
|
||||
const width = Math.min(declaredWidth ?? PREVIEW_WIDTH, PREVIEW_WIDTH);
|
||||
const height = thumbHeight(width, declaredHeight, PREVIEW_WIDTH, PREVIEW_WIDTH) ?? PREVIEW_WIDTH;
|
||||
const thumb = media.getThumbnailOfSourceHttp(PREVIEW_WIDTH, PREVIEW_HEIGHT, "scale");
|
||||
// No thumb, no preview.
|
||||
if (thumb) {
|
||||
image = {
|
||||
imageThumb: thumb,
|
||||
imageFull: media.srcHttp ?? thumb,
|
||||
width,
|
||||
height,
|
||||
fileSize: UrlPreviewGroupViewModel.getNumberFromOpenGraph(preview["matrix:image:size"]),
|
||||
};
|
||||
const imageSize = UrlPreviewGroupViewModel.getNumberFromOpenGraph(preview["matrix:image:size"]);
|
||||
const alt = typeof preview["og:image:alt"] === "string" ? preview["og:image:alt"] : undefined;
|
||||
|
||||
const isImagePreview = UrlPreviewGroupViewModel.isImagePreview(declaredWidth, declaredHeight, imageSize);
|
||||
if (isImagePreview) {
|
||||
const width = Math.min(declaredWidth ?? PREVIEW_WIDTH_PX, PREVIEW_WIDTH_PX);
|
||||
const height =
|
||||
thumbHeight(width, declaredHeight, PREVIEW_WIDTH_PX, PREVIEW_WIDTH_PX) ?? PREVIEW_WIDTH_PX;
|
||||
const thumb = media.getThumbnailOfSourceHttp(PREVIEW_WIDTH_PX, PREVIEW_HEIGHT_PX, "scale");
|
||||
const playable = !!preview["og:video"] || !!preview["og:video:type"] || !!preview["og:audio"];
|
||||
// No thumb, no preview.
|
||||
if (thumb) {
|
||||
image = {
|
||||
imageThumb: thumb,
|
||||
imageFull: media.srcHttp ?? thumb,
|
||||
width,
|
||||
height,
|
||||
fileSize: UrlPreviewGroupViewModel.getNumberFromOpenGraph(preview["matrix:image:size"]),
|
||||
alt,
|
||||
playable,
|
||||
};
|
||||
}
|
||||
} else if (media.srcHttp) {
|
||||
siteIcon = media.srcHttp;
|
||||
}
|
||||
}
|
||||
|
||||
const result = {
|
||||
link,
|
||||
title,
|
||||
author,
|
||||
description,
|
||||
siteName,
|
||||
showTooltipOnLink: link !== title && PlatformPeg.get()?.needsUrlTooltips(),
|
||||
siteIcon,
|
||||
showTooltipOnLink: !!(link !== title && PlatformPeg.get()?.needsUrlTooltips()),
|
||||
image,
|
||||
} satisfies UrlPreview;
|
||||
this.previewCache.set(link, result);
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
RoomNotifState,
|
||||
type RoomListItemViewSnapshot,
|
||||
type RoomListItemViewActions,
|
||||
type Section,
|
||||
} from "@element-hq/web-shared-components";
|
||||
import { RoomEvent } from "matrix-js-sdk/src/matrix";
|
||||
import { CallType } from "matrix-js-sdk/src/webrtc/call";
|
||||
@@ -37,7 +38,8 @@ import { Action } from "../../dispatcher/actions";
|
||||
import type { ViewRoomPayload } from "../../dispatcher/payloads/ViewRoomPayload";
|
||||
import PosthogTrackers from "../../PosthogTrackers";
|
||||
import { type Call, CallEvent } from "../../models/Call";
|
||||
import RoomListStoreV3 from "../../stores/room-list-v3/RoomListStoreV3";
|
||||
import RoomListStoreV3, { CHATS_TAG } from "../../stores/room-list-v3/RoomListStoreV3";
|
||||
import { _t } from "../../languageHandler";
|
||||
|
||||
interface RoomItemProps {
|
||||
room: Room;
|
||||
@@ -96,6 +98,13 @@ export class RoomListItemViewModel
|
||||
this.disposables.trackListener(props.room, RoomEvent.Name, this.onRoomChanged);
|
||||
this.disposables.trackListener(props.room, RoomEvent.Tags, this.onRoomChanged);
|
||||
|
||||
const orderSectionsRef = SettingsStore.watchSetting("RoomList.OrderedCustomSections", null, () =>
|
||||
this.onOrderedCustomSectionsChange(),
|
||||
);
|
||||
this.disposables.track(() => {
|
||||
SettingsStore.unwatchSetting(orderSectionsRef);
|
||||
});
|
||||
|
||||
// Load message preview asynchronously (sync data is already complete)
|
||||
void this.loadAndSetMessagePreview();
|
||||
}
|
||||
@@ -181,6 +190,7 @@ export class RoomListItemViewModel
|
||||
this.snapshot.merge({
|
||||
...newItem,
|
||||
notification: keepIfSame(this.snapshot.current.notification, newItem.notification),
|
||||
sections: keepIfSame(this.snapshot.current.sections, newItem.sections),
|
||||
// Preserve message preview - it's managed separately by loadAndSetMessagePreview
|
||||
messagePreview: this.snapshot.current.messagePreview,
|
||||
});
|
||||
@@ -279,6 +289,9 @@ export class RoomListItemViewModel
|
||||
|
||||
const canMoveToSection = SettingsStore.getValue("feature_room_list_sections");
|
||||
|
||||
// Build sections list for the "Move to section" submenu
|
||||
const sections: Section[] = canMoveToSection ? RoomListItemViewModel.buildSections(roomTags) : [];
|
||||
|
||||
return {
|
||||
id: room.roomId,
|
||||
room,
|
||||
@@ -307,6 +320,7 @@ export class RoomListItemViewModel
|
||||
canMarkAsUnread,
|
||||
roomNotifState,
|
||||
canMoveToSection,
|
||||
sections,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -389,4 +403,42 @@ export class RoomListItemViewModel
|
||||
public onCreateSection = (): void => {
|
||||
RoomListStoreV3.instance.createSection();
|
||||
};
|
||||
|
||||
public onToggleSection = (tag: string): void => {
|
||||
tagRoom(this.props.room, tag);
|
||||
};
|
||||
|
||||
private onOrderedCustomSectionsChange = (): void => {
|
||||
// Rebuild sections list to reflect new order
|
||||
const sections = RoomListItemViewModel.buildSections(this.props.room.tags);
|
||||
this.snapshot.merge({ sections: keepIfSame(this.snapshot.current.sections, sections) });
|
||||
};
|
||||
|
||||
/**
|
||||
* Build the list of available sections for the "Move to section" submenu.
|
||||
* Order follows the canonical section order from RoomListStoreV3.
|
||||
*/
|
||||
private static buildSections(roomTags: Room["tags"]): Section[] {
|
||||
const customSectionData = SettingsStore.getValue("RoomList.CustomSectionData") || {};
|
||||
|
||||
return (
|
||||
RoomListStoreV3.instance.orderedSectionTags
|
||||
// Exclude the Chats section because the user toggle the other sections to move rooms in and out of the Chats section.
|
||||
.filter((tag) => tag !== CHATS_TAG)
|
||||
.map((tag) => ({
|
||||
tag,
|
||||
name: RoomListItemViewModel.getSectionName(tag, customSectionData),
|
||||
isSelected: Boolean(roomTags[tag]),
|
||||
}))
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the display name for a section based on its tag.
|
||||
*/
|
||||
private static getSectionName(tag: string, customSectionData: Record<string, { name: string }>): string {
|
||||
if (tag === DefaultTagID.Favourite) return _t("room_list|section|favourites");
|
||||
if (tag === DefaultTagID.LowPriority) return _t("room_list|section|low_priority");
|
||||
return customSectionData[tag]?.name || tag;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
type RoomListViewState,
|
||||
type RoomListSection,
|
||||
_t,
|
||||
type ToastType,
|
||||
} from "@element-hq/web-shared-components";
|
||||
import { type MatrixClient, type Room } from "matrix-js-sdk/src/matrix";
|
||||
|
||||
@@ -153,7 +154,14 @@ export class RoomListViewModel
|
||||
this.disposables.trackListener(
|
||||
RoomListStoreV3.instance,
|
||||
RoomListStoreV3Event.SectionCreated as any,
|
||||
this.onSectionCreated,
|
||||
this.onSectionCreated as (...args: unknown[]) => void,
|
||||
);
|
||||
|
||||
// Subscribe to room tagging
|
||||
this.disposables.trackListener(
|
||||
RoomListStoreV3.instance,
|
||||
RoomListStoreV3Event.RoomTagged as any,
|
||||
this.onRoomTagged,
|
||||
);
|
||||
|
||||
// Subscribe to active room changes to update selected room
|
||||
@@ -500,6 +508,7 @@ export class RoomListViewModel
|
||||
private async updateRoomListData(
|
||||
isRoomChange: boolean = false,
|
||||
roomIdOverride: string | null = null,
|
||||
scrollToSectionTag: string | undefined = undefined,
|
||||
): Promise<void> {
|
||||
// Determine the room ID to use for calculations
|
||||
// Use override if provided (e.g., during space changes), otherwise fall back to RoomViewStore
|
||||
@@ -544,17 +553,23 @@ export class RoomListViewModel
|
||||
// Update filter keys - only update if they have actually changed to prevent unnecessary re-renders of the room list
|
||||
const previousFilterKeys = this.snapshot.current.roomListState.filterKeys;
|
||||
const newFilterKeys = this.roomsResult.filterKeys?.map((k) => String(k));
|
||||
const viewSections = toRoomListSection(this.sections);
|
||||
|
||||
const resolvedScrollToSectionTag =
|
||||
scrollToSectionTag && viewSections.some((s) => s.id === scrollToSectionTag)
|
||||
? scrollToSectionTag
|
||||
: undefined;
|
||||
|
||||
const roomListState: RoomListViewState = {
|
||||
activeRoomIndex,
|
||||
spaceId: this.roomsResult.spaceId,
|
||||
filterKeys: keepIfSame(previousFilterKeys, newFilterKeys),
|
||||
scrollToSectionTag: resolvedScrollToSectionTag,
|
||||
};
|
||||
|
||||
const activeFilterId = this.activeFilter !== undefined ? filterKeyToIdMap.get(this.activeFilter) : undefined;
|
||||
const isRoomListEmpty = this.roomsResult.sections.every((section) => section.rooms.length === 0);
|
||||
const isLoadingRooms = RoomListStoreV3.instance.isLoadingRooms;
|
||||
|
||||
const viewSections = toRoomListSection(this.sections);
|
||||
const previousSections = this.snapshot.current.sections;
|
||||
|
||||
// Single atomic snapshot update
|
||||
@@ -586,15 +601,13 @@ export class RoomListViewModel
|
||||
}
|
||||
};
|
||||
|
||||
public onSectionCreated = (): void => {
|
||||
clearTimeout(this.toastRef);
|
||||
this.snapshot.merge({
|
||||
toast: "section_created",
|
||||
});
|
||||
// Automatically close the toast after 15 seconds
|
||||
this.toastRef = setTimeout(() => {
|
||||
this.closeToast();
|
||||
}, 15 * 1000);
|
||||
public onSectionCreated = (tag: string): void => {
|
||||
this.updateRoomListData(false, null, tag);
|
||||
this.showToast("section_created");
|
||||
};
|
||||
|
||||
public onRoomTagged = (): void => {
|
||||
this.showToast("chat_moved");
|
||||
};
|
||||
|
||||
public closeToast: () => void = () => {
|
||||
@@ -603,6 +616,15 @@ export class RoomListViewModel
|
||||
toast: undefined,
|
||||
});
|
||||
};
|
||||
|
||||
private showToast(toast: ToastType): void {
|
||||
clearTimeout(this.toastRef);
|
||||
this.snapshot.merge({ toast });
|
||||
// Automatically close the toast after 15 seconds
|
||||
this.toastRef = setTimeout(() => {
|
||||
this.closeToast();
|
||||
}, 15 * 1000);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -18,20 +18,10 @@ describe("PosthogTrackers", () => {
|
||||
const tracker = new PosthogTrackers();
|
||||
tracker.trackUrlPreview("$123456", false, [
|
||||
{
|
||||
title: "A preview",
|
||||
image: {
|
||||
imageThumb: "abc",
|
||||
imageFull: "abc",
|
||||
},
|
||||
link: "a-link",
|
||||
},
|
||||
]);
|
||||
tracker.trackUrlPreview("$123456", false, [
|
||||
{
|
||||
title: "A second preview",
|
||||
link: "a-link",
|
||||
image: {},
|
||||
},
|
||||
]);
|
||||
tracker.trackUrlPreview("$123456", false, [{}]);
|
||||
// Ignores subsequent calls.
|
||||
expect(PosthogAnalytics.instance.trackEvent).toHaveBeenCalledWith({
|
||||
eventName: "UrlPreviewRendered",
|
||||
|
||||
@@ -67,15 +67,15 @@ describe("SupportedBrowser", () => {
|
||||
// Safari 26.0 on macOS
|
||||
"Mozilla/5.0 (Macintosh; Intel Mac OS X 15_7_2) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.0 Safari/605.1.15",
|
||||
// Latest Firefox on macOS Sonoma
|
||||
"Mozilla/5.0 (Macintosh; Intel Mac OS X 15.7; rv:145.0) Gecko/20100101 Firefox/147.0",
|
||||
"Mozilla/5.0 (Macintosh; Intel Mac OS X 15.7; rv:150.0) Gecko/20100101 Firefox/150.0",
|
||||
// Latest Edge on Windows
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36 Edg/146.0.3856.84",
|
||||
// Latest Edge on macOS
|
||||
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36 Edg/146.0.3856.84",
|
||||
// Latest Firefox on Windows
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:147.0) Gecko/20100101 Firefox/147.0",
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:150.0) Gecko/20100101 Firefox/150.0",
|
||||
// Latest Firefox on Linux
|
||||
"Mozilla/5.0 (X11; Linux i686; rv:147.0) Gecko/20100101 Firefox/147.0",
|
||||
"Mozilla/5.0 (X11; Linux i686; rv:150.0) Gecko/20100101 Firefox/150.0",
|
||||
// Latest Chrome on Windows
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36",
|
||||
])("should not warn for supported browsers", testUserAgentFactory());
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
/*
|
||||
Copyright 2026 Element Creations Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import React from "react";
|
||||
import { render } from "jest-matrix-react";
|
||||
import { RovingAction, type RovingTabIndexProviderProps } from "@element-hq/web-shared-components";
|
||||
|
||||
import * as KeyBindingsManagerModule from "../../../src/KeyBindingsManager";
|
||||
import { KeyBindingAction } from "../../../src/accessibility/KeyboardShortcuts";
|
||||
import { RovingTabIndexProvider } from "../../../src/accessibility/RovingTabIndex";
|
||||
|
||||
jest.mock("@element-hq/web-shared-components", () => {
|
||||
const actual = jest.requireActual("@element-hq/web-shared-components");
|
||||
const mockSharedRovingTabIndexProvider = jest.fn(({ children }: RovingTabIndexProviderProps) => {
|
||||
return <>{children({ onDragEndHandler: jest.fn(), onKeyDownHandler: jest.fn() })}</>;
|
||||
});
|
||||
|
||||
return {
|
||||
__mockSharedRovingTabIndexProvider: mockSharedRovingTabIndexProvider,
|
||||
...actual,
|
||||
RovingTabIndexProvider: mockSharedRovingTabIndexProvider,
|
||||
};
|
||||
});
|
||||
|
||||
const getMockSharedRovingTabIndexProvider = (): jest.Mock => {
|
||||
return jest.requireMock("@element-hq/web-shared-components").__mockSharedRovingTabIndexProvider as jest.Mock;
|
||||
};
|
||||
|
||||
const getInjectedGetAction = (): NonNullable<RovingTabIndexProviderProps["getAction"]> => {
|
||||
const mockSharedRovingTabIndexProvider = getMockSharedRovingTabIndexProvider();
|
||||
expect(mockSharedRovingTabIndexProvider).toHaveBeenCalled();
|
||||
const getAction = (mockSharedRovingTabIndexProvider.mock.calls.at(-1)![0] as RovingTabIndexProviderProps).getAction;
|
||||
expect(getAction).toBeDefined();
|
||||
return getAction!;
|
||||
};
|
||||
|
||||
describe("RovingTabIndex adapter", () => {
|
||||
beforeEach(() => {
|
||||
const mockSharedRovingTabIndexProvider = getMockSharedRovingTabIndexProvider();
|
||||
mockSharedRovingTabIndexProvider.mockClear();
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
it.each([
|
||||
[KeyBindingAction.ArrowDown, RovingAction.ArrowDown],
|
||||
[KeyBindingAction.ArrowUp, RovingAction.ArrowUp],
|
||||
[KeyBindingAction.ArrowRight, RovingAction.ArrowRight],
|
||||
[KeyBindingAction.ArrowLeft, RovingAction.ArrowLeft],
|
||||
[KeyBindingAction.Home, RovingAction.Home],
|
||||
[KeyBindingAction.End, RovingAction.End],
|
||||
[KeyBindingAction.Tab, RovingAction.Tab],
|
||||
])("maps %s to %s", (accessibilityAction, expectedRovingAction) => {
|
||||
const manager = new KeyBindingsManagerModule.KeyBindingsManager();
|
||||
jest.spyOn(KeyBindingsManagerModule, "getKeyBindingsManager").mockReturnValue(manager);
|
||||
jest.spyOn(manager, "getAccessibilityAction").mockReturnValue(accessibilityAction);
|
||||
|
||||
render(<RovingTabIndexProvider>{() => null}</RovingTabIndexProvider>);
|
||||
|
||||
const getAction = getInjectedGetAction();
|
||||
expect(getAction({ key: "irrelevant" } as React.KeyboardEvent)).toBe(expectedRovingAction);
|
||||
});
|
||||
|
||||
it("returns undefined when there is no matching accessibility action", () => {
|
||||
const manager = new KeyBindingsManagerModule.KeyBindingsManager();
|
||||
jest.spyOn(KeyBindingsManagerModule, "getKeyBindingsManager").mockReturnValue(manager);
|
||||
jest.spyOn(manager, "getAccessibilityAction").mockReturnValue(undefined);
|
||||
|
||||
render(<RovingTabIndexProvider>{() => null}</RovingTabIndexProvider>);
|
||||
|
||||
const getAction = getInjectedGetAction();
|
||||
expect(getAction({ key: "x" } as React.KeyboardEvent)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("forwards provider props to shared-components", () => {
|
||||
const onKeyDown = jest.fn();
|
||||
|
||||
render(
|
||||
<RovingTabIndexProvider handleHomeEnd handleLoop handleUpDown onKeyDown={onKeyDown} scrollIntoView>
|
||||
{() => null}
|
||||
</RovingTabIndexProvider>,
|
||||
);
|
||||
|
||||
const mockSharedRovingTabIndexProvider = getMockSharedRovingTabIndexProvider();
|
||||
const props = mockSharedRovingTabIndexProvider.mock.calls.at(-1)![0] as RovingTabIndexProviderProps;
|
||||
expect(props.handleHomeEnd).toBe(true);
|
||||
expect(props.handleLoop).toBe(true);
|
||||
expect(props.handleUpDown).toBe(true);
|
||||
expect(props.onKeyDown).toBe(onKeyDown);
|
||||
expect(props.scrollIntoView).toBe(true);
|
||||
expect(props.getAction).toEqual(expect.any(Function));
|
||||
});
|
||||
});
|
||||
@@ -9,7 +9,6 @@ Please see LICENSE files in the repository root for full details.
|
||||
import "fake-indexeddb/auto";
|
||||
import React, { type ComponentProps } from "react";
|
||||
import { fireEvent, render, type RenderResult, screen, waitFor, within, act } from "jest-matrix-react";
|
||||
import fetchMock from "@fetch-mock/jest";
|
||||
import { type Mocked, mocked } from "jest-mock";
|
||||
import { ClientEvent, type MatrixClient, MatrixEvent, Room, SyncState } from "matrix-js-sdk/src/matrix";
|
||||
import { type MediaHandler } from "matrix-js-sdk/src/webrtc/mediaHandler";
|
||||
@@ -1637,7 +1636,6 @@ describe("<MatrixChat />", () => {
|
||||
|
||||
// Flaky test, see https://github.com/element-hq/element-web/issues/30337
|
||||
it("waits for other tab to stop during startup", async () => {
|
||||
fetchMock.get("end:/welcome.html", { body: "<h1>Hello</h1>" });
|
||||
jest.spyOn(Lifecycle, "attemptDelegatedAuthLogin");
|
||||
|
||||
// simulate an active window
|
||||
@@ -1668,7 +1666,7 @@ describe("<MatrixChat />", () => {
|
||||
expect(Lifecycle.attemptDelegatedAuthLogin).toHaveBeenCalled();
|
||||
|
||||
// should just show the welcome screen
|
||||
await rendered.findByText("Hello");
|
||||
await rendered.findByText("Welcome to Test");
|
||||
expect(rendered.container).toMatchSnapshot();
|
||||
});
|
||||
|
||||
|
||||
@@ -124,13 +124,9 @@ exports[`<MatrixChat /> Multi-tab lockout waits for other tab to stop during sta
|
||||
class="mx_AuthPage"
|
||||
>
|
||||
<div
|
||||
class="mx_AuthPage_modal mx_AuthPage_modal_withBlur"
|
||||
class="mx_AuthPage_modal"
|
||||
style="position: relative;"
|
||||
>
|
||||
<div
|
||||
class="mx_AuthPage_modalBlur"
|
||||
style="position: absolute; top: 0px; right: 0px; bottom: 0px; left: 0px; filter: blur(40px);"
|
||||
/>
|
||||
<main
|
||||
aria-live="polite"
|
||||
class="mx_AuthPage_modalContent"
|
||||
@@ -138,53 +134,99 @@ exports[`<MatrixChat /> Multi-tab lockout waits for other tab to stop during sta
|
||||
tabindex="-1"
|
||||
>
|
||||
<div
|
||||
class="mx_Welcome"
|
||||
data-testid="mx_welcome_screen"
|
||||
class="_glass_sepwu_8"
|
||||
>
|
||||
<div
|
||||
class="mx_WelcomePage mx_WelcomePage_loggedIn"
|
||||
class="mx_Welcome"
|
||||
>
|
||||
<div
|
||||
class="mx_WelcomePage_body"
|
||||
class="mx_DefaultWelcome"
|
||||
>
|
||||
<h1>
|
||||
Hello
|
||||
<a
|
||||
class="mx_DefaultWelcome_logo"
|
||||
href="https://element.io"
|
||||
rel="noopener"
|
||||
target="_blank"
|
||||
>
|
||||
<img
|
||||
alt="Test"
|
||||
src="themes/element/img/logos/element-logo.svg"
|
||||
/>
|
||||
</a>
|
||||
<h1
|
||||
class="_typography_6v6n8_153 _font-heading-md-semibold_6v6n8_112"
|
||||
>
|
||||
Welcome to Test
|
||||
</h1>
|
||||
<div
|
||||
class="mx_DefaultWelcome_buttons"
|
||||
>
|
||||
<a
|
||||
class="_button_13vu4_8"
|
||||
data-kind="primary"
|
||||
data-size="sm"
|
||||
href="#/login"
|
||||
role="link"
|
||||
tabindex="0"
|
||||
>
|
||||
Sign in
|
||||
</a>
|
||||
<a
|
||||
class="_button_13vu4_8"
|
||||
data-kind="secondary"
|
||||
data-size="sm"
|
||||
href="#/register"
|
||||
role="link"
|
||||
tabindex="0"
|
||||
>
|
||||
Create account
|
||||
</a>
|
||||
<a
|
||||
class="_button_13vu4_8"
|
||||
data-kind="tertiary"
|
||||
data-size="sm"
|
||||
href="#/directory"
|
||||
role="link"
|
||||
tabindex="0"
|
||||
>
|
||||
Explore rooms
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="mx_Dropdown mx_LanguageDropdown mx_AuthBody_language"
|
||||
>
|
||||
<div
|
||||
aria-describedby="mx_LanguageDropdown_value"
|
||||
aria-expanded="false"
|
||||
aria-haspopup="listbox"
|
||||
aria-label="Language Dropdown"
|
||||
aria-owns="mx_LanguageDropdown_input"
|
||||
class="mx_AccessibleButton mx_Dropdown_input mx_no_textinput"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
class="mx_Dropdown mx_LanguageDropdown mx_AuthBody_language"
|
||||
>
|
||||
<div
|
||||
class="mx_Dropdown_option"
|
||||
id="mx_LanguageDropdown_value"
|
||||
aria-describedby="mx_LanguageDropdown_value"
|
||||
aria-expanded="false"
|
||||
aria-haspopup="listbox"
|
||||
aria-label="Language Dropdown"
|
||||
aria-owns="mx_LanguageDropdown_input"
|
||||
class="mx_AccessibleButton mx_Dropdown_input mx_no_textinput"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
>
|
||||
<div>
|
||||
English
|
||||
<div
|
||||
class="mx_Dropdown_option"
|
||||
id="mx_LanguageDropdown_value"
|
||||
>
|
||||
<div>
|
||||
English
|
||||
</div>
|
||||
</div>
|
||||
<svg
|
||||
class="mx_Dropdown_arrow"
|
||||
fill="currentColor"
|
||||
height="1em"
|
||||
viewBox="0 0 24 24"
|
||||
width="1em"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M12 14.95q-.2 0-.375-.062a.9.9 0 0 1-.325-.213l-4.6-4.6a.95.95 0 0 1-.275-.7q0-.425.275-.7a.95.95 0 0 1 .7-.275q.425 0 .7.275l3.9 3.9 3.9-3.9a.95.95 0 0 1 .7-.275q.425 0 .7.275a.95.95 0 0 1 .275.7.95.95 0 0 1-.275.7l-4.6 4.6q-.15.15-.325.212a1.1 1.1 0 0 1-.375.063"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<svg
|
||||
class="mx_Dropdown_arrow"
|
||||
fill="currentColor"
|
||||
height="1em"
|
||||
viewBox="0 0 24 24"
|
||||
width="1em"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M12 14.95q-.2 0-.375-.062a.9.9 0 0 1-.325-.213l-4.6-4.6a.95.95 0 0 1-.275-.7q0-.425.275-.7a.95.95 0 0 1 .7-.275q.425 0 .7.275l3.9 3.9 3.9-3.9a.95.95 0 0 1 .7-.275q.425 0 .7.275a.95.95 0 0 1 .275.7.95.95 0 0 1-.275.7l-4.6 4.6q-.15.15-.325.212a1.1 1.1 0 0 1-.375.063"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -213,12 +213,12 @@ exports[`RoomView for a local room in state ERROR should match the snapshot 1`]
|
||||
</div>
|
||||
<div
|
||||
aria-labelledby="_r_1c3_"
|
||||
class="_banner_193k4_8"
|
||||
class="_banner_n7ud0_8"
|
||||
data-type="critical"
|
||||
role="status"
|
||||
>
|
||||
<div
|
||||
class="_icon_193k4_50"
|
||||
class="_icon_n7ud0_50"
|
||||
>
|
||||
<svg
|
||||
fill="currentColor"
|
||||
@@ -234,7 +234,7 @@ exports[`RoomView for a local room in state ERROR should match the snapshot 1`]
|
||||
</svg>
|
||||
</div>
|
||||
<div
|
||||
class="_content_193k4_38"
|
||||
class="_content_n7ud0_38"
|
||||
>
|
||||
<p
|
||||
class="_typography_6v6n8_153 _font-body-md-medium_6v6n8_60 _title_1xryk_24"
|
||||
@@ -244,7 +244,7 @@ exports[`RoomView for a local room in state ERROR should match the snapshot 1`]
|
||||
</p>
|
||||
</div>
|
||||
<div
|
||||
class="_actions_193k4_60"
|
||||
class="_actions_n7ud0_61"
|
||||
>
|
||||
<button
|
||||
class="_button_13vu4_8 _primaryAction_1xryk_20 _has-icon_13vu4_60"
|
||||
|
||||
@@ -7,12 +7,13 @@ Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import React from "react";
|
||||
import { fireEvent, render, screen, findByText } from "jest-matrix-react";
|
||||
import { findByText, fireEvent, render, screen } from "jest-matrix-react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { RoomType, type MatrixClient, MatrixError, Room } from "matrix-js-sdk/src/matrix";
|
||||
import { type MatrixClient, MatrixError, Room, RoomType } from "matrix-js-sdk/src/matrix";
|
||||
import { KnownMembership } from "matrix-js-sdk/src/types";
|
||||
import { sleep } from "matrix-js-sdk/src/utils";
|
||||
import { mocked, type Mocked } from "jest-mock";
|
||||
import { UserVerificationStatus } from "matrix-js-sdk/src/crypto-api";
|
||||
|
||||
import InviteDialog from "../../../../../src/components/views/dialogs/InviteDialog";
|
||||
import { InviteKind } from "../../../../../src/components/views/dialogs/InviteDialogTypes";
|
||||
@@ -103,6 +104,11 @@ describe("InviteDialog", () => {
|
||||
|
||||
beforeEach(() => {
|
||||
mockClient = getMockClientWithEventEmitter({
|
||||
getCrypto: jest.fn().mockReturnValue({
|
||||
getUserVerificationStatus: jest
|
||||
.fn()
|
||||
.mockResolvedValue(new UserVerificationStatus(false, false, true, false)),
|
||||
}),
|
||||
getDomain: jest.fn().mockReturnValue(serverDomain),
|
||||
getUserId: jest.fn().mockReturnValue(bobId),
|
||||
getSafeUserId: jest.fn().mockReturnValue(bobId),
|
||||
@@ -449,4 +455,44 @@ describe("InviteDialog", () => {
|
||||
await flushPromises();
|
||||
expect(screen.queryByText("@localpart:server.tld")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
describe("when inviting a user whose cryptographic identity we do not know", () => {
|
||||
beforeEach(() => {
|
||||
mocked(mockClient.getCrypto()!.getUserVerificationStatus).mockImplementation(async (u) => {
|
||||
return new UserVerificationStatus(false, false, false, false);
|
||||
});
|
||||
});
|
||||
|
||||
describe.each([InviteKind.Invite, InviteKind.Dm])("with invitekind '%s'", (kind) => {
|
||||
const goButtonName = kind == InviteKind.Invite ? "Invite" : "Go";
|
||||
|
||||
beforeEach(() => {
|
||||
render(
|
||||
<InviteDialog
|
||||
kind={kind as InviteKind.Invite | InviteKind.Dm}
|
||||
roomId={roomId}
|
||||
onFinished={jest.fn()}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
|
||||
it("should show a warning when inviting by user id", async () => {
|
||||
await enterIntoSearchField(aliceId);
|
||||
await userEvent.click(screen.getByRole("button", { name: goButtonName }));
|
||||
await screen.findByText("Confirm inviting them", { exact: false });
|
||||
|
||||
expect(mocked(mockClient.getCrypto()!.getUserVerificationStatus)).toHaveBeenCalledTimes(1);
|
||||
expect(mocked(mockClient.getCrypto()!.getUserVerificationStatus)).toHaveBeenCalledWith(aliceId);
|
||||
});
|
||||
|
||||
it("should show a warning when inviting by email address", async () => {
|
||||
await enterIntoSearchField("aaa@bbb");
|
||||
await userEvent.click(screen.getByRole("button", { name: goButtonName }));
|
||||
await screen.findByText("Confirm inviting them", { exact: false });
|
||||
|
||||
// We shouldn't call getUserVerificationStatus on an email address
|
||||
expect(mocked(mockClient.getCrypto()!.getUserVerificationStatus)).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
/*
|
||||
Copyright 2026 Element Creations Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import React, { type ComponentProps } from "react";
|
||||
import { render, type RenderResult } from "jest-matrix-react";
|
||||
import { getAllByRole, getAllByText, getByText } from "@testing-library/dom";
|
||||
|
||||
import UnknownIdentityUsersWarningDialog from "../../../../../../src/components/views/dialogs/invite/UnknownIdentityUsersWarningDialog.tsx";
|
||||
import { InviteKind } from "../../../../../../src/components/views/dialogs/InviteDialogTypes.ts";
|
||||
import { DirectoryMember, ThreepidMember } from "../../../../../../src/utils/direct-messages.ts";
|
||||
import { getMockClientWithEventEmitter, mockClientMethodsUser } from "../../../../../test-utils";
|
||||
|
||||
describe("UnknownIdentityUsersWarningDialog", () => {
|
||||
beforeEach(() => {
|
||||
getMockClientWithEventEmitter({
|
||||
...mockClientMethodsUser(),
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("should show entries for each user", () => {
|
||||
const result = renderComponent({
|
||||
users: [
|
||||
new DirectoryMember({ user_id: "@alice:example.com" }),
|
||||
new DirectoryMember({
|
||||
user_id: "@bob:example.net",
|
||||
display_name: "Bob",
|
||||
avatar_url: "mxc://example.com/abc",
|
||||
}),
|
||||
new ThreepidMember("charlie@example.com"),
|
||||
],
|
||||
});
|
||||
|
||||
const list = result.getByTestId("userlist");
|
||||
const entries = getAllByRole(list, "option");
|
||||
expect(entries).toHaveLength(3);
|
||||
|
||||
// No displayname so mxid is displayed twice
|
||||
expect(getAllByText(entries[0], "@alice:example.com")).toHaveLength(2);
|
||||
|
||||
getByText(entries[1], "Bob");
|
||||
getByText(entries[2], "charlie@example.com");
|
||||
});
|
||||
|
||||
describe("in DM mode", () => {
|
||||
const kind = InviteKind.Dm;
|
||||
|
||||
it("shows a 'Continue' button", () => {
|
||||
const onContinue = jest.fn();
|
||||
const result = renderComponent({ kind, onContinue });
|
||||
const continueButton = result.getByRole("button", { name: "Continue" });
|
||||
continueButton.click();
|
||||
expect(onContinue).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("shows a 'Cancel' button", () => {
|
||||
const onCancel = jest.fn();
|
||||
const result = renderComponent({ kind, onCancel });
|
||||
const cancelButton = result.getByRole("button", { name: "Cancel" });
|
||||
cancelButton.click();
|
||||
expect(onCancel).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("in Invite mode", () => {
|
||||
const kind = InviteKind.Invite;
|
||||
|
||||
it("shows an 'Invite' button", () => {
|
||||
const onContinue = jest.fn();
|
||||
const result = renderComponent({ kind, onContinue });
|
||||
const continueButton = result.getByRole("button", { name: "Invite" });
|
||||
continueButton.click();
|
||||
expect(onContinue).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("shows a 'Remove' button", () => {
|
||||
const onRemove = jest.fn();
|
||||
const result = renderComponent({ kind, onRemove });
|
||||
const removeButton = result.getByRole("button", { name: "Remove" });
|
||||
removeButton.click();
|
||||
expect(onRemove).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
function renderComponent(props: Partial<ComponentProps<typeof UnknownIdentityUsersWarningDialog>>): RenderResult {
|
||||
const props1: ComponentProps<typeof UnknownIdentityUsersWarningDialog> = {
|
||||
onContinue: () => {},
|
||||
onCancel: () => {},
|
||||
onRemove: () => {},
|
||||
screenName: undefined,
|
||||
kind: InviteKind.Dm,
|
||||
users: [],
|
||||
...props,
|
||||
};
|
||||
return render(<UnknownIdentityUsersWarningDialog {...props1} />);
|
||||
}
|
||||
@@ -1015,7 +1015,7 @@ describe("RoomListStoreV3", () => {
|
||||
it("emits SECTION_CREATED_EVENT and LISTS_UPDATE_EVENT when section is created", async () => {
|
||||
enableSections();
|
||||
getClientAndRooms();
|
||||
jest.spyOn(sectionModule, "createSection").mockResolvedValue(true);
|
||||
jest.spyOn(sectionModule, "createSection").mockResolvedValue("element.io.section.test-tag");
|
||||
|
||||
const store = new RoomListStoreV3Class(dispatcher);
|
||||
await store.start();
|
||||
@@ -1027,14 +1027,13 @@ describe("RoomListStoreV3", () => {
|
||||
|
||||
await store.createSection();
|
||||
|
||||
expect(sectionCreatedListener).toHaveBeenCalled();
|
||||
expect(listsUpdateListener).toHaveBeenCalled();
|
||||
expect(sectionCreatedListener).toHaveBeenCalledWith("element.io.section.test-tag");
|
||||
});
|
||||
|
||||
it("does not emit when section creation is cancelled", async () => {
|
||||
enableSections();
|
||||
getClientAndRooms();
|
||||
jest.spyOn(sectionModule, "createSection").mockResolvedValue(false);
|
||||
jest.spyOn(sectionModule, "createSection").mockResolvedValue(undefined);
|
||||
|
||||
const store = new RoomListStoreV3Class(dispatcher);
|
||||
await store.start();
|
||||
|
||||