Compare commits
73 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4d55e8f433 | |||
| 02990bd275 | |||
| 67658aef56 | |||
| 8941724020 | |||
| 0a8393c9e1 | |||
| 0fa52e610e | |||
| 1ea1d386ab | |||
| afa6f377ea | |||
| b7f8623617 | |||
| e75ba356d3 | |||
| 4f1eac67a8 | |||
| aa01b17f9e | |||
| 4cba79ddcc | |||
| b64471e4f6 | |||
| d3a6f34881 | |||
| dcce9c70dc | |||
| f06ed2fa1f | |||
| 099c3073b6 | |||
| 12932e2dc6 | |||
| a7de29429c | |||
| d3ea250d77 | |||
| f243fee5a6 | |||
| 296d0074ed | |||
| df83338f26 | |||
| c0336f21f6 | |||
| 886d0e1241 | |||
| c453d33456 | |||
| ddf221b813 | |||
| 08238bb883 | |||
| c390ec333e | |||
| 3c22e5dc68 | |||
| f29ce94dd4 | |||
| 76485cfb17 | |||
| c0567fc5f4 | |||
| 95da3834f2 | |||
| b8a3468485 | |||
| b7f6e0f88c | |||
| b36d9ce32e | |||
| 9b6be0f5a9 | |||
| cae0da8f00 | |||
| 25689de34a | |||
| ce6cb47943 | |||
| 850c1a5b3a | |||
| ec4ae9e58a | |||
| a73eb378d7 | |||
| 197afd6a9e | |||
| ac565dca80 | |||
| a0044d6b5f | |||
| 68c03db557 | |||
| 9a109cdce8 | |||
| a0ab88943b | |||
| ad01218942 | |||
| e1e4d26154 | |||
| 84c614676d | |||
| 29d9e98111 | |||
| 9f5f898ed8 | |||
| 78251a3a8a | |||
| 1b077c53f5 | |||
| 68828a2326 | |||
| af8d93f58a | |||
| c0a097867e | |||
| 0b13e57518 | |||
| 8615b411b2 | |||
| 3d31376b1d | |||
| 43e5124cd4 | |||
| 19674cca08 | |||
| 6ca6cb0fbe | |||
| d92fc5a595 | |||
| b9d411eecc | |||
| 3da6619bcf | |||
| f33e7c9782 | |||
| 1ebae09834 | |||
| 56eafc908e |
@@ -7,3 +7,4 @@ test/end-to-end-tests/lib/
|
||||
src/component-index.js
|
||||
# Auto-generated file
|
||||
src/modules.ts
|
||||
src/modules.js
|
||||
|
||||
@@ -10,10 +10,11 @@
|
||||
/test/components/views/dialogs/security/ @element-hq/element-crypto-web-reviewers
|
||||
/src/stores/SetupEncryptionStore.ts @element-hq/element-crypto-web-reviewers
|
||||
/test/stores/SetupEncryptionStore-test.ts @element-hq/element-crypto-web-reviewers
|
||||
/src/components/views/settings/tabs/user/EncryptionUserSettingsTab.tsx @element-hq/element-crypto-web-reviewers
|
||||
/src/components/views/settings/tabs/user/EncryptionUserSettingsTab.tsx @element-hq/element-crypto-web-reviewers
|
||||
/src/src/components/views/settings/encryption/ @element-hq/element-crypto-web-reviewers
|
||||
/test/unit-tests/components/views/settings/encryption/ @element-hq/element-crypto-web-reviewers
|
||||
/playwright/e2e/settings/encryption-user-tab/ @element-hq/element-crypto-web-reviewers
|
||||
/playwright/e2e/settings/encryption-user-tab/ @element-hq/element-crypto-web-reviewers
|
||||
/src/components/views/dialogs/devtools/Crypto.tsx @element-hq/element-crypto-web-reviewers
|
||||
|
||||
# Ignore translations as those will be updated by GHA for Localazy download
|
||||
/src/i18n/strings
|
||||
|
||||
@@ -26,6 +26,12 @@ jobs:
|
||||
R2_URL: ${{ vars.CF_R2_S3_API }}
|
||||
R2_PUBLIC_URL: "https://element-web-develop.element.io"
|
||||
steps:
|
||||
# Workaround for https://www.cloudflarestatus.com/incidents/t5nrjmpxc1cj
|
||||
- uses: unfor19/install-aws-cli-action@v1
|
||||
with:
|
||||
version: 2.22.35
|
||||
verbose: false
|
||||
arch: amd64
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
|
||||
@@ -51,7 +51,7 @@ jobs:
|
||||
|
||||
- name: Build and push
|
||||
id: build-and-push
|
||||
uses: docker/build-push-action@b32b51a8eda65d6793cd0494a773d4f6bcef32dc # v6
|
||||
uses: docker/build-push-action@67a2d409c0a876cbe6b11854e3e25193efe4e62d # v6
|
||||
with:
|
||||
context: .
|
||||
push: true
|
||||
|
||||
@@ -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@56cd38caf0615dd03f49d42ed301f1469911ac61
|
||||
uses: guibranco/github-status-action-v2@ecd54a02cf761e85a8fb328fe937710fd4227cda
|
||||
with:
|
||||
authToken: ${{ secrets.GITHUB_TOKEN }}
|
||||
state: success
|
||||
|
||||
@@ -26,6 +26,7 @@ electron/pub
|
||||
/coverage
|
||||
# Auto-generated file
|
||||
/src/modules.ts
|
||||
/src/modules.js
|
||||
/build_config.yaml
|
||||
/book
|
||||
/index.html
|
||||
|
||||
@@ -17,6 +17,7 @@ electron/pub
|
||||
/coverage
|
||||
# Auto-generated file
|
||||
/src/modules.ts
|
||||
/src/modules.js
|
||||
/src/i18n/strings
|
||||
/build_config.yaml
|
||||
# Raises an error because it contains a template var breaking the script tag
|
||||
|
||||
@@ -33,19 +33,15 @@ module.exports = {
|
||||
"import-notation": null,
|
||||
"value-keyword-case": null,
|
||||
"declaration-block-no-redundant-longhand-properties": null,
|
||||
"declaration-block-no-duplicate-properties": [
|
||||
true,
|
||||
// useful for fallbacks
|
||||
{ ignore: ["consecutive-duplicates-with-different-values"] },
|
||||
],
|
||||
"shorthand-property-no-redundant-values": null,
|
||||
"property-no-vendor-prefix": null,
|
||||
"value-no-vendor-prefix": null,
|
||||
"selector-no-vendor-prefix": null,
|
||||
"media-feature-name-no-vendor-prefix": null,
|
||||
"number-max-precision": null,
|
||||
"no-invalid-double-slash-comments": true,
|
||||
"media-feature-range-notation": null,
|
||||
"declaration-property-value-no-unknown": null,
|
||||
"declaration-property-value-keyword-no-deprecated": null,
|
||||
"csstools/value-no-unknown-custom-properties": [
|
||||
true,
|
||||
{
|
||||
|
||||
@@ -1,3 +1,36 @@
|
||||
Changes in [1.11.92](https://github.com/element-hq/element-web/releases/tag/v1.11.92) (2025-02-11)
|
||||
==================================================================================================
|
||||
## ✨ Features
|
||||
|
||||
* [Backport staging] Log when we show, and hide, encryption setup toasts ([#29238](https://github.com/element-hq/element-web/pull/29238)). Contributed by @richvdh.
|
||||
* Make profile header section match the designs ([#29163](https://github.com/element-hq/element-web/pull/29163)). Contributed by @MidhunSureshR.
|
||||
* Always show back button in the right panel ([#29128](https://github.com/element-hq/element-web/pull/29128)). Contributed by @MidhunSureshR.
|
||||
* Schedule dehydration on reload if the dehydration key is already cached locally ([#29021](https://github.com/element-hq/element-web/pull/29021)). Contributed by @uhoreg.
|
||||
* update to twemoji 15.1.0 ([#29115](https://github.com/element-hq/element-web/pull/29115)). Contributed by @ara4n.
|
||||
* Update matrix-widget-api ([#29112](https://github.com/element-hq/element-web/pull/29112)). Contributed by @toger5.
|
||||
* Allow navigating through the memberlist using up/down keys ([#28949](https://github.com/element-hq/element-web/pull/28949)). Contributed by @MidhunSureshR.
|
||||
* Style room header icons and facepile for toggled state ([#28968](https://github.com/element-hq/element-web/pull/28968)). Contributed by @MidhunSureshR.
|
||||
* Move threads header below base card header ([#28969](https://github.com/element-hq/element-web/pull/28969)). Contributed by @MidhunSureshR.
|
||||
* Add `Advanced` section to the user settings encryption tab ([#28804](https://github.com/element-hq/element-web/pull/28804)). Contributed by @florianduros.
|
||||
* Fix outstanding UX issues with replies/mentions/keyword notifs ([#28270](https://github.com/element-hq/element-web/pull/28270)). Contributed by @taffyko.
|
||||
* Distinguish room state and timeline events when dealing with widgets ([#28681](https://github.com/element-hq/element-web/pull/28681)). Contributed by @robintown.
|
||||
* Switch OIDC primarily to new `/auth_metadata` API ([#29019](https://github.com/element-hq/element-web/pull/29019)). Contributed by @t3chguy.
|
||||
* More memberlist changes ([#29069](https://github.com/element-hq/element-web/pull/29069)). Contributed by @MidhunSureshR.
|
||||
|
||||
## 🐛 Bug Fixes
|
||||
|
||||
* [Backport staging] Wire up the "Forgot recovery key" button for the "Key storage out of sync" toast ([#29190](https://github.com/element-hq/element-web/pull/29190)). Contributed by @RiotRobot.
|
||||
* Encryption tab: hide `Advanced` section when the key storage is out of sync ([#29129](https://github.com/element-hq/element-web/pull/29129)). Contributed by @florianduros.
|
||||
* Fix share button in discovery settings being disabled incorrectly ([#29151](https://github.com/element-hq/element-web/pull/29151)). Contributed by @t3chguy.
|
||||
* Ensure switching rooms does not wrongly focus timeline search ([#29153](https://github.com/element-hq/element-web/pull/29153)). Contributed by @t3chguy.
|
||||
* Stop showing a dialog prompting the user to enter an old recovery key ([#29143](https://github.com/element-hq/element-web/pull/29143)). Contributed by @richvdh.
|
||||
* Make themed widgets reflect the effective theme ([#28342](https://github.com/element-hq/element-web/pull/28342)). Contributed by @robintown.
|
||||
* support non-VS16 emoji ligatures in TwemojiMozilla ([#29100](https://github.com/element-hq/element-web/pull/29100)). Contributed by @ara4n.
|
||||
* e2e test: Verify session with the encryption tab instead of the security \& privacy tab ([#29090](https://github.com/element-hq/element-web/pull/29090)). Contributed by @florianduros.
|
||||
* Work around cloudflare R2 / aws client incompatability ([#29086](https://github.com/element-hq/element-web/pull/29086)). Contributed by @dbkr.
|
||||
* Fix identity server settings visibility ([#29083](https://github.com/element-hq/element-web/pull/29083)). Contributed by @dbkr.
|
||||
|
||||
|
||||
Changes in [1.11.91](https://github.com/element-hq/element-web/releases/tag/v1.11.91) (2025-01-28)
|
||||
==================================================================================================
|
||||
## ✨ Features
|
||||
|
||||
@@ -8,11 +8,13 @@
|
||||
|
||||
#### develop
|
||||
|
||||
The develop branch holds the very latest and greatest code we have to offer, as such it may be less stable. It corresponds to the develop.element.io CD platform.
|
||||
The develop branch holds the very latest and greatest code we have to offer, as such it may be less stable.
|
||||
It is auto-deployed on every commit to element-web or matrix-js-sdk to develop.element.io via GitHub Actions `build_develop.yml`.
|
||||
|
||||
#### staging
|
||||
|
||||
The staging branch corresponds to the very latest release regardless of whether it is an RC or not. Deployed to staging.element.io manually.
|
||||
It is auto-deployed on every release of element-web to staging.element.io via GitHub Actions `deploy.yml`.
|
||||
|
||||
#### master
|
||||
|
||||
@@ -215,7 +217,7 @@ We ship Element Web to dockerhub, `*.element.io`, and packages.element.io.
|
||||
We ship Element Desktop to packages.element.io.
|
||||
|
||||
- [ ] Check that element-web has shipped to dockerhub
|
||||
- [ ] Deploy staging.element.io. [See docs.](https://handbook.element.io/books/element-web-team/page/deploying-appstagingelementio)
|
||||
- [ ] Check that the staging [deployment](https://github.com/element-hq/element-web/actions/workflows/deploy.yml) has completed successfully
|
||||
- [ ] Test staging.element.io
|
||||
|
||||
For final releases additionally do these steps:
|
||||
@@ -225,6 +227,9 @@ For final releases additionally do these steps:
|
||||
- [ ] Ensure Element Web package has shipped to packages.element.io
|
||||
- [ ] Ensure Element Desktop packages have shipped to packages.element.io
|
||||
|
||||
If you need to roll back a deployment to staging.element.io,
|
||||
you can run the `deploy.yml` automation choosing an older tag which you wish to deploy.
|
||||
|
||||
# Housekeeping
|
||||
|
||||
We have some manual housekeeping to do in order to prepare for the next release.
|
||||
|
||||
@@ -23,10 +23,9 @@ const MODULES_TS_HEADER = `
|
||||
* You are not a salmon.
|
||||
*/
|
||||
|
||||
import { RuntimeModule } from "@matrix-org/react-sdk-module-api/lib/RuntimeModule";
|
||||
`;
|
||||
const MODULES_TS_DEFINITIONS = `
|
||||
export const INSTALLED_MODULES: RuntimeModule[] = [];
|
||||
export const INSTALLED_MODULES = [];
|
||||
`;
|
||||
|
||||
export function installer(config: BuildConfig): void {
|
||||
@@ -78,8 +77,8 @@ export function installer(config: BuildConfig): void {
|
||||
return; // hit the finally{} block before exiting
|
||||
}
|
||||
|
||||
// If we reach here, everything seems fine. Write modules.ts and log some output
|
||||
// Note: we compile modules.ts in two parts for developer friendliness if they
|
||||
// If we reach here, everything seems fine. Write modules.js and log some output
|
||||
// Note: we compile modules.js in two parts for developer friendliness if they
|
||||
// happen to look at it.
|
||||
console.log("The following modules have been installed: ", installedModules);
|
||||
let modulesTsHeader = MODULES_TS_HEADER;
|
||||
@@ -193,5 +192,5 @@ function isModuleVersionCompatible(ourApiVersion: string, moduleApiVersion: stri
|
||||
}
|
||||
|
||||
function writeModulesTs(content: string): void {
|
||||
fs.writeFileSync("./src/modules.ts", content, "utf-8");
|
||||
fs.writeFileSync("./src/modules.js", content, "utf-8");
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "element-web",
|
||||
"version": "1.11.91",
|
||||
"version": "1.11.92",
|
||||
"description": "A feature-rich client for Matrix.org",
|
||||
"author": "New Vector Ltd.",
|
||||
"repository": {
|
||||
@@ -74,7 +74,7 @@
|
||||
"@types/react-dom": "18.3.5",
|
||||
"oidc-client-ts": "3.1.0",
|
||||
"jwt-decode": "4.0.0",
|
||||
"caniuse-lite": "1.0.30001690",
|
||||
"caniuse-lite": "1.0.30001692",
|
||||
"wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0",
|
||||
"wrap-ansi": "npm:wrap-ansi@^7.0.0"
|
||||
},
|
||||
@@ -84,14 +84,14 @@
|
||||
"@fontsource/inter": "^5",
|
||||
"@formatjs/intl-segmenter": "^11.5.7",
|
||||
"@matrix-org/analytics-events": "^0.29.0",
|
||||
"@matrix-org/emojibase-bindings": "^1.3.3",
|
||||
"@matrix-org/emojibase-bindings": "^1.3.4",
|
||||
"@matrix-org/react-sdk-module-api": "^2.4.0",
|
||||
"@matrix-org/spec": "^1.7.0",
|
||||
"@sentry/browser": "^8.0.0",
|
||||
"@types/png-chunks-extract": "^1.0.2",
|
||||
"@types/react-virtualized": "^9.21.30",
|
||||
"@vector-im/compound-design-tokens": "^2.1.0",
|
||||
"@vector-im/compound-web": "^7.5.0",
|
||||
"@vector-im/compound-web": "^7.6.1",
|
||||
"@vector-im/matrix-wysiwyg": "2.38.0",
|
||||
"@zxcvbn-ts/core": "^3.0.4",
|
||||
"@zxcvbn-ts/language-common": "^3.0.4",
|
||||
@@ -127,7 +127,7 @@
|
||||
"maplibre-gl": "^5.0.0",
|
||||
"matrix-encrypt-attachment": "^1.0.3",
|
||||
"matrix-events-sdk": "0.0.1",
|
||||
"matrix-js-sdk": "36.1.0",
|
||||
"matrix-js-sdk": "36.2.0",
|
||||
"matrix-widget-api": "^1.10.0",
|
||||
"memoize-one": "^6.0.0",
|
||||
"mime": "^4.0.4",
|
||||
@@ -178,7 +178,7 @@
|
||||
"@peculiar/webcrypto": "^1.4.3",
|
||||
"@playwright/test": "^1.40.1",
|
||||
"@principalstudio/html-webpack-inject-preload": "^1.2.7",
|
||||
"@sentry/webpack-plugin": "^2.7.1",
|
||||
"@sentry/webpack-plugin": "^3.0.0",
|
||||
"@stylistic/eslint-plugin": "^2.9.0",
|
||||
"@svgr/webpack": "^8.0.0",
|
||||
"@testcontainers/postgresql": "^10.16.0",
|
||||
@@ -230,7 +230,7 @@
|
||||
"dotenv": "^16.0.2",
|
||||
"eslint": "8.57.1",
|
||||
"eslint-config-google": "^0.14.0",
|
||||
"eslint-config-prettier": "^9.0.0",
|
||||
"eslint-config-prettier": "^10.0.0",
|
||||
"eslint-plugin-deprecate": "0.8.5",
|
||||
"eslint-plugin-import": "^2.25.4",
|
||||
"eslint-plugin-jest": "^28.0.0",
|
||||
@@ -256,7 +256,7 @@
|
||||
"jsqr": "^1.4.0",
|
||||
"knip": "^5.36.2",
|
||||
"lint-staged": "^15.0.2",
|
||||
"mailhog": "^4.16.0",
|
||||
"mailpit-api": "^1.0.5",
|
||||
"matrix-web-i18n": "^3.2.1",
|
||||
"mini-css-extract-plugin": "2.9.2",
|
||||
"minimist": "^1.2.6",
|
||||
@@ -280,14 +280,14 @@
|
||||
"semver": "^7.5.2",
|
||||
"source-map-loader": "^5.0.0",
|
||||
"strip-ansi": "^7.1.0",
|
||||
"stylelint": "^16.1.0",
|
||||
"stylelint-config-standard": "^36.0.0",
|
||||
"stylelint": "^16.13.0",
|
||||
"stylelint-config-standard": "^37.0.0",
|
||||
"stylelint-scss": "^6.0.0",
|
||||
"stylelint-value-no-unknown-custom-properties": "^6.0.1",
|
||||
"terser-webpack-plugin": "^5.3.9",
|
||||
"testcontainers": "^10.16.0",
|
||||
"ts-node": "^10.9.1",
|
||||
"typescript": "5.7.2",
|
||||
"typescript": "5.7.3",
|
||||
"util": "^0.12.5",
|
||||
"web-streams-polyfill": "^4.0.0",
|
||||
"webpack": "^5.89.0",
|
||||
|
||||
@@ -19,19 +19,19 @@ test.use(masHomeserver);
|
||||
test.describe("Encryption state after registration", () => {
|
||||
test.skip(isDendrite, "does not yet support MAS");
|
||||
|
||||
test("Key backup is enabled by default", async ({ page, mailhogClient, app }, testInfo) => {
|
||||
test("Key backup is enabled by default", async ({ page, mailpitClient, app }, testInfo) => {
|
||||
await page.goto("/#/login");
|
||||
await page.getByRole("button", { name: "Continue" }).click();
|
||||
await registerAccountMas(page, mailhogClient, `alice_${testInfo.testId}`, "alice@email.com", "Pa$sW0rD!");
|
||||
await registerAccountMas(page, mailpitClient, `alice_${testInfo.testId}`, "alice@email.com", "Pa$sW0rD!");
|
||||
|
||||
await app.settings.openUserSettings("Security & Privacy");
|
||||
await expect(page.getByText("This session is backing up your keys.")).toBeVisible();
|
||||
});
|
||||
|
||||
test("user is prompted to set up recovery", async ({ page, mailhogClient, app }, testInfo) => {
|
||||
test("user is prompted to set up recovery", async ({ page, mailpitClient, app }, testInfo) => {
|
||||
await page.goto("/#/login");
|
||||
await page.getByRole("button", { name: "Continue" }).click();
|
||||
await registerAccountMas(page, mailhogClient, `alice_${testInfo.testId}`, "alice@email.com", "Pa$sW0rD!");
|
||||
await registerAccountMas(page, mailpitClient, `alice_${testInfo.testId}`, "alice@email.com", "Pa$sW0rD!");
|
||||
|
||||
await page.getByRole("button", { name: "Add room" }).click();
|
||||
await page.getByRole("menuitem", { name: "New room" }).click();
|
||||
@@ -47,7 +47,7 @@ test.describe("Key backup reset from elsewhere", () => {
|
||||
|
||||
test("Key backup is disabled when reset from elsewhere", async ({
|
||||
page,
|
||||
mailhogClient,
|
||||
mailpitClient,
|
||||
request,
|
||||
homeserver,
|
||||
}, testInfo) => {
|
||||
@@ -60,7 +60,7 @@ test.describe("Key backup reset from elsewhere", () => {
|
||||
|
||||
await page.goto("/#/login");
|
||||
await page.getByRole("button", { name: "Continue" }).click();
|
||||
await registerAccountMas(page, mailhogClient, testUsername, "alice@email.com", testPassword);
|
||||
await registerAccountMas(page, mailpitClient, testUsername, "alice@email.com", testPassword);
|
||||
|
||||
await page.getByRole("button", { name: "Add room" }).click();
|
||||
await page.getByRole("menuitem", { name: "New room" }).click();
|
||||
|
||||
@@ -10,6 +10,7 @@ import { type Page } from "@playwright/test";
|
||||
|
||||
import { test, expect } from "../../element-web-test";
|
||||
import { isDendrite } from "../../plugins/homeserver/dendrite";
|
||||
import { completeCreateSecretStorageDialog } from "./utils.ts";
|
||||
|
||||
async function expectBackupVersionToBe(page: Page, version: string) {
|
||||
await expect(page.locator(".mx_SecureBackupPanel_statusList tr:nth-child(5) td")).toHaveText(
|
||||
@@ -35,19 +36,7 @@ test.describe("Backups", () => {
|
||||
await expect(securityTab.getByRole("heading", { name: "Secure Backup" })).toBeVisible();
|
||||
await securityTab.getByRole("button", { name: "Set up", exact: true }).click();
|
||||
|
||||
const currentDialogLocator = page.locator(".mx_Dialog");
|
||||
|
||||
// It's the first time and secure storage is not set up, so it will create one
|
||||
await expect(currentDialogLocator.getByRole("heading", { name: "Set up Secure Backup" })).toBeVisible();
|
||||
await currentDialogLocator.getByRole("button", { name: "Continue", exact: true }).click();
|
||||
await expect(currentDialogLocator.getByRole("heading", { name: "Save your Security Key" })).toBeVisible();
|
||||
await currentDialogLocator.getByRole("button", { name: "Copy", exact: true }).click();
|
||||
// copy the recovery key to use it later
|
||||
const securityKey = await app.getClipboard();
|
||||
await currentDialogLocator.getByRole("button", { name: "Continue", exact: true }).click();
|
||||
|
||||
await expect(currentDialogLocator.getByRole("heading", { name: "Secure Backup successful" })).toBeVisible();
|
||||
await currentDialogLocator.getByRole("button", { name: "Done", exact: true }).click();
|
||||
const securityKey = await completeCreateSecretStorageDialog(page);
|
||||
|
||||
// Open the settings again
|
||||
await app.settings.openUserSettings("Security & Privacy");
|
||||
@@ -62,6 +51,7 @@ test.describe("Backups", () => {
|
||||
await expectBackupVersionToBe(page, "1");
|
||||
|
||||
await securityTab.getByRole("button", { name: "Delete Backup", exact: true }).click();
|
||||
const currentDialogLocator = page.locator(".mx_Dialog");
|
||||
await expect(currentDialogLocator.getByRole("heading", { name: "Delete Backup" })).toBeVisible();
|
||||
// Delete it
|
||||
await currentDialogLocator.getByTestId("dialog-primary-button").click(); // Click "Delete Backup"
|
||||
|
||||
@@ -8,7 +8,14 @@ Please see LICENSE files in the repository root for full details.
|
||||
|
||||
import type { Page } from "@playwright/test";
|
||||
import { expect, test } from "../../element-web-test";
|
||||
import { autoJoin, copyAndContinue, createSharedRoomWithUser, enableKeyBackup, verify } from "./utils";
|
||||
import {
|
||||
autoJoin,
|
||||
completeCreateSecretStorageDialog,
|
||||
copyAndContinue,
|
||||
createSharedRoomWithUser,
|
||||
enableKeyBackup,
|
||||
verify,
|
||||
} from "./utils";
|
||||
import { Bot } from "../../pages/bot";
|
||||
import { ElementAppPage } from "../../pages/ElementAppPage";
|
||||
import { isDendrite } from "../../plugins/homeserver/dendrite";
|
||||
@@ -111,18 +118,7 @@ test.describe("Cryptography", function () {
|
||||
await app.settings.openUserSettings("Security & Privacy");
|
||||
await page.getByRole("button", { name: "Set up Secure Backup" }).click();
|
||||
|
||||
const dialog = page.locator(".mx_Dialog");
|
||||
// Recovery key is selected by default
|
||||
await dialog.getByRole("button", { name: "Continue" }).click();
|
||||
await copyAndContinue(page);
|
||||
|
||||
// If the device is unverified, there should be a "Setting up keys" step; however, it
|
||||
// can be quite quick, and playwright can miss it, so we can't test for it.
|
||||
|
||||
// Either way, we end up at a success dialog:
|
||||
await expect(dialog.getByText("Secure Backup successful")).toBeVisible();
|
||||
await dialog.getByRole("button", { name: "Done" }).click();
|
||||
await expect(dialog.getByText("Secure Backup successful")).not.toBeVisible();
|
||||
await completeCreateSecretStorageDialog(page);
|
||||
|
||||
// Verify that the SSSS keys are in the account data stored in the server
|
||||
await verifyKey(app, "master");
|
||||
|
||||
@@ -11,6 +11,8 @@ import { Locator, type Page } from "@playwright/test";
|
||||
import { test, expect } from "../../element-web-test";
|
||||
import { viewRoomSummaryByName } from "../right-panel/utils";
|
||||
import { isDendrite } from "../../plugins/homeserver/dendrite";
|
||||
import { completeCreateSecretStorageDialog, createBot, logIntoElement } from "./utils.ts";
|
||||
import { Client } from "../../pages/client.ts";
|
||||
|
||||
const ROOM_NAME = "Test room";
|
||||
const NAME = "Alice";
|
||||
@@ -44,7 +46,7 @@ test.use({
|
||||
test.describe("Dehydration", () => {
|
||||
test.skip(isDendrite, "does not yet support dehydration v2");
|
||||
|
||||
test("Create dehydrated device", async ({ page, user, app }, workerInfo) => {
|
||||
test("'Set up secure backup' creates dehydrated device", async ({ page, user, app }, workerInfo) => {
|
||||
// Create a backup (which will create SSSS, and dehydrated device)
|
||||
|
||||
const securityTab = await app.settings.openUserSettings("Security & Privacy");
|
||||
@@ -53,17 +55,7 @@ test.describe("Dehydration", () => {
|
||||
await expect(securityTab.getByText("Offline device enabled")).not.toBeVisible();
|
||||
await securityTab.getByRole("button", { name: "Set up", exact: true }).click();
|
||||
|
||||
const currentDialogLocator = page.locator(".mx_Dialog");
|
||||
|
||||
// It's the first time and secure storage is not set up, so it will create one
|
||||
await expect(currentDialogLocator.getByRole("heading", { name: "Set up Secure Backup" })).toBeVisible();
|
||||
await currentDialogLocator.getByRole("button", { name: "Continue", exact: true }).click();
|
||||
await expect(currentDialogLocator.getByRole("heading", { name: "Save your Security Key" })).toBeVisible();
|
||||
await currentDialogLocator.getByRole("button", { name: "Copy", exact: true }).click();
|
||||
await currentDialogLocator.getByRole("button", { name: "Continue", exact: true }).click();
|
||||
|
||||
await expect(currentDialogLocator.getByRole("heading", { name: "Secure Backup successful" })).toBeVisible();
|
||||
await currentDialogLocator.getByRole("button", { name: "Done", exact: true }).click();
|
||||
await completeCreateSecretStorageDialog(page);
|
||||
|
||||
// Open the settings again
|
||||
await app.settings.openUserSettings("Security & Privacy");
|
||||
@@ -96,4 +88,49 @@ test.describe("Dehydration", () => {
|
||||
await expect(page.locator(".mx_UserInfo_devices").getByText("Offline device enabled")).toBeVisible();
|
||||
await expect(page.locator(".mx_UserInfo_devices").getByText("Dehydrated device")).not.toBeVisible();
|
||||
});
|
||||
|
||||
test("Reset recovery key during login re-creates dehydrated device", async ({
|
||||
page,
|
||||
homeserver,
|
||||
app,
|
||||
credentials,
|
||||
}) => {
|
||||
// Set up cross-signing and recovery
|
||||
const { botClient } = await createBot(page, homeserver, credentials);
|
||||
// ... and dehydration
|
||||
await botClient.evaluate(async (client) => await client.getCrypto().startDehydration());
|
||||
|
||||
const initialDehydratedDeviceIds = await getDehydratedDeviceIds(botClient);
|
||||
expect(initialDehydratedDeviceIds.length).toBe(1);
|
||||
|
||||
await botClient.evaluate(async (client) => client.stopClient());
|
||||
|
||||
// Log in our client
|
||||
await logIntoElement(page, credentials);
|
||||
|
||||
// Oh no, we forgot our recovery key
|
||||
await page.locator(".mx_AuthPage").getByRole("button", { name: "Reset all" }).click();
|
||||
await page.locator(".mx_AuthPage").getByRole("button", { name: "Proceed with reset" }).click();
|
||||
|
||||
await completeCreateSecretStorageDialog(page, { accountPassword: credentials.password });
|
||||
|
||||
// There should be a brand new dehydrated device
|
||||
const dehydratedDeviceIds = await getDehydratedDeviceIds(app.client);
|
||||
expect(dehydratedDeviceIds.length).toBe(1);
|
||||
expect(dehydratedDeviceIds[0]).not.toEqual(initialDehydratedDeviceIds[0]);
|
||||
});
|
||||
});
|
||||
|
||||
async function getDehydratedDeviceIds(client: Client): Promise<string[]> {
|
||||
return await client.evaluate(async (client) => {
|
||||
const userId = client.getUserId();
|
||||
const devices = await client.getCrypto().getUserDeviceInfo([userId]);
|
||||
return Array.from(
|
||||
devices
|
||||
.get(userId)
|
||||
.values()
|
||||
.filter((d) => d.dehydrated)
|
||||
.map((d) => d.deviceId),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -68,8 +68,8 @@ test.describe("Device verification", { tag: "@no-webkit" }, () => {
|
||||
|
||||
// Check that the current device is connected to key backup
|
||||
// For now we don't check that the backup key is in cache because it's a bit flaky,
|
||||
// as we need to wait for the secret gossiping to happen and the settings dialog doesn't refresh automatically.
|
||||
await checkDeviceIsConnectedKeyBackup(page, expectedBackupVersion, false);
|
||||
// as we need to wait for the secret gossiping to happen.
|
||||
await checkDeviceIsConnectedKeyBackup(app, expectedBackupVersion, false);
|
||||
});
|
||||
|
||||
test("Verify device with QR code during login", async ({ page, app, credentials, homeserver }) => {
|
||||
@@ -112,9 +112,7 @@ test.describe("Device verification", { tag: "@no-webkit" }, () => {
|
||||
await checkDeviceIsCrossSigned(app);
|
||||
|
||||
// Check that the current device is connected to key backup
|
||||
// For now we don't check that the backup key is in cache because it's a bit flaky,
|
||||
// as we need to wait for the secret gossiping to happen and the settings dialog doesn't refresh automatically.
|
||||
await checkDeviceIsConnectedKeyBackup(page, expectedBackupVersion, false);
|
||||
await checkDeviceIsConnectedKeyBackup(app, expectedBackupVersion, true);
|
||||
});
|
||||
|
||||
test("Verify device with Security Phrase during login", async ({ page, app, credentials, homeserver }) => {
|
||||
@@ -135,7 +133,7 @@ test.describe("Device verification", { tag: "@no-webkit" }, () => {
|
||||
|
||||
// Check that the current device is connected to key backup
|
||||
// The backup decryption key should be in cache also, as we got it directly from the 4S
|
||||
await checkDeviceIsConnectedKeyBackup(page, expectedBackupVersion, true);
|
||||
await checkDeviceIsConnectedKeyBackup(app, expectedBackupVersion, true);
|
||||
});
|
||||
|
||||
test("Verify device with Security Key during login", async ({ page, app, credentials, homeserver }) => {
|
||||
@@ -158,7 +156,7 @@ test.describe("Device verification", { tag: "@no-webkit" }, () => {
|
||||
|
||||
// Check that the current device is connected to key backup
|
||||
// The backup decryption key should be in cache also, as we got it directly from the 4S
|
||||
await checkDeviceIsConnectedKeyBackup(page, expectedBackupVersion, true);
|
||||
await checkDeviceIsConnectedKeyBackup(app, expectedBackupVersion, true);
|
||||
});
|
||||
|
||||
test("Handle incoming verification request with SAS", async ({ page, credentials, homeserver, toasts }) => {
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
/*
|
||||
* 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 { GeneratedSecretStorageKey } from "matrix-js-sdk/src/crypto-api";
|
||||
|
||||
import { test, expect } from "../../element-web-test";
|
||||
import { createBot, deleteCachedSecrets, logIntoElement } from "./utils";
|
||||
|
||||
test.describe("Key storage out of sync toast", () => {
|
||||
let recoveryKey: GeneratedSecretStorageKey;
|
||||
|
||||
test.beforeEach(async ({ page, homeserver, credentials }) => {
|
||||
const res = await createBot(page, homeserver, credentials);
|
||||
recoveryKey = res.recoveryKey;
|
||||
|
||||
await logIntoElement(page, credentials, recoveryKey.encodedPrivateKey);
|
||||
|
||||
await deleteCachedSecrets(page);
|
||||
|
||||
// We won't be prompted for crypto setup unless we have an e2e room, so make one
|
||||
await page.getByRole("button", { name: "Add room" }).click();
|
||||
await page.getByRole("menuitem", { name: "New room" }).click();
|
||||
await page.getByRole("textbox", { name: "Name" }).fill("Test room");
|
||||
await page.getByRole("button", { name: "Create room" }).click();
|
||||
});
|
||||
|
||||
test("should prompt for recovery key if 'enter recovery key' pressed", { tag: "@screenshot" }, async ({ page }) => {
|
||||
// Need to wait for 2 to appear since playwright only evaluates 'first()' initially, so the waiting won't work
|
||||
await expect(page.getByRole("alert")).toHaveCount(2);
|
||||
await expect(page.getByRole("alert").first()).toMatchScreenshot("key-storage-out-of-sync-toast.png");
|
||||
|
||||
await page.getByRole("button", { name: "Enter recovery key" }).click();
|
||||
await page.locator(".mx_Dialog").getByRole("button", { name: "use your Security Key" }).click();
|
||||
|
||||
await page.getByRole("textbox", { name: "Security key" }).fill(recoveryKey.encodedPrivateKey);
|
||||
await page.getByRole("button", { name: "Continue" }).click();
|
||||
|
||||
await expect(page.getByRole("button", { name: "Enter recovery key" })).not.toBeVisible();
|
||||
});
|
||||
|
||||
test("should open settings to reset flow if 'forgot recovery key' pressed", async ({ page, app, credentials }) => {
|
||||
await expect(page.getByRole("button", { name: "Enter recovery key" })).toBeVisible();
|
||||
|
||||
await page.getByRole("button", { name: "Forgot recovery key?" }).click();
|
||||
|
||||
await expect(
|
||||
page.getByRole("heading", { name: "Forgot your recovery key? You’ll need to reset your identity." }),
|
||||
).toBeVisible();
|
||||
});
|
||||
});
|
||||
@@ -139,14 +139,14 @@ export async function checkDeviceIsCrossSigned(app: ElementAppPage): Promise<voi
|
||||
* Check that the current device is connected to the expected key backup.
|
||||
* Also checks that the decryption key is known and cached locally.
|
||||
*
|
||||
* @param page - the page to check
|
||||
* @param app -` ElementAppPage` wrapper for the playwright `Page`.
|
||||
* @param expectedBackupVersion - the version of the backup we expect to be connected to.
|
||||
* @param checkBackupKeyInCache - whether to check that the backup key is cached locally.
|
||||
* @param checkBackupPrivateKeyInCache - whether to check that the backup decryption key is cached locally
|
||||
*/
|
||||
export async function checkDeviceIsConnectedKeyBackup(
|
||||
page: Page,
|
||||
app: ElementAppPage,
|
||||
expectedBackupVersion: string,
|
||||
checkBackupKeyInCache: boolean,
|
||||
checkBackupPrivateKeyInCache: boolean,
|
||||
): Promise<void> {
|
||||
// Sanity check the given backup version: if it's null, something went wrong earlier in the test.
|
||||
if (!expectedBackupVersion) {
|
||||
@@ -155,23 +155,48 @@ export async function checkDeviceIsConnectedKeyBackup(
|
||||
);
|
||||
}
|
||||
|
||||
await page.getByRole("button", { name: "User menu" }).click();
|
||||
await page.locator(".mx_UserMenu_contextMenu").getByRole("menuitem", { name: "Security & Privacy" }).click();
|
||||
await expect(page.locator(".mx_Dialog").getByRole("button", { name: "Restore from Backup" })).toBeVisible();
|
||||
const backupData = await app.client.evaluate(async (client: MatrixClient) => {
|
||||
const crypto = client.getCrypto();
|
||||
if (!crypto) return;
|
||||
|
||||
// expand the advanced section to see the active version in the reports
|
||||
await page.locator(".mx_SecureBackupPanel_advanced").locator("..").click();
|
||||
const backupInfo = await crypto.getKeyBackupInfo();
|
||||
const backupKeyIn4S = Boolean(await client.isKeyBackupKeyStored());
|
||||
const backupPrivateKeyFromCache = await crypto.getSessionBackupPrivateKey();
|
||||
const hasBackupPrivateKeyFromCache = Boolean(backupPrivateKeyFromCache);
|
||||
const backupPrivateKeyWellFormed = backupPrivateKeyFromCache instanceof Uint8Array;
|
||||
const activeBackupVersion = await crypto.getActiveSessionBackupVersion();
|
||||
|
||||
if (checkBackupKeyInCache) {
|
||||
const cacheDecryptionKeyStatusElement = page.locator(".mx_SecureBackupPanel_statusList tr:nth-child(2) td");
|
||||
await expect(cacheDecryptionKeyStatusElement).toHaveText("cached locally, well formed");
|
||||
return {
|
||||
backupInfo,
|
||||
hasBackupPrivateKeyFromCache,
|
||||
backupPrivateKeyWellFormed,
|
||||
backupKeyIn4S,
|
||||
activeBackupVersion,
|
||||
};
|
||||
});
|
||||
|
||||
if (!backupData) {
|
||||
throw new Error("Crypto module is not available");
|
||||
}
|
||||
|
||||
await expect(page.locator(".mx_SecureBackupPanel_statusList tr:nth-child(5) td")).toHaveText(
|
||||
expectedBackupVersion + " (Algorithm: m.megolm_backup.v1.curve25519-aes-sha2)",
|
||||
);
|
||||
const { backupInfo, backupKeyIn4S, hasBackupPrivateKeyFromCache, backupPrivateKeyWellFormed, activeBackupVersion } =
|
||||
backupData;
|
||||
|
||||
await expect(page.locator(".mx_SecureBackupPanel_statusList tr:nth-child(6) td")).toHaveText(expectedBackupVersion);
|
||||
// We have a key backup
|
||||
expect(backupInfo).toBeDefined();
|
||||
// The key backup version is as expected
|
||||
expect(backupInfo.version).toBe(expectedBackupVersion);
|
||||
// The active backup version is as expected
|
||||
expect(activeBackupVersion).toBe(expectedBackupVersion);
|
||||
// The backup key is stored in 4S
|
||||
expect(backupKeyIn4S).toBe(true);
|
||||
|
||||
if (checkBackupPrivateKeyInCache) {
|
||||
// The backup key is available locally
|
||||
expect(hasBackupPrivateKeyFromCache).toBe(true);
|
||||
// The backup key is well-formed
|
||||
expect(backupPrivateKeyWellFormed).toBe(true);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -189,6 +214,11 @@ export async function logIntoElement(page: Page, credentials: Credentials, secur
|
||||
// if a securityKey was given, verify the new device
|
||||
if (securityKey !== undefined) {
|
||||
await page.locator(".mx_AuthPage").getByRole("button", { name: "Verify with Security Key" }).click();
|
||||
|
||||
const useSecurityKey = page.locator(".mx_Dialog").getByRole("button", { name: "use your Security Key" });
|
||||
if (await useSecurityKey.isVisible()) {
|
||||
await useSecurityKey.click();
|
||||
}
|
||||
// Fill in the security key
|
||||
await page.locator(".mx_Dialog").locator('input[type="password"]').fill(securityKey);
|
||||
await page.locator(".mx_Dialog_primary:not([disabled])", { hasText: "Continue" }).click();
|
||||
@@ -216,18 +246,19 @@ export async function logOutOfElement(page: Page, discardKeys: boolean = false)
|
||||
}
|
||||
|
||||
/**
|
||||
* Open the security settings, and verify the current session using the security key.
|
||||
* Open the encryption settings, and verify the current session using the security key.
|
||||
*
|
||||
* @param app - `ElementAppPage` wrapper for the playwright `Page`.
|
||||
* @param securityKey - The security key (i.e., 4S key), set up during a previous session.
|
||||
*/
|
||||
export async function verifySession(app: ElementAppPage, securityKey: string) {
|
||||
const settings = await app.settings.openUserSettings("Security & Privacy");
|
||||
await settings.getByRole("button", { name: "Verify this session" }).click();
|
||||
const settings = await app.settings.openUserSettings("Encryption");
|
||||
await settings.getByRole("button", { name: "Verify this device" }).click();
|
||||
await app.page.getByRole("button", { name: "Verify with Security Key" }).click();
|
||||
await app.page.locator(".mx_Dialog").locator('input[type="password"]').fill(securityKey);
|
||||
await app.page.getByRole("button", { name: "Continue", disabled: false }).click();
|
||||
await app.page.getByRole("button", { name: "Done" }).click();
|
||||
await app.settings.closeDialog();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -262,19 +293,52 @@ export async function doTwoWaySasVerification(page: Page, verifier: JSHandle<Ver
|
||||
export async function enableKeyBackup(app: ElementAppPage): Promise<string> {
|
||||
await app.settings.openUserSettings("Security & Privacy");
|
||||
await app.page.getByRole("button", { name: "Set up Secure Backup" }).click();
|
||||
const dialog = app.page.locator(".mx_Dialog");
|
||||
// Recovery key is selected by default
|
||||
await dialog.getByRole("button", { name: "Continue" }).click({ timeout: 60000 });
|
||||
|
||||
// copy the text ourselves
|
||||
const securityKey = await dialog.locator(".mx_CreateSecretStorageDialog_recoveryKey code").textContent();
|
||||
await copyAndContinue(app.page);
|
||||
return await completeCreateSecretStorageDialog(app.page);
|
||||
}
|
||||
|
||||
await expect(dialog.getByText("Secure Backup successful")).toBeVisible();
|
||||
await dialog.getByRole("button", { name: "Done" }).click();
|
||||
await expect(dialog.getByText("Secure Backup successful")).not.toBeVisible();
|
||||
/**
|
||||
* Go through the "Set up Secure Backup" dialog (aka the `CreateSecretStorageDialog`).
|
||||
*
|
||||
* Assumes the dialog is already open for some reason (see also {@link enableKeyBackup}).
|
||||
*
|
||||
* @param page - The playwright `Page` fixture.
|
||||
* @param opts - Options object
|
||||
* @param opts.accountPassword - The user's account password. If we are also resetting cross-signing, then we will need
|
||||
* to upload the public cross-signing keys, which will cause the app to prompt for the password.
|
||||
*
|
||||
* @returns the new recovery key.
|
||||
*/
|
||||
export async function completeCreateSecretStorageDialog(
|
||||
page: Page,
|
||||
opts?: { accountPassword?: string },
|
||||
): Promise<string> {
|
||||
const currentDialogLocator = page.locator(".mx_Dialog");
|
||||
|
||||
return securityKey;
|
||||
await expect(currentDialogLocator.getByRole("heading", { name: "Set up Secure Backup" })).toBeVisible();
|
||||
// "Generate a Security Key" is selected by default
|
||||
await currentDialogLocator.getByRole("button", { name: "Continue", exact: true }).click();
|
||||
await expect(currentDialogLocator.getByRole("heading", { name: "Save your Security Key" })).toBeVisible();
|
||||
await currentDialogLocator.getByRole("button", { name: "Copy", exact: true }).click();
|
||||
// copy the recovery key to use it later
|
||||
const recoveryKey = await page.evaluate(() => navigator.clipboard.readText());
|
||||
await currentDialogLocator.getByRole("button", { name: "Continue", exact: true }).click();
|
||||
|
||||
// If the device is unverified, there should be a "Setting up keys" step.
|
||||
// If this is not the first time we are setting up cross-signing, the app will prompt for our password; otherwise
|
||||
// the step is quite quick, and playwright can miss it, so we can't test for it.
|
||||
if (opts && Object.hasOwn(opts, "accountPassword")) {
|
||||
await expect(currentDialogLocator.getByRole("heading", { name: "Setting up keys" })).toBeVisible();
|
||||
await page.getByPlaceholder("Password").fill(opts!.accountPassword);
|
||||
await currentDialogLocator.getByRole("button", { name: "Continue" }).click();
|
||||
}
|
||||
|
||||
// Either way, we end up at a success dialog:
|
||||
await expect(currentDialogLocator.getByRole("heading", { name: "Secure Backup successful" })).toBeVisible();
|
||||
await currentDialogLocator.getByRole("button", { name: "Done", exact: true }).click();
|
||||
await expect(currentDialogLocator.getByText("Secure Backup successful")).not.toBeVisible();
|
||||
|
||||
return recoveryKey;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -6,14 +6,14 @@ 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 { API, Messages } from "mailhog";
|
||||
import { MailpitClient } from "mailpit-api";
|
||||
import { Page } from "@playwright/test";
|
||||
|
||||
import { expect } from "../../element-web-test";
|
||||
|
||||
export async function registerAccountMas(
|
||||
page: Page,
|
||||
mailhog: API,
|
||||
mailpit: MailpitClient,
|
||||
username: string,
|
||||
email: string,
|
||||
password: string,
|
||||
@@ -27,13 +27,13 @@ export async function registerAccountMas(
|
||||
await page.getByRole("textbox", { name: "Confirm Password" }).fill(password);
|
||||
await page.getByRole("button", { name: "Continue" }).click();
|
||||
|
||||
let messages: Messages;
|
||||
let code: string;
|
||||
await expect(async () => {
|
||||
messages = await mailhog.messages();
|
||||
expect(messages.items).toHaveLength(1);
|
||||
const messages = await mailpit.listMessages();
|
||||
expect(messages.messages[0].To[0].Address).toEqual(email);
|
||||
const text = await mailpit.renderMessageText(messages.messages[0].ID);
|
||||
[, code] = text.match(/Your verification code to confirm this email address is: (\d{6})/);
|
||||
}).toPass();
|
||||
expect(messages.items[0].to).toEqual(`${username} <${email}>`);
|
||||
const [, code] = messages.items[0].text.match(/Your verification code to confirm this email address is: (\d{6})/);
|
||||
|
||||
await page.getByRole("textbox", { name: "6-digit code" }).fill(code);
|
||||
await page.getByRole("button", { name: "Continue" }).click();
|
||||
|
||||
@@ -19,7 +19,7 @@ test.describe("OIDC Native", { tag: ["@no-firefox", "@no-webkit"] }, () => {
|
||||
context,
|
||||
page,
|
||||
homeserver,
|
||||
mailhogClient,
|
||||
mailpitClient,
|
||||
mas,
|
||||
}, testInfo) => {
|
||||
await page.clock.install();
|
||||
@@ -33,7 +33,7 @@ test.describe("OIDC Native", { tag: ["@no-firefox", "@no-webkit"] }, () => {
|
||||
await page.getByRole("button", { name: "Continue" }).click();
|
||||
|
||||
const userId = `alice_${testInfo.testId}`;
|
||||
await registerAccountMas(page, mailhogClient, userId, "alice@email.com", "Pa$sW0rD!");
|
||||
await registerAccountMas(page, mailpitClient, userId, "alice@email.com", "Pa$sW0rD!");
|
||||
|
||||
// Eventually, we should end up at the home screen.
|
||||
await expect(page).toHaveURL(/\/#\/home$/, { timeout: 10000 });
|
||||
|
||||
@@ -526,9 +526,10 @@ class Helpers {
|
||||
await expect(threadPanel).toBeVisible();
|
||||
await threadPanel.evaluate(($panel) => {
|
||||
const $button = $panel.querySelector<HTMLElement>('[data-testid="base-card-back-button"]');
|
||||
const title = $panel.querySelector<HTMLElement>(".mx_BaseCard_header_title")?.textContent;
|
||||
// If the Threads back button is present then click it - the
|
||||
// threads button can open either threads list or thread panel
|
||||
if ($button) {
|
||||
if ($button && title !== "Threads") {
|
||||
$button.click();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -34,7 +34,7 @@ test.describe("Email Registration", async () => {
|
||||
test(
|
||||
"registers an account and lands on the home page",
|
||||
{ tag: "@screenshot" },
|
||||
async ({ page, mailhogClient, request, checkA11y }) => {
|
||||
async ({ page, mailpitClient, request, checkA11y }) => {
|
||||
await expect(page.getByRole("textbox", { name: "Username" })).toBeVisible();
|
||||
// Hide the server text as it contains the randomly allocated Homeserver port
|
||||
const screenshotOptions = { mask: [page.locator(".mx_ServerPicker_server")] };
|
||||
@@ -51,10 +51,11 @@ test.describe("Email Registration", async () => {
|
||||
|
||||
await expect(page.getByText("An error was encountered when sending the email")).not.toBeVisible();
|
||||
|
||||
const messages = await mailhogClient.messages();
|
||||
expect(messages.items).toHaveLength(1);
|
||||
expect(messages.items[0].to).toEqual("alice@email.com");
|
||||
const [emailLink] = messages.items[0].text.match(/http.+/);
|
||||
const messages = await mailpitClient.listMessages();
|
||||
expect(messages.messages).toHaveLength(1);
|
||||
expect(messages.messages[0].To[0].Address).toEqual("alice@email.com");
|
||||
const text = await mailpitClient.renderMessageText(messages.messages[0].ID);
|
||||
const [emailLink] = text.match(/http.+/);
|
||||
await request.get(emailLink); // "Click" the link in the email
|
||||
|
||||
await expect(page.getByText("Welcome alice")).toBeVisible();
|
||||
|
||||
@@ -42,7 +42,7 @@ test.describe("Memberlist", () => {
|
||||
await app.viewRoomByName(ROOM_NAME);
|
||||
const memberlist = await app.toggleMemberlistPanel();
|
||||
await expect(memberlist.locator(".mx_MemberTileView")).toHaveCount(4);
|
||||
await expect(memberlist.getByText("(Invited)")).toHaveCount(1);
|
||||
await expect(memberlist.getByText("Invited")).toHaveCount(1);
|
||||
await expect(page.locator(".mx_MemberListView")).toMatchScreenshot("with-four-members.png");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
/*
|
||||
* 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 "./index";
|
||||
import { checkDeviceIsCrossSigned } from "../../crypto/utils";
|
||||
import { bootstrapCrossSigningForClient } from "../../../pages/client";
|
||||
|
||||
test.describe("Advanced section in Encryption tab", () => {
|
||||
test.beforeEach(async ({ page, app, homeserver, credentials, util }) => {
|
||||
const clientHandle = await app.client.prepareClient();
|
||||
// Reset cross signing in order to have a verified session
|
||||
await bootstrapCrossSigningForClient(clientHandle, credentials, true);
|
||||
});
|
||||
|
||||
test("should show the encryption details", { tag: "@screenshot" }, async ({ page, app, util }) => {
|
||||
await util.openEncryptionTab();
|
||||
const section = util.getEncryptionDetailsSection();
|
||||
|
||||
const deviceId = await page.evaluate(() => window.mxMatrixClientPeg.get().getDeviceId());
|
||||
await expect(section.getByText(deviceId)).toBeVisible();
|
||||
|
||||
await expect(section).toMatchScreenshot("encryption-details.png", {
|
||||
mask: [section.getByTestId("deviceId"), section.getByTestId("sessionKey")],
|
||||
});
|
||||
});
|
||||
|
||||
test("should show the import room keys dialog", async ({ page, app, util }) => {
|
||||
await util.openEncryptionTab();
|
||||
const section = util.getEncryptionDetailsSection();
|
||||
|
||||
await section.getByRole("button", { name: "Import keys" }).click();
|
||||
await expect(page.getByRole("heading", { name: "Import room keys" })).toBeVisible();
|
||||
});
|
||||
|
||||
test("should show the export room keys dialog", async ({ page, app, util }) => {
|
||||
await util.openEncryptionTab();
|
||||
const section = util.getEncryptionDetailsSection();
|
||||
|
||||
await section.getByRole("button", { name: "Export keys" }).click();
|
||||
await expect(page.getByRole("heading", { name: "Export room keys" })).toBeVisible();
|
||||
});
|
||||
|
||||
test(
|
||||
"should reset the cryptographic identity",
|
||||
{ tag: "@screenshot" },
|
||||
async ({ page, app, credentials, util }) => {
|
||||
const tab = await util.openEncryptionTab();
|
||||
const section = util.getEncryptionDetailsSection();
|
||||
|
||||
await section.getByRole("button", { name: "Reset cryptographic identity" }).click();
|
||||
await expect(util.getEncryptionTabContent()).toMatchScreenshot("reset-cryptographic-identity.png");
|
||||
await tab.getByRole("button", { name: "Continue" }).click();
|
||||
|
||||
// Fill password dialog and validate
|
||||
const dialog = page.locator(".mx_InteractiveAuthDialog");
|
||||
await dialog.getByRole("textbox", { name: "Password" }).fill(credentials.password);
|
||||
await dialog.getByRole("button", { name: "Continue" }).click();
|
||||
|
||||
await expect(section.getByRole("button", { name: "Reset cryptographic identity" })).toBeVisible();
|
||||
|
||||
// After resetting the identity, the user should set up a new recovery key
|
||||
await expect(
|
||||
util.getEncryptionRecoverySection().getByRole("button", { name: "Set up recovery" }),
|
||||
).toBeVisible();
|
||||
|
||||
await checkDeviceIsCrossSigned(app);
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,96 @@
|
||||
/*
|
||||
* 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 { GeneratedSecretStorageKey } from "matrix-js-sdk/src/crypto-api";
|
||||
|
||||
import { test, expect } from ".";
|
||||
import {
|
||||
checkDeviceIsConnectedKeyBackup,
|
||||
checkDeviceIsCrossSigned,
|
||||
createBot,
|
||||
deleteCachedSecrets,
|
||||
verifySession,
|
||||
} from "../../crypto/utils";
|
||||
|
||||
test.describe("Encryption tab", () => {
|
||||
test.use({
|
||||
displayName: "Alice",
|
||||
});
|
||||
|
||||
let recoveryKey: GeneratedSecretStorageKey;
|
||||
let expectedBackupVersion: string;
|
||||
|
||||
test.beforeEach(async ({ page, homeserver, credentials }) => {
|
||||
// The bot bootstraps cross-signing, creates a key backup and sets up a recovery key
|
||||
const res = await createBot(page, homeserver, credentials);
|
||||
recoveryKey = res.recoveryKey;
|
||||
expectedBackupVersion = res.expectedBackupVersion;
|
||||
});
|
||||
|
||||
test(
|
||||
"should show a 'Verify this device' button if the device is unverified",
|
||||
{ tag: "@screenshot" },
|
||||
async ({ page, app, util }) => {
|
||||
const dialog = await util.openEncryptionTab();
|
||||
const content = util.getEncryptionTabContent();
|
||||
|
||||
// The user's device is in an unverified state, therefore the only option available to them here is to verify it
|
||||
const verifyButton = dialog.getByRole("button", { name: "Verify this device" });
|
||||
await expect(verifyButton).toBeVisible();
|
||||
await expect(content).toMatchScreenshot("verify-device-encryption-tab.png");
|
||||
await verifyButton.click();
|
||||
|
||||
await util.verifyDevice(recoveryKey);
|
||||
|
||||
await expect(content).toMatchScreenshot("default-tab.png", {
|
||||
mask: [content.getByTestId("deviceId"), content.getByTestId("sessionKey")],
|
||||
});
|
||||
|
||||
// Check that our device is now cross-signed
|
||||
await checkDeviceIsCrossSigned(app);
|
||||
|
||||
// Check that the current device is connected to key backup
|
||||
// The backup decryption key should be in cache also, as we got it directly from the 4S
|
||||
await checkDeviceIsConnectedKeyBackup(app, expectedBackupVersion, true);
|
||||
},
|
||||
);
|
||||
|
||||
// Test what happens if the cross-signing secrets are in secret storage but are not cached in the local DB.
|
||||
//
|
||||
// This can happen if we verified another device and secret-gossiping failed, or the other device itself lacked the secrets.
|
||||
// We simulate this case by deleting the cached secrets in the indexedDB.
|
||||
test(
|
||||
"should prompt to enter the recovery key when the secrets are not cached locally",
|
||||
{ tag: "@screenshot" },
|
||||
async ({ page, app, util }) => {
|
||||
await verifySession(app, "new passphrase");
|
||||
// We need to delete the cached secrets
|
||||
await deleteCachedSecrets(page);
|
||||
|
||||
await util.openEncryptionTab();
|
||||
// We ask the user to enter the recovery key
|
||||
const dialog = util.getEncryptionTabContent();
|
||||
const enterKeyButton = dialog.getByRole("button", { name: "Enter recovery key" });
|
||||
await expect(enterKeyButton).toBeVisible();
|
||||
await expect(dialog).toMatchScreenshot("out-of-sync-recovery.png");
|
||||
await enterKeyButton.click();
|
||||
|
||||
// Fill the recovery key
|
||||
await util.enterRecoveryKey(recoveryKey);
|
||||
await expect(dialog).toMatchScreenshot("default-tab.png", {
|
||||
mask: [dialog.getByTestId("deviceId"), dialog.getByTestId("sessionKey")],
|
||||
});
|
||||
|
||||
// Check that our device is now cross-signed
|
||||
await checkDeviceIsCrossSigned(app);
|
||||
|
||||
// Check that the current device is connected to key backup
|
||||
// The backup decryption key should be in cache also, as we got it directly from the 4S
|
||||
await checkDeviceIsConnectedKeyBackup(app, expectedBackupVersion, true);
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -18,6 +18,8 @@ export { expect };
|
||||
export const test = base.extend<{
|
||||
util: Helpers;
|
||||
}>({
|
||||
displayName: "Alice",
|
||||
|
||||
util: async ({ page, app, bot }, use) => {
|
||||
await use(new Helpers(page, app));
|
||||
},
|
||||
@@ -67,6 +69,20 @@ class Helpers {
|
||||
return this.page.getByTestId("encryptionTab");
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the recovery section
|
||||
*/
|
||||
getEncryptionRecoverySection() {
|
||||
return this.page.getByTestId("recoveryPanel");
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the encryption details section
|
||||
*/
|
||||
getEncryptionDetailsSection() {
|
||||
return this.page.getByTestId("encryptionDetails");
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the default key id of the secret storage to `null`
|
||||
*/
|
||||
@@ -92,6 +108,6 @@ class Helpers {
|
||||
const clipboardContent = await this.app.getClipboard();
|
||||
await dialog.getByRole("textbox").fill(clipboardContent);
|
||||
await dialog.getByRole("button", { name: confirmButtonLabel }).click();
|
||||
await expect(dialog).toMatchScreenshot("default-recovery.png");
|
||||
await expect(this.getEncryptionRecoverySection()).toMatchScreenshot("default-recovery.png");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,50 +5,17 @@
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { GeneratedSecretStorageKey } from "matrix-js-sdk/src/crypto-api";
|
||||
|
||||
import { test, expect } from ".";
|
||||
import {
|
||||
checkDeviceIsConnectedKeyBackup,
|
||||
checkDeviceIsCrossSigned,
|
||||
createBot,
|
||||
deleteCachedSecrets,
|
||||
verifySession,
|
||||
} from "../../crypto/utils";
|
||||
import { checkDeviceIsConnectedKeyBackup, createBot, verifySession } from "../../crypto/utils";
|
||||
|
||||
test.describe("Recovery section in Encryption tab", () => {
|
||||
test.use({
|
||||
displayName: "Alice",
|
||||
});
|
||||
|
||||
let recoveryKey: GeneratedSecretStorageKey;
|
||||
let expectedBackupVersion: string;
|
||||
|
||||
test.beforeEach(async ({ page, homeserver, credentials }) => {
|
||||
const res = await createBot(page, homeserver, credentials);
|
||||
recoveryKey = res.recoveryKey;
|
||||
expectedBackupVersion = res.expectedBackupVersion;
|
||||
});
|
||||
|
||||
test("should verify the device", { tag: "@screenshot" }, async ({ page, app, util }) => {
|
||||
const dialog = await util.openEncryptionTab();
|
||||
|
||||
// The user's device is in an unverified state, therefore the only option available to them here is to verify it
|
||||
const verifyButton = dialog.getByRole("button", { name: "Verify this device" });
|
||||
await expect(verifyButton).toBeVisible();
|
||||
await expect(util.getEncryptionTabContent()).toMatchScreenshot("verify-device-encryption-tab.png");
|
||||
await verifyButton.click();
|
||||
|
||||
await util.verifyDevice(recoveryKey);
|
||||
await expect(util.getEncryptionTabContent()).toMatchScreenshot("default-recovery.png");
|
||||
|
||||
// Check that our device is now cross-signed
|
||||
await checkDeviceIsCrossSigned(app);
|
||||
|
||||
// Check that the current device is connected to key backup
|
||||
// The backup decryption key should be in cache also, as we got it directly from the 4S
|
||||
await app.closeDialog();
|
||||
await checkDeviceIsConnectedKeyBackup(page, expectedBackupVersion, true);
|
||||
// The bot bootstraps cross-signing, creates a key backup and sets up a recovery key
|
||||
await createBot(page, homeserver, credentials);
|
||||
});
|
||||
|
||||
test(
|
||||
@@ -61,7 +28,7 @@ test.describe("Recovery section in Encryption tab", () => {
|
||||
// The user can only change the recovery key
|
||||
const changeButton = dialog.getByRole("button", { name: "Change recovery key" });
|
||||
await expect(changeButton).toBeVisible();
|
||||
await expect(util.getEncryptionTabContent()).toMatchScreenshot("default-recovery.png");
|
||||
await expect(util.getEncryptionRecoverySection()).toMatchScreenshot("default-recovery.png");
|
||||
await changeButton.click();
|
||||
|
||||
// Display the new recovery key and click on the copy button
|
||||
@@ -89,7 +56,7 @@ test.describe("Recovery section in Encryption tab", () => {
|
||||
const dialog = await util.openEncryptionTab();
|
||||
const setupButton = dialog.getByRole("button", { name: "Set up recovery" });
|
||||
await expect(setupButton).toBeVisible();
|
||||
await expect(util.getEncryptionTabContent()).toMatchScreenshot("set-up-recovery.png");
|
||||
await expect(util.getEncryptionRecoverySection()).toMatchScreenshot("set-up-recovery.png");
|
||||
await setupButton.click();
|
||||
|
||||
// Display an informative panel about the recovery key
|
||||
@@ -115,42 +82,7 @@ test.describe("Recovery section in Encryption tab", () => {
|
||||
// The recovery key is now set up and the user can change it
|
||||
await expect(dialog.getByRole("button", { name: "Change recovery key" })).toBeVisible();
|
||||
|
||||
await app.closeDialog();
|
||||
// Check that the current device is connected to key backup and the backup version is the expected one
|
||||
await checkDeviceIsConnectedKeyBackup(page, "1", true);
|
||||
await checkDeviceIsConnectedKeyBackup(app, "1", true);
|
||||
});
|
||||
|
||||
// Test what happens if the cross-signing secrets are in secret storage but are not cached in the local DB.
|
||||
//
|
||||
// This can happen if we verified another device and secret-gossiping failed, or the other device itself lacked the secrets.
|
||||
// We simulate this case by deleting the cached secrets in the indexedDB.
|
||||
test(
|
||||
"should enter the recovery key when the secrets are not cached",
|
||||
{ tag: "@screenshot" },
|
||||
async ({ page, app, util }) => {
|
||||
await verifySession(app, "new passphrase");
|
||||
// We need to delete the cached secrets
|
||||
await deleteCachedSecrets(page);
|
||||
|
||||
await util.openEncryptionTab();
|
||||
// We ask the user to enter the recovery key
|
||||
const dialog = util.getEncryptionTabContent();
|
||||
const enterKeyButton = dialog.getByRole("button", { name: "Enter recovery key" });
|
||||
await expect(enterKeyButton).toBeVisible();
|
||||
await expect(dialog).toMatchScreenshot("out-of-sync-recovery.png");
|
||||
await enterKeyButton.click();
|
||||
|
||||
// Fill the recovery key
|
||||
await util.enterRecoveryKey(recoveryKey);
|
||||
await expect(dialog).toMatchScreenshot("default-recovery.png");
|
||||
|
||||
// Check that our device is now cross-signed
|
||||
await checkDeviceIsCrossSigned(app);
|
||||
|
||||
// Check that the current device is connected to key backup
|
||||
// The backup decryption key should be in cache also, as we got it directly from the 4S
|
||||
await app.closeDialog();
|
||||
await checkDeviceIsConnectedKeyBackup(page, expectedBackupVersion, true);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
@@ -192,7 +192,6 @@ export class Bot extends Client {
|
||||
|
||||
await clientHandle.evaluate(async (cli) => {
|
||||
await cli.initRustCrypto({ useIndexedDB: false });
|
||||
cli.setGlobalErrorOnUnknownDevices(false);
|
||||
await cli.startClient();
|
||||
});
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ import { Fixtures } from "../../../element-web-test.ts";
|
||||
|
||||
export const consentHomeserver: Fixtures = {
|
||||
_homeserver: [
|
||||
async ({ _homeserver: container, mailhog }, use) => {
|
||||
async ({ _homeserver: container, mailpit }, use) => {
|
||||
container
|
||||
.withCopyDirectoriesToContainer([
|
||||
{ source: "playwright/plugins/homeserver/synapse/res", target: "/data/res" },
|
||||
@@ -18,7 +18,7 @@ export const consentHomeserver: Fixtures = {
|
||||
.withConfig({
|
||||
email: {
|
||||
enable_notifs: false,
|
||||
smtp_host: "mailhog",
|
||||
smtp_host: "mailpit",
|
||||
smtp_port: 1025,
|
||||
smtp_user: "username",
|
||||
smtp_pass: "password",
|
||||
|
||||
@@ -10,13 +10,13 @@ import { Fixtures } from "../../../element-web-test.ts";
|
||||
|
||||
export const emailHomeserver: Fixtures = {
|
||||
_homeserver: [
|
||||
async ({ _homeserver: container, mailhog }, use) => {
|
||||
async ({ _homeserver: container, mailpit }, use) => {
|
||||
container.withConfig({
|
||||
enable_registration_without_verification: undefined,
|
||||
disable_msisdn_registration: undefined,
|
||||
registrations_require_3pid: ["email"],
|
||||
email: {
|
||||
smtp_host: "mailhog",
|
||||
smtp_host: "mailpit",
|
||||
smtp_port: 1025,
|
||||
notif_from: "Your Friendly %(app)s homeserver <noreply@example.com>",
|
||||
app_name: "my_branded_matrix_server",
|
||||
|
||||
@@ -11,7 +11,7 @@ import { Fixtures } from "../../../element-web-test.ts";
|
||||
|
||||
export const masHomeserver: Fixtures = {
|
||||
mas: [
|
||||
async ({ _homeserver: homeserver, logger, network, postgres, mailhog }, use) => {
|
||||
async ({ _homeserver: homeserver, logger, network, postgres, mailpit }, use) => {
|
||||
const config = {
|
||||
clients: [
|
||||
{
|
||||
|
||||
@@ -6,7 +6,7 @@ Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { test as base } from "@playwright/test";
|
||||
import mailhog from "mailhog";
|
||||
import { MailpitClient } from "mailpit-api";
|
||||
import { Network, StartedNetwork } from "testcontainers";
|
||||
import { PostgreSqlContainer, StartedPostgreSqlContainer } from "@testcontainers/postgresql";
|
||||
|
||||
@@ -14,13 +14,13 @@ import { SynapseConfig, SynapseContainer } from "./testcontainers/synapse.ts";
|
||||
import { Logger } from "./logger.ts";
|
||||
import { StartedMatrixAuthenticationServiceContainer } from "./testcontainers/mas.ts";
|
||||
import { HomeserverContainer, StartedHomeserverContainer } from "./testcontainers/HomeserverContainer.ts";
|
||||
import { MailhogContainer, StartedMailhogContainer } from "./testcontainers/mailhog.ts";
|
||||
import { MailhogContainer, StartedMailhogContainer } from "./testcontainers/mailpit.ts";
|
||||
import { OAuthServer } from "./plugins/oauth_server";
|
||||
import { DendriteContainer, PineconeContainer } from "./testcontainers/dendrite.ts";
|
||||
import { HomeserverType } from "./plugins/homeserver";
|
||||
|
||||
export interface TestFixtures {
|
||||
mailhogClient: mailhog.API;
|
||||
mailpitClient: MailpitClient;
|
||||
}
|
||||
|
||||
export interface Services {
|
||||
@@ -28,7 +28,7 @@ export interface Services {
|
||||
|
||||
network: StartedNetwork;
|
||||
postgres: StartedPostgreSqlContainer;
|
||||
mailhog: StartedMailhogContainer;
|
||||
mailpit: StartedMailhogContainer;
|
||||
|
||||
synapseConfig: SynapseConfig;
|
||||
_homeserver: HomeserverContainer<any>;
|
||||
@@ -90,20 +90,20 @@ export const test = base.extend<TestFixtures, Services & Options>({
|
||||
{ scope: "worker" },
|
||||
],
|
||||
|
||||
mailhog: [
|
||||
mailpit: [
|
||||
async ({ logger, network }, use) => {
|
||||
const container = await new MailhogContainer()
|
||||
.withNetwork(network)
|
||||
.withNetworkAliases("mailhog")
|
||||
.withLogConsumer(logger.getConsumer("mailhog"))
|
||||
.withNetworkAliases("mailpit")
|
||||
.withLogConsumer(logger.getConsumer("mailpit"))
|
||||
.start();
|
||||
await use(container);
|
||||
await container.stop();
|
||||
},
|
||||
{ scope: "worker" },
|
||||
],
|
||||
mailhogClient: async ({ mailhog: container }, use) => {
|
||||
await container.client.deleteAll();
|
||||
mailpitClient: async ({ mailpit: container }, use) => {
|
||||
await container.client.deleteMessages();
|
||||
await use(container.client);
|
||||
},
|
||||
|
||||
|
||||
|
Before Width: | Height: | Size: 18 KiB After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 43 KiB |
|
After Width: | Height: | Size: 37 KiB |
|
Before Width: | Height: | Size: 22 KiB After Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 13 KiB After Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 17 KiB After Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 18 KiB After Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 20 KiB After Width: | Height: | Size: 20 KiB |
@@ -6,13 +6,16 @@ Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { AbstractStartedContainer, GenericContainer, StartedTestContainer, Wait } from "testcontainers";
|
||||
import mailhog from "mailhog";
|
||||
import { MailpitClient } from "mailpit-api";
|
||||
|
||||
export class MailhogContainer extends GenericContainer {
|
||||
constructor() {
|
||||
super("mailhog/mailhog:latest");
|
||||
super("axllent/mailpit:latest");
|
||||
|
||||
this.withExposedPorts(8025).withWaitStrategy(Wait.forListeningPorts());
|
||||
this.withExposedPorts(8025).withWaitStrategy(Wait.forListeningPorts()).withEnvironment({
|
||||
MP_SMTP_AUTH_ALLOW_INSECURE: "true",
|
||||
MP_SMTP_AUTH_ACCEPT_ANY: "true",
|
||||
});
|
||||
}
|
||||
|
||||
public override async start(): Promise<StartedMailhogContainer> {
|
||||
@@ -21,10 +24,10 @@ export class MailhogContainer extends GenericContainer {
|
||||
}
|
||||
|
||||
export class StartedMailhogContainer extends AbstractStartedContainer {
|
||||
public readonly client: mailhog.API;
|
||||
public readonly client: MailpitClient;
|
||||
|
||||
constructor(container: StartedTestContainer) {
|
||||
super(container);
|
||||
this.client = mailhog({ host: container.getHost(), port: container.getMappedPort(8025) });
|
||||
this.client = new MailpitClient(`http://${container.getHost()}:${container.getMappedPort(8025)}`);
|
||||
}
|
||||
}
|
||||
@@ -92,7 +92,7 @@ const DEFAULT_CONFIG = {
|
||||
reply_to: '"Authentication Service" <root@localhost>',
|
||||
transport: "smtp",
|
||||
mode: "plain",
|
||||
hostname: "mailhog",
|
||||
hostname: "mailpit",
|
||||
port: 1025,
|
||||
username: "username",
|
||||
password: "password",
|
||||
|
||||
@@ -19,7 +19,7 @@ import { HomeserverContainer, StartedHomeserverContainer } from "./HomeserverCon
|
||||
import { StartedMatrixAuthenticationServiceContainer } from "./mas.ts";
|
||||
import { Api, ClientServerApi, Verb } from "../plugins/utils/api.ts";
|
||||
|
||||
const TAG = "develop@sha256:5d62b61c4373eaca25df6c6bb99fc1be92f8f40b8abebd8897bf5b2af9eb137a";
|
||||
const TAG = "develop@sha256:098126c6be750dffaff5bd19db254609aadaf34f76c70f2dca9821cb12428613";
|
||||
|
||||
const DEFAULT_CONFIG = {
|
||||
server_name: "localhost",
|
||||
|
||||
@@ -134,6 +134,7 @@
|
||||
@import "./views/dialogs/_ConfirmUserActionDialog.pcss";
|
||||
@import "./views/dialogs/_CreateRoomDialog.pcss";
|
||||
@import "./views/dialogs/_CreateSubspaceDialog.pcss";
|
||||
@import "./views/dialogs/_Crypto.pcss";
|
||||
@import "./views/dialogs/_DeactivateAccountDialog.pcss";
|
||||
@import "./views/dialogs/_DevtoolsDialog.pcss";
|
||||
@import "./views/dialogs/_ExportDialog.pcss";
|
||||
@@ -354,8 +355,10 @@
|
||||
@import "./views/settings/_ThemeChoicePanel.pcss";
|
||||
@import "./views/settings/_UpdateCheckButton.pcss";
|
||||
@import "./views/settings/_UserProfileSettings.pcss";
|
||||
@import "./views/settings/encryption/_AdvancedPanel.pcss";
|
||||
@import "./views/settings/encryption/_ChangeRecoveryKey.pcss";
|
||||
@import "./views/settings/encryption/_EncryptionCard.pcss";
|
||||
@import "./views/settings/encryption/_ResetIdentityPanel.pcss";
|
||||
@import "./views/settings/tabs/_SettingsBanner.pcss";
|
||||
@import "./views/settings/tabs/_SettingsIndent.pcss";
|
||||
@import "./views/settings/tabs/_SettingsSection.pcss";
|
||||
|
||||
@@ -37,27 +37,6 @@ Please see LICENSE files in the repository root for full details.
|
||||
line-height: $font-24px;
|
||||
margin-left: 10px;
|
||||
}
|
||||
|
||||
.mx_UserMenu_dndBadge {
|
||||
position: absolute;
|
||||
bottom: -2px;
|
||||
right: -7px;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border-radius: 50%;
|
||||
|
||||
&::before {
|
||||
content: "";
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
position: absolute;
|
||||
mask-position: center;
|
||||
mask-size: contain;
|
||||
mask-repeat: no-repeat;
|
||||
background-color: $alert;
|
||||
mask-image: url("$(res)/img/element-icons/roomlist/dnd.svg");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.mx_IconizedContextMenu {
|
||||
@@ -158,14 +137,6 @@ Please see LICENSE files in the repository root for full details.
|
||||
mask-image: url("@vector-im/compound-design-tokens/icons/home-solid.svg");
|
||||
}
|
||||
|
||||
.mx_UserMenu_iconDnd::before {
|
||||
mask-image: url("$(res)/img/element-icons/roomlist/dnd.svg");
|
||||
}
|
||||
|
||||
.mx_UserMenu_iconDndOff::before {
|
||||
mask-image: url("$(res)/img/element-icons/roomlist/dnd-cross.svg");
|
||||
}
|
||||
|
||||
.mx_UserMenu_iconBell::before {
|
||||
mask-image: url("$(res)/img/element-icons/notifications.svg");
|
||||
}
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
/*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
.mx_Crypto {
|
||||
table {
|
||||
margin: var(--cpd-space-4x) 0;
|
||||
text-align: left;
|
||||
border-spacing: var(--cpd-space-2x) 0;
|
||||
|
||||
thead {
|
||||
font: var(--cpd-font-heading-sm-semibold);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -26,7 +26,8 @@ Please see LICENSE files in the repository root for full details.
|
||||
}
|
||||
|
||||
&.mx_UserPill_me,
|
||||
&.mx_AtRoomPill {
|
||||
&.mx_AtRoomPill,
|
||||
&.mx_KeywordPill {
|
||||
background-color: var(--cpd-color-bg-critical-primary) !important; /* To override .markdown-body */
|
||||
}
|
||||
|
||||
@@ -45,7 +46,8 @@ Please see LICENSE files in the repository root for full details.
|
||||
}
|
||||
|
||||
/* We don't want to indicate clickability */
|
||||
&.mx_AtRoomPill:hover {
|
||||
&.mx_AtRoomPill:hover,
|
||||
&.mx_KeywordPill:hover {
|
||||
background-color: var(--cpd-color-bg-critical-primary) !important; /* To override .markdown-body */
|
||||
cursor: unset;
|
||||
}
|
||||
|
||||
@@ -35,6 +35,8 @@ Please see LICENSE files in the repository root for full details.
|
||||
.mx_DisambiguatedProfile_mxid {
|
||||
margin-inline-start: 0;
|
||||
font: var(--cpd-font-body-sm-regular);
|
||||
text-overflow: ellipsis;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
span:not(.mx_DisambiguatedProfile_mxid) {
|
||||
|
||||
@@ -15,41 +15,48 @@ Please see LICENSE files in the repository root for full details.
|
||||
flex: unset;
|
||||
}
|
||||
|
||||
.mx_BaseCard_header {
|
||||
.mx_BaseCard_header_title {
|
||||
.mx_AccessibleButton {
|
||||
font-size: 12px;
|
||||
color: $secondary-content;
|
||||
.mx_ThreadPanelHeader {
|
||||
height: 60px;
|
||||
display: flex;
|
||||
box-sizing: border-box;
|
||||
padding: 16px;
|
||||
align-items: center;
|
||||
border-bottom: 1px solid var(--cpd-color-gray-400);
|
||||
|
||||
.mx_AccessibleButton {
|
||||
font-size: 12px;
|
||||
color: $secondary-content;
|
||||
}
|
||||
|
||||
.mx_ThreadPanel_vertical_separator {
|
||||
height: 28px;
|
||||
margin-left: var(--cpd-space-3x);
|
||||
margin-right: var(--cpd-space-2x);
|
||||
border-left: 1px solid var(--cpd-color-gray-400);
|
||||
}
|
||||
|
||||
.mx_ThreadPanel_dropdown {
|
||||
font: var(--cpd-font-body-sm-regular);
|
||||
padding: 3px $spacing-4 3px $spacing-8;
|
||||
border-radius: 4px;
|
||||
line-height: 1.5;
|
||||
user-select: none;
|
||||
|
||||
&:hover,
|
||||
&[aria-expanded="true"] {
|
||||
background: $quinary-content;
|
||||
}
|
||||
|
||||
.mx_ThreadPanel_vertical_separator {
|
||||
height: 16px;
|
||||
margin-left: var(--cpd-space-3x);
|
||||
margin-right: var(--cpd-space-1x);
|
||||
border-left: 1px solid var(--cpd-color-gray-400);
|
||||
}
|
||||
|
||||
.mx_ThreadPanel_dropdown {
|
||||
padding: 3px $spacing-4 3px $spacing-8;
|
||||
border-radius: 4px;
|
||||
line-height: 1.5;
|
||||
user-select: none;
|
||||
|
||||
&:hover,
|
||||
&[aria-expanded="true"] {
|
||||
background: $quinary-content;
|
||||
}
|
||||
|
||||
&::before {
|
||||
content: "";
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
background: currentColor;
|
||||
mask-image: url("@vector-im/compound-design-tokens/icons/chevron-down.svg");
|
||||
mask-size: 100%;
|
||||
mask-repeat: no-repeat;
|
||||
float: right;
|
||||
}
|
||||
&::before {
|
||||
margin-left: 2px;
|
||||
content: "";
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
background: currentColor;
|
||||
mask-image: url("@vector-im/compound-design-tokens/icons/chevron-down.svg");
|
||||
mask-size: 100%;
|
||||
mask-repeat: no-repeat;
|
||||
float: right;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,7 +34,7 @@ Please see LICENSE files in the repository root for full details.
|
||||
}
|
||||
|
||||
.mx_UserInfo_container {
|
||||
padding: var(--cpd-space-4x) 0;
|
||||
padding: var(--cpd-space-2x) 0 var(--cpd-space-4x);
|
||||
margin: 0 var(--cpd-space-4x);
|
||||
|
||||
.mx_UserInfo_container_verifyButton {
|
||||
@@ -65,7 +65,7 @@ Please see LICENSE files in the repository root for full details.
|
||||
}
|
||||
|
||||
.mx_UserInfo_avatar {
|
||||
margin: $spacing-24 $spacing-32 0 $spacing-32;
|
||||
margin: var(--cpd-space-12x) var(--cpd-space-4x) 0 var(--cpd-space-4x);
|
||||
|
||||
.mx_UserInfo_avatar_transition {
|
||||
max-width: 120px;
|
||||
@@ -98,8 +98,18 @@ Please see LICENSE files in the repository root for full details.
|
||||
margin: 5px 0;
|
||||
}
|
||||
|
||||
.mx_UserInfo_header {
|
||||
margin-bottom: var(--cpd-space-8x);
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
.mx_UserInfo_profile {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--cpd-space-1x);
|
||||
|
||||
h1 {
|
||||
margin: 0;
|
||||
font-size: $font-20px;
|
||||
line-height: $font-25px;
|
||||
|
||||
@@ -119,8 +129,45 @@ Please see LICENSE files in the repository root for full details.
|
||||
}
|
||||
}
|
||||
|
||||
.mx_UserInfo_profile_name {
|
||||
height: 30px;
|
||||
}
|
||||
|
||||
.mx_UserInfo_profile_mxid {
|
||||
color: var(--cpd-color-text-secondary);
|
||||
height: 28px;
|
||||
}
|
||||
|
||||
.mx_UserInfo_profileStatus {
|
||||
margin: var(--cpd-space-1x) 0;
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
.mx_UserInfo_timezone {
|
||||
height: 20px;
|
||||
margin: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
/** Overrides for the copy to clipboard button **/
|
||||
.mx_CopyableText {
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.mx_CopyableText_copyButton {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
position: unset;
|
||||
padding-left: var(--cpd-space-2x);
|
||||
}
|
||||
|
||||
.mx_CopyableText_copyButton::before {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
background-color: var(--cpd-color-icon-secondary-alpha);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -135,12 +135,6 @@ $left-gutter: 64px;
|
||||
}
|
||||
}
|
||||
|
||||
&.mx_EventTile_highlight,
|
||||
&.mx_EventTile_highlight .markdown-body,
|
||||
&.mx_EventTile_highlight .mx_EventTile_edited {
|
||||
color: $alert;
|
||||
}
|
||||
|
||||
&.mx_EventTile_bubbleContainer {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 100px;
|
||||
|
||||
@@ -35,6 +35,7 @@ Please see LICENSE files in the repository root for full details.
|
||||
font: var(--cpd-font-body-sm-regular);
|
||||
font-size: 13px;
|
||||
color: var(--cpd-color-text-secondary);
|
||||
margin-left: var(--cpd-space-4x);
|
||||
}
|
||||
|
||||
.mx_MemberTileView_avatar {
|
||||
|
||||
@@ -71,6 +71,7 @@ Please see LICENSE files in the repository root for full details.
|
||||
padding: var(--cpd-space-1-5x);
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
font: var(--cpd-font-body-sm-medium);
|
||||
|
||||
/* RoomAvatar doesn't pass classes down to avatar
|
||||
So set style here
|
||||
@@ -83,6 +84,12 @@ Please see LICENSE files in the repository root for full details.
|
||||
color: $primary-content;
|
||||
background: var(--cpd-color-bg-subtle-primary);
|
||||
}
|
||||
|
||||
&.mx_FacePile_toggled {
|
||||
background: var(--cpd-color-bg-success-subtle);
|
||||
color: var(--cpd-color-text-action-accent);
|
||||
font: var(--cpd-font-body-sm-semibold);
|
||||
}
|
||||
}
|
||||
|
||||
.mx_RoomHeader .mx_BaseAvatar {
|
||||
@@ -93,3 +100,7 @@ Please see LICENSE files in the repository root for full details.
|
||||
/* Workaround for https://github.com/element-hq/compound/issues/331 */
|
||||
min-width: 240px;
|
||||
}
|
||||
|
||||
.mx_RoomHeader .mx_RoomHeader_toggled {
|
||||
color: var(--cpd-color-icon-accent-primary);
|
||||
}
|
||||
|
||||
@@ -20,8 +20,14 @@ Please see LICENSE files in the repository root for full details.
|
||||
margin-left: var(--cpd-space-6x);
|
||||
flex-grow: 1;
|
||||
}
|
||||
.mx_UserIdentityWarning_main.critical {
|
||||
color: var(--cpd-color-text-critical-primary);
|
||||
}
|
||||
}
|
||||
}
|
||||
.mx_UserIdentityWarning.critical {
|
||||
background: linear-gradient(180deg, var(--cpd-color-red-100) 0%, var(--cpd-color-theme-bg) 100%);
|
||||
}
|
||||
|
||||
.mx_MessageComposer.mx_MessageComposer--compact > .mx_UserIdentityWarning {
|
||||
margin-left: calc(-25px + var(--RoomView_MessageList-padding));
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
/*
|
||||
* Copyright 2024 New Vector Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
.mx_EncryptionDetails,
|
||||
.mx_OtherSettings {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--cpd-space-6x);
|
||||
width: 100%;
|
||||
align-items: start;
|
||||
|
||||
.mx_EncryptionDetails_session_title,
|
||||
.mx_OtherSettings_title {
|
||||
font: var(--cpd-font-body-lg-semibold);
|
||||
padding-bottom: var(--cpd-space-2x);
|
||||
border-bottom: 1px solid var(--cpd-color-gray-400);
|
||||
width: 100%;
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.mx_EncryptionDetails {
|
||||
.mx_EncryptionDetails_session {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--cpd-space-4x);
|
||||
width: 100%;
|
||||
|
||||
> div {
|
||||
display: flex;
|
||||
|
||||
> span {
|
||||
width: 50%;
|
||||
word-wrap: break-word;
|
||||
}
|
||||
}
|
||||
|
||||
> div:nth-child(odd) {
|
||||
background-color: var(--cpd-color-gray-200);
|
||||
}
|
||||
}
|
||||
|
||||
.mx_EncryptionDetails_buttons {
|
||||
display: flex;
|
||||
gap: var(--cpd-space-4x);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
/*
|
||||
* Copyright 2024 New Vector Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
.mx_ResetIdentityPanel {
|
||||
.mx_ResetIdentityPanel_content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--cpd-space-3x);
|
||||
|
||||
> span {
|
||||
font: var(--cpd-font-body-md-medium);
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
|
||||
.mx_ResetIdentityPanel_footer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--cpd-space-4x);
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M11.3333 7.3335V8.66683H10.5533L13.56 11.6735C14.26 10.6202 14.6667 9.36016 14.6667 8.00016C14.6667 4.32016 11.68 1.3335 8.00001 1.3335C6.64001 1.3335 5.38001 1.74016 4.32668 2.44016L9.22001 7.3335H11.3333ZM0.926682 2.8135L2.44001 4.32683C1.74001 5.38016 1.33335 6.64016 1.33335 8.00016C1.33335 11.6802 4.32001 14.6668 8.00001 14.6668C9.36001 14.6668 10.62 14.2602 11.6733 13.5602L13.1867 15.0735L14.1267 14.1335L1.87335 1.8735L0.926682 2.8135ZM4.66668 7.3335H5.44668L6.78001 8.66683H4.66668V7.3335Z" fill="#737D8C"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 630 B |
@@ -1,3 +0,0 @@
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M12 2C6.48 2 2 6.48 2 12C2 17.52 6.48 22 12 22C17.52 22 22 17.52 22 12C22 6.48 17.52 2 12 2ZM17 13H7V11H17V13Z" fill="#17191C"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 241 B |
@@ -72,6 +72,13 @@ if [[ "$head" == *":"* ]]; then
|
||||
fi
|
||||
clone ${TRY_ORG} $defrepo ${TRY_BRANCH}
|
||||
|
||||
# For merge queue runs we need to extract the temporary branch name
|
||||
# the ref_name will look like `gh-readonly-queue/<branch>/pr-<number>-<sha>`
|
||||
if [[ "$GITHUB_EVENT_NAME" == "merge_group" ]]; then
|
||||
withoutPrefix=${GITHUB_REF_NAME#gh-readonly-queue/}
|
||||
clone $deforg $defrepo ${withoutPrefix%%/pr-*}
|
||||
fi
|
||||
|
||||
# Try the target branch of the push or PR.
|
||||
if [ -n "$GITHUB_BASE_REF" ]; then
|
||||
clone $deforg $defrepo $GITHUB_BASE_REF
|
||||
|
||||
@@ -31,49 +31,50 @@ export async function createCrossSigning(cli: MatrixClient): Promise<void> {
|
||||
throw new Error("No crypto API found!");
|
||||
}
|
||||
|
||||
const doBootstrapUIAuth = async (
|
||||
makeRequest: (authData: AuthDict) => Promise<UIAResponse<void>>,
|
||||
): Promise<void> => {
|
||||
try {
|
||||
await makeRequest({});
|
||||
} catch (error) {
|
||||
if (!(error instanceof MatrixError) || !error.data || !error.data.flows) {
|
||||
// Not a UIA response
|
||||
throw error;
|
||||
}
|
||||
|
||||
const dialogAesthetics = {
|
||||
[SSOAuthEntry.PHASE_PREAUTH]: {
|
||||
title: _t("auth|uia|sso_title"),
|
||||
body: _t("auth|uia|sso_preauth_body"),
|
||||
continueText: _t("auth|sso"),
|
||||
continueKind: "primary",
|
||||
},
|
||||
[SSOAuthEntry.PHASE_POSTAUTH]: {
|
||||
title: _t("encryption|confirm_encryption_setup_title"),
|
||||
body: _t("encryption|confirm_encryption_setup_body"),
|
||||
continueText: _t("action|confirm"),
|
||||
continueKind: "primary",
|
||||
},
|
||||
};
|
||||
|
||||
const { finished } = Modal.createDialog(InteractiveAuthDialog, {
|
||||
title: _t("encryption|bootstrap_title"),
|
||||
matrixClient: cli,
|
||||
makeRequest,
|
||||
aestheticsForStagePhases: {
|
||||
[SSOAuthEntry.LOGIN_TYPE]: dialogAesthetics,
|
||||
[SSOAuthEntry.UNSTABLE_LOGIN_TYPE]: dialogAesthetics,
|
||||
},
|
||||
});
|
||||
const [confirmed] = await finished;
|
||||
if (!confirmed) {
|
||||
throw new Error("Cross-signing key upload auth canceled");
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
await cryptoApi.bootstrapCrossSigning({
|
||||
authUploadDeviceSigningKeys: doBootstrapUIAuth,
|
||||
authUploadDeviceSigningKeys: (makeRequest) => uiAuthCallback(cli, makeRequest),
|
||||
});
|
||||
}
|
||||
|
||||
export async function uiAuthCallback(
|
||||
matrixClient: MatrixClient,
|
||||
makeRequest: (authData: AuthDict) => Promise<UIAResponse<void>>,
|
||||
): Promise<void> {
|
||||
try {
|
||||
await makeRequest({});
|
||||
} catch (error) {
|
||||
if (!(error instanceof MatrixError) || !error.data || !error.data.flows) {
|
||||
// Not a UIA response
|
||||
throw error;
|
||||
}
|
||||
|
||||
const dialogAesthetics = {
|
||||
[SSOAuthEntry.PHASE_PREAUTH]: {
|
||||
title: _t("auth|uia|sso_title"),
|
||||
body: _t("auth|uia|sso_preauth_body"),
|
||||
continueText: _t("auth|sso"),
|
||||
continueKind: "primary",
|
||||
},
|
||||
[SSOAuthEntry.PHASE_POSTAUTH]: {
|
||||
title: _t("encryption|confirm_encryption_setup_title"),
|
||||
body: _t("encryption|confirm_encryption_setup_body"),
|
||||
continueText: _t("action|confirm"),
|
||||
continueKind: "primary",
|
||||
},
|
||||
};
|
||||
|
||||
const { finished } = Modal.createDialog(InteractiveAuthDialog, {
|
||||
title: _t("encryption|bootstrap_title"),
|
||||
matrixClient,
|
||||
makeRequest,
|
||||
aestheticsForStagePhases: {
|
||||
[SSOAuthEntry.LOGIN_TYPE]: dialogAesthetics,
|
||||
[SSOAuthEntry.UNSTABLE_LOGIN_TYPE]: dialogAesthetics,
|
||||
},
|
||||
});
|
||||
const [confirmed] = await finished;
|
||||
if (!confirmed) {
|
||||
throw new Error("Cross-signing key upload auth canceled");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@ import {
|
||||
SyncState,
|
||||
ClientStoppedError,
|
||||
} from "matrix-js-sdk/src/matrix";
|
||||
import { logger } from "matrix-js-sdk/src/logger";
|
||||
import { logger as baseLogger } from "matrix-js-sdk/src/logger";
|
||||
import { CryptoEvent, KeyBackupInfo } from "matrix-js-sdk/src/crypto-api";
|
||||
import { CryptoSessionStateChange } from "@matrix-org/analytics-events/types/typescript/CryptoSessionStateChange";
|
||||
|
||||
@@ -48,6 +48,8 @@ import { asyncSomeParallel } from "./utils/arrays.ts";
|
||||
|
||||
const KEY_BACKUP_POLL_INTERVAL = 5 * 60 * 1000;
|
||||
|
||||
const logger = baseLogger.getChild("DeviceListener:");
|
||||
|
||||
export default class DeviceListener {
|
||||
private dispatcherRef?: string;
|
||||
// device IDs for which the user has dismissed the verify toast ('Later')
|
||||
@@ -131,7 +133,7 @@ export default class DeviceListener {
|
||||
* @param {String[]} deviceIds List of device IDs to dismiss notifications for
|
||||
*/
|
||||
public async dismissUnverifiedSessions(deviceIds: Iterable<string>): Promise<void> {
|
||||
logger.log("Dismissing unverified sessions: " + Array.from(deviceIds).join(","));
|
||||
logger.debug("Dismissing unverified sessions: " + Array.from(deviceIds).join(","));
|
||||
for (const d of deviceIds) {
|
||||
this.dismissed.add(d);
|
||||
}
|
||||
@@ -309,16 +311,20 @@ export default class DeviceListener {
|
||||
if (!crossSigningReady) {
|
||||
// This account is legacy and doesn't have cross-signing set up at all.
|
||||
// Prompt the user to set it up.
|
||||
logger.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
|
||||
logger.info("Current device not verified: showing VERIFY_THIS_SESSION toast");
|
||||
showSetupEncryptionToast(SetupKind.VERIFY_THIS_SESSION);
|
||||
} else if (!allCrossSigningSecretsCached) {
|
||||
// cross signing ready & device trusted, but we are missing secrets from our local cache.
|
||||
// prompt the user to enter their recovery key.
|
||||
logger.info("Some secrets not cached: showing KEY_STORAGE_OUT_OF_SYNC toast");
|
||||
showSetupEncryptionToast(SetupKind.KEY_STORAGE_OUT_OF_SYNC);
|
||||
} else if (defaultKeyId === null) {
|
||||
// the user just hasn't set up 4S yet: prompt them to do so
|
||||
logger.info("No default 4S key: showing SET_UP_RECOVERY toast");
|
||||
showSetupEncryptionToast(SetupKind.SET_UP_RECOVERY);
|
||||
} else {
|
||||
// some other condition... yikes! Show the 'set up encryption' toast: this is what we previously did
|
||||
|
||||
@@ -41,6 +41,7 @@ import PlatformPeg from "./PlatformPeg";
|
||||
import { formatList } from "./utils/FormattingUtils";
|
||||
import SdkConfig from "./SdkConfig";
|
||||
import { setDeviceIsolationMode } from "./settings/controllers/DeviceIsolationModeController.ts";
|
||||
import { initialiseDehydration } from "./utils/device/dehydration";
|
||||
|
||||
export interface IMatrixClientCreds {
|
||||
homeserverUrl: string;
|
||||
@@ -340,7 +341,20 @@ class MatrixClientPegClass implements IMatrixClientPeg {
|
||||
|
||||
setDeviceIsolationMode(this.matrixClient, SettingsStore.getValue("feature_exclude_insecure_devices"));
|
||||
|
||||
// TODO: device dehydration and whathaveyou
|
||||
// Start dehydration. This code is only for the case where the client
|
||||
// gets restarted, so we only do this if we already have the dehydration
|
||||
// key cached, and we don't have to try to rehydrate a device. If this
|
||||
// is a new login, we will start dehydration after Secret Storage is
|
||||
// unlocked.
|
||||
try {
|
||||
await initialiseDehydration({ onlyIfKeyCached: true, rehydrate: false }, this.matrixClient);
|
||||
} catch (e) {
|
||||
// We may get an error dehydrating, such as if cross-signing and
|
||||
// SSSS are not set up yet. Just log the error and continue.
|
||||
// If SSSS gets set up later, we will re-try dehydration.
|
||||
console.log("Error starting device dehydration", e);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ Please see LICENSE files in the repository root for full details.
|
||||
import { lazy } from "react";
|
||||
import { SecretStorage } from "matrix-js-sdk/src/matrix";
|
||||
import { deriveRecoveryKeyFromPassphrase, decodeRecoveryKey, CryptoCallbacks } from "matrix-js-sdk/src/crypto-api";
|
||||
import { logger } from "matrix-js-sdk/src/logger";
|
||||
import { logger as rootLogger } from "matrix-js-sdk/src/logger";
|
||||
|
||||
import Modal from "./Modal";
|
||||
import { MatrixClientPeg } from "./MatrixClientPeg";
|
||||
@@ -29,6 +29,8 @@ let secretStorageKeys: Record<string, Uint8Array> = {};
|
||||
let secretStorageKeyInfo: Record<string, SecretStorage.SecretStorageKeyDescription> = {};
|
||||
let secretStorageBeingAccessed = false;
|
||||
|
||||
const logger = rootLogger.getChild("SecurityManager:");
|
||||
|
||||
/**
|
||||
* This can be used by other components to check if secret storage access is in
|
||||
* progress, so that we can e.g. avoid intermittently showing toasts during
|
||||
@@ -70,33 +72,34 @@ function makeInputToKey(
|
||||
};
|
||||
}
|
||||
|
||||
async function getSecretStorageKey({
|
||||
keys: keyInfos,
|
||||
}: {
|
||||
keys: Record<string, SecretStorage.SecretStorageKeyDescription>;
|
||||
}): Promise<[string, Uint8Array]> {
|
||||
async function getSecretStorageKey(
|
||||
{
|
||||
keys: keyInfos,
|
||||
}: {
|
||||
keys: Record<string, SecretStorage.SecretStorageKeyDescription>;
|
||||
},
|
||||
secretName: string,
|
||||
): Promise<[string, Uint8Array]> {
|
||||
const cli = MatrixClientPeg.safeGet();
|
||||
let keyId = await cli.secretStorage.getDefaultKeyId();
|
||||
let keyInfo!: SecretStorage.SecretStorageKeyDescription;
|
||||
if (keyId) {
|
||||
// use the default SSSS key if set
|
||||
keyInfo = keyInfos[keyId];
|
||||
if (!keyInfo) {
|
||||
// if the default key is not available, pretend the default key
|
||||
// isn't set
|
||||
keyId = null;
|
||||
}
|
||||
}
|
||||
if (!keyId) {
|
||||
// if no default SSSS key is set, fall back to a heuristic of using the
|
||||
const defaultKeyId = await cli.secretStorage.getDefaultKeyId();
|
||||
|
||||
let keyId: string;
|
||||
// If the defaultKey is useful, use that
|
||||
if (defaultKeyId && keyInfos[defaultKeyId]) {
|
||||
keyId = defaultKeyId;
|
||||
} else {
|
||||
// Fall back to a heuristic of using the
|
||||
// only available key, if only one key is set
|
||||
const keyInfoEntries = Object.entries(keyInfos);
|
||||
if (keyInfoEntries.length > 1) {
|
||||
const usefulKeys = Object.keys(keyInfos);
|
||||
if (usefulKeys.length > 1) {
|
||||
throw new Error("Multiple storage key requests not implemented");
|
||||
}
|
||||
[keyId, keyInfo] = keyInfoEntries[0];
|
||||
keyId = usefulKeys[0];
|
||||
}
|
||||
logger.debug(`getSecretStorageKey: request for 4S keys [${Object.keys(keyInfos)}]: looking for key ${keyId}`);
|
||||
const keyInfo = keyInfos[keyId];
|
||||
logger.debug(
|
||||
`getSecretStorageKey: request for 4S keys [${Object.keys(keyInfos)}] for secret \`${secretName}\`: looking for key ${keyId}`,
|
||||
);
|
||||
|
||||
// Check the in-memory cache
|
||||
if (secretStorageBeingAccessed && secretStorageKeys[keyId]) {
|
||||
@@ -106,12 +109,18 @@ async function getSecretStorageKey({
|
||||
|
||||
const keyFromCustomisations = ModuleRunner.instance.extensions.cryptoSetup.getSecretStorageKey();
|
||||
if (keyFromCustomisations) {
|
||||
logger.log("getSecretStorageKey: Using secret storage key from CryptoSetupExtension");
|
||||
logger.debug("getSecretStorageKey: Using secret storage key from CryptoSetupExtension");
|
||||
cacheSecretStorageKey(keyId, keyInfo, keyFromCustomisations);
|
||||
return [keyId, keyFromCustomisations];
|
||||
}
|
||||
|
||||
logger.debug("getSecretStorageKey: prompting user for key");
|
||||
// We only prompt the user for the default key
|
||||
if (keyId !== defaultKeyId) {
|
||||
logger.debug(`getSecretStorageKey: request for non-default key ${keyId}: not prompting user`);
|
||||
throw new Error("Request for non-default 4S key");
|
||||
}
|
||||
|
||||
logger.debug(`getSecretStorageKey: prompting user for key ${keyId}`);
|
||||
const inputToKey = makeInputToKey(keyInfo);
|
||||
const { finished } = Modal.createDialog(
|
||||
AccessSecretStorageDialog,
|
||||
@@ -139,7 +148,7 @@ async function getSecretStorageKey({
|
||||
if (!keyParams) {
|
||||
throw new AccessCancelledError();
|
||||
}
|
||||
logger.debug("getSecretStorageKey: got key from user");
|
||||
logger.debug(`getSecretStorageKey: got key ${keyId} from user`);
|
||||
const key = await inputToKey(keyParams);
|
||||
|
||||
// Save to cache to avoid future prompts in the current session
|
||||
@@ -154,6 +163,7 @@ function cacheSecretStorageKey(
|
||||
key: Uint8Array,
|
||||
): void {
|
||||
if (secretStorageBeingAccessed) {
|
||||
logger.debug(`Caching 4S key ${keyId}`);
|
||||
secretStorageKeys[keyId] = key;
|
||||
secretStorageKeyInfo[keyId] = keyInfo;
|
||||
}
|
||||
@@ -173,13 +183,13 @@ export const crossSigningCallbacks: CryptoCallbacks = {
|
||||
* @param func - The operation to be wrapped.
|
||||
*/
|
||||
export async function withSecretStorageKeyCache<T>(func: () => Promise<T>): Promise<T> {
|
||||
logger.debug("SecurityManager: enabling 4S key cache");
|
||||
logger.debug("enabling 4S key cache");
|
||||
secretStorageBeingAccessed = true;
|
||||
try {
|
||||
return await func();
|
||||
} finally {
|
||||
// Clear secret storage key cache now that work is complete
|
||||
logger.debug("SecurityManager: disabling 4S key cache");
|
||||
logger.debug("disabling 4S key cache");
|
||||
secretStorageBeingAccessed = false;
|
||||
secretStorageKeys = {};
|
||||
secretStorageKeyInfo = {};
|
||||
|
||||
@@ -7,11 +7,12 @@ Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import classNames from "classnames";
|
||||
import { SERVICE_TYPES, MatrixClient } from "matrix-js-sdk/src/matrix";
|
||||
import { SERVICE_TYPES, MatrixClient, Terms, Policy, InternationalisedPolicy } from "matrix-js-sdk/src/matrix";
|
||||
import { logger } from "matrix-js-sdk/src/logger";
|
||||
|
||||
import Modal from "./Modal";
|
||||
import TermsDialog from "./components/views/dialogs/TermsDialog";
|
||||
import { pickBestLanguage } from "./languageHandler.tsx";
|
||||
|
||||
export class TermsNotSignedError extends Error {}
|
||||
|
||||
@@ -32,23 +33,8 @@ export class Service {
|
||||
) {}
|
||||
}
|
||||
|
||||
export interface LocalisedPolicy {
|
||||
name: string;
|
||||
url: string;
|
||||
}
|
||||
|
||||
export interface Policy {
|
||||
// @ts-ignore: No great way to express indexed types together with other keys
|
||||
version: string;
|
||||
[lang: string]: LocalisedPolicy;
|
||||
}
|
||||
|
||||
export type Policies = {
|
||||
[policy: string]: Policy;
|
||||
};
|
||||
|
||||
export type ServicePolicyPair = {
|
||||
policies: Policies;
|
||||
policies: Terms["policies"];
|
||||
service: Service;
|
||||
};
|
||||
|
||||
@@ -58,6 +44,11 @@ export type TermsInteractionCallback = (
|
||||
extraClassNames?: string,
|
||||
) => Promise<string[]>;
|
||||
|
||||
export function pickBestPolicyLanguage(policy: Policy): InternationalisedPolicy | undefined {
|
||||
const termsLang = pickBestLanguage(Object.keys(policy).filter((k) => k !== "version"));
|
||||
return <InternationalisedPolicy>policy[termsLang];
|
||||
}
|
||||
|
||||
/**
|
||||
* Start a flow where the user is presented with terms & conditions for some services
|
||||
*
|
||||
@@ -96,7 +87,7 @@ export async function startTermsFlow(
|
||||
* }
|
||||
*/
|
||||
|
||||
const terms: { policies: Policies }[] = await Promise.all(termsPromises);
|
||||
const terms: Terms[] = await Promise.all(termsPromises);
|
||||
const policiesAndServicePairs = terms.map((t, i) => {
|
||||
return { service: services[i], policies: t.policies };
|
||||
});
|
||||
@@ -113,11 +104,11 @@ export async function startTermsFlow(
|
||||
// things they've not agreed to yet.
|
||||
const unagreedPoliciesAndServicePairs: ServicePolicyPair[] = [];
|
||||
for (const { service, policies } of policiesAndServicePairs) {
|
||||
const unagreedPolicies: Policies = {};
|
||||
const unagreedPolicies: Terms["policies"] = {};
|
||||
for (const [policyName, policy] of Object.entries(policies)) {
|
||||
let policyAgreed = false;
|
||||
for (const lang of Object.keys(policy)) {
|
||||
if (lang === "version") continue;
|
||||
if (lang === "version" || typeof policy[lang] === "string") continue;
|
||||
if (agreedUrlSet.has(policy[lang].url)) {
|
||||
policyAgreed = true;
|
||||
break;
|
||||
@@ -154,7 +145,7 @@ export async function startTermsFlow(
|
||||
const urlsForService = Array.from(agreedUrlSet).filter((url) => {
|
||||
for (const policy of Object.values(policiesAndService.policies)) {
|
||||
for (const lang of Object.keys(policy)) {
|
||||
if (lang === "version") continue;
|
||||
if (lang === "version" || typeof policy[lang] === "string") continue;
|
||||
if (policy[lang].url === url) return true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -332,7 +332,7 @@ export default class CreateSecretStorageDialog extends React.PureComponent<IProp
|
||||
setupNewKeyBackup: !backupInfo,
|
||||
});
|
||||
}
|
||||
await initialiseDehydration(true);
|
||||
await initialiseDehydration({ createNewKey: true });
|
||||
|
||||
this.setState({
|
||||
phase: Phase.Stored,
|
||||
|
||||
@@ -50,7 +50,7 @@ import ThemeController from "../../settings/controllers/ThemeController";
|
||||
import { startAnyRegistrationFlow } from "../../Registration";
|
||||
import ResizeNotifier from "../../utils/ResizeNotifier";
|
||||
import AutoDiscoveryUtils from "../../utils/AutoDiscoveryUtils";
|
||||
import ThemeWatcher from "../../settings/watchers/ThemeWatcher";
|
||||
import ThemeWatcher, { ThemeWatcherEvent } from "../../settings/watchers/ThemeWatcher";
|
||||
import { FontWatcher } from "../../settings/watchers/FontWatcher";
|
||||
import { storeRoomAliasInCache } from "../../RoomAliasCache";
|
||||
import ToastStore from "../../stores/ToastStore";
|
||||
@@ -131,6 +131,7 @@ import { ConfirmSessionLockTheftView } from "./auth/ConfirmSessionLockTheftView"
|
||||
import { LoginSplashView } from "./auth/LoginSplashView";
|
||||
import { cleanUpDraftsIfRequired } from "../../DraftCleaner";
|
||||
import { InitialCryptoSetupStore } from "../../stores/InitialCryptoSetupStore";
|
||||
import { setTheme } from "../../theme";
|
||||
|
||||
// legacy export
|
||||
export { default as Views } from "../../Views";
|
||||
@@ -463,6 +464,7 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
|
||||
this.themeWatcher = new ThemeWatcher();
|
||||
this.fontWatcher = new FontWatcher();
|
||||
this.themeWatcher.start();
|
||||
this.themeWatcher.on(ThemeWatcherEvent.Change, setTheme);
|
||||
this.fontWatcher.start();
|
||||
|
||||
initSentry(SdkConfig.get("sentry"));
|
||||
@@ -495,6 +497,7 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
|
||||
public componentWillUnmount(): void {
|
||||
Lifecycle.stopMatrixClient();
|
||||
dis.unregister(this.dispatcherRef);
|
||||
this.themeWatcher?.off(ThemeWatcherEvent.Change, setTheme);
|
||||
this.themeWatcher?.stop();
|
||||
this.fontWatcher?.stop();
|
||||
UIStore.destroy();
|
||||
@@ -1695,13 +1698,6 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
|
||||
if (crypto) {
|
||||
const blacklistEnabled = SettingsStore.getValueAt(SettingLevel.DEVICE, "blacklistUnverifiedDevices");
|
||||
crypto.globalBlacklistUnverifiedDevices = blacklistEnabled;
|
||||
|
||||
// With cross-signing enabled, we send to unknown devices
|
||||
// without prompting. Any bad-device status the user should
|
||||
// be aware of will be signalled through the room shield
|
||||
// changing colour. More advanced behaviour will come once
|
||||
// we implement more settings.
|
||||
cli.setGlobalErrorOnUnknownDevices(false);
|
||||
}
|
||||
|
||||
// Cannot be done in OnLoggedIn as at that point the AccountSettingsHandler doesn't yet have a client
|
||||
|
||||
@@ -66,7 +66,7 @@ import RoomPreviewBar from "../views/rooms/RoomPreviewBar";
|
||||
import RoomPreviewCard from "../views/rooms/RoomPreviewCard";
|
||||
import RoomUpgradeWarningBar from "../views/rooms/RoomUpgradeWarningBar";
|
||||
import AuxPanel from "../views/rooms/AuxPanel";
|
||||
import RoomHeader from "../views/rooms/RoomHeader";
|
||||
import RoomHeader from "../views/rooms/RoomHeader/RoomHeader";
|
||||
import { IOOBData, IThreepidInvite } from "../../stores/ThreepidInviteStore";
|
||||
import EffectsOverlay from "../views/elements/EffectsOverlay";
|
||||
import { containsEmoji } from "../../effects/utils";
|
||||
|
||||
@@ -129,10 +129,10 @@ export const ThreadPanelHeader: React.FC<{
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="mx_BaseCard_header_title">
|
||||
<div className="mx_ThreadPanelHeader">
|
||||
<Tooltip label={_t("threads|mark_all_read")}>
|
||||
<IconButton onClick={onMarkAllThreadsReadClick} size="24px">
|
||||
<MarkAllThreadsReadIcon />
|
||||
<IconButton onClick={onMarkAllThreadsReadClick} size="28px">
|
||||
<MarkAllThreadsReadIcon height={20} width={20} />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<div className="mx_ThreadPanel_vertical_separator" />
|
||||
@@ -192,9 +192,7 @@ const ThreadPanel: React.FC<IProps> = ({ roomId, onClose, permalinkCreator }) =>
|
||||
narrow={narrow}
|
||||
>
|
||||
<BaseCard
|
||||
header={
|
||||
hasThreads && <ThreadPanelHeader filterOption={filterOption} setFilterOption={setFilterOption} />
|
||||
}
|
||||
header={_t("common|threads")}
|
||||
id="thread-panel"
|
||||
className="mx_ThreadPanel"
|
||||
ariaLabelledBy="thread-panel-tab"
|
||||
@@ -204,6 +202,7 @@ const ThreadPanel: React.FC<IProps> = ({ roomId, onClose, permalinkCreator }) =>
|
||||
ref={card}
|
||||
closeButtonRef={closeButonRef}
|
||||
>
|
||||
{hasThreads && <ThreadPanelHeader filterOption={filterOption} setFilterOption={setFilterOption} />}
|
||||
<Measured sensor={card} onMeasurement={setNarrow} />
|
||||
{timelineSet ? (
|
||||
<TimelinePanel
|
||||
|
||||
@@ -11,7 +11,7 @@ import { MatrixEvent } from "matrix-js-sdk/src/matrix";
|
||||
|
||||
import ResizeNotifier from "../../utils/ResizeNotifier";
|
||||
import ErrorBoundary from "../views/elements/ErrorBoundary";
|
||||
import RoomHeader from "../views/rooms/RoomHeader";
|
||||
import RoomHeader from "../views/rooms/RoomHeader/RoomHeader.tsx";
|
||||
import ScrollPanel from "./ScrollPanel";
|
||||
import EventTileBubble from "../views/messages/EventTileBubble";
|
||||
import NewRoomIntro from "../views/rooms/NewRoomIntro";
|
||||
|
||||
@@ -7,14 +7,14 @@ Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import classNames from "classnames";
|
||||
import React, { useMemo } from "react";
|
||||
import React, { ComponentProps, JSXElementConstructor, useMemo } from "react";
|
||||
|
||||
type FlexProps = {
|
||||
type FlexProps<T extends keyof JSX.IntrinsicElements | JSXElementConstructor<any>> = {
|
||||
/**
|
||||
* The type of the HTML element
|
||||
* @default div
|
||||
*/
|
||||
as?: string;
|
||||
as?: T;
|
||||
/**
|
||||
* The CSS class name.
|
||||
*/
|
||||
@@ -30,7 +30,7 @@ type FlexProps = {
|
||||
*/
|
||||
direction?: "row" | "column" | "row-reverse" | "column-reverse";
|
||||
/**
|
||||
* The alingment of the flex children
|
||||
* The alignment of the flex children
|
||||
* @default start
|
||||
*/
|
||||
align?: "start" | "center" | "end" | "baseline" | "stretch";
|
||||
@@ -48,12 +48,12 @@ type FlexProps = {
|
||||
* the on click event callback
|
||||
*/
|
||||
onClick?: (e: React.MouseEvent) => void;
|
||||
};
|
||||
} & ComponentProps<T>;
|
||||
|
||||
/**
|
||||
* A flexbox container helper
|
||||
*/
|
||||
export function Flex({
|
||||
export function Flex<T extends keyof JSX.IntrinsicElements | JSXElementConstructor<any> = "div">({
|
||||
as = "div",
|
||||
display = "flex",
|
||||
direction = "row",
|
||||
@@ -63,7 +63,7 @@ export function Flex({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.PropsWithChildren<FlexProps>): JSX.Element {
|
||||
}: React.PropsWithChildren<FlexProps<T>>): JSX.Element {
|
||||
const style = useMemo(
|
||||
() => ({
|
||||
"--mx-flex-display": display,
|
||||
|
||||
@@ -145,7 +145,7 @@ export function useMemberTileViewModel(props: MemberTileViewModelProps): MemberT
|
||||
userLabel = _t(PowerLabel[powerStatus]);
|
||||
}
|
||||
if (props.member.isInvite) {
|
||||
userLabel = `(${_t("member_list|invited_label")})`;
|
||||
userLabel = _t("member_list|invited_label");
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
@@ -30,7 +30,7 @@ export function useThreePidTileViewModel(props: ThreePidTileViewModelProps): Thr
|
||||
});
|
||||
};
|
||||
|
||||
const userLabel = `(${_t("member_list|invited_label")})`;
|
||||
const userLabel = _t("member_list|invited_label");
|
||||
|
||||
return {
|
||||
name,
|
||||
|
||||
@@ -0,0 +1,192 @@
|
||||
/*
|
||||
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 { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { EventType, MatrixEvent, Room, RoomMember, RoomStateEvent } from "matrix-js-sdk/src/matrix";
|
||||
import { CryptoApi, CryptoEvent } from "matrix-js-sdk/src/crypto-api";
|
||||
import { throttle } from "lodash";
|
||||
import { logger } from "matrix-js-sdk/src/logger";
|
||||
|
||||
import { useMatrixClientContext } from "../../../contexts/MatrixClientContext.tsx";
|
||||
import { useTypedEventEmitter } from "../../../hooks/useEventEmitter.ts";
|
||||
|
||||
export type ViolationType = "PinViolation" | "VerificationViolation";
|
||||
|
||||
/**
|
||||
* Represents a prompt to the user about a violation in the room.
|
||||
* The type of violation and the member it relates to are included.
|
||||
* If the type is "VerificationViolation", the warning is critical and should be reported with more urgency.
|
||||
*/
|
||||
export type ViolationPrompt = {
|
||||
member: RoomMember;
|
||||
type: ViolationType;
|
||||
};
|
||||
|
||||
/**
|
||||
* The state of the UserIdentityWarningViewModel.
|
||||
* This includes the current prompt to show to the user and a callback to handle button clicks.
|
||||
* If currentPrompt is undefined, there are no violations to show.
|
||||
*/
|
||||
export interface UserIdentityWarningState {
|
||||
currentPrompt?: ViolationPrompt;
|
||||
dispatchAction: (action: UserIdentityWarningViewModelAction) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* List of actions that can be dispatched to the UserIdentityWarningViewModel.
|
||||
*/
|
||||
export type UserIdentityWarningViewModelAction =
|
||||
| { type: "PinUserIdentity"; userId: string }
|
||||
| { type: "WithdrawVerification"; userId: string };
|
||||
|
||||
/**
|
||||
* Maps a list of room members to a list of violations.
|
||||
* Checks for all members in the room to see if they have any violations.
|
||||
* If no violations are found, an empty list is returned.
|
||||
*
|
||||
* @param cryptoApi
|
||||
* @param members - The list of room members to check for violations.
|
||||
*/
|
||||
async function mapToViolations(cryptoApi: CryptoApi, members: RoomMember[]): Promise<ViolationPrompt[]> {
|
||||
const violationList = new Array<ViolationPrompt>();
|
||||
for (const member of members) {
|
||||
const verificationStatus = await cryptoApi.getUserVerificationStatus(member.userId);
|
||||
if (verificationStatus.wasCrossSigningVerified() && !verificationStatus.isCrossSigningVerified()) {
|
||||
violationList.push({ member, type: "VerificationViolation" });
|
||||
} else if (verificationStatus.needsUserApproval) {
|
||||
violationList.push({ member, type: "PinViolation" });
|
||||
}
|
||||
}
|
||||
return violationList;
|
||||
}
|
||||
|
||||
export function useUserIdentityWarningViewModel(room: Room, key: string): UserIdentityWarningState {
|
||||
const cli = useMatrixClientContext();
|
||||
const crypto = cli.getCrypto();
|
||||
|
||||
const [members, setMembers] = useState<RoomMember[]>([]);
|
||||
const [currentPrompt, setCurrentPrompt] = useState<ViolationPrompt | undefined>(undefined);
|
||||
|
||||
const loadViolations = useMemo(
|
||||
() =>
|
||||
throttle(async (): Promise<void> => {
|
||||
const isEncrypted = crypto && (await crypto.isEncryptionEnabledInRoom(room.roomId));
|
||||
if (!isEncrypted) {
|
||||
setMembers([]);
|
||||
setCurrentPrompt(undefined);
|
||||
return;
|
||||
}
|
||||
|
||||
const targetMembers = await room.getEncryptionTargetMembers();
|
||||
setMembers(targetMembers);
|
||||
const violations = await mapToViolations(crypto, targetMembers);
|
||||
|
||||
let candidatePrompt: ViolationPrompt | undefined;
|
||||
if (violations.length > 0) {
|
||||
// sort by user ID to ensure consistent ordering
|
||||
const sortedViolations = violations.sort((a, b) => a.member.userId.localeCompare(b.member.userId));
|
||||
candidatePrompt = sortedViolations[0];
|
||||
} else {
|
||||
candidatePrompt = undefined;
|
||||
}
|
||||
|
||||
// is the current prompt still valid?
|
||||
setCurrentPrompt((existingPrompt): ViolationPrompt | undefined => {
|
||||
if (existingPrompt && violations.includes(existingPrompt)) {
|
||||
return existingPrompt;
|
||||
} else if (candidatePrompt) {
|
||||
return candidatePrompt;
|
||||
} else {
|
||||
return undefined;
|
||||
}
|
||||
});
|
||||
}),
|
||||
[crypto, room],
|
||||
);
|
||||
|
||||
// We need to listen for changes to the members list
|
||||
useTypedEventEmitter(
|
||||
cli,
|
||||
RoomStateEvent.Events,
|
||||
useCallback(
|
||||
async (event: MatrixEvent): Promise<void> => {
|
||||
if (!crypto || event.getRoomId() !== room.roomId) {
|
||||
return;
|
||||
}
|
||||
let shouldRefresh = false;
|
||||
|
||||
const eventType = event.getType();
|
||||
|
||||
if (eventType === EventType.RoomEncryption && event.getStateKey() === "") {
|
||||
// Room is now encrypted, so we can initialise the component.
|
||||
shouldRefresh = true;
|
||||
} else if (eventType == EventType.RoomMember) {
|
||||
// We're processing an m.room.member event
|
||||
// Something has changed in membership, someone joined or someone left or
|
||||
// someone changed their display name. Anyhow let's refresh.
|
||||
const userId = event.getStateKey();
|
||||
shouldRefresh = !!userId;
|
||||
}
|
||||
|
||||
if (shouldRefresh) {
|
||||
loadViolations().catch((e) => {
|
||||
logger.error("Error refreshing UserIdentityWarningViewModel:", e);
|
||||
});
|
||||
}
|
||||
},
|
||||
[crypto, room, loadViolations],
|
||||
),
|
||||
);
|
||||
|
||||
// We need to listen for changes to the verification status of the members to refresh violations
|
||||
useTypedEventEmitter(
|
||||
cli,
|
||||
CryptoEvent.UserTrustStatusChanged,
|
||||
useCallback(
|
||||
(userId: string): void => {
|
||||
if (members.find((m) => m.userId == userId)) {
|
||||
// This member is tracked, we need to refresh.
|
||||
// refresh all for now?
|
||||
// As a later optimisation we could store the current violations and only update the relevant one.
|
||||
loadViolations().catch((e) => {
|
||||
logger.error("Error refreshing UserIdentityWarning:", e);
|
||||
});
|
||||
}
|
||||
},
|
||||
[loadViolations, members],
|
||||
),
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
loadViolations().catch((e) => {
|
||||
logger.error("Error initialising UserIdentityWarning:", e);
|
||||
});
|
||||
}, [loadViolations]);
|
||||
|
||||
const dispatchAction = useCallback(
|
||||
(action: UserIdentityWarningViewModelAction): void => {
|
||||
if (!crypto) {
|
||||
return;
|
||||
}
|
||||
if (action.type === "PinUserIdentity") {
|
||||
crypto.pinCurrentUserIdentity(action.userId).catch((e) => {
|
||||
logger.error("Error pinning user identity:", e);
|
||||
});
|
||||
} else if (action.type === "WithdrawVerification") {
|
||||
crypto.withdrawVerificationRequirement(action.userId).catch((e) => {
|
||||
logger.error("Error withdrawing verification requirement:", e);
|
||||
});
|
||||
}
|
||||
},
|
||||
[crypto],
|
||||
);
|
||||
|
||||
return {
|
||||
currentPrompt,
|
||||
dispatchAction,
|
||||
};
|
||||
}
|
||||
@@ -7,7 +7,7 @@ Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import classNames from "classnames";
|
||||
import { MatrixClient } from "matrix-js-sdk/src/matrix";
|
||||
import { InternationalisedPolicy, Terms, MatrixClient } from "matrix-js-sdk/src/matrix";
|
||||
import { AuthType, AuthDict, IInputs, IStageStatus } from "matrix-js-sdk/src/interactive-auth";
|
||||
import { logger } from "matrix-js-sdk/src/logger";
|
||||
import React, { ChangeEvent, createRef, FormEvent, Fragment } from "react";
|
||||
@@ -16,14 +16,13 @@ import PopOutIcon from "@vector-im/compound-design-tokens/assets/web/icons/pop-o
|
||||
|
||||
import EmailPromptIcon from "../../../../res/img/element-icons/email-prompt.svg";
|
||||
import { _t } from "../../../languageHandler";
|
||||
import SettingsStore from "../../../settings/SettingsStore";
|
||||
import { LocalisedPolicy, Policies } from "../../../Terms";
|
||||
import { AuthHeaderModifier } from "../../structures/auth/header/AuthHeaderModifier";
|
||||
import AccessibleButton, { AccessibleButtonKind, ButtonEvent } from "../elements/AccessibleButton";
|
||||
import Field from "../elements/Field";
|
||||
import Spinner from "../elements/Spinner";
|
||||
import CaptchaForm from "./CaptchaForm";
|
||||
import { Flex } from "../../utils/Flex";
|
||||
import { pickBestPolicyLanguage } from "../../../Terms.ts";
|
||||
|
||||
/* This file contains a collection of components which are used by the
|
||||
* InteractiveAuth to prompt the user to enter the information needed
|
||||
@@ -235,12 +234,10 @@ export class RecaptchaAuthEntry extends React.Component<IRecaptchaAuthEntryProps
|
||||
}
|
||||
|
||||
interface ITermsAuthEntryProps extends IAuthEntryProps {
|
||||
stageParams?: {
|
||||
policies?: Policies;
|
||||
};
|
||||
stageParams?: Partial<Terms>;
|
||||
}
|
||||
|
||||
interface LocalisedPolicyWithId extends LocalisedPolicy {
|
||||
interface LocalisedPolicyWithId extends InternationalisedPolicy {
|
||||
id: string;
|
||||
}
|
||||
|
||||
@@ -278,7 +275,6 @@ export class TermsAuthEntry extends React.Component<ITermsAuthEntryProps, ITerms
|
||||
// }
|
||||
|
||||
const allPolicies = this.props.stageParams?.policies || {};
|
||||
const prefLang = SettingsStore.getValue("language");
|
||||
const initToggles: Record<string, boolean> = {};
|
||||
const pickedPolicies: {
|
||||
id: string;
|
||||
@@ -287,17 +283,7 @@ export class TermsAuthEntry extends React.Component<ITermsAuthEntryProps, ITerms
|
||||
}[] = [];
|
||||
for (const policyId of Object.keys(allPolicies)) {
|
||||
const policy = allPolicies[policyId];
|
||||
|
||||
// Pick a language based on the user's language, falling back to english,
|
||||
// and finally to the first language available. If there's still no policy
|
||||
// available then the homeserver isn't respecting the spec.
|
||||
let langPolicy: LocalisedPolicy | undefined = policy[prefLang];
|
||||
if (!langPolicy) langPolicy = policy["en"];
|
||||
if (!langPolicy) {
|
||||
// last resort
|
||||
const firstLang = Object.keys(policy).find((e) => e !== "version");
|
||||
langPolicy = firstLang ? policy[firstLang] : undefined;
|
||||
}
|
||||
const langPolicy = pickBestPolicyLanguage(policy);
|
||||
if (!langPolicy) throw new Error("Failed to find a policy to show the user");
|
||||
|
||||
initToggles[policyId] = false;
|
||||
|
||||
@@ -24,6 +24,7 @@ import { SettingLevel } from "../../../settings/SettingLevel";
|
||||
import ServerInfo from "./devtools/ServerInfo";
|
||||
import CopyableText from "../elements/CopyableText";
|
||||
import RoomNotifications from "./devtools/RoomNotifications";
|
||||
import { Crypto } from "./devtools/Crypto";
|
||||
|
||||
enum Category {
|
||||
Room,
|
||||
@@ -49,6 +50,7 @@ const Tools: Record<Category, [label: TranslationKey, tool: Tool][]> = {
|
||||
[_td("devtools|explore_account_data"), AccountDataExplorer],
|
||||
[_td("devtools|settings_explorer"), SettingExplorer],
|
||||
[_td("devtools|server_info"), ServerInfo],
|
||||
[_td("devtools|crypto|title"), Crypto],
|
||||
],
|
||||
};
|
||||
|
||||
|
||||
@@ -33,7 +33,7 @@ import { OwnProfileStore } from "../../../stores/OwnProfileStore";
|
||||
import { arrayFastClone } from "../../../utils/arrays";
|
||||
import { ElementWidget } from "../../../stores/widgets/StopGapWidget";
|
||||
import { ELEMENT_CLIENT_ID } from "../../../identifiers";
|
||||
import SettingsStore from "../../../settings/SettingsStore";
|
||||
import ThemeWatcher, { ThemeWatcherEvent } from "../../../settings/watchers/ThemeWatcher";
|
||||
|
||||
interface IProps {
|
||||
widgetDefinition: IModalWidgetOpenRequestData;
|
||||
@@ -54,6 +54,7 @@ export default class ModalWidgetDialog extends React.PureComponent<IProps, IStat
|
||||
private readonly widget: Widget;
|
||||
private readonly possibleButtons: ModalButtonID[];
|
||||
private appFrame: React.RefObject<HTMLIFrameElement> = React.createRef();
|
||||
private readonly themeWatcher = new ThemeWatcher();
|
||||
|
||||
public state: IState = {
|
||||
disabledButtonIds: (this.props.widgetDefinition.buttons || []).filter((b) => b.disabled).map((b) => b.id),
|
||||
@@ -77,6 +78,8 @@ export default class ModalWidgetDialog extends React.PureComponent<IProps, IStat
|
||||
}
|
||||
|
||||
public componentWillUnmount(): void {
|
||||
this.themeWatcher.off(ThemeWatcherEvent.Change, this.onThemeChange);
|
||||
this.themeWatcher.stop();
|
||||
if (!this.state.messaging) return;
|
||||
this.state.messaging.off("ready", this.onReady);
|
||||
this.state.messaging.off(`action:${WidgetApiFromWidgetAction.CloseModalWidget}`, this.onWidgetClose);
|
||||
@@ -84,6 +87,10 @@ export default class ModalWidgetDialog extends React.PureComponent<IProps, IStat
|
||||
}
|
||||
|
||||
private onReady = (): void => {
|
||||
this.themeWatcher.start();
|
||||
this.themeWatcher.on(ThemeWatcherEvent.Change, this.onThemeChange);
|
||||
// Theme may have changed while messaging was starting
|
||||
this.onThemeChange(this.themeWatcher.getEffectiveTheme());
|
||||
this.state.messaging?.sendWidgetConfig(this.props.widgetDefinition);
|
||||
};
|
||||
|
||||
@@ -94,6 +101,10 @@ export default class ModalWidgetDialog extends React.PureComponent<IProps, IStat
|
||||
this.state.messaging.on(`action:${WidgetApiFromWidgetAction.SetModalButtonEnabled}`, this.onButtonEnableToggle);
|
||||
};
|
||||
|
||||
private onThemeChange = (theme: string): void => {
|
||||
this.state.messaging?.updateTheme({ name: theme });
|
||||
};
|
||||
|
||||
private onWidgetClose = (ev: CustomEvent<IModalWidgetCloseRequest>): void => {
|
||||
this.props.onFinished(true, ev.detail.data);
|
||||
};
|
||||
@@ -127,7 +138,7 @@ export default class ModalWidgetDialog extends React.PureComponent<IProps, IStat
|
||||
userDisplayName: OwnProfileStore.instance.displayName ?? undefined,
|
||||
userHttpAvatarUrl: OwnProfileStore.instance.getHttpAvatarUrl() ?? undefined,
|
||||
clientId: ELEMENT_CLIENT_ID,
|
||||
clientTheme: SettingsStore.getValue("theme"),
|
||||
clientTheme: this.themeWatcher.getEffectiveTheme(),
|
||||
clientLanguage: getUserLanguage(),
|
||||
baseUrl: MatrixClientPeg.safeGet().baseUrl,
|
||||
});
|
||||
|
||||
@@ -9,10 +9,10 @@ Please see LICENSE files in the repository root for full details.
|
||||
import React from "react";
|
||||
import { SERVICE_TYPES } from "matrix-js-sdk/src/matrix";
|
||||
|
||||
import { _t, pickBestLanguage } from "../../../languageHandler";
|
||||
import { _t } from "../../../languageHandler";
|
||||
import DialogButtons from "../elements/DialogButtons";
|
||||
import BaseDialog from "./BaseDialog";
|
||||
import { ServicePolicyPair } from "../../../Terms";
|
||||
import { pickBestPolicyLanguage, ServicePolicyPair } from "../../../Terms";
|
||||
import ExternalLink from "../elements/ExternalLink";
|
||||
import { parseUrl } from "../../../utils/UrlUtils";
|
||||
|
||||
@@ -126,8 +126,8 @@ export default class TermsDialog extends React.PureComponent<ITermsDialogProps,
|
||||
|
||||
const policyValues = Object.values(policiesAndService.policies);
|
||||
for (let i = 0; i < policyValues.length; ++i) {
|
||||
const termDoc = policyValues[i];
|
||||
const termsLang = pickBestLanguage(Object.keys(termDoc).filter((k) => k !== "version"));
|
||||
const internationalisedPolicy = pickBestPolicyLanguage(policyValues[i]);
|
||||
if (!internationalisedPolicy) continue;
|
||||
let serviceName: JSX.Element | undefined;
|
||||
let summary: JSX.Element | undefined;
|
||||
if (i === 0) {
|
||||
@@ -136,19 +136,19 @@ export default class TermsDialog extends React.PureComponent<ITermsDialogProps,
|
||||
}
|
||||
|
||||
rows.push(
|
||||
<tr key={termDoc[termsLang].url}>
|
||||
<tr key={internationalisedPolicy.url}>
|
||||
<td className="mx_TermsDialog_service">{serviceName}</td>
|
||||
<td className="mx_TermsDialog_summary">{summary}</td>
|
||||
<td>
|
||||
<ExternalLink rel="noreferrer noopener" target="_blank" href={termDoc[termsLang].url}>
|
||||
{termDoc[termsLang].name}
|
||||
<ExternalLink rel="noreferrer noopener" target="_blank" href={internationalisedPolicy.url}>
|
||||
{internationalisedPolicy.name}
|
||||
</ExternalLink>
|
||||
</td>
|
||||
<td>
|
||||
<TermsCheckbox
|
||||
url={termDoc[termsLang].url}
|
||||
url={internationalisedPolicy.url}
|
||||
onChange={this.onTermsCheckboxChange}
|
||||
checked={Boolean(this.state.agreedUrls[termDoc[termsLang].url])}
|
||||
checked={Boolean(this.state.agreedUrls[internationalisedPolicy.url])}
|
||||
/>
|
||||
</td>
|
||||
</tr>,
|
||||
@@ -164,7 +164,7 @@ export default class TermsDialog extends React.PureComponent<ITermsDialogProps,
|
||||
for (const terms of Object.values(policiesAndService.policies)) {
|
||||
let docAgreed = false;
|
||||
for (const lang of Object.keys(terms)) {
|
||||
if (lang === "version") continue;
|
||||
if (lang === "version" || typeof terms[lang] === "string") continue;
|
||||
if (this.state.agreedUrls[terms[lang].url]) {
|
||||
docAgreed = true;
|
||||
break;
|
||||
|
||||
@@ -50,6 +50,7 @@ import { EncryptionUserSettingsTab } from "../settings/tabs/user/EncryptionUserS
|
||||
interface IProps {
|
||||
initialTabId?: UserTab;
|
||||
showMsc4108QrCode?: boolean;
|
||||
showResetIdentity?: boolean;
|
||||
sdkContext: SdkContextClass;
|
||||
onFinished(): void;
|
||||
}
|
||||
@@ -91,8 +92,9 @@ function titleForTabID(tabId: UserTab): React.ReactNode {
|
||||
export default function UserSettingsDialog(props: IProps): JSX.Element {
|
||||
const voipEnabled = useSettingValue(UIFeature.Voip);
|
||||
const mjolnirEnabled = useSettingValue("feature_mjolnir");
|
||||
// store this prop in state as changing tabs back and forth should clear it
|
||||
// store these props in state as changing tabs back and forth should clear it
|
||||
const [showMsc4108QrCode, setShowMsc4108QrCode] = useState(props.showMsc4108QrCode);
|
||||
const [showResetIdentity, setShowResetIdentity] = useState(props.showResetIdentity);
|
||||
|
||||
const getTabs = (): NonEmptyArray<Tab<UserTab>> => {
|
||||
const tabs: Tab<UserTab>[] = [];
|
||||
@@ -184,7 +186,12 @@ export default function UserSettingsDialog(props: IProps): JSX.Element {
|
||||
);
|
||||
|
||||
tabs.push(
|
||||
new Tab(UserTab.Encryption, _td("settings|encryption|title"), <KeyIcon />, <EncryptionUserSettingsTab />),
|
||||
new Tab(
|
||||
UserTab.Encryption,
|
||||
_td("settings|encryption|title"),
|
||||
<KeyIcon />,
|
||||
<EncryptionUserSettingsTab initialState={showResetIdentity ? "reset_identity_forgot" : undefined} />,
|
||||
),
|
||||
);
|
||||
|
||||
if (showLabsFlags() || SettingsStore.getFeatureSettingNames().some((k) => SettingsStore.getBetaInfo(k))) {
|
||||
@@ -219,8 +226,9 @@ export default function UserSettingsDialog(props: IProps): JSX.Element {
|
||||
const [activeTabId, _setActiveTabId] = useActiveTabWithDefault(getTabs(), UserTab.Account, props.initialTabId);
|
||||
const setActiveTabId = (tabId: UserTab): void => {
|
||||
_setActiveTabId(tabId);
|
||||
// Clear this so switching away from the tab and back to it will not show the QR code again
|
||||
// Clear these so switching away from the tab and back to it will not show the QR code again
|
||||
setShowMsc4108QrCode(false);
|
||||
setShowResetIdentity(false);
|
||||
};
|
||||
|
||||
const [activeToast, toastRack] = useActiveToast();
|
||||
|
||||
@@ -0,0 +1,256 @@
|
||||
/*
|
||||
* 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 React, { JSX } from "react";
|
||||
import { InlineSpinner } from "@vector-im/compound-web";
|
||||
|
||||
import { useMatrixClientContext } from "../../../../contexts/MatrixClientContext";
|
||||
import BaseTool from "./BaseTool";
|
||||
import { useAsyncMemo } from "../../../../hooks/useAsyncMemo";
|
||||
import { _t } from "../../../../languageHandler";
|
||||
|
||||
interface KeyBackupProps {
|
||||
/**
|
||||
* Callback to invoke when the back button is clicked.
|
||||
*/
|
||||
onBack(): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* A component that displays information about the key storage and cross-signing.
|
||||
*/
|
||||
export function Crypto({ onBack }: KeyBackupProps): JSX.Element {
|
||||
const matrixClient = useMatrixClientContext();
|
||||
return (
|
||||
<BaseTool onBack={onBack} className="mx_Crypto">
|
||||
{matrixClient.getCrypto() ? (
|
||||
<>
|
||||
<KeyStorage />
|
||||
<CrossSigning />
|
||||
</>
|
||||
) : (
|
||||
<span>{_t("devtools|crypto|crypto_not_available")}</span>
|
||||
)}
|
||||
</BaseTool>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A component that displays information about the key storage.
|
||||
*/
|
||||
function KeyStorage(): JSX.Element {
|
||||
const matrixClient = useMatrixClientContext();
|
||||
const keyStorageData = useAsyncMemo(async () => {
|
||||
const crypto = matrixClient.getCrypto()!;
|
||||
|
||||
// Get all the key storage data that we will display
|
||||
const backupInfo = await crypto.getKeyBackupInfo();
|
||||
const backupKeyStored = Boolean(await matrixClient.isKeyBackupKeyStored());
|
||||
const backupKeyFromCache = await crypto.getSessionBackupPrivateKey();
|
||||
const backupKeyCached = Boolean(backupKeyFromCache);
|
||||
const backupKeyWellFormed = backupKeyFromCache instanceof Uint8Array;
|
||||
const activeBackupVersion = await crypto.getActiveSessionBackupVersion();
|
||||
const secretStorageKeyInAccount = await matrixClient.secretStorage.hasKey();
|
||||
const secretStorageReady = await crypto.isSecretStorageReady();
|
||||
|
||||
return {
|
||||
backupInfo,
|
||||
backupKeyStored,
|
||||
backupKeyCached,
|
||||
backupKeyWellFormed,
|
||||
activeBackupVersion,
|
||||
secretStorageKeyInAccount,
|
||||
secretStorageReady,
|
||||
};
|
||||
}, [matrixClient]);
|
||||
|
||||
// Show a spinner while loading
|
||||
if (keyStorageData === undefined) return <InlineSpinner aria-label={_t("common|loading")} />;
|
||||
|
||||
const {
|
||||
backupInfo,
|
||||
backupKeyStored,
|
||||
backupKeyCached,
|
||||
backupKeyWellFormed,
|
||||
activeBackupVersion,
|
||||
secretStorageKeyInAccount,
|
||||
secretStorageReady,
|
||||
} = keyStorageData;
|
||||
|
||||
return (
|
||||
<table aria-label={_t("devtools|crypto|key_storage")}>
|
||||
<thead>{_t("devtools|crypto|key_storage")}</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<th scope="row">{_t("devtools|crypto|key_backup_latest_version")}</th>
|
||||
<td>
|
||||
{backupInfo
|
||||
? `${backupInfo.version} (${_t("settings|security|key_backup_algorithm")} ${backupInfo.algorithm})`
|
||||
: _t("devtools|crypto|key_backup_inactive_warning")}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">{_t("devtools|crypto|backup_key_stored_status")}</th>
|
||||
<td>
|
||||
{backupKeyStored
|
||||
? _t("devtools|crypto|backup_key_stored")
|
||||
: _t("devtools|crypto|backup_key_not_stored")}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">{_t("devtools|crypto|key_backup_active_version")}</th>
|
||||
<td>
|
||||
{activeBackupVersion === null
|
||||
? _t("devtools|crypto|key_backup_active_version_none")
|
||||
: activeBackupVersion}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">{_t("devtools|crypto|backup_key_cached_status")}</th>
|
||||
<td>
|
||||
{`${
|
||||
backupKeyCached
|
||||
? _t("devtools|crypto|backup_key_cached")
|
||||
: _t("devtools|crypto|not_found_locally")
|
||||
}, ${
|
||||
backupKeyWellFormed
|
||||
? _t("devtools|crypto|backup_key_well_formed")
|
||||
: _t("devtools|crypto|backup_key_unexpected_type")
|
||||
}`}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">{_t("devtools|crypto|4s_public_key_status")}</th>
|
||||
<td>
|
||||
{secretStorageKeyInAccount
|
||||
? _t("devtools|crypto|4s_public_key_in_account_data")
|
||||
: _t("devtools|crypto|4s_public_key_not_in_account_data")}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">{_t("devtools|crypto|secret_storage_status")}</th>
|
||||
<td>
|
||||
{secretStorageReady
|
||||
? _t("devtools|crypto|secret_storage_ready")
|
||||
: _t("devtools|crypto|secret_storage_not_ready")}
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A component that displays information about cross-signing.
|
||||
*/
|
||||
function CrossSigning(): JSX.Element {
|
||||
const matrixClient = useMatrixClientContext();
|
||||
const crossSigningData = useAsyncMemo(async () => {
|
||||
const crypto = matrixClient.getCrypto()!;
|
||||
|
||||
// Get all the cross-signing data that we will display
|
||||
const crossSigningStatus = await crypto.getCrossSigningStatus();
|
||||
const crossSigningPublicKeysOnDevice = crossSigningStatus.publicKeysOnDevice;
|
||||
const crossSigningPrivateKeysInStorage = crossSigningStatus.privateKeysInSecretStorage;
|
||||
const masterPrivateKeyCached = crossSigningStatus.privateKeysCachedLocally.masterKey;
|
||||
const selfSigningPrivateKeyCached = crossSigningStatus.privateKeysCachedLocally.selfSigningKey;
|
||||
const userSigningPrivateKeyCached = crossSigningStatus.privateKeysCachedLocally.userSigningKey;
|
||||
const crossSigningReady = await crypto.isCrossSigningReady();
|
||||
|
||||
return {
|
||||
crossSigningPublicKeysOnDevice,
|
||||
crossSigningPrivateKeysInStorage,
|
||||
masterPrivateKeyCached,
|
||||
selfSigningPrivateKeyCached,
|
||||
userSigningPrivateKeyCached,
|
||||
crossSigningReady,
|
||||
};
|
||||
}, [matrixClient]);
|
||||
|
||||
// Show a spinner while loading
|
||||
if (crossSigningData === undefined) return <InlineSpinner aria-label={_t("common|loading")} />;
|
||||
|
||||
const {
|
||||
crossSigningPublicKeysOnDevice,
|
||||
crossSigningPrivateKeysInStorage,
|
||||
masterPrivateKeyCached,
|
||||
selfSigningPrivateKeyCached,
|
||||
userSigningPrivateKeyCached,
|
||||
crossSigningReady,
|
||||
} = crossSigningData;
|
||||
|
||||
return (
|
||||
<table aria-label={_t("devtools|crypto|cross_signing")}>
|
||||
<thead>{_t("devtools|crypto|cross_signing")}</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<th scope="row">{_t("devtools|crypto|cross_signing_status")}</th>
|
||||
<td>{getCrossSigningStatus(crossSigningReady, crossSigningPrivateKeysInStorage)}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">{_t("devtools|crypto|cross_signing_public_keys_on_device_status")}</th>
|
||||
<td>
|
||||
{crossSigningPublicKeysOnDevice
|
||||
? _t("devtools|crypto|cross_signing_public_keys_on_device")
|
||||
: _t("devtools|crypto|not_found")}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">{_t("devtools|crypto|cross_signing_private_keys_in_storage_status")}</th>
|
||||
<td>
|
||||
{crossSigningPrivateKeysInStorage
|
||||
? _t("devtools|crypto|cross_signing_private_keys_in_storage")
|
||||
: _t("devtools|crypto|cross_signing_private_keys_not_in_storage")}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">{_t("devtools|crypto|master_private_key_cached_status")}</th>
|
||||
<td>
|
||||
{masterPrivateKeyCached
|
||||
? _t("devtools|crypto|cross_signing_cached")
|
||||
: _t("devtools|crypto|not_found_locally")}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">{_t("devtools|crypto|self_signing_private_key_cached_status")}</th>
|
||||
<td>
|
||||
{selfSigningPrivateKeyCached
|
||||
? _t("devtools|crypto|cross_signing_cached")
|
||||
: _t("devtools|crypto|not_found_locally")}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">{_t("devtools|crypto|user_signing_private_key_cached_status")}</th>
|
||||
<td>
|
||||
{userSigningPrivateKeyCached
|
||||
? _t("devtools|crypto|cross_signing_cached")
|
||||
: _t("devtools|crypto|not_found_locally")}
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the cross-signing status.
|
||||
* @param crossSigningReady Whether cross-signing is ready.
|
||||
* @param crossSigningPrivateKeysInStorage Whether cross-signing private keys are in secret storage.
|
||||
*/
|
||||
function getCrossSigningStatus(crossSigningReady: boolean, crossSigningPrivateKeysInStorage: boolean): string {
|
||||
if (crossSigningReady) {
|
||||
return crossSigningPrivateKeysInStorage
|
||||
? _t("devtools|crypto|cross_signing_ready")
|
||||
: _t("devtools|crypto|cross_signing_untrusted");
|
||||
}
|
||||
|
||||
if (crossSigningPrivateKeysInStorage) {
|
||||
return _t("devtools|crypto|cross_signing_not_ready");
|
||||
}
|
||||
|
||||
return _t("devtools|crypto|cross_signing_not_ready");
|
||||
}
|
||||
@@ -9,9 +9,12 @@ Please see LICENSE files in the repository root for full details.
|
||||
import React, { FC, HTMLAttributes, ReactNode } from "react";
|
||||
import { RoomMember } from "matrix-js-sdk/src/matrix";
|
||||
import { AvatarStack, Tooltip } from "@vector-im/compound-web";
|
||||
import classNames from "classnames";
|
||||
|
||||
import MemberAvatar from "../avatars/MemberAvatar";
|
||||
import AccessibleButton, { ButtonEvent } from "./AccessibleButton";
|
||||
import { useToggled } from "../rooms/RoomHeader/toggle/useToggled";
|
||||
import { RightPanelPhases } from "../../../stores/right-panel/RightPanelStorePhases";
|
||||
|
||||
interface IProps extends Omit<HTMLAttributes<HTMLDivElement>, "onChange"> {
|
||||
members: RoomMember[];
|
||||
@@ -57,8 +60,14 @@ const FacePile: FC<IProps> = ({
|
||||
</>
|
||||
);
|
||||
|
||||
const toggled = useToggled(RightPanelPhases.MemberList);
|
||||
const classes = classNames({
|
||||
mx_FacePile: true,
|
||||
mx_FacePile_toggled: toggled,
|
||||
});
|
||||
|
||||
const content = (
|
||||
<AccessibleButton {...props} className="mx_FacePile" onClick={onClick ?? null}>
|
||||
<AccessibleButton {...props} className={classes} onClick={onClick ?? null}>
|
||||
<AvatarStack>{pileContents}</AvatarStack>
|
||||
{children}
|
||||
</AccessibleButton>
|
||||
|
||||
@@ -25,6 +25,7 @@ export enum PillType {
|
||||
AtRoomMention = "TYPE_AT_ROOM_MENTION", // '@room' mention
|
||||
EventInSameRoom = "TYPE_EVENT_IN_SAME_ROOM",
|
||||
EventInOtherRoom = "TYPE_EVENT_IN_OTHER_ROOM",
|
||||
Keyword = "TYPE_KEYWORD", // Used to highlight keywords that triggered a notification rule
|
||||
}
|
||||
|
||||
export const pillRoomNotifPos = (text: string | null): number => {
|
||||
@@ -76,14 +77,32 @@ export interface PillProps {
|
||||
room?: Room;
|
||||
// Whether to include an avatar in the pill
|
||||
shouldShowPillAvatar?: boolean;
|
||||
// Explicitly-provided text to display in the pill
|
||||
text?: string;
|
||||
}
|
||||
|
||||
export const Pill: React.FC<PillProps> = ({ type: propType, url, inMessage, room, shouldShowPillAvatar = true }) => {
|
||||
const { event, member, onClick, resourceId, targetRoom, text, type } = usePermalink({
|
||||
export const Pill: React.FC<PillProps> = ({
|
||||
type: propType,
|
||||
url,
|
||||
inMessage,
|
||||
room,
|
||||
shouldShowPillAvatar = true,
|
||||
text: customPillText,
|
||||
}) => {
|
||||
const {
|
||||
event,
|
||||
member,
|
||||
onClick,
|
||||
resourceId,
|
||||
targetRoom,
|
||||
text: linkText,
|
||||
type,
|
||||
} = usePermalink({
|
||||
room,
|
||||
type: propType,
|
||||
url,
|
||||
});
|
||||
const text = customPillText ?? linkText;
|
||||
|
||||
if (!type || !text) {
|
||||
return null;
|
||||
@@ -96,6 +115,7 @@ export const Pill: React.FC<PillProps> = ({ type: propType, url, inMessage, room
|
||||
mx_UserPill: type === PillType.UserMention,
|
||||
mx_UserPill_me: resourceId === MatrixClientPeg.safeGet().getUserId(),
|
||||
mx_EventPill: type === PillType.EventInOtherRoom || type === PillType.EventInSameRoom,
|
||||
mx_KeywordPill: type === PillType.Keyword,
|
||||
});
|
||||
|
||||
let avatar: ReactElement | null = null;
|
||||
@@ -131,6 +151,8 @@ export const Pill: React.FC<PillProps> = ({ type: propType, url, inMessage, room
|
||||
case PillType.UserMention:
|
||||
avatar = <PillMemberAvatar shouldShowPillAvatar={shouldShowPillAvatar} member={member} />;
|
||||
break;
|
||||
case PillType.Keyword:
|
||||
break;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -7,8 +7,9 @@ Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import React, { createRef, SyntheticEvent, MouseEvent, StrictMode } from "react";
|
||||
import { MsgType } from "matrix-js-sdk/src/matrix";
|
||||
import { MsgType, PushRuleKind } from "matrix-js-sdk/src/matrix";
|
||||
import { TooltipProvider } from "@vector-im/compound-web";
|
||||
import { globToRegexp } from "matrix-js-sdk/src/utils";
|
||||
|
||||
import * as HtmlUtils from "../../../HtmlUtils";
|
||||
import { formatDate } from "../../../DateUtils";
|
||||
@@ -35,6 +36,7 @@ import { EditWysiwygComposer } from "../rooms/wysiwyg_composer";
|
||||
import { IEventTileOps } from "../rooms/EventTile";
|
||||
import { MatrixClientPeg } from "../../../MatrixClientPeg";
|
||||
import CodeBlock from "./CodeBlock";
|
||||
import { Pill, PillType } from "../elements/Pill";
|
||||
import { ReactRootManager } from "../../../utils/react";
|
||||
|
||||
interface IState {
|
||||
@@ -100,6 +102,16 @@ export default class TextualBody extends React.Component<IBodyProps, IState> {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Highlight notification keywords using pills
|
||||
const pushDetails = this.props.mxEvent.getPushDetails();
|
||||
if (
|
||||
pushDetails.rule?.enabled &&
|
||||
pushDetails.rule.kind === PushRuleKind.ContentSpecific &&
|
||||
pushDetails.rule.pattern
|
||||
) {
|
||||
this.pillifyNotificationKeywords([content], this.regExpForKeywordPattern(pushDetails.rule.pattern));
|
||||
}
|
||||
}
|
||||
|
||||
private addCodeElement(pre: HTMLPreElement): void {
|
||||
@@ -210,6 +222,55 @@ export default class TextualBody extends React.Component<IBodyProps, IState> {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Marks the text that activated a push-notification keyword pattern.
|
||||
*/
|
||||
private pillifyNotificationKeywords(nodes: ArrayLike<Element>, exp: RegExp): void {
|
||||
let node: Node | null = nodes[0];
|
||||
while (node) {
|
||||
if (node.nodeType === Node.TEXT_NODE) {
|
||||
const text = node.nodeValue;
|
||||
if (!text) {
|
||||
node = node.nextSibling;
|
||||
continue;
|
||||
}
|
||||
const match = text.match(exp);
|
||||
if (!match || match.length < 3) {
|
||||
node = node.nextSibling;
|
||||
continue;
|
||||
}
|
||||
const keywordText = match[2];
|
||||
const idx = match.index! + match[1].length;
|
||||
const before = text.substring(0, idx);
|
||||
const after = text.substring(idx + keywordText.length);
|
||||
|
||||
const container = document.createElement("span");
|
||||
const newContent = (
|
||||
<>
|
||||
{before}
|
||||
<TooltipProvider>
|
||||
<Pill text={keywordText} type={PillType.Keyword} />
|
||||
</TooltipProvider>
|
||||
{after}
|
||||
</>
|
||||
);
|
||||
this.reactRoots.render(newContent, container, node);
|
||||
|
||||
node.parentNode?.replaceChild(container, node);
|
||||
} else if (node.childNodes && node.childNodes.length) {
|
||||
this.pillifyNotificationKeywords(node.childNodes as NodeListOf<Element>, exp);
|
||||
}
|
||||
|
||||
node = node.nextSibling;
|
||||
}
|
||||
}
|
||||
|
||||
private regExpForKeywordPattern(pattern: string): RegExp {
|
||||
// Reflects the push notification pattern-matching implementation at
|
||||
// https://github.com/matrix-org/matrix-js-sdk/blob/dbd7d26968b94700827bac525c39afff2c198e61/src/pushprocessor.ts#L570
|
||||
return new RegExp("(^|\\W)(" + globToRegexp(pattern) + ")(\\W|$)", "i");
|
||||
}
|
||||
|
||||
private findLinks(nodes: ArrayLike<Element>): string[] {
|
||||
let links: string[] = [];
|
||||
|
||||
|
||||
@@ -85,6 +85,7 @@ import { asyncSome } from "../../../utils/arrays";
|
||||
import { Flex } from "../../utils/Flex";
|
||||
import CopyableText from "../elements/CopyableText";
|
||||
import { useUserTimezone } from "../../../hooks/useUserTimezone";
|
||||
|
||||
export interface IDevice extends Device {
|
||||
ambiguous?: boolean;
|
||||
}
|
||||
@@ -580,8 +581,10 @@ export const warnSelfDemote = async (isSpace: boolean): Promise<boolean> => {
|
||||
|
||||
const Container: React.FC<{
|
||||
children: ReactNode;
|
||||
}> = ({ children }) => {
|
||||
return <div className="mx_UserInfo_container">{children}</div>;
|
||||
className?: string;
|
||||
}> = ({ children, className }) => {
|
||||
const classes = classNames("mx_UserInfo_container", className);
|
||||
return <div className={classes}>{children}</div>;
|
||||
};
|
||||
|
||||
interface IPowerLevelsContent {
|
||||
@@ -1707,10 +1710,10 @@ export const UserInfoHeader: React.FC<{
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Container>
|
||||
<Container className="mx_UserInfo_header">
|
||||
<Flex direction="column" align="center" className="mx_UserInfo_profile">
|
||||
<Heading size="sm" weight="semibold" as="h1" dir="auto">
|
||||
<Flex direction="row-reverse" align="center">
|
||||
<Flex className="mx_UserInfo_profile_name" direction="row-reverse" align="center">
|
||||
{displayName}
|
||||
{e2eIcon}
|
||||
</Flex>
|
||||
@@ -1718,11 +1721,11 @@ export const UserInfoHeader: React.FC<{
|
||||
{presenceLabel}
|
||||
{timezoneInfo && (
|
||||
<Tooltip label={timezoneInfo?.timezone ?? ""}>
|
||||
<span className="mx_UserInfo_timezone">
|
||||
<Flex align="center" className="mx_UserInfo_timezone">
|
||||
<Text size="sm" weight="regular">
|
||||
{timezoneInfo?.friendly ?? ""}
|
||||
</Text>
|
||||
</span>
|
||||
</Flex>
|
||||
</Tooltip>
|
||||
)}
|
||||
<Text size="sm" weight="semibold" className="mx_UserInfo_profile_mxid">
|
||||
|
||||
@@ -21,6 +21,7 @@ import { ThreePidInviteTileView } from "./tiles/ThreePidInviteTileView";
|
||||
import { MemberListHeaderView } from "./MemberListHeaderView";
|
||||
import BaseCard from "../../right_panel/BaseCard";
|
||||
import { _t } from "../../../../languageHandler";
|
||||
import { RovingTabIndexProvider } from "../../../../accessibility/RovingTabIndex";
|
||||
|
||||
interface IProps {
|
||||
roomId: string;
|
||||
@@ -86,24 +87,33 @@ const MemberListView: React.FC<IProps> = (props: IProps) => {
|
||||
header={_t("common|people")}
|
||||
onClose={props.onClose}
|
||||
>
|
||||
<Flex align="stretch" direction="column" className="mx_MemberListView_container">
|
||||
<Form.Root>
|
||||
<MemberListHeaderView vm={vm} />
|
||||
</Form.Root>
|
||||
<AutoSizer>
|
||||
{({ height, width }) => (
|
||||
<List
|
||||
rowRenderer={rowRenderer}
|
||||
rowHeight={getRowHeight}
|
||||
// The +1 refers to the additional empty div that we render at the end of the list.
|
||||
rowCount={totalRows + 1}
|
||||
// Subtract the height of MemberlistHeaderView so that the parent div does not overflow.
|
||||
height={height - 113}
|
||||
width={width}
|
||||
/>
|
||||
)}
|
||||
</AutoSizer>
|
||||
</Flex>
|
||||
<RovingTabIndexProvider handleUpDown scrollIntoView>
|
||||
{({ onKeyDownHandler }) => (
|
||||
<Flex
|
||||
align="stretch"
|
||||
direction="column"
|
||||
className="mx_MemberListView_container"
|
||||
onKeyDown={onKeyDownHandler}
|
||||
>
|
||||
<Form.Root>
|
||||
<MemberListHeaderView vm={vm} />
|
||||
</Form.Root>
|
||||
<AutoSizer>
|
||||
{({ height, width }) => (
|
||||
<List
|
||||
rowRenderer={rowRenderer}
|
||||
rowHeight={getRowHeight}
|
||||
// The +1 refers to the additional empty div that we render at the end of the list.
|
||||
rowCount={totalRows + 1}
|
||||
// Subtract the height of MemberlistHeaderView so that the parent div does not overflow.
|
||||
height={height - 113}
|
||||
width={width}
|
||||
/>
|
||||
)}
|
||||
</AutoSizer>
|
||||
</Flex>
|
||||
)}
|
||||
</RovingTabIndexProvider>
|
||||
</BaseCard>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -7,7 +7,7 @@ Please see LICENSE files in the repository root for full details.
|
||||
|
||||
import React from "react";
|
||||
|
||||
import AccessibleButton from "../../../../elements/AccessibleButton";
|
||||
import { RovingAccessibleButton } from "../../../../../../accessibility/RovingTabIndex";
|
||||
|
||||
interface Props {
|
||||
avatarJsx: JSX.Element;
|
||||
@@ -28,7 +28,7 @@ export function MemberTileView(props: Props): JSX.Element {
|
||||
return (
|
||||
// The wrapping div is required to make the magic mouse listener work, for some reason.
|
||||
<div>
|
||||
<AccessibleButton className="mx_MemberTileView" title={props.title} onClick={props.onClick}>
|
||||
<RovingAccessibleButton className="mx_MemberTileView" title={props.title} onClick={props.onClick}>
|
||||
<div className="mx_MemberTileView_left">
|
||||
<div className="mx_MemberTileView_avatar">
|
||||
{props.avatarJsx} {props.presenceJsx}
|
||||
@@ -39,7 +39,7 @@ export function MemberTileView(props: Props): JSX.Element {
|
||||
{userLabelJsx}
|
||||
{props.iconJsx}
|
||||
</div>
|
||||
</AccessibleButton>
|
||||
</RovingAccessibleButton>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,408 +0,0 @@
|
||||
/*
|
||||
Copyright 2024 New Vector Ltd.
|
||||
Copyright 2023 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, { useCallback, useMemo, useState } from "react";
|
||||
import { Body as BodyText, Button, IconButton, Menu, MenuItem, Tooltip } from "@vector-im/compound-web";
|
||||
import VideoCallIcon from "@vector-im/compound-design-tokens/assets/web/icons/video-call-solid";
|
||||
import VoiceCallIcon from "@vector-im/compound-design-tokens/assets/web/icons/voice-call";
|
||||
import CloseCallIcon from "@vector-im/compound-design-tokens/assets/web/icons/close";
|
||||
import ThreadsIcon from "@vector-im/compound-design-tokens/assets/web/icons/threads-solid";
|
||||
import RoomInfoIcon from "@vector-im/compound-design-tokens/assets/web/icons/info-solid";
|
||||
import NotificationsIcon from "@vector-im/compound-design-tokens/assets/web/icons/notifications-solid";
|
||||
import VerifiedIcon from "@vector-im/compound-design-tokens/assets/web/icons/verified";
|
||||
import ErrorIcon from "@vector-im/compound-design-tokens/assets/web/icons/error";
|
||||
import PublicIcon from "@vector-im/compound-design-tokens/assets/web/icons/public";
|
||||
import { JoinRule, type Room } from "matrix-js-sdk/src/matrix";
|
||||
import { ViewRoomOpts } from "@matrix-org/react-sdk-module-api/lib/lifecycles/RoomViewLifecycle";
|
||||
|
||||
import { useRoomName } from "../../../hooks/useRoomName";
|
||||
import { RightPanelPhases } from "../../../stores/right-panel/RightPanelStorePhases";
|
||||
import { useMatrixClientContext } from "../../../contexts/MatrixClientContext";
|
||||
import { useRoomMemberCount, useRoomMembers } from "../../../hooks/useRoomMembers";
|
||||
import { _t } from "../../../languageHandler";
|
||||
import { Flex } from "../../utils/Flex";
|
||||
import { Box } from "../../utils/Box";
|
||||
import { getPlatformCallTypeProps, useRoomCall } from "../../../hooks/room/useRoomCall";
|
||||
import { useRoomThreadNotifications } from "../../../hooks/room/useRoomThreadNotifications";
|
||||
import { useGlobalNotificationState } from "../../../hooks/useGlobalNotificationState";
|
||||
import SdkConfig from "../../../SdkConfig";
|
||||
import { useFeatureEnabled } from "../../../hooks/useSettings";
|
||||
import { useEncryptionStatus } from "../../../hooks/useEncryptionStatus";
|
||||
import { E2EStatus } from "../../../utils/ShieldUtils";
|
||||
import FacePile from "../elements/FacePile";
|
||||
import { useRoomState } from "../../../hooks/useRoomState";
|
||||
import RoomAvatar from "../avatars/RoomAvatar";
|
||||
import { formatCount } from "../../../utils/FormattingUtils";
|
||||
import RightPanelStore from "../../../stores/right-panel/RightPanelStore";
|
||||
import PosthogTrackers from "../../../PosthogTrackers";
|
||||
import { VideoRoomChatButton } from "./RoomHeader/VideoRoomChatButton";
|
||||
import { RoomKnocksBar } from "./RoomKnocksBar";
|
||||
import { isVideoRoom as calcIsVideoRoom } from "../../../utils/video-rooms";
|
||||
import { notificationLevelToIndicator } from "../../../utils/notifications";
|
||||
import { CallGuestLinkButton } from "./RoomHeader/CallGuestLinkButton";
|
||||
import { ButtonEvent } from "../elements/AccessibleButton";
|
||||
import WithPresenceIndicator, { useDmMember } from "../avatars/WithPresenceIndicator";
|
||||
import { IOOBData } from "../../../stores/ThreepidInviteStore";
|
||||
import { MainSplitContentType } from "../../structures/RoomView";
|
||||
import defaultDispatcher from "../../../dispatcher/dispatcher.ts";
|
||||
import { RoomSettingsTab } from "../dialogs/RoomSettingsDialog.tsx";
|
||||
import { useScopedRoomContext } from "../../../contexts/ScopedRoomContext.tsx";
|
||||
|
||||
export default function RoomHeader({
|
||||
room,
|
||||
additionalButtons,
|
||||
oobData,
|
||||
}: {
|
||||
room: Room;
|
||||
additionalButtons?: ViewRoomOpts["buttons"];
|
||||
oobData?: IOOBData;
|
||||
}): JSX.Element {
|
||||
const client = useMatrixClientContext();
|
||||
|
||||
const roomName = useRoomName(room);
|
||||
const joinRule = useRoomState(room, (state) => state.getJoinRule());
|
||||
|
||||
const members = useRoomMembers(room, 2500);
|
||||
const memberCount = useRoomMemberCount(room, { throttleWait: 2500 });
|
||||
|
||||
const {
|
||||
voiceCallDisabledReason,
|
||||
voiceCallClick,
|
||||
videoCallDisabledReason,
|
||||
videoCallClick,
|
||||
toggleCallMaximized: toggleCall,
|
||||
isViewingCall,
|
||||
isConnectedToCall,
|
||||
hasActiveCallSession,
|
||||
callOptions,
|
||||
showVoiceCallButton,
|
||||
showVideoCallButton,
|
||||
} = useRoomCall(room);
|
||||
|
||||
const groupCallsEnabled = useFeatureEnabled("feature_group_calls");
|
||||
/**
|
||||
* A special mode where only Element Call is used. In this case we want to
|
||||
* hide the voice call button
|
||||
*/
|
||||
const useElementCallExclusively = useMemo(() => {
|
||||
return SdkConfig.get("element_call").use_exclusively && groupCallsEnabled;
|
||||
}, [groupCallsEnabled]);
|
||||
|
||||
const threadNotifications = useRoomThreadNotifications(room);
|
||||
const globalNotificationState = useGlobalNotificationState();
|
||||
|
||||
const dmMember = useDmMember(room);
|
||||
const isDirectMessage = !!dmMember;
|
||||
const e2eStatus = useEncryptionStatus(client, room);
|
||||
|
||||
const notificationsEnabled = useFeatureEnabled("feature_notifications");
|
||||
|
||||
const askToJoinEnabled = useFeatureEnabled("feature_ask_to_join");
|
||||
|
||||
const videoClick = useCallback(
|
||||
(ev: React.MouseEvent) => videoCallClick(ev, callOptions[0]),
|
||||
[callOptions, videoCallClick],
|
||||
);
|
||||
|
||||
const toggleCallButton = (
|
||||
<Tooltip label={isViewingCall ? _t("voip|minimise_call") : _t("voip|maximise_call")}>
|
||||
<IconButton onClick={toggleCall}>
|
||||
<VideoCallIcon />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
);
|
||||
|
||||
const joinCallButton = (
|
||||
<Tooltip label={videoCallDisabledReason ?? _t("voip|video_call")}>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={videoClick}
|
||||
Icon={VideoCallIcon}
|
||||
className="mx_RoomHeader_join_button"
|
||||
disabled={!!videoCallDisabledReason}
|
||||
color="primary"
|
||||
aria-label={videoCallDisabledReason ?? _t("action|join")}
|
||||
>
|
||||
{_t("action|join")}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
);
|
||||
|
||||
const callIconWithTooltip = (
|
||||
<Tooltip label={videoCallDisabledReason ?? _t("voip|video_call")}>
|
||||
<VideoCallIcon />
|
||||
</Tooltip>
|
||||
);
|
||||
|
||||
const [menuOpen, setMenuOpen] = useState(false);
|
||||
|
||||
const onOpenChange = useCallback(
|
||||
(newOpen: boolean) => {
|
||||
if (!videoCallDisabledReason) setMenuOpen(newOpen);
|
||||
},
|
||||
[videoCallDisabledReason],
|
||||
);
|
||||
|
||||
const startVideoCallButton = (
|
||||
<>
|
||||
{/* Can be either a menu or just a button depending on the number of call options.*/}
|
||||
{callOptions.length > 1 ? (
|
||||
<Menu
|
||||
open={menuOpen}
|
||||
onOpenChange={onOpenChange}
|
||||
title={_t("voip|video_call_using")}
|
||||
trigger={
|
||||
<IconButton
|
||||
disabled={!!videoCallDisabledReason}
|
||||
aria-label={videoCallDisabledReason ?? _t("voip|video_call")}
|
||||
>
|
||||
{callIconWithTooltip}
|
||||
</IconButton>
|
||||
}
|
||||
side="left"
|
||||
align="start"
|
||||
>
|
||||
{callOptions.map((option) => {
|
||||
const { label, children } = getPlatformCallTypeProps(option);
|
||||
return (
|
||||
<MenuItem
|
||||
key={option}
|
||||
label={label}
|
||||
aria-label={label}
|
||||
children={children}
|
||||
className="mx_RoomHeader_videoCallOption"
|
||||
onClick={(ev) => videoCallClick(ev, option)}
|
||||
Icon={VideoCallIcon}
|
||||
onSelect={() => {} /* Dummy handler since we want the click event.*/}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</Menu>
|
||||
) : (
|
||||
<IconButton
|
||||
disabled={!!videoCallDisabledReason}
|
||||
aria-label={videoCallDisabledReason ?? _t("voip|video_call")}
|
||||
onClick={videoClick}
|
||||
>
|
||||
{callIconWithTooltip}
|
||||
</IconButton>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
let voiceCallButton: JSX.Element | undefined = (
|
||||
<Tooltip label={voiceCallDisabledReason ?? _t("voip|voice_call")}>
|
||||
<IconButton
|
||||
// We need both: isViewingCall and isConnectedToCall
|
||||
// - in the Lobby we are viewing a call but are not connected to it.
|
||||
// - in pip view we are connected to the call but not viewing it.
|
||||
disabled={!!voiceCallDisabledReason || isViewingCall || isConnectedToCall}
|
||||
aria-label={voiceCallDisabledReason ?? _t("voip|voice_call")}
|
||||
onClick={(ev) => voiceCallClick(ev, callOptions[0])}
|
||||
>
|
||||
<VoiceCallIcon />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
);
|
||||
const closeLobbyButton = (
|
||||
<Tooltip label={_t("voip|close_lobby")}>
|
||||
<IconButton onClick={toggleCall}>
|
||||
<CloseCallIcon />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
);
|
||||
let videoCallButton: JSX.Element | undefined = startVideoCallButton;
|
||||
if (isConnectedToCall) {
|
||||
videoCallButton = toggleCallButton;
|
||||
} else if (isViewingCall) {
|
||||
videoCallButton = closeLobbyButton;
|
||||
}
|
||||
|
||||
if (!showVideoCallButton) {
|
||||
videoCallButton = undefined;
|
||||
}
|
||||
if (!showVoiceCallButton) {
|
||||
voiceCallButton = undefined;
|
||||
}
|
||||
|
||||
const roomContext = useScopedRoomContext("mainSplitContentType");
|
||||
const isVideoRoom = calcIsVideoRoom(room);
|
||||
const showChatButton =
|
||||
isVideoRoom ||
|
||||
roomContext.mainSplitContentType === MainSplitContentType.MaximisedWidget ||
|
||||
roomContext.mainSplitContentType === MainSplitContentType.Call;
|
||||
|
||||
const onAvatarClick = (): void => {
|
||||
defaultDispatcher.dispatch({
|
||||
action: "open_room_settings",
|
||||
initial_tab_id: RoomSettingsTab.General,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Flex as="header" align="center" gap="var(--cpd-space-3x)" className="mx_RoomHeader light-panel">
|
||||
<WithPresenceIndicator room={room} size="8px">
|
||||
{/* We hide this from the tabIndex list as it is a pointer shortcut and superfluous for a11y */}
|
||||
<RoomAvatar
|
||||
room={room}
|
||||
size="40px"
|
||||
oobData={oobData}
|
||||
onClick={onAvatarClick}
|
||||
tabIndex={-1}
|
||||
aria-label={_t("room|header_avatar_open_settings_label")}
|
||||
/>
|
||||
</WithPresenceIndicator>
|
||||
<button
|
||||
aria-label={_t("right_panel|room_summary_card|title")}
|
||||
tabIndex={0}
|
||||
onClick={() => RightPanelStore.instance.showOrHidePhase(RightPanelPhases.RoomSummary)}
|
||||
className="mx_RoomHeader_infoWrapper"
|
||||
>
|
||||
<Box flex="1" className="mx_RoomHeader_info">
|
||||
<BodyText
|
||||
as="div"
|
||||
size="lg"
|
||||
weight="semibold"
|
||||
dir="auto"
|
||||
role="heading"
|
||||
aria-level={1}
|
||||
className="mx_RoomHeader_heading"
|
||||
>
|
||||
<span className="mx_RoomHeader_truncated mx_lineClamp">{roomName}</span>
|
||||
|
||||
{!isDirectMessage && joinRule === JoinRule.Public && (
|
||||
<Tooltip label={_t("common|public_room")} placement="right">
|
||||
<PublicIcon
|
||||
width="16px"
|
||||
height="16px"
|
||||
className="mx_RoomHeader_icon text-secondary"
|
||||
aria-label={_t("common|public_room")}
|
||||
/>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
{isDirectMessage && e2eStatus === E2EStatus.Verified && (
|
||||
<Tooltip label={_t("common|verified")} placement="right">
|
||||
<VerifiedIcon
|
||||
width="16px"
|
||||
height="16px"
|
||||
className="mx_RoomHeader_icon mx_Verified"
|
||||
aria-label={_t("common|verified")}
|
||||
/>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
{isDirectMessage && e2eStatus === E2EStatus.Warning && (
|
||||
<Tooltip label={_t("room|header_untrusted_label")} placement="right">
|
||||
<ErrorIcon
|
||||
width="16px"
|
||||
height="16px"
|
||||
className="mx_RoomHeader_icon mx_Untrusted"
|
||||
aria-label={_t("room|header_untrusted_label")}
|
||||
/>
|
||||
</Tooltip>
|
||||
)}
|
||||
</BodyText>
|
||||
</Box>
|
||||
</button>
|
||||
|
||||
{additionalButtons?.map((props) => {
|
||||
const label = props.label();
|
||||
|
||||
return (
|
||||
<Tooltip label={label} key={props.id}>
|
||||
<IconButton
|
||||
aria-label={label}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
props.onClick();
|
||||
}}
|
||||
>
|
||||
{typeof props.icon === "function" ? props.icon() : props.icon}
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
);
|
||||
})}
|
||||
|
||||
{isViewingCall && <CallGuestLinkButton room={room} />}
|
||||
|
||||
{hasActiveCallSession && !isConnectedToCall && !isViewingCall ? (
|
||||
joinCallButton
|
||||
) : (
|
||||
<>
|
||||
{!isVideoRoom && videoCallButton}
|
||||
{!useElementCallExclusively && !isVideoRoom && voiceCallButton}
|
||||
</>
|
||||
)}
|
||||
|
||||
{showChatButton && <VideoRoomChatButton room={room} />}
|
||||
|
||||
<Tooltip label={_t("common|threads")}>
|
||||
<IconButton
|
||||
indicator={notificationLevelToIndicator(threadNotifications)}
|
||||
onClick={(evt) => {
|
||||
evt.stopPropagation();
|
||||
RightPanelStore.instance.showOrHidePhase(RightPanelPhases.ThreadPanel);
|
||||
PosthogTrackers.trackInteraction("WebRoomHeaderButtonsThreadsButton", evt);
|
||||
}}
|
||||
aria-label={_t("common|threads")}
|
||||
>
|
||||
<ThreadsIcon />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
{notificationsEnabled && (
|
||||
<Tooltip label={_t("notifications|enable_prompt_toast_title")}>
|
||||
<IconButton
|
||||
indicator={notificationLevelToIndicator(globalNotificationState.level)}
|
||||
onClick={(evt) => {
|
||||
evt.stopPropagation();
|
||||
RightPanelStore.instance.showOrHidePhase(RightPanelPhases.NotificationPanel);
|
||||
}}
|
||||
aria-label={_t("notifications|enable_prompt_toast_title")}
|
||||
>
|
||||
<NotificationsIcon />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
<Tooltip label={_t("right_panel|room_summary_card|title")}>
|
||||
<IconButton
|
||||
onClick={(evt) => {
|
||||
evt.stopPropagation();
|
||||
RightPanelStore.instance.showOrHidePhase(RightPanelPhases.RoomSummary);
|
||||
}}
|
||||
aria-label={_t("right_panel|room_summary_card|title")}
|
||||
>
|
||||
<RoomInfoIcon />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
|
||||
{!isDirectMessage && (
|
||||
<BodyText as="div" size="sm" weight="medium">
|
||||
<FacePile
|
||||
className="mx_RoomHeader_members"
|
||||
members={members.slice(0, 3)}
|
||||
size="20px"
|
||||
overflow={false}
|
||||
viewUserOnClick={false}
|
||||
tooltipLabel={_t("room|header_face_pile_tooltip")}
|
||||
onClick={(e: ButtonEvent) => {
|
||||
RightPanelStore.instance.showOrHidePhase(RightPanelPhases.MemberList);
|
||||
e.stopPropagation();
|
||||
}}
|
||||
aria-label={_t("common|n_members", { count: memberCount })}
|
||||
>
|
||||
{formatCount(memberCount)}
|
||||
</FacePile>
|
||||
</BodyText>
|
||||
)}
|
||||
</Flex>
|
||||
{askToJoinEnabled && <RoomKnocksBar room={room} />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,412 @@
|
||||
/*
|
||||
Copyright 2024 New Vector Ltd.
|
||||
Copyright 2023 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, { useCallback, useMemo, useState } from "react";
|
||||
import { Body as BodyText, Button, IconButton, Menu, MenuItem, Tooltip } from "@vector-im/compound-web";
|
||||
import VideoCallIcon from "@vector-im/compound-design-tokens/assets/web/icons/video-call-solid";
|
||||
import VoiceCallIcon from "@vector-im/compound-design-tokens/assets/web/icons/voice-call";
|
||||
import CloseCallIcon from "@vector-im/compound-design-tokens/assets/web/icons/close";
|
||||
import ThreadsIcon from "@vector-im/compound-design-tokens/assets/web/icons/threads-solid";
|
||||
import RoomInfoIcon from "@vector-im/compound-design-tokens/assets/web/icons/info-solid";
|
||||
import NotificationsIcon from "@vector-im/compound-design-tokens/assets/web/icons/notifications-solid";
|
||||
import VerifiedIcon from "@vector-im/compound-design-tokens/assets/web/icons/verified";
|
||||
import ErrorIcon from "@vector-im/compound-design-tokens/assets/web/icons/error";
|
||||
import PublicIcon from "@vector-im/compound-design-tokens/assets/web/icons/public";
|
||||
import { JoinRule, type Room } from "matrix-js-sdk/src/matrix";
|
||||
import { ViewRoomOpts } from "@matrix-org/react-sdk-module-api/lib/lifecycles/RoomViewLifecycle";
|
||||
|
||||
import { useRoomName } from "../../../../hooks/useRoomName.ts";
|
||||
import { RightPanelPhases } from "../../../../stores/right-panel/RightPanelStorePhases.ts";
|
||||
import { useMatrixClientContext } from "../../../../contexts/MatrixClientContext.tsx";
|
||||
import { useRoomMemberCount, useRoomMembers } from "../../../../hooks/useRoomMembers.ts";
|
||||
import { _t } from "../../../../languageHandler.tsx";
|
||||
import { Flex } from "../../../utils/Flex.tsx";
|
||||
import { Box } from "../../../utils/Box.tsx";
|
||||
import { getPlatformCallTypeProps, useRoomCall } from "../../../../hooks/room/useRoomCall.tsx";
|
||||
import { useRoomThreadNotifications } from "../../../../hooks/room/useRoomThreadNotifications.ts";
|
||||
import { useGlobalNotificationState } from "../../../../hooks/useGlobalNotificationState.ts";
|
||||
import SdkConfig from "../../../../SdkConfig.ts";
|
||||
import { useFeatureEnabled } from "../../../../hooks/useSettings.ts";
|
||||
import { useEncryptionStatus } from "../../../../hooks/useEncryptionStatus.ts";
|
||||
import { E2EStatus } from "../../../../utils/ShieldUtils.ts";
|
||||
import FacePile from "../../elements/FacePile.tsx";
|
||||
import { useRoomState } from "../../../../hooks/useRoomState.ts";
|
||||
import RoomAvatar from "../../avatars/RoomAvatar.tsx";
|
||||
import { formatCount } from "../../../../utils/FormattingUtils.ts";
|
||||
import RightPanelStore from "../../../../stores/right-panel/RightPanelStore.ts";
|
||||
import PosthogTrackers from "../../../../PosthogTrackers.ts";
|
||||
import { VideoRoomChatButton } from "./VideoRoomChatButton.tsx";
|
||||
import { RoomKnocksBar } from "../RoomKnocksBar.tsx";
|
||||
import { isVideoRoom as calcIsVideoRoom } from "../../../../utils/video-rooms.ts";
|
||||
import { notificationLevelToIndicator } from "../../../../utils/notifications.ts";
|
||||
import { CallGuestLinkButton } from "./CallGuestLinkButton.tsx";
|
||||
import { ButtonEvent } from "../../elements/AccessibleButton.tsx";
|
||||
import WithPresenceIndicator, { useDmMember } from "../../avatars/WithPresenceIndicator.tsx";
|
||||
import { IOOBData } from "../../../../stores/ThreepidInviteStore.ts";
|
||||
import { MainSplitContentType } from "../../../structures/RoomView.tsx";
|
||||
import defaultDispatcher from "../../../../dispatcher/dispatcher.ts";
|
||||
import { RoomSettingsTab } from "../../dialogs/RoomSettingsDialog.tsx";
|
||||
import { useScopedRoomContext } from "../../../../contexts/ScopedRoomContext.tsx";
|
||||
import { ToggleableIcon } from "./toggle/ToggleableIcon.tsx";
|
||||
import { CurrentRightPanelPhaseContextProvider } from "../../../../contexts/CurrentRightPanelPhaseContext.tsx";
|
||||
|
||||
export default function RoomHeader({
|
||||
room,
|
||||
additionalButtons,
|
||||
oobData,
|
||||
}: {
|
||||
room: Room;
|
||||
additionalButtons?: ViewRoomOpts["buttons"];
|
||||
oobData?: IOOBData;
|
||||
}): JSX.Element {
|
||||
const client = useMatrixClientContext();
|
||||
|
||||
const roomName = useRoomName(room);
|
||||
const joinRule = useRoomState(room, (state) => state.getJoinRule());
|
||||
|
||||
const members = useRoomMembers(room, 2500);
|
||||
const memberCount = useRoomMemberCount(room, { throttleWait: 2500 });
|
||||
|
||||
const {
|
||||
voiceCallDisabledReason,
|
||||
voiceCallClick,
|
||||
videoCallDisabledReason,
|
||||
videoCallClick,
|
||||
toggleCallMaximized: toggleCall,
|
||||
isViewingCall,
|
||||
isConnectedToCall,
|
||||
hasActiveCallSession,
|
||||
callOptions,
|
||||
showVoiceCallButton,
|
||||
showVideoCallButton,
|
||||
} = useRoomCall(room);
|
||||
|
||||
const groupCallsEnabled = useFeatureEnabled("feature_group_calls");
|
||||
/**
|
||||
* A special mode where only Element Call is used. In this case we want to
|
||||
* hide the voice call button
|
||||
*/
|
||||
const useElementCallExclusively = useMemo(() => {
|
||||
return SdkConfig.get("element_call").use_exclusively && groupCallsEnabled;
|
||||
}, [groupCallsEnabled]);
|
||||
|
||||
const threadNotifications = useRoomThreadNotifications(room);
|
||||
const globalNotificationState = useGlobalNotificationState();
|
||||
|
||||
const dmMember = useDmMember(room);
|
||||
const isDirectMessage = !!dmMember;
|
||||
const e2eStatus = useEncryptionStatus(client, room);
|
||||
|
||||
const notificationsEnabled = useFeatureEnabled("feature_notifications");
|
||||
|
||||
const askToJoinEnabled = useFeatureEnabled("feature_ask_to_join");
|
||||
|
||||
const videoClick = useCallback(
|
||||
(ev: React.MouseEvent) => videoCallClick(ev, callOptions[0]),
|
||||
[callOptions, videoCallClick],
|
||||
);
|
||||
|
||||
const toggleCallButton = (
|
||||
<Tooltip label={isViewingCall ? _t("voip|minimise_call") : _t("voip|maximise_call")}>
|
||||
<IconButton onClick={toggleCall}>
|
||||
<VideoCallIcon />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
);
|
||||
|
||||
const joinCallButton = (
|
||||
<Tooltip label={videoCallDisabledReason ?? _t("voip|video_call")}>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={videoClick}
|
||||
Icon={VideoCallIcon}
|
||||
className="mx_RoomHeader_join_button"
|
||||
disabled={!!videoCallDisabledReason}
|
||||
color="primary"
|
||||
aria-label={videoCallDisabledReason ?? _t("action|join")}
|
||||
>
|
||||
{_t("action|join")}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
);
|
||||
|
||||
const callIconWithTooltip = (
|
||||
<Tooltip label={videoCallDisabledReason ?? _t("voip|video_call")}>
|
||||
<VideoCallIcon />
|
||||
</Tooltip>
|
||||
);
|
||||
|
||||
const [menuOpen, setMenuOpen] = useState(false);
|
||||
|
||||
const onOpenChange = useCallback(
|
||||
(newOpen: boolean) => {
|
||||
if (!videoCallDisabledReason) setMenuOpen(newOpen);
|
||||
},
|
||||
[videoCallDisabledReason],
|
||||
);
|
||||
|
||||
const startVideoCallButton = (
|
||||
<>
|
||||
{/* Can be either a menu or just a button depending on the number of call options.*/}
|
||||
{callOptions.length > 1 ? (
|
||||
<Menu
|
||||
open={menuOpen}
|
||||
onOpenChange={onOpenChange}
|
||||
title={_t("voip|video_call_using")}
|
||||
trigger={
|
||||
<IconButton
|
||||
disabled={!!videoCallDisabledReason}
|
||||
aria-label={videoCallDisabledReason ?? _t("voip|video_call")}
|
||||
>
|
||||
{callIconWithTooltip}
|
||||
</IconButton>
|
||||
}
|
||||
side="left"
|
||||
align="start"
|
||||
>
|
||||
{callOptions.map((option) => {
|
||||
const { label, children } = getPlatformCallTypeProps(option);
|
||||
return (
|
||||
<MenuItem
|
||||
key={option}
|
||||
label={label}
|
||||
aria-label={label}
|
||||
children={children}
|
||||
className="mx_RoomHeader_videoCallOption"
|
||||
onClick={(ev) => videoCallClick(ev, option)}
|
||||
Icon={VideoCallIcon}
|
||||
onSelect={() => {} /* Dummy handler since we want the click event.*/}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</Menu>
|
||||
) : (
|
||||
<IconButton
|
||||
disabled={!!videoCallDisabledReason}
|
||||
aria-label={videoCallDisabledReason ?? _t("voip|video_call")}
|
||||
onClick={videoClick}
|
||||
>
|
||||
{callIconWithTooltip}
|
||||
</IconButton>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
let voiceCallButton: JSX.Element | undefined = (
|
||||
<Tooltip label={voiceCallDisabledReason ?? _t("voip|voice_call")}>
|
||||
<IconButton
|
||||
// We need both: isViewingCall and isConnectedToCall
|
||||
// - in the Lobby we are viewing a call but are not connected to it.
|
||||
// - in pip view we are connected to the call but not viewing it.
|
||||
disabled={!!voiceCallDisabledReason || isViewingCall || isConnectedToCall}
|
||||
aria-label={voiceCallDisabledReason ?? _t("voip|voice_call")}
|
||||
onClick={(ev) => voiceCallClick(ev, callOptions[0])}
|
||||
>
|
||||
<VoiceCallIcon />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
);
|
||||
const closeLobbyButton = (
|
||||
<Tooltip label={_t("voip|close_lobby")}>
|
||||
<IconButton onClick={toggleCall}>
|
||||
<CloseCallIcon />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
);
|
||||
let videoCallButton: JSX.Element | undefined = startVideoCallButton;
|
||||
if (isConnectedToCall) {
|
||||
videoCallButton = toggleCallButton;
|
||||
} else if (isViewingCall) {
|
||||
videoCallButton = closeLobbyButton;
|
||||
}
|
||||
|
||||
if (!showVideoCallButton) {
|
||||
videoCallButton = undefined;
|
||||
}
|
||||
if (!showVoiceCallButton) {
|
||||
voiceCallButton = undefined;
|
||||
}
|
||||
|
||||
const roomContext = useScopedRoomContext("mainSplitContentType");
|
||||
const isVideoRoom = calcIsVideoRoom(room);
|
||||
const showChatButton =
|
||||
isVideoRoom ||
|
||||
roomContext.mainSplitContentType === MainSplitContentType.MaximisedWidget ||
|
||||
roomContext.mainSplitContentType === MainSplitContentType.Call;
|
||||
|
||||
const onAvatarClick = (): void => {
|
||||
defaultDispatcher.dispatch({
|
||||
action: "open_room_settings",
|
||||
initial_tab_id: RoomSettingsTab.General,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<CurrentRightPanelPhaseContextProvider roomId={room.roomId}>
|
||||
<Flex as="header" align="center" gap="var(--cpd-space-3x)" className="mx_RoomHeader light-panel">
|
||||
<WithPresenceIndicator room={room} size="8px">
|
||||
{/* We hide this from the tabIndex list as it is a pointer shortcut and superfluous for a11y */}
|
||||
<RoomAvatar
|
||||
room={room}
|
||||
size="40px"
|
||||
oobData={oobData}
|
||||
onClick={onAvatarClick}
|
||||
tabIndex={-1}
|
||||
aria-label={_t("room|header_avatar_open_settings_label")}
|
||||
/>
|
||||
</WithPresenceIndicator>
|
||||
<button
|
||||
aria-label={_t("right_panel|room_summary_card|title")}
|
||||
tabIndex={0}
|
||||
onClick={() => RightPanelStore.instance.showOrHidePhase(RightPanelPhases.RoomSummary)}
|
||||
className="mx_RoomHeader_infoWrapper"
|
||||
>
|
||||
<Box flex="1" className="mx_RoomHeader_info">
|
||||
<BodyText
|
||||
as="div"
|
||||
size="lg"
|
||||
weight="semibold"
|
||||
dir="auto"
|
||||
role="heading"
|
||||
aria-level={1}
|
||||
className="mx_RoomHeader_heading"
|
||||
>
|
||||
<span className="mx_RoomHeader_truncated mx_lineClamp">{roomName}</span>
|
||||
|
||||
{!isDirectMessage && joinRule === JoinRule.Public && (
|
||||
<Tooltip label={_t("common|public_room")} placement="right">
|
||||
<PublicIcon
|
||||
width="16px"
|
||||
height="16px"
|
||||
className="mx_RoomHeader_icon text-secondary"
|
||||
aria-label={_t("common|public_room")}
|
||||
/>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
{isDirectMessage && e2eStatus === E2EStatus.Verified && (
|
||||
<Tooltip label={_t("common|verified")} placement="right">
|
||||
<VerifiedIcon
|
||||
width="16px"
|
||||
height="16px"
|
||||
className="mx_RoomHeader_icon mx_Verified"
|
||||
aria-label={_t("common|verified")}
|
||||
/>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
{isDirectMessage && e2eStatus === E2EStatus.Warning && (
|
||||
<Tooltip label={_t("room|header_untrusted_label")} placement="right">
|
||||
<ErrorIcon
|
||||
width="16px"
|
||||
height="16px"
|
||||
className="mx_RoomHeader_icon mx_Untrusted"
|
||||
aria-label={_t("room|header_untrusted_label")}
|
||||
/>
|
||||
</Tooltip>
|
||||
)}
|
||||
</BodyText>
|
||||
</Box>
|
||||
</button>
|
||||
|
||||
{additionalButtons?.map((props) => {
|
||||
const label = props.label();
|
||||
|
||||
return (
|
||||
<Tooltip label={label} key={props.id}>
|
||||
<IconButton
|
||||
aria-label={label}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
props.onClick();
|
||||
}}
|
||||
>
|
||||
{typeof props.icon === "function" ? props.icon() : props.icon}
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
);
|
||||
})}
|
||||
|
||||
{isViewingCall && <CallGuestLinkButton room={room} />}
|
||||
|
||||
{hasActiveCallSession && !isConnectedToCall && !isViewingCall ? (
|
||||
joinCallButton
|
||||
) : (
|
||||
<>
|
||||
{!isVideoRoom && videoCallButton}
|
||||
{!useElementCallExclusively && !isVideoRoom && voiceCallButton}
|
||||
</>
|
||||
)}
|
||||
|
||||
{showChatButton && <VideoRoomChatButton room={room} />}
|
||||
|
||||
<Tooltip label={_t("common|threads")}>
|
||||
<IconButton
|
||||
indicator={notificationLevelToIndicator(threadNotifications)}
|
||||
onClick={(evt) => {
|
||||
evt.stopPropagation();
|
||||
RightPanelStore.instance.showOrHidePhase(RightPanelPhases.ThreadPanel);
|
||||
PosthogTrackers.trackInteraction("WebRoomHeaderButtonsThreadsButton", evt);
|
||||
}}
|
||||
aria-label={_t("common|threads")}
|
||||
>
|
||||
<ToggleableIcon Icon={ThreadsIcon} phase={RightPanelPhases.ThreadPanel} />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
{notificationsEnabled && (
|
||||
<Tooltip label={_t("notifications|enable_prompt_toast_title")}>
|
||||
<IconButton
|
||||
indicator={notificationLevelToIndicator(globalNotificationState.level)}
|
||||
onClick={(evt) => {
|
||||
evt.stopPropagation();
|
||||
RightPanelStore.instance.showOrHidePhase(RightPanelPhases.NotificationPanel);
|
||||
}}
|
||||
aria-label={_t("notifications|enable_prompt_toast_title")}
|
||||
>
|
||||
<ToggleableIcon Icon={NotificationsIcon} phase={RightPanelPhases.NotificationPanel} />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
<Tooltip label={_t("right_panel|room_summary_card|title")}>
|
||||
<IconButton
|
||||
onClick={(evt) => {
|
||||
evt.stopPropagation();
|
||||
RightPanelStore.instance.showOrHidePhase(RightPanelPhases.RoomSummary);
|
||||
}}
|
||||
aria-label={_t("right_panel|room_summary_card|title")}
|
||||
>
|
||||
<ToggleableIcon Icon={RoomInfoIcon} phase={RightPanelPhases.RoomSummary} />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
|
||||
{!isDirectMessage && (
|
||||
<BodyText as="div" size="sm" weight="medium">
|
||||
<FacePile
|
||||
className="mx_RoomHeader_members"
|
||||
members={members.slice(0, 3)}
|
||||
size="20px"
|
||||
overflow={false}
|
||||
viewUserOnClick={false}
|
||||
tooltipLabel={_t("room|header_face_pile_tooltip")}
|
||||
onClick={(e: ButtonEvent) => {
|
||||
RightPanelStore.instance.showOrHidePhase(RightPanelPhases.MemberList);
|
||||
e.stopPropagation();
|
||||
}}
|
||||
aria-label={_t("common|n_members", { count: memberCount })}
|
||||
>
|
||||
{formatCount(memberCount)}
|
||||
</FacePile>
|
||||
</BodyText>
|
||||
)}
|
||||
</Flex>
|
||||
{askToJoinEnabled && <RoomKnocksBar room={room} />}
|
||||
</CurrentRightPanelPhaseContextProvider>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -18,6 +18,7 @@ import { NotificationLevel } from "../../../../stores/notifications/Notification
|
||||
import { RightPanelPhases } from "../../../../stores/right-panel/RightPanelStorePhases";
|
||||
import { SDKContext } from "../../../../contexts/SDKContext";
|
||||
import { ButtonEvent } from "../../elements/AccessibleButton";
|
||||
import { ToggleableIcon } from "./toggle/ToggleableIcon";
|
||||
|
||||
/**
|
||||
* Display a button to toggle timeline for video rooms
|
||||
@@ -54,7 +55,7 @@ export const VideoRoomChatButton: React.FC<{ room: Room }> = ({ room }) => {
|
||||
onClick={onClick}
|
||||
indicator={displayUnreadIndicator ? "default" : undefined}
|
||||
>
|
||||
<ChatIcon />
|
||||
<ToggleableIcon Icon={ChatIcon} phase={RightPanelPhases.Timeline} />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
/*
|
||||
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 React from "react";
|
||||
import classNames from "classnames";
|
||||
|
||||
import { RightPanelPhases } from "../../../../../stores/right-panel/RightPanelStorePhases";
|
||||
import { useToggled } from "./useToggled";
|
||||
|
||||
type Props = {
|
||||
Icon: React.ComponentType<React.SVGProps<SVGSVGElement>>;
|
||||
phase: RightPanelPhases;
|
||||
};
|
||||
|
||||
/**
|
||||
* Use this component for room header icons that toggle different right panel phases.
|
||||
* Will add a class to the icon when the specified phase is on.
|
||||
*/
|
||||
export function ToggleableIcon({ Icon, phase }: Props): React.ReactElement {
|
||||
const toggled = useToggled(phase);
|
||||
const highlightClass = classNames({
|
||||
mx_RoomHeader_toggled: toggled,
|
||||
});
|
||||
|
||||
return <Icon className={highlightClass} />;
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
/*
|
||||
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 { useContext } from "react";
|
||||
|
||||
import { RightPanelPhases } from "../../../../../stores/right-panel/RightPanelStorePhases";
|
||||
import { CurrentRightPanelPhaseContext } from "../../../../../contexts/CurrentRightPanelPhaseContext";
|
||||
|
||||
/**
|
||||
* Hook to easily track whether a given right panel phase is toggled on/off.
|
||||
*/
|
||||
export function useToggled(phase: RightPanelPhases): boolean {
|
||||
const context = useContext(CurrentRightPanelPhaseContext);
|
||||
if (!context) {
|
||||
return false;
|
||||
}
|
||||
const { currentPhase, isPanelOpen } = context;
|
||||
return !!(isPanelOpen && currentPhase === phase);
|
||||
}
|
||||
@@ -5,16 +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.
|
||||
*/
|
||||
|
||||
import React, { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { EventType, KnownMembership, MatrixEvent, Room, RoomStateEvent, RoomMember } from "matrix-js-sdk/src/matrix";
|
||||
import { CryptoApi, CryptoEvent, UserVerificationStatus } from "matrix-js-sdk/src/crypto-api";
|
||||
import { logger } from "matrix-js-sdk/src/logger";
|
||||
import React from "react";
|
||||
import { Room, RoomMember } from "matrix-js-sdk/src/matrix";
|
||||
import { Button, Separator } from "@vector-im/compound-web";
|
||||
import classNames from "classnames";
|
||||
|
||||
import { _t } from "../../../languageHandler";
|
||||
import MemberAvatar from "../avatars/MemberAvatar";
|
||||
import { useMatrixClientContext } from "../../../contexts/MatrixClientContext";
|
||||
import { useTypedEventEmitter } from "../../../hooks/useEventEmitter";
|
||||
import {
|
||||
useUserIdentityWarningViewModel,
|
||||
ViolationPrompt,
|
||||
} from "../../viewmodels/rooms/UserIdentityWarningViewModel.tsx";
|
||||
import { ButtonEvent } from "../elements/AccessibleButton.tsx";
|
||||
|
||||
interface UserIdentityWarningProps {
|
||||
/**
|
||||
@@ -28,24 +30,6 @@ interface UserIdentityWarningProps {
|
||||
key: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Does the given user's identity need to be approved?
|
||||
*/
|
||||
async function userNeedsApproval(crypto: CryptoApi, userId: string): Promise<boolean> {
|
||||
const verificationStatus = await crypto.getUserVerificationStatus(userId);
|
||||
return verificationStatus.needsUserApproval;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the component is uninitialised, is in the process of initialising, or
|
||||
* has completed initialising.
|
||||
*/
|
||||
enum InitialisationStatus {
|
||||
Uninitialised,
|
||||
Initialising,
|
||||
Completed,
|
||||
}
|
||||
|
||||
/**
|
||||
* Displays a banner warning when there is an issue with a user's identity.
|
||||
*
|
||||
@@ -53,283 +37,101 @@ enum InitialisationStatus {
|
||||
* button to acknowledge the change.
|
||||
*/
|
||||
export const UserIdentityWarning: React.FC<UserIdentityWarningProps> = ({ room }) => {
|
||||
const cli = useMatrixClientContext();
|
||||
const crypto = cli.getCrypto();
|
||||
const { currentPrompt, dispatchAction } = useUserIdentityWarningViewModel(room, room.roomId);
|
||||
|
||||
// The current room member that we are prompting the user to approve.
|
||||
// `undefined` means we are not currently showing a prompt.
|
||||
const [currentPrompt, setCurrentPrompt] = useState<RoomMember | undefined>(undefined);
|
||||
if (!currentPrompt) return null;
|
||||
|
||||
// Whether or not we've already initialised the component by loading the
|
||||
// room membership.
|
||||
const initialisedRef = useRef<InitialisationStatus>(InitialisationStatus.Uninitialised);
|
||||
// Which room members need their identity approved.
|
||||
const membersNeedingApprovalRef = useRef<Map<string, RoomMember>>(new Map());
|
||||
// For each user, we assign a sequence number to each verification status
|
||||
// that we get, or fetch.
|
||||
//
|
||||
// Since fetching a verification status is asynchronous, we could get an
|
||||
// update in the middle of fetching the verification status, which could
|
||||
// mean that the status that we fetched is out of date. So if the current
|
||||
// sequence number is not the same as the sequence number when we started
|
||||
// the fetch, then we drop our fetched result, under the assumption that the
|
||||
// update that we received is the most up-to-date version. If it is in fact
|
||||
// not the most up-to-date version, then we should be receiving a new update
|
||||
// soon with the newer value, so it will fix itself in the end.
|
||||
//
|
||||
// We also assign a sequence number when the user leaves the room, in order
|
||||
// to prevent prompting about a user who leaves while we are fetching their
|
||||
// verification status.
|
||||
const verificationStatusSequencesRef = useRef<Map<string, number>>(new Map());
|
||||
const incrementVerificationStatusSequence = (userId: string): number => {
|
||||
const verificationStatusSequences = verificationStatusSequencesRef.current;
|
||||
const value = verificationStatusSequences.get(userId);
|
||||
const newValue = value === undefined ? 1 : value + 1;
|
||||
verificationStatusSequences.set(userId, newValue);
|
||||
return newValue;
|
||||
const [title, action] = getTitleAndAction(currentPrompt);
|
||||
|
||||
const onButtonClick = (ev: ButtonEvent): void => {
|
||||
ev.preventDefault();
|
||||
if (currentPrompt.type === "VerificationViolation") {
|
||||
dispatchAction({ type: "WithdrawVerification", userId: currentPrompt.member.userId });
|
||||
} else {
|
||||
dispatchAction({ type: "PinUserIdentity", userId: currentPrompt.member.userId });
|
||||
}
|
||||
};
|
||||
|
||||
// Update the current prompt. Select a new user if needed, or hide the
|
||||
// warning if we don't have anyone to warn about.
|
||||
const updateCurrentPrompt = useCallback((): undefined => {
|
||||
const membersNeedingApproval = membersNeedingApprovalRef.current;
|
||||
// We have to do this in a callback to `setCurrentPrompt`
|
||||
// because this function could have been called after an
|
||||
// `await`, and the `currentPrompt` that this function would
|
||||
// have may be outdated.
|
||||
setCurrentPrompt((currentPrompt) => {
|
||||
// If we're already displaying a warning, and that user still needs
|
||||
// approval, continue showing that user.
|
||||
if (currentPrompt && membersNeedingApproval.has(currentPrompt.userId)) return currentPrompt;
|
||||
|
||||
if (membersNeedingApproval.size === 0) {
|
||||
if (currentPrompt) {
|
||||
// If we were previously showing a warning, log that we've stopped doing so.
|
||||
logger.debug("UserIdentityWarning: no users left that need approval");
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// We pick the user with the smallest user ID.
|
||||
const keys = Array.from(membersNeedingApproval.keys()).sort((a, b) => a.localeCompare(b));
|
||||
const selection = membersNeedingApproval.get(keys[0]!);
|
||||
logger.debug(`UserIdentityWarning: now warning about user ${selection?.userId}`);
|
||||
return selection;
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Add a user to the membersNeedingApproval map, and update the current
|
||||
// prompt if necessary. The user will only be added if they are actually a
|
||||
// member of the room. If they are not a member, this function will do
|
||||
// nothing.
|
||||
const addMemberNeedingApproval = useCallback(
|
||||
(userId: string, member?: RoomMember): void => {
|
||||
if (userId === cli.getUserId()) {
|
||||
// We always skip our own user, because we can't pin our own identity.
|
||||
return;
|
||||
}
|
||||
member = member ?? room.getMember(userId) ?? undefined;
|
||||
if (!member) return;
|
||||
|
||||
membersNeedingApprovalRef.current.set(userId, member);
|
||||
// We only select the prompt if we are done initialising,
|
||||
// because we will select the prompt after we're done
|
||||
// initialising, and we want to start by displaying a warning
|
||||
// for the user with the smallest ID.
|
||||
if (initialisedRef.current === InitialisationStatus.Completed) {
|
||||
logger.debug(
|
||||
`UserIdentityWarning: user ${userId} now needs approval; approval-pending list now [${Array.from(membersNeedingApprovalRef.current.keys())}]`,
|
||||
);
|
||||
updateCurrentPrompt();
|
||||
}
|
||||
},
|
||||
[cli, room, updateCurrentPrompt],
|
||||
return warningBanner(
|
||||
currentPrompt.type === "VerificationViolation",
|
||||
memberAvatar(currentPrompt.member),
|
||||
title,
|
||||
action,
|
||||
onButtonClick,
|
||||
);
|
||||
};
|
||||
|
||||
// For each user in the list check if their identity needs approval, and if
|
||||
// so, add them to the membersNeedingApproval map and update the prompt if
|
||||
// needed.
|
||||
const addMembersWhoNeedApproval = useCallback(
|
||||
async (members: RoomMember[]): Promise<void> => {
|
||||
const verificationStatusSequences = verificationStatusSequencesRef.current;
|
||||
|
||||
const promises: Promise<void>[] = [];
|
||||
|
||||
for (const member of members) {
|
||||
const userId = member.userId;
|
||||
const sequenceNum = incrementVerificationStatusSequence(userId);
|
||||
promises.push(
|
||||
userNeedsApproval(crypto!, userId).then((needsApproval) => {
|
||||
if (needsApproval) {
|
||||
// Only actually update the list if we have the most
|
||||
// recent value.
|
||||
if (verificationStatusSequences.get(userId) === sequenceNum) {
|
||||
addMemberNeedingApproval(userId, member);
|
||||
}
|
||||
}
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
await Promise.all(promises);
|
||||
},
|
||||
[crypto, addMemberNeedingApproval],
|
||||
);
|
||||
|
||||
// Remove a user from the membersNeedingApproval map, and update the current
|
||||
// prompt if necessary.
|
||||
const removeMemberNeedingApproval = useCallback(
|
||||
(userId: string): void => {
|
||||
membersNeedingApprovalRef.current.delete(userId);
|
||||
logger.debug(
|
||||
`UserIdentityWarning: user ${userId} no longer needs approval; approval-pending list now [${Array.from(membersNeedingApprovalRef.current.keys())}]`,
|
||||
function getTitleAndAction(prompt: ViolationPrompt): [title: React.ReactNode, action: string] {
|
||||
let title: React.ReactNode;
|
||||
let action: string;
|
||||
if (prompt.type === "VerificationViolation") {
|
||||
if (prompt.member.rawDisplayName === prompt.member.userId) {
|
||||
title = _t(
|
||||
"encryption|verified_identity_changed_no_displayname",
|
||||
{ userId: prompt.member.userId },
|
||||
{
|
||||
a: substituteATag,
|
||||
b: substituteBTag,
|
||||
},
|
||||
);
|
||||
} else {
|
||||
title = _t(
|
||||
"encryption|verified_identity_changed",
|
||||
{ displayName: prompt.member.rawDisplayName, userId: prompt.member.userId },
|
||||
{
|
||||
a: substituteATag,
|
||||
b: substituteBTag,
|
||||
},
|
||||
);
|
||||
updateCurrentPrompt();
|
||||
},
|
||||
[updateCurrentPrompt],
|
||||
);
|
||||
|
||||
// Initialise the component. Get the room members, check which ones need
|
||||
// their identity approved, and pick one to display.
|
||||
const loadMembers = useCallback(async (): Promise<void> => {
|
||||
if (!crypto || initialisedRef.current !== InitialisationStatus.Uninitialised) {
|
||||
return;
|
||||
}
|
||||
// If encryption is not enabled in the room, we don't need to do
|
||||
// anything. If encryption gets enabled later, we will retry, via
|
||||
// onRoomStateEvent.
|
||||
if (!(await crypto.isEncryptionEnabledInRoom(room.roomId))) {
|
||||
return;
|
||||
action = _t("encryption|withdraw_verification_action");
|
||||
} else {
|
||||
if (prompt.member.rawDisplayName === prompt.member.userId) {
|
||||
title = _t(
|
||||
"encryption|pinned_identity_changed_no_displayname",
|
||||
{ userId: prompt.member.userId },
|
||||
{
|
||||
a: substituteATag,
|
||||
b: substituteBTag,
|
||||
},
|
||||
);
|
||||
} else {
|
||||
title = _t(
|
||||
"encryption|pinned_identity_changed",
|
||||
{ displayName: prompt.member.rawDisplayName, userId: prompt.member.userId },
|
||||
{
|
||||
a: substituteATag,
|
||||
b: substituteBTag,
|
||||
},
|
||||
);
|
||||
}
|
||||
initialisedRef.current = InitialisationStatus.Initialising;
|
||||
|
||||
const members = await room.getEncryptionTargetMembers();
|
||||
await addMembersWhoNeedApproval(members);
|
||||
|
||||
logger.info(
|
||||
`Initialised UserIdentityWarning component for room ${room.roomId} with approval-pending list [${Array.from(membersNeedingApprovalRef.current.keys())}]`,
|
||||
);
|
||||
updateCurrentPrompt();
|
||||
initialisedRef.current = InitialisationStatus.Completed;
|
||||
}, [crypto, room, addMembersWhoNeedApproval, updateCurrentPrompt]);
|
||||
|
||||
useEffect(() => {
|
||||
loadMembers().catch((e) => {
|
||||
logger.error("Error initialising UserIdentityWarning:", e);
|
||||
});
|
||||
}, [loadMembers]);
|
||||
|
||||
// When a user's verification status changes, we check if they need to be
|
||||
// added/removed from the set of members needing approval.
|
||||
const onUserVerificationStatusChanged = useCallback(
|
||||
(userId: string, verificationStatus: UserVerificationStatus): void => {
|
||||
// If we haven't started initialising, that means that we're in a
|
||||
// room where we don't need to display any warnings.
|
||||
if (initialisedRef.current === InitialisationStatus.Uninitialised) {
|
||||
return;
|
||||
}
|
||||
|
||||
incrementVerificationStatusSequence(userId);
|
||||
|
||||
if (verificationStatus.needsUserApproval) {
|
||||
addMemberNeedingApproval(userId);
|
||||
} else {
|
||||
removeMemberNeedingApproval(userId);
|
||||
}
|
||||
},
|
||||
[addMemberNeedingApproval, removeMemberNeedingApproval],
|
||||
);
|
||||
useTypedEventEmitter(cli, CryptoEvent.UserTrustStatusChanged, onUserVerificationStatusChanged);
|
||||
|
||||
// We watch for encryption events (since we only display warnings in
|
||||
// encrypted rooms), and for membership changes (since we only display
|
||||
// warnings for users in the room).
|
||||
const onRoomStateEvent = useCallback(
|
||||
async (event: MatrixEvent): Promise<void> => {
|
||||
if (!crypto || event.getRoomId() !== room.roomId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const eventType = event.getType();
|
||||
if (eventType === EventType.RoomEncryption && event.getStateKey() === "") {
|
||||
// Room is now encrypted, so we can initialise the component.
|
||||
return loadMembers().catch((e) => {
|
||||
logger.error("Error initialising UserIdentityWarning:", e);
|
||||
});
|
||||
} else if (eventType !== EventType.RoomMember) {
|
||||
return;
|
||||
}
|
||||
|
||||
// We're processing an m.room.member event
|
||||
|
||||
if (initialisedRef.current === InitialisationStatus.Uninitialised) {
|
||||
return;
|
||||
}
|
||||
|
||||
const userId = event.getStateKey();
|
||||
|
||||
if (!userId) return;
|
||||
|
||||
if (
|
||||
event.getContent().membership === KnownMembership.Join ||
|
||||
(event.getContent().membership === KnownMembership.Invite && room.shouldEncryptForInvitedMembers())
|
||||
) {
|
||||
// Someone's membership changed and we will now encrypt to them. If
|
||||
// their identity needs approval, show a warning.
|
||||
const member = room.getMember(userId);
|
||||
if (member) {
|
||||
await addMembersWhoNeedApproval([member]).catch((e) => {
|
||||
logger.error("Error adding member in UserIdentityWarning:", e);
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// Someone's membership changed and we no longer encrypt to them.
|
||||
// If we're showing a warning about them, we don't need to any more.
|
||||
removeMemberNeedingApproval(userId);
|
||||
incrementVerificationStatusSequence(userId);
|
||||
}
|
||||
},
|
||||
[crypto, room, addMembersWhoNeedApproval, removeMemberNeedingApproval, loadMembers],
|
||||
);
|
||||
useTypedEventEmitter(cli, RoomStateEvent.Events, onRoomStateEvent);
|
||||
|
||||
if (!crypto || !currentPrompt) return null;
|
||||
|
||||
const confirmIdentity = async (): Promise<void> => {
|
||||
await crypto.pinCurrentUserIdentity(currentPrompt.userId);
|
||||
};
|
||||
action = _t("action|ok");
|
||||
}
|
||||
return [title, action];
|
||||
}
|
||||
|
||||
function warningBanner(
|
||||
isCritical: boolean,
|
||||
avatar: React.ReactNode,
|
||||
title: React.ReactNode,
|
||||
action: string,
|
||||
onButtonClick: (ev: ButtonEvent) => void,
|
||||
): React.ReactNode {
|
||||
return (
|
||||
<div className="mx_UserIdentityWarning">
|
||||
<div className={classNames("mx_UserIdentityWarning", { critical: isCritical })}>
|
||||
<Separator />
|
||||
<div className="mx_UserIdentityWarning_row">
|
||||
<MemberAvatar member={currentPrompt} title={currentPrompt.userId} size="30px" />
|
||||
<span className="mx_UserIdentityWarning_main">
|
||||
{currentPrompt.rawDisplayName === currentPrompt.userId
|
||||
? _t(
|
||||
"encryption|pinned_identity_changed_no_displayname",
|
||||
{ userId: currentPrompt.userId },
|
||||
{
|
||||
a: substituteATag,
|
||||
b: substituteBTag,
|
||||
},
|
||||
)
|
||||
: _t(
|
||||
"encryption|pinned_identity_changed",
|
||||
{ displayName: currentPrompt.rawDisplayName, userId: currentPrompt.userId },
|
||||
{
|
||||
a: substituteATag,
|
||||
b: substituteBTag,
|
||||
},
|
||||
)}
|
||||
</span>
|
||||
<Button kind="primary" size="sm" onClick={confirmIdentity}>
|
||||
{_t("action|ok")}
|
||||
{avatar}
|
||||
<span className={classNames("mx_UserIdentityWarning_main", { critical: isCritical })}>{title}</span>
|
||||
<Button kind="secondary" size="sm" onClick={onButtonClick}>
|
||||
{action}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
}
|
||||
function memberAvatar(member: RoomMember): React.ReactNode {
|
||||
return <MemberAvatar member={member} title={member.userId} size="30px" />;
|
||||
}
|
||||
|
||||
function substituteATag(sub: string): React.ReactNode {
|
||||
return (
|
||||
|
||||
@@ -31,8 +31,7 @@ export function shouldShowQr(
|
||||
): boolean {
|
||||
const msc4108Supported = !!versions?.unstable_features?.["org.matrix.msc4108"];
|
||||
|
||||
const deviceAuthorizationGrantSupported =
|
||||
oidcClientConfig?.metadata?.grant_types_supported.includes(DEVICE_CODE_SCOPE);
|
||||
const deviceAuthorizationGrantSupported = oidcClientConfig?.grant_types_supported.includes(DEVICE_CODE_SCOPE);
|
||||
|
||||
return (
|
||||
!!deviceAuthorizationGrantSupported &&
|
||||
|
||||
@@ -58,7 +58,7 @@ export const DiscoverySettings: React.FC = () => {
|
||||
agreedUrls: null, // From the startTermsFlow callback
|
||||
resolve: null, // Promise resolve function for startTermsFlow callback
|
||||
});
|
||||
const [hasTerms, setHasTerms] = useState<boolean>(false);
|
||||
const [mustAgreeToTerms, setMustAgreeToTerms] = useState<boolean>(false);
|
||||
|
||||
const getThreepidState = useCallback(async () => {
|
||||
setIsLoadingThreepids(true);
|
||||
@@ -103,7 +103,7 @@ export const DiscoverySettings: React.FC = () => {
|
||||
(policiesAndServices, agreedUrls, extraClassNames) => {
|
||||
return new Promise((resolve) => {
|
||||
setIdServerName(abbreviateUrl(idServerUrl));
|
||||
setHasTerms(true);
|
||||
setMustAgreeToTerms(true);
|
||||
setRequiredPolicyInfo({
|
||||
policiesAndServices,
|
||||
agreedUrls,
|
||||
@@ -113,7 +113,7 @@ export const DiscoverySettings: React.FC = () => {
|
||||
},
|
||||
);
|
||||
// User accepted all terms
|
||||
setHasTerms(false);
|
||||
setMustAgreeToTerms(false);
|
||||
} catch (e) {
|
||||
logger.warn(
|
||||
`Unable to reach identity server at ${idServerUrl} to check ` + `for terms in Settings`,
|
||||
@@ -126,7 +126,7 @@ export const DiscoverySettings: React.FC = () => {
|
||||
|
||||
if (!SettingsStore.getValue(UIFeature.ThirdPartyID)) return null;
|
||||
|
||||
if (hasTerms && requiredPolicyInfo.policiesAndServices) {
|
||||
if (mustAgreeToTerms && requiredPolicyInfo.policiesAndServices) {
|
||||
const intro = (
|
||||
<Alert type="info" title={_t("settings|general|discovery_needs_terms_title")}>
|
||||
{_t("settings|general|discovery_needs_terms", { serverName: idServerName })}
|
||||
@@ -160,7 +160,7 @@ export const DiscoverySettings: React.FC = () => {
|
||||
medium={ThreepidMedium.Email}
|
||||
threepids={emails}
|
||||
onChange={getThreepidState}
|
||||
disabled={!hasTerms}
|
||||
disabled={mustAgreeToTerms}
|
||||
isLoading={isLoadingThreepids}
|
||||
/>
|
||||
</SettingsSubsection>
|
||||
@@ -174,7 +174,7 @@ export const DiscoverySettings: React.FC = () => {
|
||||
medium={ThreepidMedium.Phone}
|
||||
threepids={phoneNumbers}
|
||||
onChange={getThreepidState}
|
||||
disabled={!hasTerms}
|
||||
disabled={mustAgreeToTerms}
|
||||
isLoading={isLoadingThreepids}
|
||||
/>
|
||||
</SettingsSubsection>
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
/*
|
||||
* Copyright 2024 New Vector Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import React, { JSX, lazy, MouseEventHandler } from "react";
|
||||
import { Button, HelpMessage, InlineField, InlineSpinner, Label, Root, ToggleControl } from "@vector-im/compound-web";
|
||||
import DownloadIcon from "@vector-im/compound-design-tokens/assets/web/icons/download";
|
||||
import ShareIcon from "@vector-im/compound-design-tokens/assets/web/icons/share";
|
||||
|
||||
import { _t } from "../../../../languageHandler";
|
||||
import { SettingsSection } from "../shared/SettingsSection";
|
||||
import { useMatrixClientContext } from "../../../../contexts/MatrixClientContext";
|
||||
import { useAsyncMemo } from "../../../../hooks/useAsyncMemo";
|
||||
import Modal from "../../../../Modal";
|
||||
import { SettingLevel } from "../../../../settings/SettingLevel";
|
||||
import { useSettingValueAt } from "../../../../hooks/useSettings";
|
||||
import SettingsStore from "../../../../settings/SettingsStore";
|
||||
|
||||
interface AdvancedPanelProps {
|
||||
/**
|
||||
* Callback for when the user clicks the button to reset their identity.
|
||||
*/
|
||||
onResetIdentityClick: MouseEventHandler<HTMLButtonElement>;
|
||||
}
|
||||
|
||||
/**
|
||||
* The advanced panel of the encryption settings.
|
||||
*/
|
||||
export function AdvancedPanel({ onResetIdentityClick }: AdvancedPanelProps): JSX.Element {
|
||||
return (
|
||||
<SettingsSection heading={_t("settings|encryption|advanced|title")} legacy={false}>
|
||||
<EncryptionDetails onResetIdentityClick={onResetIdentityClick} />
|
||||
<OtherSettings />
|
||||
</SettingsSection>
|
||||
);
|
||||
}
|
||||
|
||||
interface EncryptionDetails {
|
||||
/**
|
||||
* Callback for when the user clicks the button to reset their identity.
|
||||
*/
|
||||
onResetIdentityClick: MouseEventHandler<HTMLButtonElement>;
|
||||
}
|
||||
|
||||
/**
|
||||
* The encryption details section of the advanced panel.
|
||||
*/
|
||||
function EncryptionDetails({ onResetIdentityClick }: EncryptionDetails): JSX.Element {
|
||||
const matrixClient = useMatrixClientContext();
|
||||
// Null when the keys are not loaded yet
|
||||
const keys = useAsyncMemo(() => matrixClient.getCrypto()!.getOwnDeviceKeys(), [matrixClient], null);
|
||||
|
||||
return (
|
||||
<div className="mx_EncryptionDetails" data-testid="encryptionDetails">
|
||||
<div className="mx_EncryptionDetails_session">
|
||||
<h3 className="mx_EncryptionDetails_session_title">
|
||||
{_t("settings|encryption|advanced|details_title")}
|
||||
</h3>
|
||||
<div>
|
||||
<span>{_t("settings|encryption|advanced|session_id")}</span>
|
||||
<span data-testid="deviceId">{matrixClient.deviceId}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span>{_t("settings|encryption|advanced|session_key")}</span>
|
||||
<span data-testid="sessionKey">
|
||||
{keys ? keys.ed25519 : <InlineSpinner aria-label={_t("common|loading")} />}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mx_EncryptionDetails_buttons">
|
||||
<Button
|
||||
size="sm"
|
||||
kind="secondary"
|
||||
Icon={ShareIcon}
|
||||
onClick={() =>
|
||||
Modal.createDialog(
|
||||
lazy(
|
||||
() => import("../../../../async-components/views/dialogs/security/ExportE2eKeysDialog"),
|
||||
),
|
||||
{ matrixClient },
|
||||
)
|
||||
}
|
||||
>
|
||||
{_t("settings|encryption|advanced|export_keys")}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
kind="secondary"
|
||||
Icon={DownloadIcon}
|
||||
onClick={() =>
|
||||
Modal.createDialog(
|
||||
lazy(
|
||||
() => import("../../../../async-components/views/dialogs/security/ImportE2eKeysDialog"),
|
||||
),
|
||||
{ matrixClient },
|
||||
)
|
||||
}
|
||||
>
|
||||
{_t("settings|encryption|advanced|import_keys")}
|
||||
</Button>
|
||||
</div>
|
||||
<Button size="sm" kind="tertiary" destructive={true} onClick={onResetIdentityClick}>
|
||||
{_t("settings|encryption|advanced|reset_identity")}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Display the never send encrypted message to unverified devices setting.
|
||||
*/
|
||||
function OtherSettings(): JSX.Element | null {
|
||||
const blacklistUnverifiedDevices = useSettingValueAt(SettingLevel.DEVICE, "blacklistUnverifiedDevices");
|
||||
const canSetValue = SettingsStore.canSetValue("blacklistUnverifiedDevices", null, SettingLevel.DEVICE);
|
||||
if (!canSetValue) return null;
|
||||
|
||||
return (
|
||||
<Root
|
||||
data-testid="otherSettings"
|
||||
className="mx_OtherSettings"
|
||||
onChange={async (evt) => {
|
||||
const checked = new FormData(evt.currentTarget).get("neverSendEncrypted") === "on";
|
||||
await SettingsStore.setValue("blacklistUnverifiedDevices", null, SettingLevel.DEVICE, checked);
|
||||
}}
|
||||
>
|
||||
<h3 className="mx_OtherSettings_title">{_t("settings|encryption|advanced|other_people_device_title")}</h3>
|
||||
<InlineField
|
||||
name="neverSendEncrypted"
|
||||
control={<ToggleControl name="neverSendEncrypted" defaultChecked={blacklistUnverifiedDevices} />}
|
||||
>
|
||||
<Label>{_t("settings|encryption|advanced|other_people_device_label")}</Label>
|
||||
<HelpMessage>{_t("settings|encryption|advanced|other_people_device_description")}</HelpMessage>
|
||||
</InlineField>
|
||||
</Root>
|
||||
);
|
||||
}
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
TextControl,
|
||||
} from "@vector-im/compound-web";
|
||||
import CopyIcon from "@vector-im/compound-design-tokens/assets/web/icons/copy";
|
||||
import KeyIcon from "@vector-im/compound-design-tokens/assets/web/icons/key-solid";
|
||||
import { logger } from "matrix-js-sdk/src/logger";
|
||||
|
||||
import { _t } from "../../../../languageHandler";
|
||||
@@ -157,7 +158,12 @@ export function ChangeRecoveryKey({
|
||||
pages={pages}
|
||||
onPageClick={onCancelClick}
|
||||
/>
|
||||
<EncryptionCard title={labels.title} description={labels.description} className="mx_ChangeRecoveryKey">
|
||||
<EncryptionCard
|
||||
Icon={KeyIcon}
|
||||
title={labels.title}
|
||||
description={labels.description}
|
||||
className="mx_ChangeRecoveryKey"
|
||||
>
|
||||
{content}
|
||||
</EncryptionCard>
|
||||
</>
|
||||
|
||||
@@ -5,9 +5,8 @@
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import React, { JSX, PropsWithChildren } from "react";
|
||||
import React, { JSX, PropsWithChildren, ComponentType, SVGAttributes } from "react";
|
||||
import { BigIcon, Heading } from "@vector-im/compound-web";
|
||||
import KeyIcon from "@vector-im/compound-design-tokens/assets/web/icons/key-solid";
|
||||
import classNames from "classnames";
|
||||
|
||||
interface EncryptionCardProps {
|
||||
@@ -22,7 +21,15 @@ interface EncryptionCardProps {
|
||||
/**
|
||||
* The description of the card.
|
||||
*/
|
||||
description: string;
|
||||
description?: string;
|
||||
/**
|
||||
* Whether this icon shows a destructive action.
|
||||
*/
|
||||
destructive?: boolean;
|
||||
/**
|
||||
* The icon to display.
|
||||
*/
|
||||
Icon: ComponentType<SVGAttributes<SVGElement>>;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -32,18 +39,20 @@ export function EncryptionCard({
|
||||
title,
|
||||
description,
|
||||
className,
|
||||
destructive = false,
|
||||
Icon,
|
||||
children,
|
||||
}: PropsWithChildren<EncryptionCardProps>): JSX.Element {
|
||||
return (
|
||||
<div className={classNames("mx_EncryptionCard", className)}>
|
||||
<div className="mx_EncryptionCard_header">
|
||||
<BigIcon>
|
||||
<KeyIcon />
|
||||
<BigIcon destructive={destructive}>
|
||||
<Icon />
|
||||
</BigIcon>
|
||||
<Heading as="h2" size="sm" weight="semibold">
|
||||
{title}
|
||||
</Heading>
|
||||
<span>{description}</span>
|
||||
{description && <span>{description}</span>}
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import React, { JSX, useCallback, useEffect, useState } from "react";
|
||||
import React, { JSX } from "react";
|
||||
import { Button, InlineSpinner } from "@vector-im/compound-web";
|
||||
import KeyIcon from "@vector-im/compound-design-tokens/assets/web/icons/key";
|
||||
|
||||
@@ -13,18 +13,15 @@ import { SettingsSection } from "../shared/SettingsSection";
|
||||
import { _t } from "../../../../languageHandler";
|
||||
import { useMatrixClientContext } from "../../../../contexts/MatrixClientContext";
|
||||
import { SettingsHeader } from "../SettingsHeader";
|
||||
import { accessSecretStorage } from "../../../../SecurityManager";
|
||||
import { SettingsSubheader } from "../SettingsSubheader";
|
||||
import { useAsyncMemo } from "../../../../hooks/useAsyncMemo";
|
||||
|
||||
/**
|
||||
* The possible states of the recovery panel.
|
||||
* - `loading`: We are checking the recovery key and the secrets.
|
||||
* - `missing_recovery_key`: The user has no recovery key.
|
||||
* - `secrets_not_cached`: The user has a recovery key but the secrets are not cached.
|
||||
* This can happen if we verified another device and secret-gossiping failed, or the other device itself lacked the secrets.
|
||||
* - `good`: The user has a recovery key and the secrets are cached.
|
||||
*/
|
||||
type State = "loading" | "missing_recovery_key" | "secrets_not_cached" | "good";
|
||||
type State = "loading" | "missing_recovery_key" | "good";
|
||||
|
||||
interface RecoveryPanelProps {
|
||||
/**
|
||||
@@ -40,29 +37,18 @@ interface RecoveryPanelProps {
|
||||
* This component allows the user to set up or change their recovery key.
|
||||
*/
|
||||
export function RecoveryPanel({ onChangeRecoveryKeyClick }: RecoveryPanelProps): JSX.Element {
|
||||
const [state, setState] = useState<State>("loading");
|
||||
const isMissingRecoveryKey = state === "missing_recovery_key";
|
||||
|
||||
const matrixClient = useMatrixClientContext();
|
||||
|
||||
const checkEncryption = useCallback(async () => {
|
||||
const crypto = matrixClient.getCrypto()!;
|
||||
|
||||
// Check if the user has a recovery key
|
||||
const hasRecoveryKey = Boolean(await matrixClient.secretStorage.getDefaultKeyId());
|
||||
if (!hasRecoveryKey) return setState("missing_recovery_key");
|
||||
|
||||
// Check if the secrets are cached
|
||||
const cachedSecrets = (await crypto.getCrossSigningStatus()).privateKeysCachedLocally;
|
||||
const secretsOk = cachedSecrets.masterKey && cachedSecrets.selfSigningKey && cachedSecrets.userSigningKey;
|
||||
if (!secretsOk) return setState("secrets_not_cached");
|
||||
|
||||
setState("good");
|
||||
}, [matrixClient]);
|
||||
|
||||
useEffect(() => {
|
||||
checkEncryption();
|
||||
}, [checkEncryption]);
|
||||
const state = useAsyncMemo<State>(
|
||||
async () => {
|
||||
// Check if the user has a recovery key
|
||||
const hasRecoveryKey = Boolean(await matrixClient.secretStorage.getDefaultKeyId());
|
||||
if (hasRecoveryKey) return "good";
|
||||
else return "missing_recovery_key";
|
||||
},
|
||||
[matrixClient],
|
||||
"loading",
|
||||
);
|
||||
const isMissingRecoveryKey = state === "missing_recovery_key";
|
||||
|
||||
let content: JSX.Element;
|
||||
switch (state) {
|
||||
@@ -76,18 +62,6 @@ export function RecoveryPanel({ onChangeRecoveryKeyClick }: RecoveryPanelProps):
|
||||
</Button>
|
||||
);
|
||||
break;
|
||||
case "secrets_not_cached":
|
||||
content = (
|
||||
<Button
|
||||
size="sm"
|
||||
kind="primary"
|
||||
Icon={KeyIcon}
|
||||
onClick={async () => await accessSecretStorage(checkEncryption)}
|
||||
>
|
||||
{_t("settings|encryption|recovery|enter_recovery_key")}
|
||||
</Button>
|
||||
);
|
||||
break;
|
||||
case "good":
|
||||
content = (
|
||||
<Button size="sm" kind="secondary" Icon={KeyIcon} onClick={() => onChangeRecoveryKeyClick(false)}>
|
||||
@@ -105,32 +79,10 @@ export function RecoveryPanel({ onChangeRecoveryKeyClick }: RecoveryPanelProps):
|
||||
label={_t("settings|encryption|recovery|title")}
|
||||
/>
|
||||
}
|
||||
subHeading={<Subheader state={state} />}
|
||||
subHeading={_t("settings|encryption|recovery|description")}
|
||||
data-testid="recoveryPanel"
|
||||
>
|
||||
{content}
|
||||
</SettingsSection>
|
||||
);
|
||||
}
|
||||
|
||||
interface SubheaderProps {
|
||||
/**
|
||||
* The state of the recovery panel.
|
||||
*/
|
||||
state: State;
|
||||
}
|
||||
|
||||
/**
|
||||
* The subheader for the recovery panel.
|
||||
*/
|
||||
function Subheader({ state }: SubheaderProps): JSX.Element {
|
||||
// If the secrets are not cached, we display a warning message.
|
||||
if (state !== "secrets_not_cached") return <>{_t("settings|encryption|recovery|description")}</>;
|
||||
|
||||
return (
|
||||
<SettingsSubheader
|
||||
label={_t("settings|encryption|recovery|description")}
|
||||
state="error"
|
||||
stateMessage={_t("settings|encryption|recovery|key_storage_warning")}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||