From 7d9086f7675b6e3e362f2b9aaca77f0ea1126f21 Mon Sep 17 00:00:00 2001 From: RiotRobot Date: Tue, 3 Feb 2026 13:17:00 +0000 Subject: [PATCH 01/13] Upgrade dependency to matrix-js-sdk@40.2.0-rc.0 --- package.json | 2 +- yarn.lock | 25 +++++++++---------------- 2 files changed, 10 insertions(+), 17 deletions(-) diff --git a/package.json b/package.json index 92cf154988..6c24f13616 100644 --- a/package.json +++ b/package.json @@ -129,7 +129,7 @@ "lodash": "^4.17.21", "maplibre-gl": "^5.0.0", "matrix-encrypt-attachment": "^1.0.3", - "matrix-js-sdk": "github:matrix-org/matrix-js-sdk#develop", + "matrix-js-sdk": "40.2.0-rc.0", "matrix-widget-api": "^1.16.1", "memoize-one": "^6.0.0", "mime": "^4.0.4", diff --git a/yarn.lock b/yarn.lock index ff26488876..f351d652be 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1690,17 +1690,8 @@ yaml "^2.7.0" "@element-hq/web-shared-components@link:packages/shared-components": - version "0.0.1" - dependencies: - "@element-hq/element-web-module-api" "^1.8.0" - "@vector-im/compound-design-tokens" "^6.4.3" - classnames "^2.5.1" - counterpart "^0.18.6" - lodash "^4.17.21" - matrix-web-i18n "3.6.0" - react-merge-refs "^3.0.2" - react-virtuoso "^4.14.0" - temporal-polyfill "^0.3.0" + version "0.0.0" + uid "" "@emnapi/core@^1.4.3": version "1.7.0" @@ -4417,15 +4408,16 @@ classnames "^2.5.1" vaul "^1.0.0" -"@vector-im/matrix-wysiwyg-wasm@link:../../../.cache/yarn/v6/npm-@vector-im-matrix-wysiwyg-2.40.0-53c9ca5ea907d91e4515da64f20a82e5586b882c-integrity/node_modules/bindings/wysiwyg-wasm": +"@vector-im/matrix-wysiwyg-wasm@link:../../Library/Caches/Yarn/v6/npm-@vector-im-matrix-wysiwyg-2.40.0-53c9ca5ea907d91e4515da64f20a82e5586b882c-integrity/node_modules/bindings/wysiwyg-wasm": version "0.0.0" + uid "" "@vector-im/matrix-wysiwyg@2.40.0": version "2.40.0" resolved "https://registry.yarnpkg.com/@vector-im/matrix-wysiwyg/-/matrix-wysiwyg-2.40.0.tgz#53c9ca5ea907d91e4515da64f20a82e5586b882c" integrity sha512-8LRFLs5PEKYs4lOL7aJ4lL/hGCrvEvOYkCR3JggXYXDVMtX4LmfdlKYucSAe98pCmqAAbLRvlRcR1bTOYvM8ug== dependencies: - "@vector-im/matrix-wysiwyg-wasm" "link:../../Library/Caches/Yarn/v6/npm-@vector-im-matrix-wysiwyg-2.40.0-53c9ca5ea907d91e4515da64f20a82e5586b882c-integrity/node_modules/bindings/wysiwyg-wasm" + "@vector-im/matrix-wysiwyg-wasm" "link:../../../.cache/yarn/v6/npm-@vector-im-matrix-wysiwyg-2.40.0-53c9ca5ea907d91e4515da64f20a82e5586b882c-integrity/node_modules/bindings/wysiwyg-wasm" "@webassemblyjs/ast@1.14.1", "@webassemblyjs/ast@^1.14.1": version "1.14.1" @@ -9682,9 +9674,10 @@ matrix-events-sdk@0.0.1: resolved "https://registry.yarnpkg.com/matrix-events-sdk/-/matrix-events-sdk-0.0.1.tgz#c8c38911e2cb29023b0bbac8d6f32e0de2c957dd" integrity sha512-1QEOsXO+bhyCroIe2/A5OwaxHvBm7EsSQ46DEDn8RBIfQwN5HWBpFvyWWR4QY0KHPPnnJdI99wgRiAl7Ad5qaA== -"matrix-js-sdk@github:matrix-org/matrix-js-sdk#develop": - version "40.1.0" - resolved "https://codeload.github.com/matrix-org/matrix-js-sdk/tar.gz/f136f6ddf7668b8a4c40dbfd4658f22afa1b668c" +matrix-js-sdk@40.2.0-rc.0: + version "40.2.0-rc.0" + resolved "https://registry.yarnpkg.com/matrix-js-sdk/-/matrix-js-sdk-40.2.0-rc.0.tgz#c9a5fddc5b87df081db60f845446952e5ad2a69e" + integrity sha512-0c3rm+poCraYmxmQ/9QnfRiZEikriarHZCt1ukQl+xKny2tYLEFcFkASdE/ce6QCMPIwMZLJOVyOw+LvS2Xqtw== dependencies: "@babel/runtime" "^7.12.5" "@matrix-org/matrix-sdk-crypto-wasm" "^17.0.0" From 6bc503604200f8b1400a6d6d5a3b00eed84eaa1c Mon Sep 17 00:00:00 2001 From: RiotRobot Date: Tue, 3 Feb 2026 13:25:07 +0000 Subject: [PATCH 02/13] v1.12.10-rc.0 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 6c24f13616..85a724821c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "element-web", - "version": "1.12.9", + "version": "1.12.10-rc.0", "description": "Element: the future of secure communication", "author": "New Vector Ltd.", "repository": { From 753e94f165519777a6dbe3ddfcc0b555b46d0212 Mon Sep 17 00:00:00 2001 From: Florian Duros Date: Tue, 10 Feb 2026 12:10:56 +0100 Subject: [PATCH 03/13] Use `act` from `@test-utils` in SC (#32432) * test: use `act` from `@test-utils` in SC * chore: add rules to enforce use of act from `@test-utils` --- packages/shared-components/.eslintrc.cjs | 12 ++++++++++++ .../RoomListPrimaryFilters.test.tsx | 4 ++-- 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/packages/shared-components/.eslintrc.cjs b/packages/shared-components/.eslintrc.cjs index 3cff936e9c..95fda95c64 100644 --- a/packages/shared-components/.eslintrc.cjs +++ b/packages/shared-components/.eslintrc.cjs @@ -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: [ { diff --git a/packages/shared-components/src/room-list/RoomListPrimaryFilters/RoomListPrimaryFilters.test.tsx b/packages/shared-components/src/room-list/RoomListPrimaryFilters/RoomListPrimaryFilters.test.tsx index a86181da15..333e8d6a3b 100644 --- a/packages/shared-components/src/room-list/RoomListPrimaryFilters/RoomListPrimaryFilters.test.tsx +++ b/packages/shared-components/src/room-list/RoomListPrimaryFilters/RoomListPrimaryFilters.test.tsx @@ -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"; From 09884c6bd17287389ab2c11e92a603aa57370e41 Mon Sep 17 00:00:00 2001 From: Andy Balaam Date: Tue, 10 Feb 2026 11:52:36 +0000 Subject: [PATCH 04/13] Extract the "this device" logic from DeviceListener (#32420) * Extract logic for this device from DeviceListener * Remove a listener that I assume we forgot in DeviceListener * Use a code block for JSON in a comment Co-authored-by: Skye Elliot * Rename to DeviceListenerCurrentDevice --------- Co-authored-by: Skye Elliot --- src/DeviceListener.ts | 382 ++-------------- .../encryption/KeyStoragePanelViewModel.ts | 3 +- .../settings/encryption/ChangeRecoveryKey.tsx | 3 +- .../tabs/user/EncryptionUserSettingsTab.tsx | 3 +- .../DeviceListenerCurrentDevice.ts | 420 ++++++++++++++++++ src/device-listener/DeviceState.ts | 39 ++ src/toasts/SetupEncryptionToast.tsx | 3 +- test/unit-tests/DeviceListener-test.ts | 3 +- 8 files changed, 497 insertions(+), 359 deletions(-) create mode 100644 src/device-listener/DeviceListenerCurrentDevice.ts create mode 100644 src/device-listener/DeviceState.ts diff --git a/src/DeviceListener.ts b/src/DeviceListener.ts index 1a04db3269..111808a976 100644 --- a/src/DeviceListener.ts +++ b/src/DeviceListener.ts @@ -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 { 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 { - 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 { - await this.client?.setAccountData(RECOVERY_ACCOUNT_DATA_KEY, { enabled: false }); + await this.currentDevice?.recordRecoveryDisabled(); } /** @@ -265,10 +193,11 @@ export default class DeviceListener extends TypedEventEmitter { 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 { - 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 { - 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 { - // 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 { - 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 { - 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 { - 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 => { - 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, diff --git a/src/components/viewmodels/settings/encryption/KeyStoragePanelViewModel.ts b/src/components/viewmodels/settings/encryption/KeyStoragePanelViewModel.ts index e0979d8dbb..7dd2419c62 100644 --- a/src/components/viewmodels/settings/encryption/KeyStoragePanelViewModel.ts +++ b/src/components/viewmodels/settings/encryption/KeyStoragePanelViewModel.ts @@ -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 { /** diff --git a/src/components/views/settings/encryption/ChangeRecoveryKey.tsx b/src/components/views/settings/encryption/ChangeRecoveryKey.tsx index 21ff7437d8..2694539f5a 100644 --- a/src/components/views/settings/encryption/ChangeRecoveryKey.tsx +++ b/src/components/views/settings/encryption/ChangeRecoveryKey.tsx @@ -30,8 +30,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. diff --git a/src/components/views/settings/tabs/user/EncryptionUserSettingsTab.tsx b/src/components/views/settings/tabs/user/EncryptionUserSettingsTab.tsx index ddc594a0b0..c0cc936f03 100644 --- a/src/components/views/settings/tabs/user/EncryptionUserSettingsTab.tsx +++ b/src/components/views/settings/tabs/user/EncryptionUserSettingsTab.tsx @@ -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. diff --git a/src/device-listener/DeviceListenerCurrentDevice.ts b/src/device-listener/DeviceListenerCurrentDevice.ts new file mode 100644 index 0000000000..4debb378f7 --- /dev/null +++ b/src/device-listener/DeviceListenerCurrentDevice.ts @@ -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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + // 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 => { + 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; + }; +} diff --git a/src/device-listener/DeviceState.ts b/src/device-listener/DeviceState.ts new file mode 100644 index 0000000000..569258d1d2 --- /dev/null +++ b/src/device-listener/DeviceState.ts @@ -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; diff --git a/src/toasts/SetupEncryptionToast.tsx b/src/toasts/SetupEncryptionToast.tsx index 93b97db365..c4b2b9d112 100644 --- a/src/toasts/SetupEncryptionToast.tsx +++ b/src/toasts/SetupEncryptionToast.tsx @@ -14,7 +14,7 @@ import { type Interaction as InteractionEvent } from "@matrix-org/analytics-even 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 +30,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"; diff --git a/test/unit-tests/DeviceListener-test.ts b/test/unit-tests/DeviceListener-test.ts index f2c3a3dc98..f5fa239520 100644 --- a/test/unit-tests/DeviceListener-test.ts +++ b/test/unit-tests/DeviceListener-test.ts @@ -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(), From a76a0a1dd127089e0aecfaef5e855ded02e47ce3 Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Tue, 10 Feb 2026 13:11:02 +0000 Subject: [PATCH 05/13] Add bug report link for Element flatpak app --- .github/ISSUE_TEMPLATE/config.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml index b34e449368..e234502bca 100644 --- a/.github/ISSUE_TEMPLATE/config.yml +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -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. From 81b111371fba05085d50493ceafd84639d59c9e7 Mon Sep 17 00:00:00 2001 From: David Baker Date: Tue, 10 Feb 2026 14:46:53 +0000 Subject: [PATCH 06/13] Fix room list not being cleared (#32436) * Fix room list not being cleared RoomListV3 was lacking an onNotReady which meant that the room list would sometimes not be cleared between logins. * Fix return type Co-authored-by: Florian Duros --------- Co-authored-by: Florian Duros --- src/stores/room-list-v3/RoomListStoreV3.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/stores/room-list-v3/RoomListStoreV3.ts b/src/stores/room-list-v3/RoomListStoreV3.ts index b643755a2a..9d6355788b 100644 --- a/src/stores/room-list-v3/RoomListStoreV3.ts +++ b/src/stores/room-list-v3/RoomListStoreV3.ts @@ -161,6 +161,10 @@ export class RoomListStoreV3Class extends AsyncStoreWithClient { this.emit(LISTS_UPDATE_EVENT); } + protected async onNotReady(): Promise { + this.roomSkipList = undefined; + } + protected async onAction(payload: ActionPayload): Promise { if (!this.matrixClient || !this.roomSkipList?.initialized) return; From 0575ca95984988a8e5ee60dc1830e6b4e3886ff0 Mon Sep 17 00:00:00 2001 From: RiotRobot Date: Tue, 10 Feb 2026 15:18:37 +0000 Subject: [PATCH 07/13] Upgrade dependency to matrix-js-sdk@40.2.0 --- package.json | 2 +- yarn.lock | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/package.json b/package.json index 85a724821c..41ea358899 100644 --- a/package.json +++ b/package.json @@ -129,7 +129,7 @@ "lodash": "^4.17.21", "maplibre-gl": "^5.0.0", "matrix-encrypt-attachment": "^1.0.3", - "matrix-js-sdk": "40.2.0-rc.0", + "matrix-js-sdk": "40.2.0", "matrix-widget-api": "^1.16.1", "memoize-one": "^6.0.0", "mime": "^4.0.4", diff --git a/yarn.lock b/yarn.lock index f351d652be..a15dc358e4 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4408,7 +4408,7 @@ classnames "^2.5.1" vaul "^1.0.0" -"@vector-im/matrix-wysiwyg-wasm@link:../../Library/Caches/Yarn/v6/npm-@vector-im-matrix-wysiwyg-2.40.0-53c9ca5ea907d91e4515da64f20a82e5586b882c-integrity/node_modules/bindings/wysiwyg-wasm": +"@vector-im/matrix-wysiwyg-wasm@link:../../../.cache/yarn/v6/npm-@vector-im-matrix-wysiwyg-2.40.0-53c9ca5ea907d91e4515da64f20a82e5586b882c-integrity/node_modules/bindings/wysiwyg-wasm": version "0.0.0" uid "" @@ -9674,10 +9674,10 @@ matrix-events-sdk@0.0.1: resolved "https://registry.yarnpkg.com/matrix-events-sdk/-/matrix-events-sdk-0.0.1.tgz#c8c38911e2cb29023b0bbac8d6f32e0de2c957dd" integrity sha512-1QEOsXO+bhyCroIe2/A5OwaxHvBm7EsSQ46DEDn8RBIfQwN5HWBpFvyWWR4QY0KHPPnnJdI99wgRiAl7Ad5qaA== -matrix-js-sdk@40.2.0-rc.0: - version "40.2.0-rc.0" - resolved "https://registry.yarnpkg.com/matrix-js-sdk/-/matrix-js-sdk-40.2.0-rc.0.tgz#c9a5fddc5b87df081db60f845446952e5ad2a69e" - integrity sha512-0c3rm+poCraYmxmQ/9QnfRiZEikriarHZCt1ukQl+xKny2tYLEFcFkASdE/ce6QCMPIwMZLJOVyOw+LvS2Xqtw== +matrix-js-sdk@40.2.0: + version "40.2.0" + resolved "https://registry.yarnpkg.com/matrix-js-sdk/-/matrix-js-sdk-40.2.0.tgz#223988416e75386a7ed4b85fa74a4ef8da28d5c8" + integrity sha512-wqb1Oq34WB9r0njxw8XiNsm8DIvYeGfCn3wrVrDwj8HMoTI0TvLSY1sQ+x6J2Eg27abfVwInxLKyxLp+dROFXQ== dependencies: "@babel/runtime" "^7.12.5" "@matrix-org/matrix-sdk-crypto-wasm" "^17.0.0" From f6352afc6ebf8258c5400d98ddf7412d0b8a4919 Mon Sep 17 00:00:00 2001 From: Skye Elliot Date: Tue, 10 Feb 2026 15:26:57 +0000 Subject: [PATCH 08/13] Update `globalBlacklistUnverifiedDevices` on setting change (#31983) * fix: Update `globalBlacklistUnverifiedDevices` on setting change Signed-off-by: Skye Elliot * fix: Use `SettingLevel.DEVICE` filter on blacklisted device watcher * tests: Add playwright test for blacklist unverified devices toggle * docs: Correct test step description Co-authored-by: Andy Balaam * tests: Add test for local vs global blacklist unverified devices * tests: Ensure local toggle overrides global toggle. * tests: Add unit tests for blacklistUnverifiedDevices listener --------- Signed-off-by: Skye Elliot Co-authored-by: Andy Balaam --- playwright/e2e/crypto/utils.ts | 47 +++- .../encryption-user-tab/other-devices.spec.ts | 253 ++++++++++++++++++ src/components/structures/MatrixChat.tsx | 16 +- .../components/structures/MatrixChat-test.tsx | 35 +++ 4 files changed, 348 insertions(+), 3 deletions(-) create mode 100644 playwright/e2e/settings/encryption-user-tab/other-devices.spec.ts diff --git a/playwright/e2e/crypto/utils.ts b/playwright/e2e/crypto/utils.ts index 4aab27f51a..792dcedd63 100644 --- a/playwright/e2e/crypto/utils.ts +++ b/playwright/e2e/crypto/utils.ts @@ -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 * diff --git a/playwright/e2e/settings/encryption-user-tab/other-devices.spec.ts b/playwright/e2e/settings/encryption-user-tab/other-devices.spec.ts new file mode 100644 index 0000000000..6c20af2d9a --- /dev/null +++ b/playwright/e2e/settings/encryption-user-tab/other-devices.spec.ts @@ -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(); + }); +}); diff --git a/src/components/structures/MatrixChat.tsx b/src/components/structures/MatrixChat.tsx index 8a9ffdd115..4c76bc8e99 100644 --- a/src/components/structures/MatrixChat.tsx +++ b/src/components/structures/MatrixChat.tsx @@ -1793,8 +1793,20 @@ export default class MatrixChat extends React.PureComponent { 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 diff --git a/test/unit-tests/components/structures/MatrixChat-test.tsx b/test/unit-tests/components/structures/MatrixChat-test.tsx index 24b190c0f8..8bffcda5d6 100644 --- a/test/unit-tests/components/structures/MatrixChat-test.tsx +++ b/test/unit-tests/components/structures/MatrixChat-test.tsx @@ -1839,4 +1839,39 @@ describe("", () => { ); }); }); + + 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); + }); }); From 8a210624769782c87d3c3532ab86c3fc75437222 Mon Sep 17 00:00:00 2001 From: ElementRobot Date: Tue, 10 Feb 2026 16:48:00 +0100 Subject: [PATCH 09/13] Fix room list not being cleared (#32436) (#32438) * Fix room list not being cleared RoomListV3 was lacking an onNotReady which meant that the room list would sometimes not be cleared between logins. * Fix return type --------- (cherry picked from commit 81b111371fba05085d50493ceafd84639d59c9e7) Co-authored-by: David Baker Co-authored-by: Florian Duros --- src/stores/room-list-v3/RoomListStoreV3.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/stores/room-list-v3/RoomListStoreV3.ts b/src/stores/room-list-v3/RoomListStoreV3.ts index b643755a2a..9d6355788b 100644 --- a/src/stores/room-list-v3/RoomListStoreV3.ts +++ b/src/stores/room-list-v3/RoomListStoreV3.ts @@ -161,6 +161,10 @@ export class RoomListStoreV3Class extends AsyncStoreWithClient { this.emit(LISTS_UPDATE_EVENT); } + protected async onNotReady(): Promise { + this.roomSkipList = undefined; + } + protected async onAction(payload: ActionPayload): Promise { if (!this.matrixClient || !this.roomSkipList?.initialized) return; From d7f94f89dcace2727aaf76ef628e58afc75ba2cb Mon Sep 17 00:00:00 2001 From: RiotRobot Date: Tue, 10 Feb 2026 16:01:35 +0000 Subject: [PATCH 10/13] v1.12.10 --- CHANGELOG.md | 33 +++++++++++++++++++++++++++++++++ package.json | 2 +- 2 files changed, 34 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4128a2b75f..8684747cb7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/package.json b/package.json index 41ea358899..9993882ed0 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "element-web", - "version": "1.12.10-rc.0", + "version": "1.12.10", "description": "Element: the future of secure communication", "author": "New Vector Ltd.", "repository": { From 6613c3f87a0b32111c101d7c6ce0ca7f9f80d37f Mon Sep 17 00:00:00 2001 From: RiotRobot Date: Tue, 10 Feb 2026 16:05:48 +0000 Subject: [PATCH 11/13] Reset matrix-js-sdk back to develop branch --- package.json | 2 +- yarn.lock | 21 +++++---------------- 2 files changed, 6 insertions(+), 17 deletions(-) diff --git a/package.json b/package.json index 789cfcaa52..51b857c675 100644 --- a/package.json +++ b/package.json @@ -130,7 +130,7 @@ "lodash": "npm:lodash-es@^4.17.21", "maplibre-gl": "^5.0.0", "matrix-encrypt-attachment": "^1.0.3", - "matrix-js-sdk": "40.2.0", + "matrix-js-sdk": "github:matrix-org/matrix-js-sdk#develop", "matrix-widget-api": "^1.16.1", "memoize-one": "^6.0.0", "mime": "^4.0.4", diff --git a/yarn.lock b/yarn.lock index 505ac4e72e..2a1e16457d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1727,18 +1727,8 @@ yaml "^2.7.0" "@element-hq/web-shared-components@link:packages/shared-components": - version "0.0.1" - dependencies: - "@element-hq/element-web-module-api" "^1.8.0" - "@matrix-org/spec" "^1.7.0" - "@vector-im/compound-design-tokens" "^6.4.3" - classnames "^2.5.1" - counterpart "^0.18.6" - lodash "^4.17.21" - matrix-web-i18n "3.6.0" - react-merge-refs "^3.0.2" - react-virtuoso "^4.14.0" - temporal-polyfill "^0.3.0" + version "0.0.0" + uid "" "@emnapi/core@^1.4.3": version "1.7.0" @@ -4508,7 +4498,7 @@ classnames "^2.5.1" vaul "^1.0.0" -"@vector-im/matrix-wysiwyg-wasm@link:../../Library/Caches/Yarn/v6/npm-@vector-im-matrix-wysiwyg-2.40.0-53c9ca5ea907d91e4515da64f20a82e5586b882c-integrity/node_modules/bindings/wysiwyg-wasm": +"@vector-im/matrix-wysiwyg-wasm@link:../../../.cache/yarn/v6/npm-@vector-im-matrix-wysiwyg-2.40.0-53c9ca5ea907d91e4515da64f20a82e5586b882c-integrity/node_modules/bindings/wysiwyg-wasm": version "0.0.0" uid "" @@ -9776,10 +9766,9 @@ matrix-events-sdk@0.0.1: resolved "https://registry.yarnpkg.com/matrix-events-sdk/-/matrix-events-sdk-0.0.1.tgz#c8c38911e2cb29023b0bbac8d6f32e0de2c957dd" integrity sha512-1QEOsXO+bhyCroIe2/A5OwaxHvBm7EsSQ46DEDn8RBIfQwN5HWBpFvyWWR4QY0KHPPnnJdI99wgRiAl7Ad5qaA== -matrix-js-sdk@40.2.0: +"matrix-js-sdk@github:matrix-org/matrix-js-sdk#develop": version "40.2.0" - resolved "https://registry.yarnpkg.com/matrix-js-sdk/-/matrix-js-sdk-40.2.0.tgz#223988416e75386a7ed4b85fa74a4ef8da28d5c8" - integrity sha512-wqb1Oq34WB9r0njxw8XiNsm8DIvYeGfCn3wrVrDwj8HMoTI0TvLSY1sQ+x6J2Eg27abfVwInxLKyxLp+dROFXQ== + resolved "https://codeload.github.com/matrix-org/matrix-js-sdk/tar.gz/87e1049dae1875e4c9879a0d0fee0fb15effdc38" dependencies: "@babel/runtime" "^7.12.5" "@matrix-org/matrix-sdk-crypto-wasm" "^17.0.0" From 2540c8a8af3cef5e39fc83230522fe89c96175dc Mon Sep 17 00:00:00 2001 From: Richard van der Hoff <1389908+richvdh@users.noreply.github.com> Date: Tue, 10 Feb 2026 17:42:14 +0100 Subject: [PATCH 12/13] Add logging around key-storage-out-of-sync handling (#31985) ... because unpicking this was a nightmare --- .../settings/encryption/ChangeRecoveryKey.tsx | 21 ++++++++---- .../encryption/RecoveryPanelOutOfSync.tsx | 4 +++ src/toasts/SetupEncryptionToast.tsx | 33 +++++++++++++------ 3 files changed, 42 insertions(+), 16 deletions(-) diff --git a/src/components/views/settings/encryption/ChangeRecoveryKey.tsx b/src/components/views/settings/encryption/ChangeRecoveryKey.tsx index 2694539f5a..7f0f61cbfe 100644 --- a/src/components/views/settings/encryption/ChangeRecoveryKey.tsx +++ b/src/components/views/settings/encryption/ChangeRecoveryKey.tsx @@ -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"; @@ -79,6 +80,11 @@ export function ChangeRecoveryKey({ // "recovery" is about. Otherwise, we jump straight to showing the user the new key. const [state, setState] = useState(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 @@ -91,7 +97,7 @@ export function ChangeRecoveryKey({ content = ( setState("save_key_setup_flow")} - onCancelClick={onCancelClick} + onCancelClick={onCancelClickWrapper} /> ); break; @@ -109,7 +115,7 @@ export function ChangeRecoveryKey({ : "confirm_key_setup_flow", ) } - onCancelClick={onCancelClick} + onCancelClick={onCancelClickWrapper} /> ); break; @@ -120,7 +126,7 @@ export function ChangeRecoveryKey({ { const crypto = matrixClient.getCrypto(); if (!crypto) return onFinish(); @@ -133,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 @@ -178,9 +187,9 @@ export function ChangeRecoveryKey({ <> { * @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, @@ -162,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, @@ -171,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, @@ -215,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, @@ -237,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(); @@ -247,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; @@ -273,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(); @@ -280,6 +288,7 @@ export const showToast = (state: DeviceStateForToast): void => { break; } default: + myLogger.debug("Secondary button clicked: dismissing"); DeviceListener.sharedInstance().dismissEncryptionSetup(); } }; @@ -293,20 +302,24 @@ export const showToast = (state: DeviceStateForToast): void => { */ const onAccessSecretStorageFailed = async (error: Error): Promise => { 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); } From 9360f0e5e2e8a2abf65ed868b7515f6ae6fd0d87 Mon Sep 17 00:00:00 2001 From: Florian Duros Date: Tue, 10 Feb 2026 18:36:54 +0100 Subject: [PATCH 13/13] Update MVVM examples and guidelines (#32437) * doc: update MVVM examples and guidelines * doc: typo `costlyDescriptionLoading` Co-authored-by: R Midhun Suresh * doc: typo `costlyDescriptionLoading` Co-authored-by: R Midhun Suresh --------- Co-authored-by: R Midhun Suresh --- docs/MVVM.md | 137 +++++++++++++++++++++++++++++++++------------------ 1 file changed, 88 insertions(+), 49 deletions(-) diff --git a/docs/MVVM.md b/docs/MVVM.md index 756a974391..d6f182bd2e 100644 --- a/docs/MVVM.md +++ b/docs/MVVM.md @@ -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 & 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 ( - - ); - } - ``` - +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 & 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 ( +
+

{title}

+ {/* Bind setTitle action */} + +

{description}

+ {/* Bind reloadDescription action */} + +
+ ); +} +``` #### 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 implements FooViewModelInterface { + public constructor(props: Props) { + // Call super with initial snapshot + super(props, { title: props.title, description: costlyDescriptionLoading() }); } - class FooViewModel extends BaseViewModel 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