Compare commits
25 Commits
toger5/add
...
floriandur
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a5dc8bdb2e | ||
|
|
f06ed2fa1f | ||
|
|
099c3073b6 | ||
|
|
12932e2dc6 | ||
|
|
a7de29429c | ||
|
|
d3ea250d77 | ||
|
|
f243fee5a6 | ||
|
|
296d0074ed | ||
|
|
df83338f26 | ||
|
|
c0336f21f6 | ||
|
|
d88f47bdbc | ||
|
|
4a26414957 | ||
|
|
886d0e1241 | ||
|
|
c453d33456 | ||
|
|
ddf221b813 | ||
|
|
08238bb883 | ||
|
|
c390ec333e | ||
|
|
3c22e5dc68 | ||
|
|
f29ce94dd4 | ||
|
|
76485cfb17 | ||
|
|
c0567fc5f4 | ||
|
|
790a976421 | ||
|
|
1e1d66924f | ||
|
|
63ecb48d7d | ||
|
|
5e3fc8aa19 |
@@ -7,3 +7,4 @@ test/end-to-end-tests/lib/
|
||||
src/component-index.js
|
||||
# Auto-generated file
|
||||
src/modules.ts
|
||||
src/modules.js
|
||||
|
||||
1
.gitignore
vendored
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
|
||||
|
||||
18
CHANGELOG.md
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
|
||||
|
||||
@@ -23,10 +23,9 @@ const MODULES_TS_HEADER = `
|
||||
* You are not a salmon.
|
||||
*/
|
||||
|
||||
import { RuntimeModule } from "@matrix-org/react-sdk-module-api/lib/RuntimeModule";
|
||||
`;
|
||||
const MODULES_TS_DEFINITIONS = `
|
||||
export const INSTALLED_MODULES: RuntimeModule[] = [];
|
||||
export const INSTALLED_MODULES = [];
|
||||
`;
|
||||
|
||||
export function installer(config: BuildConfig): void {
|
||||
@@ -78,8 +77,8 @@ export function installer(config: BuildConfig): void {
|
||||
return; // hit the finally{} block before exiting
|
||||
}
|
||||
|
||||
// If we reach here, everything seems fine. Write modules.ts and log some output
|
||||
// Note: we compile modules.ts in two parts for developer friendliness if they
|
||||
// If we reach here, everything seems fine. Write modules.js and log some output
|
||||
// Note: we compile modules.js in two parts for developer friendliness if they
|
||||
// happen to look at it.
|
||||
console.log("The following modules have been installed: ", installedModules);
|
||||
let modulesTsHeader = MODULES_TS_HEADER;
|
||||
@@ -193,5 +192,5 @@ function isModuleVersionCompatible(ourApiVersion: string, moduleApiVersion: stri
|
||||
}
|
||||
|
||||
function writeModulesTs(content: string): void {
|
||||
fs.writeFileSync("./src/modules.ts", content, "utf-8");
|
||||
fs.writeFileSync("./src/modules.js", content, "utf-8");
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "element-web",
|
||||
"version": "1.11.90",
|
||||
"version": "1.11.91",
|
||||
"description": "A feature-rich client for Matrix.org",
|
||||
"author": "New Vector Ltd.",
|
||||
"repository": {
|
||||
@@ -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",
|
||||
@@ -128,7 +128,7 @@
|
||||
"matrix-encrypt-attachment": "^1.0.3",
|
||||
"matrix-events-sdk": "0.0.1",
|
||||
"matrix-js-sdk": "github:matrix-org/matrix-js-sdk#develop",
|
||||
"matrix-widget-api": "^1.13.0",
|
||||
"matrix-widget-api": "^1.10.0",
|
||||
"memoize-one": "^6.0.0",
|
||||
"mime": "^4.0.4",
|
||||
"oidc-client-ts": "^3.0.1",
|
||||
@@ -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",
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -288,19 +288,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 });
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -192,7 +192,6 @@ export class Bot extends Client {
|
||||
|
||||
await clientHandle.evaluate(async (cli) => {
|
||||
await cli.initRustCrypto({ useIndexedDB: false });
|
||||
cli.setGlobalErrorOnUnknownDevices(false);
|
||||
await cli.startClient();
|
||||
});
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ import { Fixtures } from "../../../element-web-test.ts";
|
||||
|
||||
export const consentHomeserver: Fixtures = {
|
||||
_homeserver: [
|
||||
async ({ _homeserver: container, mailhog }, use) => {
|
||||
async ({ _homeserver: container, mailpit }, use) => {
|
||||
container
|
||||
.withCopyDirectoriesToContainer([
|
||||
{ source: "playwright/plugins/homeserver/synapse/res", target: "/data/res" },
|
||||
@@ -18,7 +18,7 @@ export const consentHomeserver: Fixtures = {
|
||||
.withConfig({
|
||||
email: {
|
||||
enable_notifs: false,
|
||||
smtp_host: "mailhog",
|
||||
smtp_host: "mailpit",
|
||||
smtp_port: 1025,
|
||||
smtp_user: "username",
|
||||
smtp_pass: "password",
|
||||
|
||||
@@ -10,13 +10,13 @@ import { Fixtures } from "../../../element-web-test.ts";
|
||||
|
||||
export const emailHomeserver: Fixtures = {
|
||||
_homeserver: [
|
||||
async ({ _homeserver: container, mailhog }, use) => {
|
||||
async ({ _homeserver: container, mailpit }, use) => {
|
||||
container.withConfig({
|
||||
enable_registration_without_verification: undefined,
|
||||
disable_msisdn_registration: undefined,
|
||||
registrations_require_3pid: ["email"],
|
||||
email: {
|
||||
smtp_host: "mailhog",
|
||||
smtp_host: "mailpit",
|
||||
smtp_port: 1025,
|
||||
notif_from: "Your Friendly %(app)s homeserver <noreply@example.com>",
|
||||
app_name: "my_branded_matrix_server",
|
||||
|
||||
@@ -11,7 +11,7 @@ import { Fixtures } from "../../../element-web-test.ts";
|
||||
|
||||
export const masHomeserver: Fixtures = {
|
||||
mas: [
|
||||
async ({ _homeserver: homeserver, logger, network, postgres, mailhog }, use) => {
|
||||
async ({ _homeserver: homeserver, logger, network, postgres, mailpit }, use) => {
|
||||
const config = {
|
||||
clients: [
|
||||
{
|
||||
|
||||
@@ -6,7 +6,7 @@ Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { test as base } from "@playwright/test";
|
||||
import mailhog from "mailhog";
|
||||
import { MailpitClient } from "mailpit-api";
|
||||
import { Network, StartedNetwork } from "testcontainers";
|
||||
import { PostgreSqlContainer, StartedPostgreSqlContainer } from "@testcontainers/postgresql";
|
||||
|
||||
@@ -14,13 +14,13 @@ import { SynapseConfig, SynapseContainer } from "./testcontainers/synapse.ts";
|
||||
import { Logger } from "./logger.ts";
|
||||
import { StartedMatrixAuthenticationServiceContainer } from "./testcontainers/mas.ts";
|
||||
import { HomeserverContainer, StartedHomeserverContainer } from "./testcontainers/HomeserverContainer.ts";
|
||||
import { MailhogContainer, StartedMailhogContainer } from "./testcontainers/mailhog.ts";
|
||||
import { MailhogContainer, StartedMailhogContainer } from "./testcontainers/mailpit.ts";
|
||||
import { OAuthServer } from "./plugins/oauth_server";
|
||||
import { DendriteContainer, PineconeContainer } from "./testcontainers/dendrite.ts";
|
||||
import { HomeserverType } from "./plugins/homeserver";
|
||||
|
||||
export interface TestFixtures {
|
||||
mailhogClient: mailhog.API;
|
||||
mailpitClient: MailpitClient;
|
||||
}
|
||||
|
||||
export interface Services {
|
||||
@@ -28,7 +28,7 @@ export interface Services {
|
||||
|
||||
network: StartedNetwork;
|
||||
postgres: StartedPostgreSqlContainer;
|
||||
mailhog: StartedMailhogContainer;
|
||||
mailpit: StartedMailhogContainer;
|
||||
|
||||
synapseConfig: SynapseConfig;
|
||||
_homeserver: HomeserverContainer<any>;
|
||||
@@ -90,20 +90,20 @@ export const test = base.extend<TestFixtures, Services & Options>({
|
||||
{ scope: "worker" },
|
||||
],
|
||||
|
||||
mailhog: [
|
||||
mailpit: [
|
||||
async ({ logger, network }, use) => {
|
||||
const container = await new MailhogContainer()
|
||||
.withNetwork(network)
|
||||
.withNetworkAliases("mailhog")
|
||||
.withLogConsumer(logger.getConsumer("mailhog"))
|
||||
.withNetworkAliases("mailpit")
|
||||
.withLogConsumer(logger.getConsumer("mailpit"))
|
||||
.start();
|
||||
await use(container);
|
||||
await container.stop();
|
||||
},
|
||||
{ scope: "worker" },
|
||||
],
|
||||
mailhogClient: async ({ mailhog: container }, use) => {
|
||||
await container.client.deleteAll();
|
||||
mailpitClient: async ({ mailpit: container }, use) => {
|
||||
await container.client.deleteMessages();
|
||||
await use(container.client);
|
||||
},
|
||||
|
||||
|
||||
@@ -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:e6b4c69101a0d8fd6ff6a26233eb6f92e984d578476f087c26a0fb72cddc9623";
|
||||
|
||||
const DEFAULT_CONFIG = {
|
||||
server_name: "localhost",
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Binary file not shown.
@@ -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 = {};
|
||||
|
||||
@@ -50,7 +50,7 @@ import ThemeController from "../../settings/controllers/ThemeController";
|
||||
import { startAnyRegistrationFlow } from "../../Registration";
|
||||
import ResizeNotifier from "../../utils/ResizeNotifier";
|
||||
import AutoDiscoveryUtils from "../../utils/AutoDiscoveryUtils";
|
||||
import ThemeWatcher from "../../settings/watchers/ThemeWatcher";
|
||||
import ThemeWatcher, { ThemeWatcherEvent } from "../../settings/watchers/ThemeWatcher";
|
||||
import { FontWatcher } from "../../settings/watchers/FontWatcher";
|
||||
import { storeRoomAliasInCache } from "../../RoomAliasCache";
|
||||
import ToastStore from "../../stores/ToastStore";
|
||||
@@ -131,6 +131,7 @@ import { ConfirmSessionLockTheftView } from "./auth/ConfirmSessionLockTheftView"
|
||||
import { LoginSplashView } from "./auth/LoginSplashView";
|
||||
import { cleanUpDraftsIfRequired } from "../../DraftCleaner";
|
||||
import { InitialCryptoSetupStore } from "../../stores/InitialCryptoSetupStore";
|
||||
import { setTheme } from "../../theme";
|
||||
|
||||
// legacy export
|
||||
export { default as Views } from "../../Views";
|
||||
@@ -463,6 +464,7 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
|
||||
this.themeWatcher = new ThemeWatcher();
|
||||
this.fontWatcher = new FontWatcher();
|
||||
this.themeWatcher.start();
|
||||
this.themeWatcher.on(ThemeWatcherEvent.Change, setTheme);
|
||||
this.fontWatcher.start();
|
||||
|
||||
initSentry(SdkConfig.get("sentry"));
|
||||
@@ -495,6 +497,7 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
|
||||
public componentWillUnmount(): void {
|
||||
Lifecycle.stopMatrixClient();
|
||||
dis.unregister(this.dispatcherRef);
|
||||
this.themeWatcher?.off(ThemeWatcherEvent.Change, setTheme);
|
||||
this.themeWatcher?.stop();
|
||||
this.fontWatcher?.stop();
|
||||
UIStore.destroy();
|
||||
@@ -1695,13 +1698,6 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
|
||||
if (crypto) {
|
||||
const blacklistEnabled = SettingsStore.getValueAt(SettingLevel.DEVICE, "blacklistUnverifiedDevices");
|
||||
crypto.globalBlacklistUnverifiedDevices = blacklistEnabled;
|
||||
|
||||
// With cross-signing enabled, we send to unknown devices
|
||||
// without prompting. Any bad-device status the user should
|
||||
// be aware of will be signalled through the room shield
|
||||
// changing colour. More advanced behaviour will come once
|
||||
// we implement more settings.
|
||||
cli.setGlobalErrorOnUnknownDevices(false);
|
||||
}
|
||||
|
||||
// Cannot be done in OnLoggedIn as at that point the AccountSettingsHandler doesn't yet have a client
|
||||
|
||||
@@ -66,7 +66,7 @@ import RoomPreviewBar from "../views/rooms/RoomPreviewBar";
|
||||
import RoomPreviewCard from "../views/rooms/RoomPreviewCard";
|
||||
import RoomUpgradeWarningBar from "../views/rooms/RoomUpgradeWarningBar";
|
||||
import AuxPanel from "../views/rooms/AuxPanel";
|
||||
import RoomHeader from "../views/rooms/RoomHeader";
|
||||
import RoomHeader from "../views/rooms/RoomHeader/RoomHeader";
|
||||
import { IOOBData, IThreepidInvite } from "../../stores/ThreepidInviteStore";
|
||||
import EffectsOverlay from "../views/elements/EffectsOverlay";
|
||||
import { containsEmoji } from "../../effects/utils";
|
||||
|
||||
@@ -11,7 +11,7 @@ import { MatrixEvent } from "matrix-js-sdk/src/matrix";
|
||||
|
||||
import ResizeNotifier from "../../utils/ResizeNotifier";
|
||||
import ErrorBoundary from "../views/elements/ErrorBoundary";
|
||||
import RoomHeader from "../views/rooms/RoomHeader";
|
||||
import RoomHeader from "../views/rooms/RoomHeader/RoomHeader.tsx";
|
||||
import ScrollPanel from "./ScrollPanel";
|
||||
import EventTileBubble from "../views/messages/EventTileBubble";
|
||||
import NewRoomIntro from "../views/rooms/NewRoomIntro";
|
||||
|
||||
@@ -7,14 +7,14 @@ Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import classNames from "classnames";
|
||||
import React, { useMemo } from "react";
|
||||
import React, { ComponentProps, JSXElementConstructor, useMemo } from "react";
|
||||
|
||||
type FlexProps = {
|
||||
type FlexProps<T extends keyof JSX.IntrinsicElements | JSXElementConstructor<any>> = {
|
||||
/**
|
||||
* The type of the HTML element
|
||||
* @default div
|
||||
*/
|
||||
as?: string;
|
||||
as?: T;
|
||||
/**
|
||||
* The CSS class name.
|
||||
*/
|
||||
@@ -30,7 +30,7 @@ type FlexProps = {
|
||||
*/
|
||||
direction?: "row" | "column" | "row-reverse" | "column-reverse";
|
||||
/**
|
||||
* The alingment of the flex children
|
||||
* The alignment of the flex children
|
||||
* @default start
|
||||
*/
|
||||
align?: "start" | "center" | "end" | "baseline" | "stretch";
|
||||
@@ -48,12 +48,12 @@ type FlexProps = {
|
||||
* the on click event callback
|
||||
*/
|
||||
onClick?: (e: React.MouseEvent) => void;
|
||||
};
|
||||
} & ComponentProps<T>;
|
||||
|
||||
/**
|
||||
* A flexbox container helper
|
||||
*/
|
||||
export function Flex({
|
||||
export function Flex<T extends keyof JSX.IntrinsicElements | JSXElementConstructor<any> = "div">({
|
||||
as = "div",
|
||||
display = "flex",
|
||||
direction = "row",
|
||||
@@ -63,7 +63,7 @@ export function Flex({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.PropsWithChildren<FlexProps>): JSX.Element {
|
||||
}: React.PropsWithChildren<FlexProps<T>>): JSX.Element {
|
||||
const style = useMemo(
|
||||
() => ({
|
||||
"--mx-flex-display": display,
|
||||
|
||||
@@ -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,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,6 +21,7 @@ import { ThreePidInviteTileView } from "./tiles/ThreePidInviteTileView";
|
||||
import { MemberListHeaderView } from "./MemberListHeaderView";
|
||||
import BaseCard from "../../right_panel/BaseCard";
|
||||
import { _t } from "../../../../languageHandler";
|
||||
import { RovingTabIndexProvider } from "../../../../accessibility/RovingTabIndex";
|
||||
|
||||
interface IProps {
|
||||
roomId: string;
|
||||
@@ -86,24 +87,33 @@ const MemberListView: React.FC<IProps> = (props: IProps) => {
|
||||
header={_t("common|people")}
|
||||
onClose={props.onClose}
|
||||
>
|
||||
<Flex align="stretch" direction="column" className="mx_MemberListView_container">
|
||||
<Form.Root>
|
||||
<MemberListHeaderView vm={vm} />
|
||||
</Form.Root>
|
||||
<AutoSizer>
|
||||
{({ height, width }) => (
|
||||
<List
|
||||
rowRenderer={rowRenderer}
|
||||
rowHeight={getRowHeight}
|
||||
// The +1 refers to the additional empty div that we render at the end of the list.
|
||||
rowCount={totalRows + 1}
|
||||
// Subtract the height of MemberlistHeaderView so that the parent div does not overflow.
|
||||
height={height - 113}
|
||||
width={width}
|
||||
/>
|
||||
)}
|
||||
</AutoSizer>
|
||||
</Flex>
|
||||
<RovingTabIndexProvider handleUpDown scrollIntoView>
|
||||
{({ onKeyDownHandler }) => (
|
||||
<Flex
|
||||
align="stretch"
|
||||
direction="column"
|
||||
className="mx_MemberListView_container"
|
||||
onKeyDown={onKeyDownHandler}
|
||||
>
|
||||
<Form.Root>
|
||||
<MemberListHeaderView vm={vm} />
|
||||
</Form.Root>
|
||||
<AutoSizer>
|
||||
{({ height, width }) => (
|
||||
<List
|
||||
rowRenderer={rowRenderer}
|
||||
rowHeight={getRowHeight}
|
||||
// The +1 refers to the additional empty div that we render at the end of the list.
|
||||
rowCount={totalRows + 1}
|
||||
// Subtract the height of MemberlistHeaderView so that the parent div does not overflow.
|
||||
height={height - 113}
|
||||
width={width}
|
||||
/>
|
||||
)}
|
||||
</AutoSizer>
|
||||
</Flex>
|
||||
)}
|
||||
</RovingTabIndexProvider>
|
||||
</BaseCard>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -7,7 +7,7 @@ Please see LICENSE files in the repository root for full details.
|
||||
|
||||
import React from "react";
|
||||
|
||||
import AccessibleButton from "../../../../elements/AccessibleButton";
|
||||
import { RovingAccessibleButton } from "../../../../../../accessibility/RovingTabIndex";
|
||||
|
||||
interface Props {
|
||||
avatarJsx: JSX.Element;
|
||||
@@ -28,7 +28,7 @@ export function MemberTileView(props: Props): JSX.Element {
|
||||
return (
|
||||
// The wrapping div is required to make the magic mouse listener work, for some reason.
|
||||
<div>
|
||||
<AccessibleButton className="mx_MemberTileView" title={props.title} onClick={props.onClick}>
|
||||
<RovingAccessibleButton className="mx_MemberTileView" title={props.title} onClick={props.onClick}>
|
||||
<div className="mx_MemberTileView_left">
|
||||
<div className="mx_MemberTileView_avatar">
|
||||
{props.avatarJsx} {props.presenceJsx}
|
||||
@@ -39,7 +39,7 @@ export function MemberTileView(props: Props): JSX.Element {
|
||||
{userLabelJsx}
|
||||
{props.iconJsx}
|
||||
</div>
|
||||
</AccessibleButton>
|
||||
</RovingAccessibleButton>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,408 +0,0 @@
|
||||
/*
|
||||
Copyright 2024 New Vector Ltd.
|
||||
Copyright 2023 The Matrix.org Foundation C.I.C.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import React, { useCallback, useMemo, useState } from "react";
|
||||
import { Body as BodyText, Button, IconButton, Menu, MenuItem, Tooltip } from "@vector-im/compound-web";
|
||||
import VideoCallIcon from "@vector-im/compound-design-tokens/assets/web/icons/video-call-solid";
|
||||
import VoiceCallIcon from "@vector-im/compound-design-tokens/assets/web/icons/voice-call";
|
||||
import CloseCallIcon from "@vector-im/compound-design-tokens/assets/web/icons/close";
|
||||
import ThreadsIcon from "@vector-im/compound-design-tokens/assets/web/icons/threads-solid";
|
||||
import RoomInfoIcon from "@vector-im/compound-design-tokens/assets/web/icons/info-solid";
|
||||
import NotificationsIcon from "@vector-im/compound-design-tokens/assets/web/icons/notifications-solid";
|
||||
import VerifiedIcon from "@vector-im/compound-design-tokens/assets/web/icons/verified";
|
||||
import ErrorIcon from "@vector-im/compound-design-tokens/assets/web/icons/error";
|
||||
import PublicIcon from "@vector-im/compound-design-tokens/assets/web/icons/public";
|
||||
import { JoinRule, type Room } from "matrix-js-sdk/src/matrix";
|
||||
import { ViewRoomOpts } from "@matrix-org/react-sdk-module-api/lib/lifecycles/RoomViewLifecycle";
|
||||
|
||||
import { useRoomName } from "../../../hooks/useRoomName";
|
||||
import { RightPanelPhases } from "../../../stores/right-panel/RightPanelStorePhases";
|
||||
import { useMatrixClientContext } from "../../../contexts/MatrixClientContext";
|
||||
import { useRoomMemberCount, useRoomMembers } from "../../../hooks/useRoomMembers";
|
||||
import { _t } from "../../../languageHandler";
|
||||
import { Flex } from "../../utils/Flex";
|
||||
import { Box } from "../../utils/Box";
|
||||
import { getPlatformCallTypeProps, useRoomCall } from "../../../hooks/room/useRoomCall";
|
||||
import { useRoomThreadNotifications } from "../../../hooks/room/useRoomThreadNotifications";
|
||||
import { useGlobalNotificationState } from "../../../hooks/useGlobalNotificationState";
|
||||
import SdkConfig from "../../../SdkConfig";
|
||||
import { useFeatureEnabled } from "../../../hooks/useSettings";
|
||||
import { useEncryptionStatus } from "../../../hooks/useEncryptionStatus";
|
||||
import { E2EStatus } from "../../../utils/ShieldUtils";
|
||||
import FacePile from "../elements/FacePile";
|
||||
import { useRoomState } from "../../../hooks/useRoomState";
|
||||
import RoomAvatar from "../avatars/RoomAvatar";
|
||||
import { formatCount } from "../../../utils/FormattingUtils";
|
||||
import RightPanelStore from "../../../stores/right-panel/RightPanelStore";
|
||||
import PosthogTrackers from "../../../PosthogTrackers";
|
||||
import { VideoRoomChatButton } from "./RoomHeader/VideoRoomChatButton";
|
||||
import { RoomKnocksBar } from "./RoomKnocksBar";
|
||||
import { isVideoRoom as calcIsVideoRoom } from "../../../utils/video-rooms";
|
||||
import { notificationLevelToIndicator } from "../../../utils/notifications";
|
||||
import { CallGuestLinkButton } from "./RoomHeader/CallGuestLinkButton";
|
||||
import { ButtonEvent } from "../elements/AccessibleButton";
|
||||
import WithPresenceIndicator, { useDmMember } from "../avatars/WithPresenceIndicator";
|
||||
import { IOOBData } from "../../../stores/ThreepidInviteStore";
|
||||
import { MainSplitContentType } from "../../structures/RoomView";
|
||||
import defaultDispatcher from "../../../dispatcher/dispatcher.ts";
|
||||
import { RoomSettingsTab } from "../dialogs/RoomSettingsDialog.tsx";
|
||||
import { useScopedRoomContext } from "../../../contexts/ScopedRoomContext.tsx";
|
||||
|
||||
export default function RoomHeader({
|
||||
room,
|
||||
additionalButtons,
|
||||
oobData,
|
||||
}: {
|
||||
room: Room;
|
||||
additionalButtons?: ViewRoomOpts["buttons"];
|
||||
oobData?: IOOBData;
|
||||
}): JSX.Element {
|
||||
const client = useMatrixClientContext();
|
||||
|
||||
const roomName = useRoomName(room);
|
||||
const joinRule = useRoomState(room, (state) => state.getJoinRule());
|
||||
|
||||
const members = useRoomMembers(room, 2500);
|
||||
const memberCount = useRoomMemberCount(room, { throttleWait: 2500 });
|
||||
|
||||
const {
|
||||
voiceCallDisabledReason,
|
||||
voiceCallClick,
|
||||
videoCallDisabledReason,
|
||||
videoCallClick,
|
||||
toggleCallMaximized: toggleCall,
|
||||
isViewingCall,
|
||||
isConnectedToCall,
|
||||
hasActiveCallSession,
|
||||
callOptions,
|
||||
showVoiceCallButton,
|
||||
showVideoCallButton,
|
||||
} = useRoomCall(room);
|
||||
|
||||
const groupCallsEnabled = useFeatureEnabled("feature_group_calls");
|
||||
/**
|
||||
* A special mode where only Element Call is used. In this case we want to
|
||||
* hide the voice call button
|
||||
*/
|
||||
const useElementCallExclusively = useMemo(() => {
|
||||
return SdkConfig.get("element_call").use_exclusively && groupCallsEnabled;
|
||||
}, [groupCallsEnabled]);
|
||||
|
||||
const threadNotifications = useRoomThreadNotifications(room);
|
||||
const globalNotificationState = useGlobalNotificationState();
|
||||
|
||||
const dmMember = useDmMember(room);
|
||||
const isDirectMessage = !!dmMember;
|
||||
const e2eStatus = useEncryptionStatus(client, room);
|
||||
|
||||
const notificationsEnabled = useFeatureEnabled("feature_notifications");
|
||||
|
||||
const askToJoinEnabled = useFeatureEnabled("feature_ask_to_join");
|
||||
|
||||
const videoClick = useCallback(
|
||||
(ev: React.MouseEvent) => videoCallClick(ev, callOptions[0]),
|
||||
[callOptions, videoCallClick],
|
||||
);
|
||||
|
||||
const toggleCallButton = (
|
||||
<Tooltip label={isViewingCall ? _t("voip|minimise_call") : _t("voip|maximise_call")}>
|
||||
<IconButton onClick={toggleCall}>
|
||||
<VideoCallIcon />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
);
|
||||
|
||||
const joinCallButton = (
|
||||
<Tooltip label={videoCallDisabledReason ?? _t("voip|video_call")}>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={videoClick}
|
||||
Icon={VideoCallIcon}
|
||||
className="mx_RoomHeader_join_button"
|
||||
disabled={!!videoCallDisabledReason}
|
||||
color="primary"
|
||||
aria-label={videoCallDisabledReason ?? _t("action|join")}
|
||||
>
|
||||
{_t("action|join")}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
);
|
||||
|
||||
const callIconWithTooltip = (
|
||||
<Tooltip label={videoCallDisabledReason ?? _t("voip|video_call")}>
|
||||
<VideoCallIcon />
|
||||
</Tooltip>
|
||||
);
|
||||
|
||||
const [menuOpen, setMenuOpen] = useState(false);
|
||||
|
||||
const onOpenChange = useCallback(
|
||||
(newOpen: boolean) => {
|
||||
if (!videoCallDisabledReason) setMenuOpen(newOpen);
|
||||
},
|
||||
[videoCallDisabledReason],
|
||||
);
|
||||
|
||||
const startVideoCallButton = (
|
||||
<>
|
||||
{/* Can be either a menu or just a button depending on the number of call options.*/}
|
||||
{callOptions.length > 1 ? (
|
||||
<Menu
|
||||
open={menuOpen}
|
||||
onOpenChange={onOpenChange}
|
||||
title={_t("voip|video_call_using")}
|
||||
trigger={
|
||||
<IconButton
|
||||
disabled={!!videoCallDisabledReason}
|
||||
aria-label={videoCallDisabledReason ?? _t("voip|video_call")}
|
||||
>
|
||||
{callIconWithTooltip}
|
||||
</IconButton>
|
||||
}
|
||||
side="left"
|
||||
align="start"
|
||||
>
|
||||
{callOptions.map((option) => {
|
||||
const { label, children } = getPlatformCallTypeProps(option);
|
||||
return (
|
||||
<MenuItem
|
||||
key={option}
|
||||
label={label}
|
||||
aria-label={label}
|
||||
children={children}
|
||||
className="mx_RoomHeader_videoCallOption"
|
||||
onClick={(ev) => videoCallClick(ev, option)}
|
||||
Icon={VideoCallIcon}
|
||||
onSelect={() => {} /* Dummy handler since we want the click event.*/}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</Menu>
|
||||
) : (
|
||||
<IconButton
|
||||
disabled={!!videoCallDisabledReason}
|
||||
aria-label={videoCallDisabledReason ?? _t("voip|video_call")}
|
||||
onClick={videoClick}
|
||||
>
|
||||
{callIconWithTooltip}
|
||||
</IconButton>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
let voiceCallButton: JSX.Element | undefined = (
|
||||
<Tooltip label={voiceCallDisabledReason ?? _t("voip|voice_call")}>
|
||||
<IconButton
|
||||
// We need both: isViewingCall and isConnectedToCall
|
||||
// - in the Lobby we are viewing a call but are not connected to it.
|
||||
// - in pip view we are connected to the call but not viewing it.
|
||||
disabled={!!voiceCallDisabledReason || isViewingCall || isConnectedToCall}
|
||||
aria-label={voiceCallDisabledReason ?? _t("voip|voice_call")}
|
||||
onClick={(ev) => voiceCallClick(ev, callOptions[0])}
|
||||
>
|
||||
<VoiceCallIcon />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
);
|
||||
const closeLobbyButton = (
|
||||
<Tooltip label={_t("voip|close_lobby")}>
|
||||
<IconButton onClick={toggleCall}>
|
||||
<CloseCallIcon />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
);
|
||||
let videoCallButton: JSX.Element | undefined = startVideoCallButton;
|
||||
if (isConnectedToCall) {
|
||||
videoCallButton = toggleCallButton;
|
||||
} else if (isViewingCall) {
|
||||
videoCallButton = closeLobbyButton;
|
||||
}
|
||||
|
||||
if (!showVideoCallButton) {
|
||||
videoCallButton = undefined;
|
||||
}
|
||||
if (!showVoiceCallButton) {
|
||||
voiceCallButton = undefined;
|
||||
}
|
||||
|
||||
const roomContext = useScopedRoomContext("mainSplitContentType");
|
||||
const isVideoRoom = calcIsVideoRoom(room);
|
||||
const showChatButton =
|
||||
isVideoRoom ||
|
||||
roomContext.mainSplitContentType === MainSplitContentType.MaximisedWidget ||
|
||||
roomContext.mainSplitContentType === MainSplitContentType.Call;
|
||||
|
||||
const onAvatarClick = (): void => {
|
||||
defaultDispatcher.dispatch({
|
||||
action: "open_room_settings",
|
||||
initial_tab_id: RoomSettingsTab.General,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Flex as="header" align="center" gap="var(--cpd-space-3x)" className="mx_RoomHeader light-panel">
|
||||
<WithPresenceIndicator room={room} size="8px">
|
||||
{/* We hide this from the tabIndex list as it is a pointer shortcut and superfluous for a11y */}
|
||||
<RoomAvatar
|
||||
room={room}
|
||||
size="40px"
|
||||
oobData={oobData}
|
||||
onClick={onAvatarClick}
|
||||
tabIndex={-1}
|
||||
aria-label={_t("room|header_avatar_open_settings_label")}
|
||||
/>
|
||||
</WithPresenceIndicator>
|
||||
<button
|
||||
aria-label={_t("right_panel|room_summary_card|title")}
|
||||
tabIndex={0}
|
||||
onClick={() => RightPanelStore.instance.showOrHidePhase(RightPanelPhases.RoomSummary)}
|
||||
className="mx_RoomHeader_infoWrapper"
|
||||
>
|
||||
<Box flex="1" className="mx_RoomHeader_info">
|
||||
<BodyText
|
||||
as="div"
|
||||
size="lg"
|
||||
weight="semibold"
|
||||
dir="auto"
|
||||
role="heading"
|
||||
aria-level={1}
|
||||
className="mx_RoomHeader_heading"
|
||||
>
|
||||
<span className="mx_RoomHeader_truncated mx_lineClamp">{roomName}</span>
|
||||
|
||||
{!isDirectMessage && joinRule === JoinRule.Public && (
|
||||
<Tooltip label={_t("common|public_room")} placement="right">
|
||||
<PublicIcon
|
||||
width="16px"
|
||||
height="16px"
|
||||
className="mx_RoomHeader_icon text-secondary"
|
||||
aria-label={_t("common|public_room")}
|
||||
/>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
{isDirectMessage && e2eStatus === E2EStatus.Verified && (
|
||||
<Tooltip label={_t("common|verified")} placement="right">
|
||||
<VerifiedIcon
|
||||
width="16px"
|
||||
height="16px"
|
||||
className="mx_RoomHeader_icon mx_Verified"
|
||||
aria-label={_t("common|verified")}
|
||||
/>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
{isDirectMessage && e2eStatus === E2EStatus.Warning && (
|
||||
<Tooltip label={_t("room|header_untrusted_label")} placement="right">
|
||||
<ErrorIcon
|
||||
width="16px"
|
||||
height="16px"
|
||||
className="mx_RoomHeader_icon mx_Untrusted"
|
||||
aria-label={_t("room|header_untrusted_label")}
|
||||
/>
|
||||
</Tooltip>
|
||||
)}
|
||||
</BodyText>
|
||||
</Box>
|
||||
</button>
|
||||
|
||||
{additionalButtons?.map((props) => {
|
||||
const label = props.label();
|
||||
|
||||
return (
|
||||
<Tooltip label={label} key={props.id}>
|
||||
<IconButton
|
||||
aria-label={label}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
props.onClick();
|
||||
}}
|
||||
>
|
||||
{typeof props.icon === "function" ? props.icon() : props.icon}
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
);
|
||||
})}
|
||||
|
||||
{isViewingCall && <CallGuestLinkButton room={room} />}
|
||||
|
||||
{hasActiveCallSession && !isConnectedToCall && !isViewingCall ? (
|
||||
joinCallButton
|
||||
) : (
|
||||
<>
|
||||
{!isVideoRoom && videoCallButton}
|
||||
{!useElementCallExclusively && !isVideoRoom && voiceCallButton}
|
||||
</>
|
||||
)}
|
||||
|
||||
{showChatButton && <VideoRoomChatButton room={room} />}
|
||||
|
||||
<Tooltip label={_t("common|threads")}>
|
||||
<IconButton
|
||||
indicator={notificationLevelToIndicator(threadNotifications)}
|
||||
onClick={(evt) => {
|
||||
evt.stopPropagation();
|
||||
RightPanelStore.instance.showOrHidePhase(RightPanelPhases.ThreadPanel);
|
||||
PosthogTrackers.trackInteraction("WebRoomHeaderButtonsThreadsButton", evt);
|
||||
}}
|
||||
aria-label={_t("common|threads")}
|
||||
>
|
||||
<ThreadsIcon />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
{notificationsEnabled && (
|
||||
<Tooltip label={_t("notifications|enable_prompt_toast_title")}>
|
||||
<IconButton
|
||||
indicator={notificationLevelToIndicator(globalNotificationState.level)}
|
||||
onClick={(evt) => {
|
||||
evt.stopPropagation();
|
||||
RightPanelStore.instance.showOrHidePhase(RightPanelPhases.NotificationPanel);
|
||||
}}
|
||||
aria-label={_t("notifications|enable_prompt_toast_title")}
|
||||
>
|
||||
<NotificationsIcon />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
<Tooltip label={_t("right_panel|room_summary_card|title")}>
|
||||
<IconButton
|
||||
onClick={(evt) => {
|
||||
evt.stopPropagation();
|
||||
RightPanelStore.instance.showOrHidePhase(RightPanelPhases.RoomSummary);
|
||||
}}
|
||||
aria-label={_t("right_panel|room_summary_card|title")}
|
||||
>
|
||||
<RoomInfoIcon />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
|
||||
{!isDirectMessage && (
|
||||
<BodyText as="div" size="sm" weight="medium">
|
||||
<FacePile
|
||||
className="mx_RoomHeader_members"
|
||||
members={members.slice(0, 3)}
|
||||
size="20px"
|
||||
overflow={false}
|
||||
viewUserOnClick={false}
|
||||
tooltipLabel={_t("room|header_face_pile_tooltip")}
|
||||
onClick={(e: ButtonEvent) => {
|
||||
RightPanelStore.instance.showOrHidePhase(RightPanelPhases.MemberList);
|
||||
e.stopPropagation();
|
||||
}}
|
||||
aria-label={_t("common|n_members", { count: memberCount })}
|
||||
>
|
||||
{formatCount(memberCount)}
|
||||
</FacePile>
|
||||
</BodyText>
|
||||
)}
|
||||
</Flex>
|
||||
{askToJoinEnabled && <RoomKnocksBar room={room} />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
412
src/components/views/rooms/RoomHeader/RoomHeader.tsx
Normal file
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
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);
|
||||
}
|
||||
34
src/contexts/CurrentRightPanelPhaseContext.tsx
Normal file
34
src/contexts/CurrentRightPanelPhaseContext.tsx
Normal file
@@ -0,0 +1,34 @@
|
||||
/*
|
||||
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, { createContext } from "react";
|
||||
|
||||
import { useCurrentPhase } from "../hooks/right-panel/useCurrentPhase";
|
||||
import { RightPanelPhases } from "../stores/right-panel/RightPanelStorePhases";
|
||||
|
||||
type Context = {
|
||||
isPanelOpen: boolean;
|
||||
currentPhase: RightPanelPhases | null;
|
||||
};
|
||||
|
||||
export const CurrentRightPanelPhaseContext = createContext<Context | null>(null);
|
||||
|
||||
type Props = {
|
||||
roomId: string;
|
||||
};
|
||||
|
||||
export const CurrentRightPanelPhaseContextProvider: React.FC<React.PropsWithChildren<Props>> = ({
|
||||
roomId,
|
||||
children,
|
||||
}) => {
|
||||
const { currentPhase, isOpen } = useCurrentPhase(roomId);
|
||||
return (
|
||||
<CurrentRightPanelPhaseContext.Provider value={{ currentPhase, isPanelOpen: isOpen }}>
|
||||
{children}
|
||||
</CurrentRightPanelPhaseContext.Provider>
|
||||
);
|
||||
};
|
||||
45
src/hooks/right-panel/useCurrentPhase.ts
Normal file
45
src/hooks/right-panel/useCurrentPhase.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
/*
|
||||
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, useState } from "react";
|
||||
|
||||
import { SDKContext } from "../../contexts/SDKContext";
|
||||
import { RightPanelPhases } from "../../stores/right-panel/RightPanelStorePhases";
|
||||
import { useEventEmitter } from "../useEventEmitter";
|
||||
import { UPDATE_EVENT } from "../../stores/AsyncStore";
|
||||
|
||||
/**
|
||||
* Returns:
|
||||
* - state which will always reflect the currently active right panel phase or null.
|
||||
* - boolean state representing whether any panel is open or not.
|
||||
* @param roomId room id if available.
|
||||
*/
|
||||
export function useCurrentPhase(roomId?: string): { currentPhase: RightPanelPhases | null; isOpen: boolean } {
|
||||
const sdkContext = useContext(SDKContext);
|
||||
|
||||
const getCurrentPhase = (): RightPanelPhases | null => {
|
||||
const card = roomId
|
||||
? sdkContext.rightPanelStore.currentCardForRoom(roomId)
|
||||
: sdkContext.rightPanelStore.currentCard;
|
||||
return card.phase;
|
||||
};
|
||||
|
||||
const getIsOpen = (): boolean => {
|
||||
const isOpen = roomId ? sdkContext.rightPanelStore.isOpenForRoom(roomId) : sdkContext.rightPanelStore.isOpen;
|
||||
return isOpen;
|
||||
};
|
||||
|
||||
const [currentPhase, setCurrentPhase] = useState<RightPanelPhases | null>(getCurrentPhase());
|
||||
const [isOpen, setIsOpen] = useState<boolean>(getIsOpen());
|
||||
|
||||
useEventEmitter(sdkContext.rightPanelStore, UPDATE_EVENT, () => {
|
||||
setCurrentPhase(getCurrentPhase());
|
||||
setIsOpen(getIsOpen());
|
||||
});
|
||||
|
||||
return { currentPhase, isOpen };
|
||||
}
|
||||
@@ -734,6 +734,44 @@
|
||||
"category_room": "Raum",
|
||||
"caution_colon": "Vorsicht:",
|
||||
"client_versions": "Anwendungsversionen",
|
||||
"crypto": {
|
||||
"4s_public_key_in_account_data": "in den Kontodaten",
|
||||
"4s_public_key_not_in_account_data": "nicht gefunden",
|
||||
"4s_public_key_status": "Öffentlicher Schlüssel des geheimen Speichers:",
|
||||
"backup_key_cached": "lokal zwischengespeichert",
|
||||
"backup_key_cached_status": "Backup-Schlüssel zwischengespeichert:",
|
||||
"backup_key_not_stored": "nicht gespeichert",
|
||||
"backup_key_stored": "im geheimen Speicher",
|
||||
"backup_key_stored_status": "Backup-Schlüssel gespeichert:",
|
||||
"backup_key_unexpected_type": "unerwarteter Typ",
|
||||
"backup_key_well_formed": "wohlgeformt",
|
||||
"cross_signing": "Kreuzsignatur",
|
||||
"cross_signing_cached": "lokal zwischengespeichert",
|
||||
"cross_signing_not_ready": "Kreuzsignatur ist nicht eingerichtet.",
|
||||
"cross_signing_private_keys_in_storage": "im geheimen Speicher",
|
||||
"cross_signing_private_keys_in_storage_status": "Überkreuzsignierung privater Schlüssel:",
|
||||
"cross_signing_private_keys_not_in_storage": "nicht gefunden im Speicher",
|
||||
"cross_signing_public_keys_on_device": "im Speicher",
|
||||
"cross_signing_public_keys_on_device_status": "Überkreuzsignierung öffentlicher Schlüssel:",
|
||||
"cross_signing_ready": "Kreuzsignatur ist einsatzbereit.",
|
||||
"cross_signing_status": "Status der Kreuzsignatur",
|
||||
"cross_signing_untrusted": "Ihr Konto verfügt über eine Cross-Signing-Identität im geheimen Speicher, diese wird von dieser Sitzung jedoch noch nicht als vertrauenswürdig eingestuft.",
|
||||
"crypto_not_available": "Das kryptografische Modul ist nicht verfügbar",
|
||||
"key_backup_active_version": "Aktive Backup Version:",
|
||||
"key_backup_active_version_none": "Keine",
|
||||
"key_backup_inactive_warning": "Für die Schlüssel dieser Session gibt es kein Backup",
|
||||
"key_backup_latest_version": "Aktuelle Backup-Version auf dem Server:",
|
||||
"key_storage": "Schlüsselspeicher",
|
||||
"master_private_key_cached_status": "Privater Hauptschlüssel:",
|
||||
"not_found": "nicht gefunden",
|
||||
"not_found_locally": "nicht lokal gefunden",
|
||||
"secret_storage_not_ready": "nicht bereit",
|
||||
"secret_storage_ready": "bereit",
|
||||
"secret_storage_status": "Geheimer Speicher:",
|
||||
"self_signing_private_key_cached_status": "Selbstsignierender privater Schlüssel:",
|
||||
"title": "Ende-zu-Ende Verschlüsselung",
|
||||
"user_signing_private_key_cached_status": "Privater Schlüssel zur Benutzersignatur:"
|
||||
},
|
||||
"developer_mode": "Entwicklungsmodus",
|
||||
"developer_tools": "Entwicklungswerkzeuge",
|
||||
"edit_setting": "Einstellung bearbeiten",
|
||||
@@ -2421,6 +2459,24 @@
|
||||
"enable_markdown": "Markdown aktivieren",
|
||||
"enable_markdown_description": "Beginne Nachrichten mit <code>/plain</code>, um sie ohne Markdown zu senden.",
|
||||
"encryption": {
|
||||
"advanced": {
|
||||
"breadcrumb_first_description": "Ihre Kontodaten, Kontakte, Einstellungen und Chat-Liste werden gespeichert",
|
||||
"breadcrumb_page": "Verschlüsselung zurücksetzen",
|
||||
"breadcrumb_second_description": "Sie verlieren jeglichen Nachrichtenverlauf, der nur auf dem Server gespeichert ist",
|
||||
"breadcrumb_third_description": "Sie müssen alle Ihre vorhandenen Geräte und Kontakte erneut verifizieren",
|
||||
"breadcrumb_title": "Sind Sie sicher, dass Sie Ihre Identität zurücksetzen möchten?",
|
||||
"breadcrumb_warning": "Tun Sie dies nur, wenn Sie glauben, dass Ihr Konto kompromittiert wurde.",
|
||||
"details_title": "Angaben zur Verschlüsselung",
|
||||
"export_keys": "Schlüssel exportieren",
|
||||
"import_keys": "Schlüssel importieren",
|
||||
"other_people_device_description": "Senden Sie in verschlüsselten Räumen standardmäßig keine verschlüsselten Nachrichten an Dritte, bis Sie diese verifiziert haben",
|
||||
"other_people_device_label": "Senden Sie niemals verschlüsselte Nachrichten an nicht verifizierte Geräte",
|
||||
"other_people_device_title": "Geräte anderer Personen",
|
||||
"reset_identity": "Kryptografische Identität zurücksetzen",
|
||||
"session_id": "Sitzungs-ID:",
|
||||
"session_key": "Sitzungsschlüssel:",
|
||||
"title": "Advanced"
|
||||
},
|
||||
"device_not_verified_button": "Dieses Gerät verifizieren",
|
||||
"device_not_verified_description": "Sie müssen dieses Gerät verifizieren, um Ihre Verschlüsselungseinstellungen einsehen zu können.",
|
||||
"device_not_verified_title": "Gerät nicht verifiziert",
|
||||
|
||||
13
src/modules.d.ts
vendored
Normal file
13
src/modules.d.ts
vendored
Normal file
@@ -0,0 +1,13 @@
|
||||
/*
|
||||
Copyright 2025 New Vector Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { ModuleApi, RuntimeModule } from "@matrix-org/react-sdk-module-api";
|
||||
|
||||
declare module "./modules.js" {
|
||||
export type RuntimeModuleConstructor = { new (api: ModuleApi): RuntimeModule };
|
||||
export const INSTALLED_MODULES: RuntimeModuleConstructor[];
|
||||
}
|
||||
@@ -8,16 +8,25 @@ Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { logger } from "matrix-js-sdk/src/logger";
|
||||
import { TypedEventEmitter } from "matrix-js-sdk/src/matrix";
|
||||
|
||||
import SettingsStore from "../SettingsStore";
|
||||
import dis from "../../dispatcher/dispatcher";
|
||||
import { Action } from "../../dispatcher/actions";
|
||||
import ThemeController from "../controllers/ThemeController";
|
||||
import { findHighContrastTheme, setTheme } from "../../theme";
|
||||
import { findHighContrastTheme } from "../../theme";
|
||||
import { ActionPayload } from "../../dispatcher/payloads";
|
||||
import { SettingLevel } from "../SettingLevel";
|
||||
|
||||
export default class ThemeWatcher {
|
||||
export enum ThemeWatcherEvent {
|
||||
Change = "change",
|
||||
}
|
||||
|
||||
interface ThemeWatcherEventHandlerMap {
|
||||
[ThemeWatcherEvent.Change]: (theme: string) => void;
|
||||
}
|
||||
|
||||
export default class ThemeWatcher extends TypedEventEmitter<ThemeWatcherEvent, ThemeWatcherEventHandlerMap> {
|
||||
private themeWatchRef?: string;
|
||||
private systemThemeWatchRef?: string;
|
||||
private dispatcherRef?: string;
|
||||
@@ -29,6 +38,7 @@ export default class ThemeWatcher {
|
||||
private currentTheme: string;
|
||||
|
||||
public constructor() {
|
||||
super();
|
||||
// we have both here as each may either match or not match, so by having both
|
||||
// we can get the tristate of dark/light/unsupported
|
||||
this.preferDark = (<any>global).matchMedia("(prefers-color-scheme: dark)");
|
||||
@@ -72,9 +82,7 @@ export default class ThemeWatcher {
|
||||
public recheck(forceTheme?: string): void {
|
||||
const oldTheme = this.currentTheme;
|
||||
this.currentTheme = forceTheme === undefined ? this.getEffectiveTheme() : forceTheme;
|
||||
if (oldTheme !== this.currentTheme) {
|
||||
setTheme(this.currentTheme);
|
||||
}
|
||||
if (oldTheme !== this.currentTheme) this.emit(ThemeWatcherEvent.Change, this.currentTheme);
|
||||
}
|
||||
|
||||
public getEffectiveTheme(): string {
|
||||
|
||||
@@ -239,7 +239,7 @@ export default class RightPanelStore extends ReadyWatchingStore {
|
||||
* @param cardState The state within the phase.
|
||||
*/
|
||||
public showOrHidePhase(phase: RightPanelPhases, cardState?: Partial<IRightPanelCardState>): void {
|
||||
if (this.currentCard.phase == phase && !cardState && this.isOpen) {
|
||||
if (this.currentCard.phase === phase && !cardState && this.isOpen) {
|
||||
this.togglePanel(null);
|
||||
} else {
|
||||
this.setCard({ phase, state: cardState });
|
||||
|
||||
@@ -43,7 +43,6 @@ import { MatrixClientPeg } from "../../MatrixClientPeg";
|
||||
import { OwnProfileStore } from "../OwnProfileStore";
|
||||
import WidgetUtils from "../../utils/WidgetUtils";
|
||||
import { IntegrationManagers } from "../../integrations/IntegrationManagers";
|
||||
import SettingsStore from "../../settings/SettingsStore";
|
||||
import { WidgetType } from "../../widgets/WidgetType";
|
||||
import ActiveWidgetStore from "../ActiveWidgetStore";
|
||||
import { objectShallowClone } from "../../utils/objects";
|
||||
@@ -52,7 +51,7 @@ import { Action } from "../../dispatcher/actions";
|
||||
import { ElementWidgetActions, IHangupCallApiRequest, IViewRoomApiRequest } from "./ElementWidgetActions";
|
||||
import { ModalWidgetStore } from "../ModalWidgetStore";
|
||||
import { IApp, isAppWidget } from "../WidgetStore";
|
||||
import ThemeWatcher from "../../settings/watchers/ThemeWatcher";
|
||||
import ThemeWatcher, { ThemeWatcherEvent } from "../../settings/watchers/ThemeWatcher";
|
||||
import { getCustomTheme } from "../../theme";
|
||||
import { ElementWidgetCapabilities } from "./ElementWidgetCapabilities";
|
||||
import { ELEMENT_CLIENT_ID } from "../../identifiers";
|
||||
@@ -163,6 +162,7 @@ export class StopGapWidget extends EventEmitter {
|
||||
private viewedRoomId: string | null = null;
|
||||
private kind: WidgetKind;
|
||||
private readonly virtual: boolean;
|
||||
private readonly themeWatcher = new ThemeWatcher();
|
||||
private readUpToMap: { [roomId: string]: string } = {}; // room ID to event ID
|
||||
// This promise will be called and needs to resolve before the widget will actually become sticky.
|
||||
private stickyPromise?: () => Promise<void>;
|
||||
@@ -213,7 +213,7 @@ export class StopGapWidget extends EventEmitter {
|
||||
userDisplayName: OwnProfileStore.instance.displayName ?? undefined,
|
||||
userHttpAvatarUrl: OwnProfileStore.instance.getHttpAvatarUrl() ?? undefined,
|
||||
clientId: ELEMENT_CLIENT_ID,
|
||||
clientTheme: SettingsStore.getValue("theme"),
|
||||
clientTheme: this.themeWatcher.getEffectiveTheme(),
|
||||
clientLanguage: getUserLanguage(),
|
||||
deviceId: this.client.getDeviceId() ?? undefined,
|
||||
baseUrl: this.client.baseUrl,
|
||||
@@ -245,6 +245,10 @@ export class StopGapWidget extends EventEmitter {
|
||||
return !!this.messaging;
|
||||
}
|
||||
|
||||
private onThemeChange = (theme: string): void => {
|
||||
this.messaging?.updateTheme({ name: theme });
|
||||
};
|
||||
|
||||
private onOpenModal = async (ev: CustomEvent<IModalWidgetOpenRequest>): Promise<void> => {
|
||||
ev.preventDefault();
|
||||
if (ModalWidgetStore.instance.canOpenModalWidget()) {
|
||||
@@ -288,9 +292,14 @@ export class StopGapWidget extends EventEmitter {
|
||||
this.messaging = new ClientWidgetApi(this.mockWidget, iframe, driver);
|
||||
this.messaging.on("preparing", () => this.emit("preparing"));
|
||||
this.messaging.on("error:preparing", (err: unknown) => this.emit("error:preparing", err));
|
||||
this.messaging.on("ready", () => {
|
||||
this.messaging.once("ready", () => {
|
||||
WidgetMessagingStore.instance.storeMessaging(this.mockWidget, this.roomId, this.messaging!);
|
||||
this.emit("ready");
|
||||
|
||||
this.themeWatcher.start();
|
||||
this.themeWatcher.on(ThemeWatcherEvent.Change, this.onThemeChange);
|
||||
// Theme may have changed while messaging was starting
|
||||
this.onThemeChange(this.themeWatcher.getEffectiveTheme());
|
||||
});
|
||||
this.messaging.on("capabilitiesNotified", () => this.emit("capabilitiesNotified"));
|
||||
this.messaging.on(`action:${WidgetApiFromWidgetAction.OpenModalWidget}`, this.onOpenModal);
|
||||
|
||||
@@ -27,7 +27,6 @@ import {
|
||||
ISearchUserDirectoryResult,
|
||||
IGetMediaConfigResult,
|
||||
UpdateDelayedEventAction,
|
||||
Symbols,
|
||||
} from "matrix-widget-api";
|
||||
import {
|
||||
ClientEvent,
|
||||
@@ -41,7 +40,6 @@ import {
|
||||
SendDelayedEventResponse,
|
||||
StateEvents,
|
||||
TimelineEvents,
|
||||
Room,
|
||||
} from "matrix-js-sdk/src/matrix";
|
||||
import { logger } from "matrix-js-sdk/src/logger";
|
||||
import {
|
||||
@@ -67,7 +65,7 @@ import { navigateToPermalink } from "../../utils/permalinks/navigator";
|
||||
import { SdkContextClass } from "../../contexts/SDKContext";
|
||||
import { ModuleRunner } from "../../modules/ModuleRunner";
|
||||
import SettingsStore from "../../settings/SettingsStore";
|
||||
import { Media } from "../../customisations/Media";
|
||||
import { mediaFromMxc } from "../../customisations/Media";
|
||||
|
||||
// TODO: Purge this from the universe
|
||||
|
||||
@@ -686,7 +684,7 @@ export class StopGapWidgetDriver extends WidgetDriver {
|
||||
*/
|
||||
public async downloadFile(contentUri: string): Promise<{ file: XMLHttpRequestBodyInit }> {
|
||||
const client = MatrixClientPeg.safeGet();
|
||||
const media = new Media({ mxc: contentUri }, client);
|
||||
const media = mediaFromMxc(contentUri, client);
|
||||
const response = await media.downloadSource();
|
||||
const blob = await response.blob();
|
||||
return { file: blob };
|
||||
@@ -713,87 +711,4 @@ export class StopGapWidgetDriver extends WidgetDriver {
|
||||
public processError(error: unknown): IWidgetApiErrorResponseDataDetails | undefined {
|
||||
return error instanceof MatrixError ? { matrix_api_error: error.asWidgetApiErrorData() } : undefined;
|
||||
}
|
||||
|
||||
// DEPRECATED FOR BACKWARDS COMPATIBILITY
|
||||
|
||||
/**
|
||||
* Picks the rooms where the widget can read events from. If no ids are passed it will use the currently viewed room of EW to
|
||||
* get the matrix room used for event reading.
|
||||
* @param roomIds optional room ids. (this version of the api allows to not pass a room id. This is deprecated now. )
|
||||
* @returns The matrix room where the widget will get events from.
|
||||
* @deprecated it is recommended to use: readRoomTimeline and readRoomState where an explicit room id is required.
|
||||
*/
|
||||
private pickRooms(roomIds?: (string | Symbols.AnyRoom)[]): Room[] {
|
||||
const client = MatrixClientPeg.get();
|
||||
if (!client) throw new Error("Not attached to a client");
|
||||
|
||||
const targetRooms = roomIds
|
||||
? roomIds.includes(Symbols.AnyRoom)
|
||||
? client.getVisibleRooms(SettingsStore.getValue("feature_dynamic_room_predecessors"))
|
||||
: roomIds.map((r) => client.getRoom(r))
|
||||
: [client.getRoom(SdkContextClass.instance.roomViewStore.getRoomId()!)];
|
||||
return targetRooms.filter((r) => !!r) as Room[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads state events from the room. Uses the `pickRooms` method and hence has roomIds is optional.
|
||||
* @deprecated it is recommended to use: `readRoomState` where an explicit room id is required.
|
||||
*/
|
||||
public async readStateEvents(
|
||||
eventType: string,
|
||||
stateKey: string | undefined,
|
||||
limitPerRoom: number,
|
||||
roomIds?: (string | Symbols.AnyRoom)[],
|
||||
): Promise<IRoomEvent[]> {
|
||||
limitPerRoom = limitPerRoom > 0 ? Math.min(limitPerRoom, Number.MAX_SAFE_INTEGER) : Number.MAX_SAFE_INTEGER; // relatively arbitrary
|
||||
|
||||
const rooms = this.pickRooms(roomIds);
|
||||
const allResults: IRoomEvent[] = [];
|
||||
for (const room of rooms) {
|
||||
const results: MatrixEvent[] = [];
|
||||
const state = room.currentState.events.get(eventType);
|
||||
if (state) {
|
||||
if (stateKey === "" || !!stateKey) {
|
||||
const forKey = state.get(stateKey);
|
||||
if (forKey) results.push(forKey);
|
||||
} else {
|
||||
results.push(...Array.from(state.values()));
|
||||
}
|
||||
}
|
||||
|
||||
results.slice(0, limitPerRoom).forEach((e) => allResults.push(e.getEffectiveEvent() as IRoomEvent));
|
||||
}
|
||||
return allResults;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads timeline events from the room. Uses the `pickRooms` method and hence has roomIds is optional.
|
||||
* @deprecated it is recommended to use: `readRoomTimeline` where an explicit room id is required.
|
||||
*/
|
||||
public async readRoomEvents(
|
||||
eventType: string,
|
||||
msgtype: string | undefined,
|
||||
limitPerRoom: number,
|
||||
roomIds?: (string | Symbols.AnyRoom)[],
|
||||
): Promise<IRoomEvent[]> {
|
||||
limitPerRoom = limitPerRoom > 0 ? Math.min(limitPerRoom, Number.MAX_SAFE_INTEGER) : Number.MAX_SAFE_INTEGER; // relatively arbitrary
|
||||
|
||||
const rooms = this.pickRooms(roomIds);
|
||||
const allResults: IRoomEvent[] = [];
|
||||
for (const room of rooms) {
|
||||
const results: MatrixEvent[] = [];
|
||||
const events = room.getLiveTimeline().getEvents(); // timelines are most recent last
|
||||
for (let i = events.length - 1; i > 0; i--) {
|
||||
if (results.length >= limitPerRoom) break;
|
||||
|
||||
const ev = events[i];
|
||||
if (ev.getType() !== eventType || ev.isState()) continue;
|
||||
if (eventType === EventType.RoomMessage && msgtype && msgtype !== ev.getContent()["msgtype"]) continue;
|
||||
results.push(ev);
|
||||
}
|
||||
|
||||
results.forEach((e) => allResults.push(e.getEffectiveEvent() as IRoomEvent));
|
||||
}
|
||||
return allResults;
|
||||
}
|
||||
}
|
||||
|
||||
30
src/utils/crypto/areLocalSecretsAvailable.ts
Normal file
30
src/utils/crypto/areLocalSecretsAvailable.ts
Normal file
@@ -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 { MatrixClient } from "matrix-js-sdk/src/matrix";
|
||||
|
||||
/**
|
||||
* This function checks if:
|
||||
* - the cross-signing private keys are cached locally
|
||||
* - the backup decryption key is also available locally
|
||||
*
|
||||
* @param matrixClient
|
||||
* @returns true if the secrets are cached and the backup decryption key is available, false otherwise
|
||||
*/
|
||||
export async function areLocalSecretsAvailable(matrixClient: MatrixClient): Promise<boolean> {
|
||||
const crypto = matrixClient.getCrypto();
|
||||
if (!crypto) return false;
|
||||
|
||||
// Check if the secrets are cached
|
||||
const cachedSecrets = (await crypto.getCrossSigningStatus()).privateKeysCachedLocally;
|
||||
const secretsOk = cachedSecrets.masterKey && cachedSecrets.selfSigningKey && cachedSecrets.userSigningKey;
|
||||
|
||||
// Check if the user has access to the backup decryption key
|
||||
const backupDecryptionKeyOk = Boolean(await matrixClient.secretStorage.get("m.megolm_backup.v1"));
|
||||
|
||||
return secretsOk && backupDecryptionKeyOk;
|
||||
}
|
||||
@@ -125,12 +125,8 @@ export async function showIncompatibleBrowser(onAccept: () => void): Promise<voi
|
||||
}
|
||||
|
||||
export async function loadModules(): Promise<void> {
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore - this path is created at runtime and therefore won't exist at typecheck time
|
||||
const { INSTALLED_MODULES } = await import("../modules");
|
||||
const { INSTALLED_MODULES } = await import("../modules.js");
|
||||
for (const InstalledModule of INSTALLED_MODULES) {
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore - we know the constructor exists even if TypeScript can't be convinced of that
|
||||
ModuleRunner.instance.registerModule((api) => new InstalledModule(api));
|
||||
}
|
||||
}
|
||||
|
||||
56
test/components/views/dialogs/ModalWidgetDialog-test.tsx
Normal file
56
test/components/views/dialogs/ModalWidgetDialog-test.tsx
Normal file
@@ -0,0 +1,56 @@
|
||||
/*
|
||||
Copyright 2024 New Vector Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
|
||||
Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { fireEvent, render } from "jest-matrix-react";
|
||||
import { ClientWidgetApi, MatrixWidgetType } from "matrix-widget-api";
|
||||
import React from "react";
|
||||
import { TooltipProvider } from "@vector-im/compound-web";
|
||||
import { mocked } from "jest-mock";
|
||||
import { findLast, last } from "lodash";
|
||||
|
||||
import ModalWidgetDialog from "../../../../src/components/views/dialogs/ModalWidgetDialog";
|
||||
import { stubClient } from "../../../test-utils";
|
||||
import defaultDispatcher from "../../../../src/dispatcher/dispatcher";
|
||||
import { Action } from "../../../../src/dispatcher/actions";
|
||||
import SettingsStore from "../../../../src/settings/SettingsStore";
|
||||
|
||||
jest.mock("matrix-widget-api", () => ({
|
||||
...jest.requireActual("matrix-widget-api"),
|
||||
ClientWidgetApi: (jest.createMockFromModule("matrix-widget-api") as any).ClientWidgetApi,
|
||||
}));
|
||||
|
||||
describe("ModalWidgetDialog", () => {
|
||||
it("informs the widget of theme changes", () => {
|
||||
stubClient();
|
||||
let theme = "light";
|
||||
const settingsSpy = jest
|
||||
.spyOn(SettingsStore, "getValue")
|
||||
.mockImplementation((name) => (name === "theme" ? theme : null));
|
||||
try {
|
||||
render(
|
||||
<TooltipProvider>
|
||||
<ModalWidgetDialog
|
||||
widgetDefinition={{ type: MatrixWidgetType.Custom, url: "https://example.org" }}
|
||||
sourceWidgetId=""
|
||||
onFinished={() => {}}
|
||||
/>
|
||||
</TooltipProvider>,
|
||||
);
|
||||
// Indicate that the widget is loaded and ready
|
||||
fireEvent.load(document.getElementsByTagName("iframe").item(0)!);
|
||||
const messaging = mocked(last(mocked(ClientWidgetApi).mock.instances)!);
|
||||
findLast(messaging.once.mock.calls, ([eventName]) => eventName === "ready")![1]();
|
||||
|
||||
// Now change the theme
|
||||
theme = "dark";
|
||||
defaultDispatcher.dispatch({ action: Action.RecheckTheme }, true);
|
||||
expect(messaging.updateTheme).toHaveBeenLastCalledWith({ name: "dark" });
|
||||
} finally {
|
||||
settingsSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -7,11 +7,18 @@ Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { mocked } from "jest-mock";
|
||||
import { CryptoApi } from "matrix-js-sdk/src/crypto-api";
|
||||
import { act } from "react";
|
||||
import { Crypto } from "@peculiar/webcrypto";
|
||||
import { CryptoApi, deriveRecoveryKeyFromPassphrase } from "matrix-js-sdk/src/crypto-api";
|
||||
import { SecretStorage } from "matrix-js-sdk/src/matrix";
|
||||
|
||||
import { accessSecretStorage } from "../../src/SecurityManager";
|
||||
import { accessSecretStorage, crossSigningCallbacks } from "../../src/SecurityManager";
|
||||
import { filterConsole, stubClient } from "../test-utils";
|
||||
import Modal from "../../src/Modal.tsx";
|
||||
import {
|
||||
default as AccessSecretStorageDialog,
|
||||
KeyParams,
|
||||
} from "../../src/components/views/dialogs/security/AccessSecretStorageDialog.tsx";
|
||||
|
||||
jest.mock("react", () => {
|
||||
const React = jest.requireActual("react");
|
||||
@@ -19,6 +26,10 @@ jest.mock("react", () => {
|
||||
return React;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("SecurityManager", () => {
|
||||
describe("accessSecretStorage", () => {
|
||||
filterConsole("Not setting dehydration key: no SSSS key found");
|
||||
@@ -74,4 +85,81 @@ describe("SecurityManager", () => {
|
||||
await expect(spy.mock.lastCall![0]).resolves.toEqual(expect.objectContaining({ __test: true }));
|
||||
});
|
||||
});
|
||||
|
||||
describe("getSecretStorageKey", () => {
|
||||
const { getSecretStorageKey } = crossSigningCallbacks;
|
||||
|
||||
/** Polyfill crypto.subtle, which is unavailable in jsdom */
|
||||
function polyFillSubtleCrypto() {
|
||||
Object.defineProperty(globalThis.crypto, "subtle", { value: new Crypto().subtle });
|
||||
}
|
||||
|
||||
it("should prompt the user if the key is uncached", async () => {
|
||||
polyFillSubtleCrypto();
|
||||
|
||||
const client = stubClient();
|
||||
mocked(client.secretStorage.getDefaultKeyId).mockResolvedValue("my_default_key");
|
||||
|
||||
const passphrase = "s3cret";
|
||||
const { recoveryKey, keyInfo } = await deriveKeyFromPassphrase(passphrase);
|
||||
|
||||
jest.spyOn(Modal, "createDialog").mockImplementation((component) => {
|
||||
expect(component).toBe(AccessSecretStorageDialog);
|
||||
|
||||
const modalFunc = async () => [{ passphrase }] as [KeyParams];
|
||||
return {
|
||||
finished: modalFunc(),
|
||||
close: () => {},
|
||||
};
|
||||
});
|
||||
|
||||
const [keyId, key] = (await act(() =>
|
||||
getSecretStorageKey!({ keys: { my_default_key: keyInfo } }, "my_secret"),
|
||||
))!;
|
||||
expect(keyId).toEqual("my_default_key");
|
||||
expect(key).toEqual(recoveryKey);
|
||||
});
|
||||
|
||||
it("should not prompt the user if the requested key is not the default", async () => {
|
||||
const client = stubClient();
|
||||
mocked(client.secretStorage.getDefaultKeyId).mockResolvedValue("my_default_key");
|
||||
const createDialogSpy = jest.spyOn(Modal, "createDialog");
|
||||
|
||||
await expect(
|
||||
act(() =>
|
||||
getSecretStorageKey!(
|
||||
{ keys: { other_key: {} as SecretStorage.SecretStorageKeyDescription } },
|
||||
"my_secret",
|
||||
),
|
||||
),
|
||||
).rejects.toThrow("Request for non-default 4S key");
|
||||
expect(createDialogSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
/** Derive a key from a passphrase, also returning the KeyInfo */
|
||||
async function deriveKeyFromPassphrase(
|
||||
passphrase: string,
|
||||
): Promise<{ recoveryKey: Uint8Array; keyInfo: SecretStorage.SecretStorageKeyDescription }> {
|
||||
const salt = "SALTYGOODNESS";
|
||||
const iterations = 1000;
|
||||
|
||||
const recoveryKey = await deriveRecoveryKeyFromPassphrase(passphrase, salt, iterations);
|
||||
|
||||
const check = await SecretStorage.calculateKeyCheck(recoveryKey);
|
||||
return {
|
||||
recoveryKey,
|
||||
keyInfo: {
|
||||
iv: check.iv,
|
||||
mac: check.mac,
|
||||
algorithm: SecretStorage.SECRET_STORAGE_ALGORITHM_V1_AES,
|
||||
name: "",
|
||||
passphrase: {
|
||||
algorithm: "m.pbkdf2",
|
||||
iterations,
|
||||
salt,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -125,7 +125,6 @@ describe("<MatrixChat />", () => {
|
||||
}),
|
||||
getVisibleRooms: jest.fn().mockReturnValue([]),
|
||||
getRooms: jest.fn().mockReturnValue([]),
|
||||
setGlobalErrorOnUnknownDevices: jest.fn(),
|
||||
getCrypto: jest.fn().mockReturnValue({
|
||||
getVerificationRequestsToDeviceInProgress: jest.fn().mockReturnValue([]),
|
||||
isCrossSigningReady: jest.fn().mockReturnValue(false),
|
||||
|
||||
@@ -110,6 +110,7 @@ exports[`RoomView for a local room in state CREATING should match the snapshot 1
|
||||
style="--cpd-icon-button-size: 100%;"
|
||||
>
|
||||
<svg
|
||||
class=""
|
||||
fill="currentColor"
|
||||
height="1em"
|
||||
viewBox="0 0 24 24"
|
||||
@@ -135,6 +136,7 @@ exports[`RoomView for a local room in state CREATING should match the snapshot 1
|
||||
style="--cpd-icon-button-size: 100%;"
|
||||
>
|
||||
<svg
|
||||
class=""
|
||||
fill="currentColor"
|
||||
height="1em"
|
||||
viewBox="0 0 24 24"
|
||||
@@ -323,6 +325,7 @@ exports[`RoomView for a local room in state ERROR should match the snapshot 1`]
|
||||
style="--cpd-icon-button-size: 100%;"
|
||||
>
|
||||
<svg
|
||||
class=""
|
||||
fill="currentColor"
|
||||
height="1em"
|
||||
viewBox="0 0 24 24"
|
||||
@@ -348,6 +351,7 @@ exports[`RoomView for a local room in state ERROR should match the snapshot 1`]
|
||||
style="--cpd-icon-button-size: 100%;"
|
||||
>
|
||||
<svg
|
||||
class=""
|
||||
fill="currentColor"
|
||||
height="1em"
|
||||
viewBox="0 0 24 24"
|
||||
@@ -621,6 +625,7 @@ exports[`RoomView for a local room in state NEW should match the snapshot 1`] =
|
||||
style="--cpd-icon-button-size: 100%;"
|
||||
>
|
||||
<svg
|
||||
class=""
|
||||
fill="currentColor"
|
||||
height="1em"
|
||||
viewBox="0 0 24 24"
|
||||
@@ -646,6 +651,7 @@ exports[`RoomView for a local room in state NEW should match the snapshot 1`] =
|
||||
style="--cpd-icon-button-size: 100%;"
|
||||
>
|
||||
<svg
|
||||
class=""
|
||||
fill="currentColor"
|
||||
height="1em"
|
||||
viewBox="0 0 24 24"
|
||||
@@ -996,6 +1002,7 @@ exports[`RoomView for a local room in state NEW that is encrypted should match t
|
||||
style="--cpd-icon-button-size: 100%;"
|
||||
>
|
||||
<svg
|
||||
class=""
|
||||
fill="currentColor"
|
||||
height="1em"
|
||||
viewBox="0 0 24 24"
|
||||
@@ -1021,6 +1028,7 @@ exports[`RoomView for a local room in state NEW that is encrypted should match t
|
||||
style="--cpd-icon-button-size: 100%;"
|
||||
>
|
||||
<svg
|
||||
class=""
|
||||
fill="currentColor"
|
||||
height="1em"
|
||||
viewBox="0 0 24 24"
|
||||
@@ -1379,6 +1387,7 @@ exports[`RoomView should not display the timeline when the room encryption is lo
|
||||
style="--cpd-icon-button-size: 100%;"
|
||||
>
|
||||
<svg
|
||||
class=""
|
||||
fill="currentColor"
|
||||
height="1em"
|
||||
viewBox="0 0 24 24"
|
||||
@@ -1404,6 +1413,7 @@ exports[`RoomView should not display the timeline when the room encryption is lo
|
||||
style="--cpd-icon-button-size: 100%;"
|
||||
>
|
||||
<svg
|
||||
class=""
|
||||
fill="currentColor"
|
||||
height="1em"
|
||||
viewBox="0 0 24 24"
|
||||
@@ -1585,6 +1595,7 @@ exports[`RoomView should not display the timeline when the room encryption is lo
|
||||
style="--cpd-icon-button-size: 100%;"
|
||||
>
|
||||
<svg
|
||||
class=""
|
||||
fill="currentColor"
|
||||
height="1em"
|
||||
viewBox="0 0 24 24"
|
||||
@@ -1610,6 +1621,7 @@ exports[`RoomView should not display the timeline when the room encryption is lo
|
||||
style="--cpd-icon-button-size: 100%;"
|
||||
>
|
||||
<svg
|
||||
class=""
|
||||
fill="currentColor"
|
||||
height="1em"
|
||||
viewBox="0 0 24 24"
|
||||
@@ -1912,6 +1924,7 @@ exports[`RoomView video rooms should render joined video room view 1`] = `
|
||||
style="--cpd-icon-button-size: 100%;"
|
||||
>
|
||||
<svg
|
||||
class="mx_RoomHeader_toggled"
|
||||
fill="currentColor"
|
||||
height="1em"
|
||||
viewBox="0 0 24 24"
|
||||
@@ -1937,6 +1950,7 @@ exports[`RoomView video rooms should render joined video room view 1`] = `
|
||||
style="--cpd-icon-button-size: 100%;"
|
||||
>
|
||||
<svg
|
||||
class=""
|
||||
fill="currentColor"
|
||||
height="1em"
|
||||
viewBox="0 0 24 24"
|
||||
@@ -1962,6 +1976,7 @@ exports[`RoomView video rooms should render joined video room view 1`] = `
|
||||
style="--cpd-icon-button-size: 100%;"
|
||||
>
|
||||
<svg
|
||||
class=""
|
||||
fill="currentColor"
|
||||
height="1em"
|
||||
viewBox="0 0 24 24"
|
||||
|
||||
@@ -36,28 +36,33 @@ import {
|
||||
import { ViewRoomOpts } from "@matrix-org/react-sdk-module-api/lib/lifecycles/RoomViewLifecycle";
|
||||
import { mocked } from "jest-mock";
|
||||
|
||||
import { filterConsole, stubClient } from "../../../../test-utils";
|
||||
import RoomHeader from "../../../../../src/components/views/rooms/RoomHeader";
|
||||
import DMRoomMap from "../../../../../src/utils/DMRoomMap";
|
||||
import { MatrixClientPeg } from "../../../../../src/MatrixClientPeg";
|
||||
import RightPanelStore from "../../../../../src/stores/right-panel/RightPanelStore";
|
||||
import { RightPanelPhases } from "../../../../../src/stores/right-panel/RightPanelStorePhases";
|
||||
import LegacyCallHandler from "../../../../../src/LegacyCallHandler";
|
||||
import SettingsStore from "../../../../../src/settings/SettingsStore";
|
||||
import SdkConfig from "../../../../../src/SdkConfig";
|
||||
import dispatcher from "../../../../../src/dispatcher/dispatcher";
|
||||
import { CallStore } from "../../../../../src/stores/CallStore";
|
||||
import { Call, ElementCall } from "../../../../../src/models/Call";
|
||||
import * as ShieldUtils from "../../../../../src/utils/ShieldUtils";
|
||||
import { Container, WidgetLayoutStore } from "../../../../../src/stores/widgets/WidgetLayoutStore";
|
||||
import MatrixClientContext from "../../../../../src/contexts/MatrixClientContext";
|
||||
import { _t } from "../../../../../src/languageHandler";
|
||||
import * as UseCall from "../../../../../src/hooks/useCall";
|
||||
import { SdkContextClass } from "../../../../../src/contexts/SDKContext";
|
||||
import WidgetStore, { IApp } from "../../../../../src/stores/WidgetStore";
|
||||
import { UIFeature } from "../../../../../src/settings/UIFeature";
|
||||
import { filterConsole, stubClient } from "../../../../../test-utils";
|
||||
import RoomHeader from "../../../../../../src/components/views/rooms/RoomHeader/RoomHeader";
|
||||
import DMRoomMap from "../../../../../../src/utils/DMRoomMap";
|
||||
import { MatrixClientPeg } from "../../../../../../src/MatrixClientPeg";
|
||||
import RightPanelStore from "../../../../../../src/stores/right-panel/RightPanelStore";
|
||||
import { RightPanelPhases } from "../../../../../../src/stores/right-panel/RightPanelStorePhases";
|
||||
import LegacyCallHandler from "../../../../../../src/LegacyCallHandler";
|
||||
import SettingsStore from "../../../../../../src/settings/SettingsStore";
|
||||
import SdkConfig from "../../../../../../src/SdkConfig";
|
||||
import dispatcher from "../../../../../../src/dispatcher/dispatcher";
|
||||
import { CallStore } from "../../../../../../src/stores/CallStore";
|
||||
import { Call, ElementCall } from "../../../../../../src/models/Call";
|
||||
import * as ShieldUtils from "../../../../../../src/utils/ShieldUtils";
|
||||
import { Container, WidgetLayoutStore } from "../../../../../../src/stores/widgets/WidgetLayoutStore";
|
||||
import MatrixClientContext from "../../../../../../src/contexts/MatrixClientContext";
|
||||
import { _t } from "../../../../../../src/languageHandler";
|
||||
import * as UseCall from "../../../../../../src/hooks/useCall";
|
||||
import { SdkContextClass } from "../../../../../../src/contexts/SDKContext";
|
||||
import WidgetStore, { IApp } from "../../../../../../src/stores/WidgetStore";
|
||||
import { UIFeature } from "../../../../../../src/settings/UIFeature";
|
||||
|
||||
jest.mock("../../../../../src/utils/ShieldUtils");
|
||||
jest.mock("../../../../../../src/utils/ShieldUtils");
|
||||
jest.mock("../../../../../../src/hooks/right-panel/useCurrentPhase", () => ({
|
||||
useCurrentPhase: () => {
|
||||
return { currentPhase: "foo", isOpen: false };
|
||||
},
|
||||
}));
|
||||
|
||||
function getWrapper(): RenderOptions {
|
||||
return {
|
||||
@@ -105,6 +105,7 @@ exports[`RoomHeader dm does not show the face pile for DMs 1`] = `
|
||||
style="--cpd-icon-button-size: 100%;"
|
||||
>
|
||||
<svg
|
||||
class=""
|
||||
fill="currentColor"
|
||||
height="1em"
|
||||
viewBox="0 0 24 24"
|
||||
@@ -130,6 +131,7 @@ exports[`RoomHeader dm does not show the face pile for DMs 1`] = `
|
||||
style="--cpd-icon-button-size: 100%;"
|
||||
>
|
||||
<svg
|
||||
class=""
|
||||
fill="currentColor"
|
||||
height="1em"
|
||||
viewBox="0 0 24 24"
|
||||
@@ -16,6 +16,7 @@ exports[`<VideoRoomChatButton /> renders button with an unread marker when room
|
||||
style="--cpd-icon-button-size: 100%;"
|
||||
>
|
||||
<svg
|
||||
class=""
|
||||
fill="currentColor"
|
||||
height="1em"
|
||||
viewBox="0 0 24 24"
|
||||
|
||||
@@ -7,7 +7,7 @@ exports[`MemberTileView RoomMemberTileView should display an verified E2EIcon wh
|
||||
aria-label="@userId:matrix.org (power 0)"
|
||||
class="mx_AccessibleButton mx_MemberTileView"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
tabindex="-1"
|
||||
>
|
||||
<div
|
||||
class="mx_MemberTileView_left"
|
||||
@@ -76,7 +76,7 @@ exports[`MemberTileView RoomMemberTileView should display an warning E2EIcon whe
|
||||
aria-label="@userId:matrix.org (power 0)"
|
||||
class="mx_AccessibleButton mx_MemberTileView"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
tabindex="-1"
|
||||
>
|
||||
<div
|
||||
class="mx_MemberTileView_left"
|
||||
@@ -145,7 +145,7 @@ exports[`MemberTileView RoomMemberTileView should not display an E2EIcon when th
|
||||
aria-label="@userId:matrix.org (power 0)"
|
||||
class="mx_AccessibleButton mx_MemberTileView"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
tabindex="-1"
|
||||
>
|
||||
<div
|
||||
class="mx_MemberTileView_left"
|
||||
@@ -195,7 +195,7 @@ exports[`MemberTileView ThreePidInviteTileView renders ThreePidInvite correctly
|
||||
<div
|
||||
class="mx_AccessibleButton mx_MemberTileView"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
tabindex="-1"
|
||||
>
|
||||
<div
|
||||
class="mx_MemberTileView_left"
|
||||
|
||||
@@ -7,7 +7,7 @@ Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { mocked, MockedFunction, MockedObject } from "jest-mock";
|
||||
import { last } from "lodash";
|
||||
import { findLast, last } from "lodash";
|
||||
import {
|
||||
MatrixEvent,
|
||||
MatrixClient,
|
||||
@@ -27,10 +27,15 @@ import { MatrixClientPeg } from "../../../../src/MatrixClientPeg";
|
||||
import { StopGapWidget } from "../../../../src/stores/widgets/StopGapWidget";
|
||||
import ActiveWidgetStore from "../../../../src/stores/ActiveWidgetStore";
|
||||
import SettingsStore from "../../../../src/settings/SettingsStore";
|
||||
import defaultDispatcher from "../../../../src/dispatcher/dispatcher";
|
||||
import { Action } from "../../../../src/dispatcher/actions";
|
||||
import { SdkContextClass } from "../../../../src/contexts/SDKContext";
|
||||
import { UPDATE_EVENT } from "../../../../src/stores/AsyncStore";
|
||||
|
||||
jest.mock("matrix-widget-api/lib/ClientWidgetApi");
|
||||
jest.mock("matrix-widget-api", () => ({
|
||||
...jest.requireActual("matrix-widget-api"),
|
||||
ClientWidgetApi: (jest.createMockFromModule("matrix-widget-api") as any).ClientWidgetApi,
|
||||
}));
|
||||
|
||||
describe("StopGapWidget", () => {
|
||||
let client: MockedObject<MatrixClient>;
|
||||
@@ -104,6 +109,24 @@ describe("StopGapWidget", () => {
|
||||
expect(messaging.feedStateUpdate).toHaveBeenCalledWith(event.getEffectiveEvent());
|
||||
});
|
||||
|
||||
it("informs widget of theme changes", () => {
|
||||
let theme = "light";
|
||||
const settingsSpy = jest
|
||||
.spyOn(SettingsStore, "getValue")
|
||||
.mockImplementation((name) => (name === "theme" ? theme : null));
|
||||
try {
|
||||
// Indicate that the widget is ready
|
||||
findLast(messaging.once.mock.calls, ([eventName]) => eventName === "ready")![1]();
|
||||
|
||||
// Now change the theme
|
||||
theme = "dark";
|
||||
defaultDispatcher.dispatch({ action: Action.RecheckTheme }, true);
|
||||
expect(messaging.updateTheme).toHaveBeenLastCalledWith({ name: "dark" });
|
||||
} finally {
|
||||
settingsSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
describe("feed event", () => {
|
||||
let event1: MatrixEvent;
|
||||
let event2: MatrixEvent;
|
||||
|
||||
69
yarn.lock
69
yarn.lock
@@ -2038,18 +2038,18 @@
|
||||
resolved "https://registry.yarnpkg.com/@matrix-org/analytics-events/-/analytics-events-0.29.1.tgz#b812b932d82de1409fa47199260c9a4d4f8349e8"
|
||||
integrity sha512-EyN6TMG4fCeNoQEa0uYTNnMLT4M/F3eCU/usjLDHkVgIcwevvBCHxw2379IbOm4kJBbhSW/pcNkGRKntWu0J9g==
|
||||
|
||||
"@matrix-org/emojibase-bindings@^1.3.3":
|
||||
version "1.3.3"
|
||||
resolved "https://registry.yarnpkg.com/@matrix-org/emojibase-bindings/-/emojibase-bindings-1.3.3.tgz#cee82a739c0866bf3100b03755647ace1f3ba6ef"
|
||||
integrity sha512-GwuZdmF+wZT34RKehQYjTzdgba1ju2W3FM4jPJfwqh0jUxVXZLb+6b6dV3lna6/7EDzgGvOMwTwCAolILDwS0g==
|
||||
"@matrix-org/emojibase-bindings@^1.3.4":
|
||||
version "1.3.4"
|
||||
resolved "https://registry.yarnpkg.com/@matrix-org/emojibase-bindings/-/emojibase-bindings-1.3.4.tgz#b0dad8e8b8bbe433e419b59e38f933bcdaf9c271"
|
||||
integrity sha512-+nhBg0dxjy3U4/Tn6WIsnzqiqazc0pfStc2dkSBxDnc4xnimDB6vcIad53fUIsl7SeT50ake0hhnBJs0ZDDk6Q==
|
||||
dependencies:
|
||||
emojibase "^15.3.1"
|
||||
emojibase-data "^15.3.1"
|
||||
|
||||
"@matrix-org/matrix-sdk-crypto-wasm@^12.1.0":
|
||||
version "12.1.0"
|
||||
resolved "https://registry.yarnpkg.com/@matrix-org/matrix-sdk-crypto-wasm/-/matrix-sdk-crypto-wasm-12.1.0.tgz#2aef64eab2d30c0a1ace9c0fe876f53aa2949f14"
|
||||
integrity sha512-NhJFu/8FOGjnW7mDssRUzaMSwXrYOcCqgAjZyAw9KQ9unNADKEi7KoIKe7GtrG2PWtm36y2bUf+hB8vhSY6Wdw==
|
||||
"@matrix-org/matrix-sdk-crypto-wasm@^13.0.0":
|
||||
version "13.0.0"
|
||||
resolved "https://registry.yarnpkg.com/@matrix-org/matrix-sdk-crypto-wasm/-/matrix-sdk-crypto-wasm-13.0.0.tgz#658bed951e4c8a06a6dd545575a79cf32022d4ba"
|
||||
integrity sha512-2gtpjnxL42sdJAgkwitpMMI4cw7Gcjf5sW0MXoe+OAlXPlxIzyM+06F5JJ8ENvBeHkuV2RqtFIRrh8i90HLsMw==
|
||||
|
||||
"@matrix-org/olm@3.2.15":
|
||||
version "3.2.15"
|
||||
@@ -3492,7 +3492,7 @@
|
||||
resolved "https://registry.yarnpkg.com/@vector-im/matrix-wysiwyg/-/matrix-wysiwyg-2.38.0.tgz#af862ffd231dc0a6b8d6f2cb3601e68456c0ff24"
|
||||
integrity sha512-cMEVicFYVzFxuSyWON0aVGjAJMcgJZ+LxuLTEp8EGuu8cRacuh0RN5rapb11YVZygzFvE7X1cMedJ/fKd5vRLA==
|
||||
dependencies:
|
||||
"@vector-im/matrix-wysiwyg-wasm" "link:../../.cache/yarn/v6/npm-@vector-im-matrix-wysiwyg-2.38.0-af862ffd231dc0a6b8d6f2cb3601e68456c0ff24-integrity/node_modules/bindings/wysiwyg-wasm"
|
||||
"@vector-im/matrix-wysiwyg-wasm" "link:../../../.cache/yarn/v6/npm-@vector-im-matrix-wysiwyg-2.38.0-af862ffd231dc0a6b8d6f2cb3601e68456c0ff24-integrity/node_modules/bindings/wysiwyg-wasm"
|
||||
|
||||
"@webassemblyjs/ast@1.14.1", "@webassemblyjs/ast@^1.14.1":
|
||||
version "1.14.1"
|
||||
@@ -4077,6 +4077,15 @@ axe-core@^4.10.0, axe-core@~4.10.2:
|
||||
resolved "https://registry.yarnpkg.com/axe-core/-/axe-core-4.10.2.tgz#85228e3e1d8b8532a27659b332e39b7fa0e022df"
|
||||
integrity sha512-RE3mdQ7P3FRSe7eqCWoeQ/Z9QXrtniSjp1wUjt5nRC3WIpz5rSCve6o3fsZ2aCpJtrZjSZgjwXAoTO5k4tEI0w==
|
||||
|
||||
axios@^1.7.8:
|
||||
version "1.7.9"
|
||||
resolved "https://registry.yarnpkg.com/axios/-/axios-1.7.9.tgz#d7d071380c132a24accda1b2cfc1535b79ec650a"
|
||||
integrity sha512-LhLcE7Hbiryz8oMDdDptSrWowmB4Bl6RCt6sIJKpRB4XtVf0iEgewX3au/pJqm+Py1kCASkb/FFKjxQaLtxJvw==
|
||||
dependencies:
|
||||
follow-redirects "^1.15.6"
|
||||
form-data "^4.0.0"
|
||||
proxy-from-env "^1.1.0"
|
||||
|
||||
axobject-query@^4.1.0:
|
||||
version "4.1.0"
|
||||
resolved "https://registry.yarnpkg.com/axobject-query/-/axobject-query-4.1.0.tgz#28768c76d0e3cff21bc62a9e2d0b6ac30042a1ee"
|
||||
@@ -6515,7 +6524,7 @@ focus-lock@^1.3.5:
|
||||
dependencies:
|
||||
tslib "^2.0.3"
|
||||
|
||||
follow-redirects@^1.0.0:
|
||||
follow-redirects@^1.0.0, follow-redirects@^1.15.6:
|
||||
version "1.15.9"
|
||||
resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.15.9.tgz#a604fa10e443bf98ca94228d9eebcc2e8a2c8ee1"
|
||||
integrity sha512-gew4GsXizNgdoRyqmyfMHyAmXsZDk6mHkSxZFCzW9gwlbtOW44CDtYavM+y+72qD/Vq2l550kMF52DT8fOLJqQ==
|
||||
@@ -7128,7 +7137,7 @@ iconv-lite@0.4.24:
|
||||
dependencies:
|
||||
safer-buffer ">= 2.1.2 < 3"
|
||||
|
||||
iconv-lite@0.6.3, iconv-lite@^0.6, iconv-lite@^0.6.3:
|
||||
iconv-lite@0.6.3, iconv-lite@^0.6.3:
|
||||
version "0.6.3"
|
||||
resolved "https://registry.yarnpkg.com/iconv-lite/-/iconv-lite-0.6.3.tgz#a52f80bf38da1952eb5c681790719871a1a72501"
|
||||
integrity sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==
|
||||
@@ -8567,12 +8576,12 @@ magic-string@0.30.8:
|
||||
dependencies:
|
||||
"@jridgewell/sourcemap-codec" "^1.4.15"
|
||||
|
||||
mailhog@^4.16.0:
|
||||
version "4.16.0"
|
||||
resolved "https://registry.yarnpkg.com/mailhog/-/mailhog-4.16.0.tgz#1ad4dda104505399f3f17824737a962696e7d240"
|
||||
integrity sha512-wXrGik+0MaAy4dbYTImxa8niX9a4aRpZTzC/b1GzCvQs09khhs0aKZgHjgScakI4Y18WInDvvF48hhEz9ifN4g==
|
||||
optionalDependencies:
|
||||
iconv-lite "^0.6"
|
||||
mailpit-api@^1.0.5:
|
||||
version "1.0.5"
|
||||
resolved "https://registry.yarnpkg.com/mailpit-api/-/mailpit-api-1.0.5.tgz#3383593707a7bc502af0ae6bf1296160daf8c730"
|
||||
integrity sha512-55OjUjNv4hwrQKIzN8DqWywuW7UIyzN1FrEd3A87sJ9Ni07LZC/f7hgeW7dp36YYxrmV8voGzUmCY3dWJ3D6Og==
|
||||
dependencies:
|
||||
axios "^1.7.8"
|
||||
|
||||
make-dir@^4.0.0:
|
||||
version "4.0.0"
|
||||
@@ -8657,11 +8666,11 @@ matrix-events-sdk@0.0.1:
|
||||
integrity sha512-1QEOsXO+bhyCroIe2/A5OwaxHvBm7EsSQ46DEDn8RBIfQwN5HWBpFvyWWR4QY0KHPPnnJdI99wgRiAl7Ad5qaA==
|
||||
|
||||
"matrix-js-sdk@github:matrix-org/matrix-js-sdk#develop":
|
||||
version "36.0.0"
|
||||
resolved "https://codeload.github.com/matrix-org/matrix-js-sdk/tar.gz/161da0597201dc3eb6870bc7e8b702948ba856e5"
|
||||
version "36.1.0"
|
||||
resolved "https://codeload.github.com/matrix-org/matrix-js-sdk/tar.gz/8175683d4bc65b730e9475d016372b948b2a6cb9"
|
||||
dependencies:
|
||||
"@babel/runtime" "^7.12.5"
|
||||
"@matrix-org/matrix-sdk-crypto-wasm" "^12.1.0"
|
||||
"@matrix-org/matrix-sdk-crypto-wasm" "^13.0.0"
|
||||
"@matrix-org/olm" "3.2.15"
|
||||
another-json "^0.2.0"
|
||||
bs58 "^6.0.0"
|
||||
@@ -8688,17 +8697,9 @@ matrix-web-i18n@^3.2.1:
|
||||
walk "^2.3.15"
|
||||
|
||||
matrix-widget-api@^1.10.0:
|
||||
version "1.12.0"
|
||||
resolved "https://registry.yarnpkg.com/matrix-widget-api/-/matrix-widget-api-1.12.0.tgz#b3d22bab1670051c8eeee66bb96d08b33148bc99"
|
||||
integrity sha512-6JRd9fJGGvuBRhcTg9wX+Skn/Q1wox3jdp5yYQKJ6pPw4urW9bkTR90APBKVDB1vorJKT44jml+lCzkDMRBjww==
|
||||
dependencies:
|
||||
"@types/events" "^3.0.0"
|
||||
events "^3.2.0"
|
||||
|
||||
matrix-widget-api@^1.13.0:
|
||||
version "1.13.0"
|
||||
resolved "https://registry.yarnpkg.com/matrix-widget-api/-/matrix-widget-api-1.13.0.tgz#40344b264b08d6d98ab9d547a41eb74dd6d8c3f7"
|
||||
integrity sha512-+LrvwkR1izL4h2euX8PDrvG/3PZZDEd6As+lmnR3jAVwbFJtU5iTnwmZGnCca9ddngCvXvAHkcpJBEPyPTZneQ==
|
||||
version "1.13.1"
|
||||
resolved "https://registry.yarnpkg.com/matrix-widget-api/-/matrix-widget-api-1.13.1.tgz#5b1caeed2fc58148bcd2984e0546d2d06a1713ad"
|
||||
integrity sha512-mkOHUVzaN018TCbObfGOSaMW2GoUxOfcxNNlTVx5/HeMk3OSQPQM0C9oEME5Liiv/dBUoSrEB64V8wF7e/gb1w==
|
||||
dependencies:
|
||||
"@types/events" "^3.0.0"
|
||||
events "^3.2.0"
|
||||
@@ -10979,9 +10980,9 @@ schema-utils@^4.0.0, schema-utils@^4.2.0, schema-utils@^4.3.0:
|
||||
ajv-keywords "^5.1.0"
|
||||
|
||||
sdp-transform@^2.14.1:
|
||||
version "2.15.0"
|
||||
resolved "https://registry.yarnpkg.com/sdp-transform/-/sdp-transform-2.15.0.tgz#79d37a2481916f36a0534e07b32ceaa87f71df42"
|
||||
integrity sha512-KrOH82c/W+GYQ0LHqtr3caRpM3ITglq3ljGUIb8LTki7ByacJZ9z+piSGiwZDsRyhQbYBOBJgr2k6X4BZXi3Kw==
|
||||
version "2.14.2"
|
||||
resolved "https://registry.yarnpkg.com/sdp-transform/-/sdp-transform-2.14.2.tgz#d2cee6a1f7abe44e6332ac6cbb94e8600f32d813"
|
||||
integrity sha512-icY6jVao7MfKCieyo1AyxFYm1baiM+fA00qW/KrNNVlkxHAd34riEKuEkUe4bBb3gJwLJZM+xT60Yj1QL8rHiA==
|
||||
|
||||
seedrandom@^3.0.5:
|
||||
version "3.0.5"
|
||||
|
||||
Reference in New Issue
Block a user