Compare commits

..

25 Commits

Author SHA1 Message Date
Florian Duros
a5dc8bdb2e refactor(crypto): add areLocalSecretsAvailable method 2025-01-31 10:33:25 +01:00
ElementRobot
f06ed2fa1f [create-pull-request] automated change (#29148)
Co-authored-by: t3chguy <2403652+t3chguy@users.noreply.github.com>
2025-01-31 06:14:35 +00:00
Richard van der Hoff
099c3073b6 Stop showing a dialog prompting the user to enter an old recovery key (#29143)
* SecurityManager: improve logging

* Only prompt user for default 4S key

We don't really support the concept of having multiple 4S keys active, so
prompting the user to enter a non-default 4S key without even telling them
which one we want is rather silly.

* playwright: factor out helper for setting up 4S

We seem to already have about 5 copies of this code, so before I add another,
let's factor it out.

* Playwright test for dehydrated device in reset flow

This should be fixed by the previous commit, so let's check it stays that way.
2025-01-30 16:27:45 +00:00
ElementRobot
12932e2dc6 [create-pull-request] automated change (#29144)
Co-authored-by: t3chguy <2403652+t3chguy@users.noreply.github.com>
2025-01-30 09:22:24 +00:00
David Langley
a7de29429c Bump emojibase_bindings to include emoji 15.1 (#29132) 2025-01-29 17:42:22 +00:00
Florian Duros
d3ea250d77 Remove call to MatrixClient.setGlobalErrorOnUnknownDevices (#29134)
* refactor(MatrixChat): remove `MatrixClient.setGlobalErrorOnUnknownDevices` call

MatrixClient.setGlobalErrorOnUnknownDevices is not implemented in the rust-crypto and will be removed when the legacy crypto will be ripped out.

* test(e2e): remove `MatrixClient.setGlobalErrorOnUnknownDevices` call

MatrixClient.setGlobalErrorOnUnknownDevices is not implemented in the rust-crypto and will be removed when the legacy crypto will be ripped out.
2025-01-29 17:24:44 +00:00
ElementRobot
f243fee5a6 [create-pull-request] automated change (#29122)
Co-authored-by: t3chguy <2403652+t3chguy@users.noreply.github.com>
2025-01-29 06:18:59 +00:00
ElementRobot
296d0074ed [create-pull-request] automated change (#29121)
Co-authored-by: t3chguy <2403652+t3chguy@users.noreply.github.com>
2025-01-29 06:14:16 +00:00
RiotRobot
df83338f26 Reset matrix-js-sdk back to develop branch 2025-01-28 13:36:28 +00:00
RiotRobot
c0336f21f6 Merge branch 'master' into develop 2025-01-28 13:36:12 +00:00
RiotRobot
d88f47bdbc v1.11.91 2025-01-28 13:33:06 +00:00
RiotRobot
4a26414957 Upgrade dependency to matrix-js-sdk@36.1.0 2025-01-28 13:23:15 +00:00
Matthew Hodgson
886d0e1241 update to twemoji 15.1.0 (#29115) 2025-01-28 11:15:33 +00:00
Robin
c453d33456 Make themed widgets reflect the effective theme (#28342)
* Make themed widgets reflect the effective theme

So that widgets such as Element Call will show up in the right theme even if the app is set to match the system theme.

* Remove debug log line
2025-01-28 10:53:35 +00:00
ElementRobot
ddf221b813 [create-pull-request] automated change (#29114)
Co-authored-by: t3chguy <2403652+t3chguy@users.noreply.github.com>
2025-01-28 06:14:17 +00:00
Timo
08238bb883 Update matrix-widget-api (#29112)
This fixes element call not loading with the `update_state` refactor: https://github.com/element-hq/element-web/pull/28681
2025-01-27 17:46:39 +00:00
Michael Telatynski
c390ec333e Tidy up modules (#29089)
Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>
2025-01-27 17:27:27 +00:00
Michael Telatynski
3c22e5dc68 Switch to mailpit for Playwright tests (#29108)
* Switch to mailpit for Playwright tests

as mailhog is unsupported and lacks arm64 support

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>

* Fix yarn.lock

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>

* Iterate

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>

* Iterate

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>

* delint

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>

* Iterate

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>

---------

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>
2025-01-27 15:35:06 +00:00
R Midhun Suresh
f29ce94dd4 Style room header icons and facepile for toggled state (#28968)
* Fix tiny typo in existing code

* Create a hook that uses the right panel store

So that we track changes to the right panel phases

* Create a context that wraps the previous hook we created

We do this so that we can get by using a single event listener i.e we
only need to call `useCurrentPhase` in the provider as opposed to
calling it in each header icon.

* Create a hook that tells you if a panel is open or not

* Create component that wraps Icon

and adds a class name when the corresponding panel is open

* Style room header icons for when they are toggled

* Style face pile for toggle state

* Fix broken CI

* Give directory a better name

* Update year in license

* Use a stronger type
2025-01-27 15:05:46 +00:00
R Midhun Suresh
76485cfb17 Allow navigating through the memberlist using up/down keys (#28949)
* Allow flex component to take child containers props

So that we can set attributes on the container

* Use Up/Down arrow keys to navigate through the list

* Update snapshot
2025-01-27 15:05:28 +00:00
Matthew Hodgson
c0567fc5f4 support non-VS16 emoji ligatures in TwemojiMozilla (#29100)
to workaround broken ligature support for VS16 ligature colour emoji in Chrome 131+
See https://github.com/element-hq/element-web/issues/28500
2025-01-27 14:54:13 +00:00
RiotRobot
790a976421 v1.11.91-rc.1 2025-01-21 15:34:13 +00:00
ElementRobot
1e1d66924f Switch to secure random strings (#29013) (#29035)
* Switch to secure random strings

Because the js-sdk methods are changing and there's no reason for these
not to use the secure versions. The dedicated upper/lower functions were
*only* used in this one case, so this should do the exact same thing with
the one exported function.

Requires https://github.com/matrix-org/matrix-js-sdk/pull/4621 (merge both together)

* Change remaining instances of randomString

which I somehow entirely missed the first time.

* Fix import order

(cherry picked from commit 56eafc908e)

Co-authored-by: David Baker <dbkr@users.noreply.github.com>
2025-01-21 15:15:44 +00:00
RiotRobot
63ecb48d7d v1.11.91-rc.0 2025-01-21 14:48:40 +00:00
RiotRobot
5e3fc8aa19 Upgrade dependency to matrix-js-sdk@36.1.0-rc.0 2025-01-21 14:42:45 +00:00
58 changed files with 1223 additions and 743 deletions

View File

@@ -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
View File

@@ -26,6 +26,7 @@ electron/pub
/coverage
# Auto-generated file
/src/modules.ts
/src/modules.js
/build_config.yaml
/book
/index.html

View File

@@ -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

View File

@@ -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

View File

@@ -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");
}

View File

@@ -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",

View File

@@ -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();

View File

@@ -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"

View File

@@ -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");

View File

@@ -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),
);
});
}

View File

@@ -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;
}
/**

View File

@@ -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();

View File

@@ -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 });

View File

@@ -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();

View File

@@ -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();
});

View File

@@ -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",

View File

@@ -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",

View File

@@ -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: [
{

View File

@@ -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);
},

View File

@@ -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)}`);
}
}

View File

@@ -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",

View File

@@ -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",

View File

@@ -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);
}

View File

@@ -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 = {};

View File

@@ -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

View File

@@ -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";

View File

@@ -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";

View File

@@ -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,

View File

@@ -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,
});

View File

@@ -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>

View File

@@ -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>
);
};

View File

@@ -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>
);
}

View File

@@ -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} />}
</>
);
}

View 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>
</>
);
}

View File

@@ -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>
);

View 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 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} />;
}

View 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);
}

View 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>
);
};

View 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 };
}

View File

@@ -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
View 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[];
}

View File

@@ -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 {

View File

@@ -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 });

View File

@@ -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);

View File

@@ -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;
}
}

View 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;
}

View File

@@ -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));
}
}

View 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();
}
});
});

View File

@@ -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,
},
},
};
}

View File

@@ -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),

View File

@@ -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"

View File

@@ -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 {

View File

@@ -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"

View File

@@ -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"

View File

@@ -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"

View File

@@ -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;

View File

@@ -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"