Extract the "other devices" logic from DeviceListener (#32419)

* Extract the other devices logic from DeviceListener

* Remove unneeded async modifier on dismissUnverifiedSessions
This commit is contained in:
Andy Balaam
2026-02-09 15:14:25 +00:00
committed by GitHub
parent 562f7cc2bd
commit 563f6d338c
3 changed files with 218 additions and 117 deletions

View File

@@ -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<DeviceListenerEvents, EventHandlerMap> {
private dispatcherRef?: string;
// device IDs for which the user has dismissed the verify toast ('Later')
private dismissed = new Set<string>();
/** 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<string> | null = null;
// The set of device IDs we're currently displaying toasts for
private displayingToastsForDeviceIds = new Set<string>();
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<DeviceListenerEven
public start(matrixClient: MatrixClient): void {
this.running = true;
this.otherDevices = new DeviceListenerOtherDevices(this, matrixClient);
this.client = matrixClient;
this.client.on(CryptoEvent.DevicesUpdated, this.onDevicesUpdated);
this.client.on(CryptoEvent.UserTrustStatusChanged, this.onUserTrustStatusChanged);
this.client.on(CryptoEvent.KeysChanged, this.onCrossSingingKeysChanged);
this.client.on(CryptoEvent.KeyBackupStatus, this.onKeyBackupStatusChanged);
@@ -147,6 +136,7 @@ export default class DeviceListener extends TypedEventEmitter<DeviceListenerEven
this.client.on(ClientEvent.Sync, this.onSync);
this.client.on(RoomStateEvent.Events, this.onRoomStateEvents);
this.client.on(ClientEvent.ToDeviceEvent, this.onToDeviceEvent);
this.shouldRecordClientInformation = SettingsStore.getValue("deviceClientInformationOptIn");
// only configurable in config, so we don't need to watch the value
this.deviceClientInformationSettingWatcherRef = SettingsStore.watchSetting(
@@ -162,7 +152,6 @@ export default class DeviceListener extends TypedEventEmitter<DeviceListenerEven
public stop(): void {
this.running = false;
if (this.client) {
this.client.removeListener(CryptoEvent.DevicesUpdated, this.onDevicesUpdated);
this.client.removeListener(CryptoEvent.UserTrustStatusChanged, this.onUserTrustStatusChanged);
this.client.removeListener(CryptoEvent.KeysChanged, this.onCrossSingingKeysChanged);
this.client.removeListener(ClientEvent.AccountData, this.onAccountData);
@@ -173,13 +162,11 @@ export default class DeviceListener extends TypedEventEmitter<DeviceListenerEven
SettingsStore.unwatchSetting(this.deviceClientInformationSettingWatcherRef);
dis.unregister(this.dispatcherRef);
this.dispatcherRef = undefined;
this.dismissed.clear();
this.otherDevices?.stop();
this.dismissedThisDeviceToast = false;
this.keyBackupInfo = null;
this.keyBackupFetchedAt = null;
this.cachedKeyBackupUploadActive = undefined;
this.ourDeviceIdsAtStart = null;
this.displayingToastsForDeviceIds = new Set();
this.client = undefined;
}
@@ -207,13 +194,10 @@ export default class DeviceListener extends TypedEventEmitter<DeviceListenerEven
*
* @param {String[]} deviceIds List of device IDs to dismiss notifications for
*/
public async dismissUnverifiedSessions(deviceIds: Iterable<string>): Promise<void> {
public dismissUnverifiedSessions(deviceIds: Iterable<string>): 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<DeviceListenerEven
}
}
private async ensureDeviceIdsAtStartPopulated(): Promise<void> {
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<Set<string>> {
const cli = this.client;
if (!cli) return new Set();
return await getUserDeviceIds(cli, cli.getSafeUserId());
}
private onDevicesUpdated = async (users: string[], initialFetch?: boolean): Promise<void> => {
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<DeviceListenerEven
return await asyncSomeParallel(cli.getRooms(), ({ roomId }) => 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<DeviceListenerEven
}
}
// 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<string>();
// Unverified devices that have appeared since then
const newUnverifiedDeviceIds = new Set<string>();
// 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);
}
/**

View File

@@ -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<string>();
/**
* 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<string> | null = null;
/**
* The set of device IDs we're currently displaying toasts for.
*/
private displayingToastsForDeviceIds = new Set<string>();
/**
* 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<string>): 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<Set<string>> {
return await getUserDeviceIds(this.client, this.client.getSafeUserId());
}
/**
*/
private async ensureDeviceIdsAtStartPopulated(): Promise<void> {
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<void> => {
// 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<void> {
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<string>();
// Unverified devices that have appeared since then
const newUnverifiedDeviceIds = new Set<string>();
// 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;
}
}

View File

@@ -734,7 +734,7 @@ describe("DeviceListener", () => {
new Set<string>([device3.deviceId]),
);
await instance.dismissUnverifiedSessions([device3.deviceId]);
await instance.otherDevices?.dismissUnverifiedSessions([device3.deviceId]);
await flushPromises();
expect(BulkUnverifiedSessionsToast.hideToast).toHaveBeenCalled();