Merge remote-tracking branch 'origin/develop' into hs/url-preview-new-design

This commit is contained in:
Half-Shot 2026-04-09 15:21:03 +01:00
commit f3515c0a1c
162 changed files with 3716 additions and 2067 deletions

View File

@ -31,7 +31,9 @@ runs:
- name: Move webapp to out-file-path
shell: bash
run: mv ${{ runner.temp }}/download-verify-element-tarball/webapp ${{ inputs.out-file-path }}
run: mv ${{ runner.temp }}/download-verify-element-tarball/webapp "$OUT_PATH"
env:
OUT_PATH: ${{ inputs.out-file-path }}
- name: Clean up temp directory
shell: bash

View File

@ -0,0 +1,49 @@
name: Setup playwright
description: Installs playwright browsers and sets up a cache
inputs:
needs-webkit:
description: Whether to install the additional dependencies for webkit
required: false
default: "false"
write-cache:
description: Whether to write the cache back
required: true
runs:
using: composite
steps:
- name: Calculate cache key
id: key
run: |
PW_VERSION=$(pnpm --silent -- playwright --version | awk '{print $2}')
echo "key=${PREFIX}-playwright-${PW_VERSION}" >> $GITHUB_OUTPUT
shell: bash
env:
PREFIX: ${{ runner.os }}-${{ runner.arch }}
- name: Cache playwright binaries
uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5
if: inputs.write-cache == 'true'
id: cache
with:
path: ~/.cache/ms-playwright
key: ${{ steps.key.outputs.key }}
# When running in merge queue only restore the cache, never write it
- name: Restore playwright binaries cache
uses: actions/cache/restore@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5
if: inputs.write-cache != 'true'
id: cache-restore
with:
path: ~/.cache/ms-playwright
key: ${{ steps.key.outputs.key }}
- name: Install Playwright browsers
if: (steps.cache.outputs.cache-hit || steps.cache-restore.outputs.cache-hit) != 'true'
shell: bash
run: pnpm playwright install --with-deps
# Some WebKit dependencies seem to lay outside the cache and will need to be installed separately
- name: Install system dependencies for WebKit
if: inputs.needs-webkit == 'true' && (steps.cache.outputs.cache-hit || steps.cache-restore.outputs.cache-hit) == 'true'
shell: bash
run: pnpm playwright install-deps webkit

View File

@ -2,6 +2,14 @@
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
"extends": ["github>matrix-org/renovate-config-element-web"],
"postUpdateOptions": ["pnpmDedupe"],
"packageRules": [
{
"groupName": "testcontainers docker digests",
"groupSlug": "{{manager}}-docker-digests",
"matchManagers": ["custom.regex"],
"matchPackageNames": ["*"]
}
],
"customManagers": [
{
"customType": "regex",

View File

@ -18,8 +18,6 @@ on:
push:
# We do not build on push to develop as the merge_group check handles that
branches: [staging, master]
repository_dispatch:
types: [element-web-notify]
# support triggering from other workflows
workflow_call:
@ -155,27 +153,11 @@ jobs:
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Get installed Playwright version
id: playwright
run: echo "version=$(pnpm --silent -- playwright --version | awk '{print $2}')" >> $GITHUB_OUTPUT
- name: Cache playwright binaries
uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5
id: playwright-cache
- name: Setup playwright
uses: ./.github/actions/setup-playwright
with:
path: ~/.cache/ms-playwright
key: ${{ runner.os }}-${{ runner.arch }}-playwright-${{ steps.playwright.outputs.version }}
- name: Install Playwright browsers
if: steps.playwright-cache.outputs.cache-hit != 'true'
working-directory: apps/web
run: pnpm playwright install --with-deps --no-shell
- name: Install system dependencies for WebKit
# Some WebKit dependencies seem to lay outside the cache and will need to be installed separately
if: matrix.project == 'WebKit' && steps.playwright-cache.outputs.cache-hit == 'true'
working-directory: apps/web
run: pnpm playwright install-deps webkit
needs-webkit: ${{ matrix.project == 'WebKit' }}
write-cache: ${{ github.event_name != 'merge_group' }}
# We skip tests tagged with @mergequeue when running on PRs, but run them in MQ and everywhere else
- name: Run Playwright tests
@ -204,6 +186,7 @@ jobs:
uses: element-hq/element-modules/.github/workflows/reusable-playwright-tests.yml@main # zizmor: ignore[unpinned-uses]
with:
webapp-artifact: webapp
reporter: blob
prepare_ed:
name: "Prepare Element Desktop"
@ -214,7 +197,7 @@ jobs:
contents: read
with:
config: ${{ (github.event.pull_request.base.ref || github.ref_name) == 'develop' && 'element.io/nightly' || 'element.io/release' }}
version: ${{ (github.event.pull_request.base.ref || github.ref_name) == 'develop' && 'develop' || '' }}
version: ${{ case((github.event.pull_request.base.ref || github.ref_name) == 'develop' || github.event_name == 'merge_group', 'develop', '') }}
webapp-artifact: webapp
build_ed_windows:
@ -262,6 +245,7 @@ jobs:
needs:
- playwright_ew
- downstream-modules
- prepare_ed
- build_ed_windows
- build_ed_linux
- build_ed_macos

View File

@ -38,7 +38,7 @@ jobs:
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
repository: ${{ github.repository == 'element-hq/element-web-pro' && 'element-hq/element-web' || github.repository }}
repository: element-hq/element-web
persist-credentials: false
- uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5

View File

@ -111,7 +111,7 @@ jobs:
running-workflow-name: "Build & Deploy develop.element.io"
repo-token: ${{ secrets.GITHUB_TOKEN }}
wait-interval: 10
check-regexp: ^((?!SonarCloud|SonarQube|issue|board|label|Release|prepare|GitHub Pages|Upload).)*$
check-regexp: ^((?!SonarCloud|SonarQube|issue|board|label|Release|prepare|GitHub Pages|Upload|Netlify).)*$
# We keep the latest develop.tar.gz on R2 instead of relying on the github artifact uploaded earlier
# as the expires after 24h and requires auth to download.

29
.github/workflows/merge-queue.yaml vendored Normal file
View File

@ -0,0 +1,29 @@
# Tweaks the behaviour of Merge Queue to skip certain checks
name: Merge Queue tweaks
on:
merge_group:
types: [checks_requested]
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}-${{ github.event_name }}
cancel-in-progress: true
permissions: {}
jobs:
run:
runs-on: ubuntu-24.04
permissions:
statuses: write
steps:
# This is only needed as license/cla at time of writing seems to be extraordinarily flaky
# and Github doesn't support conditional checks between PR & merge queue.
# This is fine to do as a PR won't make it to merge queue until it has license/cla passing.
- name: Skip license/cla on merge queues
uses: guibranco/github-status-action-v2@9bfa8773cdbdc6c185747fd43cd7faa9d7c32f09
with:
authToken: ${{ secrets.GITHUB_TOKEN }}
state: success
context: license/cla
sha: ${{ github.sha }}
target_url: https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}

View File

