From 563f6d338c7b7fd0aa31e7594962646d09789222 Mon Sep 17 00:00:00 2001 From: Andy Balaam Date: Mon, 9 Feb 2026 15:14:25 +0000 Subject: [PATCH] Extract the "other devices" logic from DeviceListener (#32419) * Extract the other devices logic from DeviceListener * Remove unneeded async modifier on dismissUnverifiedSessions --- src/DeviceListener.ts | 130 ++--------- .../DeviceListenerOtherDevices.ts | 203 ++++++++++++++++++ test/unit-tests/DeviceListener-test.ts | 2 +- 3 files changed, 218 insertions(+), 117 deletions(-) create mode 100644 src/device-listener/DeviceListenerOtherDevices.ts diff --git a/src/DeviceListener.ts b/src/DeviceListener.ts index e9c723b75d..1a04db3269 100644 --- a/src/DeviceListener.ts +++ b/src/DeviceListener.ts @@ -24,18 +24,10 @@ import { secureRandomString } from "matrix-js-sdk/src/randomstring"; import { PosthogAnalytics } from "./PosthogAnalytics"; import dis from "./dispatcher/dispatcher"; -import { - hideToast as hideBulkUnverifiedSessionsToast, - showToast as showBulkUnverifiedSessionsToast, -} from "./toasts/BulkUnverifiedSessionsToast"; import { hideToast as hideSetupEncryptionToast, showToast as showSetupEncryptionToast, } from "./toasts/SetupEncryptionToast"; -import { - hideToast as hideUnverifiedSessionsToast, - showToast as showUnverifiedSessionsToast, -} from "./toasts/UnverifiedSessionToast"; import { isSecretStorageBeingAccessed } from "./SecurityManager"; import { type ActionPayload } from "./dispatcher/payloads"; import { Action } from "./dispatcher/actions"; @@ -43,9 +35,8 @@ import SdkConfig from "./SdkConfig"; import PlatformPeg from "./PlatformPeg"; import { recordClientInformation, removeClientInformation } from "./utils/device/clientInformation"; import SettingsStore, { type CallbackFn } from "./settings/SettingsStore"; -import { isBulkUnverifiedDeviceReminderSnoozed } from "./utils/device/snoozeBulkUnverifiedDeviceReminder"; -import { getUserDeviceIds } from "./utils/crypto/deviceInfo"; import { asyncSomeParallel } from "./utils/arrays.ts"; +import DeviceListenerOtherDevices from "./device-listener/DeviceListenerOtherDevices.ts"; const KEY_BACKUP_POLL_INTERVAL = 5 * 60 * 1000; @@ -106,20 +97,16 @@ type EventHandlerMap = { export default class DeviceListener extends TypedEventEmitter { private dispatcherRef?: string; - // device IDs for which the user has dismissed the verify toast ('Later') - private dismissed = new Set(); + + /** All the information about whether other devices are verified. */ + 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; - // We keep a list of our own device IDs so we can batch ones that were already - // there the last time the app launched into a single toast, but display new - // ones in their own toasts. - private ourDeviceIdsAtStart: Set | null = null; - // The set of device IDs we're currently displaying toasts for - private displayingToastsForDeviceIds = new Set(); private running = false; // The client with which the instance is running. Only set if `running` is true, otherwise undefined. private client?: MatrixClient; @@ -138,8 +125,10 @@ export default class DeviceListener extends TypedEventEmitter): Promise { + public dismissUnverifiedSessions(deviceIds: Iterable): void { logger.debug("Dismissing unverified sessions: " + Array.from(deviceIds).join(",")); - for (const d of deviceIds) { - this.dismissed.add(d); - } - this.recheck(); + this.otherDevices?.dismissUnverifiedSessions(deviceIds); } public dismissEncryptionSetup(): void { @@ -295,35 +279,6 @@ export default class DeviceListener extends TypedEventEmitter { - if (this.ourDeviceIdsAtStart === null) { - this.ourDeviceIdsAtStart = await this.getDeviceIds(); - } - } - - /** Get the device list for the current user - * - * @returns the set of device IDs - */ - private async getDeviceIds(): Promise> { - const cli = this.client; - if (!cli) return new Set(); - return await getUserDeviceIds(cli, cli.getSafeUserId()); - } - - private onDevicesUpdated = async (users: string[], initialFetch?: boolean): Promise => { - if (!this.client) return; - // If we didn't know about *any* devices before (ie. it's fresh login), - // then they are all pre-existing devices, so ignore this and set the - // devicesAtStart list to the devices that we see after the fetch. - if (initialFetch) return; - - const myUserId = this.client.getSafeUserId(); - if (users.includes(myUserId)) await this.ensureDeviceIdsAtStartPopulated(); - - this.recheck(); - }; - private onUserTrustStatusChanged = (userId: string): void => { if (!this.client) return; if (userId !== this.client.getUserId()) return; @@ -419,7 +374,7 @@ export default class DeviceListener extends TypedEventEmitter cryptoApi.isEncryptionEnabledInRoom(roomId)); } - private recheck(): void { + public recheck(): void { this.doRecheck().catch((e) => { if (e instanceof ClientStoppedError) { // the client was stopped while recheck() was running. Nothing left to do. @@ -546,64 +501,7 @@ export default class DeviceListener extends TypedEventEmitter(); - // Unverified devices that have appeared since then - const newUnverifiedDeviceIds = new Set(); - - // as long as cross-signing isn't ready, - // you can't see or dismiss any device toasts - if (crossSigningReady) { - const devices = await this.getDeviceIds(); - for (const deviceId of devices) { - if (deviceId === cli.deviceId) continue; - - const deviceTrust = await crypto.getDeviceVerificationStatus(cli.getSafeUserId(), deviceId); - if (!deviceTrust?.crossSigningVerified && !this.dismissed.has(deviceId)) { - if (this.ourDeviceIdsAtStart?.has(deviceId)) { - oldUnverifiedDeviceIds.add(deviceId); - } else { - newUnverifiedDeviceIds.add(deviceId); - } - } - } - } - - logSpan.debug("Old unverified sessions: " + Array.from(oldUnverifiedDeviceIds).join(",")); - logSpan.debug("New unverified sessions: " + Array.from(newUnverifiedDeviceIds).join(",")); - logSpan.debug("Currently showing toasts for: " + Array.from(this.displayingToastsForDeviceIds).join(",")); - - const isBulkUnverifiedSessionsReminderSnoozed = isBulkUnverifiedDeviceReminderSnoozed(); - - // Display or hide the batch toast for old unverified sessions - // don't show the toast if the current device is unverified - if (oldUnverifiedDeviceIds.size > 0 && isCurrentDeviceTrusted && !isBulkUnverifiedSessionsReminderSnoozed) { - showBulkUnverifiedSessionsToast(oldUnverifiedDeviceIds); - } else { - hideBulkUnverifiedSessionsToast(); - } - - // Show toasts for new unverified devices if they aren't already there - for (const deviceId of newUnverifiedDeviceIds) { - showUnverifiedSessionsToast(deviceId); - } - - // ...and hide any we don't need any more - for (const deviceId of this.displayingToastsForDeviceIds) { - if (!newUnverifiedDeviceIds.has(deviceId)) { - logSpan.debug("Hiding unverified session toast for " + deviceId); - hideUnverifiedSessionsToast(deviceId); - } - } - - this.displayingToastsForDeviceIds = newUnverifiedDeviceIds; + await this.otherDevices?.recheck(logSpan); } /** diff --git a/src/device-listener/DeviceListenerOtherDevices.ts b/src/device-listener/DeviceListenerOtherDevices.ts new file mode 100644 index 0000000000..8cf839e8fd --- /dev/null +++ b/src/device-listener/DeviceListenerOtherDevices.ts @@ -0,0 +1,203 @@ +/* +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 } from "matrix-js-sdk/src/crypto-api"; +import { type LogSpan } from "matrix-js-sdk/src/logger"; +import { type MatrixClient } from "matrix-js-sdk/src/matrix"; + +import type DeviceListener from "../DeviceListener"; +import { getUserDeviceIds } from "../utils/crypto/deviceInfo"; +import { isBulkUnverifiedDeviceReminderSnoozed } from "../utils/device/snoozeBulkUnverifiedDeviceReminder"; +import { + hideToast as hideBulkUnverifiedSessionsToast, + showToast as showBulkUnverifiedSessionsToast, +} from "../toasts/BulkUnverifiedSessionsToast"; +import { + hideToast as hideUnverifiedSessionToast, + showToast as showUnverifiedSessionToast, +} from "../toasts/UnverifiedSessionToast"; + +export default class DeviceListenerOtherDevices { + /** + * The DeviceListener launching this instance. + */ + private deviceListener: DeviceListener; + + /** + * The Matrix client in use by the current user. + */ + private client: MatrixClient; + + /** + * Device IDs for which the user has dismissed the verify toast ('Later'). + */ + private dismissed = new Set(); + + /** + * A list of our own device IDs so we can batch ones that were already + * there the last time the app launched into a single toast, but display new + * ones in their own toasts. + */ + private ourDeviceIdsAtStart: Set | null = null; + + /** + * The set of device IDs we're currently displaying toasts for. + */ + private displayingToastsForDeviceIds = new Set(); + + /** + * Start tracking other devices and call `recheck()` on the supplied + * DeviceListener when something changes. + */ + public constructor(deviceListener: DeviceListener, client: MatrixClient) { + this.deviceListener = deviceListener; + this.client = client; + + this.client.on(CryptoEvent.DevicesUpdated, this.onDevicesUpdated); + } + + /** + * Stop tracking other devices and clear our stored information. + */ + public stop(): void { + this.dismissed.clear(); + this.ourDeviceIdsAtStart = null; + this.displayingToastsForDeviceIds = new Set(); + + this.client.removeListener(CryptoEvent.DevicesUpdated, this.onDevicesUpdated); + } + + /** + * Dismiss notifications about our own unverified devices. + * + * @param {String[]} deviceIds List of device IDs to dismiss notifications for + */ + public dismissUnverifiedSessions(deviceIds: Iterable): void { + for (const d of deviceIds) { + this.dismissed.add(d); + } + + // TODO: maybe we don't need a full DeviceListener check? (Maybe we only + // need to call this.recheck().) + this.deviceListener.recheck(); + } + + /** + * Get the device list for the current user. + * + * @returns the set of device IDs + */ + private async getDeviceIds(): Promise> { + return await getUserDeviceIds(this.client, this.client.getSafeUserId()); + } + + /** + */ + private async ensureDeviceIdsAtStartPopulated(): Promise { + if (this.ourDeviceIdsAtStart === null) { + this.ourDeviceIdsAtStart = await this.getDeviceIds(); + } + } + + /** + * Called when the user's devices are updated. Refreshes the device + * information and then rechecks whether we need to display any toasts. + */ + private onDevicesUpdated = async (users: string[], initialFetch?: boolean): Promise => { + // If we didn't know about *any* devices before (ie. it's fresh login), + // then they are all pre-existing devices, so ignore this and set the + // devicesAtStart list to the devices that we see after the fetch. + if (initialFetch) return; + + const myUserId = this.client.getSafeUserId(); + if (users.includes(myUserId)) await this.ensureDeviceIdsAtStartPopulated(); + + // TODO: maybe we don't need a full DeviceListener check? (Maybe we only + // need to call this.recheck().) + this.deviceListener.recheck(); + }; + + /** + * Display a toast if some new other device is unverified, or if we started + * up and some unverified devices have appeared. + */ + public async recheck(logSpan: LogSpan): Promise { + const crypto = this.client.getCrypto(); + if (!crypto) { + return; + } + + const userId = this.client.getSafeUserId(); + + const crossSigningReady = await crypto.isCrossSigningReady(); + + const isCurrentDeviceTrusted = Boolean( + (await crypto.getDeviceVerificationStatus(userId, this.client.deviceId!))?.crossSigningVerified, + ); + + // This needs to be done after awaiting on getUserDeviceInfo() above, so + // we make sure we get the devices after the fetch is done. + await this.ensureDeviceIdsAtStartPopulated(); + + // Unverified devices that were there last time the app ran + // (technically could just be a boolean: we don't actually + // need to remember the device IDs, but for the sake of + // symmetry...). + const oldUnverifiedDeviceIds = new Set(); + // Unverified devices that have appeared since then + const newUnverifiedDeviceIds = new Set(); + + // as long as cross-signing isn't ready, + // you can't see or dismiss any device toasts + if (crossSigningReady) { + const devices = await this.getDeviceIds(); + for (const deviceId of devices) { + if (deviceId === this.client.deviceId) continue; + + const deviceTrust = await crypto.getDeviceVerificationStatus(userId, deviceId); + if (!deviceTrust?.crossSigningVerified && !this.dismissed.has(deviceId)) { + if (this.ourDeviceIdsAtStart?.has(deviceId)) { + oldUnverifiedDeviceIds.add(deviceId); + } else { + newUnverifiedDeviceIds.add(deviceId); + } + } + } + } + + logSpan.debug("Old unverified sessions: " + Array.from(oldUnverifiedDeviceIds).join(",")); + logSpan.debug("New unverified sessions: " + Array.from(newUnverifiedDeviceIds).join(",")); + logSpan.debug("Currently showing toasts for: " + Array.from(this.displayingToastsForDeviceIds).join(",")); + + const isBulkUnverifiedSessionsReminderSnoozed = isBulkUnverifiedDeviceReminderSnoozed(); + + // Display or hide the batch toast for old unverified sessions + // don't show the toast if the current device is unverified + if (oldUnverifiedDeviceIds.size > 0 && isCurrentDeviceTrusted && !isBulkUnverifiedSessionsReminderSnoozed) { + showBulkUnverifiedSessionsToast(oldUnverifiedDeviceIds); + } else { + hideBulkUnverifiedSessionsToast(); + } + + // Show toasts for new unverified devices if they aren't already there + for (const deviceId of newUnverifiedDeviceIds) { + showUnverifiedSessionToast(deviceId); + } + + // ...and hide any we don't need any more + for (const deviceId of this.displayingToastsForDeviceIds) { + if (!newUnverifiedDeviceIds.has(deviceId)) { + logSpan.debug("Hiding unverified session toast for " + deviceId); + hideUnverifiedSessionToast(deviceId); + } + } + + this.displayingToastsForDeviceIds = newUnverifiedDeviceIds; + } +} diff --git a/test/unit-tests/DeviceListener-test.ts b/test/unit-tests/DeviceListener-test.ts index f9339cd38b..f2c3a3dc98 100644 --- a/test/unit-tests/DeviceListener-test.ts +++ b/test/unit-tests/DeviceListener-test.ts @@ -734,7 +734,7 @@ describe("DeviceListener", () => { new Set([device3.deviceId]), ); - await instance.dismissUnverifiedSessions([device3.deviceId]); + await instance.otherDevices?.dismissUnverifiedSessions([device3.deviceId]); await flushPromises(); expect(BulkUnverifiedSessionsToast.hideToast).toHaveBeenCalled();