Compare commits
130 Commits
hs/add-cus
...
dbkr/textu
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c3e53aa389 | ||
|
|
c16fd3dca6 | ||
|
|
049a3ae8d2 | ||
|
|
f62177ad4f | ||
|
|
dc47abb82d | ||
|
|
fa300af2ff | ||
|
|
ead893c2ec | ||
|
|
7f97f46686 | ||
|
|
553724b567 | ||
|
|
8878c7b5c7 | ||
|
|
fbd9af69bc | ||
|
|
03ee035a28 | ||
|
|
9621f791d1 | ||
|
|
3077be3e4f | ||
|
|
b4ea530bd0 | ||
|
|
7347d55479 | ||
|
|
e6dbe93877 | ||
|
|
287a064127 | ||
|
|
cfd3a968d4 | ||
|
|
6fbc2e7078 | ||
|
|
31e6f15941 | ||
|
|
f6e8350522 | ||
|
|
afb8e38fd7 | ||
|
|
6ce5228044 | ||
|
|
a1db6f5f6e | ||
|
|
f424599295 | ||
|
|
5a2cb98670 | ||
|
|
94b7adcb49 | ||
|
|
02f7c9b52d | ||
|
|
8c7daae19f | ||
|
|
c1f291347c | ||
|
|
6171d2aedd | ||
|
|
917f85ea60 | ||
|
|
a75c5e2b2b | ||
|
|
14d1141e8d | ||
|
|
09cea4ad3a | ||
|
|
a56cf55f34 | ||
|
|
39dcaaaaee | ||
|
|
fd45eaaa8e | ||
|
|
80ed90a975 | ||
|
|
b632a61be7 | ||
|
|
d2c632f93d | ||
|
|
2f62c15fec | ||
|
|
df50a50741 | ||
|
|
fd9253d958 | ||
|
|
41612d3db6 | ||
|
|
0c3830f0a9 | ||
|
|
f47673cce9 | ||
|
|
d3c5971dfe | ||
|
|
5f3fae2412 | ||
|
|
d3300acca3 | ||
|
|
7e4ff89597 | ||
|
|
c8bd639d4f | ||
|
|
fbe6a06774 | ||
|
|
aa2dc8e574 | ||
|
|
0f7e394487 | ||
|
|
9f313fcc14 | ||
|
|
1cb068a91e | ||
|
|
5dd31685bb | ||
|
|
703ba8d9fb | ||
|
|
4fddd23b60 | ||
|
|
3285007224 | ||
|
|
b1edabe384 | ||
|
|
82d08df297 | ||
|
|
9095ebdb1b | ||
|
|
862aaff468 | ||
|
|
66d7c6a100 | ||
|
|
f2fc88fb7e | ||
|
|
90f4d34fbb | ||
|
|
e1fea71c97 | ||
|
|
99f7656d09 | ||
|
|
0768534885 | ||
|
|
d83c619e65 | ||
|
|
fe1cddd34b | ||
|
|
073d97e261 | ||
|
|
472de3bd14 | ||
|
|
a9364332b3 | ||
|
|
9318006b21 | ||
|
|
5638dd7c5f | ||
|
|
3f931d317b | ||
|
|
37df62aa4e | ||
|
|
4709656510 | ||
|
|
11f9849e51 | ||
|
|
3d724ffa84 | ||
|
|
a597221d05 | ||
|
|
2125415654 | ||
|
|
3d56aa7ff6 | ||
|
|
58875e5cf2 | ||
|
|
4a8b365bf8 | ||
|
|
73d78f4be6 | ||
|
|
455ca447ea | ||
|
|
b96da8de83 | ||
|
|
84d34e1332 | ||
|
|
017928455e | ||
|
|
ddbe0989ed | ||
|
|
15817cffc5 | ||
|
|
18ac6b92fa | ||
|
|
6ce149a7a8 | ||
|
|
75d7a1d644 | ||
|
|
d0ddc92908 | ||
|
|
4f13242de2 | ||
|
|
900c4d60bc | ||
|
|
925f4f65c7 | ||
|
|
088d8121e7 | ||
|
|
d216d68e3f | ||
|
|
f6e28cb3c7 | ||
|
|
434e58de52 | ||
|
|
2c299fe24e | ||
|
|
3965a36819 | ||
|
|
d4dc89cd38 | ||
|
|
fd199b94af | ||
|
|
7eefb30750 | ||
|
|
5486a1f235 | ||
|
|
73fd91dabd | ||
|
|
e9922ee84f | ||
|
|
53eff065e4 | ||
|
|
2b8f95a25b | ||
|
|
e956bb5b6d | ||
|
|
1349726d52 | ||
|
|
f707bb410e | ||
|
|
52f836a0dd | ||
|
|
c50000d124 | ||
|
|
0edaef3f7c | ||
|
|
ac9c6f11fb | ||
|
|
8705efec40 | ||
|
|
f5f9d68f3c | ||
|
|
a3b51edc51 | ||
|
|
5ad0dceae0 | ||
|
|
af984c0e80 | ||
|
|
2034f8b6bb |
@@ -1,6 +1,11 @@
|
||||
module.exports = {
|
||||
plugins: ["matrix-org", "eslint-plugin-react-compiler"],
|
||||
extends: ["plugin:matrix-org/babel", "plugin:matrix-org/react", "plugin:matrix-org/a11y"],
|
||||
extends: [
|
||||
"plugin:matrix-org/babel",
|
||||
"plugin:matrix-org/react",
|
||||
"plugin:matrix-org/a11y",
|
||||
"plugin:storybook/recommended",
|
||||
],
|
||||
parserOptions: {
|
||||
project: ["./tsconfig.json"],
|
||||
},
|
||||
|
||||
8
.github/workflows/docker.yaml
vendored
@@ -25,14 +25,14 @@ jobs:
|
||||
fetch-depth: 0 # needed for docker-package to be able to calculate the version
|
||||
|
||||
- name: Install Cosign
|
||||
uses: sigstore/cosign-installer@3454372f43399081ed03b604cb2d021dabca52bb # v3
|
||||
uses: sigstore/cosign-installer@398d4b0eeef1380460a10c8013a76f728fb906ac # v3
|
||||
if: github.event_name != 'pull_request'
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@29109295f81e9208d7d86ff1c6c12d2833863392 # v3
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@b5ca514318bd6ebac0fb2aedd5d36ec1b5c232a2 # v3
|
||||
uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3
|
||||
with:
|
||||
install: true
|
||||
|
||||
@@ -53,7 +53,7 @@ jobs:
|
||||
|
||||
- name: Build and load
|
||||
id: test-build
|
||||
uses: docker/build-push-action@471d1dc4e07e5cdedd4c2171150001c434f0b7a4 # v6
|
||||
uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6
|
||||
with:
|
||||
context: .
|
||||
load: true
|
||||
@@ -110,7 +110,7 @@ jobs:
|
||||
|
||||
- name: Build and push
|
||||
id: build-and-push
|
||||
uses: docker/build-push-action@471d1dc4e07e5cdedd4c2171150001c434f0b7a4 # v6
|
||||
uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6
|
||||
if: github.event_name != 'pull_request'
|
||||
with:
|
||||
context: .
|
||||
|
||||
2
.github/workflows/end-to-end-tests.yaml
vendored
@@ -227,7 +227,7 @@ jobs:
|
||||
|
||||
- name: Merge into HTML Report
|
||||
if: inputs.skip != true
|
||||
run: yarn playwright merge-reports --reporter=html,./playwright/flaky-reporter.ts,./playwright/stale-screenshot-reporter.ts ./all-blob-reports
|
||||
run: yarn playwright merge-reports --reporter=html,./playwright/flaky-reporter.ts,@element-hq/element-web-playwright-common/lib/stale-screenshot-reporter.js ./all-blob-reports
|
||||
env:
|
||||
# Only pass creds to the flaky-reporter on main branch runs
|
||||
GITHUB_TOKEN: ${{ github.ref_name == 'develop' && secrets.ELEMENT_BOT_TOKEN || '' }}
|
||||
|
||||
60
.github/workflows/shared-component-visual-tests.yaml
vendored
Normal file
@@ -0,0 +1,60 @@
|
||||
name: Shared Component Visual Tests
|
||||
on:
|
||||
pull_request: {}
|
||||
merge_group:
|
||||
types: [checks_requested]
|
||||
push:
|
||||
branches: [develop, master]
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}-${{ github.event_name }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions: {} # No permissions required
|
||||
|
||||
jobs:
|
||||
testStorybook:
|
||||
name: "Run Visual Tests"
|
||||
runs-on: ubuntu-24.04
|
||||
permissions:
|
||||
actions: read
|
||||
issues: read
|
||||
pull-requests: read
|
||||
steps:
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
|
||||
with:
|
||||
persist-credentials: false
|
||||
repository: element-hq/element-web
|
||||
|
||||
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
|
||||
with:
|
||||
cache: "yarn"
|
||||
node-version: "lts/*"
|
||||
|
||||
- name: Install dependencies
|
||||
run: yarn install --frozen-lockfile
|
||||
|
||||
- name: Get installed Playwright version
|
||||
id: playwright
|
||||
run: echo "version=$(yarn list --pattern @playwright/test --depth=0 --json --non-interactive --no-progress | jq -r '.data.trees[].name')" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Cache playwright binaries
|
||||
uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4
|
||||
id: playwright-cache
|
||||
with:
|
||||
path: ~/.cache/ms-playwright
|
||||
key: ${{ runner.os }}-${{ runner.arch }}-playwright-${{ steps.playwright.outputs.version }}-onlyshell
|
||||
|
||||
- name: Install Playwright browsers
|
||||
if: steps.playwright-cache.outputs.cache-hit != 'true'
|
||||
run: "yarn playwright install --with-deps --only-shell"
|
||||
|
||||
- name: Run Visual tests
|
||||
run: "yarn test:storybook:ci"
|
||||
|
||||
- name: Upload received images & diffs
|
||||
if: always()
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
|
||||
with:
|
||||
name: received-images
|
||||
path: playwright/shared-component-received
|
||||
2
.github/workflows/tests.yml
vendored
@@ -104,7 +104,7 @@ jobs:
|
||||
|
||||
- name: Skip SonarCloud in merge queue
|
||||
if: github.event_name == 'merge_group' || inputs.disable_coverage == 'true'
|
||||
uses: guibranco/github-status-action-v2@5f2b01ce1394109f70954ae6b69ef41cf7928e63
|
||||
uses: guibranco/github-status-action-v2@741ea90ba6c3ca76fe0d43ba11a90cda97d5e685
|
||||
with:
|
||||
authToken: ${{ secrets.GITHUB_TOKEN }}
|
||||
state: success
|
||||
|
||||
7
.github/workflows/update-topics.yaml
vendored
@@ -26,7 +26,7 @@ jobs:
|
||||
env:
|
||||
HS_URL: ${{ secrets.BETABOT_HS_URL }}
|
||||
LOBBY_ROOM_ID: ${{ secrets.ROOM_ID }}
|
||||
PUBLIC_ROOM_ID: "!YTvKGNlinIzlkMTVRl:matrix.org"
|
||||
PUBLIC_ROOM_ID: "!IemiTbwVankHTFiEoh:matrix.org"
|
||||
ANNOUNCEMENT_ROOM_ID: "!bijaLdadorKgNGtHdA:matrix.org"
|
||||
TOKEN: ${{ secrets.BETABOT_ACCESS_TOKEN }}
|
||||
RELEASE_STATUS: "Release status: ${{ inputs.expected_status }} expected ${{ inputs.expected_date }}"
|
||||
@@ -81,6 +81,11 @@ jobs:
|
||||
d.body = d.body.replace(regex, releaseTopic);
|
||||
});
|
||||
}
|
||||
if (data["m.topic"]) {
|
||||
data["m.topic"].forEach(d => {
|
||||
d.body = d.body.replace(regex, releaseTopic);
|
||||
});
|
||||
}
|
||||
|
||||
res = await fetch(apiUrl, {
|
||||
method: "PUT",
|
||||
|
||||
3
.gitignore
vendored
@@ -31,3 +31,6 @@ electron/pub
|
||||
/index.html
|
||||
# version file and tarball created by `npm pack` / `yarn pack`
|
||||
/git-revision.txt
|
||||
|
||||
*storybook.log
|
||||
storybook-static
|
||||
|
||||
28
.storybook/ElementTheme.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
/*
|
||||
Copyright 2025 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 { create } from "storybook/theming";
|
||||
|
||||
export default create({
|
||||
base: "light",
|
||||
|
||||
// Colors
|
||||
textColor: "#1b1d22",
|
||||
colorSecondary: "#111111",
|
||||
|
||||
// UI
|
||||
appBg: "#ffffff",
|
||||
appContentBg: "#ffffff",
|
||||
|
||||
// Toolbar
|
||||
barBg: "#ffffff",
|
||||
|
||||
brandTitle: "Element Web",
|
||||
brandUrl: "https://github.com/element-hq/element-web",
|
||||
brandImage: "https://element.io/images/logo-ele-secondary.svg",
|
||||
brandTarget: "_self",
|
||||
});
|
||||
21
.storybook/main.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
/*
|
||||
Copyright 2025 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 type { StorybookConfig } from "@storybook/react-vite";
|
||||
|
||||
const config: StorybookConfig = {
|
||||
stories: ["../src/shared-components/**/*.stories.@(js|jsx|mjs|ts|tsx)"],
|
||||
addons: ["@storybook/addon-docs", "@storybook/addon-designs"],
|
||||
framework: "@storybook/react-vite",
|
||||
core: {
|
||||
disableTelemetry: true,
|
||||
},
|
||||
typescript: {
|
||||
reactDocgen: "react-docgen-typescript",
|
||||
},
|
||||
};
|
||||
export default config;
|
||||
13
.storybook/manager.js
Normal file
@@ -0,0 +1,13 @@
|
||||
/*
|
||||
Copyright 2025 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 { addons } from "storybook/manager-api";
|
||||
import ElementTheme from "./ElementTheme";
|
||||
|
||||
addons.setConfig({
|
||||
theme: ElementTheme,
|
||||
});
|
||||
10
.storybook/preview.css
Normal file
@@ -0,0 +1,10 @@
|
||||
/*
|
||||
Copyright 2025 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.
|
||||
*/
|
||||
|
||||
.docs-story {
|
||||
background: var(--cpd-color-bg-canvas-default);
|
||||
}
|
||||
56
.storybook/preview.tsx
Normal file
@@ -0,0 +1,56 @@
|
||||
import type { ArgTypes, Preview, Decorator } from "@storybook/react-vite";
|
||||
|
||||
import "../res/css/shared.pcss";
|
||||
import "./preview.css";
|
||||
import React, { useLayoutEffect } from "react";
|
||||
|
||||
export const globalTypes = {
|
||||
theme: {
|
||||
name: "Theme",
|
||||
defaultValue: "system",
|
||||
description: "Global theme for components",
|
||||
toolbar: {
|
||||
icon: "circlehollow",
|
||||
title: "Theme",
|
||||
items: [
|
||||
{ title: "System", value: "system", icon: "browser" },
|
||||
{ title: "Light", value: "light", icon: "sun" },
|
||||
{ title: "Light (high contrast)", value: "light-hc", icon: "sun" },
|
||||
{ title: "Dark", value: "dark", icon: "moon" },
|
||||
{ title: "Dark (high contrast)", value: "dark-hc", icon: "moon" },
|
||||
],
|
||||
},
|
||||
},
|
||||
} satisfies ArgTypes;
|
||||
|
||||
const allThemesClasses = globalTypes.theme.toolbar.items.map(({ value }) => `cpd-theme-${value}`);
|
||||
|
||||
const ThemeSwitcher: React.FC<{
|
||||
theme: string;
|
||||
}> = ({ theme }) => {
|
||||
useLayoutEffect(() => {
|
||||
document.documentElement.classList.remove(...allThemesClasses);
|
||||
if (theme !== "system") {
|
||||
document.documentElement.classList.add(`cpd-theme-${theme}`);
|
||||
}
|
||||
return () => document.documentElement.classList.remove(...allThemesClasses);
|
||||
}, [theme]);
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const withThemeProvider: Decorator = (Story, context) => {
|
||||
return (
|
||||
<>
|
||||
<ThemeSwitcher theme={context.globals.theme} />
|
||||
<Story />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const preview: Preview = {
|
||||
tags: ["autodocs"],
|
||||
decorators: [withThemeProvider],
|
||||
};
|
||||
|
||||
export default preview;
|
||||
37
.storybook/test-runner.js
Normal file
@@ -0,0 +1,37 @@
|
||||
/*
|
||||
Copyright 2025 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 { waitForPageReady } from "@storybook/test-runner";
|
||||
import { toMatchImageSnapshot } from "jest-image-snapshot";
|
||||
|
||||
const customSnapshotsDir = `${process.cwd()}/playwright/shared-component-snapshots/`;
|
||||
const customReceivedDir = `${process.cwd()}/playwright/shared-component-received/`;
|
||||
|
||||
/**
|
||||
* @type {import('@storybook/test-runner').TestRunnerConfig}
|
||||
*/
|
||||
const config = {
|
||||
setup(page) {
|
||||
expect.extend({ toMatchImageSnapshot });
|
||||
},
|
||||
async postVisit(page, context) {
|
||||
await waitForPageReady(page);
|
||||
|
||||
// If you want to take screenshot of multiple browsers, use
|
||||
// page.context().browser().browserType().name() to get the browser name to prefix the file name
|
||||
const image = await page.screenshot();
|
||||
expect(image).toMatchImageSnapshot({
|
||||
customSnapshotsDir,
|
||||
customSnapshotIdentifier: `${context.id}-${process.platform}`,
|
||||
storeReceivedOnFailure: true,
|
||||
customReceivedDir,
|
||||
customDiffDir: customReceivedDir,
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
21
CHANGELOG.md
@@ -1,3 +1,24 @@
|
||||
Changes in [1.11.105](https://github.com/element-hq/element-web/releases/tag/v1.11.105) (2025-07-01)
|
||||
====================================================================================================
|
||||
## ✨ Features
|
||||
|
||||
* New room list: add context menu to room list item ([#29952](https://github.com/element-hq/element-web/pull/29952)). Contributed by @florianduros.
|
||||
* Support for custom message components via Module API ([#30074](https://github.com/element-hq/element-web/pull/30074)). Contributed by @Half-Shot.
|
||||
* Prompt users to set up recovery ([#30075](https://github.com/element-hq/element-web/pull/30075)). Contributed by @uhoreg.
|
||||
* Update `IconButton` colors ([#30124](https://github.com/element-hq/element-web/pull/30124)). Contributed by @florianduros.
|
||||
* New room list: filter list can be collapsed ([#29992](https://github.com/element-hq/element-web/pull/29992)). Contributed by @florianduros.
|
||||
* Show `EmptyRoomListView` when low priority filter matches zero rooms ([#30122](https://github.com/element-hq/element-web/pull/30122)). Contributed by @MidhunSureshR.
|
||||
|
||||
## 🐛 Bug Fixes
|
||||
|
||||
* Fix untranslatable string "People" in notifications beta ([#30165](https://github.com/element-hq/element-web/pull/30165)). Contributed by @t3chguy.
|
||||
* Force verification even after logging in via delegate ([#30141](https://github.com/element-hq/element-web/pull/30141)). Contributed by @andybalaam.
|
||||
* Hide add integrations button based on UIComponent.AddIntegrations ([#30140](https://github.com/element-hq/element-web/pull/30140)). Contributed by @t3chguy.
|
||||
* Use nav for new room list and label sections ([#30134](https://github.com/element-hq/element-web/pull/30134)). Contributed by @dbkr.
|
||||
* Spacestore should emit event after rebuilding home space ([#30132](https://github.com/element-hq/element-web/pull/30132)). Contributed by @MidhunSureshR.
|
||||
* Handle m.room.pinned\_events being invalid ([#30129](https://github.com/element-hq/element-web/pull/30129)). Contributed by @t3chguy.
|
||||
|
||||
|
||||
Changes in [1.11.104](https://github.com/element-hq/element-web/releases/tag/v1.11.104) (2025-06-17)
|
||||
====================================================================================================
|
||||
## ✨ Features
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# syntax=docker.io/docker/dockerfile:1.16-labs@sha256:bb5e2b225985193779991f3256d1901a0b3e6a0b284c7bffa0972064f4a6d458
|
||||
# syntax=docker.io/docker/dockerfile:1.17-labs@sha256:9187104f31e3a002a8a6a3209ea1f937fb7486c093cbbde1e14b0fa0d7e4f1b5
|
||||
|
||||
# Builder
|
||||
FROM --platform=$BUILDPLATFORM node:22-bullseye@sha256:f16d8e8af67bb6361231e932b8b3e7afa040cbfed181719a450b02c3821b26c1 AS builder
|
||||
FROM --platform=$BUILDPLATFORM node:22-bullseye@sha256:125996cb2451482467fc2aa4d7653075894b08e9b7711bcd761044ca270a083e AS builder
|
||||
|
||||
# Support custom branch of the js-sdk. This also helps us build images of element-web develop.
|
||||
ARG USE_CUSTOM_SDKS=false
|
||||
@@ -19,7 +19,7 @@ RUN /src/scripts/docker-package.sh
|
||||
RUN cp /src/config.sample.json /src/webapp/config.json
|
||||
|
||||
# App
|
||||
FROM nginxinc/nginx-unprivileged:alpine-slim@sha256:66e34aa81c2faf290ea4e4c28a490f2b35a07478265a2d5994c8637506045eee
|
||||
FROM nginxinc/nginx-unprivileged:alpine-slim@sha256:ef0100e39ffe377a42ad99e1f644b78097a84f1ac60a90eac3b888196b2eeb00
|
||||
|
||||
# Need root user to install packages & manipulate the usr directory
|
||||
USER root
|
||||
|
||||
@@ -127,7 +127,6 @@ Unless otherwise specified, the following applies to all code:
|
||||
2. "Conflicted" typically refers to a getter which wants the same name as the underlying variable.
|
||||
20. Prefer readonly members over getters backed by a variable, unless an internal setter is required.
|
||||
21. Prefer Interfaces for object definitions, and types for parameter-value-only declarations.
|
||||
|
||||
1. Note that an explicit type is optional if not expected to be used outside of the function call,
|
||||
unlike in this example:
|
||||
|
||||
@@ -161,7 +160,6 @@ Unless otherwise specified, the following applies to all code:
|
||||
28. Export only what can be reused.
|
||||
29. Prefer a type like `Optional<X>` (`type Optional<T> = T | null | undefined`) instead
|
||||
of truly optional parameters.
|
||||
|
||||
1. A notable exception is when the likelihood of a bug is minimal, such as when a function
|
||||
takes an argument that is more often not required than required. An example where the
|
||||
`?` operator is inappropriate is when taking a room ID: typically the caller should
|
||||
@@ -260,7 +258,6 @@ Inheriting all the rules of TypeScript, the following additionally apply:
|
||||
12. Interdependence between stores should be kept to a minimum. Break functions and constants out to utilities
|
||||
if at all possible.
|
||||
13. A component should only use CSS class names in line with the component name.
|
||||
|
||||
1. When knowingly using a class name from another component, document it with a [comment](#comments).
|
||||
|
||||
14. Curly braces within JSX should be padded with a space, however properties on those components should not.
|
||||
@@ -388,7 +385,6 @@ Note: We use PostCSS + some plugins to process our styles. It looks like SCSS, b
|
||||
properties should be clearly documented.
|
||||
|
||||
4. Inside a function, there is no need to comment every line, but consider:
|
||||
|
||||
- before a particular multiline section of code within the function, give an overview of what it does,
|
||||
to make it easier for a reader to follow the flow through the function as a whole.
|
||||
- if it is anything less than obvious, explain _why_ we are doing a particular operation, with particular emphasis
|
||||
|
||||
@@ -20,8 +20,7 @@
|
||||
"https://scalar.vector.im/_matrix/integrations/v1",
|
||||
"https://scalar.vector.im/api",
|
||||
"https://scalar-staging.vector.im/_matrix/integrations/v1",
|
||||
"https://scalar-staging.vector.im/api",
|
||||
"https://scalar-staging.riot.im/scalar/api"
|
||||
"https://scalar-staging.vector.im/api"
|
||||
],
|
||||
"default_widget_container_height": 280,
|
||||
"default_country_code": "GB",
|
||||
|
||||
8
declaration.d.ts
vendored
Normal file
@@ -0,0 +1,8 @@
|
||||
/*
|
||||
* Copyright 2025 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.
|
||||
*/
|
||||
|
||||
declare module "*.module.css";
|
||||
@@ -109,7 +109,7 @@ yarn test
|
||||
|
||||
### End-to-End tests
|
||||
|
||||
See [matrix-react-sdk](https://github.com/matrix-org/matrix-react-sdk/#end-to-end-tests) for how to run the end-to-end tests.
|
||||
See [`docs/playwright.md`](./docs/playwright.md) for how to run the end-to-end tests.
|
||||
|
||||
## General github guidelines
|
||||
|
||||
|
||||
@@ -130,32 +130,37 @@ complete re-branding/private labeling, a more personalised experience can be ach
|
||||
6. `mobile_builds`: Optional. Like `desktop_builds`, except for the mobile apps. Also described in more detail down below.
|
||||
7. `mobile_guide_toast`: When `true` (default), users accessing the Element Web instance from a mobile device will be prompted to
|
||||
download the app instead.
|
||||
8. `update_base_url`: For the desktop app only, the URL where to acquire update packages. If specified, must be a path to a directory
|
||||
8. `mobile_guide_app_variant`: Optional. The mobile app that the user is prompted to download from the `/mobile_guide` page. When omitted
|
||||
the mobile guide will be configured for the new Element X apps. Allowed values are as follows:
|
||||
1. `element`: Element X Android/iOS.
|
||||
2. `element-classic`: Element Classic Android/iOS.
|
||||
3. `element-pro`: Element Pro Android/iOS.
|
||||
9. `update_base_url`: For the desktop app only, the URL where to acquire update packages. If specified, must be a path to a directory
|
||||
containing `macos` and `win32` directories, with the update packages within. Defaults to `https://packages.element.io/desktop/update/`
|
||||
in production.
|
||||
9. `map_style_url`: Map tile server style URL for location sharing. e.g. `https://api.maptiler.com/maps/streets/style.json?key=YOUR_KEY_GOES_HERE`
|
||||
This setting is ignored if your homeserver provides `/.well-known/matrix/client` in its well-known location, and the JSON file
|
||||
at that location has a key `m.tile_server` (or the unstable version `org.matrix.msc3488.tile_server`). In this case, the
|
||||
configuration found in the well-known location is used instead.
|
||||
10. `welcome_user_id`: **DEPRECATED** An optional user ID to start a DM with after creating an account. Defaults to nothing (no DM created).
|
||||
11. `custom_translations_url`: An optional URL to allow overriding of translatable strings. The JSON file must be in a format of
|
||||
10. `map_style_url`: Map tile server style URL for location sharing. e.g. `https://api.maptiler.com/maps/streets/style.json?key=YOUR_KEY_GOES_HERE`
|
||||
This setting is ignored if your homeserver provides `/.well-known/matrix/client` in its well-known location, and the JSON file
|
||||
at that location has a key `m.tile_server` (or the unstable version `org.matrix.msc3488.tile_server`). In this case, the
|
||||
configuration found in the well-known location is used instead.
|
||||
11. `welcome_user_id`: **DEPRECATED** An optional user ID to start a DM with after creating an account. Defaults to nothing (no DM created).
|
||||
12. `custom_translations_url`: An optional URL to allow overriding of translatable strings. The JSON file must be in a format of
|
||||
`{"affected|translation|key": {"languageCode": "new string"}}`. See https://github.com/matrix-org/matrix-react-sdk/pull/7886 for details.
|
||||
12. `branding`: Options for configuring various assets used within the app. Described in more detail down below.
|
||||
13. `embedded_pages`: Further optional URLs for various assets used within the app. Described in more detail down below.
|
||||
14. `disable_3pid_login`: When `false` (default), **enables** the options to log in with email address or phone number. Set to
|
||||
13. `branding`: Options for configuring various assets used within the app. Described in more detail down below.
|
||||
14. `embedded_pages`: Further optional URLs for various assets used within the app. Described in more detail down below.
|
||||
15. `disable_3pid_login`: When `false` (default), **enables** the options to log in with email address or phone number. Set to
|
||||
`true` to hide these options.
|
||||
15. `disable_login_language_selector`: When `false` (default), **enables** the language selector on the login pages. Set to `true`
|
||||
16. `disable_login_language_selector`: When `false` (default), **enables** the language selector on the login pages. Set to `true`
|
||||
to hide this dropdown.
|
||||
16. `disable_guests`: When `false` (default), **enable** guest-related functionality (peeking/previewing rooms, etc) for unregistered
|
||||
17. `disable_guests`: When `false` (default), **enable** guest-related functionality (peeking/previewing rooms, etc) for unregistered
|
||||
users. Set to `true` to disable this functionality.
|
||||
17. `user_notice`: Optional notice to show to the user, e.g. for sunsetting a deployment and pushing users to move in their own time.
|
||||
18. `user_notice`: Optional notice to show to the user, e.g. for sunsetting a deployment and pushing users to move in their own time.
|
||||
Takes a configuration object as below:
|
||||
1. `title`: Required. Title to show at the top of the notice.
|
||||
2. `description`: Required. The description to use for the notice.
|
||||
3. `show_once`: Optional. If true then the notice will only be shown once per device.
|
||||
18. `help_url`: The URL to point users to for help with the app, defaults to `https://element.io/help`.
|
||||
19. `help_encryption_url`: The URL to point users to for help with encryption, defaults to `https://element.io/help#encryption`.
|
||||
20. `force_verification`: If true, users must verify new logins (eg. with another device / their recovery key)
|
||||
19. `help_url`: The URL to point users to for help with the app, defaults to `https://element.io/help`.
|
||||
20. `help_encryption_url`: The URL to point users to for help with encryption, defaults to `https://element.io/help#encryption`.
|
||||
21. `force_verification`: If true, users must verify new logins (eg. with another device / their recovery key)
|
||||
|
||||
### `desktop_builds` and `mobile_builds`
|
||||
|
||||
@@ -445,8 +450,7 @@ If you would like to use Scalar, the integration manager maintained by Element,
|
||||
"https://scalar.vector.im/_matrix/integrations/v1",
|
||||
"https://scalar.vector.im/api",
|
||||
"https://scalar-staging.vector.im/_matrix/integrations/v1",
|
||||
"https://scalar-staging.vector.im/api",
|
||||
"https://scalar-staging.riot.im/scalar/api"
|
||||
"https://scalar-staging.vector.im/api"
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
@@ -55,8 +55,7 @@ Then you can deploy it to your cluster with something like `kubectl apply -f my-
|
||||
"https://scalar.vector.im/_matrix/integrations/v1",
|
||||
"https://scalar.vector.im/api",
|
||||
"https://scalar-staging.vector.im/_matrix/integrations/v1",
|
||||
"https://scalar-staging.vector.im/api",
|
||||
"https://scalar-staging.riot.im/scalar/api"
|
||||
"https://scalar-staging.vector.im/api"
|
||||
],
|
||||
"bug_report_endpoint_url": "https://element.io/bugreports/submit",
|
||||
"defaultCountryCode": "GB",
|
||||
|
||||
@@ -15,8 +15,7 @@
|
||||
"https://scalar.vector.im/_matrix/integrations/v1",
|
||||
"https://scalar.vector.im/api",
|
||||
"https://scalar-staging.vector.im/_matrix/integrations/v1",
|
||||
"https://scalar-staging.vector.im/api",
|
||||
"https://scalar-staging.riot.im/scalar/api"
|
||||
"https://scalar-staging.vector.im/api"
|
||||
],
|
||||
"bug_report_endpoint_url": "https://element.io/bugreports/submit",
|
||||
"uisi_autorageshake_app": "element-auto-uisi",
|
||||
|
||||
@@ -15,8 +15,7 @@
|
||||
"https://scalar.vector.im/_matrix/integrations/v1",
|
||||
"https://scalar.vector.im/api",
|
||||
"https://scalar-staging.vector.im/_matrix/integrations/v1",
|
||||
"https://scalar-staging.vector.im/api",
|
||||
"https://scalar-staging.riot.im/scalar/api"
|
||||
"https://scalar-staging.vector.im/api"
|
||||
],
|
||||
"bug_report_endpoint_url": "https://element.io/bugreports/submit",
|
||||
"uisi_autorageshake_app": "element-auto-uisi",
|
||||
|
||||
@@ -17,7 +17,7 @@ const config: Config = {
|
||||
// This is needed to be able to load dual CJS/ESM WASM packages e.g. rust crypto & matrix-wywiwyg
|
||||
customExportConditions: ["browser", "node"],
|
||||
},
|
||||
testMatch: ["<rootDir>/test/**/*-test.[tj]s?(x)"],
|
||||
testMatch: ["<rootDir>/test/**/*-test.[tj]s?(x)", "<rootDir>/src/shared-components/**/*.test.[t]s?(x)"],
|
||||
globalSetup: "<rootDir>/test/globalSetup.ts",
|
||||
setupFiles: ["jest-canvas-mock", "web-streams-polyfill/polyfill"],
|
||||
setupFilesAfterEnv: ["<rootDir>/test/setupTests.ts"],
|
||||
|
||||
51
package.json
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "element-web",
|
||||
"version": "1.11.104",
|
||||
"version": "1.11.105",
|
||||
"description": "Element: the future of secure communication",
|
||||
"author": "New Vector Ltd.",
|
||||
"repository": {
|
||||
@@ -65,23 +65,27 @@
|
||||
"coverage": "yarn test --coverage",
|
||||
"analyse:webpack-bundles": "webpack-bundle-analyzer webpack-stats.json webapp",
|
||||
"update:jitsi": "curl -s https://meet.element.io/libs/external_api.min.js > ./res/jitsi_external_api.min.js",
|
||||
"postinstall": "patch-package"
|
||||
"postinstall": "patch-package",
|
||||
"storybook": "storybook dev -p 6007",
|
||||
"build-storybook": "storybook build",
|
||||
"test:storybook": "test-storybook --url http://localhost:6007/",
|
||||
"test:storybook:ci": "concurrently -k -s first -n \"SB,TEST\" \"yarn storybook\" \"wait-on tcp:6007 && yarn test-storybook --url http://localhost:6007/ --ci --maxWorkers=2\""
|
||||
},
|
||||
"resolutions": {
|
||||
"**/pretty-format/react-is": "19.1.0",
|
||||
"@playwright/test": "1.52.0",
|
||||
"@types/react": "19.1.6",
|
||||
"@playwright/test": "1.53.2",
|
||||
"@types/react": "19.1.8",
|
||||
"@types/react-dom": "19.1.6",
|
||||
"oidc-client-ts": "3.2.1",
|
||||
"oidc-client-ts": "3.3.0",
|
||||
"jwt-decode": "4.0.0",
|
||||
"caniuse-lite": "1.0.30001721",
|
||||
"caniuse-lite": "1.0.30001724",
|
||||
"testcontainers": "^11.0.0",
|
||||
"wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0",
|
||||
"wrap-ansi": "npm:wrap-ansi@^7.0.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.12.5",
|
||||
"@element-hq/element-web-module-api": "1.2.0",
|
||||
"@element-hq/element-web-module-api": "1.3.0",
|
||||
"@fontsource/inconsolata": "^5",
|
||||
"@fontsource/inter": "^5",
|
||||
"@formatjs/intl-segmenter": "^11.5.7",
|
||||
@@ -92,9 +96,9 @@
|
||||
"@sentry/browser": "^9.0.0",
|
||||
"@types/png-chunks-extract": "^1.0.2",
|
||||
"@types/react-virtualized": "^9.21.30",
|
||||
"@vector-im/compound-design-tokens": "^4.0.0",
|
||||
"@vector-im/compound-web": "^8.0.0",
|
||||
"@vector-im/matrix-wysiwyg": "2.38.3",
|
||||
"@vector-im/compound-design-tokens": "^5.0.0",
|
||||
"@vector-im/compound-web": "^8.1.2",
|
||||
"@vector-im/matrix-wysiwyg": "2.38.4",
|
||||
"@zxcvbn-ts/core": "^3.0.4",
|
||||
"@zxcvbn-ts/language-common": "^3.0.4",
|
||||
"@zxcvbn-ts/language-en": "^3.0.2",
|
||||
@@ -138,7 +142,7 @@
|
||||
"opus-recorder": "^8.0.3",
|
||||
"pako": "^2.0.3",
|
||||
"png-chunks-extract": "^1.0.0",
|
||||
"posthog-js": "1.249.4",
|
||||
"posthog-js": "1.256.2",
|
||||
"qrcode": "1.5.4",
|
||||
"re-resizable": "6.11.2",
|
||||
"react": "^19.0.0",
|
||||
@@ -180,14 +184,18 @@
|
||||
"@babel/preset-typescript": "^7.12.7",
|
||||
"@babel/runtime": "^7.12.5",
|
||||
"@casualbot/jest-sonar-reporter": "2.2.7",
|
||||
"@element-hq/element-call-embedded": "0.12.2",
|
||||
"@element-hq/element-web-playwright-common": "^1.1.5",
|
||||
"@element-hq/element-call-embedded": "0.13.1",
|
||||
"@element-hq/element-web-playwright-common": "^1.4.2",
|
||||
"@peculiar/webcrypto": "^1.4.3",
|
||||
"@playwright/test": "^1.50.1",
|
||||
"@principalstudio/html-webpack-inject-preload": "^1.2.7",
|
||||
"@rrweb/types": "^2.0.0-alpha.18",
|
||||
"@sentry/webpack-plugin": "^3.0.0",
|
||||
"@stylistic/eslint-plugin": "^4.0.0",
|
||||
"@storybook/addon-designs": "^10.0.1",
|
||||
"@storybook/addon-docs": "^9.0.12",
|
||||
"@storybook/react-vite": "^9.0.15",
|
||||
"@storybook/test-runner": "^0.23.0",
|
||||
"@stylistic/eslint-plugin": "^5.0.0",
|
||||
"@svgr/webpack": "^8.0.0",
|
||||
"@testing-library/dom": "^10.4.0",
|
||||
"@testing-library/jest-dom": "^6.4.8",
|
||||
@@ -212,7 +220,7 @@
|
||||
"@types/node-fetch": "^2.6.2",
|
||||
"@types/pako": "^2.0.0",
|
||||
"@types/qrcode": "^1.3.5",
|
||||
"@types/react": "19.1.6",
|
||||
"@types/react": "19.1.8",
|
||||
"@types/react-beautiful-dnd": "^13.0.0",
|
||||
"@types/react-dom": "19.1.6",
|
||||
"@types/react-transition-group": "^4.4.0",
|
||||
@@ -231,10 +239,10 @@
|
||||
"concurrently": "^9.0.0",
|
||||
"copy-webpack-plugin": "^13.0.0",
|
||||
"core-js": "^3.38.1",
|
||||
"cronstrue": "^2.41.0",
|
||||
"cronstrue": "^3.0.0",
|
||||
"css-loader": "^7.0.0",
|
||||
"css-minimizer-webpack-plugin": "^7.0.0",
|
||||
"dotenv": "^16.0.2",
|
||||
"dotenv": "^17.0.0",
|
||||
"eslint": "8.57.1",
|
||||
"eslint-config-google": "^0.14.0",
|
||||
"eslint-config-prettier": "^10.0.0",
|
||||
@@ -246,18 +254,19 @@
|
||||
"eslint-plugin-react": "^7.28.0",
|
||||
"eslint-plugin-react-compiler": "^19.0.0-beta-df7b47d-20241124",
|
||||
"eslint-plugin-react-hooks": "^5.0.0",
|
||||
"eslint-plugin-storybook": "^9.0.12",
|
||||
"eslint-plugin-unicorn": "^56.0.0",
|
||||
"express": "^5.0.0",
|
||||
"fake-indexeddb": "^6.0.0",
|
||||
"fetch-mock": "9.11.0",
|
||||
"fetch-mock-jest": "^1.5.1",
|
||||
"file-loader": "^6.0.0",
|
||||
"glob": "^11.0.0",
|
||||
"html-webpack-plugin": "^5.5.3",
|
||||
"husky": "^9.0.0",
|
||||
"jest": "^29.6.2",
|
||||
"jest-canvas-mock": "^2.5.2",
|
||||
"jest-environment-jsdom": "^29.7.0",
|
||||
"jest-image-snapshot": "^6.5.1",
|
||||
"jest-mock": "^29.6.2",
|
||||
"jest-raw-loader": "^1.0.1",
|
||||
"jsqr": "^1.4.0",
|
||||
@@ -275,17 +284,18 @@
|
||||
"postcss-hexrgba": "2.1.0",
|
||||
"postcss-import": "16.1.0",
|
||||
"postcss-loader": "8.1.1",
|
||||
"postcss-mixins": "^11.0.0",
|
||||
"postcss-mixins": "^12.0.0",
|
||||
"postcss-nested": "^7.0.0",
|
||||
"postcss-preset-env": "^10.0.0",
|
||||
"postcss-scss": "^4.0.4",
|
||||
"postcss-simple-vars": "^7.0.1",
|
||||
"prettier": "3.5.3",
|
||||
"prettier": "3.6.2",
|
||||
"process": "^0.11.10",
|
||||
"raw-loader": "^4.0.2",
|
||||
"rimraf": "^6.0.0",
|
||||
"semver": "^7.5.2",
|
||||
"source-map-loader": "^5.0.0",
|
||||
"storybook": "^9.0.12",
|
||||
"stylelint": "^16.13.0",
|
||||
"stylelint-config-standard": "^38.0.0",
|
||||
"stylelint-scss": "^6.0.0",
|
||||
@@ -295,6 +305,7 @@
|
||||
"ts-node": "^10.9.1",
|
||||
"typescript": "5.8.3",
|
||||
"util": "^0.12.5",
|
||||
"vite": "^7.0.1",
|
||||
"web-streams-polyfill": "^4.0.0",
|
||||
"webpack": "^5.89.0",
|
||||
"webpack-bundle-analyzer": "^4.8.0",
|
||||
|
||||
46
patches/@types+mdx+2.0.13.patch
Normal file
@@ -0,0 +1,46 @@
|
||||
diff --git a/node_modules/@types/mdx/types.d.ts b/node_modules/@types/mdx/types.d.ts
|
||||
index 498bb69..4e89216 100644
|
||||
--- a/node_modules/@types/mdx/types.d.ts
|
||||
+++ b/node_modules/@types/mdx/types.d.ts
|
||||
@@ -5,7 +5,7 @@
|
||||
*/
|
||||
// @ts-ignore JSX runtimes may optionally define JSX.ElementType. The MDX types need to work regardless whether this is
|
||||
// defined or not.
|
||||
-type ElementType = any extends JSX.ElementType ? never : JSX.ElementType;
|
||||
+type ElementType = any extends JSX.ElementType ? never : React.JSX.ElementType;
|
||||
|
||||
/**
|
||||
* This matches any function component types that ar part of `ElementType`.
|
||||
@@ -20,12 +20,12 @@ type ClassElementType = Extract<ElementType, new(props: Record<string, any>) =>
|
||||
/**
|
||||
* A valid JSX string component.
|
||||
*/
|
||||
-type StringComponent = Extract<keyof JSX.IntrinsicElements, ElementType extends never ? string : ElementType>;
|
||||
+type StringComponent = Extract<keyof React.JSX.IntrinsicElements, ElementType extends never ? string : ElementType>;
|
||||
|
||||
/**
|
||||
* A JSX element returned by MDX content.
|
||||
*/
|
||||
-export type Element = JSX.Element;
|
||||
+export type Element = React.JSX.Element;
|
||||
|
||||
/**
|
||||
* A valid JSX function component.
|
||||
@@ -44,7 +44,7 @@ type FunctionComponent<Props> = ElementType extends never
|
||||
*/
|
||||
type ClassComponent<Props> = ElementType extends never
|
||||
// If JSX.ElementType isn’t defined, the valid return type is a constructor that returns JSX.ElementClass
|
||||
- ? new(props: Props) => JSX.ElementClass
|
||||
+ ? new(props: Props) => React.JSX.ElementClass
|
||||
: ClassElementType extends never
|
||||
// If JSX.ElementType is defined, but doesn’t allow constructors, function components are disallowed.
|
||||
? never
|
||||
@@ -70,7 +70,7 @@ interface NestedMDXComponents {
|
||||
export type MDXComponents =
|
||||
& NestedMDXComponents
|
||||
& {
|
||||
- [Key in StringComponent]?: Component<JSX.IntrinsicElements[Key]>;
|
||||
+ [Key in StringComponent]?: Component<React.JSX.IntrinsicElements[Key]>;
|
||||
}
|
||||
& {
|
||||
/**
|
||||
@@ -1,31 +0,0 @@
|
||||
diff --git a/node_modules/@types/react/index.d.ts b/node_modules/@types/react/index.d.ts
|
||||
index d3318dc..c2b2c77 100644
|
||||
--- a/node_modules/@types/react/index.d.ts
|
||||
+++ b/node_modules/@types/react/index.d.ts
|
||||
@@ -134,7 +134,7 @@ declare namespace React {
|
||||
props: P,
|
||||
) => ReactNode | Promise<ReactNode>)
|
||||
// constructor signature must match React.Component
|
||||
- | (new(props: P) => Component<any, any>);
|
||||
+ | (new(props: P, context?: any) => Component<any, any>);
|
||||
|
||||
/**
|
||||
* Created by {@link createRef}, or {@link useRef} when passed `null`.
|
||||
@@ -945,7 +945,7 @@ declare namespace React {
|
||||
context: unknown;
|
||||
|
||||
// Keep in sync with constructor signature of JSXElementConstructor and ComponentClass.
|
||||
- constructor(props: P);
|
||||
+ constructor(props: P, context?: unknown);
|
||||
|
||||
// We MUST keep setState() as a unified signature because it allows proper checking of the method return type.
|
||||
// See: https://github.com/DefinitelyTyped/DefinitelyTyped/issues/18365#issuecomment-351013257
|
||||
@@ -1117,7 +1117,7 @@ declare namespace React {
|
||||
*/
|
||||
interface ComponentClass<P = {}, S = ComponentState> extends StaticLifecycle<P, S> {
|
||||
// constructor signature must match React.Component
|
||||
- new(props: P): Component<P, S>;
|
||||
+ new(props: P, context?: any): Component<P, S>;
|
||||
/**
|
||||
* Ignored by React.
|
||||
* @deprecated Only kept in types for backwards compatibility. Will be removed in a future major release.
|
||||
@@ -229,13 +229,13 @@ test.describe("Room list filters and sort", () => {
|
||||
// only one room should be visible
|
||||
await expect(roomList.getByRole("gridcell", { name: "unread dm" })).toBeVisible();
|
||||
await expect(roomList.getByRole("gridcell", { name: "unread room" })).toBeVisible();
|
||||
expect(await roomList.locator("role=gridcell").count()).toBe(4);
|
||||
await expect.poll(() => roomList.locator("role=gridcell").count()).toBe(4);
|
||||
await expect(primaryFilters).toMatchScreenshot("unread-primary-filters.png");
|
||||
|
||||
await primaryFilters.getByRole("option", { name: "People" }).click();
|
||||
await expect(roomList.getByRole("gridcell", { name: "unread dm" })).toBeVisible();
|
||||
await expect(roomList.getByRole("gridcell", { name: "invited room" })).toBeVisible();
|
||||
expect(await roomList.locator("role=gridcell").count()).toBe(2);
|
||||
await expect.poll(() => roomList.locator("role=gridcell").count()).toBe(2);
|
||||
|
||||
await primaryFilters.getByRole("option", { name: "Rooms" }).click();
|
||||
await expect(roomList.getByRole("gridcell", { name: "unread room" })).toBeVisible();
|
||||
@@ -243,21 +243,21 @@ test.describe("Room list filters and sort", () => {
|
||||
await expect(roomList.getByRole("gridcell", { name: "empty room" })).toBeVisible();
|
||||
await expect(roomList.getByRole("gridcell", { name: "room with mention" })).toBeVisible();
|
||||
await expect(roomList.getByRole("gridcell", { name: "Low prio room" })).toBeVisible();
|
||||
expect(await roomList.locator("role=gridcell").count()).toBe(5);
|
||||
await expect.poll(() => roomList.locator("role=gridcell").count()).toBe(5);
|
||||
|
||||
await getFilterExpandButton(page).click();
|
||||
|
||||
await primaryFilters.getByRole("option", { name: "Favourite" }).click();
|
||||
await expect(roomList.getByRole("gridcell", { name: "favourite room" })).toBeVisible();
|
||||
expect(await roomList.locator("role=gridcell").count()).toBe(1);
|
||||
await expect.poll(() => roomList.locator("role=gridcell").count()).toBe(1);
|
||||
|
||||
await primaryFilters.getByRole("option", { name: "Mentions" }).click();
|
||||
await expect(roomList.getByRole("gridcell", { name: "room with mention" })).toBeVisible();
|
||||
expect(await roomList.locator("role=gridcell").count()).toBe(1);
|
||||
await expect.poll(() => roomList.locator("role=gridcell").count()).toBe(1);
|
||||
|
||||
await primaryFilters.getByRole("option", { name: "Invites" }).click();
|
||||
await expect(roomList.getByRole("gridcell", { name: "invited room" })).toBeVisible();
|
||||
expect(await roomList.locator("role=gridcell").count()).toBe(1);
|
||||
await expect.poll(() => roomList.locator("role=gridcell").count()).toBe(1);
|
||||
|
||||
await getFilterCollapseButton(page).click();
|
||||
await expect(primaryFilters.locator("role=option").first()).toHaveText("Invites");
|
||||
|
||||
@@ -60,6 +60,12 @@ test.describe("Room list", () => {
|
||||
await expect(page.getByRole("heading", { name: "room29", level: 1 })).toBeVisible();
|
||||
});
|
||||
|
||||
test("should open the context menu", { tag: "@screenshot" }, async ({ page, app, user }) => {
|
||||
const roomListView = getRoomList(page);
|
||||
await roomListView.getByRole("gridcell", { name: "Open room room29" }).click({ button: "right" });
|
||||
await expect(page.getByRole("menu", { name: "More Options" })).toBeVisible();
|
||||
});
|
||||
|
||||
test("should open the more options menu", { tag: "@screenshot" }, async ({ page, app, user }) => {
|
||||
const roomListView = getRoomList(page);
|
||||
const roomItem = roomListView.getByRole("gridcell", { name: "Open room room29" });
|
||||
@@ -109,10 +115,15 @@ test.describe("Room list", () => {
|
||||
// It should make the room muted
|
||||
await page.getByRole("menuitem", { name: "Mute room" }).click();
|
||||
|
||||
await expect(roomItem.getByTestId("notification-decoration")).not.toBeVisible();
|
||||
|
||||
// Put focus on the room list
|
||||
await roomListView.getByRole("gridcell", { name: "Open room room28" }).click();
|
||||
// Scroll to the end of the room list
|
||||
await page.mouse.wheel(0, 1000);
|
||||
|
||||
while (!(await roomItem.isVisible())) {
|
||||
// Scroll to the end of the room list
|
||||
await page.mouse.wheel(0, 1000);
|
||||
}
|
||||
|
||||
// The room decoration should have the muted icon
|
||||
await expect(roomItem.getByTestId("notification-decoration")).toBeVisible();
|
||||
@@ -135,6 +146,7 @@ test.describe("Room list", () => {
|
||||
// Scroll to the end of the room list
|
||||
await page.mouse.wheel(0, 1000);
|
||||
|
||||
await expect(roomListView.getByRole("gridcell", { name: "Open room room0" })).toBeVisible();
|
||||
await roomListView.getByRole("gridcell", { name: "Open room room0" }).click();
|
||||
|
||||
const filters = page.getByRole("listbox", { name: "Room list filters" });
|
||||
@@ -223,17 +235,17 @@ test.describe("Room list", () => {
|
||||
await expect(notificationButton).toBeFocused();
|
||||
|
||||
// Open the menu
|
||||
await notificationButton.click();
|
||||
await page.keyboard.press("Enter");
|
||||
// Wait for the menu to be open
|
||||
await expect(page.getByRole("menuitem", { name: "Match default settings" })).toHaveAttribute(
|
||||
"aria-selected",
|
||||
"true",
|
||||
);
|
||||
|
||||
// Close the menu
|
||||
await page.keyboard.press("ArrowDown");
|
||||
await page.keyboard.press("Escape");
|
||||
// Focus should be back on the room list item
|
||||
await expect(room29).toBeFocused();
|
||||
// Focus should be back on the notification button
|
||||
await expect(notificationButton).toBeFocused();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
36
playwright/e2e/mobile-guide/mobile-guide.spec.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
/*
|
||||
Copyright 2025 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 { test, expect } from "../../element-web-test";
|
||||
import { MobileAppVariant } from "../../../src/vector/mobile_guide/mobile-apps";
|
||||
|
||||
const variants = [MobileAppVariant.Classic, MobileAppVariant.X, MobileAppVariant.Pro];
|
||||
|
||||
test.describe("Mobile Guide Screenshots", { tag: "@screenshot" }, () => {
|
||||
for (const variant of variants) {
|
||||
test.describe(`for variant ${variant}`, () => {
|
||||
test.use({
|
||||
config: {
|
||||
default_server_config: {
|
||||
"m.homeserver": {
|
||||
base_url: "https://matrix.server.invalid",
|
||||
server_name: "server.invalid",
|
||||
},
|
||||
},
|
||||
mobile_guide_app_variant: variant,
|
||||
},
|
||||
viewport: { width: 390, height: 844 }, // iPhone 16e
|
||||
});
|
||||
|
||||
test("should match the mobile_guide screenshot", async ({ page, axe }) => {
|
||||
await page.goto("/mobile_guide/");
|
||||
await expect(page).toMatchScreenshot(`mobile-guide-${variant}.png`);
|
||||
await expect(axe).toHaveNoViolations();
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -6,6 +6,7 @@ Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { type Page } from "@playwright/test";
|
||||
import fs from "node:fs";
|
||||
|
||||
import { test, expect } from "../../element-web-test";
|
||||
|
||||
@@ -22,6 +23,9 @@ const screenshotOptions = (page: Page) => ({
|
||||
}
|
||||
`,
|
||||
});
|
||||
|
||||
const IMAGE_FILE = fs.readFileSync("playwright/sample-files/element.png");
|
||||
|
||||
test.describe("Custom Component API", () => {
|
||||
test.use({
|
||||
displayName: "Manny",
|
||||
@@ -84,6 +88,50 @@ test.describe("Custom Component API", () => {
|
||||
await page.getByRole("toolbar", { name: "Message Actions" }).getByRole("button", { name: "Edit" }),
|
||||
).not.toBeVisible();
|
||||
});
|
||||
test("should disallow downloading media when the allowDownloading hint is set to false", async ({
|
||||
page,
|
||||
room,
|
||||
app,
|
||||
}) => {
|
||||
await app.viewRoomById(room.roomId);
|
||||
await app.viewRoomById(room.roomId);
|
||||
const upload = await app.client.uploadContent(IMAGE_FILE, { name: "bad.png", type: "image/png" });
|
||||
await app.client.sendEvent(room.roomId, null, "m.room.message", {
|
||||
msgtype: "m.image",
|
||||
body: "bad.png",
|
||||
url: upload.content_uri,
|
||||
});
|
||||
|
||||
await app.timeline.scrollToBottom();
|
||||
const imgTile = page.locator(".mx_MImageBody").first();
|
||||
await expect(imgTile).toBeVisible();
|
||||
await imgTile.hover();
|
||||
await expect(page.getByRole("button", { name: "Download" })).not.toBeVisible();
|
||||
await imgTile.click();
|
||||
await expect(page.getByLabel("Image view").getByLabel("Download")).not.toBeVisible();
|
||||
});
|
||||
test("should allow downloading media when the allowDownloading hint is set to true", async ({
|
||||
page,
|
||||
room,
|
||||
app,
|
||||
}) => {
|
||||
await app.viewRoomById(room.roomId);
|
||||
await app.viewRoomById(room.roomId);
|
||||
const upload = await app.client.uploadContent(IMAGE_FILE, { name: "good.png", type: "image/png" });
|
||||
await app.client.sendEvent(room.roomId, null, "m.room.message", {
|
||||
msgtype: "m.image",
|
||||
body: "good.png",
|
||||
url: upload.content_uri,
|
||||
});
|
||||
|
||||
await app.timeline.scrollToBottom();
|
||||
const imgTile = page.locator(".mx_MImageBody").first();
|
||||
await expect(imgTile).toBeVisible();
|
||||
await imgTile.hover();
|
||||
await expect(page.getByRole("button", { name: "Download" })).toBeVisible();
|
||||
await imgTile.click();
|
||||
await expect(page.getByLabel("Image view").getByLabel("Download")).toBeVisible();
|
||||
});
|
||||
test(
|
||||
"should render the next registered component if the filter function throws",
|
||||
{ tag: "@screenshot" },
|
||||
|
||||
@@ -81,7 +81,7 @@ test.describe("OIDC Native", { tag: ["@no-firefox", "@no-webkit"] }, () => {
|
||||
test(
|
||||
"it should log out the user & wipe data when logging out via MAS",
|
||||
{ tag: "@screenshot" },
|
||||
async ({ mas, page, mailpitClient }, testInfo) => {
|
||||
async ({ mas, page, mailpitClient, homeserver }, testInfo) => {
|
||||
// We use this over the `user` fixture to ensure we get an OIDC session rather than a compatibility one
|
||||
await page.goto("/#/login");
|
||||
await page.getByRole("button", { name: "Continue" }).click();
|
||||
@@ -95,11 +95,15 @@ test.describe("OIDC Native", { tag: ["@no-firefox", "@no-webkit"] }, () => {
|
||||
const result = await mas.manage("kill-sessions", userId);
|
||||
expect(result.output).toContain("Ended 1 active OAuth 2.0 session");
|
||||
|
||||
// Workaround for Synapse's 2 minute cache on MAS token validity
|
||||
// (https://github.com/element-hq/synapse/pull/18231)
|
||||
await homeserver.restart();
|
||||
|
||||
await page.goto("http://localhost:8080");
|
||||
await expect(
|
||||
page.getByText("For security, this session has been signed out. Please sign in again."),
|
||||
).toBeVisible();
|
||||
await expect(page).toMatchScreenshot("token-expired.png", { includeDialogBackground: true });
|
||||
//await expect(page).toMatchScreenshot("token-expired.png", { includeDialogBackground: true });
|
||||
|
||||
const localStorageKeys = await page.evaluate(() => Object.keys(localStorage));
|
||||
expect(localStorageKeys).toHaveLength(0);
|
||||
|
||||
@@ -42,7 +42,10 @@ export class Helpers {
|
||||
*/
|
||||
async assertReleaseAnnouncementIsVisible(name: string) {
|
||||
await expect(this.getReleaseAnnouncement(name)).toBeVisible();
|
||||
await expect(this.page).toMatchScreenshot(`release-announcement-${name}.png`, { showTooltips: true });
|
||||
await expect(this.page).toMatchScreenshot(`release-announcement-${name}.png`, {
|
||||
showTooltips: true,
|
||||
hideJumpToBottomButton: true,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -107,6 +107,7 @@ interface ExtendedToMatchScreenshotOptions extends ToMatchScreenshotOptions {
|
||||
includeDialogBackground?: boolean;
|
||||
showTooltips?: boolean;
|
||||
timeout?: number;
|
||||
hideJumpToBottomButton?: boolean;
|
||||
}
|
||||
|
||||
type Expectations = {
|
||||
@@ -165,6 +166,14 @@ export const expect = baseExpect.extend<Expectations>({
|
||||
`;
|
||||
}
|
||||
|
||||
if (options?.hideJumpToBottomButton) {
|
||||
css += `
|
||||
.mx_JumpToBottomButton {
|
||||
display: none !important;
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
if (options?.css) {
|
||||
css += options.css;
|
||||
}
|
||||
|
||||
@@ -5,8 +5,18 @@ 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.
|
||||
*/
|
||||
|
||||
// Note: eslint-plugin-jsdoc doesn't like import types as parameters, so we
|
||||
// get around it with @typedef
|
||||
/**
|
||||
* @typedef {import("@element-hq/element-web-module-api").Api} Api
|
||||
*/
|
||||
|
||||
export default class CustomComponentModule {
|
||||
static moduleApiVersion = "^1.2.0";
|
||||
/**
|
||||
* Basic module for testing.
|
||||
* @param {Api} api API object
|
||||
*/
|
||||
constructor(api) {
|
||||
this.api = api;
|
||||
this.api.customComponents.registerMessageRenderer(
|
||||
@@ -40,6 +50,15 @@ export default class CustomComponentModule {
|
||||
throw new Error("Fail test!");
|
||||
},
|
||||
);
|
||||
|
||||
this.api.customComponents.registerMessageRenderer(
|
||||
(mxEvent) => mxEvent.type === "m.room.message" && mxEvent.content.msgtype === "m.image",
|
||||
(_props, originalComponent) => {
|
||||
return originalComponent();
|
||||
},
|
||||
{ allowDownloadingMedia: async (mxEvent) => mxEvent.content.body !== "bad.png" },
|
||||
);
|
||||
|
||||
// Order is specific here to avoid this overriding the other renderers
|
||||
this.api.customComponents.registerMessageRenderer("m.room.message", (props, originalComponent) => {
|
||||
const body = props.mxEvent.content.body;
|
||||
|
||||
|
After Width: | Height: | Size: 6.9 KiB |
|
Before Width: | Height: | Size: 4.3 KiB After Width: | Height: | Size: 4.3 KiB |
|
Before Width: | Height: | Size: 4.1 KiB After Width: | Height: | Size: 4.2 KiB |
|
Before Width: | Height: | Size: 4.3 KiB After Width: | Height: | Size: 4.3 KiB |
|
Before Width: | Height: | Size: 5.6 KiB After Width: | Height: | Size: 5.6 KiB |
|
Before Width: | Height: | Size: 5.2 KiB After Width: | Height: | Size: 5.3 KiB |
|
Before Width: | Height: | Size: 5.5 KiB After Width: | Height: | Size: 5.4 KiB |
|
Before Width: | Height: | Size: 4.1 KiB After Width: | Height: | Size: 4.2 KiB |
|
Before Width: | Height: | Size: 5.4 KiB After Width: | Height: | Size: 5.5 KiB |
|
Before Width: | Height: | Size: 5.0 KiB After Width: | Height: | Size: 5.2 KiB |
|
Before Width: | Height: | Size: 5.2 KiB After Width: | Height: | Size: 5.2 KiB |
|
Before Width: | Height: | Size: 3.8 KiB After Width: | Height: | Size: 3.7 KiB |
|
Before Width: | Height: | Size: 3.8 KiB After Width: | Height: | Size: 3.9 KiB |
|
Before Width: | Height: | Size: 8.0 KiB After Width: | Height: | Size: 8.0 KiB |
|
Before Width: | Height: | Size: 6.1 KiB After Width: | Height: | Size: 6.2 KiB |
|
Before Width: | Height: | Size: 6.3 KiB After Width: | Height: | Size: 6.3 KiB |
|
After Width: | Height: | Size: 74 KiB |
|
After Width: | Height: | Size: 75 KiB |
|
After Width: | Height: | Size: 70 KiB |
|
Before Width: | Height: | Size: 62 KiB After Width: | Height: | Size: 62 KiB |
|
Before Width: | Height: | Size: 36 KiB After Width: | Height: | Size: 36 KiB |
|
Before Width: | Height: | Size: 61 KiB After Width: | Height: | Size: 61 KiB |
|
Before Width: | Height: | Size: 67 KiB After Width: | Height: | Size: 66 KiB |
|
Before Width: | Height: | Size: 16 KiB After Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 272 KiB After Width: | Height: | Size: 272 KiB |
@@ -1,71 +0,0 @@
|
||||
/*
|
||||
Copyright 2024 New Vector Ltd.
|
||||
Copyright 2024 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.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Test reporter which compares the reported screenshots vs those on disk to find stale screenshots
|
||||
* Only intended to run from within GitHub Actions
|
||||
*/
|
||||
|
||||
import path from "node:path";
|
||||
import { glob } from "glob";
|
||||
|
||||
import type { Reporter, TestCase } from "@playwright/test/reporter";
|
||||
|
||||
const snapshotRoot = path.join(__dirname, "snapshots");
|
||||
|
||||
class StaleScreenshotReporter implements Reporter {
|
||||
private screenshots = new Set<string>();
|
||||
private failing = false;
|
||||
private success = true;
|
||||
|
||||
public onTestEnd(test: TestCase): void {
|
||||
if (!test.ok()) {
|
||||
this.failing = true;
|
||||
}
|
||||
for (const annotation of test.annotations) {
|
||||
if (annotation.type === "_screenshot") {
|
||||
this.screenshots.add(annotation.description);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private error(msg: string, file: string) {
|
||||
if (process.env.GITHUB_ACTIONS) {
|
||||
console.log(`::error file=${file}::${msg}`);
|
||||
}
|
||||
console.error(msg, file);
|
||||
this.success = false;
|
||||
}
|
||||
|
||||
public async onExit(): Promise<void> {
|
||||
if (this.failing) return;
|
||||
const screenshotFiles = new Set(await glob(`**/*.png`, { cwd: snapshotRoot }));
|
||||
for (const screenshot of screenshotFiles) {
|
||||
if (screenshot.split("-").at(-1) !== "linux.png") {
|
||||
this.error(
|
||||
"Found screenshot belonging to different platform, this should not be checked in",
|
||||
screenshot,
|
||||
);
|
||||
}
|
||||
}
|
||||
for (const screenshot of this.screenshots) {
|
||||
screenshotFiles.delete(screenshot);
|
||||
}
|
||||
if (screenshotFiles.size > 0) {
|
||||
for (const screenshot of screenshotFiles) {
|
||||
this.error("Stale screenshot file", screenshot);
|
||||
}
|
||||
}
|
||||
|
||||
if (!this.success) {
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default StaleScreenshotReporter;
|
||||
@@ -7,7 +7,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";
|
||||
|
||||
const TAG = "develop@sha256:66955f34a593cfc3b6e77b8d5510c60c6094f5bade8a17d2feaefbb8662ccf09";
|
||||
const TAG = "develop@sha256:aea1d8f371268aed7a5863fa5dde960fb4f9f578cd0a5952cc4da92537f95cfa";
|
||||
|
||||
/**
|
||||
* SynapseContainer which freezes the docker digest to stabilise tests,
|
||||
|
||||
@@ -177,7 +177,6 @@
|
||||
@import "./views/dialogs/_WidgetCapabilitiesPromptDialog.pcss";
|
||||
@import "./views/dialogs/security/_AccessSecretStorageDialog.pcss";
|
||||
@import "./views/dialogs/security/_CreateCrossSigningDialog.pcss";
|
||||
@import "./views/dialogs/security/_CreateKeyBackupDialog.pcss";
|
||||
@import "./views/dialogs/security/_CreateSecretStorageDialog.pcss";
|
||||
@import "./views/dialogs/security/_KeyBackupFailedDialog.pcss";
|
||||
@import "./views/dialogs/security/_RestoreKeyBackupDialog.pcss";
|
||||
|
||||
9
res/css/shared.pcss
Normal file
@@ -0,0 +1,9 @@
|
||||
/*
|
||||
* Copyright 2025 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 url("@vector-im/compound-design-tokens/assets/web/css/compound-design-tokens.css") layer(compound);
|
||||
@import url("@vector-im/compound-web/dist/style.css");
|
||||
@@ -30,4 +30,28 @@ Please see LICENSE files in the repository root for full details.
|
||||
/* colliding harshly with the dialog when scrolled down. */
|
||||
padding-bottom: 100px;
|
||||
}
|
||||
|
||||
.mx_SettingsDialog_tabLabelsAlert::after {
|
||||
display: inline-block;
|
||||
content: "";
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
background-color: var(--cpd-color-icon-critical-primary);
|
||||
clip-path: circle(4px);
|
||||
position: absolute;
|
||||
right: var(--cpd-space-4x);
|
||||
}
|
||||
}
|
||||
|
||||
/* On narrow viewports, the tab labels are hidden, so we need to shift the indicator so it isn't over the tab icon. */
|
||||
@media (max-width: 1024px) {
|
||||
.mx_UserSettingsDialog,
|
||||
.mx_RoomSettingsDialog,
|
||||
.mx_SpaceSettingsDialog,
|
||||
.mx_SpacePreferencesDialog {
|
||||
.mx_SettingsDialog_tabLabelsAlert::after {
|
||||
right: var(--cpd-space-1x);
|
||||
top: var(--cpd-space-1x);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,73 +0,0 @@
|
||||
/*
|
||||
Copyright 2018-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.
|
||||
*/
|
||||
|
||||
.mx_CreateKeyBackupDialog .mx_Dialog_title {
|
||||
/* TODO: Consider setting this for all dialog titles. */
|
||||
margin-bottom: 1em;
|
||||
}
|
||||
|
||||
.mx_CreateKeyBackupDialog_primaryContainer {
|
||||
/* FIXME: plinth colour in new theme(s). background-color: $accent; */
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.mx_CreateKeyBackupDialog_primaryContainer::after {
|
||||
content: "";
|
||||
clear: both;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.mx_CreateKeyBackupDialog_passPhraseContainer {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.mx_CreateKeyBackupDialog_passPhraseInput {
|
||||
flex: none;
|
||||
width: 250px;
|
||||
border: 1px solid $accent;
|
||||
border-radius: 5px;
|
||||
padding: 10px;
|
||||
margin-bottom: 1em;
|
||||
}
|
||||
|
||||
.mx_CreateKeyBackupDialog_passPhraseMatch {
|
||||
margin-left: 20px;
|
||||
}
|
||||
|
||||
.mx_CreateKeyBackupDialog_recoveryKeyHeader {
|
||||
margin-bottom: 1em;
|
||||
}
|
||||
|
||||
.mx_CreateKeyBackupDialog_recoveryKeyContainer {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.mx_CreateKeyBackupDialog_recoveryKey {
|
||||
width: 262px;
|
||||
padding: 20px;
|
||||
color: $info-plinth-fg-color;
|
||||
background-color: $info-plinth-bg-color;
|
||||
margin-right: 12px;
|
||||
}
|
||||
|
||||
.mx_CreateKeyBackupDialog_recoveryKeyButtons {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.mx_CreateKeyBackupDialog_recoveryKeyButtons button {
|
||||
flex: 1;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.mx_CreateKeyBackupDialog {
|
||||
details .mx_AccessibleButton {
|
||||
margin: 1em 0; /* emulate paragraph spacing because we can't put this button in a paragraph due to HTML rules */
|
||||
}
|
||||
}
|
||||
@@ -59,3 +59,10 @@ Please see LICENSE files in the repository root for full details.
|
||||
mask-image: url("$(res)/img/e2e/verified.svg");
|
||||
background-color: $e2e-verified-color;
|
||||
}
|
||||
|
||||
// When using the "normal" icon as a background for verified or warning icon,
|
||||
// it should be slightly smaller than the foreground icon
|
||||
.mx_E2EIcon_verified, .mx_E2EIcon_warning .mx_E2EIcon_normal::after {
|
||||
mask-size: 90%;
|
||||
background-color: white;
|
||||
}
|
||||
|
||||
@@ -16,4 +16,13 @@
|
||||
font: var(--cpd-font-body-sm-medium);
|
||||
color: var(--cpd-color-text-action-accent);
|
||||
}
|
||||
|
||||
&.mx_SettingsHeader_recommended::after {
|
||||
display: inline-block;
|
||||
content: "";
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
background-color: var(--cpd-color-icon-critical-primary);
|
||||
clip-path: circle(4px);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,8 @@ sonar.organization=element-hq
|
||||
#sonar.sourceEncoding=UTF-8
|
||||
|
||||
sonar.sources=src,res
|
||||
sonar.tests=test,playwright
|
||||
sonar.tests=test,playwright,src
|
||||
sonar.test.inclusions=test/*,playwright/*,src/**/*.test.tsx
|
||||
sonar.exclusions=__mocks__,docs,element.io,nginx
|
||||
|
||||
sonar.cpd.exclusions=src/i18n/strings/*.json
|
||||
@@ -19,5 +20,6 @@ sonar.coverage.exclusions=\
|
||||
src/vector/modernizr.js,\
|
||||
src/components/views/dialogs/devtools/**/*,\
|
||||
src/utils/SessionLock.ts,\
|
||||
src/**/*.d.ts
|
||||
src/**/*.d.ts,\
|
||||
src/vector/mobile_guide/**/*
|
||||
sonar.testExecutionReportPaths=coverage/jest-sonar-report.xml
|
||||
|
||||
3
src/@types/matrix-js-sdk.d.ts
vendored
@@ -92,6 +92,9 @@ declare module "matrix-js-sdk/src/types" {
|
||||
// MSC4155: Invite filtering
|
||||
[INVITE_RULES_ACCOUNT_DATA_TYPE]: InviteConfigAccountData;
|
||||
"io.element.msc4278.media_preview_config": MediaPreviewConfig;
|
||||
|
||||
// Indicate whether recovery is enabled or disabled
|
||||
"io.element.recovery": { enabled: boolean };
|
||||
}
|
||||
|
||||
export interface AudioContent {
|
||||
|
||||
@@ -57,6 +57,11 @@ const KEY_BACKUP_POLL_INTERVAL = 5 * 60 * 1000;
|
||||
*/
|
||||
export const BACKUP_DISABLED_ACCOUNT_DATA_KEY = "m.org.matrix.custom.backup_disabled";
|
||||
|
||||
/**
|
||||
* Account data key to indicate whether the user has chosen to enable or disable recovery.
|
||||
*/
|
||||
export const RECOVERY_ACCOUNT_DATA_KEY = "io.element.recovery";
|
||||
|
||||
const logger = baseLogger.getChild("DeviceListener:");
|
||||
|
||||
export default class DeviceListener {
|
||||
@@ -165,6 +170,13 @@ export default class DeviceListener {
|
||||
await this.client?.setAccountData(BACKUP_DISABLED_ACCOUNT_DATA_KEY, { disabled: true });
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the account data to indicate that recovery is disabled
|
||||
*/
|
||||
public async recordRecoveryDisabled(): Promise<void> {
|
||||
await this.client?.setAccountData(RECOVERY_ACCOUNT_DATA_KEY, { enabled: false });
|
||||
}
|
||||
|
||||
private async ensureDeviceIdsAtStartPopulated(): Promise<void> {
|
||||
if (this.ourDeviceIdsAtStart === null) {
|
||||
this.ourDeviceIdsAtStart = await this.getDeviceIds();
|
||||
@@ -220,7 +232,8 @@ export default class DeviceListener {
|
||||
ev.getType().startsWith("m.secret_storage.") ||
|
||||
ev.getType().startsWith("m.cross_signing.") ||
|
||||
ev.getType() === "m.megolm_backup.v1" ||
|
||||
ev.getType() === BACKUP_DISABLED_ACCOUNT_DATA_KEY
|
||||
ev.getType() === BACKUP_DISABLED_ACCOUNT_DATA_KEY ||
|
||||
ev.getType() === RECOVERY_ACCOUNT_DATA_KEY
|
||||
) {
|
||||
this.recheck();
|
||||
}
|
||||
@@ -332,6 +345,9 @@ export default class DeviceListener {
|
||||
crossSigningStatus.privateKeysCachedLocally.userSigningKey;
|
||||
|
||||
const defaultKeyId = await cli.secretStorage.getDefaultKeyId();
|
||||
const recoveryDisabled = await this.recheckRecoveryDisabled(cli);
|
||||
|
||||
const recoveryIsOk = secretStorageReady || recoveryDisabled;
|
||||
|
||||
const isCurrentDeviceTrusted =
|
||||
crossSigningReady &&
|
||||
@@ -346,8 +362,7 @@ export default class DeviceListener {
|
||||
// said we are OK with that.
|
||||
const keyBackupIsOk = keyBackupUploadActive || backupDisabled;
|
||||
|
||||
const allSystemsReady =
|
||||
crossSigningReady && keyBackupIsOk && secretStorageReady && allCrossSigningSecretsCached;
|
||||
const allSystemsReady = isCurrentDeviceTrusted && allCrossSigningSecretsCached && keyBackupIsOk && recoveryIsOk;
|
||||
|
||||
await this.reportCryptoSessionStateToAnalytics(cli);
|
||||
|
||||
@@ -360,13 +375,8 @@ export default class DeviceListener {
|
||||
// make sure our keys are finished downloading
|
||||
await crypto.getUserDeviceInfo([cli.getSafeUserId()]);
|
||||
|
||||
if (!crossSigningReady) {
|
||||
// This account is legacy and doesn't have cross-signing set up at all.
|
||||
// Prompt the user to set it up.
|
||||
logSpan.info("Cross-signing not ready: showing SET_UP_ENCRYPTION toast");
|
||||
showSetupEncryptionToast(SetupKind.SET_UP_ENCRYPTION);
|
||||
} else if (!isCurrentDeviceTrusted) {
|
||||
// cross signing is ready but the current device is not trusted: prompt the user to verify
|
||||
if (!isCurrentDeviceTrusted) {
|
||||
// the current device is not trusted: prompt the user to verify
|
||||
logSpan.info("Current device not verified: showing VERIFY_THIS_SESSION toast");
|
||||
showSetupEncryptionToast(SetupKind.VERIFY_THIS_SESSION);
|
||||
} else if (!allCrossSigningSecretsCached) {
|
||||
@@ -384,7 +394,10 @@ export default class DeviceListener {
|
||||
// The user just hasn't set up 4S yet: if they have key
|
||||
// backup, prompt them to turn on recovery too. (If not, they
|
||||
// have explicitly opted out, so don't hassle them.)
|
||||
if (keyBackupUploadActive) {
|
||||
if (recoveryDisabled) {
|
||||
logSpan.info("Recovery disabled: no toast needed");
|
||||
hideSetupEncryptionToast();
|
||||
} else if (keyBackupUploadActive) {
|
||||
logSpan.info("No default 4S key: showing SET_UP_RECOVERY toast");
|
||||
showSetupEncryptionToast(SetupKind.SET_UP_RECOVERY);
|
||||
} else {
|
||||
@@ -392,16 +405,17 @@ export default class DeviceListener {
|
||||
hideSetupEncryptionToast();
|
||||
}
|
||||
} else {
|
||||
// some other condition... yikes! Show the 'set up encryption' toast: this is what we previously did
|
||||
// in 'other' situations. Possibly we should consider prompting for a full reset in this case?
|
||||
logSpan.warn("Couldn't match encryption state to a known case: showing 'setup encryption' prompt", {
|
||||
// If we get here, then we are verified, have key backup, and
|
||||
// 4S, but crypto.isSecretStorageReady returned false, which
|
||||
// means that 4S doesn't have all the secrets.
|
||||
logSpan.warn("4S is missing secrets", {
|
||||
crossSigningReady,
|
||||
secretStorageReady,
|
||||
allCrossSigningSecretsCached,
|
||||
isCurrentDeviceTrusted,
|
||||
defaultKeyId,
|
||||
});
|
||||
showSetupEncryptionToast(SetupKind.SET_UP_ENCRYPTION);
|
||||
showSetupEncryptionToast(SetupKind.KEY_STORAGE_OUT_OF_SYNC_STORE);
|
||||
}
|
||||
} else {
|
||||
logSpan.info("Not yet ready, but shouldShowSetupEncryptionToast==false");
|
||||
@@ -482,6 +496,20 @@ export default class DeviceListener {
|
||||
return !!backupDisabled?.disabled;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check whether the user has disabled recovery. If this is the first time,
|
||||
* fetch it from the server (in case the initial sync has not finished).
|
||||
* Otherwise, fetch it from the store as normal.
|
||||
*/
|
||||
private async recheckRecoveryDisabled(cli: MatrixClient): Promise<boolean> {
|
||||
const recoveryStatus = await cli.getAccountDataFromServer(RECOVERY_ACCOUNT_DATA_KEY);
|
||||
// Recovery is disabled only if the `enabled` flag is set to `false`.
|
||||
// If it is missing, or set to any other value, we consider it as
|
||||
// not-disabled, and will prompt the user to create recovery (if
|
||||
// missing).
|
||||
return recoveryStatus?.enabled === false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reports current recovery state to analytics.
|
||||
* Checks if the session is verified and if the recovery is correctly set up (i.e all secrets known locally and in 4S).
|
||||
|
||||
@@ -81,6 +81,7 @@ export interface IConfigOptions {
|
||||
};
|
||||
|
||||
mobile_guide_toast?: boolean;
|
||||
mobile_guide_app_variant?: "element" | "element-classic" | "element-pro";
|
||||
|
||||
default_theme?: "light" | "dark" | string; // custom themes are strings
|
||||
default_country_code?: string; // ISO 3166 alpha2 country code
|
||||
|
||||
@@ -176,10 +176,11 @@ export async function withSecretStorageKeyCache<T>(func: () => Promise<T>): Prom
|
||||
}
|
||||
|
||||
export interface AccessSecretStorageOpts {
|
||||
/** Reset secret storage even if it's already set up. */
|
||||
/**
|
||||
* Reset secret storage even if it's already set up.
|
||||
* @deprecated send the user to the Encryption settings tab to reset secret storage
|
||||
*/
|
||||
forceReset?: boolean;
|
||||
/** Create new cross-signing keys. Only applicable if `forceReset` is `true`. */
|
||||
resetCrossSigning?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -189,8 +190,8 @@ export interface AccessSecretStorageOpts {
|
||||
* provided function.
|
||||
*
|
||||
* Bootstrapping secret storage may take one of these paths:
|
||||
* 1. Create secret storage from a passphrase and store cross-signing keys
|
||||
* in secret storage.
|
||||
* 1. (Only if `opts.forceReset` is set) create secret storage from a passphrase
|
||||
* and store cross-signing keys in secret storage.
|
||||
* 2. Access existing secret storage by requesting passphrase and accessing
|
||||
* cross-signing keys as needed.
|
||||
* 3. All keys are loaded and there's nothing to do.
|
||||
@@ -199,6 +200,8 @@ export interface AccessSecretStorageOpts {
|
||||
* to ensure the user is prompted only once for their secret storage
|
||||
* passphrase. The cache is then cleared once the provided function completes.
|
||||
*
|
||||
* Throws an error if secret storage is not set up (and `opts.forceReset` is not set)
|
||||
*
|
||||
* @param {Function} [func] An operation to perform once secret storage has been
|
||||
* bootstrapped. Optional.
|
||||
* @param [opts] The options to use when accessing secret storage.
|
||||
@@ -219,16 +222,8 @@ async function doAccessSecretStorage(func: () => Promise<void>, opts: AccessSecr
|
||||
throw new Error("End-to-end encryption is disabled - unable to access secret storage.");
|
||||
}
|
||||
|
||||
let createNew = false;
|
||||
if (opts.forceReset) {
|
||||
logger.debug("accessSecretStorage: resetting 4S");
|
||||
createNew = true;
|
||||
} else if (!(await cli.secretStorage.hasKey())) {
|
||||
logger.debug("accessSecretStorage: no 4S key configured, creating a new one");
|
||||
createNew = true;
|
||||
}
|
||||
|
||||
if (createNew) {
|
||||
// This dialog calls bootstrap itself after guiding the user through
|
||||
// passphrase creation.
|
||||
const { finished } = Modal.createDialog(
|
||||
@@ -251,6 +246,9 @@ async function doAccessSecretStorage(func: () => Promise<void>, opts: AccessSecr
|
||||
if (!confirmed) {
|
||||
throw new Error("Secret storage creation canceled");
|
||||
}
|
||||
} else if (!(await cli.secretStorage.hasKey())) {
|
||||
logger.debug("accessSecretStorage: no 4S key configured");
|
||||
throw new Error("Secret storage has not been created yet.");
|
||||
} else {
|
||||
logger.debug("accessSecretStorage: bootstrapCrossSigning");
|
||||
await crypto.bootstrapCrossSigning({
|
||||
|
||||
@@ -1,186 +0,0 @@
|
||||
/*
|
||||
Copyright 2024 New Vector Ltd.
|
||||
Copyright 2019, 2020 The Matrix.org Foundation C.I.C.
|
||||
Copyright 2018, 2019 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, { type JSX } from "react";
|
||||
import { logger } from "matrix-js-sdk/src/logger";
|
||||
|
||||
import { MatrixClientPeg } from "../../../../MatrixClientPeg";
|
||||
import { _t } from "../../../../languageHandler";
|
||||
import { accessSecretStorage, withSecretStorageKeyCache } from "../../../../SecurityManager";
|
||||
import Spinner from "../../../../components/views/elements/Spinner";
|
||||
import BaseDialog from "../../../../components/views/dialogs/BaseDialog";
|
||||
import DialogButtons from "../../../../components/views/elements/DialogButtons";
|
||||
|
||||
enum Phase {
|
||||
BackingUp = "backing_up",
|
||||
Done = "done",
|
||||
}
|
||||
|
||||
interface IProps {
|
||||
onFinished(done?: boolean): void;
|
||||
}
|
||||
|
||||
interface IState {
|
||||
phase: Phase;
|
||||
passPhrase: string;
|
||||
passPhraseValid: boolean;
|
||||
passPhraseConfirm: string;
|
||||
copied: boolean;
|
||||
downloaded: boolean;
|
||||
error?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Walks the user through the process of setting up e2e key backups to a new backup, and storing the decryption key in
|
||||
* SSSS.
|
||||
*
|
||||
* Uses {@link accessSecretStorage}, which means that if 4S is not already configured, it will be bootstrapped (which
|
||||
* involves displaying an {@link CreateSecretStorageDialog} so the user can enter a passphrase and/or download the 4S
|
||||
* key).
|
||||
*/
|
||||
export default class CreateKeyBackupDialog extends React.PureComponent<IProps, IState> {
|
||||
public constructor(props: IProps) {
|
||||
super(props);
|
||||
|
||||
this.state = {
|
||||
phase: Phase.BackingUp,
|
||||
passPhrase: "",
|
||||
passPhraseValid: false,
|
||||
passPhraseConfirm: "",
|
||||
copied: false,
|
||||
downloaded: false,
|
||||
};
|
||||
}
|
||||
|
||||
public componentDidMount(): void {
|
||||
this.createBackup();
|
||||
}
|
||||
|
||||
private createBackup = async (): Promise<void> => {
|
||||
this.setState({
|
||||
error: undefined,
|
||||
});
|
||||
const cli = MatrixClientPeg.safeGet();
|
||||
try {
|
||||
// Check if 4S already set up
|
||||
const secretStorageAlreadySetup = await cli.secretStorage.hasKey();
|
||||
|
||||
if (!secretStorageAlreadySetup) {
|
||||
// bootstrap secret storage; that will also create a backup version
|
||||
await accessSecretStorage(async (): Promise<void> => {
|
||||
// do nothing, all is now set up correctly
|
||||
});
|
||||
} else {
|
||||
await withSecretStorageKeyCache(async () => {
|
||||
const crypto = cli.getCrypto();
|
||||
if (!crypto) {
|
||||
throw new Error("End-to-end encryption is disabled - unable to create backup.");
|
||||
}
|
||||
|
||||
// Before we reset the backup, let's make sure we can access secret storage, to
|
||||
// reduce the chance of us getting into a broken state where we have an outdated
|
||||
// secret in secret storage.
|
||||
// `SecretStorage.get` will ask the user to enter their passphrase/key if necessary;
|
||||
// it will then be cached for the actual backup reset operation.
|
||||
await cli.secretStorage.get("m.megolm_backup.v1");
|
||||
|
||||
// We now know we can store the new backup key in secret storage, so it is safe to
|
||||
// go ahead with the reset.
|
||||
await crypto.resetKeyBackup();
|
||||
});
|
||||
}
|
||||
|
||||
this.setState({
|
||||
phase: Phase.Done,
|
||||
});
|
||||
} catch (e) {
|
||||
logger.error("Error creating key backup", e);
|
||||
// TODO: If creating a version succeeds, but backup fails, should we
|
||||
// delete the version, disable backup, or do nothing? If we just
|
||||
// disable without deleting, we'll enable on next app reload since
|
||||
// it is trusted.
|
||||
this.setState({
|
||||
error: true,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
private onCancel = (): void => {
|
||||
this.props.onFinished(false);
|
||||
};
|
||||
|
||||
private onDone = (): void => {
|
||||
this.props.onFinished(true);
|
||||
};
|
||||
|
||||
private renderBusyPhase(): JSX.Element {
|
||||
return (
|
||||
<div>
|
||||
<Spinner />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
private renderPhaseDone(): JSX.Element {
|
||||
return (
|
||||
<div>
|
||||
<p>{_t("settings|key_backup|backup_in_progress")}</p>
|
||||
<DialogButtons primaryButton={_t("action|ok")} onPrimaryButtonClick={this.onDone} hasCancel={false} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
private titleForPhase(phase: Phase): string {
|
||||
switch (phase) {
|
||||
case Phase.BackingUp:
|
||||
return _t("settings|key_backup|backup_starting");
|
||||
case Phase.Done:
|
||||
return _t("settings|key_backup|backup_success");
|
||||
default:
|
||||
return _t("settings|key_backup|create_title");
|
||||
}
|
||||
}
|
||||
|
||||
public render(): React.ReactNode {
|
||||
let content;
|
||||
if (this.state.error) {
|
||||
content = (
|
||||
<div>
|
||||
<p>{_t("settings|key_backup|cannot_create_backup")}</p>
|
||||
<DialogButtons
|
||||
primaryButton={_t("action|retry")}
|
||||
onPrimaryButtonClick={this.createBackup}
|
||||
hasCancel={true}
|
||||
onCancel={this.onCancel}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
} else {
|
||||
switch (this.state.phase) {
|
||||
case Phase.BackingUp:
|
||||
content = this.renderBusyPhase();
|
||||
break;
|
||||
case Phase.Done:
|
||||
content = this.renderPhaseDone();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<BaseDialog
|
||||
className="mx_CreateKeyBackupDialog"
|
||||
onFinished={this.props.onFinished}
|
||||
title={this.titleForPhase(this.state.phase)}
|
||||
hasCancel={[Phase.Done].includes(this.state.phase)}
|
||||
>
|
||||
<div>{content}</div>
|
||||
</BaseDialog>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -56,7 +56,6 @@ const PASSWORD_MIN_SCORE = 4; // So secure, many characters, much complex, wow,
|
||||
|
||||
interface IProps {
|
||||
forceReset?: boolean;
|
||||
resetCrossSigning?: boolean;
|
||||
onFinished(ok?: boolean): void;
|
||||
}
|
||||
|
||||
@@ -80,11 +79,12 @@ interface IState {
|
||||
* If the user already has a key backup, follows a "migration" flow (aka "Upgrade your encryption") which
|
||||
* prompts the user to enter their backup decryption password (a Curve25519 private key, possibly derived
|
||||
* from a passphrase), and uses that as the (AES) 4S encryption key.
|
||||
*
|
||||
* @deprecated send the user to EncryptionUserSettingsTab instead
|
||||
*/
|
||||
export default class CreateSecretStorageDialog extends React.PureComponent<IProps, IState> {
|
||||
public static defaultProps: Partial<IProps> = {
|
||||
forceReset: false,
|
||||
resetCrossSigning: false,
|
||||
};
|
||||
private recoveryKey?: GeneratedSecretStorageKey;
|
||||
private recoveryKeyNode = createRef<HTMLElement>();
|
||||
@@ -211,7 +211,7 @@ export default class CreateSecretStorageDialog extends React.PureComponent<IProp
|
||||
private bootstrapSecretStorage = async (): Promise<void> => {
|
||||
const cli = MatrixClientPeg.safeGet();
|
||||
const crypto = cli.getCrypto()!;
|
||||
const { forceReset, resetCrossSigning } = this.props;
|
||||
const { forceReset } = this.props;
|
||||
|
||||
let backupInfo;
|
||||
// First, unless we know we want to do a reset, we see if there is an existing key backup
|
||||
@@ -246,13 +246,6 @@ export default class CreateSecretStorageDialog extends React.PureComponent<IProp
|
||||
createSecretStorageKey: async () => this.recoveryKey!,
|
||||
setupNewSecretStorage: true,
|
||||
});
|
||||
if (resetCrossSigning) {
|
||||
logger.log("Resetting cross signing");
|
||||
await crypto.bootstrapCrossSigning({
|
||||
authUploadDeviceSigningKeys: this.doBootstrapUIAuth,
|
||||
setupNewCrossSigning: true,
|
||||
});
|
||||
}
|
||||
logger.log("Resetting key backup");
|
||||
await crypto.resetKeyBackup();
|
||||
} else {
|
||||
|
||||
@@ -7,14 +7,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, { lazy } from "react";
|
||||
import React from "react";
|
||||
|
||||
import dis from "../../../../dispatcher/dispatcher";
|
||||
import { _t } from "../../../../languageHandler";
|
||||
import Modal from "../../../../Modal";
|
||||
import { Action } from "../../../../dispatcher/actions";
|
||||
import { UserTab } from "../../../../components/views/dialogs/UserTab";
|
||||
import BaseDialog from "../../../../components/views/dialogs/BaseDialog";
|
||||
import DialogButtons from "../../../../components/views/elements/DialogButtons";
|
||||
import { type OpenToTabPayload } from "../../../../dispatcher/payloads/OpenToTabPayload";
|
||||
|
||||
interface IProps {
|
||||
onFinished(): void;
|
||||
@@ -28,13 +29,12 @@ export default class RecoveryMethodRemovedDialog extends React.PureComponent<IPr
|
||||
|
||||
private onSetupClick = (): void => {
|
||||
this.props.onFinished();
|
||||
Modal.createDialog(
|
||||
lazy(() => import("./CreateKeyBackupDialog")),
|
||||
undefined,
|
||||
undefined,
|
||||
/* priority = */ false,
|
||||
/* static = */ true,
|
||||
);
|
||||
// Open the user settings dialog to the encryption tab and start the flow to reset encryption
|
||||
const payload: OpenToTabPayload = {
|
||||
action: Action.ViewUserSettings,
|
||||
initialTabId: UserTab.Encryption,
|
||||
};
|
||||
dis.dispatch(payload);
|
||||
};
|
||||
|
||||
public render(): React.ReactNode {
|
||||
|
||||
@@ -7,11 +7,14 @@ Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { type Room } from "matrix-js-sdk/src/matrix";
|
||||
|
||||
import { _t } from "../../languageHandler";
|
||||
import UploadBigSvg from "../../../res/img/upload-big.svg";
|
||||
import { useRoomState } from "../../hooks/useRoomState.ts";
|
||||
|
||||
interface IProps {
|
||||
room: Room;
|
||||
parent: HTMLElement | null;
|
||||
onFileDrop(dataTransfer: DataTransfer): void;
|
||||
}
|
||||
@@ -21,14 +24,15 @@ interface IState {
|
||||
counter: number;
|
||||
}
|
||||
|
||||
const FileDropTarget: React.FC<IProps> = ({ parent, onFileDrop }) => {
|
||||
const FileDropTarget: React.FC<IProps> = ({ parent, onFileDrop, room }) => {
|
||||
const [state, setState] = useState<IState>({
|
||||
dragging: false,
|
||||
counter: 0,
|
||||
});
|
||||
const hasPermission = useRoomState(room, (state) => state.maySendMessage(room.client.getUserId()!));
|
||||
|
||||
useEffect(() => {
|
||||
if (!parent || parent.ondrop) return;
|
||||
if (!hasPermission || !parent || parent.ondrop) return;
|
||||
|
||||
const onDragEnter = (ev: DragEvent): void => {
|
||||
ev.stopPropagation();
|
||||
@@ -102,9 +106,9 @@ const FileDropTarget: React.FC<IProps> = ({ parent, onFileDrop }) => {
|
||||
parent?.removeEventListener("dragenter", onDragEnter);
|
||||
parent?.removeEventListener("dragleave", onDragLeave);
|
||||
};
|
||||
}, [parent, onFileDrop]);
|
||||
}, [parent, onFileDrop, hasPermission]);
|
||||
|
||||
if (state.dragging) {
|
||||
if (hasPermission && state.dragging) {
|
||||
return (
|
||||
<div className="mx_FileDropTarget">
|
||||
<img src={UploadBigSvg} className="mx_FileDropTarget_image" alt="" />
|
||||
|
||||
@@ -316,7 +316,7 @@ function LocalRoomView(props: LocalRoomViewProps): ReactElement {
|
||||
<ErrorBoundary>
|
||||
<RoomHeader room={room} />
|
||||
<main className="mx_RoomView_body" ref={props.roomView} aria-label={_t("room|room_content")}>
|
||||
<FileDropTarget parent={props.roomView.current} onFileDrop={props.onFileDrop} />
|
||||
<FileDropTarget parent={props.roomView.current} onFileDrop={props.onFileDrop} room={room} />
|
||||
<div className="mx_RoomView_timeline">
|
||||
<ScrollPanel className="mx_RoomView_messagePanel" resizeNotifier={props.resizeNotifier}>
|
||||
{encryptionTile}
|
||||
@@ -2564,7 +2564,11 @@ export class RoomView extends React.Component<IRoomProps, IRoomState> {
|
||||
{auxPanel}
|
||||
{pinnedMessageBanner}
|
||||
<main className={timelineClasses}>
|
||||
<FileDropTarget parent={this.roomView.current} onFileDrop={this.onFileDrop} />
|
||||
<FileDropTarget
|
||||
parent={this.roomView.current}
|
||||
onFileDrop={this.onFileDrop}
|
||||
room={this.state.room}
|
||||
/>
|
||||
{topUnreadMessagesBar}
|
||||
{jumpToBottom}
|
||||
{messagePanel}
|
||||
|
||||
@@ -29,6 +29,7 @@ export class Tab<T extends string> {
|
||||
* @param {string|JSX.Element} icon An SVG element to use for the tab icon. Can also be a string for legacy icons, in which case it is the class for the tab icon. This should be a simple mask.
|
||||
* @param {JSX.Element} body The JSX for the tab container.
|
||||
* @param {string} screenName The screen name to report to Posthog.
|
||||
* @param {string} labelClassName Additional class to add to the tab label.
|
||||
*/
|
||||
public constructor(
|
||||
public readonly id: T,
|
||||
@@ -36,6 +37,7 @@ export class Tab<T extends string> {
|
||||
public readonly icon: string | JSX.Element | null,
|
||||
public readonly body: JSX.Element,
|
||||
public readonly screenName?: ScreenName,
|
||||
public readonly labelClassName?: string,
|
||||
) {}
|
||||
}
|
||||
|
||||
@@ -85,7 +87,7 @@ interface ITabLabelProps<T extends string> {
|
||||
}
|
||||
|
||||
function TabLabel<T extends string>({ tab, isActive, showToolip, onClick }: ITabLabelProps<T>): JSX.Element {
|
||||
const classes = classNames("mx_TabbedView_tabLabel", {
|
||||
const classes = classNames("mx_TabbedView_tabLabel", tab.labelClassName, {
|
||||
mx_TabbedView_tabLabel_active: isActive,
|
||||
});
|
||||
|
||||
|
||||
@@ -169,8 +169,7 @@ const ThreadPanel: React.FC<IProps> = ({ roomId, onClose, permalinkCreator }) =>
|
||||
|
||||
useEffect(() => {
|
||||
const room = mxClient.getRoom(roomId);
|
||||
room
|
||||
?.createThreadsTimelineSets()
|
||||
room?.createThreadsTimelineSets()
|
||||
.then(() => room.fetchRoomThreads())
|
||||
.then(() => {
|
||||
setFilterOption(ThreadFilterType.All);
|
||||
|
||||
@@ -388,7 +388,7 @@ export default class ThreadView extends React.Component<IProps, IState> {
|
||||
|
||||
timeline = (
|
||||
<>
|
||||
<FileDropTarget parent={this.card.current} onFileDrop={this.onFileDrop} />
|
||||
<FileDropTarget parent={this.card.current} onFileDrop={this.onFileDrop} room={this.props.room} />
|
||||
<TimelinePanel
|
||||
key={this.state.thread.id}
|
||||
ref={this.timelinePanel}
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
/*
|
||||
Copyright 2025 New Vector Ltd.
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
|
||||
Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import React, { useContext, useEffect, useState, useCallback } from "react";
|
||||
import { logger } from "@sentry/browser";
|
||||
import { type RoomMember, type Room } from "matrix-js-sdk/src/matrix";
|
||||
|
||||
import MatrixClientContext from "../../../contexts/MatrixClientContext";
|
||||
import { _t } from "../../../languageHandler";
|
||||
import Modal from "../../../Modal";
|
||||
import ErrorDialog from "../../views/dialogs/ErrorDialog";
|
||||
import QuestionDialog from "../../views/dialogs/QuestionDialog";
|
||||
import { warnSelfDemote } from "../../views/right_panel/UserInfo";
|
||||
|
||||
/**
|
||||
*
|
||||
*/
|
||||
export interface UserInfoPowerLevelState {
|
||||
/**
|
||||
* default power level value of the selected user
|
||||
*/
|
||||
powerLevelUsersDefault: number;
|
||||
/**
|
||||
* The new power level to apply
|
||||
*/
|
||||
selectedPowerLevel: number;
|
||||
/**
|
||||
* Method to call When power level selection change
|
||||
*/
|
||||
onPowerChange: (powerLevel: number) => void;
|
||||
}
|
||||
|
||||
export const useUserInfoPowerlevelViewModel = (user: RoomMember, room: Room): UserInfoPowerLevelState => {
|
||||
const [selectedPowerLevel, setSelectedPowerLevel] = useState(user.powerLevel);
|
||||
|
||||
useEffect(() => {
|
||||
setSelectedPowerLevel(user.powerLevel);
|
||||
}, [user]);
|
||||
|
||||
const cli = useContext(MatrixClientContext);
|
||||
const onPowerChange = useCallback(
|
||||
async (powerLevel: number) => {
|
||||
setSelectedPowerLevel(powerLevel);
|
||||
|
||||
const applyPowerChange = (roomId: string, target: string, powerLevel: number): Promise<unknown> => {
|
||||
return cli.setPowerLevel(roomId, target, powerLevel).then(
|
||||
function () {
|
||||
logger.info("Power change success");
|
||||
},
|
||||
function (err) {
|
||||
logger.error("Failed to change power level " + err);
|
||||
Modal.createDialog(ErrorDialog, {
|
||||
title: _t("common|error"),
|
||||
description: _t("error|update_power_level"),
|
||||
});
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
const roomId = user.roomId;
|
||||
const target = user.userId;
|
||||
|
||||
const powerLevelEvent = room.currentState.getStateEvents("m.room.power_levels", "");
|
||||
if (!powerLevelEvent) return;
|
||||
|
||||
const myUserId = cli.getUserId();
|
||||
const myPower = powerLevelEvent.getContent().users[myUserId || ""];
|
||||
if (myPower && parseInt(myPower) <= powerLevel && myUserId !== target) {
|
||||
const { finished } = Modal.createDialog(QuestionDialog, {
|
||||
title: _t("common|warning"),
|
||||
description: (
|
||||
<div>
|
||||
{_t("user_info|promote_warning")}
|
||||
<br />
|
||||
{_t("common|are_you_sure")}
|
||||
</div>
|
||||
),
|
||||
button: _t("action|continue"),
|
||||
});
|
||||
|
||||
const [confirmed] = await finished;
|
||||
if (!confirmed) return;
|
||||
} else if (myUserId === target && myPower && parseInt(myPower) > powerLevel) {
|
||||
// If we are changing our own PL it can only ever be decreasing, which we cannot reverse.
|
||||
try {
|
||||
if (!(await warnSelfDemote(room?.isSpaceRoom()))) return;
|
||||
} catch (e) {
|
||||
logger.error("Failed to warn about self demotion: " + e);
|
||||
}
|
||||
}
|
||||
|
||||
await applyPowerChange(roomId, target, powerLevel);
|
||||
},
|
||||
[user.roomId, user.userId, cli, room],
|
||||
);
|
||||
|
||||
const powerLevelEvent = room.currentState.getStateEvents("m.room.power_levels", "");
|
||||
const powerLevelUsersDefault = powerLevelEvent ? powerLevelEvent.getContent().users_default : 0;
|
||||
|
||||
return {
|
||||
powerLevelUsersDefault,
|
||||
onPowerChange,
|
||||
selectedPowerLevel,
|
||||
};
|
||||
};
|
||||
@@ -30,6 +30,10 @@ export interface RoomListItemViewState {
|
||||
* The name of the room.
|
||||
*/
|
||||
name: string;
|
||||
/**
|
||||
* Whether the context menu should be shown.
|
||||
*/
|
||||
showContextMenu: boolean;
|
||||
/**
|
||||
* Whether the hover menu should be shown.
|
||||
*/
|
||||
@@ -105,12 +109,12 @@ export function useRoomListItemViewModel(room: Room): RoomListItemViewState {
|
||||
setNotificationValues(getNotificationValues(notificationState));
|
||||
}, [notificationState]);
|
||||
|
||||
// We don't want to show the hover menu if
|
||||
// We don't want to show the menus if
|
||||
// - there is an invitation for this room
|
||||
// - the user doesn't have access to both notification and more options menus
|
||||
// - the user doesn't have access to notification and more options menus
|
||||
const showContextMenu = !invited && hasAccessToOptionsMenu(room);
|
||||
const showHoverMenu =
|
||||
!invited &&
|
||||
(hasAccessToOptionsMenu(room) || hasAccessToNotificationMenu(room, matrixClient.isGuest(), isArchived));
|
||||
!invited && (showContextMenu || hasAccessToNotificationMenu(room, matrixClient.isGuest(), isArchived));
|
||||
|
||||
const messagePreview = useRoomMessagePreview(room);
|
||||
|
||||
@@ -137,6 +141,7 @@ export function useRoomListItemViewModel(room: Room): RoomListItemViewState {
|
||||
return {
|
||||
name,
|
||||
notificationState,
|
||||
showContextMenu,
|
||||
showHoverMenu,
|
||||
openRoom,
|
||||
a11yLabel,
|
||||
|
||||
@@ -13,9 +13,11 @@ import { type MatrixClient } from "matrix-js-sdk/src/matrix";
|
||||
|
||||
import Modal from "../../../Modal";
|
||||
import dis from "../../../dispatcher/dispatcher";
|
||||
import { type OpenToTabPayload } from "../../../dispatcher/payloads/OpenToTabPayload";
|
||||
import { Action } from "../../../dispatcher/actions";
|
||||
import { UserTab } from "../../../components/views/dialogs/UserTab";
|
||||
import { _t } from "../../../languageHandler";
|
||||
import { MatrixClientPeg } from "../../../MatrixClientPeg";
|
||||
import RestoreKeyBackupDialog from "./security/RestoreKeyBackupDialog";
|
||||
import QuestionDialog from "./QuestionDialog";
|
||||
import BaseDialog from "./BaseDialog";
|
||||
import Spinner from "../elements/Spinner";
|
||||
@@ -138,26 +140,12 @@ export default class LogoutDialog extends React.Component<IProps, IState> {
|
||||
};
|
||||
|
||||
private onSetRecoveryMethodClick = (): void => {
|
||||
if (this.state.backupStatus === BackupStatus.SERVER_BACKUP_BUT_DISABLED) {
|
||||
// A key backup exists for this account, but the creating device is not
|
||||
// verified, so restore the backup which will give us the keys from it and
|
||||
// allow us to trust it (ie. upload keys to it)
|
||||
Modal.createDialog(
|
||||
RestoreKeyBackupDialog,
|
||||
undefined,
|
||||
undefined,
|
||||
/* priority = */ false,
|
||||
/* static = */ true,
|
||||
);
|
||||
} else {
|
||||
Modal.createDialog(
|
||||
lazy(() => import("../../../async-components/views/dialogs/security/CreateKeyBackupDialog")),
|
||||
undefined,
|
||||
undefined,
|
||||
/* priority = */ false,
|
||||
/* static = */ true,
|
||||
);
|
||||
}
|
||||
// Open the user settings dialog to the encryption tab and start the flow to reset encryption
|
||||
const payload: OpenToTabPayload = {
|
||||
action: Action.ViewUserSettings,
|
||||
initialTabId: UserTab.Encryption,
|
||||
};
|
||||
dis.dispatch(payload);
|
||||
|
||||
// close dialog
|
||||
this.props.onFinished(true);
|
||||
@@ -190,22 +178,13 @@ export default class LogoutDialog extends React.Component<IProps, IState> {
|
||||
</div>
|
||||
);
|
||||
|
||||
let setupButtonCaption;
|
||||
if (this.state.backupStatus === BackupStatus.SERVER_BACKUP_BUT_DISABLED) {
|
||||
setupButtonCaption = _t("settings|security|key_backup_connect");
|
||||
} else {
|
||||
// if there's an error fetching the backup info, we'll just assume there's
|
||||
// no backup for the purpose of the button caption
|
||||
setupButtonCaption = _t("auth|logout_dialog|use_key_backup");
|
||||
}
|
||||
|
||||
const dialogContent = (
|
||||
<div>
|
||||
<div className="mx_Dialog_content" id="mx_Dialog_content">
|
||||
{description}
|
||||
</div>
|
||||
<DialogButtons
|
||||
primaryButton={setupButtonCaption}
|
||||
primaryButton={_t("common|go_to_settings")}
|
||||
hasCancel={false}
|
||||
onPrimaryButtonClick={this.onSetRecoveryMethodClick}
|
||||
focus={true}
|
||||
|
||||
@@ -7,6 +7,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 { ClientEvent, type MatrixEvent } from "matrix-js-sdk/src/matrix";
|
||||
import { Toast } from "@vector-im/compound-web";
|
||||
import React, { type JSX, useState } from "react";
|
||||
import UserProfileIcon from "@vector-im/compound-design-tokens/assets/web/icons/user-profile";
|
||||
@@ -44,6 +45,7 @@ import { UserTab } from "./UserTab";
|
||||
import { type NonEmptyArray } from "../../../@types/common";
|
||||
import { SDKContext, type SdkContextClass } from "../../../contexts/SDKContext";
|
||||
import { useSettingValue } from "../../../hooks/useSettings";
|
||||
import { NoChange, useEventEmitterAsyncState, type AsyncStateCallbackResult } from "../../../hooks/useEventEmitter";
|
||||
import { ToastContext, useActiveToast } from "../../../contexts/ToastContext";
|
||||
import { EncryptionUserSettingsTab, type State } from "../settings/tabs/user/EncryptionUserSettingsTab";
|
||||
|
||||
@@ -100,6 +102,26 @@ export default function UserSettingsDialog(props: IProps): JSX.Element {
|
||||
const [showMsc4108QrCode, setShowMsc4108QrCode] = useState(props.showMsc4108QrCode);
|
||||
const [initialEncryptionState, setInitialEncryptionState] = useState(props.initialEncryptionState);
|
||||
|
||||
// If the user doesn't have Recovery set up (no default Secret Storage key),
|
||||
// we show an indicator on the Encryption tab.
|
||||
const showSetupRecoveryIndicator = useEventEmitterAsyncState(
|
||||
props.sdkContext.client,
|
||||
ClientEvent.AccountData,
|
||||
async (event?: MatrixEvent): AsyncStateCallbackResult<boolean> => {
|
||||
if (event === undefined || event.getType() === "m.secret_storage.default_key") {
|
||||
const client = props.sdkContext.client;
|
||||
if (!client) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return !(await client.secretStorage.getDefaultKeyId());
|
||||
}
|
||||
return new NoChange();
|
||||
},
|
||||
[],
|
||||
false,
|
||||
);
|
||||
|
||||
const getTabs = (): NonEmptyArray<Tab<UserTab>> => {
|
||||
const tabs: Tab<UserTab>[] = [];
|
||||
|
||||
@@ -196,6 +218,7 @@ export default function UserSettingsDialog(props: IProps): JSX.Element {
|
||||
<KeyIcon />,
|
||||
<EncryptionUserSettingsTab initialState={initialEncryptionState} />,
|
||||
"UserSettingsEncryption",
|
||||
showSetupRecoveryIndicator ? "mx_SettingsDialog_tabLabelsAlert" : undefined,
|
||||
),
|
||||
);
|
||||
|
||||
|
||||
@@ -8,9 +8,10 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com
|
||||
Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import React, { type JSX, createRef, type CSSProperties, useRef, useState, useMemo } from "react";
|
||||
import React, { type JSX, createRef, type CSSProperties, useRef, useState, useMemo, useEffect } from "react";
|
||||
import FocusLock from "react-focus-lock";
|
||||
import { type MatrixEvent, parseErrorResponse } from "matrix-js-sdk/src/matrix";
|
||||
import { logger } from "matrix-js-sdk/src/logger";
|
||||
|
||||
import { _t } from "../../../languageHandler";
|
||||
import MemberAvatar from "../avatars/MemberAvatar";
|
||||
@@ -34,6 +35,7 @@ import Modal from "../../../Modal";
|
||||
import ErrorDialog from "../dialogs/ErrorDialog";
|
||||
import { FileDownloader } from "../../../utils/FileDownloader";
|
||||
import { MediaEventHelper } from "../../../utils/MediaEventHelper.ts";
|
||||
import ModuleApi from "../../../modules/Api";
|
||||
|
||||
// Max scale to keep gaps around the image
|
||||
const MAX_SCALE = 0.95;
|
||||
@@ -591,12 +593,36 @@ function DownloadButton({
|
||||
url: string;
|
||||
fileName?: string;
|
||||
mxEvent?: MatrixEvent;
|
||||
}): JSX.Element {
|
||||
}): JSX.Element | null {
|
||||
const downloader = useRef(new FileDownloader()).current;
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [canDownload, setCanDownload] = useState<boolean>(false);
|
||||
const blobRef = useRef<Blob>(undefined);
|
||||
const mediaEventHelper = useMemo(() => (mxEvent ? new MediaEventHelper(mxEvent) : undefined), [mxEvent]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!mxEvent) {
|
||||
// If we have no event, we assume this is safe to download.
|
||||
setCanDownload(true);
|
||||
return;
|
||||
}
|
||||
const hints = ModuleApi.customComponents.getHintsForMessage(mxEvent);
|
||||
if (hints?.allowDownloadingMedia) {
|
||||
// Disable downloading as soon as we know there is a hint.
|
||||
setCanDownload(false);
|
||||
hints
|
||||
.allowDownloadingMedia()
|
||||
.then((downloadable) => {
|
||||
setCanDownload(downloadable);
|
||||
})
|
||||
.catch((ex) => {
|
||||
logger.error(`Failed to check if media from ${mxEvent.getId()} could be downloaded`, ex);
|
||||
// Err on the side of safety.
|
||||
setCanDownload(false);
|
||||
});
|
||||
}
|
||||
}, [mxEvent]);
|
||||
|
||||
function showError(e: unknown): void {
|
||||
Modal.createDialog(ErrorDialog, {
|
||||
title: _t("timeline|download_failed"),
|
||||
@@ -640,6 +666,10 @@ function DownloadButton({
|
||||
setLoading(false);
|
||||
}
|
||||
|
||||
if (!canDownload) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<AccessibleButton
|
||||
className="mx_ImageView_button mx_ImageView_button_download"
|
||||
|
||||
@@ -52,8 +52,7 @@ export default class SpellCheckLanguagesDropdown extends React.Component<
|
||||
const plaf = PlatformPeg.get();
|
||||
if (plaf) {
|
||||
const languageNames = new Intl.DisplayNames([getUserLanguage()], { type: "language", style: "short" });
|
||||
plaf
|
||||
.getAvailableSpellCheckLanguages()
|
||||
plaf.getAvailableSpellCheckLanguages()
|
||||
?.then((languages) => {
|
||||
languages.sort(function (a, b) {
|
||||
if (a < b) return -1;
|
||||
|
||||
@@ -10,6 +10,7 @@ import { type MatrixEvent } from "matrix-js-sdk/src/matrix";
|
||||
import React, { type JSX } from "react";
|
||||
import classNames from "classnames";
|
||||
import { DownloadIcon } from "@vector-im/compound-design-tokens/assets/web/icons";
|
||||
import { logger } from "matrix-js-sdk/src/logger";
|
||||
|
||||
import { type MediaEventHelper } from "../../../utils/MediaEventHelper";
|
||||
import { RovingAccessibleButton } from "../../../accessibility/RovingTabIndex";
|
||||
@@ -18,6 +19,7 @@ import { _t, _td, type TranslationKey } from "../../../languageHandler";
|
||||
import { FileDownloader } from "../../../utils/FileDownloader";
|
||||
import Modal from "../../../Modal";
|
||||
import ErrorDialog from "../dialogs/ErrorDialog";
|
||||
import ModuleApi from "../../../modules/Api";
|
||||
|
||||
interface IProps {
|
||||
mxEvent: MatrixEvent;
|
||||
@@ -29,6 +31,7 @@ interface IProps {
|
||||
}
|
||||
|
||||
interface IState {
|
||||
canDownload: null | boolean;
|
||||
loading: boolean;
|
||||
blob?: Blob;
|
||||
tooltip: TranslationKey;
|
||||
@@ -40,9 +43,29 @@ export default class DownloadActionButton extends React.PureComponent<IProps, IS
|
||||
public constructor(props: IProps) {
|
||||
super(props);
|
||||
|
||||
const moduleHints = ModuleApi.customComponents.getHintsForMessage(props.mxEvent);
|
||||
const downloadState: Pick<IState, "canDownload"> = { canDownload: true };
|
||||
if (moduleHints?.allowDownloadingMedia) {
|
||||
downloadState.canDownload = null;
|
||||
moduleHints
|
||||
.allowDownloadingMedia()
|
||||
.then((canDownload) => {
|
||||
this.setState({
|
||||
canDownload: canDownload,
|
||||
});
|
||||
})
|
||||
.catch((ex) => {
|
||||
logger.error(`Failed to check if media from ${props.mxEvent.getId()} could be downloaded`, ex);
|
||||
this.setState({
|
||||
canDownload: false,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
this.state = {
|
||||
loading: false,
|
||||
tooltip: _td("timeline|download_action_downloading"),
|
||||
...downloadState,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -97,6 +120,14 @@ export default class DownloadActionButton extends React.PureComponent<IProps, IS
|
||||
spinner = <Spinner w={18} h={18} />;
|
||||
}
|
||||
|
||||
if (this.state.canDownload === null) {
|
||||
spinner = <Spinner w={18} h={18} />;
|
||||
}
|
||||
|
||||
if (this.state.canDownload === false) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const classes = classNames({
|
||||
mx_MessageActionBar_iconButton: true,
|
||||
mx_MessageActionBar_downloadButton: true,
|
||||
|
||||
@@ -1,47 +0,0 @@
|
||||
/*
|
||||
Copyright 2024 New Vector Ltd.
|
||||
Copyright 2015-2021 The Matrix.org Foundation C.I.C.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import React from "react";
|
||||
import { type MatrixEvent, MatrixEventEvent } from "matrix-js-sdk/src/matrix";
|
||||
|
||||
import RoomContext from "../../../contexts/RoomContext";
|
||||
import * as TextForEvent from "../../../TextForEvent";
|
||||
import { MatrixClientPeg } from "../../../MatrixClientPeg";
|
||||
|
||||
interface IProps {
|
||||
mxEvent: MatrixEvent;
|
||||
}
|
||||
|
||||
export default class TextualEvent extends React.Component<IProps> {
|
||||
public static contextType = RoomContext;
|
||||
declare public context: React.ContextType<typeof RoomContext>;
|
||||
|
||||
public componentDidMount(): void {
|
||||
this.props.mxEvent.on(MatrixEventEvent.SentinelUpdated, this.onEventSentinelUpdated);
|
||||
}
|
||||
public componentWillUnmount(): void {
|
||||
this.props.mxEvent.off(MatrixEventEvent.SentinelUpdated, this.onEventSentinelUpdated);
|
||||
}
|
||||
|
||||
private onEventSentinelUpdated = (): void => {
|
||||
// XXX: this is crap, but we don't have a better way to force a re-render
|
||||
// Many TextForEvent handlers render parts of `event.sender` and `event.target` so ensure they are updated
|
||||
this.forceUpdate();
|
||||
};
|
||||
|
||||
public render(): React.ReactNode {
|
||||
const text = TextForEvent.textForEvent(
|
||||
this.props.mxEvent,
|
||||
MatrixClientPeg.safeGet(),
|
||||
true,
|
||||
this.context?.showHiddenEvents,
|
||||
);
|
||||
if (!text) return null;
|
||||
return <div className="mx_TextualEvent">{text}</div>;
|
||||
}
|
||||
}
|
||||
@@ -43,7 +43,6 @@ import { type ButtonEvent } from "../elements/AccessibleButton";
|
||||
import SdkConfig from "../../../SdkConfig";
|
||||
import MultiInviter from "../../../utils/MultiInviter";
|
||||
import { useTypedEventEmitter } from "../../../hooks/useEventEmitter";
|
||||
import { textualPowerLevel } from "../../../Roles";
|
||||
import MatrixClientContext from "../../../contexts/MatrixClientContext";
|
||||
import { RightPanelPhases } from "../../../stores/right-panel/RightPanelStorePhases";
|
||||
import EncryptionPanel from "./EncryptionPanel";
|
||||
@@ -54,7 +53,6 @@ import { useIsEncrypted } from "../../../hooks/useIsEncrypted";
|
||||
import BaseCard from "./BaseCard";
|
||||
import ImageView from "../elements/ImageView";
|
||||
import Spinner from "../elements/Spinner";
|
||||
import PowerSelector from "../elements/PowerSelector";
|
||||
import MemberAvatar from "../avatars/MemberAvatar";
|
||||
import PresenceLabel from "../rooms/PresenceLabel";
|
||||
import { ShareDialog } from "../dialogs/ShareDialog";
|
||||
@@ -76,6 +74,7 @@ import { Flex } from "../../utils/Flex";
|
||||
import CopyableText from "../elements/CopyableText";
|
||||
import { useUserTimezone } from "../../../hooks/useUserTimezone";
|
||||
import { UserInfoAdminToolsContainer } from "./user_info/UserInfoAdminToolsContainer";
|
||||
import { PowerLevelSection } from "./user_info/UserInfoPowerLevels";
|
||||
|
||||
export interface IDevice extends Device {
|
||||
ambiguous?: boolean;
|
||||
@@ -437,7 +436,7 @@ const useHomeserverSupportsCrossSigning = (cli: MatrixClient): boolean => {
|
||||
);
|
||||
};
|
||||
|
||||
interface IRoomPermissions {
|
||||
export interface IRoomPermissions {
|
||||
modifyLevelMax: number;
|
||||
canEdit: boolean;
|
||||
canInvite: boolean;
|
||||
@@ -492,112 +491,6 @@ function useRoomPermissions(cli: MatrixClient, room: Room, user: RoomMember): IR
|
||||
return roomPermissions;
|
||||
}
|
||||
|
||||
const PowerLevelSection: React.FC<{
|
||||
user: RoomMember;
|
||||
room: Room;
|
||||
roomPermissions: IRoomPermissions;
|
||||
powerLevels: IPowerLevelsContent;
|
||||
}> = ({ user, room, roomPermissions, powerLevels }) => {
|
||||
if (roomPermissions.canEdit) {
|
||||
return <PowerLevelEditor user={user} room={room} roomPermissions={roomPermissions} />;
|
||||
} else {
|
||||
const powerLevelUsersDefault = powerLevels.users_default || 0;
|
||||
const powerLevel = user.powerLevel;
|
||||
const role = textualPowerLevel(powerLevel, powerLevelUsersDefault);
|
||||
return (
|
||||
<div className="mx_UserInfo_profileField">
|
||||
<div className="mx_UserInfo_roleDescription">{role}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
export const PowerLevelEditor: React.FC<{
|
||||
user: RoomMember;
|
||||
room: Room;
|
||||
roomPermissions: IRoomPermissions;
|
||||
}> = ({ user, room, roomPermissions }) => {
|
||||
const cli = useContext(MatrixClientContext);
|
||||
|
||||
const [selectedPowerLevel, setSelectedPowerLevel] = useState(user.powerLevel);
|
||||
useEffect(() => {
|
||||
setSelectedPowerLevel(user.powerLevel);
|
||||
}, [user]);
|
||||
|
||||
const onPowerChange = useCallback(
|
||||
async (powerLevel: number) => {
|
||||
setSelectedPowerLevel(powerLevel);
|
||||
|
||||
const applyPowerChange = (roomId: string, target: string, powerLevel: number): Promise<unknown> => {
|
||||
return cli.setPowerLevel(roomId, target, powerLevel).then(
|
||||
function () {
|
||||
// NO-OP; rely on the m.room.member event coming down else we could
|
||||
// get out of sync if we force setState here!
|
||||
logger.log("Power change success");
|
||||
},
|
||||
function (err) {
|
||||
logger.error("Failed to change power level " + err);
|
||||
Modal.createDialog(ErrorDialog, {
|
||||
title: _t("common|error"),
|
||||
description: _t("error|update_power_level"),
|
||||
});
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
const roomId = user.roomId;
|
||||
const target = user.userId;
|
||||
|
||||
const powerLevelEvent = room.currentState.getStateEvents("m.room.power_levels", "");
|
||||
if (!powerLevelEvent) return;
|
||||
|
||||
const myUserId = cli.getUserId();
|
||||
const myPower = powerLevelEvent.getContent().users[myUserId || ""];
|
||||
if (myPower && parseInt(myPower) <= powerLevel && myUserId !== target) {
|
||||
const { finished } = Modal.createDialog(QuestionDialog, {
|
||||
title: _t("common|warning"),
|
||||
description: (
|
||||
<div>
|
||||
{_t("user_info|promote_warning")}
|
||||
<br />
|
||||
{_t("common|are_you_sure")}
|
||||
</div>
|
||||
),
|
||||
button: _t("action|continue"),
|
||||
});
|
||||
|
||||
const [confirmed] = await finished;
|
||||
if (!confirmed) return;
|
||||
} else if (myUserId === target && myPower && parseInt(myPower) > powerLevel) {
|
||||
// If we are changing our own PL it can only ever be decreasing, which we cannot reverse.
|
||||
try {
|
||||
if (!(await warnSelfDemote(room?.isSpaceRoom()))) return;
|
||||
} catch (e) {
|
||||
logger.error("Failed to warn about self demotion: ", e);
|
||||
}
|
||||
}
|
||||
|
||||
await applyPowerChange(roomId, target, powerLevel);
|
||||
},
|
||||
[user.roomId, user.userId, cli, room],
|
||||
);
|
||||
|
||||
const powerLevelEvent = room.currentState.getStateEvents("m.room.power_levels", "");
|
||||
const powerLevelUsersDefault = powerLevelEvent ? powerLevelEvent.getContent().users_default : 0;
|
||||
|
||||
return (
|
||||
<div className="mx_UserInfo_profileField">
|
||||
<PowerSelector
|
||||
label={undefined}
|
||||
value={selectedPowerLevel}
|
||||
maxValue={roomPermissions.modifyLevelMax}
|
||||
usersDefault={powerLevelUsersDefault}
|
||||
onChange={onPowerChange}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
async function getUserDeviceInfo(
|
||||
userId: string,
|
||||
cli: MatrixClient,
|
||||
@@ -820,12 +713,7 @@ const BasicUserInfo: React.FC<{
|
||||
// hide the Roles section for DMs as it doesn't make sense there
|
||||
if (!DMRoomMap.shared().getUserIdForRoomId((member as RoomMember).roomId)) {
|
||||
memberDetails = (
|
||||
<PowerLevelSection
|
||||
powerLevels={powerLevels}
|
||||
user={member as RoomMember}
|
||||
room={room}
|
||||
roomPermissions={roomPermissions}
|
||||
/>
|
||||
<PowerLevelSection user={member as RoomMember} room={room} roomPermissions={roomPermissions} />
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
/*
|
||||
Copyright 2025 New Vector Ltd.
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
|
||||
Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import React from "react";
|
||||
import { type RoomMember, type Room } from "matrix-js-sdk/src/matrix";
|
||||
|
||||
import { textualPowerLevel } from "../../../../Roles";
|
||||
import PowerSelector from "../../elements/PowerSelector";
|
||||
import { type IRoomPermissions } from "../UserInfo";
|
||||
import {
|
||||
type UserInfoPowerLevelState,
|
||||
useUserInfoPowerlevelViewModel,
|
||||
} from "../../../viewmodels/right_panel/UserInfoPowerlevelViewModel";
|
||||
|
||||
export const PowerLevelSection: React.FC<{
|
||||
user: RoomMember;
|
||||
room: Room;
|
||||
roomPermissions: IRoomPermissions;
|
||||
}> = ({ user, room, roomPermissions }) => {
|
||||
const vm = useUserInfoPowerlevelViewModel(user, room);
|
||||
|
||||
if (roomPermissions.canEdit) {
|
||||
return <PowerLevelEditor vm={vm} roomPermissions={roomPermissions} />;
|
||||
}
|
||||
|
||||
const powerLevel = user.powerLevel;
|
||||
const role = textualPowerLevel(powerLevel, vm.powerLevelUsersDefault);
|
||||
return (
|
||||
<div className="mx_UserInfo_profileField">
|
||||
<div className="mx_UserInfo_roleDescription">{role}</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const PowerLevelEditor: React.FC<{
|
||||
vm: UserInfoPowerLevelState;
|
||||
roomPermissions: IRoomPermissions;
|
||||
}> = ({ vm, roomPermissions }) => {
|
||||
return (
|
||||
<div className="mx_UserInfo_profileField">
|
||||
<PowerSelector
|
||||
label={undefined}
|
||||
value={vm.selectedPowerLevel}
|
||||
maxValue={roomPermissions.modifyLevelMax}
|
||||
usersDefault={vm.powerLevelUsersDefault}
|
||||
onChange={vm.onPowerChange}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -76,7 +76,17 @@ const E2EIcon: React.FC<Props> = ({
|
||||
if (onClick) {
|
||||
content = <AccessibleButton onClick={onClick} className={classes} style={style} />;
|
||||
} else {
|
||||
content = <div className={classes} style={style} />;
|
||||
// Verified and warning icon have a transparent cutout, so add a white background.
|
||||
// The normal icon already has the correct shape and size, so reuse that.
|
||||
if (status === E2EStatus.Verified || status === E2EStatus.Warning) {
|
||||
content = (
|
||||
<div className={classes} style={style}>
|
||||
<div className="mx_E2EIcon_normal" />
|
||||
</div>
|
||||
);
|
||||
} else {
|
||||
content = <div className={classes} style={style} />;
|
||||
}
|
||||
}
|
||||
|
||||
if (!e2eTitle || hideTooltip) {
|
||||
|
||||
@@ -1237,22 +1237,19 @@ export class UnwrappedEventTile extends React.Component<EventTileProps, IState>
|
||||
<div className={lineClasses} key="mx_EventTile_line" onContextMenu={this.onContextMenu}>
|
||||
{this.renderContextMenu()}
|
||||
{replyChain}
|
||||
{renderTile(
|
||||
TimelineRenderingType.Thread,
|
||||
{
|
||||
...this.props,
|
||||
{renderTile(TimelineRenderingType.Thread, {
|
||||
...this.props,
|
||||
|
||||
// overrides
|
||||
ref: this.tile,
|
||||
isSeeingThroughMessageHiddenForModeration,
|
||||
// overrides
|
||||
ref: this.tile,
|
||||
isSeeingThroughMessageHiddenForModeration,
|
||||
|
||||
// appease TS
|
||||
highlights: this.props.highlights,
|
||||
highlightLink: this.props.highlightLink,
|
||||
permalinkCreator: this.props.permalinkCreator!,
|
||||
},
|
||||
this.context.showHiddenEvents,
|
||||
)}
|
||||
// appease TS
|
||||
highlights: this.props.highlights,
|
||||
highlightLink: this.props.highlightLink,
|
||||
permalinkCreator: this.props.permalinkCreator!,
|
||||
showHiddenEvents: this.context.showHiddenEvents,
|
||||
})}
|
||||
{actionBar}
|
||||
<a href={permalink} onClick={this.onPermalinkClicked}>
|
||||
{timestamp}
|
||||
@@ -1383,22 +1380,19 @@ export class UnwrappedEventTile extends React.Component<EventTileProps, IState>
|
||||
</a>,
|
||||
<div className={lineClasses} key="mx_EventTile_line" onContextMenu={this.onContextMenu}>
|
||||
{this.renderContextMenu()}
|
||||
{renderTile(
|
||||
TimelineRenderingType.File,
|
||||
{
|
||||
...this.props,
|
||||
{renderTile(TimelineRenderingType.File, {
|
||||
...this.props,
|
||||
|
||||
// overrides
|
||||
ref: this.tile,
|
||||
isSeeingThroughMessageHiddenForModeration,
|
||||
// overrides
|
||||
ref: this.tile,
|
||||
isSeeingThroughMessageHiddenForModeration,
|
||||
|
||||
// appease TS
|
||||
highlights: this.props.highlights,
|
||||
highlightLink: this.props.highlightLink,
|
||||
permalinkCreator: this.props.permalinkCreator,
|
||||
},
|
||||
this.context.showHiddenEvents,
|
||||
)}
|
||||
// appease TS
|
||||
highlights: this.props.highlights,
|
||||
highlightLink: this.props.highlightLink,
|
||||
permalinkCreator: this.props.permalinkCreator,
|
||||
showHiddenEvents: this.context.showHiddenEvents,
|
||||
})}
|
||||
</div>,
|
||||
],
|
||||
);
|
||||
@@ -1433,23 +1427,20 @@ export class UnwrappedEventTile extends React.Component<EventTileProps, IState>
|
||||
{groupTimestamp}
|
||||
{groupPadlock}
|
||||
{replyChain}
|
||||
{renderTile(
|
||||
this.context.timelineRenderingType,
|
||||
{
|
||||
...this.props,
|
||||
{renderTile(this.context.timelineRenderingType, {
|
||||
...this.props,
|
||||
|
||||
// overrides
|
||||
ref: this.tile,
|
||||
isSeeingThroughMessageHiddenForModeration,
|
||||
timestamp: bubbleTimestamp,
|
||||
// overrides
|
||||
ref: this.tile,
|
||||
isSeeingThroughMessageHiddenForModeration,
|
||||
timestamp: bubbleTimestamp,
|
||||
|
||||
// appease TS
|
||||
highlights: this.props.highlights,
|
||||
highlightLink: this.props.highlightLink,
|
||||
permalinkCreator: this.props.permalinkCreator,
|
||||
},
|
||||
this.context.showHiddenEvents,
|
||||
)}
|
||||
// appease TS
|
||||
highlights: this.props.highlights,
|
||||
highlightLink: this.props.highlightLink,
|
||||
permalinkCreator: this.props.permalinkCreator,
|
||||
showHiddenEvents: this.context.showHiddenEvents,
|
||||
})}
|
||||
{actionBar}
|
||||
{this.props.layout === Layout.IRC && (
|
||||
<>
|
||||
|
||||
@@ -163,6 +163,7 @@ export default class ReplyTile extends React.PureComponent<IProps> {
|
||||
highlights: this.props.highlights,
|
||||
highlightLink: this.props.highlightLink,
|
||||
permalinkCreator: this.props.permalinkCreator,
|
||||
showHiddenEvents: false,
|
||||
},
|
||||
false /* showHiddenEvents shouldn't be relevant */,
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
/*
|
||||
* Copyright 2025 New Vector Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { type Room } from "matrix-js-sdk/src/matrix";
|
||||
import { type JSX, type PropsWithChildren } from "react";
|
||||
import { ContextMenu } from "@vector-im/compound-web";
|
||||
import React from "react";
|
||||
|
||||
import { _t } from "../../../../languageHandler";
|
||||
import { MoreOptionContent } from "./RoomListItemMenuView";
|
||||
import { useRoomListItemMenuViewModel } from "../../../viewmodels/roomlist/RoomListItemMenuViewModel";
|
||||
|
||||
interface RoomListItemContextMenuViewProps {
|
||||
/**
|
||||
* The room to display the menu for.
|
||||
*/
|
||||
room: Room;
|
||||
/**
|
||||
* Set the menu open state.
|
||||
*/
|
||||
setMenuOpen: (isOpen: boolean) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* A view for the room list item context menu.
|
||||
*/
|
||||
export function RoomListItemContextMenuView({
|
||||
room,
|
||||
setMenuOpen,
|
||||
children,
|
||||
}: PropsWithChildren<RoomListItemContextMenuViewProps>): JSX.Element {
|
||||
const vm = useRoomListItemMenuViewModel(room);
|
||||
|
||||
return (
|
||||
<ContextMenu
|
||||
title={_t("room_list|room|more_options")}
|
||||
showTitle={false}
|
||||
// To not mess with the roving tab index of the button
|
||||
hasAccessibleAlternative={true}
|
||||
trigger={children}
|
||||
onOpenChange={setMenuOpen}
|
||||
>
|
||||
<MoreOptionContent vm={vm} />
|
||||
</ContextMenu>
|
||||
);
|
||||
}
|
||||
@@ -35,7 +35,6 @@ interface RoomListItemMenuViewProps {
|
||||
room: Room;
|
||||
/**
|
||||
* Set the menu open state.
|
||||
* @param isOpen
|
||||
*/
|
||||
setMenuOpen: (isOpen: boolean) => void;
|
||||
}
|
||||
@@ -84,6 +83,21 @@ function MoreOptionsMenu({ vm, setMenuOpen }: MoreOptionsMenuProps): JSX.Element
|
||||
align="start"
|
||||
trigger={<MoreOptionsButton size="24px" />}
|
||||
>
|
||||
<MoreOptionContent vm={vm} />
|
||||
</Menu>
|
||||
);
|
||||
}
|
||||
|
||||
interface MoreOptionContentProps {
|
||||
/**
|
||||
* The view model state for the menu.
|
||||
*/
|
||||
vm: RoomListItemMenuViewState;
|
||||
}
|
||||
|
||||
export function MoreOptionContent({ vm }: MoreOptionContentProps): JSX.Element {
|
||||
return (
|
||||
<>
|
||||
{vm.canMarkAsRead && (
|
||||
<MenuItem
|
||||
Icon={MarkAsReadIcon}
|
||||
@@ -143,7 +157,7 @@ function MoreOptionsMenu({ vm, setMenuOpen }: MoreOptionsMenuProps): JSX.Element
|
||||
onClick={(evt) => evt.stopPropagation()}
|
||||
hideChevron={true}
|
||||
/>
|
||||
</Menu>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -154,7 +168,7 @@ interface MoreOptionsButtonProps extends ComponentProps<typeof IconButton> {
|
||||
/**
|
||||
* A button to trigger the more options menu.
|
||||
*/
|
||||
export const MoreOptionsButton = function MoreOptionsButton(props: MoreOptionsButtonProps): JSX.Element {
|
||||
const MoreOptionsButton = function MoreOptionsButton(props: MoreOptionsButtonProps): JSX.Element {
|
||||
return (
|
||||
<Tooltip label={_t("room_list|room|more_options")}>
|
||||
<IconButton aria-label={_t("room_list|room|more_options")} {...props}>
|
||||
@@ -244,7 +258,7 @@ interface NotificationButtonProps extends ComponentProps<typeof IconButton> {
|
||||
/**
|
||||
* A button to trigger the notification menu.
|
||||
*/
|
||||
export const NotificationButton = function MoreOptionsButton({
|
||||
const NotificationButton = function MoreOptionsButton({
|
||||
isRoomMuted,
|
||||
ref,
|
||||
...props
|
||||
|
||||
@@ -15,6 +15,7 @@ import { RoomListItemMenuView } from "./RoomListItemMenuView";
|
||||
import { NotificationDecoration } from "../NotificationDecoration";
|
||||
import { RoomAvatarView } from "../../avatars/RoomAvatarView";
|
||||
import { useRovingTabIndex } from "../../../../accessibility/RovingTabIndex";
|
||||
import { RoomListItemContextMenuView } from "./RoomListItemContextMenuView";
|
||||
|
||||
interface RoomListItemViewProps extends React.HTMLAttributes<HTMLButtonElement> {
|
||||
/**
|
||||
@@ -47,7 +48,13 @@ export const RoomListItemView = memo(function RoomListItemView({
|
||||
const showHoverDecoration = isMenuOpen || isHover;
|
||||
const showHoverMenu = showHoverDecoration && vm.showHoverMenu;
|
||||
|
||||
return (
|
||||
const closeMenu = useCallback(() => {
|
||||
// To avoid icon blinking when closing the menu, we delay the state update
|
||||
// Also, let the focus move to the menu trigger before closing the menu
|
||||
setTimeout(() => setIsMenuOpen(false), 10);
|
||||
}, []);
|
||||
|
||||
const content = (
|
||||
<button
|
||||
ref={ref}
|
||||
className={classNames("mx_RoomListItemView", {
|
||||
@@ -92,17 +99,7 @@ export const RoomListItemView = memo(function RoomListItemView({
|
||||
{showHoverMenu ? (
|
||||
<RoomListItemMenuView
|
||||
room={room}
|
||||
setMenuOpen={(isOpen) => {
|
||||
if (isOpen) {
|
||||
setIsMenuOpen(isOpen);
|
||||
} else {
|
||||
// To avoid icon blinking when closing the menu, we delay the state update
|
||||
setTimeout(() => setIsMenuOpen(isOpen), 0);
|
||||
// After closing the menu, we need to set the focus back to the button
|
||||
// 10ms because the focus moves to the body and we put back the focus on the button
|
||||
setTimeout(() => buttonRef.current?.focus(), 10);
|
||||
}
|
||||
}}
|
||||
setMenuOpen={(isOpen) => (isOpen ? setIsMenuOpen(true) : closeMenu())}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
@@ -120,6 +117,24 @@ export const RoomListItemView = memo(function RoomListItemView({
|
||||
</Flex>
|
||||
</button>
|
||||
);
|
||||
|
||||
if (!vm.showContextMenu) return content;
|
||||
|
||||
return (
|
||||
<RoomListItemContextMenuView
|
||||
room={room}
|
||||
setMenuOpen={(isOpen) => {
|
||||
if (isOpen) {
|
||||
// To avoid icon blinking when the context menu is re-opened
|
||||
setTimeout(() => setIsMenuOpen(true), 0);
|
||||
} else {
|
||||
closeMenu();
|
||||
}
|
||||
}}
|
||||
>
|
||||
{content}
|
||||
</RoomListItemContextMenuView>
|
||||
);
|
||||
});
|
||||
|
||||
/**
|
||||
|
||||
@@ -6,10 +6,9 @@
|
||||
*/
|
||||
|
||||
import React, { type JSX } from "react";
|
||||
import classNames from "classnames";
|
||||
import { Heading } from "@vector-im/compound-web";
|
||||
|
||||
import { _t } from "../../../languageHandler";
|
||||
|
||||
/**
|
||||
* The heading for a settings section.
|
||||
*/
|
||||
@@ -25,9 +24,12 @@ interface SettingsHeaderProps {
|
||||
}
|
||||
|
||||
export function SettingsHeader({ hasRecommendedTag = false, label }: SettingsHeaderProps): JSX.Element {
|
||||
const classes = classNames("mx_SettingsHeader", {
|
||||
mx_SettingsHeader_recommended: hasRecommendedTag,
|
||||
});
|
||||
return (
|
||||
<Heading className="mx_SettingsHeader" as="h2" size="sm" weight="semibold">
|
||||
{label} {hasRecommendedTag && <span>{_t("common|recommended")}</span>}
|
||||
<Heading className={classes} as="h2" size="sm" weight="semibold">
|
||||
{label}
|
||||
</Heading>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -29,6 +29,7 @@ import { initialiseDehydrationIfEnabled } from "../../../../utils/device/dehydra
|
||||
import { withSecretStorageKeyCache } from "../../../../SecurityManager";
|
||||
import { EncryptionCardButtons } from "./EncryptionCardButtons";
|
||||
import { logErrorAndShowErrorDialog } from "../../../../utils/ErrorUtils.tsx";
|
||||
import { RECOVERY_ACCOUNT_DATA_KEY } from "../../../../DeviceListener";
|
||||
|
||||
/**
|
||||
* The possible states of the component.
|
||||
@@ -131,6 +132,10 @@ export function ChangeRecoveryKey({
|
||||
});
|
||||
await initialiseDehydrationIfEnabled(matrixClient, { createNewKey: true });
|
||||
});
|
||||
|
||||
// Record the fact that the user explicitly enabled recovery.
|
||||
await matrixClient.setAccountData(RECOVERY_ACCOUNT_DATA_KEY, { enabled: true });
|
||||
|
||||
onFinish();
|
||||
} catch (e) {
|
||||
logErrorAndShowErrorDialog("Failed to set up secret storage", e);
|
||||
|
||||
@@ -182,7 +182,7 @@ export default function NotificationSettings2(): JSX.Element {
|
||||
description={_t("settings|notifications|play_sound_for_description")}
|
||||
>
|
||||
<LabelledCheckbox
|
||||
label="People"
|
||||
label={_t("common|people")}
|
||||
value={settings.sound.people !== undefined}
|
||||
disabled={disabled || settings.defaultLevels.dm === RoomNotifState.MentionsOnly}
|
||||
onChange={(value) => {
|
||||
|
||||