Merge branch 'develop' of ssh://github.com/element-hq/element-web into t3chguy/element-web-monorepo

# Conflicts:
#	apps/element-web/playwright/e2e/settings/encryption-user-tab/other-devices.spec.ts
#	apps/element-web/src/device-listener/DeviceListenerCurrentDevice.ts
#	apps/element-web/src/device-listener/DeviceState.ts
#	package.json
#	yarn.lock
This commit is contained in:
Michael Telatynski 2026-02-11 10:02:33 +00:00
commit 46db71b3c2
No known key found for this signature in database
GPG Key ID: A2B008A5F49F5D0D
19 changed files with 1029 additions and 429 deletions

View File

@ -3,3 +3,6 @@ contact_links:
- name: Questions & support
url: https://matrix.to/#/#element-web:matrix.org
about: Please ask and answer questions here.
- name: Bug report for the Element flatpak app
url: https://github.com/flathub/im.riot.Riot/issues
about: Please file bugs with the Flatpak application on the respective repository.

View File

@ -1,3 +1,36 @@
Changes in [1.12.10](https://github.com/element-hq/element-web/releases/tag/v1.12.10) (2026-02-10)
==================================================================================================
## ✨ Features
* Support additional\_creators in /upgraderoom (MSC4289) ([#31934](https://github.com/element-hq/element-web/pull/31934)). Contributed by @andybalaam.
* Update room header icon for world\_readable rooms ([#31915](https://github.com/element-hq/element-web/pull/31915)). Contributed by @richvdh.
* Show an icon in the room header for shared history ([#31879](https://github.com/element-hq/element-web/pull/31879)). Contributed by @richvdh.
* Remove "history may be shared" banner. ([#31881](https://github.com/element-hq/element-web/pull/31881)). Contributed by @kaylendog.
* Allow dismissing 'Key storage out of sync' temporarily ([#31455](https://github.com/element-hq/element-web/pull/31455)). Contributed by @andybalaam.
* Add `resolutions` entry for `matrix-widget-api` to package.json ([#31851](https://github.com/element-hq/element-web/pull/31851)). Contributed by @toger5.
* Improve visibility under contrast control mode ([#31847](https://github.com/element-hq/element-web/pull/31847)). Contributed by @t3chguy.
* Unread Sorting - Add option for sorting in `OptionsMenuView` ([#31754](https://github.com/element-hq/element-web/pull/31754)). Contributed by @MidhunSureshR.
* Unread sorting - Implement sorter and use it in the room list store ([#31723](https://github.com/element-hq/element-web/pull/31723)). Contributed by @MidhunSureshR.
* Allow Element Call widgets to receive sticky events ([#31843](https://github.com/element-hq/element-web/pull/31843)). Contributed by @robintown.
* Improve icon rendering accessibility ([#31791](https://github.com/element-hq/element-web/pull/31791)). Contributed by @t3chguy.
* Add message preview toggle to room list header option ([#31821](https://github.com/element-hq/element-web/pull/31821)). Contributed by @florianduros.
## 🐛 Bug Fixes
* [Backport staging] Fix room list not being cleared ([#32438](https://github.com/element-hq/element-web/pull/32438)). Contributed by @RiotRobot.
* Fix failure to update room info panel on joinrule change ([#31938](https://github.com/element-hq/element-web/pull/31938)). Contributed by @richvdh.
* Throttle space notification state calculation ([#31922](https://github.com/element-hq/element-web/pull/31922)). Contributed by @dbkr.
* Fix emoji verification responsive layout ([#31899](https://github.com/element-hq/element-web/pull/31899)). Contributed by @t3chguy.
* Add patch for linkify to fix doctype handling ([#31900](https://github.com/element-hq/element-web/pull/31900)). Contributed by @dbkr.
* Fix rooms with no messages appearing at the top of the room list ([#31798](https://github.com/element-hq/element-web/pull/31798)). Contributed by @MidhunSureshR.
* Fix room list menu flashes when menu is closed ([#31868](https://github.com/element-hq/element-web/pull/31868)). Contributed by @florianduros.
* Message preview toggle is inverted in room list header ([#31865](https://github.com/element-hq/element-web/pull/31865)). Contributed by @florianduros.
* Fix duplicate toasts appearing for the same call if two events appear. ([#31693](https://github.com/element-hq/element-web/pull/31693)). Contributed by @Half-Shot.
* Fix ability to send rageshake during session restore failure ([#31848](https://github.com/element-hq/element-web/pull/31848)). Contributed by @t3chguy.
* Fix mis-alignment of `Threads` right panel title ([#31849](https://github.com/element-hq/element-web/pull/31849)). Contributed by @t3chguy.
* Unset buttons does not include color inherit ([#31801](https://github.com/element-hq/element-web/pull/31801)). Contributed by @Philldomd.
Changes in [1.12.9](https://github.com/element-hq/element-web/releases/tag/v1.12.9) (2026-01-27)
================================================================================================
## ✨ Features

View File

@ -7,8 +7,8 @@ Please see LICENSE files in the repository root for full details.
*/
import { expect, type JSHandle, type Page } from "@playwright/test";
import { type ICreateRoomOpts, type MatrixClient } from "matrix-js-sdk/src/matrix";
import type { ICreateRoomOpts, MatrixClient } from "matrix-js-sdk/src/matrix";
import type {
CryptoEvent,
EmojiMapping,
@ -499,6 +499,51 @@ export const verify = async (app: ElementAppPage, bob: Bot) => {
await roomInfo.getByRole("button", { name: "Got it" }).click();
};
/**
* Verify two instances of the Element app by emoji.
* @param aliceElementApp
* @param bobElementApp
*/
export const verifyApp = async (
aliceDisplayName: string,
aliceElementApp: ElementAppPage,
bobDisplayName: string,
bobElementApp: ElementAppPage,
) => {
// Alice opens room info and starts verification.
const aliceRoomInfo = aliceElementApp.page.locator(".mx_RightPanel");
if (await aliceRoomInfo.isHidden()) {
await aliceElementApp.toggleRoomInfoPanel();
}
await aliceElementApp.page.locator(".mx_RightPanel").getByRole("menuitem", { name: "People" }).click();
await aliceRoomInfo.getByText("Bob").click();
await aliceRoomInfo.getByRole("button", { name: "Verify" }).click();
await aliceRoomInfo.getByRole("button", { name: "Start Verification" }).click();
// Navigate to the DM created by Alice and accept the invite
const oldRoomId = await bobElementApp.getCurrentRoomIdFromUrl();
await bobElementApp.viewRoomByName(aliceDisplayName);
await bobElementApp.page.getByRole("button", { name: "Start chatting" }).click();
// Bob accepts the verification request.
await bobElementApp.page.getByRole("button", { name: "Verify" }).click({ timeout: 5000 });
// This requires creating a DM, so can take a while. Give it a longer timeout.
await aliceRoomInfo.getByRole("button", { name: "Verify by emoji" }).click({ timeout: 30000 });
await bobElementApp.page.getByRole("button", { name: "Verify by emoji" }).click();
await aliceRoomInfo.getByRole("button", { name: "They match" }).click();
await bobElementApp.page.getByRole("button", { name: "They match" }).click();
// Assert the verification was successful.
await expect(aliceRoomInfo.getByText(`You've successfully verified ${bobDisplayName}!`)).toBeVisible();
await expect(bobElementApp.page.getByText(`You've successfully verified ${aliceDisplayName}!`)).toBeVisible();
// Navigate Bob back to the old room.
await bobElementApp.viewRoomById(oldRoomId);
};
/**
* Wait for a verifier to exist for a VerificationRequest
*

View File

@ -0,0 +1,253 @@
/*
* Copyright 2026 Element Creations 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 { createNewInstance } from "@element-hq/element-web-playwright-common";
import { test, expect } from "./index";
import { ElementAppPage } from "../../../pages/ElementAppPage";
import { createRoom, sendMessageInCurrentRoom, verifyApp } from "../../crypto/utils";
test.describe("Other people's devices section in Encryption tab", () => {
test.use({
displayName: "alice",
});
test("unverified devices should be able to decrypt while global blacklist is not toggled", async ({
page: alicePage,
app: aliceElementApp,
homeserver,
browser,
user: aliceCredentials,
}, testInfo) => {
await aliceElementApp.client.bootstrapCrossSigning(aliceCredentials);
// Create a second browser instance.
const bobCredentials = await homeserver.registerUser(`user_${testInfo.testId}_bob`, "password", "bob");
const bobPage = await createNewInstance(browser, bobCredentials, {});
const bobElementApp = new ElementAppPage(bobPage);
await bobElementApp.client.bootstrapCrossSigning(bobCredentials);
// Create the room and invite bob
await createRoom(alicePage, "TestRoom", true);
await aliceElementApp.inviteUserToCurrentRoom(bobCredentials.userId);
// Bob accepts the invite
await bobPage.getByRole("option", { name: "TestRoom" }).click();
await bobPage.getByRole("button", { name: "Accept" }).click();
// Alice sends a message, which Bob should be able to decrypt
await sendMessageInCurrentRoom(alicePage, "Decryptable");
await expect(bobPage.getByText("Decryptable")).toBeVisible();
});
test("unverified devices should not be able to decrypt while global blacklist is toggled", async ({
page: alicePage,
app: aliceElementApp,
homeserver,
browser,
user: aliceCredentials,
util,
}, testInfo) => {
await aliceElementApp.client.bootstrapCrossSigning(aliceCredentials);
// Enable blacklist toggle.
const dialog = await util.openEncryptionTab();
const blacklistToggle = dialog.getByRole("switch", {
name: "In encrypted rooms, only send messages to verified users",
});
await blacklistToggle.scrollIntoViewIfNeeded();
await expect(blacklistToggle).toBeVisible();
await blacklistToggle.click();
await aliceElementApp.settings.closeDialog();
// Create a second browser instance.
const bobCredentials = await homeserver.registerUser(`user_${testInfo.testId}_bob`, "password", "bob");
const bobPage = await createNewInstance(browser, bobCredentials, {});
const bobElementApp = new ElementAppPage(bobPage);
await bobElementApp.client.bootstrapCrossSigning(bobCredentials);
// Create the room and invite bob
await createRoom(alicePage, "TestRoom", true);
await aliceElementApp.inviteUserToCurrentRoom(bobCredentials.userId);
// Bob accepts the invite
await bobPage.getByRole("option", { name: "TestRoom" }).click();
await bobPage.getByRole("button", { name: "Accept" }).click();
// Alice sends a message, which Bob should not be able to decrypt
await sendMessageInCurrentRoom(alicePage, "Undecryptable");
await expect(
bobPage.getByText(
"The sender has blocked you from receiving this message because your device is unverified",
),
).toBeVisible();
});
test("verified devices should be able to decrypt while global blacklist is toggled", async ({
page: alicePage,
app: aliceElementApp,
homeserver,
browser,
user: aliceCredentials,
util,
}, testInfo) => {
await aliceElementApp.client.bootstrapCrossSigning(aliceCredentials);
// Enable blacklist toggle.
const dialog = await util.openEncryptionTab();
const blacklistToggle = dialog.getByRole("switch", {
name: "In encrypted rooms, only send messages to verified users",
});
await blacklistToggle.scrollIntoViewIfNeeded();
await expect(blacklistToggle).toBeVisible();
await blacklistToggle.click();
await aliceElementApp.settings.closeDialog();
// Create a second browser instance.
const bobCredentials = await homeserver.registerUser(`user_${testInfo.testId}_bob`, "password", "bob");
const bobPage = await createNewInstance(browser, bobCredentials, {});
const bobElementApp = new ElementAppPage(bobPage);
await bobElementApp.client.bootstrapCrossSigning(bobCredentials);
// Create the room and invite bob
await createRoom(alicePage, "TestRoom", true);
await aliceElementApp.inviteUserToCurrentRoom(bobCredentials.userId);
// Bob accepts the invite and dismisses the warnings.
await bobPage.getByRole("option", { name: "TestRoom" }).click();
await bobPage.getByRole("button", { name: "Accept" }).click();
await bobPage.getByRole("button", { name: "Dismiss" }).click(); // enable notifications
await bobPage.getByRole("button", { name: "Dismiss" }).click(); // enable key storage
await bobPage.getByRole("button", { name: "Yes, dismiss" }).click(); // enable key storage x2
// Perform verification.
await verifyApp("alice", aliceElementApp, "bob", bobElementApp);
// Alice sends a message, which Bob should be able to decrypt
await sendMessageInCurrentRoom(alicePage, "Decryptable");
await expect(bobPage.getByText("Decryptable")).toBeVisible();
});
test("setting per-room unverified blacklist toggle does not affect other rooms", async ({
page: alicePage,
app: aliceElementApp,
homeserver,
browser,
user: aliceCredentials,
}, testInfo) => {
await aliceElementApp.client.bootstrapCrossSigning(aliceCredentials);
// Create a second browser instance.
const bobCredentials = await homeserver.registerUser(`user_${testInfo.testId}_bob`, "password", "bob");
const bobPage = await createNewInstance(browser, bobCredentials, {});
const bobElementApp = new ElementAppPage(bobPage);
await bobElementApp.client.bootstrapCrossSigning(bobCredentials);
// Alice creates the room and invite Bob.
await createRoom(alicePage, "TestRoom", true);
await aliceElementApp.inviteUserToCurrentRoom(bobCredentials.userId);
// Bob accepts the invite.
await bobPage.getByRole("option", { name: "TestRoom" }).click();
await bobPage.getByRole("button", { name: "Accept" }).click();
// Alice configures her client to blacklist unverified users in this room.
const dialog = await aliceElementApp.settings.openRoomSettings("Security & Privacy");
await dialog.getByRole("switch", { name: "Only send messages to verified users." }).click();
await aliceElementApp.settings.closeDialog();
// Alice sends a message which Bob should not be able to decrypt.
await sendMessageInCurrentRoom(alicePage, "Undecryptable");
await expect(
bobPage.getByText(
"The sender has blocked you from receiving this message because your device is unverified",
),
).toBeVisible();
// Alice dismisses key storage warnings, as they now hide the "New conversation" button.
await alicePage.getByRole("button", { name: "Dismiss" }).click(); // enable key storage
await alicePage.getByRole("button", { name: "Yes, dismiss" }).click(); // enable key storage x2
// Alice creates a second room and invites Bob.
await createRoom(alicePage, "TestRoom2", true);
await aliceElementApp.toggleRoomInfoPanel(); // should not be necessary, called in body of below
await aliceElementApp.inviteUserToCurrentRoom(bobCredentials.userId);
// Bob accepts the invite.
await bobPage.getByRole("option", { name: "TestRoom2" }).click();
await bobPage.getByRole("button", { name: "Accept" }).click();
// Alice sends a message in the new room, which Bob should be able to decrypt.
await sendMessageInCurrentRoom(alicePage, "Decryptable");
await expect(bobPage.getByText("Decryptable")).toBeVisible();
});
test("setting per-room unverified blacklist toggle overrides global toggle", async ({
page: alicePage,
app: aliceElementApp,
homeserver,
browser,
user: aliceCredentials,
util,
}, testInfo) => {
await aliceElementApp.client.bootstrapCrossSigning(aliceCredentials);
// Enable blacklist toggle.
let dialog = await util.openEncryptionTab();
const blacklistToggle = dialog.getByRole("switch", {
name: "In encrypted rooms, only send messages to verified users",
});
await blacklistToggle.scrollIntoViewIfNeeded();
await expect(blacklistToggle).toBeVisible();
await blacklistToggle.click();
await aliceElementApp.settings.closeDialog();
// Create a second browser instance.
const bobCredentials = await homeserver.registerUser(`user_${testInfo.testId}_bob`, "password", "bob");
const bobPage = await createNewInstance(browser, bobCredentials, {});
const bobElementApp = new ElementAppPage(bobPage);
await bobElementApp.client.bootstrapCrossSigning(bobCredentials);
// Alice creates the room and invite Bob.
await createRoom(alicePage, "TestRoom", true);
await aliceElementApp.inviteUserToCurrentRoom(bobCredentials.userId);
// Bob accepts the invite.
await bobPage.getByRole("option", { name: "TestRoom" }).click();
await bobPage.getByRole("button", { name: "Accept" }).click();
// Alice configures her client to allow sending to unverified users in this room.
dialog = await aliceElementApp.settings.openRoomSettings("Security & Privacy");
await dialog.getByRole("switch", { name: "Only send messages to verified users." }).click();
await aliceElementApp.settings.closeDialog();
// Alice sends a message which Bob should be able to decrypt.
await sendMessageInCurrentRoom(alicePage, "Decryptable");
await expect(bobPage.getByText("Decryptable")).toBeVisible();
// Alice dismisses key storage warnings, as they now hide the "New conversation" button.
await alicePage.getByRole("button", { name: "Dismiss" }).click(); // enable key storage
await alicePage.getByRole("button", { name: "Yes, dismiss" }).click(); // enable key storage x2
// Alice creates a second room and invites Bob.
await createRoom(alicePage, "TestRoom2", true);
await aliceElementApp.toggleRoomInfoPanel(); // should not be necessary, called in body of below
await aliceElementApp.inviteUserToCurrentRoom(bobCredentials.userId);
// Bob accepts the invite.
await bobPage.getByRole("option", { name: "TestRoom2" }).click();
await bobPage.getByRole("button", { name: "Accept" }).click();
// Alice sends a message in the new room, which Bob should not be able to decrypt.
await sendMessageInCurrentRoom(alicePage, "Undecryptable");
await expect(
bobPage.getByText(
"The sender has blocked you from receiving this message because your device is unverified",
),
).toBeVisible();
});
});

View File

@ -7,83 +7,25 @@ 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 {
type MatrixEvent,
ClientEvent,
EventType,
type MatrixClient,
RoomStateEvent,
type SyncState,
ClientStoppedError,
TypedEventEmitter,
} from "matrix-js-sdk/src/matrix";
import { logger as baseLogger, type BaseLogger, LogSpan } from "matrix-js-sdk/src/logger";
import { CryptoEvent, type KeyBackupInfo } from "matrix-js-sdk/src/crypto-api";
import { type MatrixClient, ClientStoppedError, TypedEventEmitter } from "matrix-js-sdk/src/matrix";
import { logger as baseLogger, LogSpan } from "matrix-js-sdk/src/logger";
import { type CryptoSessionStateChange } from "@matrix-org/analytics-events/types/typescript/CryptoSessionStateChange";
import { secureRandomString } from "matrix-js-sdk/src/randomstring";
import { PosthogAnalytics } from "./PosthogAnalytics";
import dis from "./dispatcher/dispatcher";
import {
hideToast as hideSetupEncryptionToast,
showToast as showSetupEncryptionToast,
} from "./toasts/SetupEncryptionToast";
import { isSecretStorageBeingAccessed } from "./SecurityManager";
import { type ActionPayload } from "./dispatcher/payloads";
import { Action } from "./dispatcher/actions";
import SdkConfig from "./SdkConfig";
import PlatformPeg from "./PlatformPeg";
import { recordClientInformation, removeClientInformation } from "./utils/device/clientInformation";
import SettingsStore, { type CallbackFn } from "./settings/SettingsStore";
import { asyncSomeParallel } from "./utils/arrays.ts";
import DeviceListenerOtherDevices from "./device-listener/DeviceListenerOtherDevices.ts";
const KEY_BACKUP_POLL_INTERVAL = 5 * 60 * 1000;
/**
* Unfortunately-named account data key used by Element X to indicate that the user
* has chosen to disable server side key backups.
*
* We need to set and honour this to prevent Element X from automatically turning key backup back on.
*/
export const BACKUP_DISABLED_ACCOUNT_DATA_KEY = "m.org.matrix.custom.backup_disabled";
/**
* Account data key to indicate whether the user has chosen to enable or disable recovery.
*/
export const RECOVERY_ACCOUNT_DATA_KEY = "io.element.recovery";
import DeviceListenerCurrentDevice from "./device-listener/DeviceListenerCurrentDevice.ts";
import type DeviceState from "./device-listener/DeviceState.ts";
const logger = baseLogger.getChild("DeviceListener:");
/**
* The state of the device and the user's account.
*/
export type DeviceState =
/**
* The device is in a good state.
*/
| "ok"
/**
* The user needs to set up recovery.
*/
| "set_up_recovery"
/**
* The device is not verified.
*/
| "verify_this_session"
/**
* Key storage is out of sync (keys are missing locally, from recovery, or both).
*/
| "key_storage_out_of_sync"
/**
* Key storage is not enabled, and has not been marked as purposely disabled.
*/
| "turn_on_key_storage"
/**
* The user's identity needs resetting, due to missing keys.
*/
| "identity_needs_reset";
/**
* The events emitted by {@link DeviceListener}
*/
@ -98,21 +40,22 @@ type EventHandlerMap = {
export default class DeviceListener extends TypedEventEmitter<DeviceListenerEvents, EventHandlerMap> {
private dispatcherRef?: string;
/** All the information about whether other devices are verified. */
/**
* All the information about whether other devices are verified. Only set
* if `running` is true, otherwise undefined.
*/
public otherDevices?: DeviceListenerOtherDevices;
// has the user dismissed any of the various nag toasts to setup encryption on this device?
private dismissedThisDeviceToast = false;
/** Cache of the info about the current key backup on the server. */
private keyBackupInfo: KeyBackupInfo | null = null;
/** When `keyBackupInfo` was last updated */
private keyBackupFetchedAt: number | null = null;
/** All the information about whether this device's encrypytion is OK. Only
* set if `running` is true, otherwise undefined.
*/
public currentDevice?: DeviceListenerCurrentDevice;
private running = false;
// The client with which the instance is running. Only set if `running` is true, otherwise undefined.
private client?: MatrixClient;
private shouldRecordClientInformation = false;
private deviceClientInformationSettingWatcherRef: string | undefined;
private deviceState: DeviceState = "ok";
// Remember the current analytics state to avoid sending the same event multiple times.
private analyticsVerificationState?: string;
@ -127,15 +70,9 @@ export default class DeviceListener extends TypedEventEmitter<DeviceListenerEven
this.running = true;
this.otherDevices = new DeviceListenerOtherDevices(this, matrixClient);
this.currentDevice = new DeviceListenerCurrentDevice(this, matrixClient, logger);
this.client = matrixClient;
this.client.on(CryptoEvent.UserTrustStatusChanged, this.onUserTrustStatusChanged);
this.client.on(CryptoEvent.KeysChanged, this.onCrossSingingKeysChanged);
this.client.on(CryptoEvent.KeyBackupStatus, this.onKeyBackupStatusChanged);
this.client.on(ClientEvent.AccountData, this.onAccountData);
this.client.on(ClientEvent.Sync, this.onSync);
this.client.on(RoomStateEvent.Events, this.onRoomStateEvents);
this.client.on(ClientEvent.ToDeviceEvent, this.onToDeviceEvent);
this.shouldRecordClientInformation = SettingsStore.getValue("deviceClientInformationOptIn");
// only configurable in config, so we don't need to watch the value
@ -151,22 +88,14 @@ export default class DeviceListener extends TypedEventEmitter<DeviceListenerEven
public stop(): void {
this.running = false;
if (this.client) {
this.client.removeListener(CryptoEvent.UserTrustStatusChanged, this.onUserTrustStatusChanged);
this.client.removeListener(CryptoEvent.KeysChanged, this.onCrossSingingKeysChanged);
this.client.removeListener(ClientEvent.AccountData, this.onAccountData);
this.client.removeListener(ClientEvent.Sync, this.onSync);
this.client.removeListener(RoomStateEvent.Events, this.onRoomStateEvents);
this.client.removeListener(ClientEvent.ToDeviceEvent, this.onToDeviceEvent);
}
SettingsStore.unwatchSetting(this.deviceClientInformationSettingWatcherRef);
dis.unregister(this.dispatcherRef);
this.dispatcherRef = undefined;
this.otherDevices?.stop();
this.dismissedThisDeviceToast = false;
this.keyBackupInfo = null;
this.keyBackupFetchedAt = null;
this.cachedKeyBackupUploadActive = undefined;
this.currentDevice?.stop();
this.client = undefined;
}
@ -201,22 +130,21 @@ export default class DeviceListener extends TypedEventEmitter<DeviceListenerEven
}
public dismissEncryptionSetup(): void {
this.dismissedThisDeviceToast = true;
this.recheck();
this.currentDevice?.dismissEncryptionSetup();
}
/**
* Set the account data "m.org.matrix.custom.backup_disabled" to { "disabled": true }.
*/
public async recordKeyBackupDisabled(): Promise<void> {
await this.client?.setAccountData(BACKUP_DISABLED_ACCOUNT_DATA_KEY, { disabled: true });
await this.currentDevice?.recordKeyBackupDisabled();
}
/**
* Set the account data to indicate that recovery is disabled
*/
public async recordRecoveryDisabled(): Promise<void> {
await this.client?.setAccountData(RECOVERY_ACCOUNT_DATA_KEY, { enabled: false });
await this.currentDevice?.recordRecoveryDisabled();
}
/**
@ -265,10 +193,11 @@ export default class DeviceListener extends TypedEventEmitter<DeviceListenerEven
*/
public async keyStorageOutOfSyncNeedsBackupReset(forgotRecovery: boolean): Promise<boolean> {
const crypto = this.client?.getCrypto();
if (!crypto) {
const thisDevice = this.currentDevice;
if (!(crypto && thisDevice)) {
return false;
}
const shouldHaveBackup = !(await this.recheckBackupDisabled(this.client!));
const shouldHaveBackup = !(await thisDevice.recheckBackupDisabled());
const backupKeyCached = (await crypto.getSessionBackupPrivateKey()) !== null;
const backupKeyStored = await this.client!.isKeyBackupKeyStored();
@ -279,101 +208,12 @@ export default class DeviceListener extends TypedEventEmitter<DeviceListenerEven
}
}
private onUserTrustStatusChanged = (userId: string): void => {
if (!this.client) return;
if (userId !== this.client.getUserId()) return;
this.recheck();
};
private onKeyBackupStatusChanged = (): void => {
logger.info("Backup status changed");
this.cachedKeyBackupUploadActive = undefined;
this.recheck();
};
private onCrossSingingKeysChanged = (): void => {
this.recheck();
};
private onAccountData = (ev: MatrixEvent): void => {
// User may have:
// * migrated SSSS to symmetric
// * uploaded keys to secret storage
// * completed secret storage creation
// * disabled key backup
// which result in account data changes affecting checks below.
if (
ev.getType().startsWith("m.secret_storage.") ||
ev.getType().startsWith("m.cross_signing.") ||
ev.getType() === "m.megolm_backup.v1" ||
ev.getType() === BACKUP_DISABLED_ACCOUNT_DATA_KEY ||
ev.getType() === RECOVERY_ACCOUNT_DATA_KEY
) {
this.recheck();
}
};
private onSync = (state: SyncState, prevState: SyncState | null): void => {
if (state === "PREPARED" && prevState === null) {
this.recheck();
}
};
private onRoomStateEvents = (ev: MatrixEvent): void => {
if (ev.getType() !== EventType.RoomEncryption) return;
// If a room changes to encrypted, re-check as it may be our first
// encrypted room. This also catches encrypted room creation as well.
this.recheck();
};
private onAction = ({ action }: ActionPayload): void => {
if (action !== Action.OnLoggedIn) return;
this.recheck();
this.updateClientInformation();
};
private onToDeviceEvent = (event: MatrixEvent): void => {
// Receiving a 4S secret can mean we are in sync where we were not before.
if (event.getType() === EventType.SecretSend) this.recheck();
};
/**
* Fetch the key backup information from the server.
*
* The result is cached for `KEY_BACKUP_POLL_INTERVAL` ms to avoid repeated API calls.
*
* @returns The key backup info from the server, or `null` if there is no key backup.
*/
private async getKeyBackupInfo(): Promise<KeyBackupInfo | null> {
if (!this.client) return null;
const now = new Date().getTime();
const crypto = this.client.getCrypto();
if (!crypto) return null;
if (
!this.keyBackupInfo ||
!this.keyBackupFetchedAt ||
this.keyBackupFetchedAt < now - KEY_BACKUP_POLL_INTERVAL
) {
this.keyBackupInfo = await crypto.getKeyBackupInfo();
this.keyBackupFetchedAt = now;
}
return this.keyBackupInfo;
}
private async shouldShowSetupEncryptionToast(): Promise<boolean> {
// If we're in the middle of a secret storage operation, we're likely
// modifying the state involved here, so don't add new toasts to setup.
if (isSecretStorageBeingAccessed()) return false;
// Show setup toasts once the user is in at least one encrypted room.
const cli = this.client;
const cryptoApi = cli?.getCrypto();
if (!cli || !cryptoApi) return false;
return await asyncSomeParallel(cli.getRooms(), ({ roomId }) => cryptoApi.isEncryptionEnabledInRoom(roomId));
}
public recheck(): void {
this.doRecheck().catch((e) => {
if (e instanceof ClientStoppedError) {
@ -405,127 +245,10 @@ export default class DeviceListener extends TypedEventEmitter<DeviceListenerEven
return;
}
const crossSigningReady = await crypto.isCrossSigningReady();
const secretStorageStatus = await crypto.getSecretStorageStatus();
const crossSigningStatus = await crypto.getCrossSigningStatus();
const allCrossSigningSecretsCached =
crossSigningStatus.privateKeysCachedLocally.masterKey &&
crossSigningStatus.privateKeysCachedLocally.selfSigningKey &&
crossSigningStatus.privateKeysCachedLocally.userSigningKey;
const recoveryDisabled = await this.recheckRecoveryDisabled(cli);
const recoveryIsOk = secretStorageStatus.ready || recoveryDisabled;
const isCurrentDeviceTrusted = Boolean(
(await crypto.getDeviceVerificationStatus(cli.getSafeUserId(), cli.deviceId!))?.crossSigningVerified,
);
const keyBackupUploadActive = await this.isKeyBackupUploadActive(logSpan);
const backupDisabled = await this.recheckBackupDisabled(cli);
// We warn if key backup upload is turned off and we have not explicitly
// said we are OK with that.
const keyBackupUploadIsOk = keyBackupUploadActive || backupDisabled;
// We warn if key backup is set up, but we don't have the decryption
// key, so can't fetch keys from backup.
const keyBackupDownloadIsOk =
!keyBackupUploadActive || backupDisabled || (await crypto.getSessionBackupPrivateKey()) !== null;
const allSystemsReady =
isCurrentDeviceTrusted &&
allCrossSigningSecretsCached &&
keyBackupUploadIsOk &&
recoveryIsOk &&
keyBackupDownloadIsOk;
await this.currentDevice?.recheck(logSpan);
await this.otherDevices?.recheck(logSpan);
await this.reportCryptoSessionStateToAnalytics(cli);
if (allSystemsReady) {
logSpan.info("No toast needed");
await this.setDeviceState("ok", logSpan);
} else {
// make sure our keys are finished downloading
await crypto.getUserDeviceInfo([cli.getSafeUserId()]);
if (!isCurrentDeviceTrusted) {
// the current device is not trusted: prompt the user to verify
logSpan.info("Current device not verified: setting state to VERIFY_THIS_SESSION");
await this.setDeviceState("verify_this_session", logSpan);
} else if (!allCrossSigningSecretsCached) {
// cross signing ready & device trusted, but we are missing secrets from our local cache.
// prompt the user to enter their recovery key.
logSpan.info(
"Some secrets not cached: setting state to KEY_STORAGE_OUT_OF_SYNC",
crossSigningStatus.privateKeysCachedLocally,
crossSigningStatus.privateKeysInSecretStorage,
);
await this.setDeviceState(
crossSigningStatus.privateKeysInSecretStorage ? "key_storage_out_of_sync" : "identity_needs_reset",
logSpan,
);
} else if (!keyBackupUploadIsOk) {
logSpan.info("Key backup upload is unexpectedly turned off: setting state to TURN_ON_KEY_STORAGE");
await this.setDeviceState("turn_on_key_storage", logSpan);
} else if (secretStorageStatus.defaultKeyId === null) {
// The user just hasn't set up 4S yet: if they have key
// backup, prompt them to turn on recovery too. (If not, they
// have explicitly opted out, so don't hassle them.)
if (recoveryDisabled) {
logSpan.info("Recovery disabled: no toast needed");
await this.setDeviceState("ok", logSpan);
} else if (keyBackupUploadActive) {
logSpan.info("No default 4S key: setting state to SET_UP_RECOVERY");
await this.setDeviceState("set_up_recovery", logSpan);
} else {
logSpan.info("No default 4S key but backup disabled: no toast needed");
await this.setDeviceState("ok", logSpan);
}
} else {
// If we get here, then we are verified, have key backup, and
// 4S, but allSystemsReady is false, which means that either
// secretStorageStatus.ready is false (which means that 4S
// doesn't have all the secrets), or we don't have the backup
// key cached locally. If any of the cross-signing keys are
// missing locally, that is handled by the
// `!allCrossSigningSecretsCached` branch above.
logSpan.warn("4S is missing secrets or backup key not cached", {
crossSigningReady,
secretStorageStatus,
allCrossSigningSecretsCached,
isCurrentDeviceTrusted,
keyBackupDownloadIsOk,
});
await this.setDeviceState("key_storage_out_of_sync", logSpan);
}
}
await this.otherDevices?.recheck(logSpan);
}
/**
* Fetch the account data for `backup_disabled`. If this is the first time,
* fetch it from the server (in case the initial sync has not finished).
* Otherwise, fetch it from the store as normal.
*/
private async recheckBackupDisabled(cli: MatrixClient): Promise<boolean> {
const backupDisabled = await cli.getAccountDataFromServer(BACKUP_DISABLED_ACCOUNT_DATA_KEY);
return !!backupDisabled?.disabled;
}
/**
* Check whether the user has disabled recovery. If this is the first time,
* fetch it from the server (in case the initial sync has not finished).
* Otherwise, fetch it from the store as normal.
*/
private async recheckRecoveryDisabled(cli: MatrixClient): Promise<boolean> {
const recoveryStatus = await cli.getAccountDataFromServer(RECOVERY_ACCOUNT_DATA_KEY);
// Recovery is disabled only if the `enabled` flag is set to `false`.
// If it is missing, or set to any other value, we consider it as
// not-disabled, and will prompt the user to create recovery (if
// missing).
return recoveryStatus?.enabled === false;
}
/**
@ -534,23 +257,7 @@ export default class DeviceListener extends TypedEventEmitter<DeviceListenerEven
* self-verified device that is using key backup and recovery.
*/
public getDeviceState(): DeviceState {
return this.deviceState;
}
/**
* Set the state of the device, and perform any actions necessary in
* response to the state changing.
*/
private async setDeviceState(newState: DeviceState, logSpan: LogSpan): Promise<void> {
this.deviceState = newState;
this.emit(DeviceListenerEvents.DeviceState, newState);
if (newState === "ok" || this.dismissedThisDeviceToast) {
hideSetupEncryptionToast();
} else if (await this.shouldShowSetupEncryptionToast()) {
showSetupEncryptionToast(newState);
} else {
logSpan.info("Not yet ready, but shouldShowSetupEncryptionToast==false");
}
return this.currentDevice?.getDeviceState() ?? "ok";
}
/**
@ -564,7 +271,7 @@ export default class DeviceListener extends TypedEventEmitter<DeviceListenerEven
const secretStorageStatus = await crypto.getSecretStorageStatus();
const secretStorageReady = secretStorageStatus.ready;
const crossSigningStatus = await crypto.getCrossSigningStatus();
const backupInfo = await this.getKeyBackupInfo();
const backupInfo = await this.currentDevice?.getKeyBackupInfo();
const is4SEnabled = secretStorageStatus.defaultKeyId != null;
const deviceVerificationStatus = await crypto.getDeviceVerificationStatus(cli.getUserId()!, cli.getDeviceId()!);
@ -618,39 +325,6 @@ export default class DeviceListener extends TypedEventEmitter<DeviceListenerEven
});
}
/**
* Is key backup enabled? Use a cached answer if we have one.
*/
private isKeyBackupUploadActive = async (logger: BaseLogger): Promise<boolean> => {
if (!this.client) {
// To preserve existing behaviour, if there is no client, we
// pretend key backup upload is on.
//
// Someone looking to improve this code could try throwing an error
// here since we don't expect client to be undefined.
return true;
}
const crypto = this.client.getCrypto();
if (!crypto) {
// If there is no crypto, there is no key backup
return false;
}
// If we've already cached the answer, return it.
if (this.cachedKeyBackupUploadActive !== undefined) {
return this.cachedKeyBackupUploadActive;
}
// Fetch the answer and cache it
const activeKeyBackupVersion = await crypto.getActiveSessionBackupVersion();
this.cachedKeyBackupUploadActive = !!activeKeyBackupVersion;
logger.debug(`Key backup upload is ${this.cachedKeyBackupUploadActive ? "active" : "inactive"}`);
return this.cachedKeyBackupUploadActive;
};
private cachedKeyBackupUploadActive: boolean | undefined = undefined;
private onRecordClientInformationSettingChange: CallbackFn = (
_originalSettingName,
_roomId,

View File

@ -1793,8 +1793,20 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
const crypto = cli.getCrypto();
if (crypto) {
const blacklistEnabled = SettingsStore.getValueAt(SettingLevel.DEVICE, "blacklistUnverifiedDevices");
crypto.globalBlacklistUnverifiedDevices = blacklistEnabled;
crypto.globalBlacklistUnverifiedDevices = SettingsStore.getValueAt(
SettingLevel.DEVICE,
"blacklistUnverifiedDevices",
);
SettingsStore.watchSetting(
"blacklistUnverifiedDevices",
null,
(_settingName, _roomId, atLevel, blacklistEnabled: boolean) => {
if (atLevel != SettingLevel.DEVICE) {
return;
}
crypto.globalBlacklistUnverifiedDevices = blacklistEnabled;
},
);
}
// Cannot be done in OnLoggedIn as at that point the AccountSettingsHandler doesn't yet have a client

View File

@ -10,9 +10,10 @@ import { CryptoEvent } from "matrix-js-sdk/src/crypto-api";
import { logger } from "matrix-js-sdk/src/logger";
import { useMatrixClientContext } from "../../../../contexts/MatrixClientContext";
import DeviceListener, { BACKUP_DISABLED_ACCOUNT_DATA_KEY } from "../../../../DeviceListener";
import DeviceListener from "../../../../DeviceListener";
import { useEventEmitterAsyncState } from "../../../../hooks/useEventEmitter";
import { resetKeyBackupAndWait } from "../../../../utils/crypto/resetKeyBackup";
import { BACKUP_DISABLED_ACCOUNT_DATA_KEY } from "../../../../device-listener/DeviceListenerCurrentDevice";
interface KeyStoragePanelState {
/**

View File

@ -6,7 +6,7 @@
* Please see LICENSE files in the repository root for full details.
*/
import React, { type JSX, type MouseEventHandler, useState } from "react";
import React, { type JSX, type MouseEventHandler, useCallback, useState } from "react";
import {
Breadcrumb,
Button,
@ -20,6 +20,7 @@ import {
} from "@vector-im/compound-web";
import CopyIcon from "@vector-im/compound-design-tokens/assets/web/icons/copy";
import KeyIcon from "@vector-im/compound-design-tokens/assets/web/icons/key-solid";
import { logger } from "matrix-js-sdk/src/logger";
import { _t } from "../../../../languageHandler";
import { EncryptionCard } from "./EncryptionCard";
@ -30,8 +31,9 @@ import { initialiseDehydrationIfEnabled } from "../../../../utils/device/dehydra
import { withSecretStorageKeyCache } from "../../../../SecurityManager";
import { EncryptionCardButtons } from "./EncryptionCardButtons";
import { logErrorAndShowErrorDialog } from "../../../../utils/ErrorUtils.tsx";
import DeviceListener, { RECOVERY_ACCOUNT_DATA_KEY } from "../../../../DeviceListener";
import DeviceListener from "../../../../DeviceListener";
import { resetKeyBackupAndWait } from "../../../../utils/crypto/resetKeyBackup";
import { RECOVERY_ACCOUNT_DATA_KEY } from "../../../../device-listener/DeviceListenerCurrentDevice.ts";
/**
* The possible states of the component.
@ -78,6 +80,11 @@ export function ChangeRecoveryKey({
// "recovery" is about. Otherwise, we jump straight to showing the user the new key.
const [state, setState] = useState<State>(userHasRecoveryKey ? "save_key_change_flow" : "inform_user");
const onCancelClickWrapper = useCallback(() => {
logger.debug("ChangeRecoveryKey: user cancelled");
onCancelClick();
}, [onCancelClick]);
// We create a new recovery key, the recovery key will be displayed to the user
const recoveryKey = useAsyncMemo(() => matrixClient.getCrypto()!.createRecoveryKeyFromPassphrase(), []);
// Waiting for the recovery key to be generated
@ -90,7 +97,7 @@ export function ChangeRecoveryKey({
content = (
<InformationPanel
onContinueClick={() => setState("save_key_setup_flow")}
onCancelClick={onCancelClick}
onCancelClick={onCancelClickWrapper}
/>
);
break;
@ -108,7 +115,7 @@ export function ChangeRecoveryKey({
: "confirm_key_setup_flow",
)
}
onCancelClick={onCancelClick}
onCancelClick={onCancelClickWrapper}
/>
);
break;
@ -119,7 +126,7 @@ export function ChangeRecoveryKey({
<KeyForm
// encodedPrivateKey is always defined, the optional typing is incorrect
recoveryKey={recoveryKey.encodedPrivateKey!}
onCancelClick={onCancelClick}
onCancelClick={onCancelClickWrapper}
onSubmit={async () => {
const crypto = matrixClient.getCrypto();
if (!crypto) return onFinish();
@ -132,6 +139,9 @@ export function ChangeRecoveryKey({
// keyStorageOutOfSyncNeedsBackupReset won't be able to check
// the backup state.
const needsBackupReset = await deviceListener.keyStorageOutOfSyncNeedsBackupReset(true);
logger.debug(
`ChangeRecoveryKey: user confirmed recovery key; now doing change. needsBackupReset: ${needsBackupReset}`,
);
await deviceListener.whilePaused(async () => {
// We need to enable the cache to avoid to prompt the user to enter the new key
// when we will try to access the secret storage during the bootstrap
@ -177,9 +187,9 @@ export function ChangeRecoveryKey({
<>
<Breadcrumb
backLabel={_t("action|back")}
onBackClick={onCancelClick}
onBackClick={onCancelClickWrapper}
pages={pages}
onPageClick={onCancelClick}
onPageClick={onCancelClickWrapper}
/>
<EncryptionCard
Icon={KeyIcon}

View File

@ -8,6 +8,7 @@
import React, { type JSX } from "react";
import { Button } from "@vector-im/compound-web";
import KeyIcon from "@vector-im/compound-design-tokens/assets/web/icons/key";
import { logger } from "matrix-js-sdk/src/logger";
import { SettingsSection } from "../shared/SettingsSection";
import { _t } from "../../../../languageHandler";
@ -78,6 +79,9 @@ export function RecoveryPanelOutOfSync({
// the backup state.
const needsBackupReset = await deviceListener.keyStorageOutOfSyncNeedsBackupReset(false);
logger.debug(
`RecoveryPanelOutOfSync: user clicked 'Enter recovery key'. needsBackupReset: ${needsBackupReset}`,
);
try {
// pause the device listener because we could be making lots
// of changes, and don't want toasts to pop up and disappear

View File

@ -24,8 +24,9 @@ import { RecoveryPanelOutOfSync } from "../../encryption/RecoveryPanelOutOfSync"
import { useTypedEventEmitterState } from "../../../../../hooks/useEventEmitter";
import { KeyStoragePanel } from "../../encryption/KeyStoragePanel";
import { DeleteKeyStoragePanel } from "../../encryption/DeleteKeyStoragePanel";
import DeviceListener, { DeviceListenerEvents, type DeviceState } from "../../../../../DeviceListener";
import DeviceListener, { DeviceListenerEvents } from "../../../../../DeviceListener";
import { useKeyStoragePanelViewModel } from "../../../../viewmodels/settings/encryption/KeyStoragePanelViewModel";
import type DeviceState from "../../../../../device-listener/DeviceState";
/**
* The state in the encryption settings tab.

View File

@ -0,0 +1,420 @@
/*
Copyright 2025-2026 Element Creations Ltd.
Copyright 2024 New Vector Ltd.
Copyright 2020 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 { CryptoEvent, type KeyBackupInfo } from "matrix-js-sdk/src/crypto-api";
import { type LogSpan, type BaseLogger, type Logger } from "matrix-js-sdk/src/logger";
import {
type MatrixEvent,
type MatrixClient,
EventType,
type SyncState,
RoomStateEvent,
ClientEvent,
} from "matrix-js-sdk/src/matrix";
import type DeviceListener from "../DeviceListener";
import type DeviceState from "./DeviceState";
import { DeviceListenerEvents } from "../DeviceListener";
import {
hideToast as hideSetupEncryptionToast,
showToast as showSetupEncryptionToast,
} from "../toasts/SetupEncryptionToast";
import { isSecretStorageBeingAccessed } from "../SecurityManager";
import { asyncSomeParallel } from "../utils/arrays";
const KEY_BACKUP_POLL_INTERVAL = 5 * 60 * 1000;
/**
* Unfortunately-named account data key used by Element X to indicate that the user
* has chosen to disable server side key backups.
*
* We need to set and honour this to prevent Element X from automatically turning key backup back on.
*/
export const BACKUP_DISABLED_ACCOUNT_DATA_KEY = "m.org.matrix.custom.backup_disabled";
/**
* Account data key to indicate whether the user has chosen to enable or disable recovery.
*/
export const RECOVERY_ACCOUNT_DATA_KEY = "io.element.recovery";
/**
* Handles all of DeviceListener's work that relates to the current device.
*/
export default class DeviceListenerCurrentDevice {
/**
* The DeviceListener launching this instance.
*/
private deviceListener: DeviceListener;
/**
* The Matrix client in use by the current user.
*/
private client: MatrixClient;
/**
* A Logger we use to write our debug information.
*/
private logger: Logger;
/**
* Has the user dismissed any of the various nag toasts to setup encryption
* on this device?
*/
private dismissedThisDeviceToast = false;
/**
* Cache of the info about the current key backup on the server.
*/
private keyBackupInfo: KeyBackupInfo | null = null;
/**
* When `keyBackupInfo` was last updated (in ms since the epoch).
*/
private keyBackupFetchedAt: number | null = null;
/**
* What is the current state of the device: is its crypto OK?
*/
private deviceState: DeviceState = "ok";
/**
* Was key backup upload active last time we checked?
*/
private cachedKeyBackupUploadActive: boolean | undefined = undefined;
public constructor(deviceListener: DeviceListener, client: MatrixClient, logger: Logger) {
this.deviceListener = deviceListener;
this.client = client;
this.logger = logger;
this.client.on(CryptoEvent.UserTrustStatusChanged, this.onUserTrustStatusChanged);
this.client.on(CryptoEvent.KeysChanged, this.onCrossSigningKeysChanged);
this.client.on(CryptoEvent.KeyBackupStatus, this.onKeyBackupStatusChanged);
this.client.on(ClientEvent.AccountData, this.onAccountData);
this.client.on(ClientEvent.Sync, this.onSync);
this.client.on(RoomStateEvent.Events, this.onRoomStateEvents);
this.client.on(ClientEvent.ToDeviceEvent, this.onToDeviceEvent);
}
/**
* Stop listening for events and clear the stored information.
*/
public stop(): void {
this.dismissedThisDeviceToast = false;
this.keyBackupInfo = null;
this.keyBackupFetchedAt = null;
this.cachedKeyBackupUploadActive = undefined;
this.client.removeListener(CryptoEvent.UserTrustStatusChanged, this.onUserTrustStatusChanged);
this.client.removeListener(CryptoEvent.KeysChanged, this.onCrossSigningKeysChanged);
this.client.removeListener(CryptoEvent.KeyBackupStatus, this.onKeyBackupStatusChanged);
this.client.removeListener(ClientEvent.AccountData, this.onAccountData);
this.client.removeListener(ClientEvent.Sync, this.onSync);
this.client.removeListener(RoomStateEvent.Events, this.onRoomStateEvents);
this.client.removeListener(ClientEvent.ToDeviceEvent, this.onToDeviceEvent);
}
/**
* The user dismissed the Key Storage out of Sync toast, so we won't nag
* them again until they refresh or restart the app.
*/
public dismissEncryptionSetup(): void {
this.dismissedThisDeviceToast = true;
this.deviceListener.recheck();
}
/**
* Set the account data "m.org.matrix.custom.backup_disabled" to `{ "disabled": true }`.
*/
public async recordKeyBackupDisabled(): Promise<void> {
await this.client.setAccountData(BACKUP_DISABLED_ACCOUNT_DATA_KEY, { disabled: true });
}
/**
* Set the account data to indicate that recovery is disabled
*/
public async recordRecoveryDisabled(): Promise<void> {
await this.client.setAccountData(RECOVERY_ACCOUNT_DATA_KEY, { enabled: false });
}
/**
* Display a toast if our crypto is in an unexpected state, or if we want to
* nag the user about setting up more stuff.
*/
public async recheck(logSpan: LogSpan): Promise<void> {
const crypto = this.client.getCrypto();
if (!crypto) {
return;
}
const crossSigningReady = await crypto.isCrossSigningReady();
const secretStorageStatus = await crypto.getSecretStorageStatus();
const crossSigningStatus = await crypto.getCrossSigningStatus();
const allCrossSigningSecretsCached =
crossSigningStatus.privateKeysCachedLocally.masterKey &&
crossSigningStatus.privateKeysCachedLocally.selfSigningKey &&
crossSigningStatus.privateKeysCachedLocally.userSigningKey;
const recoveryDisabled = await this.recheckRecoveryDisabled(this.client);
const recoveryIsOk = secretStorageStatus.ready || recoveryDisabled;
const isCurrentDeviceTrusted = Boolean(
(await crypto.getDeviceVerificationStatus(this.client.getSafeUserId(), this.client.deviceId!))
?.crossSigningVerified,
);
const keyBackupUploadActive = await this.isKeyBackupUploadActive(logSpan);
const backupDisabled = await this.recheckBackupDisabled();
// We warn if key backup upload is turned off and we have not explicitly
// said we are OK with that.
const keyBackupUploadIsOk = keyBackupUploadActive || backupDisabled;
// We warn if key backup is set up, but we don't have the decryption
// key, so can't fetch keys from backup.
const keyBackupDownloadIsOk =
!keyBackupUploadActive || backupDisabled || (await crypto.getSessionBackupPrivateKey()) !== null;
const allSystemsReady =
isCurrentDeviceTrusted &&
allCrossSigningSecretsCached &&
keyBackupUploadIsOk &&
recoveryIsOk &&
keyBackupDownloadIsOk;
if (allSystemsReady) {
logSpan.info("No toast needed");
await this.setDeviceState("ok", logSpan);
} else {
// make sure our keys are finished downloading
await crypto.getUserDeviceInfo([this.client.getSafeUserId()]);
if (!isCurrentDeviceTrusted) {
// the current device is not trusted: prompt the user to verify
logSpan.info("Current device not verified: setting state to VERIFY_THIS_SESSION");
await this.setDeviceState("verify_this_session", logSpan);
} else if (!allCrossSigningSecretsCached) {
// cross signing ready & device trusted, but we are missing secrets from our local cache.
// prompt the user to enter their recovery key.
logSpan.info(
"Some secrets not cached: setting state to KEY_STORAGE_OUT_OF_SYNC",
crossSigningStatus.privateKeysCachedLocally,
crossSigningStatus.privateKeysInSecretStorage,
);
await this.setDeviceState(
crossSigningStatus.privateKeysInSecretStorage ? "key_storage_out_of_sync" : "identity_needs_reset",
logSpan,
);
} else if (!keyBackupUploadIsOk) {
logSpan.info("Key backup upload is unexpectedly turned off: setting state to TURN_ON_KEY_STORAGE");
await this.setDeviceState("turn_on_key_storage", logSpan);
} else if (secretStorageStatus.defaultKeyId === null) {
// The user just hasn't set up 4S yet: if they have key
// backup, prompt them to turn on recovery too. (If not, they
// have explicitly opted out, so don't hassle them.)
if (recoveryDisabled) {
logSpan.info("Recovery disabled: no toast needed");
await this.setDeviceState("ok", logSpan);
} else if (keyBackupUploadActive) {
logSpan.info("No default 4S key: setting state to SET_UP_RECOVERY");
await this.setDeviceState("set_up_recovery", logSpan);
} else {
logSpan.info("No default 4S key but backup disabled: no toast needed");
await this.setDeviceState("ok", logSpan);
}
} else {
// If we get here, then we are verified, have key backup, and
// 4S, but allSystemsReady is false, which means that either
// secretStorageStatus.ready is false (which means that 4S
// doesn't have all the secrets), or we don't have the backup
// key cached locally. If any of the cross-signing keys are
// missing locally, that is handled by the
// `!allCrossSigningSecretsCached` branch above.
logSpan.warn("4S is missing secrets or backup key not cached", {
crossSigningReady,
secretStorageStatus,
allCrossSigningSecretsCached,
isCurrentDeviceTrusted,
keyBackupDownloadIsOk,
});
await this.setDeviceState("key_storage_out_of_sync", logSpan);
}
}
}
/**
* Get the state of the device and the user's account. The device/account
* state indicates what action the user must take in order to get a
* self-verified device that is using key backup and recovery.
*/
public getDeviceState(): DeviceState {
return this.deviceState;
}
/**
* Set the state of the device, and perform any actions necessary in
* response to the state changing.
*/
private async setDeviceState(newState: DeviceState, logSpan: LogSpan): Promise<void> {
this.deviceState = newState;
this.deviceListener.emit(DeviceListenerEvents.DeviceState, newState);
if (newState === "ok" || this.dismissedThisDeviceToast) {
hideSetupEncryptionToast();
} else if (await this.shouldShowSetupEncryptionToast()) {
showSetupEncryptionToast(newState);
} else {
logSpan.info("Not yet ready, but shouldShowSetupEncryptionToast==false");
}
}
/**
* Fetch the account data for `backup_disabled`. If this is the first time,
* fetch it from the server (in case the initial sync has not finished).
* Otherwise, fetch it from the store as normal.
*/
public async recheckBackupDisabled(): Promise<boolean> {
const backupDisabled = await this.client.getAccountDataFromServer(BACKUP_DISABLED_ACCOUNT_DATA_KEY);
return !!backupDisabled?.disabled;
}
/**
* Check whether the user has disabled recovery. If this is the first time,
* fetch it from the server (in case the initial sync has not finished).
* Otherwise, fetch it from the store as normal.
*/
private async recheckRecoveryDisabled(cli: MatrixClient): Promise<boolean> {
const recoveryStatus = await cli.getAccountDataFromServer(RECOVERY_ACCOUNT_DATA_KEY);
// Recovery is disabled only if the `enabled` flag is set to `false`.
// If it is missing, or set to any other value, we consider it as
// not-disabled, and will prompt the user to create recovery (if
// missing).
return recoveryStatus?.enabled === false;
}
private onUserTrustStatusChanged = (userId: string): void => {
if (userId !== this.client.getUserId()) return;
this.deviceListener.recheck();
};
private onKeyBackupStatusChanged = (): void => {
this.logger.info("Backup status changed");
this.cachedKeyBackupUploadActive = undefined;
this.deviceListener.recheck();
};
private onCrossSigningKeysChanged = (): void => {
this.deviceListener.recheck();
};
private onAccountData = (ev: MatrixEvent): void => {
// User may have:
// * migrated SSSS to symmetric
// * uploaded keys to secret storage
// * completed secret storage creation
// * disabled key backup
// which result in account data changes affecting checks below.
if (
ev.getType().startsWith("m.secret_storage.") ||
ev.getType().startsWith("m.cross_signing.") ||
ev.getType() === "m.megolm_backup.v1" ||
ev.getType() === BACKUP_DISABLED_ACCOUNT_DATA_KEY ||
ev.getType() === RECOVERY_ACCOUNT_DATA_KEY
) {
this.deviceListener.recheck();
}
};
private onSync = (state: SyncState, prevState: SyncState | null): void => {
if (state === "PREPARED" && prevState === null) {
this.deviceListener.recheck();
}
};
private onRoomStateEvents = (ev: MatrixEvent): void => {
if (ev.getType() !== EventType.RoomEncryption) return;
// If a room changes to encrypted, re-check as it may be our first
// encrypted room. This also catches encrypted room creation as well.
this.deviceListener.recheck();
};
private onToDeviceEvent = (event: MatrixEvent): void => {
// Receiving a 4S secret can mean we are in sync where we were not before.
if (event.getType() === EventType.SecretSend) {
this.deviceListener.recheck();
}
};
/**
* Fetch the key backup information from the server.
*
* The result is cached for `KEY_BACKUP_POLL_INTERVAL` ms to avoid repeated API calls.
*
* @returns The key backup info from the server, or `null` if there is no key backup.
*/
public async getKeyBackupInfo(): Promise<KeyBackupInfo | null> {
const now = new Date().getTime();
const crypto = this.client.getCrypto();
if (!crypto) return null;
if (
!this.keyBackupInfo ||
!this.keyBackupFetchedAt ||
this.keyBackupFetchedAt < now - KEY_BACKUP_POLL_INTERVAL
) {
this.keyBackupInfo = await crypto.getKeyBackupInfo();
this.keyBackupFetchedAt = now;
}
return this.keyBackupInfo;
}
/**
* Is the user in at least one encrypted room?
*/
private async shouldShowSetupEncryptionToast(): Promise<boolean> {
// If we're in the middle of a secret storage operation, we're likely
// modifying the state involved here, so don't add new toasts to setup.
if (isSecretStorageBeingAccessed()) return false;
// Show setup toasts once the user is in at least one encrypted room.
const cryptoApi = this.client.getCrypto();
if (!cryptoApi) return false;
return await asyncSomeParallel(this.client.getRooms(), ({ roomId }) =>
cryptoApi.isEncryptionEnabledInRoom(roomId),
);
}
/**
* Is key backup enabled? Use a cached answer if we have one.
*/
private isKeyBackupUploadActive = async (logger: BaseLogger): Promise<boolean> => {
const crypto = this.client.getCrypto();
if (!crypto) {
// If there is no crypto, there is no key backup
return false;
}
// If we've already cached the answer, return it.
if (this.cachedKeyBackupUploadActive !== undefined) {
return this.cachedKeyBackupUploadActive;
}
// Fetch the answer and cache it
const activeKeyBackupVersion = await crypto.getActiveSessionBackupVersion();
this.cachedKeyBackupUploadActive = !!activeKeyBackupVersion;
logger.debug(`Key backup upload is ${this.cachedKeyBackupUploadActive ? "active" : "inactive"}`);
return this.cachedKeyBackupUploadActive;
};
}

View File

@ -0,0 +1,39 @@
/*
Copyright 2025-2026 Element Creations Ltd.
Copyright 2024 New Vector Ltd.
Copyright 2020 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.
*/
/**
* The state of the device and the user's account.
*/
type DeviceState =
/**
* The device is in a good state.
*/
| "ok"
/**
* The user needs to set up recovery.
*/
| "set_up_recovery"
/**
* The device is not verified.
*/
| "verify_this_session"
/**
* Key storage is out of sync (keys are missing locally, from recovery, or both).
*/
| "key_storage_out_of_sync"
/**
* Key storage is not enabled, and has not been marked as purposely disabled.
*/
| "turn_on_key_storage"
/**
* The user's identity needs resetting, due to missing keys.
*/
| "identity_needs_reset";
export default DeviceState;

View File

@ -161,6 +161,10 @@ export class RoomListStoreV3Class extends AsyncStoreWithClient<EmptyObject> {
this.emit(LISTS_UPDATE_EVENT);
}
protected async onNotReady(): Promise<void> {
this.roomSkipList = undefined;
}
protected async onAction(payload: ActionPayload): Promise<void> {
if (!this.matrixClient || !this.roomSkipList?.initialized) return;

View File

@ -11,10 +11,11 @@ import React from "react";
import { KeyIcon, ErrorSolidIcon, SettingsSolidIcon } from "@vector-im/compound-design-tokens/assets/web/icons";
import { type ComponentType } from "react";
import { type Interaction as InteractionEvent } from "@matrix-org/analytics-events/types/typescript/Interaction";
import { logger } from "matrix-js-sdk/src/logger";
import Modal from "../Modal";
import { _t } from "../languageHandler";
import DeviceListener, { type DeviceState } from "../DeviceListener";
import DeviceListener from "../DeviceListener";
import SetupEncryptionDialog from "../components/views/dialogs/security/SetupEncryptionDialog";
import { AccessCancelledError, accessSecretStorage } from "../SecurityManager";
import ToastStore, { type IToast } from "../stores/ToastStore";
@ -30,6 +31,7 @@ import ConfirmKeyStorageOffDialog from "../components/views/dialogs/ConfirmKeySt
import { MatrixClientPeg } from "../MatrixClientPeg";
import { resetKeyBackupAndWait } from "../utils/crypto/resetKeyBackup";
import { PosthogAnalytics } from "../PosthogAnalytics";
import type DeviceState from "../device-listener/DeviceState";
const TOAST_KEY = "setupencryption";
@ -143,6 +145,7 @@ const getDescription = (state: DeviceStateForToast): string => {
* @param state The state of the device
*/
export const showToast = (state: DeviceStateForToast): void => {
const myLogger = logger.getChild(`SetupEncryptionToast[${state}]:`);
if (
ModuleRunner.instance.extensions.cryptoSetup.setupEncryptionNeeded({
kind: state as any,
@ -161,6 +164,7 @@ export const showToast = (state: DeviceStateForToast): void => {
interactionType: "Pointer",
name: state === "set_up_recovery" ? "ToastSetUpRecoveryClick" : "ToastTurnOnKeyStorageClick",
});
myLogger.debug("Primary button clicked: opening encryption settings dialog");
// Open the user settings dialog to the encryption tab
const payload: OpenToTabPayload = {
action: Action.ViewUserSettings,
@ -170,9 +174,11 @@ export const showToast = (state: DeviceStateForToast): void => {
break;
}
case "verify_this_session":
myLogger.debug("Primary button clicked: opening SetupEncryptionDialog");
Modal.createDialog(SetupEncryptionDialog, {}, undefined, /* priority = */ false, /* static = */ true);
break;
case "key_storage_out_of_sync": {
myLogger.debug("Primary button clicked: starting recovery process");
const modal = Modal.createDialog(
Spinner,
undefined,
@ -214,6 +220,7 @@ export const showToast = (state: DeviceStateForToast): void => {
break;
}
case "identity_needs_reset": {
myLogger.debug("Primary button clicked: opening encryption settings dialog");
// Open the user settings dialog to reset identity
const payload: OpenToTabPayload = {
action: Action.ViewUserSettings,
@ -236,6 +243,7 @@ export const showToast = (state: DeviceStateForToast): void => {
interactionType: "Pointer",
name: "ToastSetUpRecoveryDismiss",
});
myLogger.debug("Secondary button clicked: disabling recovery");
// Record that the user doesn't want to set up recovery
const deviceListener = DeviceListener.sharedInstance();
await deviceListener.recordRecoveryDisabled();
@ -246,14 +254,14 @@ export const showToast = (state: DeviceStateForToast): void => {
// Open the user settings dialog to the encryption tab and start the flow to reset encryption or change the recovery key
const deviceListener = DeviceListener.sharedInstance();
const needsCrossSigningReset = await deviceListener.keyStorageOutOfSyncNeedsCrossSigningReset(true);
const props = {
initialEncryptionState: needsCrossSigningReset ? "reset_identity_forgot" : "change_recovery_key",
};
myLogger.debug(`Secondary button clicked: opening encryption settings dialog with props`, props);
const payload: OpenToTabPayload = {
action: Action.ViewUserSettings,
initialTabId: UserTab.Encryption,
props: {
initialEncryptionState: needsCrossSigningReset
? "reset_identity_forgot"
: "change_recovery_key",
},
props,
};
defaultDispatcher.dispatch(payload);
break;
@ -272,6 +280,7 @@ export const showToast = (state: DeviceStateForToast): void => {
);
const [dismissed] = await modal.finished;
if (dismissed) {
myLogger.debug("Secondary button clicked and confirmed: recording key storage disabled");
const deviceListener = DeviceListener.sharedInstance();
await deviceListener.recordKeyBackupDisabled();
deviceListener.dismissEncryptionSetup();
@ -279,6 +288,7 @@ export const showToast = (state: DeviceStateForToast): void => {
break;
}
default:
myLogger.debug("Secondary button clicked: dismissing");
DeviceListener.sharedInstance().dismissEncryptionSetup();
}
};
@ -292,20 +302,24 @@ export const showToast = (state: DeviceStateForToast): void => {
*/
const onAccessSecretStorageFailed = async (error: Error): Promise<void> => {
if (error instanceof AccessCancelledError) {
myLogger.debug("AccessSecretStorage failed: user cancelled");
// The user cancelled the dialog - just allow it to close
} else {
// A real error happened - jump to the reset identity or change
// recovery tab
const needsCrossSigningReset =
await DeviceListener.sharedInstance().keyStorageOutOfSyncNeedsCrossSigningReset(true);
const props = {
initialEncryptionState: needsCrossSigningReset ? "reset_identity_sync_failed" : "change_recovery_key",
};
myLogger.debug(
`AccessSecretStorage failed: ${error}. Opening encryption settings dialog with props: `,
props,
);
const payload: OpenToTabPayload = {
action: Action.ViewUserSettings,
initialTabId: UserTab.Encryption,
props: {
initialEncryptionState: needsCrossSigningReset
? "reset_identity_sync_failed"
: "change_recovery_key",
},
props,
};
defaultDispatcher.dispatch(payload);
}

View File

@ -25,7 +25,7 @@ import {
} from "matrix-js-sdk/src/crypto-api";
import { type CryptoSessionStateChange } from "@matrix-org/analytics-events/types/typescript/CryptoSessionStateChange";
import DeviceListener, { BACKUP_DISABLED_ACCOUNT_DATA_KEY } from "../../src/DeviceListener";
import DeviceListener from "../../src/DeviceListener";
import { MatrixClientPeg } from "../../src/MatrixClientPeg";
import * as SetupEncryptionToast from "../../src/toasts/SetupEncryptionToast";
import * as UnverifiedSessionToast from "../../src/toasts/UnverifiedSessionToast";
@ -37,6 +37,7 @@ import { SettingLevel } from "../../src/settings/SettingLevel";
import { getMockClientWithEventEmitter, mockPlatformPeg } from "../test-utils";
import { isBulkUnverifiedDeviceReminderSnoozed } from "../../src/utils/device/snoozeBulkUnverifiedDeviceReminder";
import { PosthogAnalytics } from "../../src/PosthogAnalytics";
import { BACKUP_DISABLED_ACCOUNT_DATA_KEY } from "../../src/device-listener/DeviceListenerCurrentDevice";
jest.mock("../../src/dispatcher/dispatcher", () => ({
dispatch: jest.fn(),

View File

@ -1839,4 +1839,39 @@ describe("<MatrixChat />", () => {
);
});
});
describe("blacklistUnverifiedDevices settings", () => {
beforeEach(async () => {
mockPlatformPeg();
getComponent({});
// Force a client start manually to avoid needing to go through the login flow.
defaultDispatcher.dispatch({
action: Action.ClientStarted,
});
await flushPromises();
});
afterEach(() => {
SettingsStore.reset();
});
it("should ignore room-device-level blacklistUnverifiedDevices updates", async () => {
// Set the blacklist toggle at a room-specific level ...
await SettingsStore.setValue(
"blacklistUnverifiedDevices",
"!room:example.com",
SettingLevel.ROOM_DEVICE,
true,
);
// ... which SHOULD NOT affect the global blacklist property.
expect(mockClient.getCrypto()!.globalBlacklistUnverifiedDevices).toBeFalsy();
}, 10e3);
it("should update globalBlacklistUnverifiedDevices on device-level updates", async () => {
// Set the blacklist toggle at a device level ...
await SettingsStore.setValue("blacklistUnverifiedDevices", null, SettingLevel.DEVICE, true);
// shich SHOULD affect the global blacklist property.
expect(mockClient.getCrypto()!.globalBlacklistUnverifiedDevices).toBeTruthy();
}, 10e3);
});
});

View File

@ -27,66 +27,105 @@ This is anywhere your data or business logic comes from. If your view model is a
1. Located in [`shared-components`](https://github.com/element-hq/element-web/tree/develop/packages/shared-components). Develop it in storybook!
2. Views are simple react components (eg: `FooView`) with very little state and logic.
3. Views must call `useViewModel` hook with the corresponding view model passed in as argument. This allows the view to re-render when something has changed in the view model. This entire mechanism is powered by [useSyncExternalStore](https://react.dev/reference/react/useSyncExternalStore).
4. Views should define the interface of the view model they expect:
```tsx
// Snapshot is the data that your view-model provides which is rendered by the view.
interface FooViewSnapshot {
value: string;
}
// To call function on the view model
interface FooViewActions {
doSomething: () => void;
}
// ViewModel is an object (usually a class) that implements both the interfaces listed above.
// https://github.com/element-hq/element-web/blob/develop/packages/shared-components/src/ViewModel.ts
type FooViewModel = ViewModel<FooViewSnapshot> & FooViewActions;
interface FooViewProps {
// Ideally the view only depends on the view model i.e you don't expect any other props here.
vm: FooViewModel;
}
function FooView({ vm }: FooViewProps) {
const { value } = useViewModel(vm);
return (
<button type="button" onClick={() => vm.doSomething()}>
{value}
</button>
);
}
```
4. Views should define the interface of the view model (see example below).
5. Multiple views can share the same view model if necessary.
6. A full example is available [here](https://github.com/element-hq/element-web/blob/develop/packages/shared-components/src/audio/AudioPlayerView/AudioPlayerView.tsx)
**Example of view implementation**
```tsx
// Snapshot is the data that your view-model provides which is rendered by the view.
export interface FooViewSnapshot {
title: string;
description: string;
}
// To call function on the view model
interface FooViewActions {
setTitle: (title: string) => void;
reloadDescription: () => void;
}
// ViewModel is an object (usually a class) that implements both the interfaces listed above.
// https://github.com/element-hq/element-web/blob/develop/packages/shared-components/src/ViewModel.ts
export type FooViewModel = ViewModel<FooViewSnapshot> & FooViewActions;
interface FooViewProps {
// Ideally the view only depends on the view model i.e you don't expect any other props here.
vm: FooViewModel;
}
export function FooView({ vm }: FooViewProps): JSX.Element {
// useViewModel is a hook that subscribes to the view model and returns the snapshot. It also ensures that the component re-renders when the snapshot changes.
const { title, description } = useViewModel(vm);
return (
<section>
<h1>{title}</h1>
{/* Bind setTitle action */}
<button type="button" onClick={() => vm.setTitle("new title!")}>
Set title
</button>
<p>{description}</p>
{/* Bind reloadDescription action */}
<button type="button" onClick={() => vm.reloadDescription()}>
Reload description
</button>
</section>
);
}
```
#### View Model
1. A View model is a class extending [`BaseViewModel`](https://github.com/element-hq/element-web/blob/develop/src/viewmodels/base/BaseViewModel.ts).
2. Implements the interface defined in the view (e.g `FooViewModel` in the example above).
3. View models define a snapshot type that defines the data the view will consume. The snapshot is immutable and can only be changed by calling `this.snapshot.set(...)` in the view model. This will trigger a re-render in the view.
3. View models define a snapshot type that defines the data the view will consume. The snapshot is immutable and can only be changed by calling `this.snapshot.set(...)` or `this.snapshot.merge(...)` in the view model. This will trigger a re-render in the view.
4. Call [`this.snapshot.merge(...)`](https://github.com/element-hq/element-web/blob/develop/packages/shared-components/src/viewmodel/Snapshot.ts#L32) to only update part of the snapshot.
5. Avoid recomputing the entire snapshot when you only need to update a single field. For performance reasons, only recompute the fields that have actually changed. For example, if only `title` has changed, call `this.snapshot.merge({ title: newTitle })` rather than rebuilding the full snapshot object with all fields recomputed.
6. View models can have props which are passed in the constructor. Props are usually used to pass in dependencies (eg: stores, sdk, etc) that the view model needs to do its work. They can also be used to pass in initial values for the snapshot.
```ts
interface Props {
propsValue: string;
**Example of a view model implementation**
```ts
import { type FooViewSnapshot, type FooViewModel as FooViewModelInterface } from "./FooView";
// Props are the arguments passed to the view model constructor. They are usually used to pass in dependencies (eg: stores, sdk, etc) that the view model needs to do its work. They can also be used to pass in initial values for the snapshot.
interface Props {
title: string;
}
/**
* This is an example view model that implements the FooViewModelInterface.
* It extends the BaseViewModel class which provides common functionality for view models, such as managing subscriptions and snapshots.
* The view model is responsible for managing the state of the view and providing actions that can be called from the view.
* In this example, we have a title and description in the snapshot, and actions to set the title and reload the description.
*/
export class FooViewModel extends BaseViewModel<FooViewSnapshot, Props> implements FooViewModelInterface {
public constructor(props: Props) {
// Call super with initial snapshot
super(props, { title: props.title, description: costlyDescriptionLoading() });
}
class FooViewModel extends BaseViewModel<FooViewSnapshot, Props> implements FooViewModel {
constructor(props: Props) {
// Call super with initial snapshot
super(props, { value: "initial" });
}
public doSomething() {
// Call this.snapshot.set to update the snapshot
this.snapshot.set({ value: "changed" });
}
public setTitle(title: string): void {
// We only update the title in the snapshot, description remains unchanged.
// Calling `this.snapshot.merge` will trigger the view to re-render with the new snapshot value.
// If we had called `this.snapshot.set`, we would have needed to provide the full snapshot value, including the description.
this.snapshot.merge({ title });
}
```
4. A full example is available [here](https://github.com/element-hq/element-web/blob/develop/src/viewmodels/audio/AudioPlayerViewModel.ts)
public reloadDescription(): void {
// Simulate reloading the description by calling the costly function again and updating the snapshot.
this.snapshot.merge({ description: costlyDescriptionLoading() });
}
/**
* This is an example of how to access props in the view model. Props are passed in the constructor and can be accessed through `this.props`.
*/
public printProps(): void {
// Access props through `this.props`
console.log("Current props:", this.props);
}
}
```
### `useViewModel` hook

View File

@ -30,6 +30,18 @@ module.exports = {
"react/jsx-key": ["error"],
"matrix-org/require-copyright-header": "error",
"react-compiler/react-compiler": "error",
"no-restricted-imports": [
"error",
{
paths: [
{
name: "react",
importNames: ["act"],
message: "Please use @test-utils instead.",
},
],
},
],
},
overrides: [
{

View File

@ -5,8 +5,8 @@
* Please see LICENSE files in the repository root for full details.
*/
import React, { act } from "react";
import { render, screen } from "@test-utils";
import React from "react";
import { act, render, screen } from "@test-utils";
import userEvent from "@testing-library/user-event";
import { composeStories } from "@storybook/react-vite";
import { describe, it, expect, vi, beforeEach } from "vitest";