Merge remote-tracking branch 'origin/develop' into dbkr/key_storage_toggle
@@ -7,3 +7,4 @@ test/end-to-end-tests/lib/
|
||||
src/component-index.js
|
||||
# Auto-generated file
|
||||
src/modules.ts
|
||||
src/modules.js
|
||||
|
||||
10
.eslintrc.js
@@ -200,8 +200,13 @@ module.exports = {
|
||||
"@typescript-eslint/ban-ts-comment": "off",
|
||||
// We're okay with assertion errors when we ask for them
|
||||
"@typescript-eslint/no-non-null-assertion": "off",
|
||||
// We do this sometimes to brand interfaces
|
||||
"@typescript-eslint/no-empty-object-type": "off",
|
||||
"@typescript-eslint/no-empty-object-type": [
|
||||
"error",
|
||||
{
|
||||
// We do this sometimes to brand interfaces
|
||||
allowInterfaces: "with-single-extends",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
// temporary override for offending icon require files
|
||||
@@ -247,6 +252,7 @@ module.exports = {
|
||||
// We don't need super strict typing in test utilities
|
||||
"@typescript-eslint/explicit-function-return-type": "off",
|
||||
"@typescript-eslint/explicit-member-accessibility": "off",
|
||||
"@typescript-eslint/no-empty-object-type": "off",
|
||||
|
||||
// Jest/Playwright specific
|
||||
|
||||
|
||||
2
.github/workflows/dockerhub.yaml
vendored
@@ -51,7 +51,7 @@ jobs:
|
||||
|
||||
- name: Build and push
|
||||
id: build-and-push
|
||||
uses: docker/build-push-action@67a2d409c0a876cbe6b11854e3e25193efe4e62d # v6
|
||||
uses: docker/build-push-action@ca877d9245402d1537745e0e356eab47c3520991 # v6
|
||||
with:
|
||||
context: .
|
||||
push: true
|
||||
|
||||
1
.gitignore
vendored
@@ -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,
|
||||
{
|
||||
|
||||
18
CHANGELOG.md
@@ -1,3 +1,21 @@
|
||||
Changes in [1.11.91](https://github.com/element-hq/element-web/releases/tag/v1.11.91) (2025-01-28)
|
||||
==================================================================================================
|
||||
## ✨ Features
|
||||
|
||||
* Implement changes to memberlist from feedback ([#29029](https://github.com/element-hq/element-web/pull/29029)). Contributed by @MidhunSureshR.
|
||||
* Add toast for recovery keys being out of sync ([#28946](https://github.com/element-hq/element-web/pull/28946)). Contributed by @dbkr.
|
||||
* Refactor LegacyCallHandler event emitter to use TypedEventEmitter ([#29008](https://github.com/element-hq/element-web/pull/29008)). Contributed by @t3chguy.
|
||||
* Add `Recovery` section in the new user settings `Encryption` tab ([#28673](https://github.com/element-hq/element-web/pull/28673)). Contributed by @florianduros.
|
||||
* Retry loading chunks to make the app more resilient ([#29001](https://github.com/element-hq/element-web/pull/29001)). Contributed by @t3chguy.
|
||||
* Clear account idb table on logout ([#28996](https://github.com/element-hq/element-web/pull/28996)). Contributed by @t3chguy.
|
||||
* Implement new memberlist design with MVVM architecture ([#28874](https://github.com/element-hq/element-web/pull/28874)). Contributed by @MidhunSureshR.
|
||||
|
||||
## 🐛 Bug Fixes
|
||||
|
||||
* [Backport staging] Switch to secure random strings ([#29035](https://github.com/element-hq/element-web/pull/29035)). Contributed by @RiotRobot.
|
||||
* React to MatrixEvent sender/target being updated for rendering state events ([#28947](https://github.com/element-hq/element-web/pull/28947)). Contributed by @t3chguy.
|
||||
|
||||
|
||||
Changes in [1.11.90](https://github.com/element-hq/element-web/releases/tag/v1.11.90) (2025-01-14)
|
||||
==================================================================================================
|
||||
## ✨ Features
|
||||
|
||||
2
debian/control
vendored
@@ -8,6 +8,6 @@ Package: element-web
|
||||
Architecture: all
|
||||
Recommends: httpd, element-io-archive-keyring
|
||||
Description:
|
||||
A feature-rich client for Matrix.org
|
||||
Element: the future of secure communication
|
||||
This package contains the web-based client that can be served through a web
|
||||
server.
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
16
package.json
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "element-web",
|
||||
"version": "1.11.90",
|
||||
"description": "A feature-rich client for Matrix.org",
|
||||
"version": "1.11.91",
|
||||
"description": "Element: the future of secure communication",
|
||||
"author": "New Vector Ltd.",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
@@ -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.30001692",
|
||||
"caniuse-lite": "1.0.30001696",
|
||||
"wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0",
|
||||
"wrap-ansi": "npm:wrap-ansi@^7.0.0"
|
||||
},
|
||||
@@ -84,7 +84,7 @@
|
||||
"@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",
|
||||
@@ -179,7 +179,7 @@
|
||||
"@playwright/test": "^1.40.1",
|
||||
"@principalstudio/html-webpack-inject-preload": "^1.2.7",
|
||||
"@sentry/webpack-plugin": "^3.0.0",
|
||||
"@stylistic/eslint-plugin": "^2.9.0",
|
||||
"@stylistic/eslint-plugin": "^3.0.0",
|
||||
"@svgr/webpack": "^8.0.0",
|
||||
"@testcontainers/postgresql": "^10.16.0",
|
||||
"@testing-library/dom": "^10.4.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,8 +280,8 @@
|
||||
"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",
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
54
playwright/e2e/crypto/toasts.spec.ts
Normal file
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -214,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();
|
||||
@@ -288,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();
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -5,53 +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();
|
||||
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);
|
||||
// The bot bootstraps cross-signing, creates a key backup and sets up a recovery key
|
||||
await createBot(page, homeserver, credentials);
|
||||
});
|
||||
|
||||
test(
|
||||
@@ -121,37 +85,4 @@ test.describe("Recovery section in Encryption tab", () => {
|
||||
// Check that the current device is connected to key backup and the backup version is the expected one
|
||||
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(util.getEncryptionRecoverySection()).toMatchScreenshot("out-of-sync-recovery.png");
|
||||
await enterKeyButton.click();
|
||||
|
||||
// Fill the recovery key
|
||||
await util.enterRecoveryKey(recoveryKey);
|
||||
await expect(util.getEncryptionRecoverySection()).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 checkDeviceIsConnectedKeyBackup(app, 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();
|
||||
});
|
||||
|
||||
|
||||
@@ -25,6 +25,7 @@ import type {
|
||||
StateEvents,
|
||||
TimelineEvents,
|
||||
AccountDataEvents,
|
||||
EmptyObject,
|
||||
} from "matrix-js-sdk/src/matrix";
|
||||
import type { RoomMessageEventContent } from "matrix-js-sdk/src/types";
|
||||
import { Credentials } from "../plugins/homeserver";
|
||||
@@ -363,7 +364,7 @@ export class Client {
|
||||
event: JSHandle<MatrixEvent>,
|
||||
receiptType?: ReceiptType,
|
||||
unthreaded?: boolean,
|
||||
): Promise<{}> {
|
||||
): Promise<EmptyObject> {
|
||||
const client = await this.prepareClient();
|
||||
return client.evaluate(
|
||||
(client, { event, receiptType, unthreaded }) => {
|
||||
@@ -386,7 +387,7 @@ export class Client {
|
||||
* @return {Promise} Resolves: {} an empty object.
|
||||
* @return {module:http-api.MatrixError} Rejects: with an error response.
|
||||
*/
|
||||
public async setDisplayName(name: string): Promise<{}> {
|
||||
public async setDisplayName(name: string): Promise<EmptyObject> {
|
||||
const client = await this.prepareClient();
|
||||
return client.evaluate(async (cli: MatrixClient, name) => cli.setDisplayName(name), name);
|
||||
}
|
||||
@@ -397,7 +398,7 @@ export class Client {
|
||||
* @return {Promise} Resolves: {} an empty object.
|
||||
* @return {module:http-api.MatrixError} Rejects: with an error response.
|
||||
*/
|
||||
public async setAvatarUrl(url: string): Promise<{}> {
|
||||
public async setAvatarUrl(url: string): Promise<EmptyObject> {
|
||||
const client = await this.prepareClient();
|
||||
return client.evaluate(async (cli: MatrixClient, url) => cli.setAvatarUrl(url), url);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
|
||||
|
||||
|
After Width: | Height: | Size: 19 KiB |
|
Before Width: | Height: | Size: 18 KiB After Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 48 KiB After Width: | Height: | Size: 48 KiB |
|
After Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 13 KiB After Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 21 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:2f4dbd0b748e9178ea761f5586d0a1ade88f283a0481ba5dd2c42bc19d45b2a4";
|
||||
const TAG = "develop@sha256:06b88d1ca4985c50db14aa5cf4ec83d19dc1ad30ad33b79233493ccd169a477f";
|
||||
|
||||
const DEFAULT_CONFIG = {
|
||||
server_name: "localhost",
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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));
|
||||
|
||||
1
src/@types/diff-dom.d.ts
vendored
@@ -18,6 +18,7 @@ declare module "diff-dom" {
|
||||
newValue: HTMLElement | string;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-empty-object-type
|
||||
interface IOpts {}
|
||||
|
||||
export class DiffDOM {
|
||||
|
||||
5
src/@types/matrix-js-sdk.d.ts
vendored
@@ -11,6 +11,7 @@ import type { BLURHASH_FIELD } from "../utils/image-media";
|
||||
import type { JitsiCallMemberEventType, JitsiCallMemberContent } from "../call-types";
|
||||
import type { ILayoutStateEvent, WIDGET_LAYOUT_EVENT_TYPE } from "../stores/widgets/types";
|
||||
import type { EncryptedFile } from "matrix-js-sdk/src/types";
|
||||
import type { EmptyObject } from "matrix-js-sdk/src/matrix";
|
||||
import type { DeviceClientInformation } from "../utils/device/types.ts";
|
||||
import type { UserWidget } from "../utils/WidgetUtils-types.ts";
|
||||
|
||||
@@ -35,7 +36,7 @@ declare module "matrix-js-sdk/src/types" {
|
||||
[JitsiCallMemberEventType]: JitsiCallMemberContent;
|
||||
|
||||
// Unstable widgets state events
|
||||
"im.vector.modular.widgets": IWidget | {};
|
||||
"im.vector.modular.widgets": IWidget | EmptyObject;
|
||||
[WIDGET_LAYOUT_EVENT_TYPE]: ILayoutStateEvent;
|
||||
|
||||
// Element custom state events
|
||||
@@ -104,6 +105,6 @@ declare module "matrix-js-sdk/src/types" {
|
||||
// https://github.com/matrix-org/matrix-doc/pull/3246
|
||||
waveform?: number[];
|
||||
};
|
||||
"org.matrix.msc3245.voice"?: {};
|
||||
"org.matrix.msc3245.voice"?: EmptyObject;
|
||||
}
|
||||
}
|
||||
|
||||
2
src/@types/react.d.ts
vendored
@@ -10,7 +10,7 @@ import React, { PropsWithChildren } from "react";
|
||||
|
||||
declare module "react" {
|
||||
// Fix forwardRef types for Generic components - https://stackoverflow.com/a/58473012
|
||||
function forwardRef<T, P = {}>(
|
||||
function forwardRef<T, P extends object>(
|
||||
render: (props: PropsWithChildren<P>, ref: React.ForwardedRef<T>) => React.ReactElement | null,
|
||||
): (props: P & React.RefAttributes<T>) => React.ReactElement | null;
|
||||
|
||||
|
||||
@@ -249,6 +249,7 @@ export default class AddThreepid {
|
||||
* @param {{type: string, session?: string}} auth UI auth object
|
||||
* @return {Promise<Object>} Response from /3pid/add call (in current spec, an empty object)
|
||||
*/
|
||||
// eslint-disable-next-line @typescript-eslint/no-empty-object-type
|
||||
private makeAddThreepidOnlyRequest = (auth?: IAddThreePidOnlyBody["auth"] | null): Promise<{}> => {
|
||||
return this.matrixClient.addThreePidOnly({
|
||||
sid: this.sessionId!,
|
||||
|
||||
@@ -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 = {};
|
||||
|
||||
33
src/Terms.ts
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
MsgType,
|
||||
M_POLL_START,
|
||||
M_POLL_END,
|
||||
ContentHelpers,
|
||||
} from "matrix-js-sdk/src/matrix";
|
||||
import { KnownMembership } from "matrix-js-sdk/src/types";
|
||||
import { logger } from "matrix-js-sdk/src/logger";
|
||||
@@ -227,11 +228,16 @@ function textForMemberEvent(
|
||||
|
||||
function textForTopicEvent(ev: MatrixEvent): (() => string) | null {
|
||||
const senderDisplayName = ev.sender && ev.sender.name ? ev.sender.name : ev.getSender();
|
||||
const topic = ContentHelpers.parseTopicContent(ev.getContent()).text;
|
||||
return () =>
|
||||
_t("timeline|m.room.topic", {
|
||||
senderDisplayName,
|
||||
topic: ev.getContent().topic,
|
||||
});
|
||||
topic
|
||||
? _t("timeline|m.room.topic|changed", {
|
||||
senderDisplayName,
|
||||
topic,
|
||||
})
|
||||
: _t("timeline|m.room.topic|removed", {
|
||||
senderDisplayName,
|
||||
});
|
||||
}
|
||||
|
||||
function textForRoomAvatarEvent(ev: MatrixEvent): (() => string) | null {
|
||||
|
||||
@@ -10,7 +10,7 @@ import { defer, IDeferred } from "matrix-js-sdk/src/utils";
|
||||
|
||||
import { WorkerPayload } from "./workers/worker";
|
||||
|
||||
export class WorkerManager<Request extends {}, Response> {
|
||||
export class WorkerManager<Request extends object, Response> {
|
||||
private readonly worker: Worker;
|
||||
private seq = 0;
|
||||
private pendingDeferredMap = new Map<number, IDeferred<Response>>();
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -14,7 +14,7 @@ import { removeHiddenChars } from "matrix-js-sdk/src/utils";
|
||||
import { TimelineRenderingType } from "../contexts/RoomContext";
|
||||
import { Leaves } from "../@types/common";
|
||||
|
||||
interface IOptions<T extends {}> {
|
||||
interface IOptions<T extends object> {
|
||||
keys: Array<Leaves<T>>;
|
||||
funcs?: Array<(o: T) => string | string[]>;
|
||||
shouldMatchWordsOnly?: boolean;
|
||||
@@ -37,7 +37,7 @@ interface IOptions<T extends {}> {
|
||||
* @param {function[]} options.funcs List of functions that when called with the
|
||||
* object as an arg will return a string to use as an index
|
||||
*/
|
||||
export default class QueryMatcher<T extends {}> {
|
||||
export default class QueryMatcher<T extends object> {
|
||||
private _options: IOptions<T>;
|
||||
private _items = new Map<string, { object: T; keyWeight: number }[]>();
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ import classNames from "classnames";
|
||||
import React, { HTMLAttributes, ReactHTML, ReactNode, WheelEvent } from "react";
|
||||
|
||||
type DynamicHtmlElementProps<T extends keyof JSX.IntrinsicElements> =
|
||||
JSX.IntrinsicElements[T] extends HTMLAttributes<{}> ? DynamicElementProps<T> : DynamicElementProps<"div">;
|
||||
JSX.IntrinsicElements[T] extends HTMLAttributes<object> ? DynamicElementProps<T> : DynamicElementProps<"div">;
|
||||
type DynamicElementProps<T extends keyof JSX.IntrinsicElements> = Partial<Omit<JSX.IntrinsicElements[T], "ref">>;
|
||||
|
||||
export type IProps<T extends keyof JSX.IntrinsicElements> = Omit<DynamicHtmlElementProps<T>, "onScroll"> & {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -7,19 +7,18 @@ Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import * as React from "react";
|
||||
import { EmptyObject } from "matrix-js-sdk/src/matrix";
|
||||
|
||||
import { ComponentClass } from "../../@types/common";
|
||||
import NonUrgentToastStore from "../../stores/NonUrgentToastStore";
|
||||
import { UPDATE_EVENT } from "../../stores/AsyncStore";
|
||||
|
||||
interface IProps {}
|
||||
|
||||
interface IState {
|
||||
toasts: ComponentClass[];
|
||||
}
|
||||
|
||||
export default class NonUrgentToastContainer extends React.PureComponent<IProps, IState> {
|
||||
public constructor(props: IProps) {
|
||||
export default class NonUrgentToastContainer extends React.PureComponent<EmptyObject, IState> {
|
||||
public constructor(props: EmptyObject) {
|
||||
super(props);
|
||||
|
||||
this.state = {
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -9,6 +9,7 @@ Please see LICENSE files in the repository root for full details.
|
||||
import * as React from "react";
|
||||
import classNames from "classnames";
|
||||
import { Text } from "@vector-im/compound-web";
|
||||
import { EmptyObject } from "matrix-js-sdk/src/matrix";
|
||||
|
||||
import ToastStore, { IToast } from "../../stores/ToastStore";
|
||||
|
||||
@@ -17,8 +18,8 @@ interface IState {
|
||||
countSeen: number;
|
||||
}
|
||||
|
||||
export default class ToastContainer extends React.Component<{}, IState> {
|
||||
public constructor(props: {}) {
|
||||
export default class ToastContainer extends React.Component<EmptyObject, IState> {
|
||||
public constructor(props: EmptyObject) {
|
||||
super(props);
|
||||
this.state = {
|
||||
toasts: ToastStore.sharedInstance().getToasts(),
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -24,7 +24,7 @@ interface AuthHeaderAction {
|
||||
|
||||
export type AuthHeaderReducer = Reducer<ComponentProps<typeof AuthHeaderModifier>[], AuthHeaderAction>;
|
||||
|
||||
export function AuthHeaderProvider({ children }: PropsWithChildren<{}>): JSX.Element {
|
||||
export function AuthHeaderProvider({ children }: PropsWithChildren): JSX.Element {
|
||||
const [state, dispatch] = useReducer<AuthHeaderReducer>(
|
||||
(state: ComponentProps<typeof AuthHeaderModifier>[], action: AuthHeaderAction) => {
|
||||
switch (action.type) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
192
src/components/viewmodels/rooms/UserIdentityWarningViewModel.tsx
Normal file
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -18,8 +18,6 @@ interface IProps {
|
||||
progress: number; // percent complete, 0-1, default 100%
|
||||
}
|
||||
|
||||
interface IState {}
|
||||
|
||||
/**
|
||||
* A simple waveform component. This renders bars (centered vertically) for each
|
||||
* height provided in the component properties. Updating the properties will update
|
||||
@@ -28,7 +26,7 @@ interface IState {}
|
||||
* For CSS purposes, a mx_Waveform_bar_100pct class is added when the bar should be
|
||||
* "filled", as a demonstration of the progress property.
|
||||
*/
|
||||
export default class Waveform extends React.PureComponent<IProps, IState> {
|
||||
export default class Waveform extends React.PureComponent<IProps> {
|
||||
public static defaultProps = {
|
||||
progress: 1,
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
@@ -908,7 +894,7 @@ export class SSOAuthEntry extends React.Component<ISSOAuthEntryProps, ISSOAuthEn
|
||||
}
|
||||
}
|
||||
|
||||
export class FallbackAuthEntry<T = {}> extends React.Component<IAuthEntryProps & T> {
|
||||
export class FallbackAuthEntry<T extends object> extends React.Component<IAuthEntryProps & T> {
|
||||
protected popupWindow: Window | null;
|
||||
protected fallbackButton = createRef<HTMLDivElement>();
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ Please see LICENSE files in the repository root for full details.
|
||||
|
||||
import React from "react";
|
||||
import classNames from "classnames";
|
||||
import { EmptyObject } from "matrix-js-sdk/src/matrix";
|
||||
|
||||
import SdkConfig from "../../../SdkConfig";
|
||||
import AuthPage from "./AuthPage";
|
||||
@@ -16,9 +17,7 @@ import LanguageSelector from "./LanguageSelector";
|
||||
import EmbeddedPage from "../../structures/EmbeddedPage";
|
||||
import { MATRIX_LOGO_HTML } from "../../structures/static-page-vars";
|
||||
|
||||
interface IProps {}
|
||||
|
||||
export default class Welcome extends React.PureComponent<IProps> {
|
||||
export default class Welcome extends React.PureComponent<EmptyObject> {
|
||||
public render(): React.ReactNode {
|
||||
const pagesConfig = SdkConfig.getObject("embedded_pages");
|
||||
let pageUrl: string | undefined;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -101,7 +101,7 @@ interface IProps {
|
||||
onNewItemChanged?(item: string): void;
|
||||
}
|
||||
|
||||
export default class EditableItemList<P = {}> extends React.PureComponent<IProps & P> {
|
||||
export default class EditableItemList<P extends object> extends React.PureComponent<IProps & P> {
|
||||
protected onItemAdded = (e: ButtonEvent): void => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -21,9 +21,7 @@ interface IProps extends React.InputHTMLAttributes<HTMLInputElement> {
|
||||
id?: string;
|
||||
}
|
||||
|
||||
interface IState {}
|
||||
|
||||
export default class StyledCheckbox extends React.PureComponent<IProps, IState> {
|
||||
export default class StyledCheckbox extends React.PureComponent<IProps> {
|
||||
private id: string;
|
||||
|
||||
public static readonly defaultProps = {
|
||||
|
||||
@@ -18,9 +18,7 @@ interface IProps extends React.InputHTMLAttributes<HTMLInputElement> {
|
||||
childrenInLabel?: boolean;
|
||||
}
|
||||
|
||||
interface IState {}
|
||||
|
||||
export default class StyledRadioButton extends React.PureComponent<IProps, IState> {
|
||||
export default class StyledRadioButton extends React.PureComponent<IProps> {
|
||||
public static readonly defaultProps = {
|
||||
className: "",
|
||||
childrenInLabel: true,
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -7,7 +7,7 @@ Please see LICENSE files in the repository root for full details.
|
||||
|
||||
import React, { createRef } from "react";
|
||||
import classNames from "classnames";
|
||||
import { EventType } from "matrix-js-sdk/src/matrix";
|
||||
import { ContentHelpers, EventType } from "matrix-js-sdk/src/matrix";
|
||||
|
||||
import { _t } from "../../../languageHandler";
|
||||
import { MatrixClientPeg } from "../../../MatrixClientPeg";
|
||||
@@ -51,7 +51,7 @@ export default class RoomProfileSettings extends React.Component<IProps, IState>
|
||||
const avatarUrl = avatarEvent?.getContent()["url"] ?? null;
|
||||
|
||||
const topicEvent = room.currentState.getStateEvents(EventType.RoomTopic, "");
|
||||
const topic = topicEvent && topicEvent.getContent() ? topicEvent.getContent()["topic"] : "";
|
||||
const topic = (topicEvent && ContentHelpers.parseTopicContent(topicEvent.getContent()).text) || "";
|
||||
|
||||
const nameEvent = room.currentState.getStateEvents(EventType.RoomName, "");
|
||||
const name = nameEvent && nameEvent.getContent() ? nameEvent.getContent()["name"] : "";
|
||||
@@ -145,6 +145,8 @@ export default class RoomProfileSettings extends React.Component<IProps, IState>
|
||||
|
||||
if (this.state.originalTopic !== this.state.topic) {
|
||||
const html = htmlSerializeFromMdIfNeeded(this.state.topic, { forceHTML: false });
|
||||
// XXX: Note that we deliberately send an empty string on an empty topic rather
|
||||
// than a clearer `undefined` value. Synapse still requires a string in a topic.
|
||||
await client.setRoomTopic(this.props.roomId, this.state.topic, html);
|
||||
newState.originalTopic = this.state.topic;
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com
|
||||
Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import React, { createRef, KeyboardEvent, RefObject } from "react";
|
||||
import React, { createRef, RefObject } from "react";
|
||||
import classNames from "classnames";
|
||||
import { flatMap } from "lodash";
|
||||
import { Room } from "matrix-js-sdk/src/matrix";
|
||||
@@ -206,7 +206,7 @@ export default class Autocomplete extends React.PureComponent<IProps, IState> {
|
||||
this.setSelection(1 + index);
|
||||
}
|
||||
|
||||
public onEscape(e: KeyboardEvent): boolean | undefined {
|
||||
public onEscape(e: KeyboardEvent | React.KeyboardEvent): boolean | undefined {
|
||||
const completionCount = this.countCompletions();
|
||||
if (completionCount === 0) {
|
||||
// autocomplete is already empty, so don't preventDefault
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import React, { createRef } from "react";
|
||||
import { Room } from "matrix-js-sdk/src/matrix";
|
||||
import { EmptyObject, Room } from "matrix-js-sdk/src/matrix";
|
||||
import { CSSTransition } from "react-transition-group";
|
||||
|
||||
import { BreadcrumbsStore } from "../../../stores/BreadcrumbsStore";
|
||||
@@ -21,8 +21,6 @@ import { Action } from "../../../dispatcher/actions";
|
||||
import { ViewRoomPayload } from "../../../dispatcher/payloads/ViewRoomPayload";
|
||||
import AccessibleButton, { ButtonEvent } from "../elements/AccessibleButton";
|
||||
|
||||
interface IProps {}
|
||||
|
||||
interface IState {
|
||||
// Both of these control the animation for the breadcrumbs. For details on the
|
||||
// actual animation, see the CSS.
|
||||
@@ -59,11 +57,11 @@ const RoomBreadcrumbTile: React.FC<{ room: Room; onClick: (ev: ButtonEvent) => v
|
||||
);
|
||||
};
|
||||
|
||||
export default class RoomBreadcrumbs extends React.PureComponent<IProps, IState> {
|
||||
export default class RoomBreadcrumbs extends React.PureComponent<EmptyObject, IState> {
|
||||
private unmounted = false;
|
||||
private toolbar = createRef<HTMLDivElement>();
|
||||
|
||||
public constructor(props: IProps) {
|
||||
public constructor(props: EmptyObject) {
|
||||
super(props);
|
||||
|
||||
this.state = {
|
||||
|
||||
@@ -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} />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
412
src/components/views/rooms/RoomHeader/RoomHeader.tsx
Normal file
@@ -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} />;
|
||||
}
|
||||
23
src/components/views/rooms/RoomHeader/toggle/useToggled.tsx
Normal file
@@ -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 (
|
||||
|
||||
@@ -92,7 +92,7 @@ export function handleEventWithAutocomplete(
|
||||
handled = true;
|
||||
break;
|
||||
case KeyBindingAction.CancelAutocomplete:
|
||||
autocompleteRef.current.onEscape(event as {} as React.KeyboardEvent);
|
||||
autocompleteRef.current.onEscape(event);
|
||||
handled = true;
|
||||
break;
|
||||
default:
|
||||
|
||||
@@ -7,7 +7,7 @@ Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import React from "react";
|
||||
import { ClientEvent, MatrixEvent } from "matrix-js-sdk/src/matrix";
|
||||
import { ClientEvent, EmptyObject, MatrixEvent } from "matrix-js-sdk/src/matrix";
|
||||
import { logger } from "matrix-js-sdk/src/logger";
|
||||
import { CryptoEvent } from "matrix-js-sdk/src/crypto-api";
|
||||
|
||||
@@ -33,10 +33,10 @@ interface IState {
|
||||
crossSigningReady?: boolean;
|
||||
}
|
||||
|
||||
export default class CrossSigningPanel extends React.PureComponent<{}, IState> {
|
||||
export default class CrossSigningPanel extends React.PureComponent<EmptyObject, IState> {
|
||||
private unmounted = false;
|
||||
|
||||
public constructor(props: {}) {
|
||||
public constructor(props: EmptyObject) {
|
||||
super(props);
|
||||
|
||||
this.state = {
|
||||
|
||||
@@ -8,6 +8,7 @@ Please see LICENSE files in the repository root for full details.
|
||||
|
||||
import React, { lazy } from "react";
|
||||
import { logger } from "matrix-js-sdk/src/logger";
|
||||
import { EmptyObject } from "matrix-js-sdk/src/matrix";
|
||||
|
||||
import { _t } from "../../../languageHandler";
|
||||
import Modal from "../../../Modal";
|
||||
@@ -19,8 +20,6 @@ import { SettingLevel } from "../../../settings/SettingLevel";
|
||||
import { SettingsSubsection, SettingsSubsectionText } from "./shared/SettingsSubsection";
|
||||
import MatrixClientContext from "../../../contexts/MatrixClientContext";
|
||||
|
||||
interface IProps {}
|
||||
|
||||
interface IState {
|
||||
/** The device's base64-encoded Ed25519 identity key, or:
|
||||
*
|
||||
@@ -30,11 +29,11 @@ interface IState {
|
||||
deviceIdentityKey: string | undefined | null;
|
||||
}
|
||||
|
||||
export default class CryptographyPanel extends React.Component<IProps, IState> {
|
||||
export default class CryptographyPanel extends React.Component<EmptyObject, IState> {
|
||||
public static contextType = MatrixClientContext;
|
||||
declare public context: React.ContextType<typeof MatrixClientContext>;
|
||||
|
||||
public constructor(props: IProps, context: React.ContextType<typeof MatrixClientContext>) {
|
||||
public constructor(props: EmptyObject, context: React.ContextType<typeof MatrixClientContext>) {
|
||||
super(props);
|
||||
|
||||
if (!context.getCrypto()) {
|
||||
|
||||
@@ -7,6 +7,7 @@ Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import React, { lazy } from "react";
|
||||
import { EmptyObject } from "matrix-js-sdk/src/matrix";
|
||||
|
||||
import { _t } from "../../../languageHandler";
|
||||
import SdkConfig from "../../../SdkConfig";
|
||||
@@ -28,8 +29,8 @@ interface IState {
|
||||
eventIndexingEnabled: boolean;
|
||||
}
|
||||
|
||||
export default class EventIndexPanel extends React.Component<{}, IState> {
|
||||
public constructor(props: {}) {
|
||||
export default class EventIndexPanel extends React.Component<EmptyObject, IState> {
|
||||
public constructor(props: EmptyObject) {
|
||||
super(props);
|
||||
|
||||
this.state = {
|
||||
|
||||
@@ -7,6 +7,7 @@ Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import React from "react";
|
||||
import { EmptyObject } from "matrix-js-sdk/src/matrix";
|
||||
|
||||
import EventTilePreview from "../elements/EventTilePreview";
|
||||
import SettingsStore from "../../../settings/SettingsStore";
|
||||
@@ -18,8 +19,6 @@ import { SettingsSubsection } from "./shared/SettingsSubsection";
|
||||
import Field from "../elements/Field";
|
||||
import { FontWatcher } from "../../../settings/watchers/FontWatcher";
|
||||
|
||||
interface IProps {}
|
||||
|
||||
interface IState {
|
||||
browserFontSize: number;
|
||||
// String displaying the current selected fontSize.
|
||||
@@ -34,7 +33,7 @@ interface IState {
|
||||
avatarUrl?: string;
|
||||
}
|
||||
|
||||
export default class FontScalingPanel extends React.Component<IProps, IState> {
|
||||
export default class FontScalingPanel extends React.Component<EmptyObject, IState> {
|
||||
private readonly MESSAGE_PREVIEW_TEXT = _t("common|preview_message");
|
||||
/**
|
||||
* Font sizes available (in px)
|
||||
@@ -43,7 +42,7 @@ export default class FontScalingPanel extends React.Component<IProps, IState> {
|
||||
private layoutWatcherRef?: string;
|
||||
private unmounted = false;
|
||||
|
||||
public constructor(props: IProps) {
|
||||
public constructor(props: EmptyObject) {
|
||||
super(props);
|
||||
|
||||
this.state = {
|
||||
|
||||
@@ -7,6 +7,7 @@ Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import React from "react";
|
||||
import { EmptyObject } from "matrix-js-sdk/src/matrix";
|
||||
|
||||
import SettingsStore from "../../../settings/SettingsStore";
|
||||
import StyledRadioButton from "../elements/StyledRadioButton";
|
||||
@@ -15,16 +16,12 @@ import { SettingLevel } from "../../../settings/SettingLevel";
|
||||
import { ImageSize } from "../../../settings/enums/ImageSize";
|
||||
import { SettingsSubsection } from "./shared/SettingsSubsection";
|
||||
|
||||
interface IProps {
|
||||
// none
|
||||
}
|
||||
|
||||
interface IState {
|
||||
size: ImageSize;
|
||||
}
|
||||
|
||||
export default class ImageSizePanel extends React.Component<IProps, IState> {
|
||||
public constructor(props: IProps) {
|
||||
export default class ImageSizePanel extends React.Component<EmptyObject, IState> {
|
||||
public constructor(props: EmptyObject) {
|
||||
super(props);
|
||||
|
||||
this.state = {
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
IThreepid,
|
||||
ThreepidMedium,
|
||||
LocalNotificationSettings,
|
||||
EmptyObject,
|
||||
} from "matrix-js-sdk/src/matrix";
|
||||
import { logger } from "matrix-js-sdk/src/logger";
|
||||
|
||||
@@ -108,8 +109,6 @@ interface IVectorPushRule {
|
||||
syncedVectorState?: VectorState;
|
||||
}
|
||||
|
||||
interface IProps {}
|
||||
|
||||
interface IState {
|
||||
phase: Phase;
|
||||
|
||||
@@ -205,10 +204,10 @@ const NotificationActivitySettings = (): JSX.Element => {
|
||||
/**
|
||||
* The old, deprecated notifications tab view, only displayed if the user has the labs flag disabled.
|
||||
*/
|
||||
export default class Notifications extends React.PureComponent<IProps, IState> {
|
||||
export default class Notifications extends React.PureComponent<EmptyObject, IState> {
|
||||
private settingWatchers: string[] = [];
|
||||
|
||||
public constructor(props: IProps) {
|
||||
public constructor(props: EmptyObject) {
|
||||
super(props);
|
||||
|
||||
this.state = {
|
||||
@@ -255,7 +254,7 @@ export default class Notifications extends React.PureComponent<IProps, IState> {
|
||||
this.settingWatchers.forEach((watcher) => SettingsStore.unwatchSetting(watcher));
|
||||
}
|
||||
|
||||
public componentDidUpdate(prevProps: Readonly<IProps>, prevState: Readonly<IState>): void {
|
||||
public componentDidUpdate(prevProps: Readonly<EmptyObject>, prevState: Readonly<IState>): void {
|
||||
if (this.state.deviceNotificationsEnabled !== prevState.deviceNotificationsEnabled) {
|
||||
this.persistLocalNotificationSettings(this.state.deviceNotificationsEnabled);
|
||||
}
|
||||
@@ -291,7 +290,7 @@ export default class Notifications extends React.PureComponent<IProps, IState> {
|
||||
}
|
||||
}
|
||||
|
||||
private persistLocalNotificationSettings(enabled: boolean): Promise<{}> {
|
||||
private persistLocalNotificationSettings(enabled: boolean): Promise<EmptyObject> {
|
||||
const cli = MatrixClientPeg.safeGet();
|
||||
return cli.setAccountData(getLocalNotificationAccountDataEventType(cli.deviceId), {
|
||||
is_silenced: !enabled,
|
||||
|
||||
@@ -10,6 +10,7 @@ Please see LICENSE files in the repository root for full details.
|
||||
import React, { lazy, ReactNode } from "react";
|
||||
import { CryptoEvent, BackupTrustInfo, KeyBackupInfo } from "matrix-js-sdk/src/crypto-api";
|
||||
import { logger } from "matrix-js-sdk/src/logger";
|
||||
import { EmptyObject } from "matrix-js-sdk/src/matrix";
|
||||
|
||||
import { MatrixClientPeg } from "../../../MatrixClientPeg";
|
||||
import { _t } from "../../../languageHandler";
|
||||
@@ -60,10 +61,10 @@ interface IState {
|
||||
sessionsRemaining: number | null;
|
||||
}
|
||||
|
||||
export default class SecureBackupPanel extends React.PureComponent<{}, IState> {
|
||||
export default class SecureBackupPanel extends React.PureComponent<EmptyObject, IState> {
|
||||
private unmounted = false;
|
||||
|
||||
public constructor(props: {}) {
|
||||
public constructor(props: EmptyObject) {
|
||||
super(props);
|
||||
|
||||
this.state = {
|
||||
|
||||
@@ -8,6 +8,7 @@ Please see LICENSE files in the repository root for full details.
|
||||
|
||||
import React from "react";
|
||||
import { logger } from "matrix-js-sdk/src/logger";
|
||||
import { EmptyObject } from "matrix-js-sdk/src/matrix";
|
||||
|
||||
import { _t } from "../../../languageHandler";
|
||||
import { IntegrationManagers } from "../../../integrations/IntegrationManagers";
|
||||
@@ -19,15 +20,13 @@ import Heading from "../typography/Heading";
|
||||
import { SettingsSubsectionText } from "./shared/SettingsSubsection";
|
||||
import { UIFeature } from "../../../settings/UIFeature";
|
||||
|
||||
interface IProps {}
|
||||
|
||||
interface IState {
|
||||
currentManager: IntegrationManagerInstance | null;
|
||||
provisioningEnabled: boolean;
|
||||
}
|
||||
|
||||
export default class SetIntegrationManager extends React.Component<IProps, IState> {
|
||||
public constructor(props: IProps) {
|
||||
export default class SetIntegrationManager extends React.Component<EmptyObject, IState> {
|
||||
public constructor(props: EmptyObject) {
|
||||
super(props);
|
||||
|
||||
const currentManager = IntegrationManagers.sharedInstance().getPrimaryManager();
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,33 +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")}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
/*
|
||||
* 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 { Button } from "@vector-im/compound-web";
|
||||
import KeyIcon from "@vector-im/compound-design-tokens/assets/web/icons/key";
|
||||
|
||||
import { SettingsSection } from "../shared/SettingsSection";
|
||||
import { _t } from "../../../../languageHandler";
|
||||
import { SettingsSubheader } from "../SettingsSubheader";
|
||||
import { accessSecretStorage } from "../../../../SecurityManager";
|
||||
|
||||
interface RecoveryPanelOutOfSyncProps {
|
||||
/**
|
||||
* Callback for when the user has finished entering their recovery key.
|
||||
*/
|
||||
onFinish: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* This component is shown as part of the {@link EncryptionUserSettingsTab}, instead of the
|
||||
* {@link RecoveryPanel}, when some of the user secrets are not cached in the local client.
|
||||
*
|
||||
* It prompts the user to enter their recovery key so that the secrets can be loaded from 4S into
|
||||
* the client.
|
||||
*/
|
||||
export function RecoveryPanelOutOfSync({ onFinish }: RecoveryPanelOutOfSyncProps): JSX.Element {
|
||||
return (
|
||||
<SettingsSection
|
||||
legacy={false}
|
||||
heading={_t("settings|encryption|recovery|title")}
|
||||
subHeading={
|
||||
<SettingsSubheader
|
||||
label={_t("settings|encryption|recovery|description")}
|
||||
state="error"
|
||||
stateMessage={_t("settings|encryption|recovery|key_storage_warning")}
|
||||
/>
|
||||
}
|
||||
data-testid="recoveryPanel"
|
||||
>
|
||||
<Button
|
||||
size="sm"
|
||||
kind="primary"
|
||||
Icon={KeyIcon}
|
||||
onClick={async () => {
|
||||
await accessSecretStorage();
|
||||
onFinish();
|
||||
}}
|
||||
>
|
||||
{_t("settings|encryption|recovery|enter_recovery_key")}
|
||||
</Button>
|
||||
</SettingsSection>
|
||||
);
|
||||
}
|
||||
@@ -25,12 +25,21 @@ interface ResetIdentityPanelProps {
|
||||
* Called when the cancel button is clicked or when we go back in the breadcrumbs.
|
||||
*/
|
||||
onCancelClick: () => void;
|
||||
|
||||
/**
|
||||
* The variant of the panel to show. We show more warnings in the 'compromised' variant (no use in showing a user this
|
||||
* warning if they have to reset because they no longer have their key)
|
||||
* "compromised" is shown when the user chooses 'reset' explicitly in settings, usually because they believe their
|
||||
* identity has been compromised.
|
||||
* "forgot" is shown when the user has just forgotten their passphrase.
|
||||
*/
|
||||
variant: "compromised" | "forgot";
|
||||
}
|
||||
|
||||
/**
|
||||
* The panel for resetting the identity of the current user.
|
||||
*/
|
||||
export function ResetIdentityPanel({ onCancelClick, onFinish }: ResetIdentityPanelProps): JSX.Element {
|
||||
export function ResetIdentityPanel({ onCancelClick, onFinish, variant }: ResetIdentityPanelProps): JSX.Element {
|
||||
const matrixClient = useMatrixClientContext();
|
||||
|
||||
return (
|
||||
@@ -44,7 +53,11 @@ export function ResetIdentityPanel({ onCancelClick, onFinish }: ResetIdentityPan
|
||||
<EncryptionCard
|
||||
Icon={ErrorIcon}
|
||||
destructive={true}
|
||||
title={_t("settings|encryption|advanced|breadcrumb_title")}
|
||||
title={
|
||||
variant === "forgot"
|
||||
? _t("settings|encryption|advanced|breadcrumb_title_forgot")
|
||||
: _t("settings|encryption|advanced|breadcrumb_title")
|
||||
}
|
||||
className="mx_ResetIdentityPanel"
|
||||
>
|
||||
<div className="mx_ResetIdentityPanel_content">
|
||||
@@ -59,7 +72,7 @@ export function ResetIdentityPanel({ onCancelClick, onFinish }: ResetIdentityPan
|
||||
{_t("settings|encryption|advanced|breadcrumb_third_description")}
|
||||
</VisualListItem>
|
||||
</VisualList>
|
||||
<span>{_t("settings|encryption|advanced|breadcrumb_warning")}</span>
|
||||
{variant === "compromised" && <span>{_t("settings|encryption|advanced|breadcrumb_warning")}</span>}
|
||||
</div>
|
||||
<div className="mx_ResetIdentityPanel_footer">
|
||||
<Button
|
||||
|
||||
@@ -8,6 +8,7 @@ Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import React, { ChangeEvent, ReactNode } from "react";
|
||||
import { EmptyObject } from "matrix-js-sdk/src/matrix";
|
||||
|
||||
import { _t } from "../../../../../languageHandler";
|
||||
import SdkConfig from "../../../../../SdkConfig";
|
||||
@@ -25,8 +26,6 @@ import SettingsTab from "../SettingsTab";
|
||||
import { SettingsSection } from "../../shared/SettingsSection";
|
||||
import { SettingsSubsection } from "../../shared/SettingsSubsection";
|
||||
|
||||
interface IProps {}
|
||||
|
||||
interface IState {
|
||||
useBundledEmojiFont: boolean;
|
||||
useSystemFont: boolean;
|
||||
@@ -34,8 +33,8 @@ interface IState {
|
||||
showAdvanced: boolean;
|
||||
}
|
||||
|
||||
export default class AppearanceUserSettingsTab extends React.Component<IProps, IState> {
|
||||
public constructor(props: IProps) {
|
||||
export default class AppearanceUserSettingsTab extends React.Component<EmptyObject, IState> {
|
||||
public constructor(props: EmptyObject) {
|
||||
super(props);
|
||||
|
||||
this.state = {
|
||||
|
||||
@@ -21,7 +21,7 @@ import { SettingsSection } from "../../shared/SettingsSection";
|
||||
import { SettingsSubheader } from "../../SettingsSubheader";
|
||||
import { AdvancedPanel } from "../../encryption/AdvancedPanel";
|
||||
import { ResetIdentityPanel } from "../../encryption/ResetIdentityPanel";
|
||||
import { KeyBackupPanel } from "../../encryption/KeyStoragePanel";
|
||||
import { RecoveryPanelOutOfSync } from "../../encryption/RecoveryPanelOutOfSync";
|
||||
import Spinner from "../../../elements/Spinner";
|
||||
import { useEventEmitter } from "../../../../../hooks/useEventEmitter";
|
||||
|
||||
@@ -35,9 +35,27 @@ import { useEventEmitter } from "../../../../../hooks/useEventEmitter";
|
||||
* This happens when the user has a recovery key and the user clicks on "Change recovery key" button of the RecoveryPanel.
|
||||
* - "set_recovery_key": The panel to show when the user is setting up their recovery key.
|
||||
* This happens when the user doesn't have a key a recovery key and the user clicks on "Set up recovery key" button of the RecoveryPanel.
|
||||
* - "reset_identity": The panel to show when the user is resetting their identity.
|
||||
* - "reset_identity_compromised": The panel to show when the user is resetting their identity, in te case where their key is compromised.
|
||||
* - "reset_identity_forgot": The panel to show when the user is resetting their identity, in the case where they forgot their recovery key.
|
||||
* - `secrets_not_cached`: The secrets are not cached locally. This can happen if we verified another device and secret-gossiping failed, or the other device itself lacked the secrets.
|
||||
* If the "set_up_encryption" and "secrets_not_cached" conditions are both filled, "set_up_encryption" prevails.
|
||||
*/
|
||||
type State = "loading" | "main" | "set_up_encryption" | "change_recovery_key" | "set_recovery_key" | "reset_identity";
|
||||
export type State =
|
||||
| "loading"
|
||||
| "main"
|
||||
| "set_up_encryption"
|
||||
| "change_recovery_key"
|
||||
| "set_recovery_key"
|
||||
| "reset_identity_compromised"
|
||||
| "reset_identity_forgot"
|
||||
| "secrets_not_cached";
|
||||
|
||||
interface EncryptionUserSettingsTabProps {
|
||||
/**
|
||||
* If the tab should start in a state other than the deasult
|
||||
*/
|
||||
initialState?: State;
|
||||
}
|
||||
|
||||
const useKeyBackupIsEnabled = (): boolean | undefined => {
|
||||
const [isEnabled, setIsEnabled] = useState<boolean | undefined>(undefined);
|
||||
@@ -68,9 +86,13 @@ const useKeyBackupIsEnabled = (): boolean | undefined => {
|
||||
return loading ? undefined : isEnabled;
|
||||
};
|
||||
|
||||
export function EncryptionUserSettingsTab(): JSX.Element {
|
||||
const [state, setState] = useState<State>("loading");
|
||||
const setUpEncryptionRequired = useSetUpEncryptionRequired(setState);
|
||||
/**
|
||||
* The encryption settings tab.
|
||||
*/
|
||||
export function EncryptionUserSettingsTab({ initialState = "loading" }: EncryptionUserSettingsTabProps): JSX.Element {
|
||||
const [state, setState] = useState<State>(initialState);
|
||||
|
||||
const checkEncryptionState = useCheckEncryptionState(state, setState);
|
||||
const keyBackupIsEnabled = useKeyBackupIsEnabled();
|
||||
|
||||
let content: JSX.Element;
|
||||
@@ -82,23 +104,21 @@ export function EncryptionUserSettingsTab(): JSX.Element {
|
||||
content = <InlineSpinner aria-label={_t("common|loading")} />;
|
||||
break;
|
||||
case "set_up_encryption":
|
||||
content = <SetUpEncryptionPanel onFinish={setUpEncryptionRequired} />;
|
||||
content = <SetUpEncryptionPanel onFinish={checkEncryptionState} />;
|
||||
break;
|
||||
case "secrets_not_cached":
|
||||
content = <RecoveryPanelOutOfSync onFinish={checkEncryptionState} />;
|
||||
break;
|
||||
case "main":
|
||||
content = (
|
||||
<>
|
||||
<KeyBackupPanel />
|
||||
{keyBackupIsEnabled && (
|
||||
<>
|
||||
<RecoveryPanel
|
||||
onChangeRecoveryKeyClick={(setupNewKey) =>
|
||||
setupNewKey ? setState("set_recovery_key") : setState("change_recovery_key")
|
||||
}
|
||||
/>
|
||||
<Separator kind="section" />
|
||||
</>
|
||||
)}
|
||||
<AdvancedPanel onResetIdentityClick={() => setState("reset_identity")} />
|
||||
<RecoveryPanel
|
||||
onChangeRecoveryKeyClick={(setupNewKey) =>
|
||||
setupNewKey ? setState("set_recovery_key") : setState("change_recovery_key")
|
||||
}
|
||||
/>
|
||||
<Separator kind="section" />
|
||||
<AdvancedPanel onResetIdentityClick={() => setState("reset_identity_compromised")} />
|
||||
</>
|
||||
);
|
||||
break;
|
||||
@@ -112,9 +132,22 @@ export function EncryptionUserSettingsTab(): JSX.Element {
|
||||
/>
|
||||
);
|
||||
break;
|
||||
case "reset_identity":
|
||||
case "reset_identity_compromised":
|
||||
content = (
|
||||
<ResetIdentityPanel onCancelClick={() => setState("main")} onFinish={() => setState("main")} />
|
||||
<ResetIdentityPanel
|
||||
variant="compromised"
|
||||
onCancelClick={() => setState("main")}
|
||||
onFinish={() => setState("main")}
|
||||
/>
|
||||
);
|
||||
break;
|
||||
case "reset_identity_forgot":
|
||||
content = (
|
||||
<ResetIdentityPanel
|
||||
variant="forgot"
|
||||
onCancelClick={() => setState("main")}
|
||||
onFinish={() => setState("main")}
|
||||
/>
|
||||
);
|
||||
break;
|
||||
}
|
||||
@@ -128,8 +161,12 @@ export function EncryptionUserSettingsTab(): JSX.Element {
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to check if the user needs to go through the SetupEncryption flow.
|
||||
* Hook to check if the user needs:
|
||||
* - to go through the SetupEncryption flow.
|
||||
* - to enter their recovery key, if the secrets are not cached locally.
|
||||
*
|
||||
* If the user needs to set up the encryption, the state will be set to "set_up_encryption".
|
||||
* If the user secrets are not cached, the state will be set to "secrets_not_cached".
|
||||
* Otherwise, the state will be set to "main".
|
||||
*
|
||||
* The state is set once when the component is first mounted.
|
||||
@@ -138,23 +175,29 @@ export function EncryptionUserSettingsTab(): JSX.Element {
|
||||
* @param setState - callback passed from the EncryptionUserSettingsTab to set the current `State`.
|
||||
* @returns a callback function, which will re-run the logic and update the state.
|
||||
*/
|
||||
function useSetUpEncryptionRequired(setState: (state: State) => void): () => Promise<void> {
|
||||
function useCheckEncryptionState(state: State, setState: (state: State) => void): () => Promise<void> {
|
||||
const matrixClient = useMatrixClientContext();
|
||||
|
||||
const setUpEncryptionRequired = useCallback(async () => {
|
||||
const checkEncryptionState = useCallback(async () => {
|
||||
const crypto = matrixClient.getCrypto()!;
|
||||
const isCrossSigningReady = await crypto.isCrossSigningReady();
|
||||
if (isCrossSigningReady) setState("main");
|
||||
else setState("set_up_encryption");
|
||||
|
||||
// Check if the secrets are cached
|
||||
const cachedSecrets = (await crypto.getCrossSigningStatus()).privateKeysCachedLocally;
|
||||
const secretsOk = cachedSecrets.masterKey && cachedSecrets.selfSigningKey && cachedSecrets.userSigningKey;
|
||||
|
||||
if (isCrossSigningReady && secretsOk) setState("main");
|
||||
else if (!isCrossSigningReady) setState("set_up_encryption");
|
||||
else setState("secrets_not_cached");
|
||||
}, [matrixClient, setState]);
|
||||
|
||||
// Initialise the state when the component is mounted
|
||||
useEffect(() => {
|
||||
setUpEncryptionRequired();
|
||||
}, [setUpEncryptionRequired]);
|
||||
if (state === "loading") checkEncryptionState();
|
||||
}, [checkEncryptionState, state]);
|
||||
|
||||
// Also return the callback so that the component can re-run the logic.
|
||||
return setUpEncryptionRequired;
|
||||
return checkEncryptionState;
|
||||
}
|
||||
|
||||
interface SetUpEncryptionPanelProps {
|
||||
|
||||
@@ -8,6 +8,7 @@ Please see LICENSE files in the repository root for full details.
|
||||
|
||||
import React, { ReactNode } from "react";
|
||||
import { logger } from "matrix-js-sdk/src/logger";
|
||||
import { EmptyObject } from "matrix-js-sdk/src/matrix";
|
||||
|
||||
import AccessibleButton from "../../../elements/AccessibleButton";
|
||||
import { _t } from "../../../../../languageHandler";
|
||||
@@ -23,18 +24,16 @@ import { SettingsSubsection, SettingsSubsectionText } from "../../shared/Setting
|
||||
import ExternalLink from "../../../elements/ExternalLink";
|
||||
import MatrixClientContext from "../../../../../contexts/MatrixClientContext";
|
||||
|
||||
interface IProps {}
|
||||
|
||||
interface IState {
|
||||
appVersion: string | null;
|
||||
canUpdate: boolean;
|
||||
}
|
||||
|
||||
export default class HelpUserSettingsTab extends React.Component<IProps, IState> {
|
||||
export default class HelpUserSettingsTab extends React.Component<EmptyObject, IState> {
|
||||
public static contextType = MatrixClientContext;
|
||||
declare public context: React.ContextType<typeof MatrixClientContext>;
|
||||
|
||||
public constructor(props: IProps, context: React.ContextType<typeof MatrixClientContext>) {
|
||||
public constructor(props: EmptyObject, context: React.ContextType<typeof MatrixClientContext>) {
|
||||
super(props, context);
|
||||
|
||||
this.state = {
|
||||
|
||||