@ -30,7 +30,8 @@ jobs:
asset-path: dist/*.tar.gz
expected-asset-count: 3
# Desktop has no dist script so we only target web here
dir: apps/web
dist-dir: apps/web
version-dirs: apps/web apps/desktop
check:
name: Post release checks

View File

@ -2,7 +2,9 @@
# It uploads the received images and diffs to netlify, printing the URLs to the console
name: Upload Shared Component Visual Test Diffs
on:
workflow_run:
# Privilege escalation necessary to deploy to Netlify
# 🚨 We must not execute any checked out code here.
workflow_run: # zizmor: ignore[dangerous-triggers]
workflows: ["Shared Component Visual Tests"]
types:
- completed

View File

@ -36,22 +36,10 @@ jobs:
working-directory: packages/shared-components
run: pnpm install --frozen-lockfile
- name: Get installed Playwright version
working-directory: packages/shared-components
id: playwright
run: echo "version=$(pnpm list @playwright/test --depth=0 --json | jq -r '.[].devDependencies["@playwright/test"].version')" >> $GITHUB_OUTPUT
- name: Cache playwright binaries
uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5
id: playwright-cache
- name: Setup playwright
uses: ./.github/actions/setup-playwright
with:
path: ~/.cache/ms-playwright
key: ${{ runner.os }}-${{ runner.arch }}-playwright-${{ steps.playwright.outputs.version }}-onlyshell
- name: Install Playwright browsers
working-directory: packages/shared-components
if: steps.playwright-cache.outputs.cache-hit != 'true'
run: "pnpm playwright install --with-deps --only-shell"
write-cache: ${{ github.event_name != 'merge_group' }}
- name: Run Visual tests
working-directory: packages/shared-components

View File

@ -1,6 +1,8 @@
name: SonarQube
on:
workflow_run:
# Privilege escalation necessary to call upon SonarCloud
# 🚨 We must not execute any checked out code here.
workflow_run: # zizmor: ignore[dangerous-triggers]
workflows: ["Tests"]
types:
- completed

View File

@ -5,8 +5,6 @@ on:
branches: [develop, master]
merge_group:
types: [checks_requested]
repository_dispatch:
types: [element-web-notify]
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}-${{ github.event_name }}
cancel-in-progress: true

View File

@ -5,8 +5,6 @@ on:
types: [checks_requested]
push:
branches: [develop, master]
repository_dispatch:
types: [element-web-notify]
workflow_call:
inputs:
disable_coverage:
@ -151,22 +149,10 @@ jobs:
packages/shared-components/node_modules/.vite/vitest
key: ${{ hashFiles('pnpm-lock.yaml') }}
- name: Get installed Playwright version
working-directory: packages/shared-components
id: playwright
run: echo "version=$(pnpm list @playwright/test --depth=0 --json | jq -r '.[].devDependencies["@playwright/test"].version')" >> $GITHUB_OUTPUT
- name: Cache playwright binaries
uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5
id: playwright-cache
- name: Setup playwright
uses: ./.github/actions/setup-playwright
with:
path: ~/.cache/ms-playwright
key: ${{ runner.os }}-${{ runner.arch }}-playwright-${{ steps.playwright.outputs.version }}-onlyshell
- name: Install Playwright browsers
working-directory: packages/shared-components
if: steps.playwright-cache.outputs.cache-hit != 'true'
run: "pnpm playwright install --with-deps --only-shell"
write-cache: ${{ github.event_name != 'merge_group' }}
- name: Run tests
working-directory: "packages/shared-components"

View File

@ -1,3 +1,32 @@
Changes in [1.12.15](https://github.com/element-hq/element-web/releases/tag/v1.12.15) (2026-04-08)
==================================================================================================
Fixes Desktop release workflow.
This release is identical to v1.12.14 otherwise.
Changes in [1.12.14](https://github.com/element-hq/element-web/releases/tag/v1.12.14) (2026-04-07)
==================================================================================================
## ✨ Features
* Add analytics tracking for URL previews ([#32659](https://github.com/element-hq/element-web/pull/32659)). Contributed by @Half-Shot.
* Collapsible Room List - Clicking on separator should expand to last set width ([#32909](https://github.com/element-hq/element-web/pull/32909)). Contributed by @MidhunSureshR.
* RoomList: improve performance ([#32919](https://github.com/element-hq/element-web/pull/32919)). Contributed by @florianduros.
* Implement collapsible panels for the new room list ([#32742](https://github.com/element-hq/element-web/pull/32742)). Contributed by @MidhunSureshR.
* Hide the names of banned users behind a spoiler tag (attempt 2) ([#32636](https://github.com/element-hq/element-web/pull/32636)). Contributed by @andybalaam.
## 🐛 Bug Fixes
* Use the code signing Subject Name as basis for Tray GUID on Windows ([#32939](https://github.com/element-hq/element-web/pull/32939)). Contributed by @t3chguy.
* Ensure the incoming verification request appears above the please verify prompt ([#32931](https://github.com/element-hq/element-web/pull/32931)). Contributed by @andybalaam.
* Collapsible Room List - Prevent any interaction with the separator when the panel is expanded ([#32910](https://github.com/element-hq/element-web/pull/32910)). Contributed by @MidhunSureshR.
* Fix icon size of badges in right panel ([#32952](https://github.com/element-hq/element-web/pull/32952)). Contributed by @florianduros.
* Fix room list often showing the wrong icons for calls ([#32881](https://github.com/element-hq/element-web/pull/32881)). Contributed by @robintown.
* Fix emoticon slash commands including stale buffers ([#32928](https://github.com/element-hq/element-web/pull/32928)). Contributed by @t3chguy.
* Fix presence indicators not showing without cache ([#32880](https://github.com/element-hq/element-web/pull/32880)). Contributed by @DLCSharp.
* Show space name instead of 'Empty room' after creation ([#32886](https://github.com/element-hq/element-web/pull/32886)). Contributed by @gugaribeiro05.
* Strip ephemeral query params from OIDC redirect URI ([#32875](https://github.com/element-hq/element-web/pull/32875)). Contributed by @azmeuk.
Changes in [1.12.13](https://github.com/element-hq/element-web/releases/tag/v1.12.13) (2026-03-24)
==================================================================================================
## 🦖 Deprecations

View File

@ -3,7 +3,7 @@
"productName": "Element",
"main": "lib/electron-main.js",
"exports": "./lib/electron-main.js",
"version": "1.12.13",
"version": "1.12.15",
"description": "Element: the future of secure communication",
"author": {
"name": "Element",

View File

@ -1,4 +1,4 @@
FROM mcr.microsoft.com/playwright:v1.58.2-jammy@sha256:4698a73749c5848d3f5fcd42a2174d172fcad2b2283e087843b115424303a565
FROM mcr.microsoft.com/playwright:v1.59.1-jammy@sha256:8a0360d39d1973be506dd59002904a774f6d697d4946c94063b3fd006461c8ff
WORKDIR /work/element-desktop

View File

@ -1,6 +1,6 @@
{
"name": "element-web",
"version": "1.12.13",
"version": "1.12.15",
"description": "Element: the future of secure communication",
"author": "New Vector Ltd.",
"repository": {
@ -67,7 +67,7 @@
"emojibase-regex": "^17.0.0",
"escape-html": "^1.0.3",
"file-saver": "^2.0.5",
"filesize": "11.0.13",
"filesize": "11.0.15",
"github-markdown-css": "^5.5.1",
"glob-to-regexp": "^0.4.1",
"highlight.js": "^11.3.1",
@ -78,7 +78,7 @@
"jsrsasign": "^11.0.0",
"jszip": "^3.7.0",
"katex": "^0.16.0",
"lodash": "npm:lodash-es@^4.17.21",
"lodash": "npm:lodash-es@4.18.1",
"maplibre-gl": "^5.0.0",
"matrix-encrypt-attachment": "^1.0.3",
"matrix-js-sdk": "github:matrix-org/matrix-js-sdk#develop",
@ -89,7 +89,7 @@
"opus-recorder": "^8.0.3",
"pako": "^2.0.3",
"png-chunks-extract": "^1.0.0",
"posthog-js": "1.360.2",
"posthog-js": "1.364.7",
"qrcode": "1.5.4",
"re-resizable": "6.11.2",
"react": "catalog:",

View File

@ -0,0 +1,61 @@
/*
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 { test, expect } from "../../element-web-test";
import { getSampleFilePath } from "../../sample-files";
test.describe("Devtools", () => {
test.use({
displayName: "Alice",
});
test("should allow enabling low bandwidth mode", async ({ page, homeserver, user, app }) => {
// Upload a picture
const userSettings = await app.settings.openUserSettings("Account");
const profileSettings = userSettings.locator(".mx_UserProfileSettings");
await profileSettings.getByAltText("Upload").setInputFiles(getSampleFilePath("riot.png"));
await app.closeDialog();
// Create an initial room.
const createRoomDialog = await app.openCreateRoomDialog();
await createRoomDialog.getByRole("textbox", { name: "Name" }).fill("Test Room");
await createRoomDialog.getByRole("button", { name: "Create room" }).click();
const composer = app.getComposer().locator("[contenteditable]");
await composer.fill("/devtools");
await composer.press("Enter");
const dialog = page.locator(".mx_Dialog");
await dialog.getByLabel("Developer mode").check();
await dialog.getByLabel("Disable bandwidth-heavy features").click();
// Wait for refresh.
await page.waitForEvent("domcontentloaded");
await app.viewRoomByName("Test Room");
// This only appears when encryption has been disabled in the client.
await expect(page.getByText("The encryption used by this room isn't supported.")).toBeVisible();
// None of these should be requested.
let hasSentTyping = false;
let hasRequestedThumbnail = false;
await page.route("**/_matrix/client/v3/rooms/*/typing/*", async (route) => {
hasSentTyping = true;
await route.fulfill({ json: {} });
});
await page.route("**/_matrix/media/v3/thumbnail/**", async (route) => {
hasRequestedThumbnail = true;
await route.fulfill({ json: {} });
});
await page.route("**/_matrix/client/v1/media/thumbnail/**", async (route) => {
hasRequestedThumbnail = true;
await route.fulfill({ json: {} });
});
await composer.pressSequentially("Provoke typing request", { delay: 5 });
expect(hasSentTyping).toEqual(false);
expect(hasRequestedThumbnail).toEqual(false);
});
});

View File

@ -24,7 +24,7 @@ test.describe("Account user settings tab", () => {
},
});
test("should be rendered properly", { tag: "@screenshot" }, async ({ uut, user }) => {
test("should be rendered properly", { tag: "@screenshot" }, async ({ uut, user, axe }) => {
await expect(uut).toMatchScreenshot("account.png");
// Assert that the top heading is rendered
@ -70,6 +70,8 @@ test.describe("Account user settings tab", () => {
await expect(accountManagementSection.getByRole("button", { name: "Deactivate Account" })).toHaveClass(
/mx_AccessibleButton_kind_danger/,
);
await expect(axe).toHaveNoViolations();
});
test("should respond to small screen sizes", { tag: "@screenshot" }, async ({ page, uut }) => {

View File

@ -13,7 +13,7 @@ test.describe("Appearance user settings tab", () => {
displayName: "Hanako",
});
test("should be rendered properly", { tag: "@screenshot" }, async ({ page, user, app }) => {
test("should be rendered properly", { tag: "@screenshot" }, async ({ page, user, app, axe }) => {
const tab = await app.settings.openUserSettings("Appearance");
// Click "Show advanced" link button
@ -23,6 +23,8 @@ test.describe("Appearance user settings tab", () => {
await expect(tab.getByRole("button", { name: "Hide advanced" })).toBeVisible();
await expect(tab).toMatchScreenshot("appearance-tab.png");
await expect(axe).toHaveNoViolations();
});
test(

View File

@ -23,7 +23,7 @@ test.describe("Appearance user settings tab", () => {
test(
"should be rendered with the light theme selected",
{ tag: "@screenshot" },
async ({ page, app, util }) => {
async ({ page, app, util, axe }) => {
// Assert that 'Match system theme' is not checked
await expect(util.getMatchSystemThemeSwitch()).not.toBeChecked();
@ -34,6 +34,8 @@ test.describe("Appearance user settings tab", () => {
await expect(util.getHighContrastTheme()).not.toBeChecked();
await expect(util.getThemePanel()).toMatchScreenshot("theme-panel-light.png");
await expect(axe).toHaveNoViolations();
},
);

View File

@ -23,7 +23,7 @@ test.describe("Device manager", () => {
}
});
test("should display sessions", async ({ page, app }) => {
test("should display sessions", async ({ page, app, axe }) => {
await app.settings.openUserSettings("Sessions");
const tab = page.locator(".mx_SettingsTab");
@ -85,7 +85,7 @@ test.describe("Device manager", () => {
// session name updated in details
await expect(firstSession.locator(".mx_DeviceDetailHeading h4").getByText(sessionName)).toBeVisible();
// and main list item
await expect(firstSession.locator(".mx_DeviceTile h4").getByText(sessionName)).toBeVisible();
await expect(firstSession.locator(".mx_DeviceTile h3").getByText(sessionName)).toBeVisible();
// sign out using the device details sign out
await firstSession.getByRole("button", { name: "Remove this session" }).click();
@ -96,5 +96,7 @@ test.describe("Device manager", () => {
// no other sessions or security recommendations sections when only one session
await expect(tab.getByText("Other sessions")).not.toBeVisible();
await expect(tab.getByTestId("security-recommendations-section")).not.toBeVisible();
await expect(axe).toHaveNoViolations();
});
});

View File

@ -16,7 +16,7 @@ test.describe("Advanced section in Encryption tab", () => {
await bootstrapCrossSigningForClient(clientHandle, credentials, true);
});
test("should show the encryption details", { tag: "@screenshot" }, async ({ page, app, util }) => {
test("should show the encryption details", { tag: "@screenshot" }, async ({ page, app, util, axe }) => {
await util.openEncryptionTab();
const section = util.getEncryptionDetailsSection();
@ -26,6 +26,8 @@ test.describe("Advanced section in Encryption tab", () => {
await expect(section).toMatchScreenshot("encryption-details.png", {
mask: [section.getByTestId("deviceId"), section.getByTestId("sessionKey")],
});
await expect(axe).toHaveNoViolations();
});
test("should show the import room keys dialog", async ({ page, app, util }) => {

View File

@ -20,7 +20,7 @@ test.describe("General room settings tab", () => {
await app.viewRoomByName(roomName);
});
test("should be rendered properly", { tag: "@screenshot" }, async ({ page, app }) => {
test("should be rendered properly", { tag: "@screenshot" }, async ({ page, app, axe }) => {
const settings = await app.settings.openRoomSettings("General");
// Assert that "Show less" details element is rendered
@ -34,6 +34,9 @@ test.describe("General room settings tab", () => {
// Assert that "Show more" details element is rendered instead of "Show more"
await expect(settings.getByText("Show less")).not.toBeVisible();
await expect(settings.getByText("Show more")).toBeVisible();
axe.disableRules("color-contrast"); // XXX: We have some known contrast issues here
await expect(axe).toHaveNoViolations();
});
test("long address should not cause dialog to overflow", { tag: "@no-webkit" }, async ({ page, app, user }) => {

View File

@ -25,7 +25,7 @@ test.describe("Preferences user settings tab", () => {
labsFlags: ["feature_new_room_list"],
});
test("should be rendered properly", { tag: "@screenshot" }, async ({ app, page, user }) => {
test("should be rendered properly", { tag: "@screenshot" }, async ({ app, page, user, axe }) => {
await page.setViewportSize({ width: 1024, height: 4000 });
const tab = await app.settings.openUserSettings("Preferences");
// Assert that the top heading is rendered
@ -39,6 +39,8 @@ test.describe("Preferences user settings tab", () => {
}
`,
});
await expect(axe).toHaveNoViolations();
});
test("should be able to change the app language", { tag: ["@no-firefox", "@no-webkit"] }, async ({ uut, user }) => {

View File

@ -8,11 +8,13 @@ Please see LICENSE files in the repository root for full details.
import { test, expect } from "../../element-web-test";
test.describe("Quick settings menu", () => {
test("should be rendered properly", { tag: "@screenshot" }, async ({ app, page, user }) => {
test("should be rendered properly", { tag: "@screenshot" }, async ({ app, page, user, axe }) => {
await page.getByRole("button", { name: "Quick settings" }).click();
// Assert that the top heading is renderedc
const settings = page.getByTestId("quick-settings-menu");
await expect(settings).toBeVisible();
await expect(settings).toMatchScreenshot("quick-settings.png");
await expect(axe).toHaveNoViolations();
});
});

View File

@ -25,7 +25,7 @@ test.describe("Roles & Permissions room settings tab", () => {
settings = await app.settings.openRoomSettings("Roles & Permissions");
});
test("should be able to change the role of a user", async ({ page, app, user }) => {
test("should be able to change the role of a user", async ({ page, app, user, axe }) => {
const privilegedUserSection = settings.locator(".mx_SettingsFieldset").first();
const applyButton = privilegedUserSection.getByRole("button", { name: "Apply" });
@ -55,5 +55,7 @@ test.describe("Roles & Permissions room settings tab", () => {
settings = await app.settings.openRoomSettings("Roles & Permissions");
combobox = privilegedUserSection.getByRole("combobox", { name: user.userId });
await expect(combobox).toHaveValue("50");
await expect(axe).toHaveNoViolations();
});
});

View File

@ -32,12 +32,14 @@ test.describe("Security user settings tab", () => {
});
test.describe("AnalyticsLearnMoreDialog", () => {
test("should be rendered properly", { tag: "@screenshot" }, async ({ app, page, user }) => {
test("should be rendered properly", { tag: "@screenshot" }, async ({ app, page, user, axe }) => {
const tab = await app.settings.openUserSettings("Security");
await tab.getByRole("button", { name: "Learn more" }).click();
await expect(page.locator(".mx_AnalyticsLearnMoreDialog_wrapper .mx_Dialog")).toMatchScreenshot(
"Security-user-settings-tab-with-posthog-enable-b5d89-csLearnMoreDialog-should-be-rendered-properly-1.png",
);
await expect(axe).toHaveNoViolations();
});
});

View File

@ -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 { Page, Request, Route } from "@playwright/test";
import type { Page, Request, Route, Disposable } from "@playwright/test";
import type { Client } from "./client";
/**
@ -16,7 +16,7 @@ import type { Client } from "./client";
*/
export class Network {
private isOffline = false;
private setupPromise?: Promise<void>;
private setupPromise?: Promise<Disposable>;
constructor(
private page: Page,

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 956 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 61 KiB

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 34 KiB

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 43 KiB

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 63 KiB

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 259 KiB

After

Width:  |  Height:  |  Size: 268 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 78 KiB

After

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 87 KiB

After

Width:  |  Height:  |  Size: 87 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 149 KiB

After

Width:  |  Height:  |  Size: 150 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 151 KiB

After

Width:  |  Height:  |  Size: 151 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 167 KiB

After

Width:  |  Height:  |  Size: 167 KiB

View File

@ -11,7 +11,7 @@ import {
} from "@element-hq/element-web-playwright-common/lib/testcontainers/index.js";
const DOCKER_IMAGE =
"ghcr.io/element-hq/matrix-authentication-service:main@sha256:f54c2214354ec3294694a525523debb5f38c8580c1a5afc8cdec0f8372374ef3";
"ghcr.io/element-hq/matrix-authentication-service:main@sha256:b7bbeb4249bf4abfc86ccd6d1be60c3b68ccb41be407b2a50658f7ff53a44d80";
/**
* MatrixAuthenticationServiceContainer which freezes the docker digest to

View File

@ -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:73fe964d854412cd905f4f0e668b2a9d10edc86891036e03656714de0311f6f6";
"ghcr.io/element-hq/synapse:develop@sha256:2ccad8f89462d119d910fbf91750477118a875a767d19e6387f750f8e8a9a5ae";
/**
* SynapseContainer which freezes the docker digest to stabilise tests,

View File

@ -70,6 +70,7 @@
@import "./structures/_MatrixChat.pcss";
@import "./structures/_MessagePanel.pcss";
@import "./structures/_NonUrgentToastContainer.pcss";
@import "./structures/_PictureInPictureDragger.pcss";
@import "./structures/_QuickSettingsButton.pcss";
@import "./structures/_RightPanel.pcss";
@import "./structures/_RoomSearch.pcss";
@ -375,7 +376,6 @@
@import "./views/voip/_DialPad.pcss";
@import "./views/voip/_DialPadContextMenu.pcss";
@import "./views/voip/_DialPadModal.pcss";
@import "./views/voip/_LegacyCallPreview.pcss";
@import "./views/voip/_LegacyCallView.pcss";
@import "./views/voip/_LegacyCallViewForRoom.pcss";
@import "./views/voip/_LegacyCallViewHeader.pcss";

View File

@ -0,0 +1,20 @@
/*
Copyright 2026 Element Creations Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
.mx_PictureInPictureDragger {
cursor: grab;
user-select: none;
left: 0;
position: fixed;
top: 0;
/* Display above any widget elements */
z-index: 102;
}
.mx_PictureInPictureDragger:active {
cursor: grabbing;
}

View File

@ -1378,6 +1378,10 @@ $left-gutter: 64px;
display: flex;
}
.mx_EventTile_annotatedInline {
display: inline-flex;
}
.mx_EventTile_footer {
display: flex;
gap: var(--cpd-space-2x);

View File

@ -36,5 +36,5 @@ Please see LICENSE files in the repository root for full details.
grid-template-columns: minmax(0, 1fr);
gap: $spacing-32;
padding: $spacing-16 0;
margin: $spacing-16 0;
}

View File

@ -1,28 +0,0 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2021 Šimon Brandner <simon.bra.ag@gmail.com>
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_LegacyCallPreview {
align-items: flex-end;
display: flex;
flex-direction: column;
gap: $spacing-16;
left: 0;
position: fixed;
top: 0;
/* Display above any widget elements */
z-index: 102;
.mx_VideoFeed_remote.mx_VideoFeed_voice {
min-height: 150px;
}
.mx_VideoFeed_local {
border-radius: 8px;
overflow: hidden;
}
}

