diff --git a/apps/web/src/device-listener/DeviceListenerCurrentDevice.ts b/apps/web/src/device-listener/DeviceListenerCurrentDevice.ts index f6127c84d9..c66a7e9945 100644 --- a/apps/web/src/device-listener/DeviceListenerCurrentDevice.ts +++ b/apps/web/src/device-listener/DeviceListenerCurrentDevice.ts @@ -8,7 +8,7 @@ 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 { LogSpan, type BaseLogger, type Logger } from "matrix-js-sdk/src/logger"; import { type MatrixEvent, type MatrixClient, @@ -17,6 +17,7 @@ import { RoomStateEvent, ClientEvent, } from "matrix-js-sdk/src/matrix"; +import { secureRandomString } from "matrix-js-sdk/src/randomstring"; import { type DeviceListener, type DeviceState } from "."; import { @@ -40,6 +41,11 @@ export const BACKUP_DISABLED_ACCOUNT_DATA_KEY = "m.org.matrix.custom.backup_disa */ export const RECOVERY_ACCOUNT_DATA_KEY = "io.element.recovery"; +/** + * We remind the user to verify their device 2 days after they dismiss the toast. + */ +const DEVICE_VERIFICATION_NAG_INTERVAL = 2 * 24 * 60 * 60 * 1000; + /** * Handles all of DeviceListener's work that relates to the current device. */ @@ -124,6 +130,19 @@ export class DeviceListenerCurrentDevice { * them again until they refresh or restart the app. */ public dismissEncryptionSetup(): void { + // If the user dismissed the "verify this session" toast, then we will + // re-show it later if the device still isn't verified. + if (this.deviceState === "verify_this_session") { + setTimeout(() => { + if (this.deviceState === "verify_this_session") { + const logSpan = new LogSpan(this.logger, "nag_" + secureRandomString(4)); + logSpan.info("Re-showing device verification toast"); + this.dismissedThisDeviceToast = false; + this.setDeviceState("verify_this_session", logSpan); + } + }, DEVICE_VERIFICATION_NAG_INTERVAL); + } + this.dismissedThisDeviceToast = true; this.deviceListener.recheck(); } @@ -218,7 +237,7 @@ export class DeviceListenerCurrentDevice { } else { // Everything is OK - no need to show a toast logSpan.info("No toast needed"); - await this.setDeviceState("ok", logSpan); + this.setDeviceState("ok", logSpan); } } @@ -252,14 +271,14 @@ export class DeviceListenerCurrentDevice { logSpan.warn(fullMessage, ...logItems); } - await this.setDeviceState(newState, logSpan); + this.setDeviceState(newState, logSpan); } /** * 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 { + private setDeviceState(newState: DeviceState, logSpan: LogSpan): void { this.deviceState = newState; this.deviceListener.currentDeviceChangedEmitter.onStateChanged(newState); diff --git a/apps/web/test/unit-tests/DeviceListener-test.ts b/apps/web/test/unit-tests/DeviceListener-test.ts index 6ec2129472..a8ebd4760e 100644 --- a/apps/web/test/unit-tests/DeviceListener-test.ts +++ b/apps/web/test/unit-tests/DeviceListener-test.ts @@ -381,6 +381,50 @@ describe("DeviceListener", () => { expect(SetupEncryptionToast.hideToast).toHaveBeenCalled(); }); + it("re-shows toast after two days", async () => { + const instance = await createAndStart(); + expect(SetupEncryptionToast.showToast).toHaveBeenCalledTimes(1); + + jest.useFakeTimers({ advanceTimers: true }); + instance.dismissEncryptionSetup(); + await flushPromises(); + expect(SetupEncryptionToast.hideToast).toHaveBeenCalled(); + + // 1.5 days after the toast was dismissed, we don't re-show the + // toast yet. + jest.advanceTimersByTime(1.5 * 24 * 60 * 60 * 1000); + expect(SetupEncryptionToast.showToast).toHaveBeenCalledTimes(1); + + // 2 days after the toast was dismissed, we re-show the toast. + jest.advanceTimersByTime(0.5 * 24 * 60 * 60 * 1000); + expect(SetupEncryptionToast.showToast).toHaveBeenCalledTimes(2); + jest.useRealTimers(); + }); + + it("doesn't re-show toast if the device is now verified", async () => { + const instance = await createAndStart(); + expect(SetupEncryptionToast.showToast).toHaveBeenCalledTimes(1); + + jest.useFakeTimers({ advanceTimers: true }); + instance.dismissEncryptionSetup(); + await flushPromises(); + expect(SetupEncryptionToast.hideToast).toHaveBeenCalled(); + + // If the device becomes verified before the end of two days, we + // don't re-show the toast. + mockCrypto!.getDeviceVerificationStatus.mockResolvedValue( + new DeviceVerificationStatus({ + trustCrossSignedDevices: true, + crossSigningVerified: true, + }), + ); + instance.recheck(); + await flushPromises(); + jest.advanceTimersByTime(2 * 24 * 60 * 60 * 1000); + expect(SetupEncryptionToast.showToast).toHaveBeenCalledTimes(1); + jest.useRealTimers(); + }); + it("does not show any toasts when secret storage is being accessed", async () => { mocked(isSecretStorageBeingAccessed).mockReturnValue(true); await createAndStart();