View File

@ -103,10 +103,14 @@ export class VoiceRecording extends EventEmitter implements IDestroyable {
private async makeRecorder(): Promise<void> {
try {
const requestedDeviceId = MediaDeviceHandler.getAudioInput();
const deviceIdConstraint =
requestedDeviceId && requestedDeviceId !== "default" ? { deviceId: { exact: requestedDeviceId } } : {};
this.recorderStream = await navigator.mediaDevices.getUserMedia({
audio: {
channelCount: CHANNELS,
deviceId: MediaDeviceHandler.getAudioInput(),
...deviceIdConstraint,
autoGainControl: { ideal: MediaDeviceHandler.getAudioAutoGainControl() },
echoCancellation: { ideal: MediaDeviceHandler.getAudioEchoCancellation() },
noiseSuppression: { ideal: MediaDeviceHandler.getAudioNoiseSuppression() },

View File

@ -8,7 +8,15 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com
Please see LICENSE files in the repository root for full details.
*/
import React, { type JSX, type CSSProperties, type RefObject, type SyntheticEvent, useRef, useState } from "react";
import React, {
type JSX,
type CSSProperties,
type RefObject,
type SyntheticEvent,
useRef,
useState,
type AriaRole,
} from "react";
import ReactDOM from "react-dom";
import classNames from "classnames";
import FocusLock from "react-focus-lock";
@ -74,27 +82,31 @@ export interface MenuProps extends IPosition {
export interface IProps extends MenuProps {
// If true, insert an invisible screen-sized element behind the menu that when clicked will close it.
hasBackground?: boolean;
"hasBackground"?: boolean;
// whether this context menu should be focus managed. If false it must handle itself
managed?: boolean;
wrapperClassName?: string;
menuClassName?: string;
"managed"?: boolean;
"wrapperClassName"?: string;
"menuClassName"?: string;
// If true, this context menu will be mounted as a child to the parent container. Otherwise
// it will be mounted to a container at the root of the DOM.
mountAsChild?: boolean;
"mountAsChild"?: boolean;
// If specified, contents will be wrapped in a FocusLock, this is only needed if the context menu is being rendered
// within an existing FocusLock e.g inside a modal.
focusLock?: boolean;
"focusLock"?: boolean;
// call onFinished on any interaction with the menu
closeOnInteraction?: boolean;
"closeOnInteraction"?: boolean;
// Function to be called on menu close
onFinished(this: void): void;
// on resize callback
windowResize?(this: void): void;
// Role & label for accessibility
"role"?: AriaRole;
"aria-label"?: string;
}
interface IState {
@ -257,9 +269,11 @@ export default class ContextMenu extends React.PureComponent<React.PropsWithChil
focusLock,
managed,
wrapperClassName,
chevronFace: propsChevronFace,
chevronOffset: propsChevronOffset,
"chevronFace": propsChevronFace,
"chevronOffset": propsChevronOffset,
mountAsChild,
role,
"aria-label": ariaLabel,
...props
} = this.props;
@ -424,6 +438,8 @@ export default class ContextMenu extends React.PureComponent<React.PropsWithChil
onClick={this.onClick}
onKeyDown={onKeyDownHandler}
onContextMenu={this.onContextMenuPreventBubbling}
role={role}
aria-label={ariaLabel}
>
{background}
<TooltipProvider>

View File

@ -37,9 +37,7 @@ interface IChildrenOptions {
}
interface IProps {
className?: string;
children: Array<CreatePipChildren>;
draggable: boolean;
onDoubleClick?: () => void;
onMove?: () => void;
}
@ -181,9 +179,6 @@ export default class PictureInPictureDragger extends React.Component<IProps> {
};
private onStartMoving = (event: React.MouseEvent | MouseEvent): void => {
event.preventDefault();
event.stopPropagation();
this.mouseHeld = true;
this.startingPositionX = event.clientX;
this.startingPositionY = event.clientY;
@ -217,9 +212,6 @@ export default class PictureInPictureDragger extends React.Component<IProps> {
private onEndMoving = (event: MouseEvent): void => {
if (!this.mouseHeld) return;
event.preventDefault();
event.stopPropagation();
this.mouseHeld = false;
// Delaying this to the next event loop tick is necessary for click
// event cancellation to work
@ -250,7 +242,7 @@ export default class PictureInPictureDragger extends React.Component<IProps> {
return (
<aside
className={this.props.className}
className="mx_PictureInPictureDragger"
style={style}
ref={this.callViewWrapper}
onClickCapture={this.onClickCapture}

View File

@ -266,12 +266,7 @@ class PipContainerInner extends React.Component<IProps, IState> {
if (pipContent.length) {
return (
<PictureInPictureDragger
className="mx_LegacyCallPreview"
draggable={pipMode}
onDoubleClick={this.onDoubleClick}
onMove={this.onMove}
>
<PictureInPictureDragger onDoubleClick={this.onDoubleClick} onMove={this.onMove}>
{pipContent}
</PictureInPictureDragger>
);

View File

@ -1380,12 +1380,12 @@ export class RoomView extends React.Component<IRoomProps, IRoomState> {
if (data.timeline.getTimelineSet() !== room.getUnfilteredTimelineSet()) return;
if (ev.getType() === "org.matrix.room.preview_urls") {
this.updatePreviewUrlVisibility(room);
this.updatePreviewUrlVisibility();
}
if (ev.getType() === "m.room.encryption") {
this.updateE2EStatus(room);
this.updatePreviewUrlVisibility(room);
this.updatePreviewUrlVisibility();
}
// ignore anything but real-time updates at the end of the room:
@ -1541,15 +1541,14 @@ export class RoomView extends React.Component<IRoomProps, IRoomState> {
});
}
private updatePreviewUrlVisibility(room: Room): void {
private updatePreviewUrlVisibility(): void {
this.setState(({ isRoomEncrypted }) => ({
showUrlPreview: this.getPreviewUrlVisibility(room, isRoomEncrypted),
showUrlPreview: this.getPreviewUrlVisibility(isRoomEncrypted),
}));
}
private getPreviewUrlVisibility({ roomId }: Room, isRoomEncrypted: boolean | null): boolean {
const key = isRoomEncrypted ? "urlPreviewsEnabled_e2ee" : "urlPreviewsEnabled";
return SettingsStore.getValue(key, roomId);
private getPreviewUrlVisibility(isRoomEncrypted: boolean | null): boolean {
return SettingsStore.getValue(isRoomEncrypted ? "urlPreviewsEnabled_e2ee" : "urlPreviewsEnabled");
}
private onRoom = (room: Room): void => {
@ -1608,9 +1607,7 @@ export class RoomView extends React.Component<IRoomProps, IRoomState> {
}
private onUrlPreviewsEnabledChange = (): void => {
if (this.state.room) {
this.updatePreviewUrlVisibility(this.state.room);
}
this.updatePreviewUrlVisibility();
};
private onRoomStateEvents = async (ev: MatrixEvent, state: RoomState): Promise<void> => {
@ -1638,7 +1635,7 @@ export class RoomView extends React.Component<IRoomProps, IRoomState> {
this.setState({
isRoomEncrypted,
showUrlPreview: this.getPreviewUrlVisibility(room, isRoomEncrypted),
showUrlPreview: this.getPreviewUrlVisibility(isRoomEncrypted),
...(newE2EStatus && { e2eStatus: newE2EStatus }),
});
}

View File

@ -115,6 +115,7 @@ const DevtoolsDialog: React.FC<IProps> = ({ roomId, threadRootId, onFinished })
<SettingsFlag name="developerMode" level={SettingLevel.ACCOUNT} />
<SettingsFlag name="showHiddenEventsInTimeline" level={SettingLevel.DEVICE} />
<SettingsFlag name="enableWidgetScreenshots" level={SettingLevel.ACCOUNT} />
<SettingsFlag name="lowBandwidth" level={SettingLevel.DEVICE} />
</Form.Root>
{/* The settings field needs to be outside `Form.Root` because `SettingsField` will have a inner Form,
Otherwise we end up with a nester `Form` and that prohibits `preventDefault` so setting the value

View File

@ -25,6 +25,7 @@ interface IProps {
label?: string;
isExplicit?: boolean;
hideIfCannotSet?: boolean;
requires?: BooleanSettingKey[];
onChange?(checked: boolean): void;
}
@ -45,6 +46,12 @@ export default class SettingsFlag extends React.Component<IProps, IState> {
public componentDidMount(): void {
defaultWatchManager.watchSetting(this.props.name, this.props.roomId ?? null, this.onSettingChange);
if (this.props.requires) {
// If we have any dependencies for this feature, also watch those features to ensure we catch the disabled state.
for (const flag of this.props.requires) {
defaultWatchManager.watchSetting(flag, this.props.roomId ?? null, this.onSettingChange);
}
}
}
public componentWillUnmount(): void {

View File

@ -301,6 +301,9 @@ class InnerTextualBody extends React.Component<Props> {
const isCaption = [MsgType.Image, MsgType.File, MsgType.Audio, MsgType.Video].includes(
content.msgtype as MsgType,
);
const annotatedClassName = isEmote
? "mx_EventTile_annotated mx_EventTile_annotatedInline"
: "mx_EventTile_annotated";
const willHaveWrapper =
this.props.replacingEventId || this.props.isSeeingThroughMessageHiddenForModeration || isEmote;
@ -315,7 +318,7 @@ class InnerTextualBody extends React.Component<Props> {
if (this.props.replacingEventId) {
body = (
<div dir="auto" className="mx_EventTile_annotated">
<div dir="auto" className={annotatedClassName}>
{body}
{this.renderEditedMarker()}
</div>
@ -323,7 +326,7 @@ class InnerTextualBody extends React.Component<Props> {
}
if (this.props.isSeeingThroughMessageHiddenForModeration) {
body = (
<div dir="auto" className="mx_EventTile_annotated">
<div dir="auto" className={annotatedClassName}>
{body}
{this.renderPendingModerationMarker()}
</div>

View File

@ -1,92 +0,0 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2020-2022 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import React, { type ReactNode } from "react";
import classNames from "classnames";
import { type MatrixEvent } from "matrix-js-sdk/src/matrix";
import { _t } from "../../../languageHandler";
import Modal from "../../../Modal";
import AccessibleButton from "../elements/AccessibleButton";
import SettingsStore from "../../../settings/SettingsStore";
import ViewSource from "../../structures/ViewSource";
import { type Layout } from "../../../settings/enums/Layout";
import { BugReportDialogButton } from "../elements/BugReportDialogButton";
interface IProps {
mxEvent: MatrixEvent;
layout: Layout;
children: ReactNode;
}
interface IState {
error?: Error;
}
export default class TileErrorBoundary extends React.Component<IProps, IState> {
public constructor(props: IProps) {
super(props);
this.state = {};
}
public static getDerivedStateFromError(error: Error): Partial<IState> {
// Side effects are not permitted here, so we only update the state so
// that the next render shows an error message.
return { error };
}
private onViewSource = (): void => {
Modal.createDialog(
ViewSource,
{
mxEvent: this.props.mxEvent,
},
"mx_Dialog_viewsource",
);
};
public render(): ReactNode {
if (this.state.error) {
const { mxEvent } = this.props;
const classes = {
mx_EventTile: true,
mx_EventTile_info: true,
mx_EventTile_content: true,
mx_EventTile_tileError: true,
};
let viewSourceButton;
if (mxEvent && SettingsStore.getValue("developerMode")) {
viewSourceButton = (
<>
&nbsp;
<AccessibleButton onClick={this.onViewSource} kind="link">
{_t("action|view_source")}
</AccessibleButton>
</>
);
}
return (
<li className={classNames(classes)} data-layout={this.props.layout}>
<div className="mx_EventTile_line">
<span>
{_t("timeline|error_rendering_message")}
{mxEvent && ` (${mxEvent.getType()})`}
<BugReportDialogButton error={this.state.error} label="react-tile-soft-crash" />
{viewSourceButton}
</span>
</div>
</li>
);
}
return this.props.children;
}
}

View File

@ -1,152 +0,0 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2019 The Matrix.org Foundation C.I.C.
Copyright 2018, 2019 New Vector Ltd
Copyright 2017 Travis Ralston
Copyright 2016 OpenMarket 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 ReactNode, type JSX } from "react";
import { type Room } from "matrix-js-sdk/src/matrix";
import { InlineSpinner } from "@vector-im/compound-web";
import { _t } from "../../../languageHandler";
import SettingsStore from "../../../settings/SettingsStore";
import dis from "../../../dispatcher/dispatcher";
import { Action } from "../../../dispatcher/actions";
import { SettingLevel } from "../../../settings/SettingLevel";
import SettingsFlag from "../elements/SettingsFlag";
import SettingsFieldset from "../settings/SettingsFieldset";
import AccessibleButton, { type ButtonEvent } from "../elements/AccessibleButton";
import { useIsEncrypted } from "../../../hooks/useIsEncrypted.ts";
import { useMatrixClientContext } from "../../../contexts/MatrixClientContext.tsx";
import { useSettingValueAt } from "../../../hooks/useSettings.ts";
/**
* The URL preview settings for a room
*/
interface UrlPreviewSettingsProps {
/**
* The room.
*/
room: Room;
}
export function UrlPreviewSettings({ room }: UrlPreviewSettingsProps): JSX.Element {
const { roomId } = room;
const matrixClient = useMatrixClientContext();
const isEncrypted = useIsEncrypted(matrixClient, room);
const isLoading = isEncrypted === null;
return (
<SettingsFieldset
legend={_t("room_settings|general|url_previews_section")}
description={!isLoading && <Description isEncrypted={isEncrypted} />}
>
{isLoading ? (
<InlineSpinner />
) : (
<>
<PreviewsForRoom isEncrypted={isEncrypted} roomId={roomId} />
<SettingsFlag
name={isEncrypted ? "urlPreviewsEnabled_e2ee" : "urlPreviewsEnabled"}
level={SettingLevel.ROOM_DEVICE}
roomId={roomId}
/>
</>
)}
</SettingsFieldset>
);
}
/**
* Click handler for the user settings link
* @param e
*/
function onClickUserSettings(e: ButtonEvent): void {
e.preventDefault();
e.stopPropagation();
dis.fire(Action.ViewUserSettings);
}
/**
* The description for the URL preview settings
*/
interface DescriptionProps {
/**
* Whether the room is encrypted
*/
isEncrypted: boolean;
}
function Description({ isEncrypted }: DescriptionProps): JSX.Element {
const urlPreviewsEnabled = useSettingValueAt(SettingLevel.ACCOUNT, "urlPreviewsEnabled");
let previewsForAccount: ReactNode | undefined;
if (isEncrypted) {
previewsForAccount = _t("room_settings|general|url_preview_encryption_warning");
} else {
const button = {
a: (sub: string) => (
<AccessibleButton kind="link_inline" onClick={onClickUserSettings}>
{sub}
</AccessibleButton>
),
};
previewsForAccount = urlPreviewsEnabled
? _t("room_settings|general|user_url_previews_default_on", {}, button)
: _t("room_settings|general|user_url_previews_default_off", {}, button);
}
return (
<>
<p>{_t("room_settings|general|url_preview_explainer")}</p>
<p>{previewsForAccount}</p>
</>
);
}
/**
* The description for the URL preview settings
*/
interface PreviewsForRoomProps {
/**
* Whether the room is encrypted
*/
isEncrypted: boolean;
/**
* The room ID
*/
roomId: string;
}
function PreviewsForRoom({ isEncrypted, roomId }: PreviewsForRoomProps): JSX.Element | null {
const urlPreviewsEnabled = useSettingValueAt(
SettingLevel.ACCOUNT,
"urlPreviewsEnabled",
roomId,
/*explicit=*/ true,
);
if (isEncrypted) return null;
let previewsForRoom: ReactNode;
if (SettingsStore.canSetValue("urlPreviewsEnabled", roomId, SettingLevel.ROOM)) {
previewsForRoom = (
<SettingsFlag name="urlPreviewsEnabled" level={SettingLevel.ROOM} roomId={roomId} isExplicit={true} />
);
} else {
previewsForRoom = (
<div>
{urlPreviewsEnabled
? _t("room_settings|general|default_url_previews_on")
: _t("room_settings|general|default_url_previews_off")}
</div>
);
}
return previewsForRoom;
}

View File

@ -56,6 +56,8 @@ import {
PinnedMessageBadge,
ReactionsRowButtonView,
ReactionsRowView,
TileErrorView,
type TileErrorViewLayout,
useViewModel,
} from "@element-hq/web-shared-components";
@ -89,7 +91,6 @@ import { DecryptionFailureTracker } from "../../../DecryptionFailureTracker";
import { type ViewRoomPayload } from "../../../dispatcher/payloads/ViewRoomPayload";
import { shouldDisplayReply } from "../../../utils/Reply";
import PosthogTrackers from "../../../PosthogTrackers";
import TileErrorBoundary from "../messages/TileErrorBoundary";
import { haveRendererForEvent, isMessageEvent, renderTile } from "../../../events/EventTileFactory";
import ThreadSummary, { ThreadMessagePreview } from "./ThreadSummary";
import { ReadReceiptGroup } from "./ReadReceiptGroup";
@ -114,9 +115,11 @@ import {
MAX_ITEMS_WHEN_LIMITED,
ReactionsRowViewModel,
} from "../../../viewmodels/room/timeline/event-tile/reactions/ReactionsRowViewModel";
import { TileErrorViewModel } from "../../../viewmodels/message-body/TileErrorViewModel";
import { EventTileActionBarViewModel } from "../../../viewmodels/room/EventTileActionBarViewModel";
import { ThreadListActionBarViewModel } from "../../../viewmodels/room/ThreadListActionBarViewModel";
import { useMatrixClientContext } from "../../../contexts/MatrixClientContext";
import { useSettingValue } from "../../../hooks/useSettings";
import { DecryptionFailureBodyFactory, RedactedBodyFactory } from "../messages/MBodyFactory";
export type GetRelationsForEvent = (
@ -1571,12 +1574,77 @@ export class UnwrappedEventTile extends React.Component<EventTileProps, IState>
}
}
/**
* Props for the event-tile fallback rendered after the tile error boundary catches a render failure.
*/
interface EventTileErrorFallbackProps {
error: Error;
layout: Layout;
mxEvent: MatrixEvent;
}
function EventTileErrorFallback({ error, layout, mxEvent }: Readonly<EventTileErrorFallbackProps>): JSX.Element {
const developerMode = useSettingValue("developerMode");
const vm = useCreateAutoDisposedViewModel(
() => new TileErrorViewModel({ error, layout: layout as TileErrorViewLayout, mxEvent, developerMode }),
);
useEffect(() => {
vm.setError(error);
}, [error, vm]);
useEffect(() => {
vm.setLayout(layout as TileErrorViewLayout);
}, [layout, vm]);
useEffect(() => {
vm.setDeveloperMode(developerMode);
}, [developerMode, vm]);
return <TileErrorView vm={vm} className="mx_EventTile mx_EventTile_info mx_EventTile_content" />;
}
interface EventTileErrorBoundaryProps {
children: ReactNode;
layout: Layout;
mxEvent: MatrixEvent;
}
interface EventTileErrorBoundaryState {
error?: Error;
}
class EventTileErrorBoundary extends React.Component<EventTileErrorBoundaryProps, EventTileErrorBoundaryState> {
public constructor(props: EventTileErrorBoundaryProps) {
super(props);
this.state = {};
}
public static getDerivedStateFromError(error: Error): Partial<EventTileErrorBoundaryState> {
return { error };
}
public render(): ReactNode {
if (this.state.error) {
return (
<EventTileErrorFallback
error={this.state.error}
layout={this.props.layout}
mxEvent={this.props.mxEvent}
/>
);
}
return this.props.children;
}
}
// Wrap all event tiles with the tile error boundary so that any throws even during construction are captured
const SafeEventTile = (props: EventTileProps): JSX.Element => {
return (
<TileErrorBoundary mxEvent={props.mxEvent} layout={props.layout ?? Layout.Group}>
<EventTileErrorBoundary mxEvent={props.mxEvent} layout={props.layout ?? Layout.Group}>
<UnwrappedEventTile {...props} />
</TileErrorBoundary>
</EventTileErrorBoundary>
);
};
export default SafeEventTile;

View File

@ -76,8 +76,10 @@ export default class SetIntegrationManager extends React.Component<EmptyObject,
>
<div className="mx_SettingsFlag">
<div className="mx_SetIntegrationManager_heading_manager">
<Heading size="3">{_t("integration_manager|manage_title")}</Heading>
<Heading id="mx_SetIntegrationManager_ManagerName" size="4">
<Heading as="h2" size="3">
{_t("integration_manager|manage_title")}
</Heading>
<Heading id="mx_SetIntegrationManager_ManagerName" as="h3" size="4">
{managerName}
</Heading>
</div>

View File

@ -13,6 +13,10 @@ import { Heading } from "@vector-im/compound-web";
* The heading for a settings section.
*/
interface SettingsHeaderProps {
/**
* The component to render the heading as, defaults to h2
*/
as?: React.ComponentProps<typeof Heading>["as"];
/**
* Whether the user has a recommended tag.
*/
@ -23,12 +27,12 @@ interface SettingsHeaderProps {
label: string;
}
export function SettingsHeader({ hasRecommendedTag = false, label }: SettingsHeaderProps): JSX.Element {
export function SettingsHeader({ hasRecommendedTag = false, label, as = "h2" }: SettingsHeaderProps): JSX.Element {
const classes = classNames("mx_SettingsHeader", {
mx_SettingsHeader_recommended: hasRecommendedTag,
});
return (
<Heading className={classes} as="h2" size="sm" weight="semibold">
<Heading className={classes} as={as} size="sm" weight="semibold">
{label}
</Heading>
);

View File

@ -18,7 +18,7 @@ import { type ThirdPartyIdentifier } from "../../../AddThreepid";
import SettingsStore from "../../../settings/SettingsStore";
import { UIFeature } from "../../../settings/UIFeature";
import { AddRemoveThreepids } from "./AddRemoveThreepids";
import Heading from "../typography/Heading.tsx";
import { SettingsSection } from "./shared/SettingsSection.tsx";
type LoadingState = "loading" | "loaded" | "error";
@ -82,8 +82,7 @@ export const UserPersonalInfoSettings: React.FC<UserPersonalInfoSettingsProps> =
if (!SettingsStore.getValue(UIFeature.ThirdPartyID)) return null;
return (
<div className="mx_UserPersonalInfoSettings">
<Heading size="2">{_t("settings|general|personal_info")}</Heading>
<SettingsSection heading={_t("settings|general|personal_info")} className="mx_UserPersonalInfoSettings">
<SettingsSubsection
heading={_t("settings|general|emails_heading")}
stretchContent
@ -123,6 +122,6 @@ export const UserPersonalInfoSettings: React.FC<UserPersonalInfoSettingsProps> =
/>
</ThreepidSectionWrapper>
</SettingsSubsection>
</div>
</SettingsSection>
);
};

View File

@ -27,7 +27,7 @@ import AccessibleButton from "../elements/AccessibleButton";
import LogoutDialog, { shouldShowLogoutDialog } from "../dialogs/LogoutDialog";
import Modal from "../../../Modal";
import defaultDispatcher from "../../../dispatcher/dispatcher";
import Heading from "../typography/Heading.tsx";
import { SettingsSection } from "./shared/SettingsSection.tsx";
const SpinnerToast: React.FC<{ children?: ReactNode }> = ({ children }) => (
<>
@ -194,54 +194,55 @@ const UserProfileSettings: React.FC<UserProfileSettingsProps> = ({
const someFieldsDisabled = !canSetDisplayName || !canSetAvatar;
return (
<div className="mx_UserProfileSettings">
<Heading size="2">{_t("common|profile")}</Heading>
<div>
{someFieldsDisabled
? _t("settings|general|profile_subtitle_oidc")
: _t("settings|general|profile_subtitle")}
</div>
<div className="mx_UserProfileSettings_profile">
<AvatarSetting
avatar={avatarURL ?? undefined}
avatarAccessibleName={_t("common|user_avatar")}
onChange={onAvatarChange}
removeAvatar={avatarURL ? onAvatarRemove : undefined}
placeholderName={displayName}
placeholderId={client.getUserId() ?? ""}
disabled={!canSetAvatar}
/>
<EditInPlace
className="mx_UserProfileSettings_profile_displayName"
label={_t("settings|general|display_name")}
value={displayName}
saveButtonLabel={_t("common|save")}
cancelButtonLabel={_t("common|cancel")}
savedLabel={_t("common|saved")}
savingLabel={_t("common|updating")}
onChange={onDisplayNameChanged}
onCancel={onDisplayNameCancel}
onSave={onDisplayNameSave}
disabled={!canSetDisplayName}
>
{displayNameError && <ErrorMessage>{_t("settings|general|display_name_error")}</ErrorMessage>}
</EditInPlace>
</div>
{avatarError && (
<Alert title={_t("settings|general|avatar_upload_error_title")} type="critical">
{maxUploadSize === undefined
? _t("settings|general|avatar_upload_error_text_generic")
: _t("settings|general|avatar_upload_error_text", { size: formatBytes(maxUploadSize) })}
</Alert>
)}
{userIdentifier && <UsernameBox username={userIdentifier} />}
<Flex gap="var(--cpd-space-4x)" className="mx_UserProfileSettings_profile_buttons">
{externalAccountManagementUrl && (
<ManageAccountButton externalAccountManagementUrl={externalAccountManagementUrl} />
<SettingsSection heading={_t("common|profile")}>
<div className="mx_UserProfileSettings">
<div>
{someFieldsDisabled
? _t("settings|general|profile_subtitle_oidc")
: _t("settings|general|profile_subtitle")}
</div>
<div className="mx_UserProfileSettings_profile">
<AvatarSetting
avatar={avatarURL ?? undefined}
avatarAccessibleName={_t("common|user_avatar")}
onChange={onAvatarChange}
removeAvatar={avatarURL ? onAvatarRemove : undefined}
placeholderName={displayName}
placeholderId={client.getUserId() ?? ""}
disabled={!canSetAvatar}
/>
<EditInPlace
className="mx_UserProfileSettings_profile_displayName"
label={_t("settings|general|display_name")}
value={displayName}
saveButtonLabel={_t("common|save")}
cancelButtonLabel={_t("common|cancel")}
savedLabel={_t("common|saved")}
savingLabel={_t("common|updating")}
onChange={onDisplayNameChanged}
onCancel={onDisplayNameCancel}
onSave={onDisplayNameSave}
disabled={!canSetDisplayName}
>
{displayNameError && <ErrorMessage>{_t("settings|general|display_name_error")}</ErrorMessage>}
</EditInPlace>
</div>
{avatarError && (
<Alert title={_t("settings|general|avatar_upload_error_title")} type="critical">
{maxUploadSize === undefined
? _t("settings|general|avatar_upload_error_text_generic")
: _t("settings|general|avatar_upload_error_text", { size: formatBytes(maxUploadSize) })}
</Alert>
)}
<SignOutButton />
</Flex>
</div>
{userIdentifier && <UsernameBox username={userIdentifier} />}
<Flex gap="var(--cpd-space-4x)" className="mx_UserProfileSettings_profile_buttons">
{externalAccountManagementUrl && (
<ManageAccountButton externalAccountManagementUrl={externalAccountManagementUrl} />
)}
<SignOutButton />
</Flex>
</div>
</SettingsSection>
);
};

View File

@ -22,7 +22,11 @@ export interface DeviceTileProps {
}
const DeviceTileName: React.FC<{ device: ExtendedDevice }> = ({ device }) => {
return <Heading size="4">{device.display_name || device.device_id}</Heading>;
return (
<Heading as="h3" size="4">
{device.display_name || device.device_id}
</Heading>
);
};
const DeviceTile: React.FC<DeviceTileProps> = ({ device, children, isSelected, onClick }) => {

View File

@ -6,7 +6,7 @@
*/
import React, { useCallback } from "react";
import { InlineField, InlineSpinner, Label, Root, ToggleControl } from "@vector-im/compound-web";
import { InlineField, InlineSpinner, Label, Link, Root, ToggleControl } from "@vector-im/compound-web";
import type { FormEvent } from "react";
import { SettingsSection } from "../shared/SettingsSection";
@ -56,9 +56,9 @@ export const KeyStoragePanel: React.FC<Props> = ({ onKeyStorageDisableClick }) =
}
subHeading={_t("settings|encryption|key_storage|description", undefined, {
a: (sub) => (
<a href={SdkConfig.get("help_key_storage_url")} target="_blank" rel="noreferrer noopener">
<Link href={SdkConfig.get("help_key_storage_url")} target="_blank">
{sub}
</a>
</Link>
),
})}
>

View File

@ -7,7 +7,7 @@ Please see LICENSE files in the repository root for full details.
*/
import classnames from "classnames";
import React, { type HTMLAttributes } from "react";
import React, { type ComponentProps, createContext, type HTMLAttributes, useContext } from "react";
import Heading from "../../typography/Heading";
import { SettingsHeader } from "../SettingsHeader";
@ -19,15 +19,30 @@ export interface SettingsSectionProps extends HTMLAttributes<HTMLDivElement> {
legacy?: boolean;
}
function renderHeading(heading: string | React.ReactNode | undefined, legacy: boolean): React.ReactNode | undefined {
type HeadingLevel = 2 | 3 | 4 | 5 | 6;
/**
* React context to correctly set heading levels in nested settings sections dynamically
*/
export const HeadingLevelContext = createContext<HeadingLevel>(2);
function SectionHeading({
heading,
legacy,
level,
}: {
heading: string | React.ReactNode | undefined;
legacy: boolean;
level: HeadingLevel;
}): React.ReactNode | undefined {
switch (typeof heading) {
case "string":
return legacy ? (
<Heading as="h2" size="3">
<Heading as={`h${level}`} size={(level + 1).toString() as ComponentProps<typeof Heading>["size"]}>
{heading}
</Heading>
) : (
<SettingsHeader label={heading} />
<SettingsHeader as={`h${level}`} label={heading} />
);
case "undefined":
return undefined;
@ -60,22 +75,28 @@ export const SettingsSection: React.FC<SettingsSectionProps> = ({
legacy = true,
children,
...rest
}) => (
<div
{...rest}
className={classnames("mx_SettingsSection", className, {
mx_SettingsSection_newUi: !legacy,
})}
>
{heading &&
(subHeading ? (
<div className="mx_SettingsSection_header">
{renderHeading(heading, legacy)}
{subHeading}
</div>
) : (
renderHeading(heading, legacy)
))}
{legacy ? <div className="mx_SettingsSection_subSections">{children}</div> : children}
</div>
);
}) => {
const level = useContext(HeadingLevelContext);
return (
<div
{...rest}
className={classnames("mx_SettingsSection", className, {
mx_SettingsSection_newUi: !legacy,
})}
>
{heading &&
(subHeading ? (
<div className="mx_SettingsSection_header">
<SectionHeading heading={heading} legacy={legacy} level={level} />
{subHeading}
</div>
) : (
<SectionHeading heading={heading} legacy={legacy} level={level} />
))}
<HeadingLevelContext.Provider value={heading ? ((level + 1) as HeadingLevel) : level}>
{legacy ? <div className="mx_SettingsSection_subSections">{children}</div> : children}
</HeadingLevelContext.Provider>
</div>
);
};

View File

@ -7,10 +7,11 @@ Please see LICENSE files in the repository root for full details.
*/
import classNames from "classnames";
import React, { type HTMLAttributes } from "react";
import React, { type HTMLAttributes, useContext } from "react";
import { Form, Separator } from "@vector-im/compound-web";
import { SettingsSubsectionHeading } from "./SettingsSubsectionHeading";
import { HeadingLevelContext } from "./SettingsSection.tsx";
export interface SettingsSubsectionProps extends HTMLAttributes<HTMLDivElement> {
heading?: string | React.ReactNode;
@ -45,6 +46,8 @@ export const SettingsSubsection: React.FC<SettingsSubsectionProps> = ({
formWrap,
...rest
}) => {
const level = useContext(HeadingLevelContext);
const content = (
<div
{...rest}
@ -52,7 +55,11 @@ export const SettingsSubsection: React.FC<SettingsSubsectionProps> = ({
mx_SettingsSubsection_newUi: !legacy,
})}
>
{typeof heading === "string" ? <SettingsSubsectionHeading heading={heading} /> : <>{heading}</>}
{typeof heading === "string" ? (
<SettingsSubsectionHeading heading={heading} as={`h${level}`} />
) : (
<>{heading}</>
)}
{!!description && (
<div className="mx_SettingsSubsection_description">
<SettingsSubsectionText>{description}</SettingsSubsectionText>

View File

@ -18,7 +18,7 @@ export interface SettingsSubsectionHeadingProps extends HTMLAttributes<HTMLDivEl
export const SettingsSubsectionHeading: React.FC<SettingsSubsectionHeadingProps> = ({
heading,
as = "h3",
as = "h2",
children,
...rest
}) => {

View File

@ -15,14 +15,11 @@ import RoomProfileSettings from "../../../room_settings/RoomProfileSettings";
import AccessibleButton, { type ButtonEvent } from "../../../elements/AccessibleButton";
import dis from "../../../../../dispatcher/dispatcher";
import MatrixClientContext from "../../../../../contexts/MatrixClientContext";
import SettingsStore from "../../../../../settings/SettingsStore";
import { UIFeature } from "../../../../../settings/UIFeature";
import AliasSettings from "../../../room_settings/AliasSettings";
import PosthogTrackers from "../../../../../PosthogTrackers";
import { SettingsSubsection } from "../../shared/SettingsSubsection";
import SettingsTab from "../SettingsTab";
import { SettingsSection } from "../../shared/SettingsSection";
import { UrlPreviewSettings } from "../../../room_settings/UrlPreviewSettings";
import { MediaPreviewAccountSettings } from "../user/MediaPreviewAccountSettings";
interface IProps {
@ -62,10 +59,6 @@ export default class GeneralRoomSettingsTab extends React.Component<IProps, ISta
const canSetCanonical = room.currentState.mayClientSendStateEvent("m.room.canonical_alias", client);
const canonicalAliasEv = room.currentState.getStateEvents("m.room.canonical_alias", "") ?? undefined;
const urlPreviewSettings = SettingsStore.getValue(UIFeature.URLPreviews) ? (
<UrlPreviewSettings room={room} />
) : null;
let leaveSection;
if (room.getMyMembership() === KnownMembership.Join) {
leaveSection = (
@ -99,7 +92,6 @@ export default class GeneralRoomSettingsTab extends React.Component<IProps, ISta
</SettingsSection>
<SettingsSection heading={_t("room_settings|general|other_section")}>
{urlPreviewSettings}
<SettingsSubsection heading={_t("common|moderation_and_safety")} legacy={false}>
<MediaPreviewAccountSettings roomId={room.roomId} />
</SettingsSubsection>

View File

@ -45,7 +45,7 @@ const AccountSection: React.FC<AccountSectionProps> = ({
if (!canChangePassword) return <></>;
return (
<>
<SettingsSection>
<SettingsSubsection
heading={_t("settings|general|account_section")}
stretchContent
@ -59,7 +59,7 @@ const AccountSection: React.FC<AccountSectionProps> = ({
onFinished={onPasswordChanged}
/>
</SettingsSubsection>
</>
</SettingsSection>
);
};
@ -179,21 +179,19 @@ const AccountUserSettingsTab: React.FC<IProps> = ({ closeSettingsFn }) => {
return (
<SettingsTab data-testid="mx_AccountUserSettingsTab">
<SettingsSection>
<UserProfileSettings
externalAccountManagementUrl={externalAccountManagementUrl}
canSetDisplayName={canSetDisplayName}
canSetAvatar={canSetAvatar}
/>
{(!isAccountManagedExternally || canMake3pidChanges) && (
<UserPersonalInfoSettings canMake3pidChanges={canMake3pidChanges} />
)}
<AccountSection
canChangePassword={canChangePassword}
onPasswordChanged={onPasswordChanged}
onPasswordChangeError={onPasswordChangeError}
/>
</SettingsSection>
<UserProfileSettings
externalAccountManagementUrl={externalAccountManagementUrl}
canSetDisplayName={canSetDisplayName}
canSetAvatar={canSetAvatar}
/>
{(!isAccountManagedExternally || canMake3pidChanges) && (
<UserPersonalInfoSettings canMake3pidChanges={canMake3pidChanges} />
)}
<AccountSection
canChangePassword={canChangePassword}
onPasswordChanged={onPasswordChanged}
onPasswordChangeError={onPasswordChangeError}
/>
{accountManagementSection}
</SettingsTab>
);

View File

@ -16,7 +16,7 @@ import { SettingLevel } from "../../../../../settings/SettingLevel";
import SdkConfig from "../../../../../SdkConfig";
import BetaCard from "../../../beta/BetaCard";
import SettingsFlag from "../../../elements/SettingsFlag";
import { type FeatureSettingKey, LabGroup, labGroupNames } from "../../../../../settings/Settings";
import { type FeatureSettingKey, type LabGroup, labGroupNames } from "../../../../../settings/Settings";
import { EnhancedMap } from "../../../../../utils/maps";
import { SettingsSection } from "../../shared/SettingsSection";
import { SettingsSubsection, SettingsSubsectionText } from "../../shared/SettingsSubsection";
@ -71,10 +71,6 @@ export default class LabsUserSettingsTab extends React.Component<EmptyObject> {
.push(<SettingsFlag level={SettingLevel.DEVICE} name={f} key={f} />);
});
groups
.getOrCreate(LabGroup.Experimental, [])
.push(<SettingsFlag key="lowBandwidth" name="lowBandwidth" level={SettingLevel.DEVICE} />);
labsSections = (
<>
{sortBy(Array.from(groups.entries()), "0").map(([group, flags]) => (

View File

@ -147,11 +147,7 @@ export default class PreferencesUserSettingsTab extends React.Component<IProps,
"showCodeLineNumbers",
];
private static IMAGES_AND_VIDEOS_SETTINGS: BooleanSettingKey[] = [
"urlPreviewsEnabled",
"autoplayGifs",
"autoplayVideo",
];
private static IMAGES_AND_VIDEOS_SETTINGS: BooleanSettingKey[] = ["autoplayGifs", "autoplayVideo"];
private static TIMELINE_SETTINGS: BooleanSettingKey[] = [
"showTypingNotifications",
@ -350,6 +346,19 @@ export default class PreferencesUserSettingsTab extends React.Component<IProps,
{this.renderGroup(PreferencesUserSettingsTab.CODE_BLOCKS_SETTINGS)}
</SettingsSubsection>
<SettingsSubsection
heading={_t("settings|preferences|link_previews_heading")}
description={_t("settings|preferences|link_previews_description")}
formWrap
>
<SettingsFlag name="urlPreviewsEnabled" level={SettingLevel.DEVICE} />
<SettingsFlag
name="urlPreviewsEnabled_e2ee"
level={SettingLevel.DEVICE}
requires={["urlPreviewsEnabled"]}
/>
</SettingsSubsection>
<SettingsSubsection heading={_t("settings|preferences|media_heading")} formWrap>
{this.renderGroup(PreferencesUserSettingsTab.IMAGES_AND_VIDEOS_SETTINGS)}
</SettingsSubsection>

View File

@ -58,6 +58,8 @@ const QuickSettingsButton: React.FC<{
onFinished={closeMenu}
managed={false}
focusLock={true}
role="region"
aria-label={_t("quick_settings|title")}
>
<h2>{_t("quick_settings|title")}</h2>

View File

@ -72,7 +72,7 @@ const QuickThemeSwitcher: React.FC<Props> = ({ requestClose }) => {
return (
<div className="mx_QuickThemeSwitcher">
<h4 className="mx_QuickThemeSwitcher_heading">{_t("common|theme")}</h4>
<h3 className="mx_QuickThemeSwitcher_heading">{_t("common|theme")}</h3>
<Dropdown
id="mx_QuickSettingsButton_themePickerDropdown"
onOptionChange={onOptionChange}

View File

@ -828,8 +828,8 @@
"invalid_device_key_id": "Invalid device key ID",
"invalid_json": "Doesn't look like valid JSON.",
"level": "Level",
"low_bandwidth_mode": "Low bandwidth mode",
"low_bandwidth_mode_description": "Requires compatible homeserver.",
"low_bandwidth_mode": "Disable bandwidth-heavy features",
"low_bandwidth_mode_description": "Disables encryption, presence, avatars, read receipts, and typing notifications",
"main_timeline": "Main timeline",
"manual_device_verification": "Manual device verification",
"no_receipt_found": "No receipt found",
@ -2238,8 +2238,6 @@
"aliases_section": "Room Addresses",
"avatar_field_label": "Room avatar",
"canonical_alias_field_label": "Main address",
"default_url_previews_off": "URL previews are disabled by default for participants in this room.",
"default_url_previews_on": "URL previews are enabled by default for participants in this room.",
"description_space": "Edit settings relating to your space.",
"error_creating_alias_description": "There was an error creating that address. It may not be allowed by the server or a temporary failure occurred.",
"error_creating_alias_title": "Error creating address",
@ -2270,12 +2268,7 @@
"published_aliases_explainer_space": "Published addresses can be used by anyone on any server to join your space.",
"published_aliases_section": "Published Addresses",
"save": "Save Changes",
"topic_field_label": "Room Topic",
"url_preview_encryption_warning": "In encrypted rooms, like this one, URL previews are disabled by default to ensure that your homeserver (where the previews are generated) cannot gather information about links you see in this room.",
"url_preview_explainer": "When someone puts a URL in their message, a URL preview can be shown to give more information about that link such as the title, description, and an image from the website.",
"url_previews_section": "URL Previews",
"user_url_previews_default_off": "You have <a>disabled</a> URL previews by default.",
"user_url_previews_default_on": "You have <a>enabled</a> URL previews by default."
"topic_field_label": "Room Topic"
},
"notifications": {
"browse_button": "Browse",
@ -2697,9 +2690,8 @@
"unable_to_load_msisdns": "Unable to load phone numbers",
"username": "Username"
},
"inline_url_previews_default": "Enable inline URL previews by default",
"inline_url_previews_room": "Enable URL previews by default for participants in this room",
"inline_url_previews_room_account": "Enable URL previews for this room (only affects you)",
"inline_url_previews_default": "Enable previews",
"inline_url_previews_encrypted": "Enable previews in encrypted rooms",
"insert_trailing_colon_mentions": "Insert a trailing colon after user mentions at the start of a message",
"invite_controls": {
"default_label": "Allow users to invite you to rooms"
@ -2837,6 +2829,8 @@
"enable_tray_icon": "Show tray icon and minimise window to it on close",
"keyboard_heading": "Keyboard shortcuts",
"keyboard_view_shortcuts_button": "To view all keyboard shortcuts, <a>click here</a>.",
"link_previews_description": "Shows information about links underneath messages",
"link_previews_heading": "Link previews",
"media_heading": "Images, GIFs and videos",
"presence_description": "Share your activity and status with others.",
"publish_timezone": "Publish timezone on public profile",
@ -4003,6 +3997,7 @@
"change_name_this_room": "Change the name of this room",
"change_topic_active_room": "Change the topic of your active room",
"change_topic_this_room": "Change the topic of this room",
"download_file": "Download files from the media repository",
"receive_membership_active_room": "See when people join, leave, or are invited to your active room",
"receive_membership_this_room": "See when people join, leave, or are invited to this room",
"remove_ban_invite_leave_active_room": "Remove, ban, or invite people to your active room, and make you leave",

View File

@ -1546,7 +1546,6 @@
"mjolnir": "Új lehetőség emberek figyelmen kívül hagyására",
"msc3531_hide_messages_pending_moderation": "A moderátorok kitakarhatják a még nem moderált üzeneteket.",
"new_room_list": "Új szobalista engedélyezése",
"new_timeline": "Új idővonal (MVVM)",
"notification_settings": "Új értesítési beállítások",
"notification_settings_beta_caption": "Bemutatjuk az értesítési beállítások módosításának egyszerűbb módját. Testreszabhatja a sajátját, ahogy tetszik itt: %(brand)s.",
"notification_settings_beta_title": "Értesítési beállítások",

View File

@ -115,7 +115,7 @@
"show_advanced": "Показать дополнительные настройки",
"show_all": "Показать все",
"sign_in": "Войти",
"sign_out": "Выйти",
"sign_out": "Удалить это устройство",
"skip": "Пропустить",
"start": "Начать",
"start_chat": "Отправить личное сообщение",
@ -405,7 +405,7 @@
"download_logs": "Скачать журналы",
"downloading_logs": "Скачивание журналов",
"error_empty": "Пожалуйста, расскажите нам что пошло не так, либо, ещё лучше, создайте отчёт в GitHub с описанием проблемы.",
"failed_download_logs": "Не удалось загрузить журналы отладки: ",
"failed_download_logs": "Не удалось скачать журналы отладки: ",
"failed_send_logs_causes": {
"disallowed_app": "Ваш отчет об ошибке отклонен. Сервер rageshake не поддерживает это приложение.",
"rejected_generic": "Ваш отчет об ошибке отклонен. Сервер rageshake отклонил содержимое отчета из-за политики.",
@ -419,7 +419,7 @@
"log_request": "Чтобы помочь нам предотвратить это в будущем, пожалуйста, <a>отправьте нам логи</a>.",
"logs_sent": "Журналы отправлены",
"matrix_security_issue": "Чтобы сообщить о проблеме безопасности Matrix, пожалуйста, прочитайте <a>Политику раскрытия информации</a> Matrix.org.",
"preparing_download": "Подготовка к загрузке журналов",
"preparing_download": "Подготовка к скачиванию журналов",
"preparing_logs": "Подготовка к отправке журналов",
"send_logs": "Отправить журналы",
"submit_debug_logs": "Отправить отладочные журналы",
@ -563,6 +563,7 @@
"someone": "Кто-то",
"space": "Пространство",
"spaces": "Пространства",
"state_encryption_enabled": "Экспериментальное шифрование включено",
"sticker": "Стикер",
"stickerpack": "Набор стикеров",
"success": "Успех",
@ -666,10 +667,11 @@
"join_rule_knock_label": "Любой желающий может подать заявку на участие, но администраторы или модераторы должны предоставить доступ. Это можно изменить позже.",
"join_rule_public_label": "Любой желающий сможет найти эту комнату и присоединиться к ней.",
"join_rule_public_parent_space_label": "Любой сможет найти и присоединиться к этой комнате, а не только участники <SpaceName/>.",
"join_rule_restricted": "Видимая для участников пространства",
"join_rule_restricted_label": "Все в <SpaceName/> смогут найти и присоединиться к этой комнате.",
"join_rule_restricted": "Стандартная",
"join_rule_restricted_label": "Любой может присоединиться в <SpaceName/>.",
"name_validation_required": "Пожалуйста, введите название комнаты",
"room_visibility_label": "Видимость комнаты",
"state_encryption_label": "Шифровать события состояния",
"title_private_room": "Создать приватную комнату",
"title_public_room": "Создать публичную комнату",
"title_video_room": "Создайте видеокомнату",
@ -807,6 +809,7 @@
"event_id": "ID события: %(eventId)s",
"event_sent": "Событие отправлено!",
"event_type": "Тип события",
"expired": "Срок действия истек",
"explore_account_data": "Посмотреть данные учётной записи",
"explore_room_account_data": "Посмотреть данные учётной записи комнаты",
"explore_room_state": "Посмотреть состояние комнаты",
@ -907,7 +910,7 @@
"widget_screenshots": "Включить скриншоты виджетов для поддерживаемых виджетов"
},
"dialog_close_label": "Закрыть диалог",
"download_completed": "Загрузка завершена",
"download_completed": "Скачивание завершено",
"emoji": {
"categories": "Категории",
"category_activities": "Действия",
@ -1665,9 +1668,9 @@
},
"member_list": {
"count": {
"one": "%(count)s Участник",
"few": "%(count)s Участника",
"many": "%(count)s Участников"
"one": "%(count)s участник",
"few": "%(count)s участника",
"many": "%(count)s участников"
},
"filter_placeholder": "Поиск по участникам",
"invite_button_no_perms_tooltip": "У вас нет разрешения приглашать пользователей",
@ -3235,7 +3238,8 @@
"copy_link_text": "Копировать ссылку приглашения",
"count_of_members": {
"one": "%(count)s участник",
"other": "%(count)s участников"
"few": "%(count)s участника",
"many": "%(count)s участников"
},
"create_new_room_button": "Создать комнату",
"failed_querying_public_rooms": "Не удалось запросить публичную комнату",

View File

@ -51,6 +51,7 @@ import MediaPreviewConfigController from "./controllers/MediaPreviewConfigContro
import InviteRulesConfigController from "./controllers/InviteRulesConfigController.ts";
import { type ComputedInviteConfig } from "../@types/invite-rules.ts";
import BlockInvitesConfigController from "./controllers/BlockInvitesConfigController.ts";
import RequiresSettingsController from "./controllers/RequiresSettingsController.ts";
export const defaultWatchManager = new WatchManager();
@ -327,6 +328,10 @@ export interface Settings {
}>;
"breadcrumbs": IBaseSetting<boolean>;
"showHiddenEventsInTimeline": IBaseSetting<boolean>;
/**
* This is the 2019-era low bandwidth that deals with disabling features of the
* client. It does NOT make any API or spec changes.
*/
"lowBandwidth": IBaseSetting<boolean>;
"fallbackICEServerAllowed": IBaseSetting<boolean | null>;
"RoomList.preferredSorting": IBaseSetting<SortingAlgorithm>;
@ -1136,22 +1141,22 @@ export const SETTINGS: Settings = {
controller: new UIFeatureController(UIFeature.AdvancedEncryption),
},
"urlPreviewsEnabled": {
supportedLevels: LEVELS_ROOM_SETTINGS_WITH_ROOM,
displayName: {
"default": _td("settings|inline_url_previews_default"),
"room-account": _td("settings|inline_url_previews_room_account"),
"room": _td("settings|inline_url_previews_room"),
},
// Enabled by default and client configurable as this setting only allows unencrypted
// messages to be previewed.
supportedLevels: [SettingLevel.DEVICE, SettingLevel.ACCOUNT, SettingLevel.CONFIG],
supportedLevelsAreOrdered: true,
displayName: _td("settings|inline_url_previews_default"),
default: true,
controller: new UIFeatureController(UIFeature.URLPreviews),
},
"urlPreviewsEnabled_e2ee": {
supportedLevels: [SettingLevel.ROOM_DEVICE],
displayName: {
"room-device": _td("settings|inline_url_previews_room_account"),
},
// Can only be enabled per-device to ensure neither the homeserver nor client config
// can impact the user's choices.
supportedLevels: [SettingLevel.DEVICE],
supportedLevelsAreOrdered: true,
displayName: _td("settings|inline_url_previews_encrypted"),
default: false,
controller: new UIFeatureController(UIFeature.URLPreviews),
controller: new RequiresSettingsController([UIFeature.URLPreviews, "urlPreviewsEnabled"]),
},
"notificationsEnabled": {
supportedLevels: LEVELS_DEVICE_ONLY_SETTINGS,

View File

@ -654,40 +654,6 @@ export default class SettingsStore {
return null;
}
/**
* Migrate the setting for URL previews in e2e rooms from room account
* data to the room device level.
*
* @param isFreshLogin True if the user has just logged in, false if a previous session is being restored.
*/
private static async migrateURLPreviewsE2EE(isFreshLogin: boolean): Promise<void> {
const MIGRATION_DONE_FLAG = "url_previews_e2ee_migration_done";
if (localStorage.getItem(MIGRATION_DONE_FLAG)) return;
if (isFreshLogin) return;
const client = MatrixClientPeg.safeGet();
while (!client.isInitialSyncComplete()) {
await new Promise((r) => client.once(ClientEvent.Sync, r));
}
logger.info("Performing one-time settings migration of URL previews in E2EE rooms");
const roomAccounthandler = LEVEL_HANDLERS[SettingLevel.ROOM_ACCOUNT];
for (const room of client.getRooms()) {
// We need to use the handler directly because this setting is no longer supported
// at this level at all
const val = roomAccounthandler.getValue("urlPreviewsEnabled_e2ee", room.roomId);
if (val !== undefined) {
await SettingsStore.setValue("urlPreviewsEnabled_e2ee", room.roomId, SettingLevel.ROOM_DEVICE, val);
}
}
localStorage.setItem(MIGRATION_DONE_FLAG, "true");
}
/**
* Migrate the setting for visible images to a setting.
*/
@ -739,15 +705,6 @@ export default class SettingsStore {
* Runs or queues any setting migrations needed.
*/
public static runMigrations(isFreshLogin: boolean): void {
// This can be removed once enough users have run a version of Element with
// this migration. A couple of months after its release should be sufficient
// (so around October 2024).
// The consequences of missing the migration are only that URL previews will
// be disabled in E2EE rooms.
SettingsStore.migrateURLPreviewsE2EE(isFreshLogin).catch((e) => {
logger.error("Failed to migrate URL previews in E2EE rooms:", e);
});
// This can be removed once enough users have run a version of Element with
// this migration.
// The consequences of missing the migration are that previously shown images

View File

@ -0,0 +1,34 @@
/*
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 SettingController from "./SettingController";
import SettingsStore from "../SettingsStore";
import type { BooleanSettingKey } from "../Settings.tsx";
/**
* Disables a setting & forces it's value if one or more settings are not enabled
*/
export default class RequiresSettingsController extends SettingController {
public constructor(
public readonly settingNames: BooleanSettingKey[],
private forcedValue = false,
) {
super();
}
public getValueOverride(): any {
if (this.settingDisabled) {
// per the docs: we force a disabled state when the feature isn't active
return this.forcedValue;
}
return null; // no override
}
public get settingDisabled(): boolean {
return this.settingNames.some((s) => !SettingsStore.getValue(s));
}
}

View File

@ -76,15 +76,6 @@ export default class RoomAccountSettingsHandler extends MatrixClientBackedSettin
};
public getValue(settingName: string, roomId: string): any {
// Special case URL previews
if (settingName === "urlPreviewsEnabled") {
const content = this.getSettings(roomId, "org.matrix.room.preview_urls") || {};
// Check to make sure that we actually got a boolean
if (typeof content["disable"] !== "boolean") return null;
return !content["disable"];
}
// Special case allowed widgets
if (settingName === "allowedWidgets") {
return this.getSettings(roomId, ALLOWED_WIDGETS_EVENT_TYPE);

View File

@ -0,0 +1,128 @@
/*
* 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 { type MouseEventHandler } from "react";
import { type MatrixEvent } from "matrix-js-sdk/src/matrix";
import {
BaseViewModel,
type TileErrorViewLayout,
type TileErrorViewSnapshot as TileErrorViewSnapshotInterface,
type TileErrorViewModel as TileErrorViewModelInterface,
} from "@element-hq/web-shared-components";
import { _t } from "../../languageHandler";
import Modal from "../../Modal";
import SdkConfig from "../../SdkConfig";
import { BugReportEndpointURLLocal } from "../../IConfigOptions";
import ViewSource from "../../components/structures/ViewSource";
import BugReportDialog from "../../components/views/dialogs/BugReportDialog";
const TILE_ERROR_BUG_REPORT_LABEL = "react-tile-soft-crash";
export interface TileErrorViewModelProps {
/**
* Layout variant used by the host timeline.
*/
layout: TileErrorViewLayout;
/**
* Event whose tile failed to render.
*/
mxEvent: MatrixEvent;
/**
* Render error captured by the boundary.
*/
error: Error;
/**
* Whether developer mode is enabled, which controls the view-source action.
*/
developerMode: boolean;
}
function getBugReportCtaLabel(): string | undefined {
const bugReportUrl = SdkConfig.get().bug_report_endpoint_url;
if (!bugReportUrl) {
return undefined;
}
return bugReportUrl === BugReportEndpointURLLocal
? _t("bug_reporting|download_logs")
: _t("bug_reporting|submit_debug_logs");
}
/**
* Returns the localized view-source action label when developer mode is enabled.
*/
function getViewSourceCtaLabel(developerMode: boolean): string | undefined {
return developerMode ? _t("action|view_source") : undefined;
}
/**
* ViewModel for the tile error fallback, providing the snapshot shown when a tile fails to render.
*
* The snapshot includes the host timeline layout, the fallback message, the event type,
* and optional bug-report and view-source action labels. The view model also exposes
* click handlers for those actions, opening the bug-report or view-source dialog when
* available.
*/
export class TileErrorViewModel
extends BaseViewModel<TileErrorViewSnapshotInterface, TileErrorViewModelProps>
implements TileErrorViewModelInterface
{
private static readonly computeSnapshot = (props: TileErrorViewModelProps): TileErrorViewSnapshotInterface => ({
layout: props.layout,
message: _t("timeline|error_rendering_message"),
eventType: props.mxEvent.getType(),
bugReportCtaLabel: getBugReportCtaLabel(),
viewSourceCtaLabel: getViewSourceCtaLabel(props.developerMode),
});
public constructor(props: TileErrorViewModelProps) {
super(props, TileErrorViewModel.computeSnapshot(props));
}
public setLayout(layout: TileErrorViewLayout): void {
this.props.layout = layout;
this.snapshot.merge({ layout });
}
public setError(error: Error): void {
this.props.error = error;
}
public setDeveloperMode(developerMode: boolean): void {
this.props.developerMode = developerMode;
const nextViewSourceCtaLabel = getViewSourceCtaLabel(developerMode);
this.snapshot.merge({ viewSourceCtaLabel: nextViewSourceCtaLabel });
}
public onBugReportClick: MouseEventHandler<HTMLButtonElement> = () => {
if (!this.snapshot.current.bugReportCtaLabel) {
return;
}
Modal.createDialog(BugReportDialog, {
label: TILE_ERROR_BUG_REPORT_LABEL,
error: this.props.error,
});
};
public onViewSourceClick: MouseEventHandler<HTMLButtonElement> = () => {
if (!this.snapshot.current.viewSourceCtaLabel) {
return;
}
Modal.createDialog(
ViewSource,
{
mxEvent: this.props.mxEvent,
},
"mx_Dialog_viewsource",
);
};
}

View File

@ -19,6 +19,10 @@ import { type RoomNotificationState } from "../../stores/notifications/RoomNotif
interface RoomListSectionHeaderViewModelProps {
tag: string;
title: string;
/**
* The ID of the current space.
*/
spaceId: string;
onToggleExpanded: (isExpanded: boolean) => void;
}
@ -31,12 +35,19 @@ export class RoomListSectionHeaderViewModel
*/
private roomNotificationStates = new Set<RoomNotificationState>();
/**
* Tracks the expanded/collapsed state per space.
* Key is spaceId. Defaults to expanded if not set.
*/
private readonly expandedBySpace = new Map<string, boolean>();
public constructor(props: RoomListSectionHeaderViewModelProps) {
super(props, { id: props.tag, title: props.title, isExpanded: true, isUnread: false });
}
public onClick = (): void => {
const isExpanded = !this.snapshot.current.isExpanded;
this.expandedBySpace.set(this.props.spaceId, isExpanded);
this.snapshot.merge({ isExpanded });
this.props.onToggleExpanded(isExpanded);
};
@ -48,6 +59,25 @@ export class RoomListSectionHeaderViewModel
return this.snapshot.current.isExpanded;
}
/**
* Set whether the section is expanded for the current space.
* This will not trigger the onToggleExpanded callback.
*/
public set isExpanded(value: boolean) {
this.expandedBySpace.set(this.props.spaceId, value);
this.snapshot.merge({ isExpanded: value });
}
/**
* Switch to a different space, restoring the expanded state for that space.
* Defaults to expanded if no state has been saved for the space.
*/
public setSpace(spaceId: string): void {
this.props.spaceId = spaceId;
const isExpanded = this.expandedBySpace.get(this.props.spaceId) ?? true;
this.snapshot.merge({ isExpanded });
}
/**
* Update the rooms tracked by this section header for unread state computation.
* Only subscribes to new rooms and unsubscribes from rooms no longer in the section.

View File

@ -182,6 +182,16 @@ export class RoomListViewModel
// Update roomsMap immediately before clearing VMs
this.updateRoomsMap(this.roomsResult);
// When a filter is toggled on, expand sections that have results so they're visible
if (newFilter) {
for (const section of this.roomsResult.sections) {
if (section.rooms.length > 0) {
const sectionHeaderVM = this.roomSectionHeaderViewModels.get(section.tag);
if (sectionHeaderVM) sectionHeaderVM.isExpanded = true;
}
}
}
this.updateRoomListData();
};
@ -258,6 +268,7 @@ export class RoomListViewModel
const viewModel = new RoomListSectionHeaderViewModel({
tag,
title,
spaceId: this.roomsResult.spaceId,
onToggleExpanded: () => this.updateRoomListData(),
});
this.roomSectionHeaderViewModels.set(tag, viewModel);
@ -367,6 +378,11 @@ export class RoomListViewModel
this.updateRoomsMap(this.roomsResult);
// Restore the expanded/collapsed state for the new space
for (const viewModel of this.roomSectionHeaderViewModels.values()) {
viewModel.setSpace(newSpaceId);
}
// Space changed - get the last selected room for the new space to prevent flicker
const lastSelectedRoom = SpaceStore.instance.getLastSelectedRoomIdForSpace(newSpaceId);
@ -501,6 +517,12 @@ export class RoomListViewModel
this.roomsResult,
(tag) => this.roomSectionHeaderViewModels.get(tag)?.isExpanded ?? true,
);
// If it's a flat list, we need to make sure the single section is expanded and has all rooms, otherwise the room list will be empty
if (isFlatList) {
const chatSections = this.roomSectionHeaderViewModels.get(CHATS_TAG);
if (chatSections) chatSections.isExpanded = true;
chatSections?.setRooms(this.roomsResult.sections.flatMap((section) => section.rooms));
}
this.sections = sections;
// Calculate the active room index from the computed sections (which exclude collapsed sections' rooms)

View File

@ -57,6 +57,12 @@ export class ResizerViewModel
}, 50);
public onLeftPanelResized = (newSize: number): void => {
// We don't want the panels to have fractional widths as that can cause blurry UI elements.
if (!Number.isInteger(newSize)) {
this.panelHandle?.resize(`${Math.round(newSize)}%`);
return;
}
const isCollapsed = newSize === 0;
// Store the size if the panel isn't collapsed.
if (!isCollapsed) {
@ -76,7 +82,7 @@ export class ResizerViewModel
public onSeparatorClick = (): void => {
if (this.panelHandle?.isCollapsed()) {
const lastSize = SettingsStore.getValue("RoomList.panelSize");
this.panelHandle.resize(`${lastSize}%`);
this.panelHandle.resize(`${lastSize ?? 100}%`);
}
};

View File

@ -57,6 +57,9 @@ export class CapabilityText {
[MatrixCapabilities.MSC2931Navigate]: {
[GENERIC_WIDGET_KIND]: _td("widget|capability|switch_room_message_user"),
},
[MatrixCapabilities.MSC4039DownloadFile]: {
[GENERIC_WIDGET_KIND]: _td("widget|capability|download_file"),
},
};
private static stateSendRecvCaps: SendRecvStaticCapText = {

View File

@ -15,9 +15,9 @@ exports[`bodyToHtml feature_latex_maths should not mangle code blocks 1`] = `"<p
exports[`bodyToHtml feature_latex_maths should not mangle divs 1`] = `"<p>hello</p><div>world</div>"`;
exports[`bodyToHtml feature_latex_maths should render block katex 1`] = `"<p>hello</p><span class="katex-display"><span class="katex"><span class="katex-mathml"><math xmlns="http://www.w3.org/1998/Math/MathML" display="block"><semantics><mrow><mi>ξ</mi></mrow><annotation encoding="application/x-tex">\\xi</annotation></semantics></math></span><span class="katex-html" aria-hidden="true"><span class="base"><span class="strut" style="height:0.8889em;vertical-align:-0.1944em;"></span><span class="mord mathnormal" style="margin-right:0.04601em;">ξ</span></span></span></span></span><p>world</p>"`;
exports[`bodyToHtml feature_latex_maths should render block katex 1`] = `"<p>hello</p><span class="katex-display"><span class="katex"><span class="katex-mathml"><math xmlns="http://www.w3.org/1998/Math/MathML" display="block"><semantics><mrow><mi>ξ</mi></mrow><annotation encoding="application/x-tex">\\xi</annotation></semantics></math></span><span class="katex-html" aria-hidden="true"><span class="base"><span class="strut" style="height:0.8889em;vertical-align:-0.1944em;"></span><span class="mord mathnormal" style="margin-right:0.046em;">ξ</span></span></span></span></span><p>world</p>"`;
exports[`bodyToHtml feature_latex_maths should render inline katex 1`] = `"hello <span class="katex"><span class="katex-mathml"><math xmlns="http://www.w3.org/1998/Math/MathML"><semantics><mrow><mi>ξ</mi></mrow><annotation encoding="application/x-tex">\\xi</annotation></semantics></math></span><span class="katex-html" aria-hidden="true"><span class="base"><span class="strut" style="height:0.8889em;vertical-align:-0.1944em;"></span><span class="mord mathnormal" style="margin-right:0.04601em;">ξ</span></span></span></span> world"`;
exports[`bodyToHtml feature_latex_maths should render inline katex 1`] = `"hello <span class="katex"><span class="katex-mathml"><math xmlns="http://www.w3.org/1998/Math/MathML"><semantics><mrow><mi>ξ</mi></mrow><annotation encoding="application/x-tex">\\xi</annotation></semantics></math></span><span class="katex-html" aria-hidden="true"><span class="base"><span class="strut" style="height:0.8889em;vertical-align:-0.1944em;"></span><span class="mord mathnormal" style="margin-right:0.046em;">ξ</span></span></span></span> world"`;
exports[`bodyToNode generates big emoji for emoji made of multiple characters 1`] = `
<DocumentFragment>

View File

@ -120,6 +120,29 @@ describe("VoiceRecording", () => {
}),
);
});
it("should request the selected microphone as an exact device constraint", async () => {
MediaDeviceHandlerMock.getAudioInput.mockReturnValue("selected-mic");
await recording.start();
expect(navigator.mediaDevices.getUserMedia).toHaveBeenCalledWith(
expect.objectContaining({
audio: expect.objectContaining({ deviceId: { exact: "selected-mic" } }),
}),
);
});
it("should not force an exact microphone when default device is selected", async () => {
MediaDeviceHandlerMock.getAudioInput.mockReturnValue("default");
await recording.start();
const constraints = mocked(navigator.mediaDevices.getUserMedia).mock.calls[0][0] as MediaStreamConstraints;
expect(constraints.audio).toEqual(
expect.not.objectContaining({
deviceId: expect.anything(),
}),
);
});
});
describe("when recording", () => {

View File

@ -36,7 +36,7 @@ describe("PictureInPictureDragger", () => {
describe("when rendering the dragger with PiP content 1", () => {
beforeEach(() => {
renderResult = render(<PictureInPictureDragger draggable={true}>{mkContent1}</PictureInPictureDragger>);
renderResult = render(<PictureInPictureDragger>{mkContent1}</PictureInPictureDragger>);
});
it("should render the PiP content", () => {
@ -45,7 +45,7 @@ describe("PictureInPictureDragger", () => {
describe("and rerendering PiP content 1", () => {
beforeEach(() => {
renderResult.rerender(<PictureInPictureDragger draggable={true}>{mkContent1}</PictureInPictureDragger>);
renderResult.rerender(<PictureInPictureDragger>{mkContent1}</PictureInPictureDragger>);
});
it("should not change the PiP content", () => {
@ -55,7 +55,7 @@ describe("PictureInPictureDragger", () => {
describe("and rendering PiP content 2", () => {
beforeEach(() => {
renderResult.rerender(<PictureInPictureDragger draggable={true}>{mkContent2}</PictureInPictureDragger>);
renderResult.rerender(<PictureInPictureDragger>{mkContent2}</PictureInPictureDragger>);
});
it("should update the PiP content", () => {
@ -66,9 +66,7 @@ describe("PictureInPictureDragger", () => {
describe("when rendering the dragger with PiP content 1 and 2", () => {
beforeEach(() => {
renderResult = render(
<PictureInPictureDragger draggable={true}>{[...mkContent1, ...mkContent2]}</PictureInPictureDragger>,
);
renderResult = render(<PictureInPictureDragger>{[...mkContent1, ...mkContent2]}</PictureInPictureDragger>);
});
it("should render both contents", () => {
@ -83,7 +81,7 @@ describe("PictureInPictureDragger", () => {
beforeEach(() => {
clickSpy = jest.fn();
render(
<PictureInPictureDragger draggable={true}>
<PictureInPictureDragger>
{[
({ onStartMoving }) => (
// eslint-disable-next-line jsx-a11y/click-events-have-key-events

View File

@ -3,6 +3,7 @@
exports[`PictureInPictureDragger when rendering the dragger with PiP content 1 and 2 should render both contents 1`] = `
<div>
<aside
class="mx_PictureInPictureDragger"
style="transform: translateX(672px) translateY(80px);"
>
<div>
@ -20,6 +21,7 @@ exports[`PictureInPictureDragger when rendering the dragger with PiP content 1 a
exports[`PictureInPictureDragger when rendering the dragger with PiP content 1 and rendering PiP content 2 should update the PiP content 1`] = `
<div>
<aside
class="mx_PictureInPictureDragger"
style="transform: translateX(672px) translateY(80px);"
>
<div>
@ -34,6 +36,7 @@ exports[`PictureInPictureDragger when rendering the dragger with PiP content 1 a
exports[`PictureInPictureDragger when rendering the dragger with PiP content 1 and rerendering PiP content 1 should not change the PiP content: pip-content-1 1`] = `
<div>
<aside
class="mx_PictureInPictureDragger"
style="transform: translateX(672px) translateY(80px);"
>
<div>
@ -46,6 +49,7 @@ exports[`PictureInPictureDragger when rendering the dragger with PiP content 1 a
exports[`PictureInPictureDragger when rendering the dragger with PiP content 1 should render the PiP content: pip-content-1 1`] = `
<div>
<aside
class="mx_PictureInPictureDragger"
style="transform: translateX(672px) translateY(80px);"
>
<div>

View File

@ -237,6 +237,50 @@ exports[`DevtoolsDialog renders the devtools dialog 1`] = `
</label>
</div>
</div>
<div
class="_inline-field_19upo_32"
>
<div
class="_inline-field-control_19upo_44"
>
<div
class="_container_udcm8_10"
>
<input
class="_input_udcm8_24"
id="mx_SettingsFlag_4yVCeEefiPqp"
role="switch"
type="checkbox"
/>
<div
class="_ui_udcm8_34"
/>
</div>
</div>
<div
class="_inline-field-body_19upo_38"
>
<label
class="_label_19upo_59"
for="mx_SettingsFlag_4yVCeEefiPqp"
>
Disable bandwidth-heavy features
</label>
<span
class="_message_19upo_85 _help-message_19upo_91"
id="radix-_r_c_"
>
<span>
<span
class="mx_SettingsTab_microcopy_warning"
>
WARNING:
</span>
Disables encryption, presence, avatars, read receipts, and typing notifications
</span>
</span>
</div>
</div>
</form>
<form
class="_root_19upo_16"
@ -246,7 +290,7 @@ exports[`DevtoolsDialog renders the devtools dialog 1`] = `
>
<label
class="_label_19upo_59"
for="radix-_r_a_"
for="radix-_r_d_"
>
Element Call URL
</label>
@ -255,7 +299,7 @@ exports[`DevtoolsDialog renders the devtools dialog 1`] = `
>
<input
class="_control_sqdq4_10"
id="radix-_r_a_"
id="radix-_r_d_"
name="input"
title=""
value=""

View File

@ -11,8 +11,6 @@ import { type RenderResult, render } from "jest-matrix-react";
import { type MatrixClient, MatrixEvent } from "matrix-js-sdk/src/matrix";
import MKeyVerificationRequest from "../../../../../src/components/views/messages/MKeyVerificationRequest";
import TileErrorBoundary from "../../../../../src/components/views/messages/TileErrorBoundary";
import { Layout } from "../../../../../src/settings/enums/Layout";
import MatrixClientContext from "../../../../../src/contexts/MatrixClientContext";
import { filterConsole } from "../../../../test-utils";
@ -60,22 +58,49 @@ describe("MKeyVerificationRequest", () => {
});
});
interface TestTileErrorBoundaryProps {
children: React.ReactNode;
}
interface TestTileErrorBoundaryState {
error?: Error;
}
class TestTileErrorBoundary extends React.Component<TestTileErrorBoundaryProps, TestTileErrorBoundaryState> {
public constructor(props: TestTileErrorBoundaryProps) {
super(props);
this.state = {};
}
public static getDerivedStateFromError(error: Error): Partial<TestTileErrorBoundaryState> {
return { error };
}
public render(): React.ReactNode {
if (this.state.error) {
return "Can't load this message";
}
return this.props.children;
}
}
function renderEventNoClient(event: MatrixEvent): RenderResult {
return render(
<TileErrorBoundary mxEvent={event} layout={Layout.Group}>
<TestTileErrorBoundary>
<MKeyVerificationRequest mxEvent={event} />
</TileErrorBoundary>,
</TestTileErrorBoundary>,
);
}
function renderEvent(client: MatrixClient, event: MatrixEvent): RenderResult {
return render(
<TileErrorBoundary mxEvent={event} layout={Layout.Group}>
<TestTileErrorBoundary>
<MatrixClientContext.Provider value={client}>
<MKeyVerificationRequest mxEvent={event} />
</MatrixClientContext.Provider>
,
</TileErrorBoundary>,
</TestTileErrorBoundary>,
);
}

View File

@ -146,6 +146,28 @@ describe("<TextualBody />", () => {
expect(content).toMatchSnapshot();
});
it("keeps edited emote bodies inline with the sender", () => {
DMRoomMap.makeShared(defaultMatrixClient);
const ev = mkEvent({
type: "m.room.message",
room: room1Id,
user: "sender",
content: {
body: "winks",
msgtype: "m.emote",
},
event: true,
});
jest.spyOn(ev, "replacingEventDate").mockReturnValue(new Date(1993, 7, 3));
const { container } = getComponent({ mxEvent: ev, replacingEventId: ev.getId() });
const annotated = container.querySelector(".mx_MEmoteBody > .mx_EventTile_annotatedInline");
expect(annotated).not.toBeNull();
expect(annotated?.tagName).toBe("DIV");
});
it("renders m.notice correctly", () => {
DMRoomMap.makeShared(defaultMatrixClient);

View File

@ -1,96 +0,0 @@
/*
* Copyright 2024 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
import React from "react";
import { type MatrixClient, type Room } from "matrix-js-sdk/src/matrix";
import { render, screen } from "jest-matrix-react";
import { waitFor } from "@testing-library/dom";
import { Form } from "@vector-im/compound-web";
import { createTestClient, mkStubRoom, withClientContextRenderOptions } from "../../../../test-utils";
import { UrlPreviewSettings } from "../../../../../src/components/views/room_settings/UrlPreviewSettings.tsx";
import SettingsStore from "../../../../../src/settings/SettingsStore.ts";
import dis from "../../../../../src/dispatcher/dispatcher.ts";
import { Action } from "../../../../../src/dispatcher/actions.ts";
describe("UrlPreviewSettings", () => {
let client: MatrixClient;
let room: Room;
beforeEach(() => {
client = createTestClient();
room = mkStubRoom("roomId", "room", client);
});
afterEach(() => {
jest.restoreAllMocks();
});
function renderComponent() {
return render(
<Form.Root>
<UrlPreviewSettings room={room} />
</Form.Root>,
withClientContextRenderOptions(client),
);
}
it("should display the correct preview when the setting is in a loading state", () => {
jest.spyOn(client, "getCrypto").mockReturnValue(undefined);
const { asFragment } = renderComponent();
expect(screen.getByText("URL Previews")).toBeInTheDocument();
expect(asFragment()).toMatchSnapshot();
});
it("should display the correct preview when the room is encrypted and the url preview is enabled", async () => {
jest.spyOn(client.getCrypto()!, "isEncryptionEnabledInRoom").mockResolvedValue(true);
jest.spyOn(SettingsStore, "getValueAt").mockReturnValue(true);
const { asFragment } = renderComponent();
await waitFor(() => {
expect(
screen.getByText(
"In encrypted rooms, like this one, URL previews are disabled by default to ensure that your homeserver (where the previews are generated) cannot gather information about links you see in this room.",
),
).toBeInTheDocument();
});
expect(asFragment()).toMatchSnapshot();
});
it("should display the correct preview when the room is unencrypted and the url preview is enabled", async () => {
jest.spyOn(client.getCrypto()!, "isEncryptionEnabledInRoom").mockResolvedValue(false);
jest.spyOn(SettingsStore, "getValueAt").mockReturnValue(true);
jest.spyOn(dis, "fire").mockReturnValue(undefined);
const { asFragment } = renderComponent();
await waitFor(() => {
expect(screen.getByRole("button", { name: "enabled" })).toBeInTheDocument();
expect(
screen.getByText("URL previews are enabled by default for participants in this room."),
).toBeInTheDocument();
});
expect(asFragment()).toMatchSnapshot();
screen.getByRole("button", { name: "enabled" }).click();
expect(dis.fire).toHaveBeenCalledWith(Action.ViewUserSettings);
});
it("should display the correct preview when the room is unencrypted and the url preview is disabled", async () => {
jest.spyOn(client.getCrypto()!, "isEncryptionEnabledInRoom").mockResolvedValue(false);
jest.spyOn(SettingsStore, "getValueAt").mockReturnValue(false);
const { asFragment } = renderComponent();
await waitFor(() => {
expect(screen.getByRole("button", { name: "disabled" })).toBeInTheDocument();
expect(
screen.getByText("URL previews are disabled by default for participants in this room."),
).toBeInTheDocument();
});
expect(asFragment()).toMatchSnapshot();
});
});

View File

@ -1,270 +0,0 @@
// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing
exports[`UrlPreviewSettings should display the correct preview when the room is encrypted and the url preview is enabled 1`] = `
<DocumentFragment>
<form
class="_root_19upo_16"
>
<fieldset
class="mx_SettingsFieldset"
>
<legend
class="mx_SettingsFieldset_legend"
>
URL Previews
</legend>
<div
class="mx_SettingsFieldset_description"
>
<div
class="mx_SettingsSubsection_text"
>
<p>
When someone puts a URL in their message, a URL preview can be shown to give more information about that link such as the title, description, and an image from the website.
</p>
<p>
In encrypted rooms, like this one, URL previews are disabled by default to ensure that your homeserver (where the previews are generated) cannot gather information about links you see in this room.
</p>
</div>
</div>
<div
class="mx_SettingsFieldset_content"
>
<div
class="_inline-field_19upo_32"
>
<div
class="_inline-field-control_19upo_44"
>
<div
class="_container_udcm8_10"
>
<input
checked=""
class="_input_udcm8_24"
id="mx_SettingsFlag_vY7Q4uEh9K38"
role="switch"
type="checkbox"
/>
<div
class="_ui_udcm8_34"
/>
</div>
</div>
<div
class="_inline-field-body_19upo_38"
>
<label
class="_label_19upo_59"
for="mx_SettingsFlag_vY7Q4uEh9K38"
>
Enable URL previews for this room (only affects you)
</label>
</div>
</div>
</div>
</fieldset>
</form>
</DocumentFragment>
`;
exports[`UrlPreviewSettings should display the correct preview when the room is unencrypted and the url preview is disabled 1`] = `
<DocumentFragment>
<form
class="_root_19upo_16"
>
<fieldset
class="mx_SettingsFieldset"
>
<legend
class="mx_SettingsFieldset_legend"
>
URL Previews
</legend>
<div
class="mx_SettingsFieldset_description"
>
<div
class="mx_SettingsSubsection_text"
>
<p>
When someone puts a URL in their message, a URL preview can be shown to give more information about that link such as the title, description, and an image from the website.
</p>
<p>
<span>
You have
</span>
</p>
<div
class="mx_AccessibleButton mx_AccessibleButton_hasKind mx_AccessibleButton_kind_link_inline"
role="button"
tabindex="0"
>
disabled
</div>
URL previews by default.
<p />
</div>
</div>
<div
class="mx_SettingsFieldset_content"
>
<div>
URL previews are disabled by default for participants in this room.
</div>
<div
class="_inline-field_19upo_32"
>
<div
class="_inline-field-control_19upo_44"
>
<div
class="_container_udcm8_10"
>
<input
class="_input_udcm8_24"
disabled=""
id="mx_SettingsFlag_vY7Q4uEh9K38"
role="switch"
type="checkbox"
/>
<div
class="_ui_udcm8_34"
/>
</div>
</div>
<div
class="_inline-field-body_19upo_38"
>
<label
class="_label_19upo_59"
for="mx_SettingsFlag_vY7Q4uEh9K38"
>
Enable inline URL previews by default
</label>
</div>
</div>
</div>
</fieldset>
</form>
</DocumentFragment>
`;
exports[`UrlPreviewSettings should display the correct preview when the room is unencrypted and the url preview is enabled 1`] = `
<DocumentFragment>
<form
class="_root_19upo_16"
>
<fieldset
class="mx_SettingsFieldset"
>
<legend
class="mx_SettingsFieldset_legend"
>
URL Previews
</legend>
<div
class="mx_SettingsFieldset_description"
>
<div
class="mx_SettingsSubsection_text"
>
<p>
When someone puts a URL in their message, a URL preview can be shown to give more information about that link such as the title, description, and an image from the website.
</p>
<p>
<span>
You have
</span>
</p>
<div
class="mx_AccessibleButton mx_AccessibleButton_hasKind mx_AccessibleButton_kind_link_inline"
role="button"
tabindex="0"
>
enabled
</div>
URL previews by default.
<p />
</div>
</div>
<div
class="mx_SettingsFieldset_content"
>
<div>
URL previews are enabled by default for participants in this room.
</div>
<div
class="_inline-field_19upo_32"
>
<div
class="_inline-field-control_19upo_44"
>
<div
class="_container_udcm8_10"
>
<input
checked=""
class="_input_udcm8_24"
id="mx_SettingsFlag_vY7Q4uEh9K38"
role="switch"
type="checkbox"
/>
<div
class="_ui_udcm8_34"
/>
</div>
</div>
<div
class="_inline-field-body_19upo_38"
>
<label
class="_label_19upo_59"
for="mx_SettingsFlag_vY7Q4uEh9K38"
>
Enable inline URL previews by default
</label>
</div>
</div>
</div>
</fieldset>
</form>
</DocumentFragment>
`;
exports[`UrlPreviewSettings should display the correct preview when the setting is in a loading state 1`] = `
<DocumentFragment>
<form
class="_root_19upo_16"
>
<fieldset
class="mx_SettingsFieldset"
>
<legend
class="mx_SettingsFieldset_legend"
>
URL Previews
</legend>
<div
class="mx_SettingsFieldset_content"
>
<svg
class="_icon_11k6c_18"
fill="currentColor"
height="1em"
style="width: 20px; height: 20px;"
viewBox="0 0 24 24"
width="1em"
xmlns="http://www.w3.org/2000/svg"
>
<path
clip-rule="evenodd"
d="M12 4.031a8 8 0 1 0 8 8 1 1 0 0 1 2 0c0 5.523-4.477 10-10 10s-10-4.477-10-10 4.477-10 10-10a1 1 0 1 1 0 2"
fill-rule="evenodd"
/>
</svg>
</div>
</fieldset>
</form>
</DocumentFragment>
`;

View File

@ -31,6 +31,7 @@ import { mkEncryptedMatrixEvent } from "matrix-js-sdk/src/testing";
import { getByTestId } from "@testing-library/dom";
import EventTile, { type EventTileProps } from "../../../../../src/components/views/rooms/EventTile";
import * as EventTileFactory from "../../../../../src/events/EventTileFactory";
import MatrixClientContext from "../../../../../src/contexts/MatrixClientContext";
import { type RoomContextType, TimelineRenderingType } from "../../../../../src/contexts/RoomContext";
import { MatrixClientPeg } from "../../../../../src/MatrixClientPeg";
@ -106,7 +107,7 @@ describe("EventTile", () => {
});
afterEach(() => {
jest.spyOn(PinningUtils, "isPinned").mockReturnValue(false);
jest.restoreAllMocks();
});
describe("EventTile thread summary", () => {
@ -200,6 +201,19 @@ describe("EventTile", () => {
expect(screen.getByText("Pinned message")).toBeInTheDocument();
},
);
it("renders the tile error fallback when tile rendering throws", async () => {
jest.spyOn(console, "error").mockImplementation(() => {});
jest.spyOn(EventTileFactory, "renderTile").mockImplementation(() => {
throw new Error("Boom");
});
getComponent();
await waitFor(() => {
expect(screen.getByText("Can't load this message (m.room.message)")).toBeInTheDocument();
});
});
});
describe("EventTile in the right panel", () => {

View File

@ -9,11 +9,11 @@ exports[`FontScalingPanel renders the font scaling UI 1`] = `
<div
class="mx_SettingsSubsectionHeading"
>
<h3
<h2
class="mx_Heading_h4 mx_SettingsSubsectionHeading_heading"
>
Font size
</h3>
</h2>
</div>
<div
class="mx_SettingsSubsection_content mx_SettingsSubsection_contentStretch"

View File

@ -9,11 +9,11 @@ exports[`<LayoutSwitcher /> should render 1`] = `
<div
class="mx_SettingsSubsectionHeading"
>
<h3
<h2
class="mx_Heading_h4 mx_SettingsSubsectionHeading_heading"
>
Message layout
</h3>
</h2>
</div>
<div
class="mx_SettingsSubsection_content mx_SettingsSubsection_content_newUi"

View File

@ -11,17 +11,17 @@ exports[`SetIntegrationManager should render manage integrations sections 1`] =
<div
class="mx_SetIntegrationManager_heading_manager"
>
<h3
<h2
class="mx_Heading_h3"
>
Manage integrations
</h3>
<h4
</h2>
<h3
class="mx_Heading_h4"
id="mx_SetIntegrationManager_ManagerName"
>
(scalar.vector.im)
</h4>
</h3>
</div>
</div>
<div

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